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.
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
LineParameterlines and edits them (isLine: truein 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
templateSourcecomes fromMetaTranslations.
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 theNprefix 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;ItemCharacteristicsTestuses a Cyrillic base name on purpose. - Auto-name and the item's own save.
ItemEventHandler.OnAfterSavecallsComposeNameAsync. That method writesNameonly 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#
- Item characteristics for analysts
- Fill item characteristics
- Computed list columns: the same seam pattern
- Mobile sync pull