ZuloOne
English
Sign in
Menu

Record characteristics: `IRecordCharacteristics`

Updated

Typed characteristics of dictionary records («Grade: White · Weight: 1 kg»). The platform owns the seam, the endpoints and the native Characteristics tab of a record card. The business layer owns the data and the rules: the Common model's CharacteristicsEngine serves every dictionary that has a field named CharacteristicTemplate.

← Developer wiki

The split#

Part Where Does
IRecordCharacteristics + DTOs ZuloOne.Runtime.Services the contract: SupportsAsync, SheetAsync, SaveAsync, CopyAsync
RecordCharacteristicsService ZuloOne.Core.Services the first model service whose SupportsAsync is true; a provider that cannot be built is logged and skipped
endpoints DataController see below
CharacteristicsTab frontend/src/windows first tab of the record's collections pane when supported
CharacteristicsEngine workspace Common/Services storage, template resolution, validation, display, SKU keys

It is the same pattern as computed list columns: a model service implements a platform interface, and the platform finds it with ServiceRegistry.ResolveAllImplementingAsync. Inject platform services only; resolve other model services lazily with ScriptServices.Get<T>().

Convention fields#

The engine needs no registration. It reads the dictionary's metadata:

Field Effect
CharacteristicTemplate (reference to CharacteristicTemplate) the dictionary is supported: the card gets the tab. A record's own template.
MemberCharacteristicTemplate on a referenced dictionary that dictionary is a template source (ItemGroup for items): any reference field pointing at it is walked by (displayOrder, field name), and up ParentId for a hierarchical target. The source itself gets no tab.
BaseName SKU mode: auto-name, duplicate guard
Name written by auto-name

To give customers characteristics, add CharacteristicTemplate to Customer (in its own model or as an extension field). Nothing else is needed for the tab. For the values to go with a deleted record, call ICharacteristicsEngine.ForgetAsync("Customer", id) from the dictionary's OnAfterDelete, as ItemEventHandler does.

Line mode#

