@tallyui/pos 3.7.0 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +62 -6
- package/dist/index.js +190 -20
- package/package.json +6 -6
- package/src/index.ts +2 -2
- package/src/outbox/register-outbox.ts +26 -3
- package/src/outbox/use-register-outbox.ts +6 -2
- package/src/register/index.ts +2 -1
- package/src/register/register-adoption.ts +102 -0
- package/src/register/register-commands.ts +20 -7
- package/src/register/schemas.ts +2 -2
- package/src/register/session-store.ts +21 -1
- package/src/register/use-register-session.ts +50 -10
package/dist/index.d.ts
CHANGED
|
@@ -1074,8 +1074,8 @@ interface RegisterSession {
|
|
|
1074
1074
|
register_id: string;
|
|
1075
1075
|
/** The app's neutral store key (see `bindRegister`). */
|
|
1076
1076
|
store_key?: string | null;
|
|
1077
|
-
/** Stored status is an open string so a later local state costs no migration (ADR-078 decision 9); the union names today's states. */
|
|
1078
|
-
status: 'open' | 'counting' | 'closed' | 'conflict' | 'superseded';
|
|
1077
|
+
/** Stored status is an open string so a later local state costs no migration (ADR-078 decision 9); the union names today's states. `abandoned` is local only and terminal (the cashier chose another register while in conflict). */
|
|
1078
|
+
status: 'open' | 'counting' | 'closed' | 'conflict' | 'superseded' | 'abandoned';
|
|
1079
1079
|
/** The store session this local id was resumed as (ADR-078 decision 2), never the session that superseded it; null until a resume. */
|
|
1080
1080
|
server_session_id?: string | null;
|
|
1081
1081
|
/** The store's day the session opened, `yyyy-MM-dd`. */
|
|
@@ -1261,6 +1261,15 @@ declare class RegisterNeedsUpgradeError extends Error {
|
|
|
1261
1261
|
readonly code = "REGISTER_NEEDS_UPGRADE";
|
|
1262
1262
|
constructor();
|
|
1263
1263
|
}
|
|
1264
|
+
/** Opening is refused while the register has a `conflict` session (ADR-078); its message can be shown to a cashier as it is. */
|
|
1265
|
+
declare class RegisterSessionConflictError extends Error {
|
|
1266
|
+
readonly code = "REGISTER_SESSION_CONFLICT";
|
|
1267
|
+
constructor();
|
|
1268
|
+
}
|
|
1269
|
+
declare class RegisterTakeOverError extends Error {
|
|
1270
|
+
readonly code: 'REGISTER_TAKEOVER_UNSUPPORTED' | 'REGISTER_TAKEOVER_PENDING';
|
|
1271
|
+
constructor(code: 'REGISTER_TAKEOVER_UNSUPPORTED' | 'REGISTER_TAKEOVER_PENDING');
|
|
1272
|
+
}
|
|
1264
1273
|
/** ADR-068 6a: movement amounts must be safe integers, positive for paid_in/paid_out and zero for no_sale. */
|
|
1265
1274
|
declare class RegisterMovementAmountError extends Error {
|
|
1266
1275
|
constructor(type: 'paid_in' | 'paid_out' | 'no_sale', amountMinor: number);
|
|
@@ -1840,9 +1849,11 @@ declare const registerCommandCollection: () => {
|
|
|
1840
1849
|
schema: RxJsonSchema<RegisterCommand>;
|
|
1841
1850
|
};
|
|
1842
1851
|
type BuiltCommand = Pick<RegisterCommand, 'key' | 'type' | 'payload'> & {
|
|
1843
|
-
version: 1;
|
|
1852
|
+
version: 1 | 2;
|
|
1844
1853
|
};
|
|
1845
|
-
declare function sessionOpenCommand(s: RegisterSession
|
|
1854
|
+
declare function sessionOpenCommand(s: RegisterSession, v2?: {
|
|
1855
|
+
deviceName?: string | null;
|
|
1856
|
+
}): BuiltCommand;
|
|
1846
1857
|
declare function sessionTransitionCommand(s: RegisterSession & {
|
|
1847
1858
|
status: RegisterSessionTransitionPayload['status'];
|
|
1848
1859
|
status_at: string;
|
|
@@ -1850,7 +1861,7 @@ declare function sessionTransitionCommand(s: RegisterSession & {
|
|
|
1850
1861
|
declare function movementCommand(m: CashMovement): BuiltCommand;
|
|
1851
1862
|
declare function closureCommand(c: Closure): BuiltCommand;
|
|
1852
1863
|
/** Appends missing facts in dependency order; the bytes of an existing key never change. */
|
|
1853
|
-
declare function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed }: {
|
|
1864
|
+
declare function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed, registerContract, deviceName }: {
|
|
1854
1865
|
commands: RegisterCommandCollection;
|
|
1855
1866
|
sessions: RegisterSessionCollection;
|
|
1856
1867
|
movements: CashMovementCollection;
|
|
@@ -1860,6 +1871,8 @@ declare function reconcileRegisterCommands({ commands, sessions, movements, clos
|
|
|
1860
1871
|
registerId: string;
|
|
1861
1872
|
now?: string;
|
|
1862
1873
|
observed?: RegisterSession[];
|
|
1874
|
+
registerContract?: number;
|
|
1875
|
+
deviceName?: string | null;
|
|
1863
1876
|
}): Promise<string[]>;
|
|
1864
1877
|
|
|
1865
1878
|
/** A sale is at tender, so the till can't count or close under it. Its message can be shown to a cashier as it is. */
|
|
@@ -1888,6 +1901,8 @@ interface UseRegisterSessionOptions {
|
|
|
1888
1901
|
/** `register_commands`, created with `registerCommandCollection()`; `null` while it opens. */
|
|
1889
1902
|
commands?: RegisterCommandCollection | null;
|
|
1890
1903
|
capabilities?: ServerCapabilities;
|
|
1904
|
+
/** The till's name from the app's settings, sent on a register v2 open; the app supplies its platform fallback. */
|
|
1905
|
+
deviceName?: string | null;
|
|
1891
1906
|
/** `pos_orders`: a session's sales are those whose `sessionId` is its id. */
|
|
1892
1907
|
orders: RxCollection<PosOrder> | null;
|
|
1893
1908
|
/** The register document's host, for the closure number and perpetual totals (`writeClosure`); `null` while it opens. */
|
|
@@ -1920,6 +1935,20 @@ interface UseRegisterSessionOptions {
|
|
|
1920
1935
|
}
|
|
1921
1936
|
declare function useRegisterSession(options: UseRegisterSessionOptions): {
|
|
1922
1937
|
session: RegisterSession | null;
|
|
1938
|
+
/** The other till's live session, or the session this till is waiting to take over. */
|
|
1939
|
+
conflict: {
|
|
1940
|
+
sessionId: string;
|
|
1941
|
+
takingOver: boolean;
|
|
1942
|
+
openedAt?: undefined;
|
|
1943
|
+
openedBy?: undefined;
|
|
1944
|
+
deviceName?: undefined;
|
|
1945
|
+
} | {
|
|
1946
|
+
sessionId: string;
|
|
1947
|
+
openedAt: string | undefined;
|
|
1948
|
+
openedBy: string | undefined;
|
|
1949
|
+
deviceName: string | undefined;
|
|
1950
|
+
takingOver: boolean;
|
|
1951
|
+
} | undefined;
|
|
1923
1952
|
/** Any unknown session status in the current snapshot means the register needs upgrade. */
|
|
1924
1953
|
needsUpgrade: boolean;
|
|
1925
1954
|
movements: CashMovement[];
|
|
@@ -1946,11 +1975,14 @@ declare function useRegisterSession(options: UseRegisterSessionOptions): {
|
|
|
1946
1975
|
sessions: RegisterSessionCollection;
|
|
1947
1976
|
} | null>;
|
|
1948
1977
|
actions: {
|
|
1978
|
+
takeOver: () => Promise<void>;
|
|
1979
|
+
chooseAnotherRegister: () => Promise<void>;
|
|
1949
1980
|
/**
|
|
1950
1981
|
* Refuses with `RegisterSessionAlreadyOpenError` while the register has a live session or
|
|
1951
1982
|
* another open is running (in any hook instance), and with `RegisterCloseIncompleteError`
|
|
1952
1983
|
* while an earlier close hasn't finished: a new session would lock the till behind it.
|
|
1953
1984
|
* An unknown stored status refuses with `RegisterNeedsUpgradeError`.
|
|
1985
|
+
* A conflict session refuses with `RegisterSessionConflictError`; superseded and abandoned sessions do not block.
|
|
1954
1986
|
*/
|
|
1955
1987
|
openSession: (input: {
|
|
1956
1988
|
expectedFloatMinor: number | null;
|
|
@@ -1982,6 +2014,25 @@ declare function useRegisterSession(options: UseRegisterSessionOptions): {
|
|
|
1982
2014
|
};
|
|
1983
2015
|
};
|
|
1984
2016
|
|
|
2017
|
+
/**
|
|
2018
|
+
* Applied opens fill a missing resume id; refused opens make open/counting sessions conflict.
|
|
2019
|
+
* Superseded rejections make open/counting sessions terminal; a conflict session leaves only by an applied open or abandon (ADR-078 decision 9); terminal/unknown states stay untouched.
|
|
2020
|
+
* Applied conflict opens restore the latest transition's counting state, otherwise open; pending rows do nothing.
|
|
2021
|
+
* Answers are walked in seq/key order; adoption is idempotent and safe to run at any time.
|
|
2022
|
+
*/
|
|
2023
|
+
declare function adoptRegisterResults({ commands, sessions, registerId }: {
|
|
2024
|
+
commands: RegisterCommandCollection;
|
|
2025
|
+
sessions: RegisterSessionCollection;
|
|
2026
|
+
registerId: string;
|
|
2027
|
+
}): Promise<void>;
|
|
2028
|
+
declare function takeOverSession({ commands, sessions, sessionId, now }: {
|
|
2029
|
+
commands: RegisterCommandCollection;
|
|
2030
|
+
sessions: RegisterSessionCollection;
|
|
2031
|
+
sessionId: string;
|
|
2032
|
+
now?: string;
|
|
2033
|
+
}): Promise<void>;
|
|
2034
|
+
declare function abandonSession(input: Parameters<typeof takeOverSession>[0]): Promise<void>;
|
|
2035
|
+
|
|
1985
2036
|
type CatalogueEntry<Doc> = {
|
|
1986
2037
|
product: Doc;
|
|
1987
2038
|
variant: VariantSummary;
|
|
@@ -2287,6 +2338,9 @@ declare function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox;
|
|
|
2287
2338
|
|
|
2288
2339
|
interface RegisterOutboxOptions {
|
|
2289
2340
|
collection: RxCollection<RegisterCommand>;
|
|
2341
|
+
/** The till's `register_sessions`. With it, a refused open (`register_session_already_open`) stops blocking its register
|
|
2342
|
+
* once its session is `abandoned` (ADR-078 decision 9). Without it, that refusal always blocks. */
|
|
2343
|
+
sessions?: RegisterSessionCollection;
|
|
2290
2344
|
transport: CommandTransport<RegisterCommandEnvelope>;
|
|
2291
2345
|
deviceId: string;
|
|
2292
2346
|
/** Checked at the start of every run; false sends nothing. */
|
|
@@ -2314,6 +2368,8 @@ declare function createRegisterOutbox(options: RegisterOutboxOptions): RegisterO
|
|
|
2314
2368
|
interface UseRegisterOutboxOptions {
|
|
2315
2369
|
/** `register_commands` (see `registerCommandCollection()`); `null` while it opens or when there's no store. A new collection (a store switch) restarts the outbox. */
|
|
2316
2370
|
commands: RegisterCommandCollection | null;
|
|
2371
|
+
/** The till's `register_sessions`; abandoned sessions unblock refused opens. A new collection restarts the outbox. */
|
|
2372
|
+
sessions?: RegisterSessionCollection | null;
|
|
2317
2373
|
/** Builds the transport; called when the outbox is created: on each new collection or device id (twice under React StrictMode's double effect, which stops the first outbox). */
|
|
2318
2374
|
transport(): CommandTransport<RegisterCommandEnvelope>;
|
|
2319
2375
|
/** The device id sent on every command. A change restarts the outbox. */
|
|
@@ -2577,4 +2633,4 @@ declare function tenderVoidCollection(): {
|
|
|
2577
2633
|
readonly schema: RxJsonSchema<TenderVoid>;
|
|
2578
2634
|
};
|
|
2579
2635
|
|
|
2580
|
-
export { type Actor, type AddLineInput, type AppliedDiscount, type BackendNotFound, type BasketLine, CartError, type CashMovement, type CashMovementCollection, type CatalogueEntry, type CatalogueGridColumns, type CatalogueView, type CatalogueViewAction, type CatalogueViewState, type ChargeInput, type ChargeLine, type Closure, type ClosureCollection, type ClosureContext, type ClosureScope, type CommandTransport, type Correction, CurrencyProvider, type CurrencyProviderProps, type CustomerSummary, DEFAULT_CATALOGUE_VIEW_STATE, DISCOUNTS_UNSUPPORTED, type Discount, type DisplayLine, type DisplayTotals, type FeeLine, type FinalizeOptions, type HttpTransportOptions, type LedgerRow, type LineItem, type LineTaxLine, type LogEntry, type LogLevel, type LogSink, type Logger, MAX_TENDER_MINOR, MICROS_PER_MINOR, type Movement$1 as Movement, type MovementType, type Order, type OrderBuilder, type OrderBuilderOptions, OrderContentMismatchError, type OrderManager, type OrderManagerOptions, type OrderOutbox, type OrderOutboxOptions, type OrderTaxTotals, type OrderTransportContext, type OutboxState, PRODUCT_SORT_FIELDS, type ParkedOrderSummary, type Payment, type PaymentMethod, type PaymentTransport, type PlanLeg, type PosOrder, type PosOrderLine, type PosOrderLocalWarning, PosOrderOpenClosedError, type PosOrderPayment, type PosOrderServerFailures, type PosOrderSyncStatus, type ProductSort, type ProductSortValue, RECEIPT_SCHEMA_VERSION, REGISTER_SESSION_MIGRATION_CLOSE_WAIT_MS, type RateTaxLine, type ReceiptConfig, type ReceiptData, type ReceiptFiscal, type ReceiptLineItem, type ReceiptRegister, type ReceiptSoftware, type RecordedFigures, RegisterApprovalRequiredError, type RegisterBucket, RegisterCloseIncompleteError, type RegisterCommand, type RegisterCommandCollection, type RegisterCounters, type RegisterDocument, type RegisterFact, type RegisterHost, RegisterIdInvalidError, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, RegisterNeedsUpgradeError, type RegisterOutbox, type RegisterOutboxOptions, type RegisterSession, RegisterSessionAlreadyOpenError, RegisterSessionClosedError, type RegisterSessionCollection, RegisterSessionOpenClosedError, RegisterSessionRequiredError, type RegisterStore, RegisterTenderInProgressError, type Repository, type ResolveStoreSettingsOptions, SALE_SAVING, type SaleStage, type SentOrder, type ShippingLine, type SplitTab, type StoreSettingsResolution, type StoreSettingsState, TAX_ROUNDING_MIXED_NOTE, type TaxContext, type TaxLineInput, TaxProvider, type TaxProviderProps, type TaxRateMap, type TenderAction, type TenderKey, type TenderLineId, type TenderPlan, type TenderState, type TenderView, type TenderVoid, type TransportOutcome, UnsupportedOrderVersionError, type UseOrderOutboxOptions, type UseOrderOutboxResult, type UseRegisterOutboxOptions, type UseRegisterOutboxResult, type UseRegisterSessionOptions, activePlan, addEntryToCart, addPosOrderCollection, addRegisterSessionCollection, advancePerpetual, allocateOrderDiscount, appliedMinor, backToSelling, bindRegister, buildClosureDocument, buildReceiptData, buildXReportDocument, callbackSink, cashMovementSchema, catalogueEntries, catalogueViewReducer, changeMinor, clampClosureScope, closeNeedsApproval, closeSession, closureCommand, closureSchema, computeOrderTax, consoleSink, countVariance, createBackendNotFound, createHttpCommandTransport, createLogger, createOrderBuilder, createOrderManager, createOrderOutbox, createRegisterOutbox, createRepository, denominationTotal, denominations, deriveExpected, deriveSettled, ensureRegister, evenSplitShareMinor, exportCsv, finalizeOrder, findEntryByCode, formatClosureDate, getBoundRegisterId, getDeviceId, inCategory, initTenderState, initialTenderState, isKnownSessionStatus, isServerDecimal, labelKeys, listCategories, minorToDecimal, mintClosureNumber, mintUuid, movementCommand, movementFieldError, needsAttention, nextSaleCounter, normalizeAmount, normalizeCatalogueViewState, observeRegister$, openSession, openSessionSelector, orderDraftSchema, outboxLogger, overThreshold, parkedOrderSummaries$, parseMinor, planLegs, posOrderCollection, posOrderSchema, posOrdersLogger, productCategories, productSortValue, quickTenderedAmounts, ratePpmFromPercent, readBoundRegister, readRegister, reconcileRegisterCommands, recordMovement, recordRegisterFact, registerCommandCollection, registerCommandSchema, registerCommandsLogger, registerFactsLogger, registerSessionCollection, registerSessionSchema, registerSessionsLogger, requireOpenSession, resolveGridColumns, resolveStoreSettings, restoreOrderDraft, roundMicrosToMinor, saleLogger, sameSale, searchProducts, selectClosureRows, sessionOpenCommand, sessionTransitionCommand, sortProducts, stampSession, startCounting, stockOverlay$, stockOverlayAsOf$, taxFiguresForBasket, taxLinesByRate, taxLogger, taxMicros, taxProviderProps, tenderReducer, tenderVoidCollection, tenderVoidSchema, toOrderCreateEnvelope, unbindRegister, useCurrencyCode, useCurrencyFormatter, useOrderOutbox, useParkedSales, useRegisterOutbox, useRegisterSession, useSale, useStoreSettings, useTax, uuidv7, validAmount, varianceText, variantPriceLabel, voidMovement, withPricingContext, writeClosure, writeOrderDraft };
|
|
2636
|
+
export { type Actor, type AddLineInput, type AppliedDiscount, type BackendNotFound, type BasketLine, CartError, type CashMovement, type CashMovementCollection, type CatalogueEntry, type CatalogueGridColumns, type CatalogueView, type CatalogueViewAction, type CatalogueViewState, type ChargeInput, type ChargeLine, type Closure, type ClosureCollection, type ClosureContext, type ClosureScope, type CommandTransport, type Correction, CurrencyProvider, type CurrencyProviderProps, type CustomerSummary, DEFAULT_CATALOGUE_VIEW_STATE, DISCOUNTS_UNSUPPORTED, type Discount, type DisplayLine, type DisplayTotals, type FeeLine, type FinalizeOptions, type HttpTransportOptions, type LedgerRow, type LineItem, type LineTaxLine, type LogEntry, type LogLevel, type LogSink, type Logger, MAX_TENDER_MINOR, MICROS_PER_MINOR, type Movement$1 as Movement, type MovementType, type Order, type OrderBuilder, type OrderBuilderOptions, OrderContentMismatchError, type OrderManager, type OrderManagerOptions, type OrderOutbox, type OrderOutboxOptions, type OrderTaxTotals, type OrderTransportContext, type OutboxState, PRODUCT_SORT_FIELDS, type ParkedOrderSummary, type Payment, type PaymentMethod, type PaymentTransport, type PlanLeg, type PosOrder, type PosOrderLine, type PosOrderLocalWarning, PosOrderOpenClosedError, type PosOrderPayment, type PosOrderServerFailures, type PosOrderSyncStatus, type ProductSort, type ProductSortValue, RECEIPT_SCHEMA_VERSION, REGISTER_SESSION_MIGRATION_CLOSE_WAIT_MS, type RateTaxLine, type ReceiptConfig, type ReceiptData, type ReceiptFiscal, type ReceiptLineItem, type ReceiptRegister, type ReceiptSoftware, type RecordedFigures, RegisterApprovalRequiredError, type RegisterBucket, RegisterCloseIncompleteError, type RegisterCommand, type RegisterCommandCollection, type RegisterCounters, type RegisterDocument, type RegisterFact, type RegisterHost, RegisterIdInvalidError, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, RegisterNeedsUpgradeError, type RegisterOutbox, type RegisterOutboxOptions, type RegisterSession, RegisterSessionAlreadyOpenError, RegisterSessionClosedError, type RegisterSessionCollection, RegisterSessionConflictError, RegisterSessionOpenClosedError, RegisterSessionRequiredError, type RegisterStore, RegisterTakeOverError, RegisterTenderInProgressError, type Repository, type ResolveStoreSettingsOptions, SALE_SAVING, type SaleStage, type SentOrder, type ShippingLine, type SplitTab, type StoreSettingsResolution, type StoreSettingsState, TAX_ROUNDING_MIXED_NOTE, type TaxContext, type TaxLineInput, TaxProvider, type TaxProviderProps, type TaxRateMap, type TenderAction, type TenderKey, type TenderLineId, type TenderPlan, type TenderState, type TenderView, type TenderVoid, type TransportOutcome, UnsupportedOrderVersionError, type UseOrderOutboxOptions, type UseOrderOutboxResult, type UseRegisterOutboxOptions, type UseRegisterOutboxResult, type UseRegisterSessionOptions, abandonSession, activePlan, addEntryToCart, addPosOrderCollection, addRegisterSessionCollection, adoptRegisterResults, advancePerpetual, allocateOrderDiscount, appliedMinor, backToSelling, bindRegister, buildClosureDocument, buildReceiptData, buildXReportDocument, callbackSink, cashMovementSchema, catalogueEntries, catalogueViewReducer, changeMinor, clampClosureScope, closeNeedsApproval, closeSession, closureCommand, closureSchema, computeOrderTax, consoleSink, countVariance, createBackendNotFound, createHttpCommandTransport, createLogger, createOrderBuilder, createOrderManager, createOrderOutbox, createRegisterOutbox, createRepository, denominationTotal, denominations, deriveExpected, deriveSettled, ensureRegister, evenSplitShareMinor, exportCsv, finalizeOrder, findEntryByCode, formatClosureDate, getBoundRegisterId, getDeviceId, inCategory, initTenderState, initialTenderState, isKnownSessionStatus, isServerDecimal, labelKeys, listCategories, minorToDecimal, mintClosureNumber, mintUuid, movementCommand, movementFieldError, needsAttention, nextSaleCounter, normalizeAmount, normalizeCatalogueViewState, observeRegister$, openSession, openSessionSelector, orderDraftSchema, outboxLogger, overThreshold, parkedOrderSummaries$, parseMinor, planLegs, posOrderCollection, posOrderSchema, posOrdersLogger, productCategories, productSortValue, quickTenderedAmounts, ratePpmFromPercent, readBoundRegister, readRegister, reconcileRegisterCommands, recordMovement, recordRegisterFact, registerCommandCollection, registerCommandSchema, registerCommandsLogger, registerFactsLogger, registerSessionCollection, registerSessionSchema, registerSessionsLogger, requireOpenSession, resolveGridColumns, resolveStoreSettings, restoreOrderDraft, roundMicrosToMinor, saleLogger, sameSale, searchProducts, selectClosureRows, sessionOpenCommand, sessionTransitionCommand, sortProducts, stampSession, startCounting, stockOverlay$, stockOverlayAsOf$, takeOverSession, taxFiguresForBasket, taxLinesByRate, taxLogger, taxMicros, taxProviderProps, tenderReducer, tenderVoidCollection, tenderVoidSchema, toOrderCreateEnvelope, unbindRegister, useCurrencyCode, useCurrencyFormatter, useOrderOutbox, useParkedSales, useRegisterOutbox, useRegisterSession, useSale, useStoreSettings, useTax, uuidv7, validAmount, varianceText, variantPriceLabel, voidMovement, withPricingContext, writeClosure, writeOrderDraft };
|
package/dist/index.js
CHANGED
|
@@ -2813,6 +2813,20 @@ var RegisterNeedsUpgradeError = class extends Error {
|
|
|
2813
2813
|
this.name = "RegisterNeedsUpgradeError";
|
|
2814
2814
|
}
|
|
2815
2815
|
};
|
|
2816
|
+
var RegisterSessionConflictError = class extends Error {
|
|
2817
|
+
code = "REGISTER_SESSION_CONFLICT";
|
|
2818
|
+
constructor() {
|
|
2819
|
+
super("This register is open on another till. Take it over or choose another register.");
|
|
2820
|
+
this.name = "RegisterSessionConflictError";
|
|
2821
|
+
}
|
|
2822
|
+
};
|
|
2823
|
+
var RegisterTakeOverError = class extends Error {
|
|
2824
|
+
constructor(code) {
|
|
2825
|
+
super(code === "REGISTER_TAKEOVER_UNSUPPORTED" ? "This store cannot take over a register. Choose another register." : "Taking over this register is still waiting for the store.");
|
|
2826
|
+
this.code = code;
|
|
2827
|
+
this.name = "RegisterTakeOverError";
|
|
2828
|
+
}
|
|
2829
|
+
};
|
|
2816
2830
|
var RegisterMovementAmountError = class extends Error {
|
|
2817
2831
|
constructor(type, amountMinor) {
|
|
2818
2832
|
super(`movement_amount_invalid:${type}:${amountMinor}`);
|
|
@@ -2840,7 +2854,8 @@ var TRANSITIONS = {
|
|
|
2840
2854
|
counting: ["open", "closed"],
|
|
2841
2855
|
closed: [],
|
|
2842
2856
|
conflict: [],
|
|
2843
|
-
superseded: []
|
|
2857
|
+
superseded: [],
|
|
2858
|
+
abandoned: []
|
|
2844
2859
|
};
|
|
2845
2860
|
function isKnownSessionStatus(status) {
|
|
2846
2861
|
return Object.hasOwn(TRANSITIONS, status);
|
|
@@ -2858,6 +2873,7 @@ async function requireLiveSession(sessions, id2) {
|
|
|
2858
2873
|
if (!session) throw new RegisterSessionRequiredError();
|
|
2859
2874
|
if (!isKnownSessionStatus(session.status)) throw new RegisterNeedsUpgradeError();
|
|
2860
2875
|
if (session.status === "closed") throw new RegisterSessionClosedError();
|
|
2876
|
+
if (session.status === "abandoned") throw new RegisterSessionClosedError();
|
|
2861
2877
|
return session;
|
|
2862
2878
|
}
|
|
2863
2879
|
var openSessionSelector = { status: "open" };
|
|
@@ -3603,8 +3619,17 @@ var registerCommandSchema = {
|
|
|
3603
3619
|
indexes: [["registerId", "seq"], ["syncStatus", "registerId", "seq"]]
|
|
3604
3620
|
};
|
|
3605
3621
|
var registerCommandCollection = () => ({ schema: registerCommandSchema });
|
|
3606
|
-
function
|
|
3607
|
-
|
|
3622
|
+
function deviceNameOf(name) {
|
|
3623
|
+
let trimmed = name?.trim();
|
|
3624
|
+
if (trimmed && trimmed.length > 64) {
|
|
3625
|
+
const last = trimmed.charCodeAt(63);
|
|
3626
|
+
trimmed = trimmed.slice(0, last >= 55296 && last <= 56319 ? 63 : 64).trimEnd();
|
|
3627
|
+
}
|
|
3628
|
+
return trimmed || void 0;
|
|
3629
|
+
}
|
|
3630
|
+
function sessionOpenCommand(s, v2) {
|
|
3631
|
+
const deviceName = deviceNameOf(v2?.deviceName);
|
|
3632
|
+
return { key: `session.open:${s.id}`, type: "register.session.open", version: v2 ? 2 : 1, payload: {
|
|
3608
3633
|
sessionId: s.id,
|
|
3609
3634
|
registerId: s.register_id,
|
|
3610
3635
|
openedAt: s.opened_at_gmt,
|
|
@@ -3613,7 +3638,8 @@ function sessionOpenCommand(s) {
|
|
|
3613
3638
|
...s.business_day == null ? {} : { businessDay: s.business_day },
|
|
3614
3639
|
...s.opened_by == null ? {} : { openedBy: s.opened_by },
|
|
3615
3640
|
...s.expected_float_minor == null ? {} : { expectedFloatMinor: s.expected_float_minor },
|
|
3616
|
-
...s.opening_variance_minor == null ? {} : { openingVarianceMinor: s.opening_variance_minor }
|
|
3641
|
+
...s.opening_variance_minor == null ? {} : { openingVarianceMinor: s.opening_variance_minor },
|
|
3642
|
+
...deviceName === void 0 ? {} : { deviceName }
|
|
3617
3643
|
} };
|
|
3618
3644
|
}
|
|
3619
3645
|
function sessionTransitionCommand(s) {
|
|
@@ -3670,7 +3696,7 @@ function closureCommand(c) {
|
|
|
3670
3696
|
} };
|
|
3671
3697
|
}
|
|
3672
3698
|
var chains = /* @__PURE__ */ new WeakMap();
|
|
3673
|
-
function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed }) {
|
|
3699
|
+
function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed, registerContract, deviceName }) {
|
|
3674
3700
|
const sentStatus = (s) => s.status === "open" || s.status === "counting" || s.status === "closed";
|
|
3675
3701
|
let registers = chains.get(commands);
|
|
3676
3702
|
if (!registers) chains.set(commands, registers = /* @__PURE__ */ new Map());
|
|
@@ -3681,7 +3707,7 @@ function reconcileRegisterCommands({ commands, sessions, movements, closures, ho
|
|
|
3681
3707
|
const transitioned = new Set((await readFresh(commands, { selector: { registerId, type: "register.session.transition" } })).map(({ payload }) => payload.sessionId));
|
|
3682
3708
|
const rows = await readFresh(sessions, { selector: { register_id: registerId } });
|
|
3683
3709
|
const complete = new Set((await commands.storageInstance.findDocumentsById(rows.flatMap((session) => [`session.open:${session.id}`, ...session.status === "closed" && session.closure_id ? [`closure.submit:${session.closure_id}`] : []]), false)).map(({ key }) => key));
|
|
3684
|
-
const facts = await Promise.all(rows.filter((session) => session.status !== "closed" || (session.status_at == null || session.status_at >= since || complete.has(`session.open:${session.id}`)) && !complete.has(`closure.submit:${session.closure_id}`)).map(async (session) => {
|
|
3710
|
+
const facts = await Promise.all(rows.filter((session) => session.status !== "abandoned" && (session.status !== "closed" || (session.status_at == null || session.status_at >= since || complete.has(`session.open:${session.id}`)) && !complete.has(`closure.submit:${session.closure_id}`))).map(async (session) => {
|
|
3685
3711
|
const [entries, closureRows] = await Promise.all([
|
|
3686
3712
|
readFresh(movements, { selector: { session_id: session.id } }),
|
|
3687
3713
|
session.closure_id ? closures.storageInstance.findDocumentsById([session.closure_id], false) : Promise.resolve([])
|
|
@@ -3693,7 +3719,7 @@ function reconcileRegisterCommands({ commands, sessions, movements, closures, ho
|
|
|
3693
3719
|
);
|
|
3694
3720
|
if (session.status_at != null && sentStatus(session)) events.push(sessionTransitionCommand({ ...session, status_at: session.status_at }));
|
|
3695
3721
|
return { session, number: closureRows[0]?.number, built: [
|
|
3696
|
-
sessionOpenCommand(session),
|
|
3722
|
+
(registerContract ?? 0) >= 2 ? sessionOpenCommand(session, { deviceName }) : sessionOpenCommand(session),
|
|
3697
3723
|
...events,
|
|
3698
3724
|
...closureRows.map((closure) => closureCommand(closure))
|
|
3699
3725
|
] };
|
|
@@ -3721,6 +3747,94 @@ function reconcileRegisterCommands({ commands, sessions, movements, closures, ho
|
|
|
3721
3747
|
return run;
|
|
3722
3748
|
}
|
|
3723
3749
|
|
|
3750
|
+
// src/register/register-adoption.ts
|
|
3751
|
+
var chains2 = /* @__PURE__ */ new WeakMap();
|
|
3752
|
+
async function adoptRegisterResults({ commands, sessions, registerId }) {
|
|
3753
|
+
const run = (chains2.get(commands) ?? Promise.resolve()).then(async () => {
|
|
3754
|
+
const [answers, transitions, rows] = await Promise.all([
|
|
3755
|
+
readFresh(commands, { selector: { registerId, syncStatus: { $in: ["applied", "rejected"] } }, sort: [{ seq: "asc" }, { key: "asc" }] }),
|
|
3756
|
+
readFresh(commands, { selector: { registerId, type: "register.session.transition" }, sort: [{ seq: "desc" }, { key: "desc" }] }),
|
|
3757
|
+
readFresh(sessions, { selector: { register_id: registerId } })
|
|
3758
|
+
]);
|
|
3759
|
+
for (const session of rows) {
|
|
3760
|
+
if (session.status === "conflict" && answers.some((answer) => answer.payload.sessionId === session.id && answer.syncStatus === "rejected" && answer.error?.code === "register_session_abandoned")) {
|
|
3761
|
+
await finishAbandon({ commands, sessions, sessionId: session.id });
|
|
3762
|
+
continue;
|
|
3763
|
+
}
|
|
3764
|
+
const latest = transitions.find(({ payload }) => payload.sessionId === session.id);
|
|
3765
|
+
const modify = (doc) => {
|
|
3766
|
+
let next = doc;
|
|
3767
|
+
for (const answer of answers) {
|
|
3768
|
+
if (!isKnownSessionStatus(next.status) || next.status === "closed" || next.status === "superseded" || next.status === "abandoned") break;
|
|
3769
|
+
if (answer.payload.sessionId !== doc.id) continue;
|
|
3770
|
+
if (answer.syncStatus === "rejected") {
|
|
3771
|
+
if (answer.error?.code === "register_session_superseded" && (next.status === "open" || next.status === "counting")) next = { ...next, status: "superseded" };
|
|
3772
|
+
else if (answer.type === "register.session.open" && answer.error?.code === "register_session_already_open" && (next.status === "open" || next.status === "counting")) next = { ...next, status: "conflict" };
|
|
3773
|
+
} else if (answer.type === "register.session.open") {
|
|
3774
|
+
const resumed = answer.result?.resumed?.fromSessionId;
|
|
3775
|
+
if (typeof resumed === "string" && next.server_session_id == null) next = { ...next, server_session_id: resumed };
|
|
3776
|
+
if (next.status === "conflict") next = { ...next, status: latest?.payload.status === "counting" ? "counting" : "open" };
|
|
3777
|
+
}
|
|
3778
|
+
}
|
|
3779
|
+
return next;
|
|
3780
|
+
};
|
|
3781
|
+
if (modify(session) === session) continue;
|
|
3782
|
+
const row = await sessions.findOne(session.id).exec();
|
|
3783
|
+
if (row) await row.incrementalModify(modify);
|
|
3784
|
+
}
|
|
3785
|
+
}).catch((error) => {
|
|
3786
|
+
registerCommandsLogger.error("Register result adoption failed", { context: { registerId, error: String(error) } });
|
|
3787
|
+
});
|
|
3788
|
+
chains2.set(commands, run.then(() => void 0, () => void 0));
|
|
3789
|
+
return run;
|
|
3790
|
+
}
|
|
3791
|
+
async function takeOverSession({ commands, sessions, sessionId, now }) {
|
|
3792
|
+
const run = (chains2.get(commands) ?? Promise.resolve()).then(async () => {
|
|
3793
|
+
const [session] = await readFresh(sessions, { selector: { id: sessionId } });
|
|
3794
|
+
const row = await commands.findOne(`session.open:${sessionId}`).exec();
|
|
3795
|
+
if (session?.status !== "conflict" || !row) throw new RegisterSessionRequiredError();
|
|
3796
|
+
await row.incrementalModify((doc) => {
|
|
3797
|
+
if (doc.syncStatus !== "rejected" || doc.error?.code !== "register_session_already_open" || typeof doc.error.data?.sessionId !== "string") throw new RegisterSessionRequiredError();
|
|
3798
|
+
const { error, ...rest } = doc;
|
|
3799
|
+
return {
|
|
3800
|
+
...rest,
|
|
3801
|
+
commandId: uuidv7(),
|
|
3802
|
+
version: 2,
|
|
3803
|
+
syncStatus: "pending",
|
|
3804
|
+
payload: { ...doc.payload, supersedes: error.data.sessionId },
|
|
3805
|
+
updatedAt: now ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
3806
|
+
};
|
|
3807
|
+
});
|
|
3808
|
+
});
|
|
3809
|
+
chains2.set(commands, run.then(() => void 0, () => void 0));
|
|
3810
|
+
return run;
|
|
3811
|
+
}
|
|
3812
|
+
async function finishAbandon({ commands, sessions, sessionId, now }) {
|
|
3813
|
+
const rows = await readFresh(commands, { selector: { "payload.sessionId": sessionId, syncStatus: "pending" } });
|
|
3814
|
+
for (const row of rows) {
|
|
3815
|
+
await (await commands.findOne(row.key).exec(true)).incrementalModify((doc) => doc.syncStatus !== "pending" ? doc : {
|
|
3816
|
+
...doc,
|
|
3817
|
+
syncStatus: "rejected",
|
|
3818
|
+
error: { code: "register_session_abandoned", message: "The cashier chose another register." },
|
|
3819
|
+
updatedAt: now ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
3820
|
+
});
|
|
3821
|
+
}
|
|
3822
|
+
await (await sessions.findOne(sessionId).exec(true)).incrementalModify((doc) => doc.status === "conflict" ? { ...doc, status: "abandoned" } : doc);
|
|
3823
|
+
}
|
|
3824
|
+
async function abandonSession(input) {
|
|
3825
|
+
const { commands, sessions, sessionId } = input;
|
|
3826
|
+
const run = (chains2.get(commands) ?? Promise.resolve()).then(async () => {
|
|
3827
|
+
const [session] = await readFresh(sessions, { selector: { id: sessionId } });
|
|
3828
|
+
if (session?.status === "abandoned") return;
|
|
3829
|
+
if (session?.status !== "conflict") throw new RegisterSessionRequiredError();
|
|
3830
|
+
const [open] = await readFresh(commands, { selector: { key: `session.open:${sessionId}` } });
|
|
3831
|
+
if (open?.syncStatus === "pending") throw new RegisterTakeOverError("REGISTER_TAKEOVER_PENDING");
|
|
3832
|
+
await finishAbandon(input);
|
|
3833
|
+
});
|
|
3834
|
+
chains2.set(commands, run.then(() => void 0, () => void 0));
|
|
3835
|
+
return run;
|
|
3836
|
+
}
|
|
3837
|
+
|
|
3724
3838
|
// src/register/use-register-session.ts
|
|
3725
3839
|
var RegisterTenderInProgressError = class extends Error {
|
|
3726
3840
|
constructor() {
|
|
@@ -3761,7 +3875,7 @@ var reservationOf = (register, storeKey, registerId) => register?.stores[storeKe
|
|
|
3761
3875
|
var unwritten = (row, closureRows) => row.status === "closed" && row.closure_id === row.id && !closureRows.some((closure) => closure.id === row.id);
|
|
3762
3876
|
function currentSession(rows, closureRows, reservation) {
|
|
3763
3877
|
if (rows.some((row) => !isKnownSessionStatus(row.status))) return null;
|
|
3764
|
-
return (reservation && !reservation.applied ? rows.find((row) => row.id === reservation.row.session_id) : void 0) ?? rows.find((row) => row.status
|
|
3878
|
+
return (reservation && !reservation.applied ? rows.find((row) => row.id === reservation.row.session_id) : void 0) ?? rows.find((row) => row.status === "open" || row.status === "counting") ?? rows.find((row) => row.status === "conflict") ?? rows.find((row) => unwritten(row, closureRows)) ?? null;
|
|
3765
3879
|
}
|
|
3766
3880
|
function ledgerRows(orders) {
|
|
3767
3881
|
return orders.flatMap((order) => order.payments.map((payment) => ({
|
|
@@ -3777,8 +3891,8 @@ function useRegisterSession(options) {
|
|
|
3777
3891
|
const { commands } = options;
|
|
3778
3892
|
const commandEnabled = (options.capabilities?.register ?? 0) >= 1;
|
|
3779
3893
|
const commandTarget = useMemo3(
|
|
3780
|
-
() => enabled && commandEnabled && commands && sessions && movements && closures && register && registerId ? { commands, sessions, movements, closures, host: register, storeKey, registerId } : null,
|
|
3781
|
-
[enabled, commandEnabled, commands, sessions, movements, closures, register, storeKey, registerId]
|
|
3894
|
+
() => enabled && commandEnabled && commands && sessions && movements && closures && register && registerId ? { commands, sessions, movements, closures, host: register, storeKey, registerId, registerContract: options.capabilities?.register, deviceName: options.deviceName } : null,
|
|
3895
|
+
[enabled, commandEnabled, commands, sessions, movements, closures, register, storeKey, registerId, options.capabilities?.register, options.deviceName]
|
|
3782
3896
|
);
|
|
3783
3897
|
const commandTargetRef = useRef2(commandTarget);
|
|
3784
3898
|
commandTargetRef.current = commandTarget;
|
|
@@ -3793,6 +3907,13 @@ function useRegisterSession(options) {
|
|
|
3793
3907
|
});
|
|
3794
3908
|
};
|
|
3795
3909
|
useEffect3(reconcile, [commandTarget]);
|
|
3910
|
+
useEffect3(() => {
|
|
3911
|
+
if (!commandTarget) return;
|
|
3912
|
+
const subscription = commandTarget.commands.find({ selector: { registerId: commandTarget.registerId } }).$.subscribe(() => {
|
|
3913
|
+
void adoptRegisterResults(commandTarget);
|
|
3914
|
+
});
|
|
3915
|
+
return () => subscription.unsubscribe();
|
|
3916
|
+
}, [commandTarget]);
|
|
3796
3917
|
const source = useMemo3(() => {
|
|
3797
3918
|
if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) return null;
|
|
3798
3919
|
return combineLatest([
|
|
@@ -3804,10 +3925,11 @@ function useRegisterSession(options) {
|
|
|
3804
3925
|
if (!current2) return of({ rows, closureRows, reservation, entries: [], sales: [] });
|
|
3805
3926
|
return combineLatest([
|
|
3806
3927
|
watchFresh(movements, { selector: { session_id: current2.id } }),
|
|
3807
|
-
watchFresh(orders, { selector: { sessionId: current2.id } })
|
|
3808
|
-
|
|
3928
|
+
watchFresh(orders, { selector: { sessionId: current2.id } }),
|
|
3929
|
+
commandTarget ? watchFresh(commandTarget.commands, { selector: { key: `session.open:${current2.id}` } }) : of([])
|
|
3930
|
+
]).pipe(map5(([entries2, sales2, opens]) => ({ rows, closureRows, reservation, entries: entries2, sales: sales2, open: opens[0] })));
|
|
3809
3931
|
}));
|
|
3810
|
-
}, [enabled, sessions, movements, closures, orders, register, storeKey, registerId]);
|
|
3932
|
+
}, [enabled, sessions, movements, closures, orders, register, storeKey, registerId, commandTarget]);
|
|
3811
3933
|
const tender = useRef2(tenderInProgress);
|
|
3812
3934
|
tender.current = tenderInProgress;
|
|
3813
3935
|
const closing = useSyncExternalStore(subscribeClosing, () => closingByRegister.has(registerId ?? ""));
|
|
@@ -3862,6 +3984,8 @@ function useRegisterSession(options) {
|
|
|
3862
3984
|
}
|
|
3863
3985
|
}, [data, source]);
|
|
3864
3986
|
const session = data ? currentSession(data.rows, data.closureRows, data.reservation) : null;
|
|
3987
|
+
const refusal = data?.open?.error?.data;
|
|
3988
|
+
const conflict = session?.status !== "conflict" || !commandTarget ? void 0 : data?.open?.syncStatus === "pending" && typeof data.open.payload.supersedes === "string" ? { sessionId: data.open.payload.supersedes, takingOver: true } : data?.open?.syncStatus === "rejected" && refusal ? { sessionId: refusal.sessionId, openedAt: refusal.openedAt, openedBy: refusal.openedBy, deviceName: refusal.deviceName, takingOver: false } : void 0;
|
|
3865
3989
|
const entries = data?.entries ?? [];
|
|
3866
3990
|
const sales = data?.sales ?? [];
|
|
3867
3991
|
const expected = session ? deriveExpected({ session: { id: session.id, countedFloatMinor: session.counted_float_minor }, movements: entries, ledgerRowsBySession: ledgerRows(sales) }) : {};
|
|
@@ -3887,6 +4011,8 @@ function useRegisterSession(options) {
|
|
|
3887
4011
|
const nameOf = (id2) => !id2 ? "" : id2 === actor.id ? actor.name : labels?.resolveCashierName?.(id2) ?? "";
|
|
3888
4012
|
return {
|
|
3889
4013
|
session,
|
|
4014
|
+
/** The other till's live session, or the session this till is waiting to take over. */
|
|
4015
|
+
conflict,
|
|
3890
4016
|
/** Any unknown session status in the current snapshot means the register needs upgrade. */
|
|
3891
4017
|
needsUpgrade: data?.rows.some((row) => !isKnownSessionStatus(row.status)) ?? false,
|
|
3892
4018
|
movements: entries,
|
|
@@ -3901,17 +4027,39 @@ function useRegisterSession(options) {
|
|
|
3901
4027
|
enabled,
|
|
3902
4028
|
blind: options.blind ?? false,
|
|
3903
4029
|
/** Pass straight to useSale's `session` option: `complete()` then stamps through `stampSession`. */
|
|
3904
|
-
saleSession: session && sessions && session.status
|
|
4030
|
+
saleSession: session && sessions && (session.status === "open" || session.status === "counting") ? { id: session.id, sessions } : void 0,
|
|
3905
4031
|
/** The open session's id, `null` when sessions are off, else `RegisterSessionRequiredError`. Call it when tender starts and before a card terminal captures. */
|
|
3906
4032
|
requireOpen,
|
|
3907
4033
|
/** `requireOpen()`, returning `{ id, sessions }`: pass it to useSale's `startTender`, which pins it (the rendered `saleSession` can lag). */
|
|
3908
4034
|
requireSaleSession,
|
|
3909
4035
|
actions: {
|
|
4036
|
+
takeOver: async () => {
|
|
4037
|
+
if (!commandTarget) throw new RegisterSessionRequiredError();
|
|
4038
|
+
const row = currentSession(
|
|
4039
|
+
await readFresh(commandTarget.sessions, { selector: { register_id: commandTarget.registerId } }),
|
|
4040
|
+
await readFresh(commandTarget.closures, { selector: { register_id: commandTarget.registerId } }),
|
|
4041
|
+
reservationOf(await readRegister(commandTarget.host), commandTarget.storeKey, commandTarget.registerId)
|
|
4042
|
+
);
|
|
4043
|
+
if (row?.status !== "conflict") throw new RegisterSessionRequiredError();
|
|
4044
|
+
if ((options.capabilities?.register ?? 0) < 2) throw new RegisterTakeOverError("REGISTER_TAKEOVER_UNSUPPORTED");
|
|
4045
|
+
await takeOverSession({ ...commandTarget, sessionId: row.id });
|
|
4046
|
+
},
|
|
4047
|
+
chooseAnotherRegister: async () => {
|
|
4048
|
+
if (!commandTarget) throw new RegisterSessionRequiredError();
|
|
4049
|
+
const row = currentSession(
|
|
4050
|
+
await readFresh(commandTarget.sessions, { selector: { register_id: commandTarget.registerId } }),
|
|
4051
|
+
await readFresh(commandTarget.closures, { selector: { register_id: commandTarget.registerId } }),
|
|
4052
|
+
reservationOf(await readRegister(commandTarget.host), commandTarget.storeKey, commandTarget.registerId)
|
|
4053
|
+
);
|
|
4054
|
+
if (row?.status !== "conflict") throw new RegisterSessionRequiredError();
|
|
4055
|
+
await abandonSession({ ...commandTarget, sessionId: row.id });
|
|
4056
|
+
},
|
|
3910
4057
|
/**
|
|
3911
4058
|
* Refuses with `RegisterSessionAlreadyOpenError` while the register has a live session or
|
|
3912
4059
|
* another open is running (in any hook instance), and with `RegisterCloseIncompleteError`
|
|
3913
4060
|
* while an earlier close hasn't finished: a new session would lock the till behind it.
|
|
3914
4061
|
* An unknown stored status refuses with `RegisterNeedsUpgradeError`.
|
|
4062
|
+
* A conflict session refuses with `RegisterSessionConflictError`; superseded and abandoned sessions do not block.
|
|
3915
4063
|
*/
|
|
3916
4064
|
openSession: async (input) => {
|
|
3917
4065
|
const key = registerId ?? "";
|
|
@@ -3920,7 +4068,8 @@ function useRegisterSession(options) {
|
|
|
3920
4068
|
const { sessions: sessions2, closures: closures2, registerId: registerId2, register: register2 } = live();
|
|
3921
4069
|
const rows = await readFresh(sessions2, { selector: { register_id: registerId2 } });
|
|
3922
4070
|
if (rows.some((row2) => !isKnownSessionStatus(row2.status))) throw new RegisterNeedsUpgradeError();
|
|
3923
|
-
if (rows.some((row2) => row2.status
|
|
4071
|
+
if (rows.some((row2) => row2.status === "conflict")) throw new RegisterSessionConflictError();
|
|
4072
|
+
if (rows.some((row2) => row2.status === "open" || row2.status === "counting")) throw new RegisterSessionAlreadyOpenError();
|
|
3924
4073
|
const closureRows = await readFresh(closures2, { selector: { register_id: registerId2 } });
|
|
3925
4074
|
const reservation = reservationOf(await readRegister(register2), storeKey, registerId2);
|
|
3926
4075
|
if (reservation && !reservation.applied || rows.some((row2) => unwritten(row2, closureRows))) {
|
|
@@ -5203,7 +5352,7 @@ function plainCommand({
|
|
|
5203
5352
|
});
|
|
5204
5353
|
}
|
|
5205
5354
|
function createRegisterOutbox(options) {
|
|
5206
|
-
const { collection, transport, deviceId } = options;
|
|
5355
|
+
const { collection, sessions, transport, deviceId } = options;
|
|
5207
5356
|
const size = options.batchSize;
|
|
5208
5357
|
const batchSize = Math.max(1, Math.min(typeof size === "number" && Number.isFinite(size) ? Math.floor(size) : 10, 10));
|
|
5209
5358
|
const initialBackoff = options.initialBackoffMs ?? 1e3;
|
|
@@ -5223,6 +5372,7 @@ function createRegisterOutbox(options) {
|
|
|
5223
5372
|
let running;
|
|
5224
5373
|
let timer;
|
|
5225
5374
|
let subscription;
|
|
5375
|
+
let sessionsSubscription;
|
|
5226
5376
|
let stopped = false;
|
|
5227
5377
|
let insertedDuringRun = false;
|
|
5228
5378
|
let stoppedDuringRun = false;
|
|
@@ -5244,7 +5394,11 @@ function createRegisterOutbox(options) {
|
|
|
5244
5394
|
const at2 = now();
|
|
5245
5395
|
for (const { commandId } of commands) clocks.set(commandId, { ...clocks.get(commandId) ?? { since: at2, firstFailedAt: at2 }, seq: ++failureSeq, reason });
|
|
5246
5396
|
}
|
|
5397
|
+
function isNonBlocking(command, abandoned) {
|
|
5398
|
+
return NON_BLOCKING_REJECTIONS.has(command.error?.code ?? "") || command.error?.code === "register_session_already_open" && typeof command.payload.sessionId === "string" && abandoned.has(command.payload.sessionId);
|
|
5399
|
+
}
|
|
5247
5400
|
async function updateState(patch = {}) {
|
|
5401
|
+
const abandoned = new Set(sessions && clocks.size ? (await readFresh(sessions, { selector: { status: "abandoned" } })).map((session) => session.id) : []);
|
|
5248
5402
|
const pending = await countFresh(collection, { syncStatus: "pending" });
|
|
5249
5403
|
const sendable2 = /* @__PURE__ */ new Set();
|
|
5250
5404
|
const blocked = /* @__PURE__ */ new Set();
|
|
@@ -5253,7 +5407,7 @@ function createRegisterOutbox(options) {
|
|
|
5253
5407
|
sort: [{ seq: "asc" }, { key: "asc" }]
|
|
5254
5408
|
})) {
|
|
5255
5409
|
if (command.syncStatus === "rejected") {
|
|
5256
|
-
if (!
|
|
5410
|
+
if (!isNonBlocking(command, abandoned)) blocked.add(command.registerId);
|
|
5257
5411
|
} else if (!blocked.has(command.registerId)) sendable2.add(command.commandId);
|
|
5258
5412
|
}
|
|
5259
5413
|
for (const id2 of clocks.keys()) if (!sendable2.has(id2)) clocks.delete(id2);
|
|
@@ -5282,6 +5436,7 @@ function createRegisterOutbox(options) {
|
|
|
5282
5436
|
if (options.isEnabled?.() === false) return;
|
|
5283
5437
|
const pending = await readFresh(collection, { selector: { syncStatus: "pending" } });
|
|
5284
5438
|
const registerIds = [...new Set(pending.map((command) => command.registerId))].sort();
|
|
5439
|
+
const abandoned = new Set(sessions ? (await readFresh(sessions, { selector: { status: "abandoned" } })).map((session) => session.id) : []);
|
|
5285
5440
|
let sent = false;
|
|
5286
5441
|
for (const registerId of registerIds) {
|
|
5287
5442
|
const ledger = await readFresh(collection, {
|
|
@@ -5290,7 +5445,7 @@ function createRegisterOutbox(options) {
|
|
|
5290
5445
|
});
|
|
5291
5446
|
const commands = [];
|
|
5292
5447
|
for (const command of ledger) {
|
|
5293
|
-
if (command.syncStatus === "rejected" &&
|
|
5448
|
+
if (command.syncStatus === "rejected" && isNonBlocking(command, abandoned)) continue;
|
|
5294
5449
|
if (command.syncStatus === "rejected") break;
|
|
5295
5450
|
commands.push(command);
|
|
5296
5451
|
if (command.type === "register.session.open" || commands.length >= batchSize) break;
|
|
@@ -5426,6 +5581,13 @@ function createRegisterOutbox(options) {
|
|
|
5426
5581
|
});
|
|
5427
5582
|
}
|
|
5428
5583
|
});
|
|
5584
|
+
if (sessions && !sessionsSubscription) sessionsSubscription = sessions.$.subscribe((event) => {
|
|
5585
|
+
if (event.documentData?.status === "abandoned") {
|
|
5586
|
+
insertedDuringRun = true;
|
|
5587
|
+
flush().catch(() => {
|
|
5588
|
+
});
|
|
5589
|
+
}
|
|
5590
|
+
});
|
|
5429
5591
|
if (!stopped) flush().catch(() => {
|
|
5430
5592
|
});
|
|
5431
5593
|
},
|
|
@@ -5435,6 +5597,8 @@ function createRegisterOutbox(options) {
|
|
|
5435
5597
|
timer = void 0;
|
|
5436
5598
|
subscription?.unsubscribe();
|
|
5437
5599
|
subscription = void 0;
|
|
5600
|
+
sessionsSubscription?.unsubscribe();
|
|
5601
|
+
sessionsSubscription = void 0;
|
|
5438
5602
|
missingSubscription?.unsubscribe();
|
|
5439
5603
|
missingSubscription = void 0;
|
|
5440
5604
|
state$.next({ ...state$.value, nextAttemptAt: void 0 });
|
|
@@ -5446,7 +5610,7 @@ function createRegisterOutbox(options) {
|
|
|
5446
5610
|
import { useEffect as useEffect5, useRef as useRef4, useState as useState5 } from "react";
|
|
5447
5611
|
var idle = { pending: 0, sending: false };
|
|
5448
5612
|
function useRegisterOutbox(options) {
|
|
5449
|
-
const { commands, deviceId } = options;
|
|
5613
|
+
const { commands, sessions, deviceId } = options;
|
|
5450
5614
|
const latest = useRef4(options);
|
|
5451
5615
|
latest.current = options;
|
|
5452
5616
|
const current = useRef4(null);
|
|
@@ -5458,6 +5622,7 @@ function useRegisterOutbox(options) {
|
|
|
5458
5622
|
collection: commands,
|
|
5459
5623
|
deviceId,
|
|
5460
5624
|
transport: latest.current.transport(),
|
|
5625
|
+
sessions: sessions ?? void 0,
|
|
5461
5626
|
isEnabled: latest.current.isEnabled ? () => latest.current.isEnabled?.() ?? true : void 0,
|
|
5462
5627
|
onResult: latest.current.onResult ? async (command, result) => {
|
|
5463
5628
|
await latest.current.onResult?.(command, result);
|
|
@@ -5473,7 +5638,7 @@ function useRegisterOutbox(options) {
|
|
|
5473
5638
|
current.current = null;
|
|
5474
5639
|
setState(idle);
|
|
5475
5640
|
};
|
|
5476
|
-
}, [commands, deviceId]);
|
|
5641
|
+
}, [commands, sessions, deviceId]);
|
|
5477
5642
|
const opened = current.current;
|
|
5478
5643
|
return {
|
|
5479
5644
|
state: opened?.commands === commands && opened.deviceId === deviceId ? state : idle,
|
|
@@ -5860,17 +6025,21 @@ export {
|
|
|
5860
6025
|
RegisterNeedsUpgradeError,
|
|
5861
6026
|
RegisterSessionAlreadyOpenError,
|
|
5862
6027
|
RegisterSessionClosedError,
|
|
6028
|
+
RegisterSessionConflictError,
|
|
5863
6029
|
RegisterSessionOpenClosedError,
|
|
5864
6030
|
RegisterSessionRequiredError,
|
|
6031
|
+
RegisterTakeOverError,
|
|
5865
6032
|
RegisterTenderInProgressError,
|
|
5866
6033
|
SALE_SAVING,
|
|
5867
6034
|
TAX_ROUNDING_MIXED_NOTE,
|
|
5868
6035
|
TaxProvider,
|
|
5869
6036
|
UnsupportedOrderVersionError,
|
|
6037
|
+
abandonSession,
|
|
5870
6038
|
activePlan,
|
|
5871
6039
|
addEntryToCart,
|
|
5872
6040
|
addPosOrderCollection,
|
|
5873
6041
|
addRegisterSessionCollection,
|
|
6042
|
+
adoptRegisterResults,
|
|
5874
6043
|
advancePerpetual,
|
|
5875
6044
|
allocateOrderDiscount,
|
|
5876
6045
|
appliedMinor,
|
|
@@ -5975,6 +6144,7 @@ export {
|
|
|
5975
6144
|
startCounting,
|
|
5976
6145
|
stockOverlay$,
|
|
5977
6146
|
stockOverlayAsOf$,
|
|
6147
|
+
takeOverSession,
|
|
5978
6148
|
taxFiguresForBasket,
|
|
5979
6149
|
taxLinesByRate,
|
|
5980
6150
|
taxLogger,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/pos",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared POS business logic for Tally UI — tax, orders, payments, discounts, receipts",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -27,20 +27,20 @@
|
|
|
27
27
|
"directory": "packages/pos"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@tallyui/core": "^3.
|
|
30
|
+
"@tallyui/core": "^3.8.0",
|
|
31
31
|
"react": ">=18",
|
|
32
32
|
"rxdb": "~17.5.0",
|
|
33
33
|
"rxjs": ">=7"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@tallyui/core": "3.
|
|
36
|
+
"@tallyui/core": "3.8.0",
|
|
37
37
|
"@types/react": ">=18",
|
|
38
38
|
"react": "^19.2.4",
|
|
39
39
|
"rxdb": "17.5.0",
|
|
40
40
|
"rxjs": "7.8.2",
|
|
41
|
-
"@tallyui/connector-medusa": "3.
|
|
42
|
-
"@tallyui/connector-woocommerce": "3.
|
|
43
|
-
"@tallyui/database": "3.
|
|
41
|
+
"@tallyui/connector-medusa": "3.8.0",
|
|
42
|
+
"@tallyui/connector-woocommerce": "3.8.0",
|
|
43
|
+
"@tallyui/database": "3.8.0"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
46
|
"build": "tsup",
|
package/src/index.ts
CHANGED
|
@@ -78,11 +78,11 @@ export type { MovementType, LedgerRow, Movement } from './register';
|
|
|
78
78
|
export { registerSessionSchema, registerSessionCollection, cashMovementSchema, closureSchema } from './register';
|
|
79
79
|
export { addRegisterSessionCollection, RegisterSessionOpenClosedError, registerSessionsLogger, REGISTER_SESSION_MIGRATION_CLOSE_WAIT_MS } from './register';
|
|
80
80
|
export type { RegisterSession, CashMovement, Closure } from './register';
|
|
81
|
-
export { registerCommandSchema, registerCommandCollection, registerCommandsLogger, sessionOpenCommand, sessionTransitionCommand, movementCommand, closureCommand, reconcileRegisterCommands } from './register';
|
|
81
|
+
export { registerCommandSchema, registerCommandCollection, registerCommandsLogger, sessionOpenCommand, sessionTransitionCommand, movementCommand, closureCommand, reconcileRegisterCommands, adoptRegisterResults, takeOverSession, abandonSession } from './register';
|
|
82
82
|
export type { RegisterCommand, RegisterCommandCollection } from './register';
|
|
83
83
|
export { mintUuid, readRegister, ensureRegister, observeRegister$, getBoundRegisterId, readBoundRegister, bindRegister, unbindRegister, nextSaleCounter, mintClosureNumber, advancePerpetual, RegisterIdInvalidError } from './register';
|
|
84
84
|
export type { RegisterHost, RegisterCounters, RegisterBucket, RegisterStore, RegisterDocument } from './register';
|
|
85
|
-
export { RegisterSessionRequiredError, RegisterSessionClosedError, RegisterNeedsUpgradeError, isKnownSessionStatus, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector, requireOpenSession, stampSession, openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, writeClosure } from './register';
|
|
85
|
+
export { RegisterSessionRequiredError, RegisterSessionClosedError, RegisterNeedsUpgradeError, RegisterSessionConflictError, RegisterTakeOverError, isKnownSessionStatus, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector, requireOpenSession, stampSession, openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, writeClosure } from './register';
|
|
86
86
|
export type { RegisterSessionCollection, CashMovementCollection, ClosureCollection } from './register';
|
|
87
87
|
export { deriveSettled, exportCsv, labelKeys, clampClosureScope, selectClosureRows } from './register';
|
|
88
88
|
export type { Correction, RecordedFigures, ClosureScope } from './register';
|
|
@@ -2,6 +2,7 @@ import type { CommandResult, RegisterCommandEnvelope } from '@tallyui/core';
|
|
|
2
2
|
import type { RxCollection } from 'rxdb';
|
|
3
3
|
import { BehaviorSubject, type Observable, type Subscription } from 'rxjs';
|
|
4
4
|
import { registerCommandsLogger, type RegisterCommand } from '../register/register-commands';
|
|
5
|
+
import type { RegisterSessionCollection } from '../register/session-store';
|
|
5
6
|
import { countFresh, readFresh } from '../rxdb';
|
|
6
7
|
import { createBackendNotFound, type BackendNotFound } from './backend-not-found';
|
|
7
8
|
import { STUCK_AFTER_MS } from './order-outbox';
|
|
@@ -12,6 +13,9 @@ export const NON_BLOCKING_REJECTIONS: ReadonlySet<string> = new Set(['register_s
|
|
|
12
13
|
|
|
13
14
|
export interface RegisterOutboxOptions {
|
|
14
15
|
collection: RxCollection<RegisterCommand>;
|
|
16
|
+
/** The till's `register_sessions`. With it, a refused open (`register_session_already_open`) stops blocking its register
|
|
17
|
+
* once its session is `abandoned` (ADR-078 decision 9). Without it, that refusal always blocks. */
|
|
18
|
+
sessions?: RegisterSessionCollection;
|
|
15
19
|
transport: CommandTransport<RegisterCommandEnvelope>;
|
|
16
20
|
deviceId: string;
|
|
17
21
|
/** Checked at the start of every run; false sends nothing. */
|
|
@@ -42,7 +46,7 @@ function plainCommand({ key, registerId, seq, commandId, type, version, payload,
|
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOutbox {
|
|
45
|
-
const { collection, transport, deviceId } = options;
|
|
49
|
+
const { collection, sessions, transport, deviceId } = options;
|
|
46
50
|
const size = options.batchSize;
|
|
47
51
|
const batchSize = Math.max(1, Math.min(typeof size === 'number' && Number.isFinite(size) ? Math.floor(size) : 10, 10));
|
|
48
52
|
const initialBackoff = options.initialBackoffMs ?? 1000;
|
|
@@ -62,6 +66,7 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
|
|
|
62
66
|
let running: Promise<void> | undefined;
|
|
63
67
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
64
68
|
let subscription: Subscription | undefined;
|
|
69
|
+
let sessionsSubscription: Subscription | undefined;
|
|
65
70
|
let stopped = false;
|
|
66
71
|
let insertedDuringRun = false;
|
|
67
72
|
let stoppedDuringRun = false;
|
|
@@ -92,7 +97,15 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
|
|
|
92
97
|
for (const { commandId } of commands) clocks.set(commandId, { ...(clocks.get(commandId) ?? { since: at, firstFailedAt: at }), seq: ++failureSeq, reason });
|
|
93
98
|
}
|
|
94
99
|
|
|
100
|
+
function isNonBlocking(command: RegisterCommand, abandoned: ReadonlySet<string>): boolean {
|
|
101
|
+
return NON_BLOCKING_REJECTIONS.has(command.error?.code ?? '') ||
|
|
102
|
+
(command.error?.code === 'register_session_already_open' &&
|
|
103
|
+
typeof command.payload.sessionId === 'string' && abandoned.has(command.payload.sessionId));
|
|
104
|
+
}
|
|
105
|
+
|
|
95
106
|
async function updateState(patch: Partial<OutboxState> = {}) {
|
|
107
|
+
const abandoned = new Set(sessions && clocks.size
|
|
108
|
+
? (await readFresh(sessions, { selector: { status: 'abandoned' } })).map((session) => session.id) : []);
|
|
96
109
|
const pending = await countFresh(collection, { syncStatus: 'pending' });
|
|
97
110
|
// Only commands in a register's sendable prefix (pending, before its first rejected command) keep a clock.
|
|
98
111
|
const sendable = new Set<string>();
|
|
@@ -100,7 +113,7 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
|
|
|
100
113
|
if (clocks.size) for (const command of await readFresh(collection, { selector: { syncStatus: { $in: ['pending', 'rejected'] } },
|
|
101
114
|
sort: [{ seq: 'asc' }, { key: 'asc' }] })) {
|
|
102
115
|
if (command.syncStatus === 'rejected') {
|
|
103
|
-
if (!
|
|
116
|
+
if (!isNonBlocking(command, abandoned)) blocked.add(command.registerId);
|
|
104
117
|
} else if (!blocked.has(command.registerId)) sendable.add(command.commandId);
|
|
105
118
|
}
|
|
106
119
|
for (const id of clocks.keys()) if (!sendable.has(id)) clocks.delete(id);
|
|
@@ -123,6 +136,8 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
|
|
|
123
136
|
if (options.isEnabled?.() === false) return;
|
|
124
137
|
const pending = await readFresh(collection, { selector: { syncStatus: 'pending' } });
|
|
125
138
|
const registerIds = [...new Set(pending.map((command) => command.registerId))].sort();
|
|
139
|
+
const abandoned = new Set(sessions
|
|
140
|
+
? (await readFresh(sessions, { selector: { status: 'abandoned' } })).map((session) => session.id) : []);
|
|
126
141
|
let sent = false;
|
|
127
142
|
for (const registerId of registerIds) {
|
|
128
143
|
const ledger = await readFresh(collection, {
|
|
@@ -131,7 +146,7 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
|
|
|
131
146
|
});
|
|
132
147
|
const commands: RegisterCommand[] = [];
|
|
133
148
|
for (const command of ledger) {
|
|
134
|
-
if (command.syncStatus === 'rejected' &&
|
|
149
|
+
if (command.syncStatus === 'rejected' && isNonBlocking(command, abandoned)) continue;
|
|
135
150
|
if (command.syncStatus === 'rejected') break;
|
|
136
151
|
commands.push(command);
|
|
137
152
|
if (command.type === 'register.session.open' || commands.length >= batchSize) break;
|
|
@@ -241,6 +256,12 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
|
|
|
241
256
|
flush().catch(() => {});
|
|
242
257
|
}
|
|
243
258
|
});
|
|
259
|
+
if (sessions && !sessionsSubscription) sessionsSubscription = sessions.$.subscribe((event) => {
|
|
260
|
+
if (event.documentData?.status === 'abandoned') {
|
|
261
|
+
insertedDuringRun = true;
|
|
262
|
+
flush().catch(() => {});
|
|
263
|
+
}
|
|
264
|
+
});
|
|
244
265
|
if (!stopped) flush().catch(() => {});
|
|
245
266
|
},
|
|
246
267
|
stop() {
|
|
@@ -249,6 +270,8 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
|
|
|
249
270
|
timer = undefined;
|
|
250
271
|
subscription?.unsubscribe();
|
|
251
272
|
subscription = undefined;
|
|
273
|
+
sessionsSubscription?.unsubscribe();
|
|
274
|
+
sessionsSubscription = undefined;
|
|
252
275
|
missingSubscription?.unsubscribe(); missingSubscription = undefined;
|
|
253
276
|
state$.next({ ...state$.value, nextAttemptAt: undefined });
|
|
254
277
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { RegisterCommandEnvelope } from '@tallyui/core';
|
|
2
2
|
import { useEffect, useRef, useState } from 'react';
|
|
3
3
|
import type { RegisterCommandCollection } from '../register/register-commands';
|
|
4
|
+
import type { RegisterSessionCollection } from '../register/session-store';
|
|
4
5
|
import { createRegisterOutbox, type RegisterOutboxOptions } from './register-outbox';
|
|
5
6
|
import type { CommandTransport, OutboxState } from './types';
|
|
6
7
|
|
|
@@ -9,6 +10,8 @@ const idle: OutboxState = { pending: 0, sending: false };
|
|
|
9
10
|
export interface UseRegisterOutboxOptions {
|
|
10
11
|
/** `register_commands` (see `registerCommandCollection()`); `null` while it opens or when there's no store. A new collection (a store switch) restarts the outbox. */
|
|
11
12
|
commands: RegisterCommandCollection | null;
|
|
13
|
+
/** The till's `register_sessions`; abandoned sessions unblock refused opens. A new collection restarts the outbox. */
|
|
14
|
+
sessions?: RegisterSessionCollection | null;
|
|
12
15
|
/** Builds the transport; called when the outbox is created: on each new collection or device id (twice under React StrictMode's double effect, which stops the first outbox). */
|
|
13
16
|
transport(): CommandTransport<RegisterCommandEnvelope>;
|
|
14
17
|
/** The device id sent on every command. A change restarts the outbox. */
|
|
@@ -27,7 +30,7 @@ export interface UseRegisterOutboxResult {
|
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export function useRegisterOutbox(options: UseRegisterOutboxOptions): UseRegisterOutboxResult {
|
|
30
|
-
const { commands, deviceId } = options;
|
|
33
|
+
const { commands, sessions, deviceId } = options;
|
|
31
34
|
const latest = useRef(options);
|
|
32
35
|
latest.current = options;
|
|
33
36
|
const current = useRef<{ commands: RegisterCommandCollection; deviceId: string; outbox: ReturnType<typeof createRegisterOutbox> } | null>(null);
|
|
@@ -37,6 +40,7 @@ export function useRegisterOutbox(options: UseRegisterOutboxOptions): UseRegiste
|
|
|
37
40
|
setState(idle);
|
|
38
41
|
if (!commands) return;
|
|
39
42
|
const outbox = createRegisterOutbox({ collection: commands, deviceId, transport: latest.current.transport(),
|
|
43
|
+
sessions: sessions ?? undefined,
|
|
40
44
|
isEnabled: latest.current.isEnabled ? () => latest.current.isEnabled?.() ?? true : undefined,
|
|
41
45
|
onResult: latest.current.onResult ? async (command, result) => { await latest.current.onResult?.(command, result); } : undefined,
|
|
42
46
|
backendNotFound: latest.current.backendNotFound });
|
|
@@ -44,7 +48,7 @@ export function useRegisterOutbox(options: UseRegisterOutboxOptions): UseRegiste
|
|
|
44
48
|
const status = outbox.state$.subscribe(setState);
|
|
45
49
|
outbox.start();
|
|
46
50
|
return () => { outbox.stop(); status.unsubscribe(); current.current = null; setState(idle); };
|
|
47
|
-
}, [commands, deviceId]);
|
|
51
|
+
}, [commands, sessions, deviceId]);
|
|
48
52
|
|
|
49
53
|
const opened = current.current;
|
|
50
54
|
return { state: opened?.commands === commands && opened.deviceId === deviceId ? state : idle,
|
package/src/register/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ export {
|
|
|
13
13
|
} from './register-document';
|
|
14
14
|
export type { RegisterHost, RegisterCounters, RegisterBucket, RegisterStore, RegisterDocument } from './register-document';
|
|
15
15
|
export {
|
|
16
|
-
RegisterSessionRequiredError, RegisterSessionClosedError, RegisterNeedsUpgradeError, isKnownSessionStatus, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector,
|
|
16
|
+
RegisterSessionRequiredError, RegisterSessionClosedError, RegisterNeedsUpgradeError, RegisterSessionConflictError, RegisterTakeOverError, isKnownSessionStatus, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector,
|
|
17
17
|
requireOpenSession, stampSession, openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, writeClosure,
|
|
18
18
|
} from './session-store';
|
|
19
19
|
export type { RegisterSessionCollection, CashMovementCollection, ClosureCollection } from './session-store';
|
|
@@ -30,4 +30,5 @@ export { registerFactsLogger, recordRegisterFact, type Actor, type RegisterFact
|
|
|
30
30
|
export { useRegisterSession, RegisterTenderInProgressError, RegisterSessionAlreadyOpenError, RegisterCloseIncompleteError, RegisterApprovalRequiredError } from './use-register-session';
|
|
31
31
|
export type { UseRegisterSessionOptions } from './use-register-session';
|
|
32
32
|
export { registerCommandSchema, registerCommandCollection, registerCommandsLogger, sessionOpenCommand, sessionTransitionCommand, movementCommand, closureCommand, reconcileRegisterCommands } from './register-commands';
|
|
33
|
+
export { adoptRegisterResults, takeOverSession, abandonSession } from './register-adoption';
|
|
33
34
|
export type { RegisterCommand, RegisterCommandCollection } from './register-commands';
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readFresh } from '../rxdb';
|
|
2
|
+
import { uuidv7 } from '../pos-order/uuidv7';
|
|
3
|
+
import { registerCommandsLogger, type RegisterCommandCollection } from './register-commands';
|
|
4
|
+
import type { RegisterSession } from './schemas';
|
|
5
|
+
import { isKnownSessionStatus, RegisterSessionRequiredError, RegisterTakeOverError, type RegisterSessionCollection } from './session-store';
|
|
6
|
+
|
|
7
|
+
const chains = new WeakMap<RegisterCommandCollection, Promise<void>>();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Applied opens fill a missing resume id; refused opens make open/counting sessions conflict.
|
|
11
|
+
* Superseded rejections make open/counting sessions terminal; a conflict session leaves only by an applied open or abandon (ADR-078 decision 9); terminal/unknown states stay untouched.
|
|
12
|
+
* Applied conflict opens restore the latest transition's counting state, otherwise open; pending rows do nothing.
|
|
13
|
+
* Answers are walked in seq/key order; adoption is idempotent and safe to run at any time.
|
|
14
|
+
*/
|
|
15
|
+
export async function adoptRegisterResults({ commands, sessions, registerId }: {
|
|
16
|
+
commands: RegisterCommandCollection; sessions: RegisterSessionCollection; registerId: string;
|
|
17
|
+
}): Promise<void> {
|
|
18
|
+
const run = (chains.get(commands) ?? Promise.resolve()).then(async () => {
|
|
19
|
+
const [answers, transitions, rows] = await Promise.all([
|
|
20
|
+
readFresh(commands, { selector: { registerId, syncStatus: { $in: ['applied', 'rejected'] } }, sort: [{ seq: 'asc' }, { key: 'asc' }] }),
|
|
21
|
+
readFresh(commands, { selector: { registerId, type: 'register.session.transition' }, sort: [{ seq: 'desc' }, { key: 'desc' }] }),
|
|
22
|
+
readFresh(sessions, { selector: { register_id: registerId } }),
|
|
23
|
+
]);
|
|
24
|
+
for (const session of rows) {
|
|
25
|
+
if (session.status === 'conflict' && answers.some((answer) => answer.payload.sessionId === session.id
|
|
26
|
+
&& answer.syncStatus === 'rejected' && answer.error?.code === 'register_session_abandoned')) {
|
|
27
|
+
await finishAbandon({ commands, sessions, sessionId: session.id });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const latest = transitions.find(({ payload }) => payload.sessionId === session.id);
|
|
31
|
+
const modify = (doc: RegisterSession): RegisterSession => {
|
|
32
|
+
let next = doc;
|
|
33
|
+
for (const answer of answers) {
|
|
34
|
+
if (!isKnownSessionStatus(next.status) || next.status === 'closed' || next.status === 'superseded' || next.status === 'abandoned') break;
|
|
35
|
+
if (answer.payload.sessionId !== doc.id) continue;
|
|
36
|
+
if (answer.syncStatus === 'rejected') {
|
|
37
|
+
if (answer.error?.code === 'register_session_superseded'
|
|
38
|
+
&& (next.status === 'open' || next.status === 'counting')) next = { ...next, status: 'superseded' };
|
|
39
|
+
else if (answer.type === 'register.session.open' && answer.error?.code === 'register_session_already_open'
|
|
40
|
+
&& (next.status === 'open' || next.status === 'counting')) next = { ...next, status: 'conflict' };
|
|
41
|
+
} else if (answer.type === 'register.session.open') {
|
|
42
|
+
const resumed = answer.result?.resumed?.fromSessionId;
|
|
43
|
+
if (typeof resumed === 'string' && next.server_session_id == null) next = { ...next, server_session_id: resumed };
|
|
44
|
+
if (next.status === 'conflict') next = { ...next, status: latest?.payload.status === 'counting' ? 'counting' : 'open' };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return next;
|
|
48
|
+
};
|
|
49
|
+
if (modify(session) === session) continue;
|
|
50
|
+
const row = await sessions.findOne(session.id).exec();
|
|
51
|
+
if (row) await row.incrementalModify(modify);
|
|
52
|
+
}
|
|
53
|
+
}).catch((error: unknown) => {
|
|
54
|
+
registerCommandsLogger.error('Register result adoption failed', { context: { registerId, error: String(error) } });
|
|
55
|
+
});
|
|
56
|
+
chains.set(commands, run.then(() => undefined, () => undefined));
|
|
57
|
+
return run;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function takeOverSession({ commands, sessions, sessionId, now }: {
|
|
61
|
+
commands: RegisterCommandCollection; sessions: RegisterSessionCollection; sessionId: string; now?: string;
|
|
62
|
+
}): Promise<void> {
|
|
63
|
+
const run = (chains.get(commands) ?? Promise.resolve()).then(async () => {
|
|
64
|
+
const [session] = await readFresh(sessions, { selector: { id: sessionId } });
|
|
65
|
+
const row = await commands.findOne(`session.open:${sessionId}`).exec();
|
|
66
|
+
if (session?.status !== 'conflict' || !row) throw new RegisterSessionRequiredError();
|
|
67
|
+
await row.incrementalModify((doc) => {
|
|
68
|
+
if (doc.syncStatus !== 'rejected' || doc.error?.code !== 'register_session_already_open'
|
|
69
|
+
|| typeof doc.error.data?.sessionId !== 'string') throw new RegisterSessionRequiredError();
|
|
70
|
+
const { error, ...rest } = doc;
|
|
71
|
+
return { ...rest, commandId: uuidv7(), version: 2, syncStatus: 'pending',
|
|
72
|
+
payload: { ...doc.payload, supersedes: error.data!.sessionId }, updatedAt: now ?? new Date().toISOString() };
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
chains.set(commands, run.then(() => undefined, () => undefined));
|
|
76
|
+
return run;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function finishAbandon({ commands, sessions, sessionId, now }: Parameters<typeof takeOverSession>[0]) {
|
|
80
|
+
const rows = await readFresh(commands, { selector: { 'payload.sessionId': sessionId, syncStatus: 'pending' } });
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
await (await commands.findOne(row.key).exec(true)).incrementalModify((doc) => doc.syncStatus !== 'pending' ? doc : ({
|
|
83
|
+
...doc, syncStatus: 'rejected', error: { code: 'register_session_abandoned', message: 'The cashier chose another register.' },
|
|
84
|
+
updatedAt: now ?? new Date().toISOString(),
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
await (await sessions.findOne(sessionId).exec(true)).incrementalModify((doc) => doc.status === 'conflict' ? { ...doc, status: 'abandoned' } : doc);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function abandonSession(input: Parameters<typeof takeOverSession>[0]): Promise<void> {
|
|
91
|
+
const { commands, sessions, sessionId } = input;
|
|
92
|
+
const run = (chains.get(commands) ?? Promise.resolve()).then(async () => {
|
|
93
|
+
const [session] = await readFresh(sessions, { selector: { id: sessionId } });
|
|
94
|
+
if (session?.status === 'abandoned') return;
|
|
95
|
+
if (session?.status !== 'conflict') throw new RegisterSessionRequiredError();
|
|
96
|
+
const [open] = await readFresh(commands, { selector: { key: `session.open:${sessionId}` } });
|
|
97
|
+
if (open?.syncStatus === 'pending') throw new RegisterTakeOverError('REGISTER_TAKEOVER_PENDING');
|
|
98
|
+
await finishAbandon(input);
|
|
99
|
+
});
|
|
100
|
+
chains.set(commands, run.then(() => undefined, () => undefined));
|
|
101
|
+
return run;
|
|
102
|
+
}
|
|
@@ -38,16 +38,28 @@ export const registerCommandSchema: RxJsonSchema<RegisterCommand> = {
|
|
|
38
38
|
};
|
|
39
39
|
export const registerCommandCollection = () => ({ schema: registerCommandSchema });
|
|
40
40
|
|
|
41
|
-
type BuiltCommand = Pick<RegisterCommand, 'key' | 'type' | 'payload'> & { version: 1 };
|
|
41
|
+
type BuiltCommand = Pick<RegisterCommand, 'key' | 'type' | 'payload'> & { version: 1 | 2 };
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
/** ADR-078: 1 to 64 characters after trim. */
|
|
44
|
+
export function deviceNameOf(name: string | null | undefined): string | undefined {
|
|
45
|
+
let trimmed = name?.trim();
|
|
46
|
+
if (trimmed && trimmed.length > 64) {
|
|
47
|
+
const last = trimmed.charCodeAt(63);
|
|
48
|
+
trimmed = trimmed.slice(0, last >= 0xd800 && last <= 0xdbff ? 63 : 64).trimEnd();
|
|
49
|
+
}
|
|
50
|
+
return trimmed || undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function sessionOpenCommand(s: RegisterSession, v2?: { deviceName?: string | null }): BuiltCommand {
|
|
54
|
+
const deviceName = deviceNameOf(v2?.deviceName);
|
|
55
|
+
return { key: `session.open:${s.id}`, type: 'register.session.open', version: v2 ? 2 : 1, payload: {
|
|
45
56
|
sessionId: s.id, registerId: s.register_id, openedAt: s.opened_at_gmt, countedFloatMinor: s.counted_float_minor,
|
|
46
57
|
...(s.store_key == null ? {} : { storeKey: s.store_key }),
|
|
47
58
|
...(s.business_day == null ? {} : { businessDay: s.business_day }),
|
|
48
59
|
...(s.opened_by == null ? {} : { openedBy: s.opened_by }),
|
|
49
60
|
...(s.expected_float_minor == null ? {} : { expectedFloatMinor: s.expected_float_minor }),
|
|
50
61
|
...(s.opening_variance_minor == null ? {} : { openingVarianceMinor: s.opening_variance_minor }),
|
|
62
|
+
...(deviceName === undefined ? {} : { deviceName }),
|
|
51
63
|
} satisfies RegisterSessionOpenPayload };
|
|
52
64
|
}
|
|
53
65
|
|
|
@@ -88,9 +100,10 @@ export function closureCommand(c: Closure): BuiltCommand {
|
|
|
88
100
|
const chains = new WeakMap<RegisterCommandCollection, Map<string, Promise<void>>>();
|
|
89
101
|
|
|
90
102
|
/** Appends missing facts in dependency order; the bytes of an existing key never change. */
|
|
91
|
-
export function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed }: {
|
|
103
|
+
export function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed, registerContract, deviceName }: {
|
|
92
104
|
commands: RegisterCommandCollection; sessions: RegisterSessionCollection; movements: CashMovementCollection;
|
|
93
105
|
closures: ClosureCollection; host: RegisterHost; storeKey: string; registerId: string; now?: string; observed?: RegisterSession[];
|
|
106
|
+
registerContract?: number; deviceName?: string | null;
|
|
94
107
|
}): Promise<string[]> {
|
|
95
108
|
// `conflict` and `superseded` are local states derived from the store's answers (ADR-078 decision 9), never sent as a transition.
|
|
96
109
|
const sentStatus = (s: RegisterSession): s is RegisterSession & { status: RegisterSessionTransitionPayload['status'] } => s.status === 'open' || s.status === 'counting' || s.status === 'closed';
|
|
@@ -106,8 +119,8 @@ export function reconcileRegisterCommands({ commands, sessions, movements, closu
|
|
|
106
119
|
const complete = new Set((await commands.storageInstance.findDocumentsById(rows.flatMap((session) =>
|
|
107
120
|
[`session.open:${session.id}`, ...(session.status === 'closed' && session.closure_id ? [`closure.submit:${session.closure_id}`] : [])]), false)).map(({ key }) => key));
|
|
108
121
|
const facts = await Promise.all(rows.filter((session) =>
|
|
109
|
-
session.status !== 'closed' || ((session.status_at == null || session.status_at >= since || complete.has(`session.open:${session.id}`))
|
|
110
|
-
&& !complete.has(`closure.submit:${session.closure_id}`)))
|
|
122
|
+
session.status !== 'abandoned' && (session.status !== 'closed' || ((session.status_at == null || session.status_at >= since || complete.has(`session.open:${session.id}`))
|
|
123
|
+
&& !complete.has(`closure.submit:${session.closure_id}`))))
|
|
111
124
|
.map(async (session) => {
|
|
112
125
|
const [entries, closureRows] = await Promise.all([
|
|
113
126
|
readFresh(movements, { selector: { session_id: session.id } }),
|
|
@@ -119,7 +132,7 @@ export function reconcileRegisterCommands({ commands, sessions, movements, closu
|
|
|
119
132
|
events.push(...entries.filter((entry) => entry.type !== 'void').map(movementCommand),
|
|
120
133
|
...entries.filter((entry) => entry.type === 'void').map(movementCommand));
|
|
121
134
|
if (session.status_at != null && sentStatus(session)) events.push(sessionTransitionCommand({ ...session, status_at: session.status_at }));
|
|
122
|
-
return { session, number: closureRows[0]?.number, built: [sessionOpenCommand(session), ...events,
|
|
135
|
+
return { session, number: closureRows[0]?.number, built: [(registerContract ?? 0) >= 2 ? sessionOpenCommand(session, { deviceName }) : sessionOpenCommand(session), ...events,
|
|
123
136
|
...closureRows.map((closure) => closureCommand(closure))] };
|
|
124
137
|
}));
|
|
125
138
|
facts.sort((a, b) => Number(a.session.status !== 'closed') - Number(b.session.status !== 'closed')
|
package/src/register/schemas.ts
CHANGED
|
@@ -24,8 +24,8 @@ export interface RegisterSession {
|
|
|
24
24
|
register_id: string;
|
|
25
25
|
/** The app's neutral store key (see `bindRegister`). */
|
|
26
26
|
store_key?: string | null;
|
|
27
|
-
/** Stored status is an open string so a later local state costs no migration (ADR-078 decision 9); the union names today's states. */
|
|
28
|
-
status: 'open' | 'counting' | 'closed' | 'conflict' | 'superseded';
|
|
27
|
+
/** Stored status is an open string so a later local state costs no migration (ADR-078 decision 9); the union names today's states. `abandoned` is local only and terminal (the cashier chose another register while in conflict). */
|
|
28
|
+
status: 'open' | 'counting' | 'closed' | 'conflict' | 'superseded' | 'abandoned';
|
|
29
29
|
/** The store session this local id was resumed as (ADR-078 decision 2), never the session that superseded it; null until a resume. */
|
|
30
30
|
server_session_id?: string | null;
|
|
31
31
|
/** The store's day the session opened, `yyyy-MM-dd`. */
|
|
@@ -49,6 +49,24 @@ export class RegisterNeedsUpgradeError extends Error {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** Opening is refused while the register has a `conflict` session (ADR-078); its message can be shown to a cashier as it is. */
|
|
53
|
+
export class RegisterSessionConflictError extends Error {
|
|
54
|
+
readonly code = 'REGISTER_SESSION_CONFLICT';
|
|
55
|
+
constructor() {
|
|
56
|
+
super('This register is open on another till. Take it over or choose another register.');
|
|
57
|
+
this.name = 'RegisterSessionConflictError';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class RegisterTakeOverError extends Error {
|
|
62
|
+
constructor(readonly code: 'REGISTER_TAKEOVER_UNSUPPORTED' | 'REGISTER_TAKEOVER_PENDING') {
|
|
63
|
+
super(code === 'REGISTER_TAKEOVER_UNSUPPORTED'
|
|
64
|
+
? 'This store cannot take over a register. Choose another register.'
|
|
65
|
+
: 'Taking over this register is still waiting for the store.');
|
|
66
|
+
this.name = 'RegisterTakeOverError';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
/** ADR-068 6a: movement amounts must be safe integers, positive for paid_in/paid_out and zero for no_sale. */
|
|
53
71
|
export class RegisterMovementAmountError extends Error {
|
|
54
72
|
constructor(type: 'paid_in' | 'paid_out' | 'no_sale', amountMinor: number) {
|
|
@@ -80,7 +98,7 @@ export class RegisterMovementStrandedError extends Error {
|
|
|
80
98
|
}
|
|
81
99
|
}
|
|
82
100
|
|
|
83
|
-
/** Every move a session may make.
|
|
101
|
+
/** Every move a session may make. `closed` and `abandoned` are terminal: nothing leaves them; everything else is refused.
|
|
84
102
|
* No cashier move leaves `conflict` or `superseded`; the store's answers set them (ADR-078). */
|
|
85
103
|
const TRANSITIONS: Record<RegisterSession['status'], readonly RegisterSession['status'][]> = {
|
|
86
104
|
open: ['counting', 'closed'],
|
|
@@ -88,6 +106,7 @@ const TRANSITIONS: Record<RegisterSession['status'], readonly RegisterSession['s
|
|
|
88
106
|
closed: [],
|
|
89
107
|
conflict: [],
|
|
90
108
|
superseded: [],
|
|
109
|
+
abandoned: [],
|
|
91
110
|
};
|
|
92
111
|
|
|
93
112
|
/** Whether this build knows the stored session status. */
|
|
@@ -116,6 +135,7 @@ async function requireLiveSession(sessions: RegisterSessionCollection, id: strin
|
|
|
116
135
|
if (!session) throw new RegisterSessionRequiredError();
|
|
117
136
|
if (!isKnownSessionStatus(session.status)) throw new RegisterNeedsUpgradeError();
|
|
118
137
|
if (session.status === 'closed') throw new RegisterSessionClosedError();
|
|
138
|
+
if (session.status === 'abandoned') throw new RegisterSessionClosedError();
|
|
119
139
|
return session;
|
|
120
140
|
}
|
|
121
141
|
|
|
@@ -17,13 +17,14 @@
|
|
|
17
17
|
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
18
18
|
import { combineLatest, map, of, switchMap } from 'rxjs';
|
|
19
19
|
import type { RxCollection } from 'rxdb';
|
|
20
|
-
import type { ServerCapabilities } from '@tallyui/core';
|
|
20
|
+
import type { RegisterSessionAlreadyOpenData, ServerCapabilities } from '@tallyui/core';
|
|
21
21
|
import type { PosOrder } from '../pos-order/types';
|
|
22
22
|
import { readFresh, watchFresh } from '../rxdb';
|
|
23
23
|
import { deriveExpected, type LedgerRow } from './expected';
|
|
24
24
|
import { recordRegisterFact, registerFactsLogger, type Actor } from './facts';
|
|
25
25
|
import { closeNeedsApproval } from './register-count.helpers';
|
|
26
|
-
import {
|
|
26
|
+
import { abandonSession, adoptRegisterResults, takeOverSession } from './register-adoption';
|
|
27
|
+
import { reconcileRegisterCommands, registerCommandsLogger, type RegisterCommand, type RegisterCommandCollection } from './register-commands';
|
|
27
28
|
import { observeRegister$, readRegister, type RegisterBucket, type RegisterDocument, type RegisterHost } from './register-document';
|
|
28
29
|
import type { CashMovement, Closure, RegisterSession } from './schemas';
|
|
29
30
|
import * as store from './session-store';
|
|
@@ -84,6 +85,8 @@ export interface UseRegisterSessionOptions {
|
|
|
84
85
|
/** `register_commands`, created with `registerCommandCollection()`; `null` while it opens. */
|
|
85
86
|
commands?: RegisterCommandCollection | null;
|
|
86
87
|
capabilities?: ServerCapabilities;
|
|
88
|
+
/** The till's name from the app's settings, sent on a register v2 open; the app supplies its platform fallback. */
|
|
89
|
+
deviceName?: string | null;
|
|
87
90
|
/** `pos_orders`: a session's sales are those whose `sessionId` is its id. */
|
|
88
91
|
orders: RxCollection<PosOrder> | null;
|
|
89
92
|
/** The register document's host, for the closure number and perpetual totals (`writeClosure`); `null` while it opens. */
|
|
@@ -116,6 +119,7 @@ type Reservation = RegisterBucket['closure_reservation'];
|
|
|
116
119
|
type Snapshot = {
|
|
117
120
|
rows: RegisterSession[]; closureRows: Closure[]; reservation: Reservation;
|
|
118
121
|
entries: CashMovement[]; sales: PosOrder[];
|
|
122
|
+
open?: RegisterCommand;
|
|
119
123
|
};
|
|
120
124
|
|
|
121
125
|
const reservationOf = (register: RegisterDocument | null, storeKey: string, registerId: string) =>
|
|
@@ -128,12 +132,13 @@ const unwritten = (row: RegisterSession, closureRows: readonly { id: string }[])
|
|
|
128
132
|
/**
|
|
129
133
|
* The session for this register: first the one an unapplied closure reservation names (its close
|
|
130
134
|
* was interrupted after the number was reserved, perhaps after its row was written), so it can
|
|
131
|
-
* finish; then the open or counting one; then a closed one whose closure row was never written; null if any status is unknown.
|
|
135
|
+
* finish; then the open or counting one; then a conflict one; then a closed one whose closure row was never written; null if any status is unknown.
|
|
132
136
|
*/
|
|
133
137
|
function currentSession(rows: RegisterSession[], closureRows: Closure[], reservation: Reservation) {
|
|
134
138
|
if (rows.some((row) => !store.isKnownSessionStatus(row.status))) return null;
|
|
135
139
|
return (reservation && !reservation.applied ? rows.find((row) => row.id === reservation.row.session_id) : undefined)
|
|
136
|
-
?? rows.find((row) => row.status
|
|
140
|
+
?? rows.find((row) => row.status === 'open' || row.status === 'counting')
|
|
141
|
+
?? rows.find((row) => row.status === 'conflict')
|
|
137
142
|
?? rows.find((row) => unwritten(row, closureRows))
|
|
138
143
|
?? null;
|
|
139
144
|
}
|
|
@@ -151,8 +156,8 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
151
156
|
const { commands } = options;
|
|
152
157
|
const commandEnabled = (options.capabilities?.register ?? 0) >= 1;
|
|
153
158
|
const commandTarget = useMemo(() => enabled && commandEnabled && commands && sessions && movements && closures && register && registerId
|
|
154
|
-
? { commands, sessions, movements, closures, host: register, storeKey, registerId } : null,
|
|
155
|
-
[enabled, commandEnabled, commands, sessions, movements, closures, register, storeKey, registerId]);
|
|
159
|
+
? { commands, sessions, movements, closures, host: register, storeKey, registerId, registerContract: options.capabilities?.register, deviceName: options.deviceName } : null,
|
|
160
|
+
[enabled, commandEnabled, commands, sessions, movements, closures, register, storeKey, registerId, options.capabilities?.register, options.deviceName]);
|
|
156
161
|
const commandTargetRef = useRef(commandTarget);
|
|
157
162
|
commandTargetRef.current = commandTarget;
|
|
158
163
|
const reconcile = (row?: RegisterSession) => {
|
|
@@ -167,6 +172,13 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
167
172
|
});
|
|
168
173
|
};
|
|
169
174
|
useEffect(reconcile, [commandTarget]);
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (!commandTarget) return;
|
|
177
|
+
const subscription = commandTarget.commands.find({ selector: { registerId: commandTarget.registerId } }).$.subscribe(() => {
|
|
178
|
+
void adoptRegisterResults(commandTarget);
|
|
179
|
+
});
|
|
180
|
+
return () => subscription.unsubscribe();
|
|
181
|
+
}, [commandTarget]);
|
|
170
182
|
const source = useMemo(() => {
|
|
171
183
|
if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) return null;
|
|
172
184
|
return combineLatest([
|
|
@@ -179,9 +191,10 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
179
191
|
return combineLatest([
|
|
180
192
|
watchFresh(movements, { selector: { session_id: current.id } }),
|
|
181
193
|
watchFresh(orders, { selector: { sessionId: current.id } }),
|
|
182
|
-
|
|
194
|
+
commandTarget ? watchFresh(commandTarget.commands, { selector: { key: `session.open:${current.id}` } }) : of([]),
|
|
195
|
+
]).pipe(map(([entries, sales, opens]) => ({ rows, closureRows, reservation, entries, sales, open: opens[0] })));
|
|
183
196
|
}));
|
|
184
|
-
}, [enabled, sessions, movements, closures, orders, register, storeKey, registerId]);
|
|
197
|
+
}, [enabled, sessions, movements, closures, orders, register, storeKey, registerId, commandTarget]);
|
|
185
198
|
// A latest-value ref: an `actions` object from an earlier render still sees the current flag.
|
|
186
199
|
const tender = useRef(tenderInProgress);
|
|
187
200
|
tender.current = tenderInProgress;
|
|
@@ -251,6 +264,12 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
251
264
|
}
|
|
252
265
|
}, [data, source]);
|
|
253
266
|
const session = data ? currentSession(data.rows, data.closureRows, data.reservation) : null;
|
|
267
|
+
const refusal = data?.open?.error?.data as RegisterSessionAlreadyOpenData | undefined;
|
|
268
|
+
const conflict = session?.status !== 'conflict' || !commandTarget ? undefined
|
|
269
|
+
: data?.open?.syncStatus === 'pending' && typeof data.open.payload.supersedes === 'string'
|
|
270
|
+
? { sessionId: data.open.payload.supersedes, takingOver: true }
|
|
271
|
+
: data?.open?.syncStatus === 'rejected' && refusal
|
|
272
|
+
? { sessionId: refusal.sessionId, openedAt: refusal.openedAt, openedBy: refusal.openedBy, deviceName: refusal.deviceName, takingOver: false } : undefined;
|
|
254
273
|
const entries = data?.entries ?? [];
|
|
255
274
|
const sales = data?.sales ?? [];
|
|
256
275
|
const expected = session
|
|
@@ -282,6 +301,8 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
282
301
|
|
|
283
302
|
return {
|
|
284
303
|
session,
|
|
304
|
+
/** The other till's live session, or the session this till is waiting to take over. */
|
|
305
|
+
conflict,
|
|
285
306
|
/** Any unknown session status in the current snapshot means the register needs upgrade. */
|
|
286
307
|
needsUpgrade: data?.rows.some((row) => !store.isKnownSessionStatus(row.status)) ?? false,
|
|
287
308
|
movements: entries,
|
|
@@ -297,17 +318,35 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
297
318
|
enabled,
|
|
298
319
|
blind: options.blind ?? false,
|
|
299
320
|
/** Pass straight to useSale's `session` option: `complete()` then stamps through `stampSession`. */
|
|
300
|
-
saleSession: session && sessions && session.status
|
|
321
|
+
saleSession: session && sessions && (session.status === 'open' || session.status === 'counting') ? { id: session.id, sessions } : undefined,
|
|
301
322
|
/** The open session's id, `null` when sessions are off, else `RegisterSessionRequiredError`. Call it when tender starts and before a card terminal captures. */
|
|
302
323
|
requireOpen,
|
|
303
324
|
/** `requireOpen()`, returning `{ id, sessions }`: pass it to useSale's `startTender`, which pins it (the rendered `saleSession` can lag). */
|
|
304
325
|
requireSaleSession,
|
|
305
326
|
actions: {
|
|
327
|
+
takeOver: async () => {
|
|
328
|
+
if (!commandTarget) throw new RegisterSessionRequiredError();
|
|
329
|
+
const row = currentSession(await readFresh(commandTarget.sessions, { selector: { register_id: commandTarget.registerId } }),
|
|
330
|
+
await readFresh(commandTarget.closures, { selector: { register_id: commandTarget.registerId } }),
|
|
331
|
+
reservationOf(await readRegister(commandTarget.host), commandTarget.storeKey, commandTarget.registerId));
|
|
332
|
+
if (row?.status !== 'conflict') throw new RegisterSessionRequiredError();
|
|
333
|
+
if ((options.capabilities?.register ?? 0) < 2) throw new store.RegisterTakeOverError('REGISTER_TAKEOVER_UNSUPPORTED');
|
|
334
|
+
await takeOverSession({ ...commandTarget, sessionId: row.id });
|
|
335
|
+
},
|
|
336
|
+
chooseAnotherRegister: async () => {
|
|
337
|
+
if (!commandTarget) throw new RegisterSessionRequiredError();
|
|
338
|
+
const row = currentSession(await readFresh(commandTarget.sessions, { selector: { register_id: commandTarget.registerId } }),
|
|
339
|
+
await readFresh(commandTarget.closures, { selector: { register_id: commandTarget.registerId } }),
|
|
340
|
+
reservationOf(await readRegister(commandTarget.host), commandTarget.storeKey, commandTarget.registerId));
|
|
341
|
+
if (row?.status !== 'conflict') throw new RegisterSessionRequiredError();
|
|
342
|
+
await abandonSession({ ...commandTarget, sessionId: row.id });
|
|
343
|
+
},
|
|
306
344
|
/**
|
|
307
345
|
* Refuses with `RegisterSessionAlreadyOpenError` while the register has a live session or
|
|
308
346
|
* another open is running (in any hook instance), and with `RegisterCloseIncompleteError`
|
|
309
347
|
* while an earlier close hasn't finished: a new session would lock the till behind it.
|
|
310
348
|
* An unknown stored status refuses with `RegisterNeedsUpgradeError`.
|
|
349
|
+
* A conflict session refuses with `RegisterSessionConflictError`; superseded and abandoned sessions do not block.
|
|
311
350
|
*/
|
|
312
351
|
openSession: async (input: { expectedFloatMinor: number | null; countedFloatMinor: number }) => {
|
|
313
352
|
// A double tap: the second call sees the first's promise, set before its first await.
|
|
@@ -318,7 +357,8 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
318
357
|
// The storage, not the rendered snapshot or a cached query, which can lag a new write.
|
|
319
358
|
const rows = await readFresh(sessions, { selector: { register_id: registerId } });
|
|
320
359
|
if (rows.some((row) => !store.isKnownSessionStatus(row.status))) throw new store.RegisterNeedsUpgradeError();
|
|
321
|
-
if (rows.some((row) => row.status
|
|
360
|
+
if (rows.some((row) => row.status === 'conflict')) throw new store.RegisterSessionConflictError();
|
|
361
|
+
if (rows.some((row) => row.status === 'open' || row.status === 'counting')) throw new RegisterSessionAlreadyOpenError();
|
|
322
362
|
// Closure rows before the reservation: a close reserves, then inserts its row, then applies the
|
|
323
363
|
// reservation, so a close landing between the two reads is still caught by one of them.
|
|
324
364
|
const closureRows = await readFresh(closures, { selector: { register_id: registerId } });
|