mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 04:20:55 +00:00
New endpoints:
POST invoices/eims/bulk-register { invoiceIds: [...] } — trigger
POST eims/webhook/bulk-register — MoR's callback
Fundamentally different shape from single register: bulkRegister
answers only {conversationId, status:202} immediately: MoR processes
the array asynchronously and pushes the real per-invoice results
(a mix of accepted/rejected in one array, per the collection's own
examples) to a webhook configured out of band. So this ships as two
halves that don't share a call stack — EimsBulkRegistrationService.
registerBulk() reserves a contiguous block of counters (durable
reservation, same doctrine as single register, extended to N items)
and submits; handleBulkCallback(), invoked by the new
EimsWebhookController whenever MoR gets around to it, settles.
New EimsSystemState.inFlightConversationId is the bulk equivalent of
inFlightInvoiceId — a whole batch outstanding, not one invoice — and
the two markers block each other since they share the same counter
sequence. The conversation id isn't known until MoR's 202 arrives, so
reservation stamps a locally-generated placeholder first (same
commit-before-the-network-call reasoning as single register), then
swaps it for MoR's real id right after — the only value the callback
can actually use to find the batch again.
Only the first invoice in a bulk batch chains via PreviousIrn — every
other item gets an empty string, matching the collection's own
two-invoice example exactly (MoR doesn't expect a batch to chain to
IRNs that don't exist yet at submission time).
Webhook has no auth (MoR has no JWT to send) — the conversation id
embedded in the payload is what stands between this and a forged
callback: an item only ever touches an invoice actually holding that
exact id, and an unknown id is logged and ignored, never applied.
Migration 3580000000000: eims_system_state.in_flight_conversation_id,
invoices.eims_bulk_conversation_id (tags which batch an invoice was
submitted in, so a stuck batch — webhook never arrived — can be found
and reconciled by conversation id). Applied to dev DB and recorded in
freight.migrations directly (idempotent IF NOT EXISTS DDL).
Not live-testable from this sandbox (no route to MoR's real gateway).
Signing the whole array as one envelope, the way single /v1/register
was confirmed live to need despite the collection's raw example
showing no envelope, is the reasonable extension of that confirmed
behavior, not a blind guess — but it has not itself been exercised
against the real gateway. Left for the first live bulk attempt to
confirm, same as every other MoR-facing assumption this integration
has made.
65 lines
2.7 KiB
TypeScript
65 lines
2.7 KiB
TypeScript
import { BaseEntity } from "@edr/api-common";
|
|
import { Column, Entity } from "typeorm";
|
|
|
|
/**
|
|
* One row per MoR system number, holding the sequence state EIMS expects across registrations:
|
|
* the next `SourceSystem.InvoiceCounter` and the IRN that the next document must chain to via
|
|
* `ReferenceDetails.PreviousIrn`.
|
|
*
|
|
* Registration locks this row `FOR UPDATE` for the duration of the submission, which is what keeps
|
|
* two concurrent registrations from claiming the same counter or breaking the IRN chain.
|
|
*/
|
|
@Entity({ schema: "freight", name: "eims_system_state" })
|
|
export class EimsSystemState extends BaseEntity {
|
|
@Column({ name: "system_number", type: "varchar", length: 32, unique: true })
|
|
systemNumber!: string;
|
|
|
|
/** Counter to send on the next registration; advanced only once an attempt has consumed it. */
|
|
@Column({ name: "next_invoice_counter", type: "bigint", default: 1 })
|
|
nextInvoiceCounter!: number;
|
|
|
|
/**
|
|
* `DocumentDetails.DocumentNumber` for the next registration.
|
|
*
|
|
* Separate from our own `invoiceNumber`, which MoR cannot accept: it validates the field against
|
|
* `^(0|[1-9][0-9]{0,8})$`, a plain integer.
|
|
*/
|
|
@Column({ name: "next_document_number", type: "bigint", default: 1 })
|
|
nextDocumentNumber!: number;
|
|
|
|
@Column({ name: "in_flight_document_number", type: "bigint", nullable: true })
|
|
inFlightDocumentNumber?: number | null;
|
|
|
|
/**
|
|
* IRN of the last successful registration; null until the first one succeeds. `text`, not a
|
|
* fixed varchar — same reasoning as `Invoice.eimsIrn`: a real IRN already overflowed varchar(64).
|
|
*/
|
|
@Column({ name: "previous_irn", type: "text", nullable: true })
|
|
previousIrn?: string | null;
|
|
|
|
/**
|
|
* Invoice holding the current reservation. Committed before the HTTP call, so it survives a
|
|
* crash and blocks a blind resubmission of a document that may already have reached MoR.
|
|
*/
|
|
@Column({ name: "in_flight_invoice_id", type: "uuid", nullable: true })
|
|
inFlightInvoiceId?: string | null;
|
|
|
|
/** Counter handed to the in-flight submission. */
|
|
@Column({ name: "in_flight_counter", type: "bigint", nullable: true })
|
|
inFlightCounter?: number | null;
|
|
|
|
/**
|
|
* Why registration is blocked for this system number. Set when a submission ends ambiguously:
|
|
* the IRN is unknown, so no further document can chain correctly until it is resolved.
|
|
*/
|
|
@Column({ name: "blocked_reason", type: "text", nullable: true })
|
|
blockedReason?: string | null;
|
|
|
|
/**
|
|
* Bulk equivalent of `in_flight_invoice_id` — a whole batch, not one invoice, is outstanding
|
|
* while MoR processes `POST /v1/bulkRegister` asynchronously. See `EimsBulkRegistrationService`.
|
|
*/
|
|
@Column({ name: "in_flight_conversation_id", type: "text", nullable: true })
|
|
inFlightConversationId?: string | null;
|
|
}
|