A table part is supported when one of its reference fields points at a characterised dictionary (an order line → Item). objectName is then the table part type name (SalesOrderLines), the id is the line's MetaId, and the sheet:

  • takes the template of the referenced record (the line's item), with templateSource «Item: Sugar, White, 1 kg»;
  • lists only the template's LineParameter lines and edits them (isLine: true in the sheet);
  • has no SKU rules: no auto-name, no duplicate guard, canCopy: false.

Values are stored like any other, with ObjectName = "SalesOrderLines". The document line grid (TablePartRowsGrid) shows the Line parameters action on saved lines when GET /api/data/{tablePartType}/characteristics/supported is true. Saving needs no permission matrix: a table part is not a dictionary.

Carrying line parameters down a document chain#

A new document's rows get their ids from the save. A row created with MetaId = Guid.NewGuid() is refused («a row id that is not already stored cannot be written»), so the id cannot be fixed up front. The services keep the new row objects, read their ids after SaveDocumentAsync, and fall back to pairing the stored lines by item and quantity, in order, when the save did not write the ids back.

Language#

The sheet, the preview, the list column and the editor speak the request language (X-ZuloOne-Lang, ILanguageContext.CurrentAsync):

  • names come translated by the platform, because the name fields are isTranslatable;
  • the engine's own words («Yes», «from», the number format) follow language, or the request language when the caller passes none;
  • a field caption in templateSource comes from MetaTranslations.

ComposeNameAsync pins the default language (ILanguageContext.UseLanguage), so the base column holds base-language names. A section typed in the editor is matched by its translated or base name and created in the base language when new.

Template inheritance#

CharacteristicTemplate.BaseTemplate chains templates. The effective lines of a template are its root's lines first, each derived template's own lines after them. A derived line for a characteristic already asked replaces that line in place. The chain is walked at most 16 deep; a cycle is refused when the template is saved.

Storage#

LT_CharacteristicValue(ObjectName, RecordId, Characteristic, ValueString, ValueNumber, ValueNumberTo, ValueBoolean, ValueDate, ValueRef). It holds one row per value, and one row per chosen option of a MultiOption. The link table has no primary-key side, so no card shows it raw. Write only through the engine: it validates by type.

Template lines are LT_CharacteristicTemplateLine(Template PK, Characteristic, SortOrder, Section, IsKey, IsRequired, Role). Role is a plain integer: 0 = Property, 1 = LineParameter.

Endpoints#

Endpoint Permission Returns
GET /api/data/{table}/characteristics/supported Read { supported }
GET /api/data/{table}/{id}/characteristics Read the sheet: templateId, templateName, templateSource, lines[], display, missing[], canCopy
PUT /api/data/{table}/{id}/characteristics Update { ok, errors: { characteristicId: message }, message, sheet }
POST /api/data/{table}/{id}/characteristics/copy Create { id } of the copy

A refused save is 200 with ok: false: per-characteristic errors, or a whole-save message (a duplicate SKU). A BusinessRuleException thrown by the provider is a 400 with its message. The body's values arrive as JSON. The controller hands the provider plain values: a string, a decimal, a bool, or null.

Example save:

PUT /api/data/Item/2ecbdf03-…/characteristics
{ "values": [
  { "characteristicId": "ae93…grade", "value": "5f1c…white" },
  { "characteristicId": "77d0…weight", "value": 1 },
  { "characteristicId": "c2b9…storage-temperature", "value": 5, "valueTo": 25 },
  { "characteristicId": "0a4e…old", "remove": true } ] }
{ "ok": true, "errors": {}, "message": null,
  "sheet": { "templateName": "Grocery", "templateSource": "Item group: Grocery",
    "display": "Grade: White · Weight: 1 kg · … · Storage temperature: 5–25 °C",
    "missing": [], "canCopy": true, "lines": [ … ] } }

The record's Name is now «Sugar, White, 1 kg». The tab invalidates the card's ['record', table, id] query so the name field shows it.

The engine from a script#

GetService<ICharacteristicsEngine>():

Method Use
SheetAsync(objectName, id) the sheet, as the tab sees it
SaveAsync(objectName, id, values) the same validation as the tab
TemplateOfAsync(objectName, id) the resolved template id
DisplayAsync(objectName, id, language) «Grade: White · Weight: 1 kg» for a print form or a feed
ComposeNameAsync(objectName, id) rebuild the SKU name, for example after the base name changed
CopyAsync(objectName, id) «Copy with other values»
ForgetAsync(objectName, id) drop a deleted record's values
CopyValuesAsync(fromObject, fromId, toObject, toId) copy every value of one record onto another: QuotationService (quotation line → order line) and SalesFulfillmentService.InvoiceOrderAsync (order line → invoice line) call it after the save
PurgeOrphansAsync() delete values whose record is gone, or whose characteristic is; the nightly job PurgeCharacteristicValues (Common) runs it
TemplateEditorAsync(template) / SaveTemplateLinesAsync(template, lines) the template card's editor (an HTML widget): inherited lines, own lines, the characteristics to pick; save replaces the own lines in the given order
ValidateBaseTemplateAsync(template, base) refuses a circular Based on; CharacteristicTemplateEventHandler calls it on save
ColumnAsync(objectName, characteristicId, ids, language) one characteristic for a page of records: numbers for Integer / Decimal, display text otherwise. ItemListColumns serves it as the list column «Characteristic» (parameter: the Characteristic dictionary). Another dictionary gets the same column with a few lines in its own IListColumnProvider.

Traps#

  • Cyrillic in a SQL literal. A Cyrillic WHERE [BaseName] = '…' literal reaches SQL Server without the N prefix as '?????' and matches nothing. The duplicate guard passed its first test with a Latin base name and let every Cyrillic duplicate through on the stand. The engine queries with a parameter; ItemCharacteristicsTest uses a Cyrillic base name on purpose.
  • Auto-name and the item's own save. ItemEventHandler.OnAfterSave calls ComposeNameAsync. That method writes Name only when it differs, so the update it makes ends the loop on the next pass. With no key characteristics in the template it never touches the name. The card's OK and Save write dirty characteristic drafts first, then the record, so the name is composed from the values just entered. The tab's own Save still stores only the characteristics.
  • Mobile pull and SheetAsync. One call per SKU with no budget used to spend minutes on a large catalogue, miss the proxy's 100 s, and apply nothing. The pull time-boxes new sheets (15 s) and continues on the next sync. See Mobile sync pull.

Tests#

  • Inventory/Tests/ItemCharacteristicsTest:
    • template resolution: own, then the item group up the tree;
    • typed validation and the display string;
    • auto-name, the duplicate guard, copy, rename on the base name, and values dropped on delete;
    • values outside the template.
  • frontend/src/windows/CharacteristicsTab.test.ts: the draft comparison and the section grouping.

Same topic for other readers: Analyst · User

Where next#

Was this page helpful?