Turnover register
TurnoverRegister<> has infrastructure — a writer, a reader, and an AddTurnoverRegister<>()
DI extension — but nothing in Kandra's reference configuration uses it, and no test exercises
it. It is validated by design, not by use. The register below (SalesTurnover) is
illustrative, built from the engine's source and the create-register skill's
data/Turnover.cs template rather than copied from working code. Before choosing it, check
whether a Balance register (if you want totals) or a
document-bound Info register (if a document sets a value) fits instead.
A Turnover register is the Balance family without the balance table: it keeps only the movement log — every posted receipt/expense row — and the log is the register. Reach for it when you want period-bucketed movement totals ("units sold per item per month") and explicitly don't want the cost of maintaining a running-total table. You sum movements when you read.
Read Creating a register first for the shared concepts, and
Balance register: Inventory — everything there about dimensions, resources,
movements, IsExpense, LineId, and the write path applies unchanged. This page covers only
what's different. Acme stands in for your configuration's root name.
How it differs from Balance
- One row entity (
Movement<,,>), one table. NoBalance<,>class, no_Balancetable, noVersionconcurrency column. - Nothing to overdraw. With no balance there is nothing for
EnsureNonNegative()to guard at the register level — so a plainQuantityResources(sign unconstrained) is a natural fit. Each movement's resources still getEnsureNonNegative()atPostAsynctime, so a non-negative resource type still rejects a negative movement value. - Reads sum movements, via
ITurnoverReader<TMovement>.Movements(from, to, ...). There's noBalances(...)and no as-of-date balance. - DI is the one thing that is not generated. See section 2.
1. Dimensions, record, register
Acme.Domain/Entities/Registers/SalesTurnover.cs. Resources reuse the shipped
QuantityResources, and there are no movement-scoped fields, so NoDetails:
using System.Text.Json.Serialization;
using Kandra.Attributes.Entities;
using Kandra.Attributes.Naming;
using Kandra.Domain.Registers;
using Acme.Domain.Entities.Dictionaries;
namespace Acme.Domain.Entities.Registers;
[KandraGeneratedEquality]
public sealed partial class SalesTurnoverDimensions : IRegisterDimensions<SalesTurnoverDimensions>
{
[Caption("Item")]
public Guid ItemId { get; set; }
[JsonIgnore]
public Item Item { get; set; } = null!;
[Caption("Warehouse")]
public Guid WarehouseId { get; set; }
[JsonIgnore]
public Warehouse Warehouse { get; set; } = null!;
}
[KandraRegisterEntity(TableName = "Reg_SalesTurnover")]
[RegisterCaption("SalesTurnover")]
public sealed class SalesTurnoverMovement
: Movement<SalesTurnoverDimensions, QuantityResources, NoDetails>
{
}
public sealed partial class SalesTurnoverRegister : TurnoverRegister<SalesTurnoverMovement>
{
}
Only [KandraRegisterEntity] on the Movement — there is no balance to annotate.
[RegisterCaption] goes on the Movement, as in the Balance family, so the register appears in
the document's Register Transactions tab.
2. DI — the one hand-written line
The DI generator registers a Balance register by walking discovered Balance entities and
looking up the Movement with matching Dimensions and Resources types. A Movement with no
matching Balance — which is exactly what a Turnover register is — is silently skipped. There is
no separate "is this Turnover?" case in the generator. So you register it yourself, one line in
Acme.Persistence/ConfigureServices.cs, next to the generated-entities call:
public static IServiceCollection AddAcmePersistence(this IServiceCollection services)
{
// ...
services.AddGeneratedAcmePersistenceEntities();
// Turnover registers are not wired by the DI generator — add each by hand.
services.AddTurnoverRegister<SalesTurnoverMovement, SalesTurnoverDimensions,
QuantityResources, NoDetails>();
return services;
}
Pass your Details class instead of NoDetails if you have one. This registers the reader
(ITurnoverReader<SalesTurnoverMovement>) and a keyed writer
(ITurnoverRegisterWriter<SalesTurnoverMovement>).
Everything else is still generated: EF configuration (table name, owned types, precision, FKs) and the register-transactions reader both cover a Turnover Movement exactly as they do a Balance one.
Nothing fails to compile if you omit AddTurnoverRegister<>(). The first GetWriter<>() call
for it, or the first injection of its reader, throws because the service isn't registered.
Re-check this against the DI generator's source before relying on it being permanent — if it ever grows a Turnover case, the manual call becomes redundant and should be removed rather than left as a duplicate registration.
3. Localization
SalesTurnover, Item, Warehouse — all three resx files.
4. Write it from a document Behavior
Identical to the Balance flow, with the turnover writer interface:
[KandraDocumentBehavior(typeof(SalesInvoiceDto))]
[RegisterTransactions(typeof(SalesTurnoverMovement))]
public class SalesInvoiceBehavior(/* ... */) : IDocumentBehavior<SalesInvoice>
{
public async ValueTask OnSubmitAsync(SalesInvoice entity, bool isInsert,
ISubmitScope registers, CancellationToken cancellationToken)
{
var movements = entity.Lines.Select(line => new SalesTurnoverMovement
{
LineId = line.Id,
IsExpense = false,
Dimensions = new SalesTurnoverDimensions
{
ItemId = line.ItemId,
WarehouseId = entity.WarehouseId,
},
Resources = new QuantityResources { Quantity = line.Quantity },
}).ToList();
var writer = registers.GetWriter<ITurnoverRegisterWriter<SalesTurnoverMovement>>();
await writer.PostAsync(entity.Id, entity.Date, movements, cancellationToken);
}
public async ValueTask OnUnsubmitAsync(SalesInvoice entity, ISubmitScope registers,
CancellationToken cancellationToken) =>
await registers.GetWriter<ITurnoverRegisterWriter<SalesTurnoverMovement>>()
.UnpostAsync(entity.Id, cancellationToken);
public ValueTask OnDeleteAsync(Guid id, ISubmitScope registers, CancellationToken cancellationToken) =>
new(registers.GetWriter<ITurnoverRegisterWriter<SalesTurnoverMovement>>()
.DeleteAsync(id, cancellationToken));
}
(SalesInvoice is an illustrative document; substitute yours.) The three writer calls behave
as in the Balance family — PostAsync replaces the document's whole movement set,
UnpostAsync marks the rows Active = false, DeleteAsync unposts and removes — minus the
balance delta step.
5. Read it back
var sold = await turnoverReader.Movements(
from, to,
q => q.Where(m => m.Dimensions.ItemId == itemId)
.GroupBy(m => m.Dimensions.ItemId)
.Select(g => new { ItemId = g.Key, Quantity = g.Sum(m => m.Resources.Quantity) }),
cancellationToken);
The reader returns only Active movements in [from, to]. Aggregation is yours to write inside
the query transform. Whether a given GroupBy/Sum over the owned-type Resources
translates to SQL on your provider is exactly the sort of thing this unproven family hasn't
been tested for — write a SQLite integration test for your aggregate (the pattern is
InventoryBalancePredicateSqliteTests) so a silent client-side fallback shows up as a failure
rather than a slow report.
Note that a movement's IsExpense flag is just a stored column here — nothing subtracts it for
you the way the balance writer does. If your totals need to treat expense rows as negative, do
that in your aggregation.
6. Migration and verify
Normal per-provider flow (Database migrations). Then dotnet build, and —
because DI is manual — confirm the AddTurnoverRegister<>() line is present and the generated
SalesTurnoverMovementConfiguration exists. Live: submit the document, run your aggregate query,
unsubmit and confirm the rows drop out of it.
Gotchas specific to this family
- The
AddTurnoverRegister<>()line is manual and its omission surfaces only at runtime (section 2). - No overdraft protection at the register level. With no balance table nothing can be "insufficient". If you need "never sell more than we have", that needs a Balance register (or a check in your Behavior against another register).
- No as-of-date balance and no maintenance tooling.
IRegisterMaintenance<,>(rebuild/verify) exists only for Balance registers — there is no derived table to drift. - It's the least-proven family. No live consumer and no test — budget time for finding an edge in your first real use.
See also
- Creating a register — shared concepts and cross-family Gotchas.
- Balance register: Inventory — the family this one is derived from.
- Reference: Register Engine.