References in module settings
Updated
On a settings card, the reference is selected from a dictionary. The code string remains the column that the calculation already reads, and it is stamped on save if the reference is filled. An empty reference does not erase the string: on UPDATE the dictionary hydrates only the touched fields, and an empty Guid means “the field did not arrive”.
On load, if the reference is empty but the code is written, the reference is substituted. This is how the old row UAH opens with the currency card.
| Singleton | On the form | Stamp | Who reads the stamp |
|---|---|---|---|
CommonSettings |
DefaultCurrency |
DefaultCurrencyCode |
TenantSetup.StatusAsync |
AccountingSettings |
DefaultCurrency |
DefaultCurrencyCode |
accounting card |
OrganizationSettings |
DefaultLegalEntity |
DefaultLegalEntityCode = LegalEntity.ID |
new record with a legal-entity field |
ProductionSettings |
DefaultOutputLocation |
DefaultOutputLocationCode = StoreCell.ID |
new production order card: an empty output cell copies the reference (code is the fallback key if the reference is empty) |
TaxSettings |
DefaultTax |
DefaultTaxCode |
ITaxService.ResolveDefaultTaxCodeAsync |
PurchasingSettings, SalesSettings |
DefaultPaymentTerm |
DefaultPaymentTermDays |
payment due date for an order and invoice while no own term is selected |
LocalizationUkraineSettings |
ExemptVat, SingleTax*, IncomeTax, MilitaryLevy |
paired *Code |
VAT (ПДВ), single tax (ЄП), personal income tax (ПДФО), military levy (ВЗ) |
The costing method is not a dictionary. The calculation knows two values; a third word in the string would be read as FIFO. On the form this is CostingSettings.ValuationMethod (CostingMethodKind: 0 not set, 1 FIFO, 2 average). Saving writes CostingMethod = FIFO or AVG. Zero does not erase the string. The CostingValuation driver still compares the string with AVG; empty and FIFO mean batches by age.
Accounting accounts use the same technique, separately: account profile.
Codes that have no dictionary row remain text: tax authority form number (TaxInvoiceFormCode, J1201010), declaration type, income code for 4DF, VAT number, ISO2 on TaxAuthority.CountryCode and TaxJurisdiction.CountryCode (two letters go into the submission file).
A new card opens already with these values. Record creation calls the value service: empty currency, legal entity, output cell, and payment due date are taken from the singleton of their own module. Purchasing reads the term from purchasing settings; sale, customer, and contract read it from sales settings. The customer’s or contract’s term overrides the module term on save if the user did not change it manually.
Start dates (EffectiveFrom, DateFrom, HireDate, CountDate,
DeliveryDate, TaxPointDate, PeriodFrom) on a new record are today. Period end, actual date, arrival and departure marks, and fiscal-year start remain empty. A timesheet sets the first and last day of the month itself.
The module may be absent#
Settings are read by table name across six models, and each tenant has its own model composition. No module means no settings table for it, and a query to it fails (42P01 on Postgres, “Invalid object name” on SQL Server).
Therefore RecordDefaults first asks metadata (IMetadataService.GetAllDictionariesAsync()) and only then reads the table. If the object is absent from metadata, the tenant does not have the module, and that is a normal answer: there will simply be no default, and the caller already treats an empty reference as “nothing to substitute”. An object that IS present in metadata must be readable: if there is no table for it, the stand is broken and must fail loudly.
Why this matters more than it sounds: one service reads all of them on OnBeforeCreate, so an exception from one foreign module would drop POST /api/data/<X>/new entirely — the card would not open for ANY dictionary. A stand without Production is the usual way to hit it.
try/catch does not work here, and this is not a style question. In Postgres, a failed statement dooms the WHOLE transaction: every next command in it returns 25P02: current transaction is aborted until the block ends. Catching does not reset the transaction — it only moves the cause away from the failure point. Swallow the missing-table error and the clear “no ProductionSettings table” becomes 25P02 on an order subtype change three calls later, where people investigate postings. The same mechanism is recorded in Sales/Tests/SalesOutputTaxTest.cs.
One row is an engine rule, not a drawing instruction#
isSingleton decides what the UI opens: a record card instead of a list. That
is not enough: nothing in the UI stops a script, a test, or a POST /api/data/<X> from inserting a second row, and the dictionary has no database
constraint behind the flag.
ITaxService.ResolveDefaultTaxCodeAsync reads with FirstOrDefault() under a
comment asserting “there is exactly one row”. A second row would make an
arbitrary row the stand-wide default tax. Every document with no tax contour of
its own would silently pick that code up — including models that never read tax
settings, and including tests that assert zero tax because no contour is
configured.
The engine therefore refuses the second row itself: a save that would create one is cancelled before any model handler runs. Two consequences for business-layer code:
- Do not re-implement the check in a handler. A duplicate guard in
<X>EventHandler.OnBeforeSaveAsyncis unreachable —next(...)cancels first — and will quietly rot. - A reader still needs a deterministic order. Stands provisioned before the
engine check already hold extra rows, and an upgrade does not collapse them.
ResolveDefaultTaxCodeAsyncorders byCreatedDateTime, thenMetaId, so the oldest row wins — the one the settings form opens. Without theORDER BY, whichever row the database returned first would win, which is to say a random one.
Count rows with SQL, not with the dictionary manager, when the answer has to be
authoritative: these dictionaries are isCached, and the cache survives a test
case’s rollback. A read-through can honestly return zero where the table holds a
row — which is one way the extra rows got written in the first place.
Tests: CommonSettingsCurrencyTest, TaxSettingsRefTest,
TaxSettingsSingletonTest, ProductionOutputCellTest.