mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
BuyerDetails Country/Region/City/Wereda now resolve from the Ministry's own EIMS_COUNTRY_REGION_VW master instead of the EIMS_BUYER_*_CODES env maps and the ethiopia-geo-codes table. Both invented their codes and looked names up globally, so KERSA/GORO/BABILE/BURE — each present in several zones with different LOCALITY_NOs — could be filed against the wrong jurisdiction. Resolution is hierarchical and refuses to guess: an unknown or ambiguous address raises a local validation error naming the level that failed, and never selects the first matching row. Spelling differences between EDR and MoR live in a reviewed, parent-scoped alias layer; the dataset itself stays verbatim so it remains traceable to the Ministry sheet. Resolution now runs before the counter reservation in both the single and bulk paths, so a bad company address no longer burns an EIMS sequence number. Adds eims:import-locations to regenerate the dataset from a future workbook, reporting duplicate rows and same-hierarchy code conflicts.
538 lines
24 KiB
TypeScript
538 lines
24 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { InjectDataSource } from "@nestjs/typeorm";
|
|
import { DataSource, EntityManager, In } from "typeorm";
|
|
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
|
|
|
|
import { EimsConfig } from "../../config/eims.config";
|
|
import { Invoice } from "../billing/entities/invoice.entity";
|
|
import { MorGeoCodes, resolveMorGeo } from "../../config/mor-location.resolver";
|
|
import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper";
|
|
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
|
import { NotificationsService } from "../notifications/notifications.service";
|
|
import { EimsAuthService } from "./eims-auth.service";
|
|
import { EimsClientService } from "./eims-client.service";
|
|
import { EimsApiException, EimsConfigException } from "./eims.errors";
|
|
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
|
|
import {
|
|
EimsBulkCallbackItem,
|
|
EimsBulkRegisterAcceptedResponse,
|
|
EimsBulkRegisterItemResult,
|
|
EimsBulkRegisterRequest,
|
|
EimsInvoiceError,
|
|
EimsInvoiceStatus,
|
|
} from "./eims-registration.types";
|
|
|
|
const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]);
|
|
|
|
interface BulkReservation {
|
|
stateId: string;
|
|
invoice: Invoice & { lines: EimsMapperLine[] };
|
|
documentType: EimsDocumentType;
|
|
relatedDocument: string | null;
|
|
/** Resolved before this reservation existed — see the `prepared` pass in `bulkRegister`. */
|
|
buyerGeo: MorGeoCodes;
|
|
invoiceCounter: number;
|
|
documentNumber: string;
|
|
previousIrn: string;
|
|
}
|
|
|
|
/**
|
|
* Registers many invoices with MoR EIMS in one call — `POST /v1/bulkRegister`.
|
|
*
|
|
* Fundamentally different shape from `EimsInvoiceRegistrationService.registerInvoiceWithEims`:
|
|
* that endpoint answers synchronously (an IRN or a rejection, in the HTTP response itself). Bulk
|
|
* does not — it returns only `{conversationId, status:202}` immediately, and the real per-invoice
|
|
* results (a mix of accepted/rejected in one array, per the collection's own examples) arrive later
|
|
* as a POST to a webhook MoR was configured with out of band. That means this service has two
|
|
* halves that don't share a call stack: `registerBulk` reserves and submits; `handleBulkCallback`
|
|
* — invoked by `EimsWebhookController`, whenever MoR gets around to it — settles.
|
|
*
|
|
* Reservation follows the same durable-reservation doctrine as the single-invoice service (counters
|
|
* consumed and the holder recorded, committed, before the HTTP call leaves the process), extended
|
|
* to a contiguous block of N counters instead of one. The "something is in flight" marker is
|
|
* `EimsSystemState.inFlightConversationId`, not `inFlightInvoiceId` — a whole batch is outstanding,
|
|
* not one invoice — and the two markers block each other: a single registration cannot start while
|
|
* a bulk batch is pending, and vice versa, because they share the same counter sequence.
|
|
*
|
|
* The conversation id is not known until MoR's 202 response arrives, so reservation stamps a
|
|
* locally-generated placeholder token first (same "commit the reservation before the network call"
|
|
* reasoning as the single flow), then swaps it for MoR's real conversation id right after — the only
|
|
* value the webhook callback can actually use to find this batch again.
|
|
*
|
|
* 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.
|
|
*/
|
|
@Injectable()
|
|
export class EimsBulkRegistrationService {
|
|
private readonly logger = new Logger(EimsBulkRegistrationService.name);
|
|
|
|
constructor(
|
|
@InjectDataSource() private readonly dataSource: DataSource,
|
|
private readonly config: ConfigService,
|
|
private readonly client: EimsClientService,
|
|
private readonly auth: EimsAuthService,
|
|
private readonly notifications: NotificationsService,
|
|
private readonly sellerCache: EimsSellerCacheService,
|
|
) {}
|
|
|
|
private get cfg(): EimsConfig {
|
|
return this.config.get<EimsConfig>("eims")!;
|
|
}
|
|
|
|
/**
|
|
* Reserve counters for every eligible invoice and submit them as one `/v1/bulkRegister` call.
|
|
* An invoice that already has an IRN is silently skipped (idempotent, matching single register);
|
|
* everything else must pass the same DEB/CRE precondition single register checks, or the whole
|
|
* call is refused before anything is reserved.
|
|
*/
|
|
async registerBulk(
|
|
invoiceIds: string[],
|
|
): Promise<{ conversationId: string | null; accepted: string[]; alreadyRegistered: string[] }> {
|
|
const cfg = this.cfg;
|
|
assertEimsInvoiceConfig(cfg);
|
|
|
|
const ids = [...new Set(invoiceIds)];
|
|
if (ids.length === 0) {
|
|
throw new BadRequestException({ code: "EIMS_BULK_EMPTY", message: "No invoice ids given" });
|
|
}
|
|
|
|
const invoices = await this.loadInvoicesForMapping(ids);
|
|
const alreadyRegistered = invoices.filter((inv) => inv.eimsIrn).map((inv) => inv.id);
|
|
const pending = invoices.filter((inv) => !inv.eimsIrn);
|
|
|
|
// Same DEB/CRE precondition as single register, checked for every pending invoice before any
|
|
// counter is touched: a bad member must fail the whole batch, not surface mid-submission.
|
|
const prepared = pending.map((invoice) => {
|
|
const documentType = (invoice.eimsDocumentType as EimsDocumentType | undefined) ?? "INV";
|
|
let relatedDocument: string | null = null;
|
|
if (documentType !== "INV") {
|
|
if (!invoice.relatedInvoice) {
|
|
throw new BadRequestException({
|
|
code: "EIMS_RELATED_INVOICE_REQUIRED",
|
|
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} but has no related invoice set.`,
|
|
});
|
|
}
|
|
if (!invoice.relatedInvoice.eimsIrn) {
|
|
throw new BadRequestException({
|
|
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
|
|
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} against invoice ${invoice.relatedInvoice.invoiceNumber}, which was never registered with EIMS — nothing to reference.`,
|
|
});
|
|
}
|
|
relatedDocument = invoice.relatedInvoice.eimsIrn;
|
|
}
|
|
// Same rule as the single-invoice path: buyer geography is resolved from the MoR location
|
|
// master before reserveBulk touches a counter, so one bad company address fails the whole
|
|
// batch locally instead of burning a block of EIMS sequence numbers.
|
|
const buyerGeo = resolveMorGeo({
|
|
country: invoice.company?.country,
|
|
region: invoice.company?.region,
|
|
zone: invoice.company?.zone,
|
|
woreda: invoice.company?.woreda,
|
|
});
|
|
return { invoice, documentType, relatedDocument, buyerGeo };
|
|
});
|
|
|
|
if (prepared.length === 0) {
|
|
return { conversationId: null, accepted: [], alreadyRegistered };
|
|
}
|
|
|
|
const session = await this.auth.getSessionContext();
|
|
const placeholder = `local:${randomUUID()}`;
|
|
const reservations = await this.reserveBulk(prepared, session.systemNumber, placeholder);
|
|
|
|
let conversationId: string;
|
|
try {
|
|
const requests: EimsBulkRegisterRequest = reservations.map((r) =>
|
|
toEimsInvoice(
|
|
r.invoice,
|
|
this.sellerCache.getSellerDetails(cfg),
|
|
buildEimsContext(cfg, {
|
|
buyerGeo: r.buyerGeo,
|
|
documentNumber: r.documentNumber,
|
|
invoiceCounter: r.invoiceCounter,
|
|
previousIrn: r.previousIrn,
|
|
session,
|
|
documentType: r.documentType,
|
|
reason: r.invoice.eimsReason,
|
|
relatedDocument: r.relatedDocument,
|
|
}),
|
|
),
|
|
);
|
|
const response = await this.client.postSigned<EimsBulkRegisterRequest, EimsBulkRegisterAcceptedResponse>(
|
|
"/v1/bulkRegister",
|
|
requests,
|
|
);
|
|
if (!response?.conversationId) {
|
|
throw new EimsApiException(
|
|
"SCHEMA_VALIDATION",
|
|
"EIMS bulkRegister returned no conversationId",
|
|
response?.status,
|
|
);
|
|
}
|
|
conversationId = response.conversationId;
|
|
} catch (err) {
|
|
await this.settleBulkFailure(reservations, err);
|
|
throw err;
|
|
}
|
|
|
|
await this.claimConversationId(placeholder, conversationId);
|
|
this.logger.log(
|
|
`Bulk-registered ${reservations.length} invoice(s) with EIMS (conversation ${conversationId}), awaiting callback`,
|
|
);
|
|
return { conversationId, accepted: reservations.map((r) => r.invoice.id), alreadyRegistered };
|
|
}
|
|
|
|
/**
|
|
* Settle a batch's callback, whenever MoR gets around to sending it. Called by
|
|
* `EimsWebhookController` with the raw parsed array body — no auth on that route (MoR calls it,
|
|
* not a logged-in user), so the only thing standing between this and a forged callback is the
|
|
* conversation id itself: an item is only ever applied to an invoice actually holding that exact
|
|
* id, and an unknown id is logged and ignored rather than touching anything.
|
|
*/
|
|
async handleBulkCallback(items: EimsBulkCallbackItem[]): Promise<EimsBulkRegisterItemResult[]> {
|
|
const settlements = items.filter(
|
|
(item): item is Exclude<EimsBulkCallbackItem, { conversationId?: string; conversionId?: string }> =>
|
|
"irn" in item || "ruleError" in item,
|
|
);
|
|
|
|
const conversationId = this.markerFrom(items);
|
|
const invoices = await this.dataSource
|
|
.getRepository(Invoice)
|
|
.createQueryBuilder("invoice")
|
|
.where("invoice.eims_bulk_conversation_id = :id", { id: conversationId })
|
|
.getMany();
|
|
|
|
if (invoices.length === 0) {
|
|
this.logger.warn(
|
|
`EIMS bulk callback for an unknown or already-settled conversation — ignored (${settlements.length} item(s))`,
|
|
);
|
|
return [];
|
|
}
|
|
|
|
const byDocumentNumber = new Map(invoices.map((inv) => [inv.eimsDocumentNumber, inv]));
|
|
// Process in invoiceCounter order so `previousIrn` ends up as the last-accepted item's IRN —
|
|
// the same "advance the chain" semantics as single register's settleSuccess.
|
|
const ordered = [...settlements].sort((a, b) => {
|
|
const invA = byDocumentNumber.get("documentNumber" in a ? a.documentNumber : a.docNo);
|
|
const invB = byDocumentNumber.get("documentNumber" in b ? b.documentNumber : b.docNo);
|
|
return (invA?.eimsInvoiceCounter ?? 0) - (invB?.eimsInvoiceCounter ?? 0);
|
|
});
|
|
|
|
const results: EimsBulkRegisterItemResult[] = [];
|
|
for (const item of ordered) {
|
|
const docNumber = "documentNumber" in item ? item.documentNumber : item.docNo;
|
|
const invoice = byDocumentNumber.get(docNumber);
|
|
if (!invoice) {
|
|
this.logger.warn(`EIMS bulk callback item for unknown document number ${docNumber} — ignored`);
|
|
continue;
|
|
}
|
|
if (invoice.eimsStatus !== EimsInvoiceStatus.Submitting) {
|
|
// Already settled — a duplicate callback delivery. Report the current state, touch nothing.
|
|
results.push({
|
|
invoiceId: invoice.id,
|
|
invoiceNumber: invoice.invoiceNumber,
|
|
success: invoice.eimsStatus === EimsInvoiceStatus.Registered,
|
|
message: `Already settled (${invoice.eimsStatus})`,
|
|
irn: invoice.eimsIrn ?? undefined,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if ("irn" in item) {
|
|
await this.settleBulkItemSuccess(invoice, item.irn, conversationId, item.signedQR);
|
|
results.push({
|
|
invoiceId: invoice.id,
|
|
invoiceNumber: invoice.invoiceNumber,
|
|
success: true,
|
|
message: `Registered with EIMS (IRN ${item.irn})`,
|
|
irn: item.irn,
|
|
});
|
|
} else {
|
|
const message = item.ruleError.flatMap((e) => e.errorMessage).join("; ") || "EIMS bulk rule validation error";
|
|
await this.settleBulkItemFailure(invoice, message);
|
|
results.push({ invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber, success: false, message });
|
|
}
|
|
}
|
|
|
|
// Clear the batch's in-flight marker only once nothing submitted under this conversation is
|
|
// still waiting — a partial/incremental callback (not expected per the collection's docs, but
|
|
// not ruled out either) must not prematurely unblock the system number.
|
|
const stillPending = await this.dataSource
|
|
.getRepository(Invoice)
|
|
.count({ where: { eimsBulkConversationId: conversationId, eimsStatus: EimsInvoiceStatus.Submitting } });
|
|
if (stillPending === 0) {
|
|
await this.dataSource.manager.update(
|
|
EimsSystemState,
|
|
{ inFlightConversationId: conversationId },
|
|
{ inFlightConversationId: null },
|
|
);
|
|
this.logger.log(`EIMS bulk conversation ${conversationId} fully settled (${results.length} item(s))`);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ── transactions ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */
|
|
private async reserveBulk(
|
|
prepared: Array<{
|
|
invoice: Invoice & { lines: EimsMapperLine[] };
|
|
documentType: EimsDocumentType;
|
|
relatedDocument: string | null;
|
|
buyerGeo: MorGeoCodes;
|
|
}>,
|
|
systemNumber: string,
|
|
placeholder: string,
|
|
): Promise<BulkReservation[]> {
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const state = await this.lockSystemState(manager, systemNumber);
|
|
|
|
if (state.blockedReason) {
|
|
throw new ConflictException({
|
|
code: "EIMS_SYSTEM_BLOCKED",
|
|
message: `EIMS registration is blocked for system ${systemNumber}: ${state.blockedReason}. Resolve the affected invoice before registering anything else.`,
|
|
});
|
|
}
|
|
if (state.inFlightInvoiceId) {
|
|
throw new ConflictException({
|
|
code: "EIMS_SUBMISSION_IN_FLIGHT",
|
|
message: `A submission for invoice ${state.inFlightInvoiceId} is already in flight on system ${systemNumber}. Wait for it to settle, or resolve it if the process was interrupted.`,
|
|
});
|
|
}
|
|
if (state.inFlightConversationId) {
|
|
throw new ConflictException({
|
|
code: "EIMS_BULK_IN_FLIGHT",
|
|
message: `A bulk submission (conversation ${state.inFlightConversationId}) is already in flight on system ${systemNumber}. Wait for its callback, or resolve it if the process was interrupted.`,
|
|
});
|
|
}
|
|
|
|
let counter = Number(state.nextInvoiceCounter);
|
|
let docNumber = Number(state.nextDocumentNumber);
|
|
let previousIrn = state.previousIrn ?? "";
|
|
const reservations: BulkReservation[] = [];
|
|
|
|
// Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking
|
|
// on the opposite lock order.
|
|
for (const { invoice, documentType, relatedDocument, buyerGeo } of prepared) {
|
|
const locked = await this.lockInvoice(manager, invoice.id);
|
|
const thisCounter = counter++;
|
|
const thisDocNumber = String(docNumber++);
|
|
const thisPreviousIrn = reservations.length === 0 ? previousIrn : "";
|
|
|
|
await manager.update(Invoice, invoice.id, {
|
|
eimsStatus: EimsInvoiceStatus.Submitting,
|
|
eimsInvoiceCounter: thisCounter,
|
|
eimsDocumentNumber: thisDocNumber,
|
|
eimsSubmittedAt: new Date(),
|
|
eimsLastError: null,
|
|
eimsBulkConversationId: placeholder,
|
|
} as QueryDeepPartialEntity<Invoice>);
|
|
|
|
reservations.push({
|
|
stateId: state.id,
|
|
invoice: Object.assign(locked, { lines: invoice.lines }),
|
|
documentType,
|
|
relatedDocument,
|
|
buyerGeo,
|
|
invoiceCounter: thisCounter,
|
|
documentNumber: thisDocNumber,
|
|
previousIrn: thisPreviousIrn,
|
|
});
|
|
}
|
|
|
|
await manager.update(EimsSystemState, state.id, {
|
|
nextInvoiceCounter: counter,
|
|
nextDocumentNumber: docNumber,
|
|
inFlightConversationId: placeholder,
|
|
});
|
|
|
|
return reservations;
|
|
});
|
|
}
|
|
|
|
/** Swap the local placeholder for MoR's real conversation id, on both the state row and every invoice. */
|
|
private async claimConversationId(placeholder: string, conversationId: string): Promise<void> {
|
|
await this.dataSource.transaction(async (manager) => {
|
|
await manager.update(EimsSystemState, { inFlightConversationId: placeholder }, { inFlightConversationId: conversationId });
|
|
await manager.update(Invoice, { eimsBulkConversationId: placeholder }, { eimsBulkConversationId: conversationId });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* TX2b for the whole batch — the same determinism doctrine as single register's settleFailure,
|
|
* applied once since `/v1/bulkRegister` either accepts the whole array (202) or fails as one HTTP
|
|
* call; there is no per-item answer yet at this point, only after the callback.
|
|
*/
|
|
private async settleBulkFailure(reservations: BulkReservation[], err: unknown): Promise<void> {
|
|
const api = err instanceof EimsApiException ? err : null;
|
|
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true;
|
|
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
|
|
const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL";
|
|
const lastError: EimsInvoiceError = {
|
|
kind: api?.kind ?? localKind,
|
|
message: (err as Error)?.message ?? "unknown error",
|
|
httpStatus: api?.httpStatus,
|
|
details: api?.details,
|
|
at: new Date().toISOString(),
|
|
};
|
|
const first = reservations[0];
|
|
|
|
await this.dataSource.transaction(async (manager) => {
|
|
for (const r of reservations) {
|
|
await manager.update(Invoice, r.invoice.id, {
|
|
eimsStatus: status,
|
|
eimsLastError: lastError,
|
|
...(deterministic ? { eimsBulkConversationId: null } : {}),
|
|
} as QueryDeepPartialEntity<Invoice>);
|
|
}
|
|
await manager.update(
|
|
EimsSystemState,
|
|
first.stateId,
|
|
deterministic
|
|
? {
|
|
// The whole block returns: MoR never counted a refused batch against either sequence.
|
|
nextInvoiceCounter: first.invoiceCounter,
|
|
nextDocumentNumber: Number(first.documentNumber),
|
|
inFlightConversationId: null,
|
|
}
|
|
: {
|
|
blockedReason:
|
|
`A bulk submission of ${reservations.length} invoice(s) (starting counter ${first.invoiceCounter}) ` +
|
|
`was sent but never acknowledged (${lastError.kind}). No further document can be filed until it is resolved.`,
|
|
},
|
|
);
|
|
});
|
|
|
|
this.logger.error(`EIMS bulk submission ${status}: ${lastError.message}`);
|
|
}
|
|
|
|
/** One callback item accepted. */
|
|
private async settleBulkItemSuccess(
|
|
invoice: Invoice,
|
|
irn: string,
|
|
conversationId: string,
|
|
signedQR?: string,
|
|
): Promise<void> {
|
|
await this.dataSource.transaction(async (manager) => {
|
|
await this.lockInvoice(manager, invoice.id);
|
|
await manager.update(Invoice, invoice.id, {
|
|
eimsStatus: EimsInvoiceStatus.Registered,
|
|
eimsIrn: irn,
|
|
eimsSignedQr: signedQR ?? null,
|
|
eimsLastError: null,
|
|
});
|
|
// Looked up by conversation id, not system number — this batch's state row is whichever one
|
|
// is holding this conversation, which is exactly what `inFlightConversationId` already tracks.
|
|
await manager.update(EimsSystemState, { inFlightConversationId: conversationId }, { previousIrn: irn });
|
|
});
|
|
this.logger.log(`Invoice ${invoice.invoiceNumber} registered with EIMS via bulk (IRN ${irn})`);
|
|
|
|
if (invoice.companyId) {
|
|
try {
|
|
await sendCompanyChannels(
|
|
this.dataSource,
|
|
this.notifications,
|
|
invoice.companyId,
|
|
`Invoice ${invoice.invoiceNumber} has been registered with MoR EIMS. Reference (IRN): ${irn}`,
|
|
);
|
|
} catch (err) {
|
|
this.logger.warn(`EIMS buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One callback item rejected. Unlike single register's settleFailure, the counter/document
|
|
* number are not returned — MoR's own bulk processing already advanced the whole array's
|
|
* allocation regardless of this item's individual outcome, so there is nothing local to roll back.
|
|
*/
|
|
private async settleBulkItemFailure(invoice: Invoice, message: string): Promise<void> {
|
|
const lastError: EimsInvoiceError = { kind: "RULE_VALIDATION", message, at: new Date().toISOString() };
|
|
await this.dataSource.manager.update(Invoice, invoice.id, {
|
|
eimsStatus: EimsInvoiceStatus.Failed,
|
|
eimsLastError: lastError,
|
|
} as QueryDeepPartialEntity<Invoice>);
|
|
this.logger.error(`Invoice ${invoice.invoiceNumber} EIMS bulk registration FAILED: ${message}`);
|
|
}
|
|
|
|
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
|
|
|
private markerFrom(items: EimsBulkCallbackItem[]): string {
|
|
const marker = items.find((i) => "conversationId" in i || "conversionId" in i) as
|
|
| { conversationId?: string; conversionId?: string }
|
|
| undefined;
|
|
return marker?.conversationId ?? marker?.conversionId ?? "";
|
|
}
|
|
|
|
|
|
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
|
const invoice = await manager
|
|
.createQueryBuilder(Invoice, "invoice")
|
|
.setLock("pessimistic_write")
|
|
.where("invoice.id = :invoiceId", { invoiceId })
|
|
.getOne();
|
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
|
return invoice;
|
|
}
|
|
|
|
private async lockSystemState(manager: EntityManager, systemNumber: string): Promise<EimsSystemState> {
|
|
const select = () =>
|
|
manager
|
|
.createQueryBuilder(EimsSystemState, "state")
|
|
.setLock("pessimistic_write")
|
|
.where("state.system_number = :systemNumber", { systemNumber })
|
|
.getOne();
|
|
|
|
const existing = await select();
|
|
if (existing) return existing;
|
|
|
|
await manager.query(
|
|
`INSERT INTO freight.eims_system_state (system_number) VALUES ($1) ON CONFLICT (system_number) DO NOTHING`,
|
|
[systemNumber],
|
|
);
|
|
const created = await select();
|
|
if (!created) throw new Error(`Could not initialise EIMS system state for ${systemNumber}`);
|
|
return created;
|
|
}
|
|
|
|
private async loadInvoicesForMapping(invoiceIds: string[]): Promise<Array<Invoice & { lines: EimsMapperLine[] }>> {
|
|
const invoices = await this.dataSource.getRepository(Invoice).find({
|
|
where: { id: In(invoiceIds) },
|
|
relations: { company: true, companyProfile: true, relatedInvoice: true },
|
|
});
|
|
const found = new Set(invoices.map((inv) => inv.id));
|
|
const missing = invoiceIds.filter((id) => !found.has(id));
|
|
if (missing.length > 0) {
|
|
throw new NotFoundException(`Invoice(s) not found: ${missing.join(", ")}`);
|
|
}
|
|
|
|
const lines: Array<EimsMapperLine & { invoiceId: string }> = await this.dataSource.query(
|
|
`SELECT invoice_id AS "invoiceId", charge_type AS "chargeType", description, quantity,
|
|
unit_rate AS "unitRate", amount, currency, metadata
|
|
FROM freight.invoice_lines
|
|
WHERE invoice_id = ANY($1) AND deleted_at IS NULL
|
|
ORDER BY created_at ASC`,
|
|
[invoiceIds],
|
|
);
|
|
const linesByInvoice = new Map<string, EimsMapperLine[]>();
|
|
for (const line of lines) {
|
|
const { invoiceId, ...rest } = line;
|
|
if (!linesByInvoice.has(invoiceId)) linesByInvoice.set(invoiceId, []);
|
|
linesByInvoice.get(invoiceId)!.push(rest);
|
|
}
|
|
|
|
// Preserve the caller's given order — reservation and result ordering both depend on it.
|
|
return invoiceIds.map((id) => {
|
|
const invoice = invoices.find((inv) => inv.id === id)!;
|
|
return Object.assign(invoice, { lines: linesByInvoice.get(id) ?? [] });
|
|
});
|
|
}
|
|
}
|