Перейти до основного вмісту

Independent/periodic info register

No live consumer yet

Unlike Inventory and ExchangeRates, nothing in Kandra's reference configuration uses a PeriodicInfoRegister<>. It is fully implemented and DI-wired — the engine emits its registration the moment such a record type exists — but it has not been proven under a real workload. The register below (ItemMinStock) is illustrative: built from the engine's own source and the create-register skill's data/PeriodicInfo.cs template, not copied from working code. Expect to be the first to find any rough edges, and check first whether a document-bound Info register would do the job.

This is the one register family that is not written through document posting. Where a document-bound Info register's records belong to the document that posted them (and vanish when it's unposted), a periodic Info register holds reference-style state — values with an effective date that are set and cleared directly: by an import job, an admin screen, a dictionary behavior. Think "minimum stock level for this item at this warehouse, effective from this date", edited by a manager, not derived from any transaction.

Read Creating a register first for the shared concepts. Acme stands in for your configuration's root name.

How it differs from document-bound Info

Document-boundIndependent/periodic
Base recordDocumentInfoRecord<TDim,TDetails>PeriodicInfoRecord<TDim,TDetails>
Structural fieldsPeriod, DocumentId, LineId, ActivePeriod only
Written byA document Behavior, via keyed IDocumentInfoRegisterWriter<>Any service, via plain injectable IInfoRegisterWriter<,,>
Write semanticsReplace the document's whole record setUpsert / delete one value by (Dimensions, Period)
Register transactions tabYes (needs [RegisterCaption])No — no DocumentId to attribute rows to

Because there is no document, there is no recorder, no unposting, and no Active flag: a value is either present or deleted.

1. Dimensions, Details, record, register

Acme.Domain/Entities/Registers/ItemMinStock.cs:

using System.Text.Json.Serialization;
using Kandra.Attributes.Entities;
using Kandra.Attributes.Naming;
using Kandra.Attributes.Printable;
using Kandra.Domain.Registers;
using Acme.Domain.Entities.Dictionaries;
using Microsoft.EntityFrameworkCore;

namespace Acme.Domain.Entities.Registers;

public sealed class ItemMinStockDetails : IRegisterDetails
{
[Precision(14, 3)]
[Caption("MinQuantity")]
[Format("N3")]
public decimal MinQuantity { get; set; }
}

[KandraGeneratedEquality]
public sealed partial class ItemMinStockDimensions : IRegisterDimensions<ItemMinStockDimensions>
{
[Caption("Warehouse")]
public Guid WarehouseId { get; set; }
[JsonIgnore]
public Warehouse Warehouse { get; set; } = null!;

[Caption("Item")]
public Guid ItemId { get; set; }
[JsonIgnore]
public Item Item { get; set; } = null!;
}

[KandraRegisterEntity(TableName = "Reg_ItemMinStock")]
public sealed class ItemMinStockRecord : PeriodicInfoRecord<ItemMinStockDimensions, ItemMinStockDetails>
{
}

public sealed partial class ItemMinStockRegister : PeriodicInfoRegister<ItemMinStockRecord>
{
}

Compared with the document-bound record, note what is absent: no [RegisterCaption] (the register transactions viewer only reads Movement and DocumentInfoRecord rows, so this record type never appears in it — the caption would be dead weight), and the same "no Resources class" rule as every Info register — the stored value is Details.

2. DI and EF configuration — nothing to write

Build, then confirm the generated persistence DI extensions contain:

services.AddPeriodicInfoRegister<ItemMinStockRecord, ItemMinStockDimensions, ItemMinStockDetails>();

It registers the reader (IInfoRegisterReader<ItemMinStockRecord>) and a plain IInfoRegisterWriter<ItemMinStockRecord, ItemMinStockDimensions, ItemMinStockDetails> — an ordinary scoped service, not keyed. EF configuration is generated as for every family.

3. Localization

MinQuantity, Warehouse, Item in all three resx files (the latter two probably already exist).

4. Write it from any service

There is no ISubmitScope here. Inject the writer like any other dependency:

public class ItemMinStockService(
IInfoRegisterWriter<ItemMinStockRecord, ItemMinStockDimensions, ItemMinStockDetails> writer)
{
public Task SetAsync(Guid warehouseId, Guid itemId, DateTime effectiveFrom, decimal minQuantity,
CancellationToken cancellationToken) =>
writer.SetAsync(
new ItemMinStockDimensions { WarehouseId = warehouseId, ItemId = itemId },
effectiveFrom,
new ItemMinStockDetails { MinQuantity = minQuantity },
cancellationToken);

public Task DeleteAsync(Guid warehouseId, Guid itemId, DateTime effectiveFrom,
CancellationToken cancellationToken) =>
writer.DeleteAsync(
new ItemMinStockDimensions { WarehouseId = warehouseId, ItemId = itemId },
effectiveFrom,
cancellationToken);
}

Register the service in your Acme.Application DI as you would any application service.

The writer contract is two calls:

  • SetAsync(dimensions, period, details, ct) — an upsert keyed by exactly (dimensions, period). If a record with that exact key exists its Details are overwritten; otherwise a new record is inserted. It does not touch any other period for the same dimensions, which is what makes the values "periodic": each SetAsync for a new effective date adds a new point in that dimension's history.
  • DeleteAsync(dimensions, period, ct) — physically removes the record for exactly that key; a no-op if it doesn't exist.

5. Read it back

The reader is the same IInfoRegisterReader<TRecord> used by document-bound registers:

var current = await reader.SliceOfLast(
DateTime.UtcNow,
q => q.Where(r => r.Dimensions.WarehouseId == warehouseId),
cancellationToken);

SliceOfLast(at, ...) returns, per dimension combination, the record with the latest Period at or before at — "the min-stock level currently in effect". Records(from, to, ...) returns the history in a range. See Document-bound info register for the details, including that SliceOfLast groups in memory (so always pass an adjust).

6. Migration and verify

Normal per-provider migration flow (Database migrations); dotnet build and check the generated DI line and record configuration exist. There is no live example to model tests on — write a SQLite round-trip test of SetAsyncSliceOfLastDeleteAsync (the same shape as PriceRecordTransactionsReaderSqliteTests, minus the document), since this is exactly where an unproven family is most likely to surprise you.

Gotchas specific to this family

  • The writer commits. SetAsync and DeleteAsync each call SaveChangesAsync on the scope's shared DbContext themselves, so calling one from inside a larger unit of work also commits whatever else is pending in that scope. Don't call them mid-way through something you'd expect to be able to roll back.
  • Uniqueness of (Dimensions, Period) is by convention, not by index. The record table indexes Period but has no unique constraint over the dimensions. SetAsync finds the existing row with a lookup-then-insert, so two concurrent SetAsync calls for the same new key could both insert. If that can happen in your workload, serialize writes for a given key.
  • Period matching is exact. SetAsync/DeleteAsync match on the DateTime exactly, not "the same day". Normalize what you pass in (e.g. always a date at UTC midnight) or you'll create near-duplicate points instead of updating one.
  • No document, no audit trail of who wrote it. With no recorder there is no DocumentId linking a record back to what produced it, and no Register Transactions tab entry. If you need that, use a document-bound register and put the editing in a document.
  • Nothing calls this from generated UI. There's no form for it (registers never have one); the admin screen or import job that writes it is yours to build.

See also