mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
fix
This commit is contained in:
@@ -3,6 +3,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { BillingController } from "./billing.controller";
|
||||
import { PortalBillingController } from "./portal-billing.controller";
|
||||
import { PaymentController } from "./payment.controller";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { DocumentsModule } from "./documents/documents.module";
|
||||
import { Invoice } from "./entities/invoice.entity";
|
||||
@@ -19,7 +20,7 @@ import { CompaniesModule } from "../companies/companies.module";
|
||||
CompaniesModule,
|
||||
DocumentsModule,
|
||||
],
|
||||
controllers: [BillingController, PortalBillingController],
|
||||
controllers: [BillingController, PortalBillingController, PaymentController],
|
||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||
exports: [BillingService],
|
||||
})
|
||||
|
||||
@@ -116,12 +116,14 @@ describe("BillingService.generateInvoice", () => {
|
||||
});
|
||||
|
||||
describe("BillingService.markInvoiceAsPaid", () => {
|
||||
it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => {
|
||||
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
totalAmount: 1500,
|
||||
paidAt: null,
|
||||
};
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(open),
|
||||
@@ -143,7 +145,22 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ id: "inv-1" },
|
||||
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
|
||||
{
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
paymentId: "pay-1",
|
||||
paidAt: expect.any(Date),
|
||||
paidAmount: 1500,
|
||||
balanceAmount: 0,
|
||||
payments: [
|
||||
{
|
||||
amount: 1500,
|
||||
method: "GATEWAY",
|
||||
reference: "pay-1",
|
||||
paidAt: expect.any(String),
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
"booking.invoice.paid",
|
||||
@@ -190,8 +207,14 @@ describe("BillingService.recordPayment", () => {
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const dataSource = {
|
||||
manager: mg,
|
||||
transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((cb: (mg: unknown) => unknown) => cb(mg)),
|
||||
};
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
dataSource as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
@@ -257,6 +280,14 @@ describe("BillingService.recordPayment", () => {
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a payment that exceeds the outstanding balance", async () => {
|
||||
const { service, mg } = serviceFor(openInvoice());
|
||||
await expect(
|
||||
service.recordPayment("inv-1", { amount: 1500 }),
|
||||
).rejects.toThrow();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects payment against a cancelled invoice", async () => {
|
||||
const { service, mg } = serviceFor(
|
||||
openInvoice({ status: Freight.InvoiceStatus.Cancelled }),
|
||||
@@ -265,74 +296,3 @@ describe("BillingService.recordPayment", () => {
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.settlePayable", () => {
|
||||
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
};
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(open),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
|
||||
const settled = await service.settlePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"pay-1",
|
||||
mg as never,
|
||||
);
|
||||
|
||||
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ id: "inv-1" },
|
||||
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
|
||||
);
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
"booking.invoice.paid",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op (returns null) when the source has no open invoice", async () => {
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
|
||||
const settled = await service.settlePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"pay-1",
|
||||
mg as never,
|
||||
);
|
||||
|
||||
expect(settled).toBeNull();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,6 @@ const DEFAULT_DUE_DAYS = 14;
|
||||
|
||||
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
|
||||
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Draft,
|
||||
Freight.InvoiceStatus.Issued,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
Freight.InvoiceStatus.PartiallyPaid,
|
||||
@@ -285,20 +284,15 @@ export class BillingService {
|
||||
|
||||
/**
|
||||
* Initiate gateway payment for one of the customer's own invoices. Verifies
|
||||
* ownership, then charges whichever open invoice the source currently has
|
||||
* (see {@link payInvoice}).
|
||||
* ownership, then charges the invoice directly by ID (see {@link payInvoice}).
|
||||
*/
|
||||
async payInvoiceForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
opts: PayInvoiceOptions = {},
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.findByIdForUser(id, userId);
|
||||
return this.payInvoice(
|
||||
invoice.source as Freight.InvoiceSource,
|
||||
invoice.sourceId,
|
||||
opts,
|
||||
);
|
||||
await this.findByIdForUser(id, userId);
|
||||
return this.payInvoice(id, opts);
|
||||
}
|
||||
|
||||
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
|
||||
@@ -423,23 +417,89 @@ export class BillingService {
|
||||
// ── State transitions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mark an invoice paid and link the gateway payment, then emit
|
||||
* `${source}.invoice.paid`. Full-payment only — no partial settlement.
|
||||
* No-op when the invoice is already paid. Pass `manager` to enlist in a
|
||||
* caller's transaction.
|
||||
* Run `fn` inside a transaction and only emit its returned domain event
|
||||
* after commit. When the caller passes their own `manager`, they own commit
|
||||
* timing — `fn`'s event fires inline as soon as it resolves (the outer
|
||||
* transaction may still roll back afterwards; this is the caller's
|
||||
* documented tradeoff). When no `manager` is given, this opens its own
|
||||
* transaction and defers the emit until after that transaction commits, so
|
||||
* listeners (e.g. booking advancement) can never observe an invoice change
|
||||
* that then rolls back.
|
||||
*/
|
||||
private async runTransition<T>(
|
||||
manager: EntityManager | undefined,
|
||||
fn: (mg: EntityManager) => Promise<{ result: T; emit?: () => void }>,
|
||||
): Promise<T> {
|
||||
if (manager) {
|
||||
const { result, emit } = await fn(manager);
|
||||
emit?.();
|
||||
return result;
|
||||
}
|
||||
let pending: (() => void) | undefined;
|
||||
const result = await this.dataSource.transaction(async (mg) => {
|
||||
const out = await fn(mg);
|
||||
pending = out.emit;
|
||||
return out.result;
|
||||
});
|
||||
pending?.();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts,
|
||||
* append the settlement to the `payments` ledger, link the gateway payment,
|
||||
* then emit `${source}.invoice.paid`. Full-payment only — no partial
|
||||
* settlement. No-op when the invoice is already paid. Pass `manager` to
|
||||
* enlist in a caller's transaction; otherwise locks the row for update and
|
||||
* emits only after commit (see {@link runTransition}).
|
||||
*/
|
||||
async markInvoiceAsPaid(
|
||||
invoiceId: string,
|
||||
paymentId: string | null = null,
|
||||
manager?: EntityManager,
|
||||
settlement: { providerTxnId?: string; paidAt?: Date } = {},
|
||||
): Promise<Invoice | null> {
|
||||
return this.transition(
|
||||
invoiceId,
|
||||
Freight.InvoiceStatus.Paid,
|
||||
"paid",
|
||||
{ paymentId: paymentId ?? undefined },
|
||||
manager,
|
||||
);
|
||||
return this.runTransition(manager, async (mg) => {
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { id: invoiceId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
return { result: invoice };
|
||||
}
|
||||
|
||||
const paidAt = invoice.paidAt ?? settlement.paidAt ?? new Date();
|
||||
const settledAmount = round2(
|
||||
Number(invoice.totalAmount) - Number(invoice.paidAmount ?? 0),
|
||||
);
|
||||
const entry: InvoicePayment = {
|
||||
amount: settledAmount,
|
||||
method: "GATEWAY",
|
||||
reference: settlement.providerTxnId ?? paymentId ?? null,
|
||||
paidAt: paidAt.toISOString(),
|
||||
metadata: null,
|
||||
};
|
||||
const payments = [...(invoice.payments ?? []), entry];
|
||||
|
||||
const patch = {
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
paymentId,
|
||||
paidAt,
|
||||
paidAmount: invoice.totalAmount,
|
||||
balanceAmount: 0,
|
||||
payments,
|
||||
};
|
||||
await mg.update(Invoice, { id: invoiceId }, patch as never);
|
||||
|
||||
const updated = { ...invoice, ...patch } as Invoice;
|
||||
return {
|
||||
result: updated,
|
||||
emit: () => this.emitInvoiceEvent("paid", updated),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -451,9 +511,11 @@ export class BillingService {
|
||||
* at the warehouse counter); gateway settlement goes through
|
||||
* {@link markInvoiceAsPaid}.
|
||||
*
|
||||
* Throws when the invoice is missing, cancelled, refunded, already fully paid,
|
||||
* or when `amount` is not positive. Pass `manager` to enlist in a caller's
|
||||
* transaction.
|
||||
* Throws when the invoice is missing, cancelled, refunded, already fully
|
||||
* paid, `amount` is not positive, or `amount` exceeds the outstanding
|
||||
* balance. Pass `manager` to enlist in a caller's transaction; otherwise
|
||||
* locks the row for update and emits only after commit (see
|
||||
* {@link runTransition}).
|
||||
*/
|
||||
async recordPayment(
|
||||
invoiceId: string,
|
||||
@@ -466,62 +528,71 @@ export class BillingService {
|
||||
);
|
||||
}
|
||||
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
|
||||
throw new BadRequestException("Cannot pay a cancelled invoice.");
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Refunded) {
|
||||
throw new BadRequestException("Cannot pay a refunded invoice.");
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException("Invoice is already fully paid.");
|
||||
}
|
||||
return this.runTransition(manager, async (mg) => {
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { id: invoiceId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
|
||||
throw new BadRequestException("Cannot pay a cancelled invoice.");
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Refunded) {
|
||||
throw new BadRequestException("Cannot pay a refunded invoice.");
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException("Invoice is already fully paid.");
|
||||
}
|
||||
if (round2(input.amount) > Number(invoice.balanceAmount)) {
|
||||
throw new BadRequestException(
|
||||
`Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const at = input.paidAt ?? new Date();
|
||||
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
|
||||
invoice.totalAmount,
|
||||
invoice.paidAmount,
|
||||
input.amount,
|
||||
);
|
||||
const status = fullyPaid
|
||||
? Freight.InvoiceStatus.Paid
|
||||
: Freight.InvoiceStatus.PartiallyPaid;
|
||||
const at = input.paidAt ?? new Date();
|
||||
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
|
||||
invoice.totalAmount,
|
||||
invoice.paidAmount,
|
||||
input.amount,
|
||||
);
|
||||
const status = fullyPaid
|
||||
? Freight.InvoiceStatus.Paid
|
||||
: Freight.InvoiceStatus.PartiallyPaid;
|
||||
|
||||
const entry: InvoicePayment = {
|
||||
amount: round2(input.amount),
|
||||
method: input.method ?? null,
|
||||
reference: input.reference ?? null,
|
||||
paidAt: at.toISOString(),
|
||||
metadata: input.metadata ?? null,
|
||||
};
|
||||
const payments = [...(invoice.payments ?? []), entry];
|
||||
const entry: InvoicePayment = {
|
||||
amount: round2(input.amount),
|
||||
method: input.method ?? null,
|
||||
reference: input.reference ?? null,
|
||||
paidAt: at.toISOString(),
|
||||
metadata: input.metadata ?? null,
|
||||
};
|
||||
const payments = [...(invoice.payments ?? []), entry];
|
||||
|
||||
await mg.update(Invoice, { id: invoice.id }, {
|
||||
paidAmount,
|
||||
balanceAmount,
|
||||
status,
|
||||
payments,
|
||||
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
|
||||
} as never);
|
||||
const patch = {
|
||||
paidAmount,
|
||||
balanceAmount,
|
||||
status,
|
||||
payments,
|
||||
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
|
||||
};
|
||||
await mg.update(Invoice, { id: invoice.id }, patch as never);
|
||||
|
||||
const updated = {
|
||||
...invoice,
|
||||
paidAmount,
|
||||
balanceAmount,
|
||||
status,
|
||||
payments,
|
||||
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
|
||||
} as Invoice;
|
||||
|
||||
if (fullyPaid) this.emitInvoiceEvent("paid", updated);
|
||||
return updated;
|
||||
const updated = { ...invoice, ...patch } as Invoice;
|
||||
return {
|
||||
result: updated,
|
||||
emit: fullyPaid
|
||||
? () => this.emitInvoiceEvent("paid", updated)
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
|
||||
* No-op when already refunded.
|
||||
* No-op when already refunded. Throws when the invoice has no recorded
|
||||
* payment (nothing to refund).
|
||||
*/
|
||||
async markInvoiceAsRefunded(
|
||||
invoiceId: string,
|
||||
@@ -533,12 +604,20 @@ export class BillingService {
|
||||
"refunded",
|
||||
{},
|
||||
manager,
|
||||
(invoice) => {
|
||||
if (!(Number(invoice.paidAmount) > 0)) {
|
||||
throw new BadRequestException(
|
||||
"Cannot refund an invoice with no recorded payment.",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
|
||||
* No-op when already cancelled.
|
||||
* No-op when already cancelled. Throws when the invoice has payments
|
||||
* recorded against it (refund it instead).
|
||||
*/
|
||||
async cancelInvoice(
|
||||
invoiceId: string,
|
||||
@@ -550,16 +629,23 @@ export class BillingService {
|
||||
"cancelled",
|
||||
{},
|
||||
manager,
|
||||
(invoice) => {
|
||||
if (Number(invoice.paidAmount) > 0) {
|
||||
throw new BadRequestException(
|
||||
"Cannot cancel an invoice that has payments recorded against it.",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the invoice, apply the new status (+ extra columns), then emit
|
||||
* `${source}.invoice.<event>`. No-op (returns the invoice) when it is already
|
||||
* in the target status. Throws when the invoice does not exist.
|
||||
*
|
||||
* Note: the event fires in-process synchronously. When a `manager` from an
|
||||
* outer transaction is passed, listeners run before that transaction commits.
|
||||
* `${source}.invoice.<event>`. No-op (returns the invoice, skipping `guard`)
|
||||
* when it is already in the target status. Throws when the invoice does not
|
||||
* exist or `guard` rejects the current state. Pass `manager` to enlist in a
|
||||
* caller's transaction; otherwise locks the row for update and emits only
|
||||
* after commit (see {@link runTransition}).
|
||||
*/
|
||||
private async transition(
|
||||
invoiceId: string,
|
||||
@@ -567,17 +653,27 @@ export class BillingService {
|
||||
event: string,
|
||||
extra: { paymentId?: string },
|
||||
manager?: EntityManager,
|
||||
guard?: (invoice: Invoice) => void,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
if (invoice.status === status) return invoice;
|
||||
return this.runTransition(manager, async (mg) => {
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { id: invoiceId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
if (invoice.status === status) return { result: invoice };
|
||||
guard?.(invoice);
|
||||
|
||||
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
||||
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
||||
|
||||
const updated = { ...invoice, ...extra, status } as Invoice;
|
||||
this.emitInvoiceEvent(event, updated);
|
||||
return updated;
|
||||
const updated = { ...invoice, ...extra, status } as Invoice;
|
||||
return {
|
||||
result: updated,
|
||||
emit: () => this.emitInvoiceEvent(event, updated),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
|
||||
@@ -595,22 +691,29 @@ export class BillingService {
|
||||
status: invoice.status,
|
||||
paymentId: invoice.paymentId ?? null,
|
||||
};
|
||||
this.events.emit(`${invoice.source}.invoice.${event}`, payload);
|
||||
this.events
|
||||
.emitAsync(`${invoice.source}.invoice.${event}`, payload)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Listener for ${invoice.source}.invoice.${event} (invoice ${invoice.id}) failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Payment reconciliation (by source) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The invoice a gateway payment should settle for a source record, or null if
|
||||
* none. This is the billing document of record for "what is owed" — callers
|
||||
* (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than
|
||||
* recomputing from the source's own total, so discounts/penalties/adjustments
|
||||
* carried on the invoice are honored.
|
||||
* The invoice a source record already has open, or null if it needs a new
|
||||
* one. This is the idempotency check every `ensureInvoiceFor*` (booking,
|
||||
* first-mile, last-mile) runs before generating — it must see DRAFT
|
||||
* invoices too, not just issued ones, otherwise a source that already has
|
||||
* an unissued draft gets a second, duplicate invoice minted alongside it
|
||||
* instead of that draft being reused and then issued.
|
||||
*
|
||||
* Pass `type` to select a specific invoice when a source carries several (e.g.
|
||||
* a booking's up-front vs final charge); omit it to settle whichever single
|
||||
* invoice is currently open. Returns the most recent matching open (unpaid,
|
||||
* non-cancelled) invoice.
|
||||
* invoice is currently open. Returns the most recent matching draft-or-open
|
||||
* (unpaid, non-cancelled) invoice.
|
||||
*/
|
||||
findPayable(
|
||||
source: Freight.InvoiceSource,
|
||||
@@ -621,7 +724,7 @@ export class BillingService {
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In(OPEN_STATUSES),
|
||||
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
@@ -629,56 +732,24 @@ export class BillingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a source's currently-open invoice as paid and link the gateway
|
||||
* payment, then emit `${source}.invoice.paid`. Resolves the open invoice then
|
||||
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
|
||||
* settlement. No-op (returns null) when the source has no open invoice.
|
||||
*
|
||||
* Type-blind by design: settles whichever invoice is due; any per-type reaction
|
||||
* belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`.
|
||||
* Pass the caller's transaction `manager` to enlist in its DB transaction.
|
||||
*
|
||||
* NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded`
|
||||
* event ({@link settleByPaymentId}); this source-keyed settle is a generic helper
|
||||
* for callers that settle by source rather than by gateway intent id.
|
||||
* Pass `type` to select a specific invoice when a source carries several (e.g.
|
||||
* a booking's up-front vs final charge); omit it to settle whichever single
|
||||
* invoice is currently open. Returns the most recent matching open (unpaid,
|
||||
* non-cancelled) invoice.
|
||||
*/
|
||||
async settlePayable(
|
||||
findInvoice(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
paymentId: string | null,
|
||||
manager?: EntityManager,
|
||||
type?: string,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: In(OPEN_STATUSES) },
|
||||
return this.dataSource.getRepository(Invoice).findOne({
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
|
||||
* Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
|
||||
* No-op (returns null) when the source has no paid invoice.
|
||||
*
|
||||
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
|
||||
* to enlist in its DB transaction.
|
||||
*/
|
||||
async refundPayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: Freight.InvoiceStatus.Paid },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsRefunded(invoice.id, mg);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -694,11 +765,17 @@ export class BillingService {
|
||||
async expirePayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
type?: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: In(OPEN_STATUSES) },
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In(OPEN_STATUSES),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
@@ -722,17 +799,29 @@ export class BillingService {
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
dueAt: Date,
|
||||
type?: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: In(OPEN_STATUSES) },
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In(OPEN_STATUSES),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return;
|
||||
await mg.update(Invoice, { id: invoice.id }, { dueAt });
|
||||
}
|
||||
|
||||
/**
|
||||
* Force an invoice to `status`, including issuing a still-DRAFT invoice
|
||||
* (stamping `issuedAt`) — unlike the other transitions here, this is a
|
||||
* blunt admin/workflow override, not a settlement. No-op when the invoice
|
||||
* is missing or already terminal (paid/cancelled/refunded/expired).
|
||||
*/
|
||||
async updateStatus(
|
||||
invoiceId: string,
|
||||
status: Freight.InvoiceStatus,
|
||||
@@ -740,29 +829,36 @@ export class BillingService {
|
||||
): Promise<void> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { id: invoiceId, status: In(OPEN_STATUSES) },
|
||||
order: { issuedAt: "DESC" },
|
||||
where: {
|
||||
id: invoiceId,
|
||||
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||
},
|
||||
});
|
||||
if (!invoice) return;
|
||||
await mg.update(Invoice, { id: invoice.id }, { status });
|
||||
await mg.update(
|
||||
Invoice,
|
||||
{ id: invoice.id },
|
||||
{ status, issuedAt: invoice.issuedAt ?? new Date() },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
|
||||
|
||||
/**
|
||||
* Charge a source's open invoice through the payment gateway. Billing is the
|
||||
* single place that turns "what is owed" (the invoice) into a payment intent —
|
||||
* the domain never talks to the payment service directly. Resolves the open
|
||||
* invoice, opens an intent for `invoice.totalAmount`, records the intent id on
|
||||
* the invoice (the settlement correlation key), and returns the client action.
|
||||
* Charge an invoice through the payment gateway. Billing is the single place
|
||||
* that turns "what is owed" (the invoice) into a payment intent — the domain
|
||||
* never talks to the payment service directly. Resolves the invoice by ID,
|
||||
* opens an intent for `invoice.balanceAmount` (so partial payments are honored),
|
||||
* records the intent id on the invoice (the settlement correlation key), and
|
||||
* returns the client action.
|
||||
*
|
||||
* When the provider settles synchronously, the invoice is settled inline here —
|
||||
* after the intent id is stored — so the `payment.succeeded` correlation can
|
||||
* never fire before the link exists. Throws when the source has no open invoice.
|
||||
* never fire before the link exists. Throws when the invoice is not found or
|
||||
* not in an open/payable status.
|
||||
*/
|
||||
async payInvoice(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
invoiceId: string,
|
||||
opts: {
|
||||
method?: string;
|
||||
platform?: "web" | "mobile";
|
||||
@@ -771,15 +867,22 @@ export class BillingService {
|
||||
failureUrl?: string;
|
||||
} = {},
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.findPayable(source, sourceId);
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { id: invoiceId, status: In(OPEN_STATUSES) },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(
|
||||
`No open invoice to charge for ${source}:${sourceId}`,
|
||||
`Invoice ${invoiceId} not found or not in a payable status`,
|
||||
);
|
||||
}
|
||||
|
||||
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
||||
if (!(amountDue > 0)) {
|
||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
||||
}
|
||||
|
||||
const result = await this.payment.initiate({
|
||||
referenceId: sourceId,
|
||||
referenceId: invoice.sourceId,
|
||||
source: invoice.source,
|
||||
// Freight payments settle under the generic SHIPMENT reference — how the
|
||||
// payment service attributes them to the freight API. The payment ↔ invoice
|
||||
@@ -787,8 +890,8 @@ export class BillingService {
|
||||
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
|
||||
// service branches on a domain-specific reference type.
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
orderRef: invoice.invoiceNumber,
|
||||
amountMinor: Math.round(Number(invoice.totalAmount)),
|
||||
orderRef: invoice.invoiceNumber.replace("-", "_"),
|
||||
amountMinor: Math.round(Number(invoice.balanceAmount)),
|
||||
currency: invoice.currency,
|
||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
@@ -823,8 +926,8 @@ export class BillingService {
|
||||
*/
|
||||
async settleByPaymentId(
|
||||
paymentId: string,
|
||||
_providerTxnId?: string,
|
||||
_paidAt?: Date,
|
||||
providerTxnId?: string,
|
||||
paidAt?: Date,
|
||||
): Promise<Invoice | null> {
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { paymentId, status: In(OPEN_STATUSES) },
|
||||
@@ -832,6 +935,9 @@ export class BillingService {
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsPaid(invoice.id, paymentId);
|
||||
return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, {
|
||||
providerTxnId,
|
||||
paidAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
/** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */
|
||||
export interface SqlRunner {
|
||||
query(sql: string, params?: unknown[]): Promise<Array<{ seq: number | string }>>;
|
||||
query(sql: string, params?: unknown[]): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface InvoiceNumberOptions {
|
||||
@@ -34,11 +34,18 @@ export async function nextDailyInvoiceNumber(
|
||||
const prefix = `${opts.code}-${ymd}-`;
|
||||
const column = opts.column ?? "invoice_number";
|
||||
|
||||
const [row] = await runner.query(
|
||||
// Serialize concurrent allocation for this exact day+code prefix so two
|
||||
// simultaneous transactions can't both read the same MAX(seq) and mint a
|
||||
// duplicate number. Session-scoped to the caller's transaction — released
|
||||
// automatically on commit/rollback. Different prefixes hash to different
|
||||
// keys and never contend with each other.
|
||||
await runner.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [prefix]);
|
||||
|
||||
const rows = (await runner.query(
|
||||
`SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq
|
||||
FROM ${opts.table} WHERE ${column} LIKE $1`,
|
||||
[`${prefix}%`],
|
||||
);
|
||||
const next = Number(row?.seq ?? 0) + 1;
|
||||
)) as Array<{ seq: number | string }>;
|
||||
const next = Number(rows[0]?.seq ?? 0) + 1;
|
||||
return `${prefix}${String(next).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@ import {
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import { BillingService } from "./billing.service";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
@@ -27,25 +26,24 @@ import {
|
||||
} from "../payment/payments.dto";
|
||||
|
||||
/**
|
||||
* Booking-payment entrypoints. This is the ONE place that knows a payment is for a
|
||||
* booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands
|
||||
* off to billing, which resolves the invoice/amount and drives the gateway. Billing
|
||||
* and payment stay source-agnostic; the booking knowledge lives here, in the domain.
|
||||
* Central payment entrypoints. Domain-agnostic — the caller supplies an
|
||||
* invoice ID and the billing service resolves the amount and drives the
|
||||
* gateway. The domain never talks to the payment service directly.
|
||||
* Routes are unchanged (`/payments/*`) so the portal is unaffected.
|
||||
*/
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
export class BookingPaymentController {
|
||||
export class PaymentController {
|
||||
constructor(private readonly billing: BillingService) { }
|
||||
|
||||
@Post("initiate")
|
||||
@ApiOperation({
|
||||
summary: "Initiate payment for a freight booking",
|
||||
description: "Charges the booking's open invoice through the payment gateway.",
|
||||
summary: "Initiate payment for an invoice",
|
||||
description: "Charges the invoice through the payment gateway.",
|
||||
})
|
||||
@ApiOkResponse({ type: InitiateResponseDto })
|
||||
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, {
|
||||
return this.billing.payInvoice(dto.invoiceId, {
|
||||
method: dto.method,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
@@ -59,23 +57,23 @@ export class BookingPaymentController {
|
||||
@ApiOperation({
|
||||
summary: "Browser checkout redirect",
|
||||
description:
|
||||
"Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
|
||||
"Charges the invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
|
||||
})
|
||||
@ApiQuery({ name: "bookingId", required: true })
|
||||
@ApiQuery({ name: "invoiceId", required: true })
|
||||
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
|
||||
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
||||
@ApiProduces("text/html")
|
||||
async checkout(
|
||||
@Query("bookingId") bookingId: string,
|
||||
@Query("invoiceId") invoiceId: string,
|
||||
@Query("method") method: PaymentMethodTypeEnum,
|
||||
@Query("platform") platform: PaymentPlatformDto = "web",
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!bookingId) {
|
||||
if (!invoiceId) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
|
||||
.send(this.buildErrorHtml("Missing required query parameter: invoiceId"));
|
||||
}
|
||||
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
|
||||
return res
|
||||
@@ -86,8 +84,7 @@ export class BookingPaymentController {
|
||||
|
||||
try {
|
||||
const result = await this.billing.payInvoice(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
invoiceId,
|
||||
{ method, platform },
|
||||
);
|
||||
const url =
|
||||
@@ -1,7 +1,13 @@
|
||||
import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { Freight } from "@edr/types";
|
||||
import { DataSource } from "typeorm";
|
||||
import { DataSource, EntityManager } from "typeorm";
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
@@ -58,9 +64,9 @@ export class BookingInvoiceService {
|
||||
* Ensure the booking has its invoice, generating one from the snapshotted
|
||||
* pricing breakdown if absent. Called when a booking reaches a billable state.
|
||||
* Idempotent — returns the existing open invoice instead of a duplicate.
|
||||
* Returns `null` (and logs) when the booking is not billable: no company to
|
||||
* bill (e.g. government bookings whose `companyId` is null, which the invoices
|
||||
* FK requires), or no priced amount.
|
||||
* Throws `BadRequestException` when the booking is not billable: no company
|
||||
* to bill (e.g. government bookings whose `companyId` is null, which the
|
||||
* invoices FK requires), or no priced amount.
|
||||
*/
|
||||
async ensureInvoiceForBooking(
|
||||
booking: Booking,
|
||||
@@ -74,8 +80,8 @@ export class BookingInvoiceService {
|
||||
if (existing) return existing;
|
||||
|
||||
if (!booking.companyId) {
|
||||
this.logger.warn(
|
||||
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
|
||||
throw new BadRequestException(
|
||||
`Cannot generate invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,6 +97,9 @@ export class BookingInvoiceService {
|
||||
*/
|
||||
@OnEvent("booking.invoice.paid")
|
||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
this.logger.log(
|
||||
`onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`,
|
||||
);
|
||||
switch (payload.type) {
|
||||
case "PREPAID":
|
||||
await this.advanceBookingOnPayment(payload.sourceId);
|
||||
@@ -102,7 +111,13 @@ export class BookingInvoiceService {
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus = this.billing.updateStatus;
|
||||
updateStatus(
|
||||
invoiceId: string,
|
||||
status: Freight.InvoiceStatus,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
return this.billing.updateStatus(invoiceId, status, manager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a booking once its prepaid invoice settles — the domain side-effect
|
||||
@@ -123,7 +138,7 @@ export class BookingInvoiceService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (booking.paymentStatus === "PAID") return;
|
||||
// if (booking.paymentStatus === "PAID") return;
|
||||
|
||||
await this.dataSource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
@@ -131,9 +146,16 @@ export class BookingInvoiceService {
|
||||
{ id: bookingId },
|
||||
{ paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
await this.firstMile.acceptBooking(bookingId);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.firstMile.acceptBooking(bookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error accepting first-mile after payment: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.bookingBatch.ensurePaidBookingAllocated(bookingId);
|
||||
} catch (err) {
|
||||
@@ -165,7 +187,11 @@ export class BookingInvoiceService {
|
||||
// Fall back to a single freight line when no breakdown was snapshotted.
|
||||
if (lines.length === 0) {
|
||||
const amount = Number(booking.totalAmount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) throw new Error("No price");
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot generate invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
|
||||
);
|
||||
}
|
||||
lines.push({
|
||||
chargeType: "FREIGHT",
|
||||
description: "Rail freight",
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Freight } from '@edr/types';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
|
||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||
|
||||
@Injectable()
|
||||
export class BookingPaymentService {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly billing: BillingService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Start payment for a booking. The booking never touches the payment gateway
|
||||
* directly — it charges its invoice through billing, which resolves the amount
|
||||
* and drives the provider. Returns the provider redirect URL (empty when none).
|
||||
*/
|
||||
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
|
||||
|
||||
const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, {
|
||||
method: PaymentMethodTypeEnum.TELEBIRR,
|
||||
platform: 'web',
|
||||
});
|
||||
|
||||
const action = resp.clientAction as { type?: string; url?: string } | undefined;
|
||||
return {
|
||||
redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '',
|
||||
};
|
||||
}
|
||||
|
||||
private async requireBooking(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findById(id);
|
||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||
return booking;
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,6 @@ export function buildCargoTypeTree(
|
||||
id: child.id,
|
||||
name: child.cargoTypeName,
|
||||
code: child.code,
|
||||
show_free_text_box: child.showFreeTextBox,
|
||||
unit_of_measure: child.unitOfMeasure ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -35,6 +35,8 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
{} as never, // fileUploadSettingsService
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository, ruleEngineService };
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
fileUploadSettingsService as never,
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
@@ -128,6 +130,8 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
@@ -196,6 +200,8 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository, filesService };
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ describe('BookingTransitionService — operation review', () => {
|
||||
{} as never, // fileUploadSettingsService
|
||||
bookingBatchService as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService };
|
||||
}
|
||||
|
||||
@@ -7,28 +7,30 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { assertCanApproveBookingStep } from "../../common/freight-permission.util";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { eatDay } from "../train-scheduling/batch-window.util";
|
||||
import { isRoadService } from "./road.util";
|
||||
import { RuleEngineService } from "../rule-engine/rule-engine.service";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
||||
import { BookingContractService } from "./booking-contract.service";
|
||||
import { BookingPricingService } from "./booking-pricing.service";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { assertBookingStatus } from "./booking-status.util";
|
||||
import { clearanceCodesForBooking } from "./clearance.util";
|
||||
import {
|
||||
computeNextStep,
|
||||
type BookingNextStep,
|
||||
} from "./booking-next-step.util";
|
||||
import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto";
|
||||
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { isRoadService } from './road.util';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { clearanceCodesForBooking } from './clearance.util';
|
||||
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
||||
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
|
||||
import { Freight } from "@edr/types";
|
||||
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||
|
||||
@Injectable()
|
||||
export class BookingTransitionService {
|
||||
@@ -44,8 +46,17 @@ export class BookingTransitionService {
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
@Inject(forwardRef(() => BookingsService))
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
) { }
|
||||
@Inject(forwardRef(() => BookingClearanceService))
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
@Inject(forwardRef(() => ClearanceWorkflowService))
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
|
||||
) {}
|
||||
|
||||
private isPhasedGeneralCustoms(booking: Booking): boolean {
|
||||
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
|
||||
}
|
||||
|
||||
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
@@ -447,6 +458,7 @@ export class BookingTransitionService {
|
||||
"CHANGES_REQUESTED",
|
||||
"PENDING_APPROVAL",
|
||||
"CONTRACT_READY",
|
||||
"OPERATION_REQUEST_PENDING",
|
||||
]);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
@@ -510,8 +522,19 @@ export class BookingTransitionService {
|
||||
note: string | null;
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
phase?: string | null;
|
||||
milestones?: unknown[];
|
||||
nextAction?: unknown;
|
||||
dutyRequired?: boolean | null;
|
||||
roHold?: boolean;
|
||||
roHoldReason?: string | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
operationReady?: boolean;
|
||||
}> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
return this.bookingClearanceService.getClearanceView(bookingId);
|
||||
}
|
||||
const { inputCode, outputCode, includesCustoms } =
|
||||
clearanceCodesForBooking(booking);
|
||||
|
||||
@@ -667,6 +690,18 @@ export class BookingTransitionService {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "DOCUMENTS_UNDER_REVIEW",
|
||||
} as never);
|
||||
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
await this.workflowService.onCustomerDocsUploadedForBooking(
|
||||
bookingId,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||
} as never);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
@@ -734,6 +769,15 @@ export class BookingTransitionService {
|
||||
"A note is required when querying a document",
|
||||
);
|
||||
}
|
||||
if (
|
||||
status === 'QUERIED' &&
|
||||
this.isPhasedGeneralCustoms(booking) &&
|
||||
booking.preClearanceFinalizedAt
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Customer documents cannot be queried after pre-clearance is finalized.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.setDocumentReviewStatus(
|
||||
bookingId,
|
||||
@@ -750,8 +794,30 @@ export class BookingTransitionService {
|
||||
"CHANGES_REQUESTED",
|
||||
staffId,
|
||||
);
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
return this.bookingsService.findById(bookingId);
|
||||
|
||||
const updated = await this.bookingsService.findById(bookingId);
|
||||
if (this.isPhasedGeneralCustoms(updated)) {
|
||||
const allApproved = await this.isClearanceFullyApproved(updated);
|
||||
if (allApproved) {
|
||||
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
||||
const phase =
|
||||
updated.tradeDirection === 'EXPORT'
|
||||
? ContractDocPhase.GlDjCollection
|
||||
: ContractDocPhase.GlEtOutput;
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: phase,
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */
|
||||
@@ -787,7 +853,12 @@ export class BookingTransitionService {
|
||||
*/
|
||||
async finalizeClearance(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
throw new BadRequestException(
|
||||
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
|
||||
);
|
||||
}
|
||||
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||
|
||||
const approved = await this.isClearanceFullyApproved(booking);
|
||||
if (!approved) {
|
||||
|
||||
@@ -12,14 +12,15 @@ import {
|
||||
Request,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
@@ -30,17 +31,22 @@ import {
|
||||
} from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
|
||||
import { BookingContractService } from "./booking-contract.service";
|
||||
import { BookingPricingService } from "./booking-pricing.service";
|
||||
import { BookingTransitionService } from "./booking-transition.service";
|
||||
import { BookingReferenceDataService } from "./booking-reference-data.service";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto";
|
||||
import { CreateBookingDto } from "./dto/create-booking.dto";
|
||||
import { BookingListSummaryDto } from "./dto/booking-list-summary.dto";
|
||||
import { FilterBookingDto } from "./dto/filter-booking.dto";
|
||||
import { GeneratePriceResponseDto } from "./dto/generate-price-response.dto";
|
||||
import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto";
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
||||
import {
|
||||
AdviseContractDutyDto,
|
||||
RoAmendmentDto,
|
||||
} from '../contracts/dto/phased-clearance.dto';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
||||
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import {
|
||||
AcceptIntakeDto,
|
||||
ApproveStepDto,
|
||||
@@ -52,10 +58,11 @@ import {
|
||||
RequestOperationDto,
|
||||
OperationReviewDto,
|
||||
StaffRejectDto,
|
||||
} from "./dto/request-changes.dto";
|
||||
import { ContractViewDto } from "./dto/contract-view.dto";
|
||||
import { SignContractDto } from "./dto/sign-contract.dto";
|
||||
import { UpdateBookingDto } from "./dto/update-booking.dto";
|
||||
} from './dto/request-changes.dto';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
@@ -75,7 +82,8 @@ export class BookingsController {
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly transitionService: BookingTransitionService,
|
||||
private readonly contractService: BookingContractService,
|
||||
) { }
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@@ -268,7 +276,40 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(":id/tracking")
|
||||
@Post(':id/customer-truck-assignment')
|
||||
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
|
||||
async assignCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CustomerTruckAssignmentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
const assigned = await this.bookingsService.assignCustomerTruck(id, dto);
|
||||
return this.transitionService.enrichBookingResponse(assigned);
|
||||
}
|
||||
|
||||
@Get(':id/customer-truck-assignment/freight-order')
|
||||
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
|
||||
async customerTruckFreightOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
const { filename, buffer } =
|
||||
await this.bookingsService.customerTruckFreightOrderCopies(id);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/tracking')
|
||||
@ApiOperation({
|
||||
summary: "Shipment tracking timeline for a booking",
|
||||
description:
|
||||
@@ -358,7 +399,21 @@ export class BookingsController {
|
||||
|
||||
// ── Document clearance (post counter-sign) ────────────────────────────────
|
||||
|
||||
@Get(":id/clearance")
|
||||
@Get('clearance/et-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' })
|
||||
getBookingEtClearanceQueue() {
|
||||
return this.bookingClearanceService.etQueue();
|
||||
}
|
||||
|
||||
@Get('clearance/dj-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({ summary: 'GL DJ queue — general customs bookings awaiting DJ action' })
|
||||
getBookingDjClearanceQueue() {
|
||||
return this.bookingClearanceService.djQueue();
|
||||
}
|
||||
|
||||
@Get(':id/clearance')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Document-clearance grid (required docs + upload + GL review status)",
|
||||
@@ -469,7 +524,161 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/staff/request-changes")
|
||||
@Post(':id/clearance/declaration')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET uploads customs declaration on booking (GENERAL customs)' })
|
||||
async uploadBookingDeclaration(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDeclaration(
|
||||
id,
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise)
|
||||
@UseInterceptors(FileInterceptor('attachment'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET sets duty/tax on booking with notice attachment' })
|
||||
async adviseBookingDuty(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('dutyRequired') dutyRequiredRaw: string,
|
||||
@Body('amount') amountRaw: string | undefined,
|
||||
@Body('currency') currency: string | undefined,
|
||||
@Body('declarationSerial') declarationSerial: string | undefined,
|
||||
@UploadedFile() attachment: Express.Multer.File | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1';
|
||||
const dto: AdviseContractDutyDto = {
|
||||
dutyRequired,
|
||||
amount:
|
||||
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
|
||||
currency: currency ?? 'ETB',
|
||||
declarationSerial,
|
||||
};
|
||||
const booking = await this.bookingClearanceService.adviseDuty(
|
||||
id,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
attachment,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize-pre-clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
|
||||
async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.bookingClearanceService.finalizePreClearance(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty-slip')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' })
|
||||
async uploadBookingDutySlip(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDutySlip(id, file);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/transit-permit')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async uploadBookingTransitPermit(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadTransitPermit(
|
||||
id,
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/delivery-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async uploadBookingDeliveryOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/release-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async uploadBookingReleaseOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const result = await this.bookingClearanceService.uploadReleaseOrder(
|
||||
id,
|
||||
file,
|
||||
vesselDepartureDate,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return {
|
||||
...this.transitionService.enrichBookingResponse(result.booking),
|
||||
hold: result.hold,
|
||||
holdReason: result.holdReason,
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':id/clearance/ro-amendment')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
async requestBookingRoAmendment(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RoAmendmentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.requestRoAmendment(
|
||||
id,
|
||||
dto.note,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/export-release')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
async confirmBookingExportRelease(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.confirmExportRelease(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
|
||||
@ApiOperation({ summary: "Staff return booking for customer updates" })
|
||||
async requestChanges(
|
||||
|
||||
@@ -4,36 +4,37 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
|
||||
|
||||
// import { CustomersModule } from '../customers/customers.module';
|
||||
import { CompaniesModule } from "../companies/companies.module";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { RuleEngineModule } from "../rule-engine/rule-engine.module";
|
||||
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
|
||||
import { SignaturesModule } from "../signatures/signatures.module";
|
||||
import { BillingModule } from "../billing/billing.module";
|
||||
import { FirstMileModule } from "../first-mile/first-mile.module";
|
||||
import { BookingContractService } from "./booking-contract.service";
|
||||
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||
import { BookingPaymentController } from "./booking-payment.controller";
|
||||
import { BookingPaymentService } from "./booking-payment.service";
|
||||
import { BookingPricingService } from "./booking-pricing.service";
|
||||
import { BookingReferenceDataService } from "./booking-reference-data.service";
|
||||
import { BookingTransitionService } from "./booking-transition.service";
|
||||
import { BookingsController } from "./bookings.controller";
|
||||
import { PayController } from "./pay.controller";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { ConsolidationService } from "./consolidation.service";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingApprovalStep } from "./entities/booking-approval-step.entity";
|
||||
import { BookingCargoModifier } from "./entities/booking-cargo-modifier.entity";
|
||||
import { BookingDocumentReview } from "./entities/booking-document-review.entity";
|
||||
import { BookingContainer } from "./entities/booking-container.entity";
|
||||
import { BookingRateSnapshot } from "./entities/booking-rate-snapshot.entity";
|
||||
import { BookingContractSignature } from "./entities/booking-contract-signature.entity";
|
||||
import { BookingReviewNote } from "./entities/booking-review-note.entity";
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { FirstMileModule } from '../first-mile/first-mile.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingInvoiceService } from './booking-invoice.service';
|
||||
// import { BookingPaymentController } from './booking-payment.controller';
|
||||
// import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
// import { PayController } from './pay.controller';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
||||
import { ContractPdfService } from "../../contracts/contract-pdf.service";
|
||||
import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder";
|
||||
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
||||
import { ContractTemplateResolver } from "../../contracts/contract-template.resolver";
|
||||
@@ -57,6 +58,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BillingModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
forwardRef(() => ContractsModule),
|
||||
forwardRef(() => ContractsModule),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
VehiclesModule,
|
||||
@@ -71,7 +74,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
config.get<ExchangeOptions>("app.cbeExchange") ?? {},
|
||||
}),
|
||||
],
|
||||
controllers: [BookingsController, PayController, BookingPaymentController],
|
||||
controllers: [BookingsController],
|
||||
providers: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
@@ -81,7 +84,6 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingTransitionService,
|
||||
BookingContractService,
|
||||
BookingInvoiceService,
|
||||
BookingPaymentService,
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
ContractPricingScheduleBuilder,
|
||||
|
||||
@@ -121,6 +121,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
weightResult: ContainerWeightResult;
|
||||
}>,
|
||||
): Promise<BookingContainer[]> {
|
||||
@@ -133,11 +135,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
|
||||
const totalVgm = item.quantity * item.vgmPerUnitTons;
|
||||
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
|
||||
// A per-line breakdown can never exceed the line's own quantity.
|
||||
const clamp = (v?: number) =>
|
||||
Math.max(0, Math.min(item.quantity, Math.floor(Number(v ?? 0)) || 0));
|
||||
|
||||
const row = containerRepo.create({
|
||||
bookingId,
|
||||
containerTypeId: item.containerTypeId,
|
||||
quantity: item.quantity,
|
||||
hazardousQuantity: clamp(item.hazardousQuantity),
|
||||
reeferQuantity: clamp(item.reeferQuantity),
|
||||
vgmPerUnitTons: item.vgmPerUnitTons,
|
||||
totalVgmTons: totalVgm,
|
||||
wagonsRequired,
|
||||
@@ -483,6 +490,15 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Bookings in any of the given statuses (clearance queue helpers). */
|
||||
async findByStatuses(statuses: string[]): Promise<Booking[]> {
|
||||
if (!statuses.length) return [];
|
||||
return this.repository.find({
|
||||
where: { status: In(statuses) },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
|
||||
async findQueue(options: {
|
||||
status: string | string[];
|
||||
|
||||
@@ -47,6 +47,8 @@ import {
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
|
||||
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
|
||||
export interface PaginatedBookings {
|
||||
@@ -69,6 +71,17 @@ const NEEDS_ACTION_STATUSES = [
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Clamp a bulk hazardous/reefer amount into 0..cargoAmount: it can never exceed
|
||||
* the total cargo it's a portion of, and is never negative.
|
||||
*/
|
||||
function clampToCargo(value: number | undefined, cargoAmount: number): number {
|
||||
const v = Number(value ?? 0);
|
||||
if (!Number.isFinite(v) || v <= 0) return 0;
|
||||
const cap = Number.isFinite(cargoAmount) && cargoAmount > 0 ? cargoAmount : 0;
|
||||
return Math.min(v, cap);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
constructor(
|
||||
@@ -84,8 +97,62 @@ export class BookingsService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly contractPdfService: ContractPdfService,
|
||||
) {}
|
||||
|
||||
async assignCustomerTruck(
|
||||
bookingId: string,
|
||||
dto: CustomerTruckAssignmentDto,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim());
|
||||
const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim());
|
||||
const usesMileService =
|
||||
booking.tradeDirection === 'IMPORT'
|
||||
? hasLastMile
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? hasFirstMile
|
||||
: hasFirstMile || hasLastMile;
|
||||
if (usesMileService) {
|
||||
throw new BadRequestException(
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
|
||||
);
|
||||
}
|
||||
if (booking.customerTruckAssignedAt) {
|
||||
throw new ConflictException('Customer truck assignment is already submitted and locked');
|
||||
}
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
throw new BadRequestException('Booking must be paid before assigning an external customer truck');
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'TRUCK_ASSIGNED',
|
||||
customerTruckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||
customerTruckDriverName: dto.driverName.trim(),
|
||||
customerTruckType: dto.truckType.trim(),
|
||||
customerTruckContainerNumber: dto.containerNumberToLoad.trim().toUpperCase(),
|
||||
customerTruckAssignedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.findById(bookingId);
|
||||
}
|
||||
|
||||
async customerTruckFreightOrderCopies(
|
||||
bookingId: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
if (!booking.customerTruckAssignedAt) {
|
||||
throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
|
||||
}
|
||||
|
||||
const html = this.buildCustomerTruckFreightOrderHtml(booking);
|
||||
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
|
||||
return {
|
||||
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve trade direction from yard countries; reject client mismatch. */
|
||||
private async resolveTradeDirectionForBooking(
|
||||
originYardId: string,
|
||||
@@ -123,6 +190,79 @@ export class BookingsService {
|
||||
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
private buildCustomerTruckFreightOrderHtml(booking: Booking): string {
|
||||
const assignedAt = booking.customerTruckAssignedAt
|
||||
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
|
||||
: '-';
|
||||
const rows: Array<[string, string | null | undefined]> = [
|
||||
['Booking Reference', booking.reference],
|
||||
['Client Name', booking.company?.name],
|
||||
['Client ID', booking.companyId],
|
||||
['Trade Direction', booking.tradeDirection],
|
||||
['Freight Type', booking.freightType],
|
||||
['Truck Plate Number', booking.customerTruckPlateNumber],
|
||||
['Driver Name', booking.customerTruckDriverName],
|
||||
['Truck Type', booking.customerTruckType],
|
||||
['Container Number to Load', booking.customerTruckContainerNumber],
|
||||
['Assigned At', assignedAt],
|
||||
['Booking Status', booking.status],
|
||||
];
|
||||
const rowHtml = rows
|
||||
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
|
||||
.join('');
|
||||
const copy = (watermark: string) => `
|
||||
<section class="copy">
|
||||
<div class="watermark">${this.escapeHtml(watermark)}</div>
|
||||
<header>
|
||||
<div>
|
||||
<h1>Freight Order</h1>
|
||||
<p>Customer external truck assignment</p>
|
||||
</div>
|
||||
<strong>${this.escapeHtml(booking.reference)}</strong>
|
||||
</header>
|
||||
<table>${rowHtml}</table>
|
||||
<div class="signatures">
|
||||
<div>Customer / Carrier Signature</div>
|
||||
<div>Port Operations Verification</div>
|
||||
<div>Gate Security Verification</div>
|
||||
</div>
|
||||
</section>`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #10202f; margin: 0; }
|
||||
.copy { position: relative; min-height: 46vh; padding: 28px 32px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
|
||||
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
|
||||
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
|
||||
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
|
||||
p { margin: 4px 0 0; color: #64748b; }
|
||||
strong { font-size: 16px; color: #0a9f6a; }
|
||||
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
|
||||
th { width: 34%; background: #f1f5f9; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
|
||||
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${copy('Copy 1: Port Operations Copy')}
|
||||
${copy('Copy 2: Gate Security & Carrier Copy')}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Build evaluation input from booking freight shape. */
|
||||
/**
|
||||
* Whether a service type bundles customs clearance. This is the single source
|
||||
@@ -509,6 +649,16 @@ export class BookingsService {
|
||||
// the container type at pricing time, so the booking-level flag stays off
|
||||
// for container freight to avoid double-counting.
|
||||
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
|
||||
// Bulk-only hazardous/reefer amount, clamped to the cargo amount. Container
|
||||
// freight tracks this per line, so these are 0 for CONTAINER.
|
||||
bulkHazardousQuantity:
|
||||
dto.freightType === 'BULK'
|
||||
? clampToCargo(dto.bulkHazardousQuantity, dto.cargoTotalWeightVgm)
|
||||
: 0,
|
||||
bulkReeferQuantity:
|
||||
dto.freightType === 'BULK'
|
||||
? clampToCargo(dto.bulkReeferQuantity, dto.cargoTotalWeightVgm)
|
||||
: 0,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
pnrCode: dto.pnrCode,
|
||||
financialTerms: dto.financialTerms,
|
||||
@@ -531,6 +681,8 @@ export class BookingsService {
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
hazardousQuantity: c.hazardousQuantity,
|
||||
reeferQuantity: c.reeferQuantity,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
@@ -668,6 +820,8 @@ export class BookingsService {
|
||||
containers,
|
||||
);
|
||||
|
||||
const cargoAmount =
|
||||
dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0);
|
||||
const updates: Record<string, unknown> = {
|
||||
...dto,
|
||||
freightType,
|
||||
@@ -678,6 +832,22 @@ export class BookingsService {
|
||||
freightType === 'BULK'
|
||||
? (dto.isReefer ?? existing.isReefer ?? false)
|
||||
: false,
|
||||
// Bulk-only hazardous/reefer amount, clamped to the cargo amount; 0 for
|
||||
// container freight (per-line on the containers instead).
|
||||
bulkHazardousQuantity:
|
||||
freightType === 'BULK'
|
||||
? clampToCargo(
|
||||
dto.bulkHazardousQuantity ?? Number(existing.bulkHazardousQuantity ?? 0),
|
||||
cargoAmount,
|
||||
)
|
||||
: 0,
|
||||
bulkReeferQuantity:
|
||||
freightType === 'BULK'
|
||||
? clampToCargo(
|
||||
dto.bulkReeferQuantity ?? Number(existing.bulkReeferQuantity ?? 0),
|
||||
cargoAmount,
|
||||
)
|
||||
: 0,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
tradeDirection,
|
||||
};
|
||||
@@ -724,6 +894,8 @@ export class BookingsService {
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
hazardousQuantity: c.hazardousQuantity,
|
||||
reeferQuantity: c.reeferQuantity,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -72,9 +72,6 @@ export class BookingReferenceCargoTypeChildDto {
|
||||
@ApiProperty({ example: 'BULK_COFFEE' })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
show_free_text_box!: boolean;
|
||||
|
||||
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false })
|
||||
unit_of_measure?: CargoUnitOfMeasure | null;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,28 @@ export class CreateBookingContainerDto {
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
vgmPerUnitTons!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'How many of this line are hazardous (0..quantity)',
|
||||
minimum: 0,
|
||||
default: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
hazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'How many of this line are refrigerated (0..quantity)',
|
||||
minimum: 0,
|
||||
default: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
reeferQuantity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,6 +342,25 @@ export class CreateBookingDto {
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReefer?: boolean;
|
||||
|
||||
/**
|
||||
* Bulk-only: how much of the cargo is hazardous / refrigerated, in the cargo's
|
||||
* unit of measure (tons for PER_TON, item count for PER_ITEM). Must not exceed
|
||||
* cargoTotalWeightVgm. Ignored for container freight (per-line on containers).
|
||||
*/
|
||||
@ApiPropertyOptional({ minimum: 0, default: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
bulkHazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0, default: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
bulkReeferQuantity?: number;
|
||||
|
||||
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency!: string;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export const CUSTOMER_TRUCK_TYPES = [
|
||||
'Flatbed',
|
||||
'Container Chassis',
|
||||
'Lowboy',
|
||||
'Box Truck',
|
||||
'Tipper',
|
||||
] as const;
|
||||
|
||||
export class CustomerTruckAssignmentDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(32)
|
||||
truckPlateNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
driverName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(CUSTOMER_TRUCK_TYPES)
|
||||
truckType!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(16)
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
containerNumberToLoad!: string;
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export const BOOKING_STATUSES = [
|
||||
// Road (truck) drawdown orders skip the train batch pool and wait here for
|
||||
// truck dispatch after Marketing accepts; billed by KM, not wagons.
|
||||
'ROAD_DISPATCH_PENDING',
|
||||
'TRUCK_ASSIGNED',
|
||||
'OPERATION_REQUESTED',
|
||||
// Operations review gate: customer picks a schedule day and submits the
|
||||
// operation request; the operations team reviews capacity/docs/route before
|
||||
@@ -260,6 +261,24 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
|
||||
lastMileDeliveryLng?: number | null;
|
||||
|
||||
@Column({ name: 'customer_truck_plate_number', type: 'varchar', length: 32, nullable: true })
|
||||
customerTruckPlateNumber?: string | null;
|
||||
|
||||
@Column({ name: 'customer_truck_driver_name', type: 'varchar', length: 120, nullable: true })
|
||||
customerTruckDriverName?: string | null;
|
||||
|
||||
@Column({ name: 'customer_truck_type', type: 'varchar', length: 60, nullable: true })
|
||||
customerTruckType?: string | null;
|
||||
|
||||
@Column({ name: 'customer_truck_container_number', type: 'varchar', length: 16, nullable: true })
|
||||
customerTruckContainerNumber?: string | null;
|
||||
|
||||
@Column({ name: 'customer_truck_assigned_at', type: 'timestamptz', nullable: true })
|
||||
customerTruckAssignedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
|
||||
customerTruckArrivedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
|
||||
customsClearingEnabled!: boolean;
|
||||
|
||||
@@ -272,7 +291,7 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||
originYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'origin_yard_id' })
|
||||
originYard?: Yard;
|
||||
|
||||
@@ -321,6 +340,19 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
/**
|
||||
* Bulk-only hazardous / reefer amount, in the cargo's own unit of measure
|
||||
* (tons for PER_TON commodities, item count for PER_ITEM) — i.e. how much of
|
||||
* `cargoTotalWeightVgm` is hazardous / refrigerated. 0 when none. Container
|
||||
* freight carries this per line on `booking_container` instead, so these stay
|
||||
* 0 for CONTAINER bookings. The booleans above remain the surcharge trigger.
|
||||
*/
|
||||
@Column({ name: 'bulk_hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
bulkHazardousQuantity!: number;
|
||||
|
||||
@Column({ name: 'bulk_reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
bulkReeferQuantity!: number;
|
||||
|
||||
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
|
||||
paymentCurrency!: string;
|
||||
|
||||
@@ -435,6 +467,25 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true })
|
||||
glStationYardId?: string | null;
|
||||
|
||||
/** Per-booking phased clearance (GENERAL + customs). */
|
||||
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
||||
clearanceCurrentPhase?: string | null;
|
||||
|
||||
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
||||
dutyRequired?: boolean | null;
|
||||
|
||||
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
|
||||
vesselDepartureDate?: string | null;
|
||||
|
||||
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
|
||||
roAmendmentRequestedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'ro_hold_reason', type: 'text', nullable: true })
|
||||
roHoldReason?: string | null;
|
||||
|
||||
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
|
||||
/** GL staff user bound to this shipment by the station manager. */
|
||||
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
|
||||
glAssignedStaffId?: string | null;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
// import { BookingTransitionService } from './booking-transition.service';
|
||||
// import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||
// import { Booking } from './entities/booking.entity';
|
||||
// import { BookingNextStep } from './booking-next-step.util';
|
||||
|
||||
@ApiTags('payments')
|
||||
@ApiBearerAuth()
|
||||
@Controller('bookings')
|
||||
export class PayController {
|
||||
constructor(
|
||||
private readonly paymentService: BookingPaymentService,
|
||||
// private readonly transitionService: BookingTransitionService,
|
||||
) { }
|
||||
|
||||
@Post(':id/payment/pay')
|
||||
@ApiOperation({ summary: 'Complete in-app payment (mock)' })
|
||||
@ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' })
|
||||
async pay(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return await this.paymentService.pay(id);
|
||||
// const abstract = await this.transitionService.enrichBookingResponse(booking);
|
||||
// return { ...abstract, paymentReceipt: receipt };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
import { BookingClearanceService } from './booking-clearance.service';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
const generalImportBooking = {
|
||||
id: 'b-general',
|
||||
status: 'DOCUMENTS_UNDER_REVIEW',
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: 'CONTAINER',
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'GENERAL',
|
||||
contractId: 'c-1',
|
||||
dutyRequired: true,
|
||||
roHoldReason: null,
|
||||
vesselDepartureDate: null,
|
||||
} as Booking;
|
||||
|
||||
const generalExportBooking = {
|
||||
...generalImportBooking,
|
||||
id: 'b-export',
|
||||
tradeDirection: 'EXPORT',
|
||||
dutyRequired: null,
|
||||
} as Booking;
|
||||
|
||||
function makeService(overrides?: {
|
||||
booking?: Booking;
|
||||
workflowThrows?: boolean;
|
||||
}) {
|
||||
const booking = overrides?.booking ?? generalImportBooking;
|
||||
const bookingsRepository = {
|
||||
findDocumentReviews: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(booking),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
};
|
||||
const filesService = {
|
||||
upsertByCode: jest.fn().mockResolvedValue({}),
|
||||
findByResource: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const fileUploadSettingsService = {
|
||||
getByCode: jest.fn().mockRejectedValue(new Error('no setting')),
|
||||
};
|
||||
const workflowService = {
|
||||
assertPriorCompleteForBooking: overrides?.workflowThrows
|
||||
? jest.fn().mockRejectedValue(new BadRequestException('Prior milestone incomplete'))
|
||||
: jest.fn().mockResolvedValue(undefined),
|
||||
completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
listMilestonesForBooking: jest.fn().mockResolvedValue([]),
|
||||
resolvePhaseForBooking: jest.fn().mockReturnValue(null),
|
||||
computeNextActionForBooking: jest.fn().mockReturnValue(null),
|
||||
isBoundaryCompleteForBooking: jest.fn().mockResolvedValue(false),
|
||||
markReadyForOperation: jest.fn().mockResolvedValue(undefined),
|
||||
onExportReleasedForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const milestoneService = {
|
||||
adviseDuty: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const dropdownSettingsService = {
|
||||
getByCode: jest.fn().mockResolvedValue({
|
||||
children: [{ value: '2' }],
|
||||
}),
|
||||
};
|
||||
const glOperationsService = {
|
||||
t1State: jest.fn().mockResolvedValue({
|
||||
bookingId: 'b-general',
|
||||
wagonAllocated: false,
|
||||
trainDepartedAt: null,
|
||||
trainArrivedAt: null,
|
||||
closed: false,
|
||||
closedAt: null,
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new BookingClearanceService(
|
||||
bookingsRepository as never,
|
||||
bookingsService as never,
|
||||
filesService as never,
|
||||
fileUploadSettingsService as never,
|
||||
workflowService as never,
|
||||
milestoneService as never,
|
||||
dropdownSettingsService as never,
|
||||
glOperationsService as never,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
bookingsRepository,
|
||||
bookingsService,
|
||||
filesService,
|
||||
workflowService,
|
||||
milestoneService,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BookingClearanceService', () => {
|
||||
describe('adviseDuty', () => {
|
||||
it('skips duty milestones when duty is not required', async () => {
|
||||
const { service, workflowService, bookingsRepository } = makeService();
|
||||
await service.adviseDuty('b-general', { dutyRequired: false });
|
||||
|
||||
expect(workflowService.onDutySkippedForBooking).toHaveBeenCalledWith('b-general');
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-general',
|
||||
expect.objectContaining({
|
||||
dutyRequired: false,
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records duty advice when duty applies', async () => {
|
||||
const { service, milestoneService } = makeService();
|
||||
await service.adviseDuty('b-general', {
|
||||
dutyRequired: true,
|
||||
amount: 1500,
|
||||
currency: 'ETB',
|
||||
declarationSerial: 'DS-1',
|
||||
});
|
||||
|
||||
expect(milestoneService.adviseDuty).toHaveBeenCalledWith(
|
||||
'b-general',
|
||||
expect.objectContaining({ amount: 1500, currency: 'ETB', declarationSerial: 'DS-1' }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadDutySlip', () => {
|
||||
it('rejects when duty is not required', async () => {
|
||||
const { service } = makeService({
|
||||
booking: { ...generalImportBooking, dutyRequired: false } as Booking,
|
||||
});
|
||||
await expect(
|
||||
service.uploadDutySlip('b-general', { fieldname: 'file' } as Express.Multer.File),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('uploads slip and completes DUTY_TAX_PAID on happy path', async () => {
|
||||
const { service, filesService, workflowService, bookingsRepository } = makeService();
|
||||
const file = { fieldname: 'file' } as Express.Multer.File;
|
||||
|
||||
await service.uploadDutySlip('b-general', file);
|
||||
|
||||
expect(filesService.upsertByCode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceId: 'b-general',
|
||||
code: 'duty_tax_receipt',
|
||||
file,
|
||||
}),
|
||||
);
|
||||
expect(workflowService.completeMilestoneForBooking).toHaveBeenCalledWith(
|
||||
'b-general',
|
||||
'DUTY_TAX_PAID',
|
||||
);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-general',
|
||||
expect.objectContaining({
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadDeclaration', () => {
|
||||
it('rejects when a prior milestone is incomplete', async () => {
|
||||
const { service } = makeService({ workflowThrows: true });
|
||||
await expect(
|
||||
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadReleaseOrder', () => {
|
||||
it('places RO on hold when vessel departs too soon', async () => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateStr = tomorrow.toISOString().slice(0, 10);
|
||||
|
||||
const { service, bookingsRepository } = makeService({ booking: generalExportBooking });
|
||||
const result = await service.uploadReleaseOrder(
|
||||
'b-export',
|
||||
{ fieldname: 'ro' } as Express.Multer.File,
|
||||
dateStr,
|
||||
);
|
||||
|
||||
expect(result.hold).toBe(true);
|
||||
expect(result.holdReason).toMatch(/minimum lead time/i);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-export',
|
||||
expect.objectContaining({ roHoldReason: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,637 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ContractDocPhase, type ClearanceT1State } from '@edr/types';
|
||||
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
export interface BookingClearanceView {
|
||||
bookingId: string;
|
||||
status: string;
|
||||
includesCustoms: boolean;
|
||||
inputCode: string | null;
|
||||
outputCode: string | null;
|
||||
documents: Array<{
|
||||
fileKey: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
uploadedBy: 'customer' | 'gl';
|
||||
settingCode: string;
|
||||
file: { id: string; name: string; url: string } | null;
|
||||
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
|
||||
note: string | null;
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
phase?: string | null;
|
||||
milestones?: Array<{
|
||||
id: string;
|
||||
milestoneCode: string;
|
||||
milestoneLabel: string;
|
||||
status: string;
|
||||
ownerRegion?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
nextAction?: {
|
||||
actor: string;
|
||||
action: string;
|
||||
milestoneCode?: string | null;
|
||||
blockedReason?: string | null;
|
||||
} | null;
|
||||
dutyRequired?: boolean | null;
|
||||
roHold?: boolean;
|
||||
roHoldReason?: string | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
roAmendmentRequestedAt?: string | null;
|
||||
operationReady?: boolean;
|
||||
preClearanceFinalized?: boolean;
|
||||
dutyAdvice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
declarationSerial?: string | null;
|
||||
noticeFile?: { id: string; name: string; url: string } | null;
|
||||
} | null;
|
||||
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
||||
/** Import post-allocation T1 transit document state (null until wagon allocation). */
|
||||
t1?: ClearanceT1State | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingClearanceService {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
) {}
|
||||
|
||||
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
|
||||
if (!booking.customsClearingEnabled) {
|
||||
throw new BadRequestException('Phased clearance applies only to customs bookings.');
|
||||
}
|
||||
if (booking.contractKind !== 'GENERAL') {
|
||||
throw new BadRequestException('Per-booking phased clearance applies to general contracts.');
|
||||
}
|
||||
if (!booking.contractId) {
|
||||
throw new BadRequestException('Booking is not linked to a contract.');
|
||||
}
|
||||
}
|
||||
|
||||
private async loadBooking(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
await this.assertPhasedGeneralCustoms(booking);
|
||||
return booking;
|
||||
}
|
||||
|
||||
async getClearanceView(bookingId: string): Promise<BookingClearanceView> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking);
|
||||
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
||||
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
|
||||
|
||||
const documents: BookingClearanceView['documents'] = [];
|
||||
|
||||
const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => {
|
||||
if (!code) return;
|
||||
let setting;
|
||||
try {
|
||||
setting = await this.fileUploadSettingsService.getByCode(code);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const field of setting.fields ?? []) {
|
||||
const file = fileByCode.get(field.fileKey) ?? null;
|
||||
const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null;
|
||||
documents.push({
|
||||
fileKey: field.fileKey,
|
||||
label: field.fileLabel,
|
||||
required: field.isRequired,
|
||||
uploadedBy,
|
||||
settingCode: code,
|
||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await pushSetting(inputCode, 'customer');
|
||||
await pushSetting(outputCode, 'gl');
|
||||
|
||||
for (const f of files) {
|
||||
if (!f.code?.startsWith('custom_')) continue;
|
||||
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
||||
documents.push({
|
||||
fileKey: f.code,
|
||||
label: f.name,
|
||||
required: false,
|
||||
uploadedBy: 'customer',
|
||||
settingCode: 'custom',
|
||||
file: { id: f.id, name: f.name, url: f.url },
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const allApproved = await this.isClearanceFullyApproved(booking);
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||
const phase = this.workflowService.resolvePhaseForBooking(booking, milestones);
|
||||
const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones);
|
||||
const boundary = await this.workflowService.isBoundaryCompleteForBooking(
|
||||
bookingId,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
||||
const workflowFiles = buildWorkflowFiles(
|
||||
files,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
|
||||
let t1: ClearanceT1State | null = null;
|
||||
if ((booking.tradeDirection ?? 'IMPORT') === 'IMPORT') {
|
||||
try {
|
||||
t1 = await this.glOperationsService.t1State(bookingId);
|
||||
} catch {
|
||||
t1 = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
status: booking.status,
|
||||
includesCustoms,
|
||||
inputCode,
|
||||
outputCode,
|
||||
documents,
|
||||
allApproved,
|
||||
phase,
|
||||
milestones: milestones.map((m) => ({
|
||||
id: m.id,
|
||||
milestoneCode: m.milestoneCode,
|
||||
milestoneLabel: m.milestoneLabel,
|
||||
status: m.status,
|
||||
ownerRegion: m.ownerRegion,
|
||||
metadata: (m.metadata ?? null) as Record<string, unknown> | null,
|
||||
sortOrder: m.sortOrder,
|
||||
})),
|
||||
nextAction,
|
||||
dutyRequired: booking.dutyRequired ?? null,
|
||||
roHold: Boolean(booking.roHoldReason),
|
||||
roHoldReason: booking.roHoldReason ?? null,
|
||||
vesselDepartureDate: booking.vesselDepartureDate ?? null,
|
||||
roAmendmentRequestedAt: booking.roAmendmentRequestedAt
|
||||
? booking.roAmendmentRequestedAt.toISOString()
|
||||
: null,
|
||||
operationReady: boundary,
|
||||
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
|
||||
dutyAdvice,
|
||||
workflowFiles,
|
||||
t1,
|
||||
};
|
||||
}
|
||||
|
||||
private buildDutyAdvice(
|
||||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||
milestones: ClearanceMilestone[],
|
||||
): BookingClearanceView['dutyAdvice'] {
|
||||
const advised = milestones.find(
|
||||
(m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED',
|
||||
);
|
||||
if (!advised?.metadata) return null;
|
||||
const amount = advised.metadata.dutyAmount;
|
||||
const currency = advised.metadata.dutyCurrency;
|
||||
if (typeof amount !== 'number' || typeof currency !== 'string') return null;
|
||||
const notice = files.find((f) => f.code === 'duty_tax_notice');
|
||||
return {
|
||||
amount,
|
||||
currency,
|
||||
declarationSerial:
|
||||
typeof advised.metadata.declarationSerial === 'string'
|
||||
? advised.metadata.declarationSerial
|
||||
: null,
|
||||
noticeFile: notice
|
||||
? { id: notice.id, name: notice.name, url: notice.url }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
|
||||
const { inputCode } = clearanceCodesForBooking(booking);
|
||||
if (!inputCode) return true;
|
||||
let setting;
|
||||
try {
|
||||
setting = await this.fileUploadSettingsService.getByCode(inputCode);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||
if (required.length === 0) return true;
|
||||
const reviews = await this.bookingsRepository.findDocumentReviews(booking.id);
|
||||
return required.every((field) =>
|
||||
reviews.some(
|
||||
(r) =>
|
||||
r.settingCode === inputCode &&
|
||||
r.fileKey === field.fileKey &&
|
||||
r.status === 'APPROVED',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
isPhasedGeneralCustomsBooking(booking: Booking): boolean {
|
||||
return (
|
||||
Boolean(booking.customsClearingEnabled) &&
|
||||
booking.contractKind === 'GENERAL' &&
|
||||
Boolean(booking.contractId)
|
||||
);
|
||||
}
|
||||
|
||||
async uploadDeclaration(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
||||
const allApproved = await this.isClearanceFullyApproved(booking);
|
||||
if (!allApproved) {
|
||||
throw new BadRequestException(
|
||||
'All required customer documents must be approved before uploading a declaration.',
|
||||
);
|
||||
}
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
|
||||
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
|
||||
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
||||
}
|
||||
await this.workflowService.assertPriorCompleteForBooking(
|
||||
bookingId,
|
||||
tradeDirection,
|
||||
'UNDER_CUSTOMS_CLEARANCE',
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No declaration documents uploaded');
|
||||
}
|
||||
|
||||
await persistDeclarationUploads(this.filesService, bookingId, 'bookings', files);
|
||||
|
||||
await this.workflowService.onDeclarationUploadedForBooking(bookingId, userId);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase:
|
||||
tradeDirection === 'EXPORT'
|
||||
? ContractDocPhase.GlEtPostClearance
|
||||
: ContractDocPhase.CustomerDuty,
|
||||
} as never);
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async adviseDuty(
|
||||
bookingId: string,
|
||||
dto: AdviseContractDutyDto,
|
||||
userId?: string,
|
||||
attachment?: Express.Multer.File,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Duty advice applies only to import bookings.');
|
||||
}
|
||||
await this.workflowService.assertPriorCompleteForBooking(
|
||||
bookingId,
|
||||
'IMPORT',
|
||||
'DUTY_TAXES_ADVISED',
|
||||
);
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
dutyRequired: dto.dutyRequired,
|
||||
clearanceCurrentPhase: dto.dutyRequired
|
||||
? ContractDocPhase.CustomerDuty
|
||||
: ContractDocPhase.GlEtPostClearance,
|
||||
} as never);
|
||||
|
||||
if (!dto.dutyRequired) {
|
||||
await this.workflowService.onDutySkippedForBooking(bookingId);
|
||||
} else {
|
||||
if (dto.amount == null || dto.amount < 0) {
|
||||
throw new BadRequestException('Duty amount is required when duty applies.');
|
||||
}
|
||||
if (!attachment) {
|
||||
throw new BadRequestException('Duty notice attachment is required when duty applies.');
|
||||
}
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'duty_tax_notice',
|
||||
file: attachment,
|
||||
});
|
||||
await this.milestoneService.adviseDuty(
|
||||
bookingId,
|
||||
{
|
||||
amount: dto.amount,
|
||||
currency: dto.currency ?? 'ETB',
|
||||
declarationSerial: dto.declarationSerial,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Duty slip upload applies only to import bookings.');
|
||||
}
|
||||
if (!booking.dutyRequired) {
|
||||
throw new BadRequestException('Duty/tax is not required for this clearance.');
|
||||
}
|
||||
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'duty_tax_receipt',
|
||||
file,
|
||||
});
|
||||
|
||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DUTY_TAX_PAID');
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
} as never);
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async uploadTransitPermit(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Transit permit applies only to import bookings.');
|
||||
}
|
||||
await this.workflowService.assertPriorCompleteForBooking(
|
||||
bookingId,
|
||||
'IMPORT',
|
||||
'TRANSIT_PERMIT_UPLOADED',
|
||||
);
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No transit permit documents uploaded');
|
||||
}
|
||||
|
||||
await persistTransitPermitUploads(this.filesService, bookingId, 'bookings', files);
|
||||
|
||||
await this.workflowService.completeMilestoneForBooking(
|
||||
bookingId,
|
||||
'TRANSIT_PERMIT_UPLOADED',
|
||||
userId,
|
||||
);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
} as never);
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async finalizePreClearance(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Pre-clearance finalize applies only to import bookings.');
|
||||
}
|
||||
|
||||
await this.workflowService.assertPriorCompleteForBooking(
|
||||
bookingId,
|
||||
'IMPORT',
|
||||
'TRANSIT_PERMIT_UPLOADED',
|
||||
);
|
||||
|
||||
if (booking.preClearanceFinalizedAt) {
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
preClearanceFinalizedAt: new Date(),
|
||||
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
|
||||
} as never);
|
||||
|
||||
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
if (files.some((f) => f.code === 'delivery_order')) {
|
||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED');
|
||||
await this.workflowService.markReadyForOperation(bookingId);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async uploadDeliveryOrder(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Delivery Order applies only to import bookings.');
|
||||
}
|
||||
|
||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||
|
||||
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
||||
// any file type. The DO_COLLECTED milestone (and operation readiness) still
|
||||
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'delivery_order',
|
||||
file,
|
||||
});
|
||||
|
||||
if (booking.preClearanceFinalizedAt) {
|
||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
||||
await this.workflowService.markReadyForOperation(bookingId);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
private async resolveRoMinDays(): Promise<number> {
|
||||
try {
|
||||
const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE);
|
||||
const first = setting.children?.[0];
|
||||
const n = Number(first?.value);
|
||||
return Number.isFinite(n) && n > 0 ? n : 2;
|
||||
} catch {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private daysUntil(dateStr: string): number {
|
||||
const target = new Date(dateStr);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
async uploadReleaseOrder(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
vesselDepartureDate: string,
|
||||
userId?: string,
|
||||
): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('Release Order applies only to export bookings.');
|
||||
}
|
||||
await this.workflowService.assertPriorCompleteForBooking(
|
||||
bookingId,
|
||||
'EXPORT',
|
||||
'RELEASE_ORDER_SECURED',
|
||||
);
|
||||
|
||||
if (!file) throw new BadRequestException('No Release Order uploaded');
|
||||
if (!vesselDepartureDate?.trim()) {
|
||||
throw new BadRequestException('Vessel departure date is required');
|
||||
}
|
||||
|
||||
const minDays = await this.resolveRoMinDays();
|
||||
const leadDays = this.daysUntil(vesselDepartureDate);
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'release_order',
|
||||
file,
|
||||
});
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
vesselDepartureDate,
|
||||
roAmendmentRequestedAt: null,
|
||||
} as never);
|
||||
|
||||
if (leadDays < minDays) {
|
||||
const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`;
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
roHoldReason: reason,
|
||||
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
|
||||
} as never);
|
||||
return {
|
||||
booking: await this.bookingsService.findById(bookingId),
|
||||
hold: true,
|
||||
holdReason: reason,
|
||||
};
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
roHoldReason: null,
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
||||
} as never);
|
||||
await this.workflowService.completeMilestoneForBooking(
|
||||
bookingId,
|
||||
'RELEASE_ORDER_SECURED',
|
||||
userId,
|
||||
);
|
||||
|
||||
return { booking: await this.bookingsService.findById(bookingId), hold: false };
|
||||
}
|
||||
|
||||
async requestRoAmendment(
|
||||
bookingId: string,
|
||||
note?: string,
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('RO amendment applies only to export bookings.');
|
||||
}
|
||||
|
||||
const reason =
|
||||
note?.trim() ||
|
||||
'Port amendment requested — vessel departure window is too short. A new Release Order will be required.';
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
roAmendmentRequestedAt: new Date(),
|
||||
roHoldReason: reason,
|
||||
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
|
||||
} as never);
|
||||
|
||||
if (userId) {
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason,
|
||||
'CHANGES_REQUESTED',
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async confirmExportRelease(bookingId: string, userId?: string): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('Export release applies only to export bookings.');
|
||||
}
|
||||
await this.workflowService.assertPriorCompleteForBooking(
|
||||
bookingId,
|
||||
'EXPORT',
|
||||
'EXPORT_RELEASED',
|
||||
);
|
||||
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async etQueue(): Promise<Booking[]> {
|
||||
const candidates = await this.bookingsRepository.findByStatuses([
|
||||
...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES,
|
||||
]);
|
||||
const filtered: Booking[] = [];
|
||||
for (const b of candidates) {
|
||||
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async djQueue(): Promise<Booking[]> {
|
||||
const candidates = await this.bookingsRepository.findByStatuses([
|
||||
...DJ_BOOKING_QUEUE_STATUSES,
|
||||
]);
|
||||
const filtered: Booking[] = [];
|
||||
for (const b of candidates) {
|
||||
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||
if (
|
||||
belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, {
|
||||
roHoldReason: b.roHoldReason,
|
||||
preClearanceFinalizedAt: b.preClearanceFinalizedAt,
|
||||
})
|
||||
) {
|
||||
filtered.push(b);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,17 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
|
||||
async findById(id: string): Promise<BookingRequest | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
relations: { contract: true },
|
||||
// Load the contract with the bits the detail page surfaces: customer
|
||||
// (company), service type (mile/customs flags), routes (with yard labels)
|
||||
// and cargo scope.
|
||||
relations: {
|
||||
contract: {
|
||||
company: true,
|
||||
serviceType: true,
|
||||
routes: { originYard: true, destinationYard: true },
|
||||
cargoScope: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
DUTY_TAX_PAID: { label: 'Duty and Tax Paid', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||
TRANSIT_PERMIT_UPLOADED: {
|
||||
label: 'Transit Permit Uploaded',
|
||||
ownerRegion: 'ET',
|
||||
triggeredByDoc: true,
|
||||
},
|
||||
DO_COLLECTED: { label: 'DO Collected', ownerRegion: 'DJ', triggeredByDoc: true },
|
||||
WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled (freight)', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||
@@ -52,6 +57,11 @@ const EXPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||
FREIGHT_PAYMENT_PENDING: { label: 'Pending Payment', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||
WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
EXPORT_TRANSPORT_ISSUED: {
|
||||
label: 'Export Transport Document Issued',
|
||||
ownerRegion: 'ET',
|
||||
triggeredByDoc: true,
|
||||
},
|
||||
CARGO_ARRIVED: { label: 'Cargo Arrived', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
@@ -39,6 +39,15 @@ export class ClearanceMilestoneService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Seed pre-booking milestones on a booking (GENERAL + customs per-shipment clearance). */
|
||||
async seedPreBookingMilestonesOnBooking(
|
||||
bookingId: string,
|
||||
tradeDirection: string,
|
||||
): Promise<void> {
|
||||
const { preBooking } = splitMilestones(tradeDirection);
|
||||
await this.seed(preBooking, { bookingId });
|
||||
}
|
||||
|
||||
/** Seed the post-booking milestones onto a freshly created booking. */
|
||||
async seedPostBookingMilestones(
|
||||
bookingId: string,
|
||||
@@ -94,7 +103,7 @@ export class ClearanceMilestoneService {
|
||||
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') {
|
||||
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
||||
return milestone;
|
||||
}
|
||||
milestone.status = 'COMPLETED';
|
||||
milestone.triggeredAt = new Date();
|
||||
@@ -178,7 +187,7 @@ export class ClearanceMilestoneService {
|
||||
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') {
|
||||
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
||||
return milestone;
|
||||
}
|
||||
milestone.status = 'COMPLETED';
|
||||
milestone.triggeredAt = new Date();
|
||||
@@ -187,6 +196,99 @@ export class ClearanceMilestoneService {
|
||||
return this.repo.save(milestone);
|
||||
}
|
||||
|
||||
/** Skip optional milestones (e.g. duty when not required). */
|
||||
/** Reopen a completed contract milestone so review can continue after a query. */
|
||||
async reopenForContract(contractId: string, code: string): Promise<void> {
|
||||
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
|
||||
if (!milestone || milestone.status !== 'COMPLETED') return;
|
||||
milestone.status = 'PENDING';
|
||||
milestone.triggeredAt = null;
|
||||
milestone.triggeredByUserId = null;
|
||||
await this.repo.save(milestone);
|
||||
}
|
||||
|
||||
/** Reopen a completed booking milestone so review can continue after a query. */
|
||||
async reopenForBooking(bookingId: string, code: string): Promise<void> {
|
||||
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
|
||||
if (!milestone || milestone.status !== 'COMPLETED') return;
|
||||
milestone.status = 'PENDING';
|
||||
milestone.triggeredAt = null;
|
||||
milestone.triggeredByUserId = null;
|
||||
await this.repo.save(milestone);
|
||||
}
|
||||
|
||||
async skipForContract(contractId: string, code: string): Promise<ClearanceMilestone> {
|
||||
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
|
||||
if (!milestone) {
|
||||
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') return milestone;
|
||||
milestone.status = 'SKIPPED';
|
||||
milestone.triggeredAt = new Date();
|
||||
return this.repo.save(milestone);
|
||||
}
|
||||
|
||||
async skipForBooking(bookingId: string, code: string): Promise<ClearanceMilestone> {
|
||||
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
|
||||
if (!milestone) {
|
||||
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') return milestone;
|
||||
milestone.status = 'SKIPPED';
|
||||
milestone.triggeredAt = new Date();
|
||||
return this.repo.save(milestone);
|
||||
}
|
||||
|
||||
async completeWithMetadataForBooking(
|
||||
bookingId: string,
|
||||
code: string,
|
||||
metadata: MilestoneMetadata,
|
||||
userId?: string,
|
||||
note?: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
return this.completeWithMetadata(bookingId, code, metadata, userId, note);
|
||||
}
|
||||
|
||||
/** Complete a contract milestone with structured metadata (duty advice, etc.). */
|
||||
async completeWithMetadataForContract(
|
||||
contractId: string,
|
||||
code: string,
|
||||
metadata: MilestoneMetadata,
|
||||
userId?: string,
|
||||
note?: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
|
||||
if (!milestone) {
|
||||
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') {
|
||||
return milestone;
|
||||
}
|
||||
milestone.status = 'COMPLETED';
|
||||
milestone.triggeredAt = new Date();
|
||||
milestone.triggeredByUserId = userId ?? null;
|
||||
milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata };
|
||||
if (note) milestone.note = note;
|
||||
return this.repo.save(milestone);
|
||||
}
|
||||
|
||||
async adviseDutyForContract(
|
||||
contractId: string,
|
||||
input: { amount: number; currency: string; declarationSerial?: string },
|
||||
userId?: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
return this.completeWithMetadataForContract(
|
||||
contractId,
|
||||
'DUTY_TAXES_ADVISED',
|
||||
{
|
||||
dutyAmount: input.amount,
|
||||
dutyCurrency: input.currency,
|
||||
declarationSerial: input.declarationSerial,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Complete a doc-triggered milestone when its document is uploaded/approved. */
|
||||
async completeByDocTrigger(
|
||||
scope: { bookingId?: string; contractId?: string },
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import type { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import type { Contract } from './entities/contract.entity';
|
||||
import type { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
function ms(
|
||||
code: string,
|
||||
status: 'PENDING' | 'COMPLETED' | 'SKIPPED',
|
||||
ownerRegion: 'ET' | 'DJ' | 'CUST' | 'OPS' = 'ET',
|
||||
): ClearanceMilestone {
|
||||
return { milestoneCode: code, status, ownerRegion } as ClearanceMilestone;
|
||||
}
|
||||
|
||||
function importThroughDeclaration(): ClearanceMilestone[] {
|
||||
return [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('UNDER_CUSTOMS_CLEARANCE', 'PENDING', 'ET'),
|
||||
ms('DECLARED', 'PENDING', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'PENDING', 'CUST'),
|
||||
ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'),
|
||||
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||
];
|
||||
}
|
||||
|
||||
function makeService(milestones: ClearanceMilestone[]) {
|
||||
const contractsRepository = {
|
||||
currentCycle: jest.fn(),
|
||||
update: jest.fn(),
|
||||
setCycleStatus: jest.fn(),
|
||||
updateCycle: jest.fn(),
|
||||
};
|
||||
const milestoneService = {
|
||||
listForContract: jest.fn().mockResolvedValue(milestones),
|
||||
listForBooking: jest.fn().mockResolvedValue(milestones),
|
||||
skipForContract: jest.fn(),
|
||||
completeForContract: jest.fn(),
|
||||
completeWithMetadataForContract: jest.fn(),
|
||||
};
|
||||
const bookingsRepository = { update: jest.fn() };
|
||||
const service = new ClearanceWorkflowService(
|
||||
contractsRepository as never,
|
||||
milestoneService as never,
|
||||
bookingsRepository as never,
|
||||
);
|
||||
return { service, milestoneService, contractsRepository, bookingsRepository };
|
||||
}
|
||||
|
||||
const importContract = {
|
||||
id: 'c-import',
|
||||
tradeDirection: 'IMPORT',
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'ONE_TIME',
|
||||
} as Contract;
|
||||
|
||||
const exportContract = {
|
||||
id: 'c-export',
|
||||
tradeDirection: 'EXPORT',
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'ONE_TIME',
|
||||
} as Contract;
|
||||
|
||||
describe('ClearanceWorkflowService', () => {
|
||||
describe('boundaryMilestone', () => {
|
||||
it('uses DO_COLLECTED for import and EXPORT_RELEASED for export', () => {
|
||||
const { service } = makeService([]);
|
||||
expect(service.boundaryMilestone('IMPORT')).toBe('DO_COLLECTED');
|
||||
expect(service.boundaryMilestone('EXPORT')).toBe('EXPORT_RELEASED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertPriorComplete', () => {
|
||||
it('rejects when a prior milestone is still pending', async () => {
|
||||
const milestones = importThroughDeclaration().map((m) =>
|
||||
m.milestoneCode === 'DOCUMENTS_APPROVED'
|
||||
? ms('DOCUMENTS_APPROVED', 'PENDING', 'ET')
|
||||
: m,
|
||||
);
|
||||
const { service } = makeService(milestones);
|
||||
await expect(
|
||||
service.assertPriorComplete('c-import', 'IMPORT', 'DECLARED'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('allows proceeding when prior milestones are completed or skipped', async () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('UNDER_CUSTOMS_CLEARANCE', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
|
||||
ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'),
|
||||
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||
];
|
||||
const { service } = makeService(milestones);
|
||||
await expect(
|
||||
service.assertPriorComplete('c-import', 'IMPORT', 'TRANSIT_PERMIT_UPLOADED'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBoundaryComplete', () => {
|
||||
it('returns true only when boundary milestone is completed', async () => {
|
||||
const done = [
|
||||
...importThroughDeclaration().slice(0, -1),
|
||||
ms('DO_COLLECTED', 'COMPLETED', 'DJ'),
|
||||
];
|
||||
const { service: doneSvc } = makeService(done);
|
||||
await expect(doneSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(true);
|
||||
|
||||
const pending = importThroughDeclaration();
|
||||
const { service: pendingSvc } = makeService(pending);
|
||||
await expect(pendingSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onDutySkipped', () => {
|
||||
it('skips duty milestones on the contract', async () => {
|
||||
const { service, milestoneService } = makeService([]);
|
||||
await service.onDutySkipped('c-import');
|
||||
expect(milestoneService.skipForContract).toHaveBeenCalledWith(
|
||||
'c-import',
|
||||
'DUTY_TAXES_ADVISED',
|
||||
);
|
||||
expect(milestoneService.skipForContract).toHaveBeenCalledWith(
|
||||
'c-import',
|
||||
'DUTY_TAX_PAID',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextAction — import happy path', () => {
|
||||
it('prompts customer to upload docs first', () => {
|
||||
const { service } = makeService([ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST')]);
|
||||
const next = service.computeNextAction(importContract, null, [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST'),
|
||||
]);
|
||||
expect(next?.actor).toBe('CUSTOMER');
|
||||
expect(next?.milestoneCode).toBe('IMPORT_DOCS_UPLOADED');
|
||||
});
|
||||
|
||||
it('prompts ET review after customer docs', () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'),
|
||||
];
|
||||
const { service } = makeService(milestones);
|
||||
const next = service.computeNextAction(importContract, null, milestones);
|
||||
expect(next?.actor).toBe('GL_ET');
|
||||
expect(next?.action).toMatch(/Review/i);
|
||||
});
|
||||
|
||||
it('prompts duty toggle when declaration done and duty unset', () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'),
|
||||
];
|
||||
const cycle = { dutyRequired: null } as ContractClearanceCycle;
|
||||
const { service } = makeService(milestones);
|
||||
const next = service.computeNextAction(importContract, cycle, milestones);
|
||||
expect(next?.actor).toBe('GL_ET');
|
||||
expect(next?.action).toMatch(/duty/i);
|
||||
});
|
||||
|
||||
it('prompts customer duty slip when duty required and advised', () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'PENDING', 'CUST'),
|
||||
];
|
||||
const cycle = { dutyRequired: true } as ContractClearanceCycle;
|
||||
const { service } = makeService(milestones);
|
||||
const next = service.computeNextAction(importContract, cycle, milestones);
|
||||
expect(next?.actor).toBe('CUSTOMER');
|
||||
expect(next?.milestoneCode).toBe('DUTY_TAX_PAID');
|
||||
});
|
||||
|
||||
it('skips duty path when duty not required', () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
|
||||
ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'),
|
||||
];
|
||||
const cycle = { dutyRequired: false } as ContractClearanceCycle;
|
||||
const { service } = makeService(milestones);
|
||||
const next = service.computeNextAction(importContract, cycle, milestones);
|
||||
expect(next?.actor).toBe('GL_ET');
|
||||
expect(next?.milestoneCode).toBe('TRANSIT_PERMIT_UPLOADED');
|
||||
});
|
||||
|
||||
it('prompts ET to finalize pre-clearance after transit permit', () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
|
||||
ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'),
|
||||
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||
];
|
||||
const cycle = { dutyRequired: false } as ContractClearanceCycle;
|
||||
const { service } = makeService(milestones);
|
||||
const next = service.computeNextAction(importContract, cycle, milestones);
|
||||
expect(next?.actor).toBe('GL_ET');
|
||||
expect(next?.action).toMatch(/finalize pre-clearance/i);
|
||||
});
|
||||
|
||||
it('prompts DJ for DO then ET booking when pre-booking complete', () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
|
||||
ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'),
|
||||
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||
];
|
||||
const cycle = {
|
||||
dutyRequired: false,
|
||||
preClearanceFinalizedAt: new Date(),
|
||||
} as ContractClearanceCycle;
|
||||
const { service } = makeService(milestones);
|
||||
const djNext = service.computeNextAction(importContract, cycle, milestones);
|
||||
expect(djNext?.actor).toBe('GL_DJ');
|
||||
|
||||
const booked = milestones.map((m) =>
|
||||
m.milestoneCode === 'DO_COLLECTED' ? ms('DO_COLLECTED', 'COMPLETED', 'DJ') : m,
|
||||
);
|
||||
const etNext = service.computeNextAction(importContract, cycle, booked);
|
||||
expect(etNext?.actor).toBe('GL_ET');
|
||||
expect(etNext?.action).toMatch(/booking/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextAction — export RO hold', () => {
|
||||
it('surfaces DJ action when RO is on hold', () => {
|
||||
const milestones = [
|
||||
ms('EXPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('RELEASE_ORDER_SECURED', 'PENDING', 'DJ'),
|
||||
];
|
||||
const cycle = {
|
||||
roHoldReason: 'Vessel departs in 1 day(s) — minimum lead time is 2 day(s).',
|
||||
} as ContractClearanceCycle;
|
||||
const { service } = makeService(milestones);
|
||||
const next = service.computeNextAction(exportContract, cycle, milestones);
|
||||
expect(next?.actor).toBe('GL_DJ');
|
||||
expect(next?.blockedReason).toMatch(/minimum lead time/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferPhase', () => {
|
||||
it('places import contract in customer duty phase when duty outstanding', () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'PENDING', 'CUST'),
|
||||
];
|
||||
const cycle = { dutyRequired: true } as ContractClearanceCycle;
|
||||
const { service } = makeService(milestones);
|
||||
const phase = service.inferPhase(importContract, cycle, milestones);
|
||||
expect(phase).toBe(ContractDocPhase.CustomerDuty);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queue helpers', () => {
|
||||
it('returns first pending ET-owned milestone code', () => {
|
||||
const { service } = makeService([
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'),
|
||||
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||
]);
|
||||
expect(service.etPendingMilestoneCodes([])).toBeNull();
|
||||
expect(
|
||||
service.etPendingMilestoneCodes([
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'),
|
||||
]),
|
||||
).toBe('DOCUMENTS_APPROVED');
|
||||
});
|
||||
|
||||
it('returns first pending DJ-owned milestone code', () => {
|
||||
const { service } = makeService([]);
|
||||
expect(
|
||||
service.djPendingMilestoneCodes([
|
||||
ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'),
|
||||
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||
]),
|
||||
).toBe('DO_COLLECTED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextActionForBooking', () => {
|
||||
it('prompts customer to proceed after import boundary on booking', () => {
|
||||
const booking = {
|
||||
tradeDirection: 'IMPORT',
|
||||
dutyRequired: false,
|
||||
preClearanceFinalizedAt: new Date(),
|
||||
} as Booking;
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
|
||||
ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'),
|
||||
ms('DO_COLLECTED', 'COMPLETED', 'DJ'),
|
||||
];
|
||||
const { service } = makeService(milestones);
|
||||
const next = service.computeNextActionForBooking(booking, milestones);
|
||||
expect(next?.actor).toBe('CUSTOMER');
|
||||
expect(next?.action).toMatch(/operation/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { MilestoneMetadata } from './entities/clearance-milestone.entity';
|
||||
import { splitMilestones } from './clearance-milestone.catalog';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import type { ClearanceMetaState } from './clearance-workflow.types';
|
||||
import { metaFromBooking } from './clearance-workflow.types';
|
||||
|
||||
export type ClearanceActorRole = 'CUSTOMER' | 'GL_ET' | 'GL_DJ' | 'OPERATIONS';
|
||||
|
||||
export interface ClearanceNextAction {
|
||||
actor: ClearanceActorRole;
|
||||
action: string;
|
||||
milestoneCode?: string | null;
|
||||
blockedReason?: string | null;
|
||||
}
|
||||
|
||||
const IMPORT_BOUNDARY = 'DO_COLLECTED';
|
||||
const EXPORT_BOUNDARY = 'EXPORT_RELEASED';
|
||||
|
||||
const IMPORT_DOC_UPLOADED = 'IMPORT_DOCS_UPLOADED';
|
||||
const EXPORT_DOC_UPLOADED = 'EXPORT_DOCS_UPLOADED';
|
||||
|
||||
@Injectable()
|
||||
export class ClearanceWorkflowService {
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
) {}
|
||||
|
||||
boundaryMilestone(tradeDirection: string): string {
|
||||
return tradeDirection === 'IMPORT' ? IMPORT_BOUNDARY : EXPORT_BOUNDARY;
|
||||
}
|
||||
|
||||
// ── Contract scope (ONE_TIME) ─────────────────────────────────────────────
|
||||
|
||||
async listMilestones(contractId: string): Promise<ClearanceMilestone[]> {
|
||||
return this.milestoneService.listForContract(contractId);
|
||||
}
|
||||
|
||||
async listMilestonesForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
|
||||
return this.milestoneService.listForBooking(bookingId);
|
||||
}
|
||||
|
||||
async isBoundaryComplete(contractId: string, tradeDirection: string): Promise<boolean> {
|
||||
return this.isBoundaryCompleteForMilestones(
|
||||
await this.listMilestones(contractId),
|
||||
tradeDirection,
|
||||
);
|
||||
}
|
||||
|
||||
async isBoundaryCompleteForBooking(
|
||||
bookingId: string,
|
||||
tradeDirection: string,
|
||||
): Promise<boolean> {
|
||||
return this.isBoundaryCompleteForMilestones(
|
||||
await this.listMilestonesForBooking(bookingId),
|
||||
tradeDirection,
|
||||
);
|
||||
}
|
||||
|
||||
private isBoundaryCompleteForMilestones(
|
||||
milestones: ClearanceMilestone[],
|
||||
tradeDirection: string,
|
||||
): boolean {
|
||||
const code = this.boundaryMilestone(tradeDirection);
|
||||
const m = milestones.find((x) => x.milestoneCode === code);
|
||||
return m?.status === 'COMPLETED';
|
||||
}
|
||||
|
||||
async assertBoundaryComplete(contract: Contract): Promise<void> {
|
||||
const ok = await this.isBoundaryComplete(contract.id, contract.tradeDirection);
|
||||
if (!ok) {
|
||||
throw new BadRequestException(
|
||||
`Pre-booking clearance is not complete — ${this.boundaryMilestone(contract.tradeDirection)} must be finished before booking.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async assertPriorComplete(
|
||||
contractId: string,
|
||||
tradeDirection: string,
|
||||
targetCode: string,
|
||||
): Promise<void> {
|
||||
await this.assertPriorCompleteOnMilestones(
|
||||
await this.listMilestones(contractId),
|
||||
tradeDirection,
|
||||
targetCode,
|
||||
);
|
||||
}
|
||||
|
||||
async assertPriorCompleteForBooking(
|
||||
bookingId: string,
|
||||
tradeDirection: string,
|
||||
targetCode: string,
|
||||
): Promise<void> {
|
||||
await this.assertPriorCompleteOnMilestones(
|
||||
await this.listMilestonesForBooking(bookingId),
|
||||
tradeDirection,
|
||||
targetCode,
|
||||
);
|
||||
}
|
||||
|
||||
private async assertPriorCompleteOnMilestones(
|
||||
milestones: ClearanceMilestone[],
|
||||
tradeDirection: string,
|
||||
targetCode: string,
|
||||
): Promise<void> {
|
||||
const { preBooking } = splitMilestones(tradeDirection);
|
||||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||
const targetIdx = preBooking.findIndex((d) => d.code === targetCode);
|
||||
if (targetIdx < 0) return;
|
||||
|
||||
for (let i = 0; i < targetIdx; i++) {
|
||||
|
||||
const code = preBooking[i]!.code;
|
||||
const m = byCode.get(code);
|
||||
if (!m) continue;
|
||||
if (m.status === 'SKIPPED') continue;
|
||||
if (m.status !== 'COMPLETED') {
|
||||
throw new BadRequestException(
|
||||
`Complete "${preBooking[i]!.label}" before proceeding.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async skipMilestones(contractId: string, codes: string[]): Promise<void> {
|
||||
for (const code of codes) {
|
||||
await this.milestoneService.skipForContract(contractId, code);
|
||||
}
|
||||
}
|
||||
|
||||
async skipMilestonesForBooking(bookingId: string, codes: string[]): Promise<void> {
|
||||
for (const code of codes) {
|
||||
await this.milestoneService.skipForBooking(bookingId, code);
|
||||
}
|
||||
}
|
||||
|
||||
async completeMilestone(
|
||||
contractId: string,
|
||||
code: string,
|
||||
userId?: string,
|
||||
metadata?: MilestoneMetadata,
|
||||
): Promise<ClearanceMilestone> {
|
||||
if (metadata && Object.keys(metadata).length > 0) {
|
||||
return this.milestoneService.completeWithMetadataForContract(
|
||||
contractId,
|
||||
code,
|
||||
metadata,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
return this.milestoneService.completeForContract(contractId, code, userId);
|
||||
}
|
||||
|
||||
async completeMilestoneForBooking(
|
||||
bookingId: string,
|
||||
code: string,
|
||||
userId?: string,
|
||||
metadata?: MilestoneMetadata,
|
||||
): Promise<ClearanceMilestone> {
|
||||
if (metadata && Object.keys(metadata).length > 0) {
|
||||
return this.milestoneService.completeWithMetadataForBooking(
|
||||
bookingId,
|
||||
code,
|
||||
metadata,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
return this.milestoneService.completeForBooking(bookingId, code, userId);
|
||||
}
|
||||
|
||||
async onCustomerDocsUploaded(contractId: string, tradeDirection: string): Promise<void> {
|
||||
const uploaded =
|
||||
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
||||
await this.completeMilestone(contractId, uploaded);
|
||||
await this.completeMilestone(contractId, 'PENDING_DOCUMENT_REVIEW');
|
||||
}
|
||||
|
||||
async onCustomerDocsUploadedForBooking(
|
||||
bookingId: string,
|
||||
tradeDirection: string,
|
||||
): Promise<void> {
|
||||
const uploaded =
|
||||
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
||||
await this.completeMilestoneForBooking(bookingId, uploaded);
|
||||
await this.completeMilestoneForBooking(bookingId, 'PENDING_DOCUMENT_REVIEW');
|
||||
}
|
||||
|
||||
async onAllDocsApproved(contractId: string): Promise<void> {
|
||||
await this.completeMilestone(contractId, 'DOCUMENTS_APPROVED');
|
||||
}
|
||||
|
||||
async onAllDocsApprovedForBooking(bookingId: string): Promise<void> {
|
||||
await this.completeMilestoneForBooking(bookingId, 'DOCUMENTS_APPROVED');
|
||||
}
|
||||
|
||||
/** Customer doc queried or re-uploaded — document approval milestone must reopen. */
|
||||
async onDocumentReviewReopened(contractId: string): Promise<void> {
|
||||
await this.milestoneService.reopenForContract(contractId, 'DOCUMENTS_APPROVED');
|
||||
}
|
||||
|
||||
async onDocumentReviewReopenedForBooking(bookingId: string): Promise<void> {
|
||||
await this.milestoneService.reopenForBooking(bookingId, 'DOCUMENTS_APPROVED');
|
||||
}
|
||||
|
||||
async onDeclarationUploaded(contractId: string, userId?: string): Promise<void> {
|
||||
await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE');
|
||||
await this.completeMilestone(contractId, 'DECLARED', userId);
|
||||
}
|
||||
|
||||
async onDeclarationUploadedForBooking(bookingId: string, userId?: string): Promise<void> {
|
||||
await this.completeMilestoneForBooking(bookingId, 'UNDER_CUSTOMS_CLEARANCE');
|
||||
await this.completeMilestoneForBooking(bookingId, 'DECLARED', userId);
|
||||
}
|
||||
|
||||
async onDutySkipped(contractId: string): Promise<void> {
|
||||
await this.skipMilestones(contractId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']);
|
||||
}
|
||||
|
||||
async onDutySkippedForBooking(bookingId: string): Promise<void> {
|
||||
await this.skipMilestonesForBooking(bookingId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']);
|
||||
}
|
||||
|
||||
async onExportReleased(contractId: string, userId?: string): Promise<void> {
|
||||
await this.completeMilestone(contractId, 'EXPORT_RELEASED', userId);
|
||||
await this.markReadyForBooking(contractId);
|
||||
}
|
||||
|
||||
async onExportReleasedForBooking(bookingId: string, userId?: string): Promise<void> {
|
||||
await this.completeMilestoneForBooking(bookingId, 'EXPORT_RELEASED', userId);
|
||||
await this.markReadyForOperation(bookingId);
|
||||
}
|
||||
|
||||
async markReadyForBooking(contractId: string): Promise<void> {
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CLEARANCE_READY_FOR_BOOKING',
|
||||
clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING',
|
||||
} as never);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', {
|
||||
clearanceReadyAt: new Date(),
|
||||
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
});
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** GENERAL per-booking: boundary complete → customer may proceed to operations. */
|
||||
async markReadyForOperation(bookingId: string): Promise<void> {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'CLEARANCE_READY',
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
} as never);
|
||||
}
|
||||
|
||||
resolvePhase(
|
||||
contract: Contract,
|
||||
cycle: ContractClearanceCycle | null,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ContractDocPhase {
|
||||
const meta: ClearanceMetaState = {
|
||||
dutyRequired: cycle?.dutyRequired ?? null,
|
||||
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
|
||||
roHoldReason: cycle?.roHoldReason ?? null,
|
||||
currentPhase: cycle?.currentPhase ?? null,
|
||||
};
|
||||
return this.resolvePhaseFromMeta(contract.tradeDirection, meta, milestones);
|
||||
}
|
||||
|
||||
resolvePhaseForBooking(
|
||||
booking: Booking,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ContractDocPhase {
|
||||
return this.resolvePhaseFromMeta(
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
metaFromBooking(booking),
|
||||
milestones,
|
||||
);
|
||||
}
|
||||
|
||||
private resolvePhaseFromMeta(
|
||||
tradeDirection: string,
|
||||
meta: ClearanceMetaState,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ContractDocPhase {
|
||||
if (meta.currentPhase) {
|
||||
return meta.currentPhase as ContractDocPhase;
|
||||
}
|
||||
return this.inferPhaseFromMeta(tradeDirection, meta, milestones);
|
||||
}
|
||||
|
||||
inferPhase(
|
||||
contract: Contract,
|
||||
cycle: ContractClearanceCycle | null,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ContractDocPhase {
|
||||
return this.inferPhaseFromMeta(
|
||||
contract.tradeDirection,
|
||||
{
|
||||
dutyRequired: cycle?.dutyRequired ?? null,
|
||||
roHoldReason: cycle?.roHoldReason ?? null,
|
||||
},
|
||||
milestones,
|
||||
);
|
||||
}
|
||||
|
||||
inferPhaseForBooking(booking: Booking, milestones: ClearanceMilestone[]): ContractDocPhase {
|
||||
return this.inferPhaseFromMeta(
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
metaFromBooking(booking),
|
||||
milestones,
|
||||
);
|
||||
}
|
||||
|
||||
private inferPhaseFromMeta(
|
||||
tradeDirection: string,
|
||||
meta: ClearanceMetaState,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ContractDocPhase {
|
||||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||
const isDone = (code: string) =>
|
||||
byCode.get(code)?.status === 'COMPLETED' || byCode.get(code)?.status === 'SKIPPED';
|
||||
|
||||
const docUploaded =
|
||||
tradeDirection === 'IMPORT'
|
||||
? isDone(IMPORT_DOC_UPLOADED)
|
||||
: isDone(EXPORT_DOC_UPLOADED);
|
||||
|
||||
if (!docUploaded) return ContractDocPhase.CustomerIntake;
|
||||
if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview;
|
||||
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||
return ContractDocPhase.GlDjCollection;
|
||||
}
|
||||
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||
if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance;
|
||||
return ContractDocPhase.GlEtPostClearance;
|
||||
}
|
||||
|
||||
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||
if (meta.dutyRequired === true && !isDone('DUTY_TAX_PAID')) {
|
||||
return ContractDocPhase.CustomerDuty;
|
||||
}
|
||||
if (!isDone('TRANSIT_PERMIT_UPLOADED')) return ContractDocPhase.GlEtPostClearance;
|
||||
if (!meta.preClearanceFinalizedAt) return ContractDocPhase.GlEtPostClearance;
|
||||
if (!isDone(IMPORT_BOUNDARY)) return ContractDocPhase.GlDjCollection;
|
||||
return ContractDocPhase.GlEtPostClearance;
|
||||
}
|
||||
|
||||
computeNextAction(
|
||||
contract: Contract,
|
||||
cycle: ContractClearanceCycle | null,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ClearanceNextAction | null {
|
||||
return this.computeNextActionFromMeta(
|
||||
contract.tradeDirection,
|
||||
{
|
||||
dutyRequired: cycle?.dutyRequired ?? null,
|
||||
roHoldReason: cycle?.roHoldReason ?? null,
|
||||
preClearanceFinalizedAt: cycle?.preClearanceFinalizedAt ?? null,
|
||||
},
|
||||
milestones,
|
||||
'contract',
|
||||
);
|
||||
}
|
||||
|
||||
computeNextActionForBooking(
|
||||
booking: Booking,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ClearanceNextAction | null {
|
||||
return this.computeNextActionFromMeta(
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
metaFromBooking(booking),
|
||||
milestones,
|
||||
'booking',
|
||||
);
|
||||
}
|
||||
|
||||
private computeNextActionFromMeta(
|
||||
tradeDirection: string,
|
||||
meta: ClearanceMetaState,
|
||||
milestones: ClearanceMilestone[],
|
||||
terminalScope: 'contract' | 'booking',
|
||||
): ClearanceNextAction | null {
|
||||
if (meta.roHoldReason) {
|
||||
return {
|
||||
actor: 'GL_DJ',
|
||||
action: 'Re-upload Release Order or request port amendment',
|
||||
milestoneCode: 'RELEASE_ORDER_SECURED',
|
||||
blockedReason: meta.roHoldReason,
|
||||
};
|
||||
}
|
||||
|
||||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||
const pending = (code: string) => {
|
||||
const m = byCode.get(code);
|
||||
return m && m.status === 'PENDING';
|
||||
};
|
||||
const isDone = (code: string) => {
|
||||
const m = byCode.get(code);
|
||||
return m?.status === 'COMPLETED' || m?.status === 'SKIPPED';
|
||||
};
|
||||
|
||||
const docCode =
|
||||
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
||||
|
||||
if (pending(docCode) || !isDone(docCode)) {
|
||||
return {
|
||||
actor: 'CUSTOMER',
|
||||
action: 'Upload clearance documents',
|
||||
milestoneCode: docCode,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDone('DOCUMENTS_APPROVED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Review and approve customer documents',
|
||||
milestoneCode: 'DOCUMENTS_APPROVED',
|
||||
};
|
||||
}
|
||||
|
||||
const terminalAction =
|
||||
terminalScope === 'contract'
|
||||
? 'Create shipment booking'
|
||||
: 'Proceed to request operation';
|
||||
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||
return {
|
||||
actor: 'GL_DJ',
|
||||
action: 'Upload Release Order and vessel departure date',
|
||||
milestoneCode: 'RELEASE_ORDER_SECURED',
|
||||
};
|
||||
}
|
||||
if (!isDone('DECLARED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Upload customs declaration documents',
|
||||
milestoneCode: 'DECLARED',
|
||||
};
|
||||
}
|
||||
if (!isDone(EXPORT_BOUNDARY)) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Confirm export release',
|
||||
milestoneCode: EXPORT_BOUNDARY,
|
||||
};
|
||||
}
|
||||
if (terminalScope === 'booking') {
|
||||
if (!isDone('FREIGHT_PAYMENT_SETTLED')) {
|
||||
return {
|
||||
actor: 'CUSTOMER',
|
||||
action: 'Pay freight charges',
|
||||
milestoneCode: 'FREIGHT_PAYMENT_SETTLED',
|
||||
};
|
||||
}
|
||||
if (!isDone('WAGON_ALLOCATED')) {
|
||||
return {
|
||||
actor: 'OPERATIONS',
|
||||
action: 'Allocate wagon',
|
||||
milestoneCode: 'WAGON_ALLOCATED',
|
||||
};
|
||||
}
|
||||
if (!isDone('EXPORT_TRANSPORT_ISSUED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Upload transit permit',
|
||||
milestoneCode: 'EXPORT_TRANSPORT_ISSUED',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
|
||||
action: terminalAction,
|
||||
milestoneCode: EXPORT_BOUNDARY,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDone('DECLARED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Upload customs declaration documents',
|
||||
milestoneCode: 'DECLARED',
|
||||
};
|
||||
}
|
||||
|
||||
if (meta.dutyRequired === null || meta.dutyRequired === undefined) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Set whether duty/tax applies',
|
||||
milestoneCode: 'DUTY_TAXES_ADVISED',
|
||||
};
|
||||
}
|
||||
|
||||
if (meta.dutyRequired && !isDone('DUTY_TAX_PAID')) {
|
||||
if (!isDone('DUTY_TAXES_ADVISED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Advise duty and tax amount',
|
||||
milestoneCode: 'DUTY_TAXES_ADVISED',
|
||||
};
|
||||
}
|
||||
return {
|
||||
actor: 'CUSTOMER',
|
||||
action: 'Upload duty/tax payment slip',
|
||||
milestoneCode: 'DUTY_TAX_PAID',
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDone('TRANSIT_PERMIT_UPLOADED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Upload transit permit screenshot',
|
||||
milestoneCode: 'TRANSIT_PERMIT_UPLOADED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!meta.preClearanceFinalizedAt) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Finalize pre-clearance',
|
||||
milestoneCode: 'TRANSIT_PERMIT_UPLOADED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDone(IMPORT_BOUNDARY)) {
|
||||
return {
|
||||
actor: 'GL_DJ',
|
||||
action: 'Upload Delivery Order',
|
||||
milestoneCode: IMPORT_BOUNDARY,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
|
||||
action: terminalAction,
|
||||
milestoneCode: IMPORT_BOUNDARY,
|
||||
};
|
||||
}
|
||||
|
||||
etPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
|
||||
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'ET');
|
||||
return pending?.milestoneCode ?? null;
|
||||
}
|
||||
|
||||
djPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
|
||||
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'DJ');
|
||||
return pending?.milestoneCode ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ContractDocPhase } from '@edr/types';
|
||||
|
||||
/** Shared clearance metadata for contract cycles and per-booking GENERAL clearance. */
|
||||
export interface ClearanceMetaState {
|
||||
dutyRequired?: boolean | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
roAmendmentRequestedAt?: Date | null;
|
||||
roHoldReason?: string | null;
|
||||
currentPhase?: ContractDocPhase | string | null;
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
}
|
||||
|
||||
export type ClearanceScope =
|
||||
| { kind: 'contract'; contractId: string }
|
||||
| { kind: 'booking'; bookingId: string };
|
||||
|
||||
export function metaFromBooking(booking: {
|
||||
dutyRequired?: boolean | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
roAmendmentRequestedAt?: Date | null;
|
||||
roHoldReason?: string | null;
|
||||
clearanceCurrentPhase?: string | null;
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
}): ClearanceMetaState {
|
||||
return {
|
||||
dutyRequired: booking.dutyRequired ?? null,
|
||||
vesselDepartureDate: booking.vesselDepartureDate ?? null,
|
||||
roAmendmentRequestedAt: booking.roAmendmentRequestedAt ?? null,
|
||||
roHoldReason: booking.roHoldReason ?? null,
|
||||
currentPhase: booking.clearanceCurrentPhase ?? null,
|
||||
preClearanceFinalizedAt: booking.preClearanceFinalizedAt ?? null,
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
|
||||
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
|
||||
@@ -56,6 +57,7 @@ export class ContractBookingService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
@@ -194,9 +196,11 @@ export class ContractBookingService {
|
||||
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
} as never);
|
||||
} else if (generalCustoms) {
|
||||
// Per-booking clearance: seed post-booking milestones on the booking (no
|
||||
// cycle needed) and leave the contract active. The booking now drives its
|
||||
// own clearance via the booking-level pipeline.
|
||||
// Per-booking clearance: seed full milestone timeline on the booking.
|
||||
await this.milestoneService.seedPreBookingMilestonesOnBooking(
|
||||
booking.id,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
contract.tradeDirection,
|
||||
@@ -246,10 +250,14 @@ export class ContractBookingService {
|
||||
}
|
||||
return 'GL_ET';
|
||||
}
|
||||
// ONE_TIME customs — UNCHANGED: requires the finalized contract cycle.
|
||||
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') {
|
||||
// ONE_TIME customs — pre-booking boundary milestone must be complete.
|
||||
const boundaryOk = await this.workflowService.isBoundaryComplete(
|
||||
contract.id,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
if (!boundaryOk) {
|
||||
throw new BadRequestException(
|
||||
'Contract clearance is not ready for booking yet.',
|
||||
'Pre-booking clearance is not complete — booking cannot be created yet.',
|
||||
);
|
||||
}
|
||||
return 'GL_ET';
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
import { ContractDocPhase, type ClearanceT1State } from '@edr/types';
|
||||
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractsService, PaginatedContracts } from './contracts.service';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { contractClearanceCodes } from './contract-clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
export interface ContractClearanceDocument {
|
||||
fileKey: string;
|
||||
@@ -34,6 +45,41 @@ export interface ContractClearanceView {
|
||||
outputCode: string | null;
|
||||
documents: ContractClearanceDocument[];
|
||||
allApproved: boolean;
|
||||
phase?: string | null;
|
||||
milestones?: Array<{
|
||||
id: string;
|
||||
milestoneCode: string;
|
||||
milestoneLabel: string;
|
||||
status: string;
|
||||
ownerRegion?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
nextAction?: {
|
||||
actor: string;
|
||||
action: string;
|
||||
milestoneCode?: string | null;
|
||||
blockedReason?: string | null;
|
||||
} | null;
|
||||
dutyRequired?: boolean | null;
|
||||
roHold?: boolean;
|
||||
roHoldReason?: string | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
roAmendmentRequestedAt?: string | null;
|
||||
bookingReady?: boolean;
|
||||
preClearanceFinalized?: boolean;
|
||||
/** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */
|
||||
exportClearanceFinalized?: boolean;
|
||||
linkedBookingId?: string | null;
|
||||
dutyAdvice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
declarationSerial?: string | null;
|
||||
noticeFile?: { id: string; name: string; url: string } | null;
|
||||
} | null;
|
||||
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
||||
/** Import post-allocation T1 transit document state (null until a booking is linked). */
|
||||
t1?: ClearanceT1State | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -41,13 +87,58 @@ export class ContractClearanceService {
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
) {}
|
||||
|
||||
private isPhasedCustoms(contract: Contract): boolean {
|
||||
return contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME';
|
||||
}
|
||||
|
||||
private assertPhasedCustoms(contract: Contract): void {
|
||||
if (!this.isPhasedCustoms(contract)) {
|
||||
throw new BadRequestException(
|
||||
'Phased clearance (Phase 1) applies to one-time customs contracts.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy finalize() used to set CLEARANCE_READY_FOR_BOOKING without completing
|
||||
* phased milestones. Revert that state so declaration / DO steps can proceed.
|
||||
*/
|
||||
private async reconcilePrematureBookingReady(
|
||||
contractId: string,
|
||||
contract: Contract,
|
||||
bookingReady: boolean,
|
||||
): Promise<Contract> {
|
||||
if (
|
||||
!this.isPhasedCustoms(contract) ||
|
||||
contract.status !== 'CLEARANCE_READY_FOR_BOOKING' ||
|
||||
bookingReady
|
||||
) {
|
||||
return contract;
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CLEARANCE_UNDER_REVIEW',
|
||||
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
if (cycle?.status === 'CLEARANCE_READY_FOR_BOOKING') {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
|
||||
}
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** The pre-booking clearance document grid for a contract (Path B). */
|
||||
async getClearanceView(contractId: string): Promise<ContractClearanceView> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
let contract = await this.contractsService.findById(contractId);
|
||||
const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
|
||||
@@ -109,6 +200,56 @@ export class ContractClearanceService {
|
||||
}
|
||||
|
||||
const allApproved = await this.isClearanceFullyApproved(contract);
|
||||
const milestones = await this.workflowService.listMilestones(contractId);
|
||||
let boundary = await this.workflowService.isBoundaryComplete(
|
||||
contractId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
|
||||
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
|
||||
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
||||
let workflowFiles = buildWorkflowFiles(
|
||||
files,
|
||||
contract.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
if (cycle?.bookingId) {
|
||||
const bookingFiles = await this.filesService.findByResource(
|
||||
cycle.bookingId,
|
||||
'bookings',
|
||||
);
|
||||
const bookingWorkflow = buildWorkflowFiles(
|
||||
bookingFiles,
|
||||
contract.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
const byCode = new Map(workflowFiles.map((f) => [f.code, f]));
|
||||
for (const row of bookingWorkflow) {
|
||||
if (row.file) byCode.set(row.code, row);
|
||||
}
|
||||
workflowFiles = [...byCode.values()];
|
||||
}
|
||||
|
||||
let t1: ClearanceT1State | null = null;
|
||||
if (cycle?.bookingId && contract.tradeDirection === 'IMPORT') {
|
||||
try {
|
||||
t1 = await this.glOperationsService.t1State(cycle.bookingId);
|
||||
} catch {
|
||||
t1 = null; // linked booking missing — view stays usable
|
||||
}
|
||||
}
|
||||
|
||||
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
||||
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
|
||||
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
|
||||
cycle.bookingId,
|
||||
);
|
||||
const booking = await this.bookingsService.findById(cycle.bookingId);
|
||||
if (booking) {
|
||||
nextAction = this.workflowService.computeNextActionForBooking(
|
||||
booking,
|
||||
bookingMilestones,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contractId,
|
||||
@@ -120,6 +261,56 @@ export class ContractClearanceService {
|
||||
outputCode,
|
||||
documents,
|
||||
allApproved,
|
||||
phase,
|
||||
milestones: milestones.map((m) => ({
|
||||
id: m.id,
|
||||
milestoneCode: m.milestoneCode,
|
||||
milestoneLabel: m.milestoneLabel,
|
||||
status: m.status,
|
||||
ownerRegion: m.ownerRegion,
|
||||
metadata: (m.metadata ?? null) as Record<string, unknown> | null,
|
||||
sortOrder: m.sortOrder,
|
||||
})),
|
||||
nextAction,
|
||||
dutyRequired: cycle?.dutyRequired ?? null,
|
||||
roHold: Boolean(cycle?.roHoldReason),
|
||||
roHoldReason: cycle?.roHoldReason ?? null,
|
||||
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
|
||||
roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt
|
||||
? cycle.roAmendmentRequestedAt.toISOString()
|
||||
: null,
|
||||
bookingReady: boundary,
|
||||
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
|
||||
exportClearanceFinalized: Boolean(cycle?.completedAt),
|
||||
linkedBookingId: cycle?.bookingId ?? null,
|
||||
dutyAdvice,
|
||||
workflowFiles,
|
||||
t1,
|
||||
};
|
||||
}
|
||||
|
||||
private buildDutyAdvice(
|
||||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ContractClearanceView['dutyAdvice'] {
|
||||
const advised = milestones.find(
|
||||
(m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED',
|
||||
);
|
||||
if (!advised?.metadata) return null;
|
||||
const amount = advised.metadata.dutyAmount;
|
||||
const currency = advised.metadata.dutyCurrency;
|
||||
if (typeof amount !== 'number' || typeof currency !== 'string') return null;
|
||||
const notice = files.find((f) => f.code === 'duty_tax_notice');
|
||||
return {
|
||||
amount,
|
||||
currency,
|
||||
declarationSerial:
|
||||
typeof advised.metadata.declarationSerial === 'string'
|
||||
? advised.metadata.declarationSerial
|
||||
: null,
|
||||
noticeFile: notice
|
||||
? { id: notice.id, name: notice.name, url: notice.url }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,6 +345,59 @@ export class ContractClearanceService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Staff may approve/query documents during review, after a query cycle, or post-finalize re-query. */
|
||||
private assertClearanceReviewableStatus(contract: Contract): void {
|
||||
const allowed = [
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
];
|
||||
if (!allowed.includes(contract.status)) {
|
||||
throw new ConflictException(
|
||||
`Cannot review clearance documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Finalize when docs are under review or all approved after a partial query cycle. */
|
||||
private assertClearanceFinalizableStatus(contract: Contract): void {
|
||||
const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
|
||||
if (!allowed.includes(contract.status)) {
|
||||
throw new ConflictException(
|
||||
`Cannot finalize clearance on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private assertClearanceOutputUploadableStatus(contract: Contract): void {
|
||||
const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
|
||||
if (!allowed.includes(contract.status)) {
|
||||
throw new ConflictException(
|
||||
`Cannot upload output documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async bumpToUnderReviewWhenFullyApproved(contractId: string): Promise<void> {
|
||||
const refreshed = await this.contractsService.findById(contractId);
|
||||
const allApproved = await this.isClearanceFullyApproved(refreshed);
|
||||
if (
|
||||
!allApproved ||
|
||||
(refreshed.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
||||
refreshed.status !== 'CLEARANCE_READY_FOR_BOOKING')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CLEARANCE_UNDER_REVIEW',
|
||||
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer uploads clearance documents on the contract. When every required
|
||||
* input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET.
|
||||
@@ -209,8 +453,16 @@ export class ContractClearanceService {
|
||||
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW', {
|
||||
currentPhase: ContractDocPhase.GlEtReview,
|
||||
});
|
||||
}
|
||||
|
||||
if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') {
|
||||
await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection);
|
||||
await this.workflowService.onDocumentReviewReopened(contractId);
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
@@ -294,25 +546,22 @@ export class ContractClearanceService {
|
||||
note?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
// Reviewing is allowed both while the batch is UNDER_REVIEW and after it has
|
||||
// dropped back to AWAITING_CLEARANCE_DOCUMENTS — querying one document flips
|
||||
// the contract to "awaiting" (the customer must re-upload), but the reviewer
|
||||
// may still be working through the rest of the batch. Restricting to
|
||||
// UNDER_REVIEW only would 409 every review after the first query.
|
||||
if (
|
||||
contract.status !== 'CLEARANCE_UNDER_REVIEW' &&
|
||||
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS'
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`Cannot review clearance documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
this.assertClearanceReviewableStatus(contract);
|
||||
if (status === 'QUERIED' && !note?.trim()) {
|
||||
throw new BadRequestException('A note is required when querying a document');
|
||||
}
|
||||
|
||||
const { inputCode, outputCode } = contractClearanceCodes(contract);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (
|
||||
status === 'QUERIED' &&
|
||||
this.isPhasedCustoms(contract) &&
|
||||
cycle?.preClearanceFinalizedAt
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Customer documents cannot be queried after pre-clearance is finalized.',
|
||||
);
|
||||
}
|
||||
const reviews = await this.contractsRepository.findDocumentReviews(
|
||||
contractId,
|
||||
cycle?.id ?? null,
|
||||
@@ -348,6 +597,33 @@ export class ContractClearanceService {
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
||||
}
|
||||
if (this.isPhasedCustoms(contract)) {
|
||||
await this.workflowService.onDocumentReviewReopened(contractId);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
currentPhase: ContractDocPhase.GlEtReview,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (status === 'APPROVED') {
|
||||
await this.bumpToUnderReviewWhenFullyApproved(contractId);
|
||||
const refreshed = await this.contractsService.findById(contractId);
|
||||
if (
|
||||
refreshed.customsClearingEnabled &&
|
||||
refreshed.contractKind === 'ONE_TIME' &&
|
||||
(await this.isClearanceFullyApproved(refreshed))
|
||||
) {
|
||||
await this.workflowService.onAllDocsApproved(contractId);
|
||||
const c = await this.contractsRepository.currentCycle(contractId);
|
||||
if (c) {
|
||||
await this.contractsRepository.updateCycle(c.id, {
|
||||
currentPhase:
|
||||
refreshed.tradeDirection === 'EXPORT'
|
||||
? ContractDocPhase.GlDjCollection
|
||||
: ContractDocPhase.GlEtOutput,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
@@ -359,11 +635,7 @@ export class ContractClearanceService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
throw new ConflictException(
|
||||
`Cannot upload output documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
this.assertClearanceOutputUploadableStatus(contract);
|
||||
const { outputCode } = contractClearanceCodes(contract);
|
||||
if (!outputCode) {
|
||||
throw new BadRequestException('This contract has no customs output documents');
|
||||
@@ -384,8 +656,10 @@ export class ContractClearanceService {
|
||||
|
||||
/**
|
||||
* GL ET finalizes Path B pre-booking clearance: requires every customer
|
||||
* document APPROVED and required output docs present → CLEARANCE_READY_FOR_BOOKING
|
||||
* (GL then creates the booking). Rejects self-clearance (Path A) contracts.
|
||||
* document APPROVED. For phased customs (ONE_TIME), document review completes
|
||||
* here — booking readiness is set only after delivery order (import) or export
|
||||
* release via the milestone workflow. Non-phased customs still jump straight to
|
||||
* CLEARANCE_READY_FOR_BOOKING. Rejects self-clearance (Path A) contracts.
|
||||
*/
|
||||
async finalize(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
@@ -394,11 +668,7 @@ export class ContractClearanceService {
|
||||
'Self-clearance (Path A) contracts are finalized by Operations, not GL.',
|
||||
);
|
||||
}
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
throw new ConflictException(
|
||||
`Cannot finalize clearance on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
this.assertClearanceFinalizableStatus(contract);
|
||||
|
||||
const approved = await this.isClearanceFullyApproved(contract);
|
||||
if (!approved) {
|
||||
@@ -407,6 +677,24 @@ export class ContractClearanceService {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.isPhasedCustoms(contract)) {
|
||||
await this.workflowService.onAllDocsApproved(contractId);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
currentPhase:
|
||||
contract.tradeDirection === 'EXPORT'
|
||||
? ContractDocPhase.GlDjCollection
|
||||
: ContractDocPhase.GlEtOutput,
|
||||
});
|
||||
}
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CLEARANCE_UNDER_REVIEW',
|
||||
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
const { outputCode } = contractClearanceCodes(contract);
|
||||
if (outputCode) {
|
||||
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
|
||||
@@ -452,11 +740,7 @@ export class ContractClearanceService {
|
||||
'Operations finalize applies only to self-clearance (non-customs) contracts.',
|
||||
);
|
||||
}
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
throw new ConflictException(
|
||||
`Cannot finalize clearance on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
this.assertClearanceFinalizableStatus(contract);
|
||||
|
||||
const approved = await this.isClearanceFullyApproved(contract);
|
||||
if (!approved) {
|
||||
@@ -479,19 +763,14 @@ export class ContractClearanceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET clearance hub: every customs (Path B) contract that still needs
|
||||
* customs clearance — awaiting the customer's documents, under GL review, or
|
||||
* finalized and waiting for the customer to create the booking in the portal.
|
||||
* GL ET clearance hub: every customs (Path B) contract in phased clearance,
|
||||
* including after booking is created.
|
||||
*/
|
||||
async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
return this.contractsRepository.findAllPaginated({
|
||||
page: filter.page ?? 1,
|
||||
pageSize: filter.pageSize ?? 100,
|
||||
statuses: [
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
],
|
||||
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
|
||||
customsClearingEnabled: true,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
@@ -536,4 +815,496 @@ export class ContractClearanceService {
|
||||
sortOrder: filter.sortOrder ?? 'DESC',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
|
||||
|
||||
/** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */
|
||||
private async ensureDeclarationPrerequisites(
|
||||
contractId: string,
|
||||
contract: Contract,
|
||||
): Promise<void> {
|
||||
const allApproved = await this.isClearanceFullyApproved(contract);
|
||||
if (!allApproved) {
|
||||
throw new BadRequestException(
|
||||
'All required customer documents must be approved before uploading a declaration.',
|
||||
);
|
||||
}
|
||||
const milestones = await this.workflowService.listMilestones(contractId);
|
||||
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
|
||||
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
|
||||
await this.workflowService.onAllDocsApproved(contractId);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadDeclaration(
|
||||
contractId: string,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
await this.ensureDeclarationPrerequisites(contractId, contract);
|
||||
await this.workflowService.assertPriorComplete(
|
||||
contractId,
|
||||
contract.tradeDirection,
|
||||
'UNDER_CUSTOMS_CLEARANCE',
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No declaration documents uploaded');
|
||||
}
|
||||
|
||||
await persistDeclarationUploads(
|
||||
this.filesService,
|
||||
contractId,
|
||||
'contracts',
|
||||
files,
|
||||
);
|
||||
|
||||
await this.workflowService.onDeclarationUploaded(contractId, userId);
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
currentPhase:
|
||||
contract.tradeDirection === 'EXPORT'
|
||||
? ContractDocPhase.GlEtPostClearance
|
||||
: ContractDocPhase.CustomerDuty,
|
||||
});
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async adviseDuty(
|
||||
contractId: string,
|
||||
dto: AdviseContractDutyDto,
|
||||
userId?: string,
|
||||
attachment?: Express.Multer.File,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Duty advice applies only to import contracts.');
|
||||
}
|
||||
await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DUTY_TAXES_ADVISED');
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
dutyRequired: dto.dutyRequired,
|
||||
currentPhase: dto.dutyRequired
|
||||
? ContractDocPhase.CustomerDuty
|
||||
: ContractDocPhase.GlEtPostClearance,
|
||||
});
|
||||
|
||||
if (!dto.dutyRequired) {
|
||||
await this.workflowService.onDutySkipped(contractId);
|
||||
} else {
|
||||
if (dto.amount == null || dto.amount < 0) {
|
||||
throw new BadRequestException('Duty amount is required when duty applies.');
|
||||
}
|
||||
if (!attachment) {
|
||||
throw new BadRequestException('Duty notice attachment is required when duty applies.');
|
||||
}
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: 'duty_tax_notice',
|
||||
file: attachment,
|
||||
});
|
||||
await this.milestoneService.adviseDutyForContract(
|
||||
contractId,
|
||||
{
|
||||
amount: dto.amount,
|
||||
currency: dto.currency ?? 'ETB',
|
||||
declarationSerial: dto.declarationSerial,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async uploadDutySlip(
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Duty slip upload applies only to import contracts.');
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle?.dutyRequired) {
|
||||
throw new BadRequestException('Duty/tax is not required for this clearance.');
|
||||
}
|
||||
|
||||
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: 'duty_tax_receipt',
|
||||
file,
|
||||
});
|
||||
|
||||
await this.workflowService.completeMilestone(contractId, 'DUTY_TAX_PAID');
|
||||
if (cycle) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
});
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async uploadTransitPermit(
|
||||
contractId: string,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Transit permit applies only to import contracts.');
|
||||
}
|
||||
await this.workflowService.assertPriorComplete(
|
||||
contractId,
|
||||
'IMPORT',
|
||||
'TRANSIT_PERMIT_UPLOADED',
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No transit permit documents uploaded');
|
||||
}
|
||||
|
||||
await persistTransitPermitUploads(
|
||||
this.filesService,
|
||||
contractId,
|
||||
'contracts',
|
||||
files,
|
||||
);
|
||||
|
||||
await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId);
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
});
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async finalizePreClearance(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Pre-clearance finalize applies only to import contracts.');
|
||||
}
|
||||
|
||||
await this.workflowService.assertPriorComplete(
|
||||
contractId,
|
||||
'IMPORT',
|
||||
'TRANSIT_PERMIT_UPLOADED',
|
||||
);
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||
if (cycle.preClearanceFinalizedAt) {
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
preClearanceFinalizedAt: new Date(),
|
||||
currentPhase: ContractDocPhase.GlDjCollection,
|
||||
});
|
||||
|
||||
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
|
||||
const files = await this.filesService.findByResource(contractId, 'contracts');
|
||||
if (files.some((f) => f.code === 'delivery_order')) {
|
||||
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED');
|
||||
await this.workflowService.markReadyForBooking(contractId);
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async uploadDeliveryOrder(
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
userId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Delivery Order applies only to import contracts.');
|
||||
}
|
||||
|
||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||
|
||||
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
||||
// any file type. The DO_COLLECTED milestone (and booking readiness) still waits
|
||||
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: 'delivery_order',
|
||||
file,
|
||||
});
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle?.preClearanceFinalizedAt) {
|
||||
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
|
||||
await this.workflowService.markReadyForBooking(contractId);
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
private async resolveRoMinDays(): Promise<number> {
|
||||
try {
|
||||
const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE);
|
||||
const first = setting.children?.[0];
|
||||
const n = Number(first?.value);
|
||||
return Number.isFinite(n) && n > 0 ? n : 2;
|
||||
} catch {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private daysUntil(dateStr: string): number {
|
||||
const target = new Date(dateStr);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
async uploadReleaseOrder(
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
vesselDepartureDate: string,
|
||||
userId?: string,
|
||||
): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('Release Order applies only to export contracts.');
|
||||
}
|
||||
await this.workflowService.assertPriorComplete(
|
||||
contractId,
|
||||
'EXPORT',
|
||||
'RELEASE_ORDER_SECURED',
|
||||
);
|
||||
|
||||
if (!file) throw new BadRequestException('No Release Order uploaded');
|
||||
if (!vesselDepartureDate?.trim()) {
|
||||
throw new BadRequestException('Vessel departure date is required');
|
||||
}
|
||||
|
||||
const minDays = await this.resolveRoMinDays();
|
||||
const leadDays = this.daysUntil(vesselDepartureDate);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: 'release_order',
|
||||
file,
|
||||
});
|
||||
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
vesselDepartureDate,
|
||||
roAmendmentRequestedAt: null,
|
||||
});
|
||||
|
||||
if (leadDays < minDays) {
|
||||
const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`;
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
roHoldReason: reason,
|
||||
currentPhase: ContractDocPhase.GlDjCollection,
|
||||
});
|
||||
return { contract: await this.contractsService.findById(contractId), hold: true, holdReason: reason };
|
||||
}
|
||||
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
roHoldReason: null,
|
||||
currentPhase: ContractDocPhase.GlEtOutput,
|
||||
});
|
||||
await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId);
|
||||
|
||||
return { contract: await this.contractsService.findById(contractId), hold: false };
|
||||
}
|
||||
|
||||
async requestRoAmendment(
|
||||
contractId: string,
|
||||
note?: string,
|
||||
userId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('RO amendment applies only to export contracts.');
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||
|
||||
const reason =
|
||||
note?.trim() ||
|
||||
'Port amendment requested — vessel departure window is too short. A new Release Order will be required.';
|
||||
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
roAmendmentRequestedAt: new Date(),
|
||||
roHoldReason: reason,
|
||||
currentPhase: ContractDocPhase.GlDjCollection,
|
||||
});
|
||||
|
||||
if (userId) {
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contractId,
|
||||
reason,
|
||||
'CHANGES_REQUESTED',
|
||||
userId,
|
||||
'GL_DJ',
|
||||
);
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async confirmExportRelease(contractId: string, userId?: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('Export release applies only to export contracts.');
|
||||
}
|
||||
await this.workflowService.assertPriorComplete(contractId, 'EXPORT', 'EXPORT_RELEASED');
|
||||
|
||||
await this.workflowService.onExportReleased(contractId, userId);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** GL ET finalizes export clearance after post-booking transit permit is uploaded. */
|
||||
async finalizeExportClearance(contractId: string, userId?: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('Export clearance finalize applies only to export contracts.');
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle?.bookingId) {
|
||||
throw new BadRequestException(
|
||||
'A shipment booking must exist before export clearance can be finalized.',
|
||||
);
|
||||
}
|
||||
if (cycle.completedAt) {
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
|
||||
cycle.bookingId,
|
||||
);
|
||||
const transportDone = bookingMilestones.some(
|
||||
(m) => m.milestoneCode === 'EXPORT_TRANSPORT_ISSUED' && m.status === 'COMPLETED',
|
||||
);
|
||||
if (!transportDone) {
|
||||
throw new BadRequestException(
|
||||
'Upload the transit permit before finalizing export clearance.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
completedAt: new Date(),
|
||||
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
});
|
||||
|
||||
void userId;
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */
|
||||
async etQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
const base = await this.contractsRepository.findAllPaginated({
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'ONE_TIME',
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
|
||||
const filtered: typeof base.items = [];
|
||||
for (const c of base.items) {
|
||||
const milestones = await this.workflowService.listMilestones(c.id);
|
||||
if (belongsOnEtClearanceQueue(milestones)) filtered.push(c);
|
||||
}
|
||||
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 50;
|
||||
const start = (page - 1) * pageSize;
|
||||
const items = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: filtered.length,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total: filtered.length,
|
||||
totalPages: Math.ceil(filtered.length / pageSize) || 1,
|
||||
hasNextPage: start + pageSize < filtered.length,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */
|
||||
async djQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
const base = await this.contractsRepository.findAllPaginated({
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
statuses: [...DJ_CONTRACT_QUEUE_STATUSES],
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'ONE_TIME',
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
|
||||
const filtered: typeof base.items = [];
|
||||
for (const c of base.items) {
|
||||
const cycle = await this.contractsRepository.currentCycle(c.id);
|
||||
const milestones = await this.workflowService.listMilestones(c.id);
|
||||
if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) {
|
||||
filtered.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 50;
|
||||
const start = (page - 1) * pageSize;
|
||||
const items = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: filtered.length,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total: filtered.length,
|
||||
totalPages: Math.ceil(filtered.length / pageSize) || 1,
|
||||
hasNextPage: start + pageSize < filtered.length,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,15 +172,11 @@ export class ContractTransitionService {
|
||||
const cargoTypeId =
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null;
|
||||
|
||||
// US-06 routing: bulk always needs director approval; container needs it only
|
||||
// when its cargo type flags it. Resolve the chain via the same approval_rules
|
||||
// source of truth the booking flow uses (no booking row is created here).
|
||||
let requiresDirectorApproval = contract.freightType === 'BULK';
|
||||
// Resolve the chain from the cargo type flag only.
|
||||
let requiresDirectorApproval = false;
|
||||
if (cargoTypeId) {
|
||||
const cargoType = await this.cargoTypesService.findById(cargoTypeId);
|
||||
if (cargoType?.requiresDirectorApproval) {
|
||||
requiresDirectorApproval = true;
|
||||
}
|
||||
requiresDirectorApproval = cargoType?.requiresDirectorApproval ?? false;
|
||||
}
|
||||
|
||||
const chain = await this.approvalRulesService.findChain(requiresDirectorApproval);
|
||||
@@ -345,6 +341,14 @@ export class ContractTransitionService {
|
||||
return { view, html, signatures: view.signatures };
|
||||
}
|
||||
|
||||
/** Lazy-generate (or refresh) the stored contract PDF and stream it for download. */
|
||||
async streamContractPdf(contractId: string) {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
const { view } = await this.documentViewModelBuilder.build(contractId);
|
||||
const record = await this.upsertContractPdf(contractId, contract.reference, view);
|
||||
return this.filesService.streamById(record.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the stored `contract` PDF from the current aggregate (now including
|
||||
* the latest signatures) so the downloaded/viewed file matches the live HTML
|
||||
|
||||
@@ -9,13 +9,16 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
@@ -40,11 +43,13 @@ import { ContractsService } from './contracts.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
import { BookingClearanceService } from './booking-clearance.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { BookingRequestService } from './booking-request.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { CreateContractDto } from './dto/create-contract.dto';
|
||||
import { UpdateContractDto } from './dto/update-contract.dto';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
@@ -70,6 +75,10 @@ import {
|
||||
CompleteMilestoneDto,
|
||||
ReportIncidentDto,
|
||||
} from './dto/gl-operations.dto';
|
||||
import {
|
||||
AdviseContractDutyDto,
|
||||
RoAmendmentDto,
|
||||
} from './dto/phased-clearance.dto';
|
||||
|
||||
@ApiTags('contracts')
|
||||
@Controller('contracts')
|
||||
@@ -85,6 +94,8 @@ export class ContractsController {
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
private readonly bookingRequestService: BookingRequestService,
|
||||
private readonly signaturesService: SignaturesService,
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
) {}
|
||||
|
||||
// ── Shipment / booking requests (GENERAL + customs, Path B) ───────────────
|
||||
@@ -417,6 +428,26 @@ export class ContractsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id/contract/document')
|
||||
@ApiOperation({ summary: 'Download contract PDF' })
|
||||
async downloadContractDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
const { stream, record } = await this.transitionService.streamContractPdf(id);
|
||||
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${record.name}"`,
|
||||
);
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
@Post(':id/contract/sign')
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||
signContract(
|
||||
@@ -459,7 +490,10 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/clearance/review')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
])
|
||||
@ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' })
|
||||
reviewClearanceDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -489,11 +523,164 @@ export class ContractsController {
|
||||
|
||||
@Post(':id/clearance/finalize')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance)
|
||||
@ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING' })
|
||||
@ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)' })
|
||||
finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.clearanceService.finalize(id);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/declaration')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET uploads customs declaration documents (multi-file)' })
|
||||
uploadDeclaration(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.uploadDeclaration(id, files ?? [], resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise)
|
||||
@UseInterceptors(FileInterceptor('attachment'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount with notice attachment' })
|
||||
adviseContractDuty(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('dutyRequired') dutyRequiredRaw: string,
|
||||
@Body('amount') amountRaw: string | undefined,
|
||||
@Body('currency') currency: string | undefined,
|
||||
@Body('declarationSerial') declarationSerial: string | undefined,
|
||||
@UploadedFile() attachment: Express.Multer.File | undefined,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1';
|
||||
const dto: AdviseContractDutyDto = {
|
||||
dutyRequired,
|
||||
amount:
|
||||
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
|
||||
currency: currency ?? 'ETB',
|
||||
declarationSerial,
|
||||
};
|
||||
return this.clearanceService.adviseDuty(
|
||||
id,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
attachment,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize-pre-clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance — unlocks Djibouti DO upload' })
|
||||
finalizePreClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.clearanceService.finalizePreClearance(id);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty-slip')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' })
|
||||
uploadContractDutySlip(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.clearanceService.uploadDutySlip(id, file);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/transit-permit')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET uploads import transit permit documents (multi-file)' })
|
||||
uploadTransitPermit(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.uploadTransitPermit(id, files ?? [], resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/clearance/delivery-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' })
|
||||
uploadDeliveryOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/clearance/release-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL DJ uploads Release Order + vessel departure date (export)' })
|
||||
uploadReleaseOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.uploadReleaseOrder(
|
||||
id,
|
||||
file,
|
||||
vesselDepartureDate,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/ro-amendment')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({ summary: 'GL DJ requests port amendment when RO vessel window is too short' })
|
||||
requestRoAmendment(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RoAmendmentDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.requestRoAmendment(id, dto.note, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/clearance/export-release')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET confirms export release after declaration' })
|
||||
confirmExportRelease(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.confirmExportRelease(id, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize-export-clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary: 'GL ET finalizes export clearance after post-booking transit permit upload',
|
||||
})
|
||||
finalizeExportClearance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get('clearance/et-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' })
|
||||
etClearanceQueue(@Query() filter: FilterContractDto) {
|
||||
return this.clearanceService.etQueue(filter);
|
||||
}
|
||||
|
||||
@Get('clearance/dj-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' })
|
||||
djClearanceQueue(@Query() filter: FilterContractDto) {
|
||||
return this.clearanceService.djQueue(filter);
|
||||
}
|
||||
|
||||
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
||||
|
||||
@Get('clearance/ops-queue')
|
||||
@@ -674,6 +861,45 @@ export class ContractsController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/transport-document')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET uploads export transit permit documents (multi-file)' })
|
||||
uploadTransportDocument(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/t1-documents')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs',
|
||||
})
|
||||
uploadT1Documents(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.glOperationsService.uploadT1Documents(bookingId, files ?? []);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/t1-close')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary: 'GL Ethiopia closes (accepts) the T1 document set after the train arrives',
|
||||
})
|
||||
closeT1(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/documents')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@@ -692,11 +918,16 @@ export class ContractsController {
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' })
|
||||
uploadDutySlip(
|
||||
async uploadDutySlip(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.glOperationsService.uploadDutySlip(bookingId, (files ?? [])[0]);
|
||||
const file = (files ?? [])[0];
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) {
|
||||
return this.bookingClearanceService.uploadDutySlip(bookingId, file);
|
||||
}
|
||||
return this.glOperationsService.uploadDutySlip(bookingId, file);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/incidents')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
@@ -18,6 +18,8 @@ import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
import { BookingClearanceService } from './booking-clearance.service';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
@@ -71,7 +73,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
CompaniesModule,
|
||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||
BookingsModule,
|
||||
forwardRef(() => BookingsModule),
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
@@ -85,6 +87,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractPricingService,
|
||||
ContractTransitionService,
|
||||
ContractClearanceService,
|
||||
ClearanceWorkflowService,
|
||||
BookingClearanceService,
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
GlOperationsService,
|
||||
@@ -103,6 +107,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractPricingService,
|
||||
ContractTransitionService,
|
||||
ContractClearanceService,
|
||||
ClearanceWorkflowService,
|
||||
BookingClearanceService,
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
],
|
||||
|
||||
@@ -518,7 +518,17 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
cycleId: string,
|
||||
status: string,
|
||||
fields: Partial<
|
||||
Pick<ContractClearanceCycle, 'bookingId' | 'clearanceReadyAt' | 'completedAt'>
|
||||
Pick<
|
||||
ContractClearanceCycle,
|
||||
| 'bookingId'
|
||||
| 'clearanceReadyAt'
|
||||
| 'completedAt'
|
||||
| 'dutyRequired'
|
||||
| 'vesselDepartureDate'
|
||||
| 'roAmendmentRequestedAt'
|
||||
| 'roHoldReason'
|
||||
| 'currentPhase'
|
||||
>
|
||||
> = {},
|
||||
): Promise<void> {
|
||||
await this.dataSource
|
||||
@@ -526,6 +536,25 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
.update(cycleId, { status, ...fields } as never);
|
||||
}
|
||||
|
||||
async updateCycle(
|
||||
cycleId: string,
|
||||
fields: Partial<
|
||||
Pick<
|
||||
ContractClearanceCycle,
|
||||
| 'dutyRequired'
|
||||
| 'vesselDepartureDate'
|
||||
| 'roAmendmentRequestedAt'
|
||||
| 'roHoldReason'
|
||||
| 'currentPhase'
|
||||
| 'status'
|
||||
| 'preClearanceFinalizedAt'
|
||||
| 'completedAt'
|
||||
>
|
||||
>,
|
||||
): Promise<void> {
|
||||
await this.dataSource.getRepository(ContractClearanceCycle).update(cycleId, fields as never);
|
||||
}
|
||||
|
||||
/** Link the GL-created booking to a clearance cycle. */
|
||||
async linkBooking(cycleId: string, bookingId: string): Promise<void> {
|
||||
await this.dataSource
|
||||
|
||||
@@ -200,9 +200,6 @@ export class ContractsService {
|
||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
isReefer: dto.isReefer ?? false,
|
||||
estimatedShipmentDate: dto.estimatedShipmentDate
|
||||
? new Date(dto.estimatedShipmentDate)
|
||||
: null,
|
||||
contractType: dto.contractType ?? null,
|
||||
status: 'DRAFT',
|
||||
clearanceStatus: 'NOT_APPLICABLE',
|
||||
@@ -347,9 +344,6 @@ export class ContractsService {
|
||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng,
|
||||
contractType: dto.contractType ?? existing.contractType,
|
||||
};
|
||||
if (dto.estimatedShipmentDate) {
|
||||
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
|
||||
}
|
||||
if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null;
|
||||
|
||||
// Customs clearing always mirrors the (possibly changed) service type.
|
||||
|
||||
@@ -101,6 +101,13 @@ export class CreateBulkLineDto {
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
hazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
reeferQuantity?: number;
|
||||
}
|
||||
|
||||
/** Shipment booking created under a contract (Path A customer, Path B GL ET). */
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@@ -219,14 +218,6 @@ export class CreateContractDto {
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReefer?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Non-binding estimate from the wizard (NOT validated against departures)',
|
||||
example: '2026-07-15T00:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
estimatedShipmentDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Contract document type (SPOT, etc.)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
|
||||
export class AdviseContractDutyDto {
|
||||
@ApiProperty({ description: 'Whether the customer must pay duty/tax' })
|
||||
@IsBoolean()
|
||||
dutyRequired!: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Duty amount (required when dutyRequired is true)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 'ETB' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Declaration / payment reference code' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
declarationSerial?: string;
|
||||
}
|
||||
|
||||
export class ReleaseOrderDto {
|
||||
@ApiProperty({ description: 'Vessel departure date (ISO date YYYY-MM-DD)' })
|
||||
@IsString()
|
||||
vesselDepartureDate!: string;
|
||||
}
|
||||
|
||||
export class RoAmendmentDto {
|
||||
@ApiPropertyOptional({ description: 'Note to customer / ET GL about the amendment request' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -34,4 +34,25 @@ export class ContractClearanceCycle extends BaseEntity {
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
completedAt?: Date | null;
|
||||
|
||||
/** ET GL toggle: whether customer must pay duty/tax before DO collection (import). */
|
||||
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
||||
dutyRequired?: boolean | null;
|
||||
|
||||
/** Export RO vessel departure date (Path B export). */
|
||||
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
|
||||
vesselDepartureDate?: string | null;
|
||||
|
||||
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
|
||||
roAmendmentRequestedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'ro_hold_reason', type: 'text', nullable: true })
|
||||
roHoldReason?: string | null;
|
||||
|
||||
@Column({ name: 'current_phase', type: 'varchar', length: 40, nullable: true })
|
||||
currentPhase?: string | null;
|
||||
|
||||
/** ET GL confirms import pre-clearance complete — unlocks Djibouti DO upload. */
|
||||
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { isT1TransportFileCode, type Freight } from '@edr/types';
|
||||
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import {
|
||||
ClearanceIncident,
|
||||
IncidentType,
|
||||
} from './entities/clearance-incident.entity';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import {
|
||||
persistExportTransportUploads,
|
||||
persistT1TransportUploads,
|
||||
} from './phased-clearance.util';
|
||||
|
||||
/**
|
||||
* Maps a GL post-booking document `code` to the milestone it auto-completes when
|
||||
@@ -17,10 +23,12 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
const DOC_CODE_TO_MILESTONE: Record<string, string> = {
|
||||
release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ
|
||||
delivery_order: 'DO_COLLECTED', // import — GL DJ
|
||||
t1_transport_document: 'T1_CLOSED', // import — GL ET
|
||||
// t1_transport_document intentionally NOT doc-triggered: T1_CLOSED completes only
|
||||
// when GL Ethiopia accepts the T1 set after the train arrives (closeT1).
|
||||
import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET
|
||||
full_in_interchange: 'OFFLOADED', // export — GL DJ
|
||||
final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET
|
||||
export_transport_document: 'EXPORT_TRANSPORT_ISSUED', // export — GL ET post-allocation
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -158,4 +166,152 @@ export class GlOperationsService {
|
||||
}
|
||||
return { uploaded: files.length, completedMilestones };
|
||||
}
|
||||
|
||||
/**
|
||||
* T1 transit-document lifecycle state for an import shipment booking. Wagon
|
||||
* allocation opens the upload window; train departure locks it; train arrival
|
||||
* lets GL Ethiopia close (accept) the T1 set.
|
||||
*/
|
||||
async t1State(bookingId: string): Promise<Freight.ClearanceT1State> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
|
||||
const wagonMilestone = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED');
|
||||
const wagonAllocated =
|
||||
wagonMilestone?.status === 'COMPLETED' ||
|
||||
booking.schedulingStatus === 'SCHEDULED' ||
|
||||
booking.schedulingStatus === 'DISPATCHED' ||
|
||||
Boolean(booking.trainScheduleId);
|
||||
|
||||
let schedule: TrainSchedule | null = null;
|
||||
if (booking.trainScheduleId) {
|
||||
schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: booking.trainScheduleId } });
|
||||
}
|
||||
|
||||
const closedMilestone = milestones.find(
|
||||
(m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED',
|
||||
);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
wagonAllocated,
|
||||
trainDepartedAt: schedule?.actualDepartureAt
|
||||
? new Date(schedule.actualDepartureAt).toISOString()
|
||||
: null,
|
||||
trainArrivedAt: schedule?.actualArrivalAt
|
||||
? new Date(schedule.actualArrivalAt).toISOString()
|
||||
: null,
|
||||
closed: Boolean(closedMilestone),
|
||||
closedAt: closedMilestone?.triggeredAt
|
||||
? new Date(closedMilestone.triggeredAt).toISOString()
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Djibouti uploads T1 transport documents (multi-file) after wagon allocation.
|
||||
* Replaces the previous batch; locked once the train departs or T1 is closed.
|
||||
*/
|
||||
async uploadT1Documents(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<{ uploaded: number }> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('T1 transport documents apply to import shipments only.');
|
||||
}
|
||||
|
||||
const state = await this.t1State(bookingId);
|
||||
if (!state.wagonAllocated) {
|
||||
throw new BadRequestException(
|
||||
'Wagons must be allocated before T1 transport documents can be uploaded.',
|
||||
);
|
||||
}
|
||||
if (state.closed) {
|
||||
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
|
||||
}
|
||||
if (state.trainDepartedAt) {
|
||||
throw new BadRequestException(
|
||||
'The train has departed — T1 transport documents can no longer be changed.',
|
||||
);
|
||||
}
|
||||
|
||||
await persistT1TransportUploads(this.filesService, bookingId, files);
|
||||
return { uploaded: files.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia closes (accepts) the T1 document set once the train has arrived.
|
||||
* Completes the T1_CLOSED milestone; the document set becomes final.
|
||||
*/
|
||||
async closeT1(
|
||||
bookingId: string,
|
||||
userId?: string,
|
||||
): Promise<Freight.ClearanceT1State> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('T1 closure applies to import shipments only.');
|
||||
}
|
||||
|
||||
const state = await this.t1State(bookingId);
|
||||
if (state.closed) return state;
|
||||
if (!state.trainArrivedAt) {
|
||||
throw new BadRequestException(
|
||||
'The train has not arrived yet — T1 can be closed only after arrival.',
|
||||
);
|
||||
}
|
||||
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const hasT1 = files.some((f) => isT1TransportFileCode(f.code));
|
||||
if (!hasT1) {
|
||||
throw new BadRequestException(
|
||||
'No T1 transport documents on file — GL Djibouti must upload them first.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId);
|
||||
return this.t1State(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET uploads export transport document after wagon allocation (export ONE_TIME).
|
||||
*/
|
||||
async uploadTransportDocument(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('Transport document upload applies to export shipments only.');
|
||||
}
|
||||
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
const wagonAllocated = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED');
|
||||
const wagonDone =
|
||||
wagonAllocated?.status === 'COMPLETED' || booking.schedulingStatus === 'SCHEDULED';
|
||||
if (!wagonDone) {
|
||||
throw new BadRequestException(
|
||||
'Wagon must be allocated before the transport document can be uploaded.',
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No transit permit documents uploaded');
|
||||
}
|
||||
|
||||
await persistExportTransportUploads(this.filesService, bookingId, files);
|
||||
|
||||
if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') {
|
||||
await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED');
|
||||
}
|
||||
|
||||
await this.milestoneService.completeByDocTrigger(
|
||||
{ bookingId },
|
||||
'EXPORT_TRANSPORT_ISSUED',
|
||||
);
|
||||
|
||||
return { uploaded: true, milestoneCompleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue } from './phased-clearance.util';
|
||||
|
||||
describe('buildWorkflowFiles', () => {
|
||||
const resourceFiles = [
|
||||
{ code: 'im4', id: 'f-im4', name: 'im4.pdf', url: '/files/im4' },
|
||||
{ code: 'im5', id: 'f-im5', name: 'im5.pdf', url: '/files/im5' },
|
||||
{
|
||||
code: 'transit_permitted',
|
||||
id: 'f-transit',
|
||||
name: 'transit.png',
|
||||
url: '/files/transit',
|
||||
},
|
||||
{
|
||||
code: 'duty_tax_notice',
|
||||
id: 'f-duty',
|
||||
name: 'notice.pdf',
|
||||
url: '/files/duty',
|
||||
},
|
||||
{ code: 'commercial_invoice', id: 'f-inv', name: 'inv.pdf', url: '/files/inv' },
|
||||
];
|
||||
|
||||
it('includes declaration and transit files even when they also appear in GL output document settings', () => {
|
||||
const result = buildWorkflowFiles(resourceFiles, 'IMPORT');
|
||||
|
||||
expect(result.map((f) => f.code)).toEqual(
|
||||
expect.arrayContaining(['im4', 'im5', 'transit_permitted', 'duty_tax_notice']),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes multi-file declaration uploads alongside catalog codes', () => {
|
||||
const result = buildWorkflowFiles(
|
||||
[
|
||||
...resourceFiles,
|
||||
{
|
||||
code: 'declaration_0',
|
||||
id: 'f-dec-0',
|
||||
name: 'decl-a.pdf',
|
||||
url: '/files/decl-a',
|
||||
},
|
||||
{
|
||||
code: 'declaration_1',
|
||||
id: 'f-dec-1',
|
||||
name: 'decl-b.pdf',
|
||||
url: '/files/decl-b',
|
||||
},
|
||||
],
|
||||
'IMPORT',
|
||||
);
|
||||
|
||||
expect(result.map((f) => f.code)).toEqual(
|
||||
expect.arrayContaining(['im4', 'im5', 'declaration_0', 'declaration_1']),
|
||||
);
|
||||
expect(result.find((f) => f.code === 'declaration_0')?.label).toBe(
|
||||
'Declaration document 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes multi-file import transit permit uploads', () => {
|
||||
const result = buildWorkflowFiles(
|
||||
[
|
||||
...resourceFiles,
|
||||
{
|
||||
code: 'transit_permit_0',
|
||||
id: 'f-tp-0',
|
||||
name: 'permit-a.pdf',
|
||||
url: '/files/tp-a',
|
||||
},
|
||||
{
|
||||
code: 'transit_permit_1',
|
||||
id: 'f-tp-1',
|
||||
name: 'permit-b.pdf',
|
||||
url: '/files/tp-b',
|
||||
},
|
||||
],
|
||||
'IMPORT',
|
||||
);
|
||||
|
||||
expect(result.map((f) => f.code)).toEqual(
|
||||
expect.arrayContaining(['transit_permitted', 'transit_permit_0', 'transit_permit_1']),
|
||||
);
|
||||
expect(result.find((f) => f.code === 'transit_permit_0')?.label).toBe('Transit permit 1');
|
||||
});
|
||||
|
||||
it('does not include non-catalog customer document codes', () => {
|
||||
const result = buildWorkflowFiles(resourceFiles, 'IMPORT');
|
||||
|
||||
expect(result.some((f) => f.code === 'commercial_invoice')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('belongsOnDjClearanceQueue', () => {
|
||||
it('keeps import contracts after pre-clearance is finalized (even post-booking)', () => {
|
||||
expect(
|
||||
belongsOnDjClearanceQueue(
|
||||
'IMPORT',
|
||||
{ preClearanceFinalizedAt: new Date('2026-01-01') },
|
||||
[],
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps contracts with completed Djibouti milestones', () => {
|
||||
expect(
|
||||
belongsOnDjClearanceQueue('IMPORT', null, [
|
||||
{ ownerRegion: 'DJ', status: 'COMPLETED' },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes import contracts still on Ethiopia-side clearance only', () => {
|
||||
expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('belongsOnEtClearanceQueue', () => {
|
||||
it('keeps contracts once phased clearance milestones exist', () => {
|
||||
expect(
|
||||
belongsOnEtClearanceQueue([{ ownerRegion: 'ET', status: 'COMPLETED' }]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes contracts with no clearance milestones', () => {
|
||||
expect(belongsOnEtClearanceQueue([])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
catalogEntriesForTradeDirection,
|
||||
declarationFileLabel,
|
||||
isDeclarationFileCode,
|
||||
isImportTransitPermitFileCode,
|
||||
isExportTransportFileCode,
|
||||
isT1TransportFileCode,
|
||||
exportTransportFileLabel,
|
||||
t1TransportFileLabel,
|
||||
transitPermitFileLabel,
|
||||
type ClearanceWorkflowFile,
|
||||
} from '@edr/types';
|
||||
|
||||
/** Require at least one declaration file in the upload batch. */
|
||||
export function assertDeclarationFiles(files: Express.Multer.File[]): void {
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No declaration documents uploaded');
|
||||
}
|
||||
}
|
||||
|
||||
/** Assign stable `declaration_*` codes so multi-file uploads always pass validation. */
|
||||
export function normalizeDeclarationFieldNames(
|
||||
files: Express.Multer.File[],
|
||||
): Express.Multer.File[] {
|
||||
return files.map((file, index) => ({
|
||||
...file,
|
||||
fieldname: `declaration_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
type DeclarationFileStore = {
|
||||
findByResource(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
): Promise<Array<{ code?: string | null }>>;
|
||||
deleteByCode(resourceId: string, resource: string, code: string): Promise<void>;
|
||||
upload(input: {
|
||||
resourceId: string;
|
||||
resource: string;
|
||||
code: string;
|
||||
file: Express.Multer.File;
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
|
||||
/** Replace all declaration files on a resource with a new multi-file upload batch. */
|
||||
export async function persistDeclarationUploads(
|
||||
store: DeclarationFileStore,
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<void> {
|
||||
const normalized = normalizeDeclarationFieldNames(files);
|
||||
assertDeclarationFiles(normalized);
|
||||
|
||||
const existing = await store.findByResource(resourceId, resource);
|
||||
await Promise.all(
|
||||
existing
|
||||
.filter((f) => f.code && isDeclarationFileCode(f.code))
|
||||
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
normalized.map((file, index) =>
|
||||
store.upload({
|
||||
resourceId,
|
||||
resource,
|
||||
code: `declaration_${index}`,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Require at least one transit permit file in the upload batch. */
|
||||
export function assertTransitPermitFiles(files: Express.Multer.File[]): void {
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No transit permit documents uploaded');
|
||||
}
|
||||
}
|
||||
|
||||
/** Assign stable `transit_permit_*` codes for multi-file import transit uploads. */
|
||||
export function normalizeTransitPermitFieldNames(
|
||||
files: Express.Multer.File[],
|
||||
): Express.Multer.File[] {
|
||||
return files.map((file, index) => ({
|
||||
...file,
|
||||
fieldname: `transit_permit_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Replace all import transit permit files on a resource with a new multi-file batch. */
|
||||
export async function persistTransitPermitUploads(
|
||||
store: DeclarationFileStore,
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<void> {
|
||||
const normalized = normalizeTransitPermitFieldNames(files);
|
||||
assertTransitPermitFiles(normalized);
|
||||
|
||||
const existing = await store.findByResource(resourceId, resource);
|
||||
await Promise.all(
|
||||
existing
|
||||
.filter((f) => f.code && isImportTransitPermitFileCode(f.code))
|
||||
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
normalized.map((file, index) =>
|
||||
store.upload({
|
||||
resourceId,
|
||||
resource,
|
||||
code: `transit_permit_${index}`,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Require at least one export transport document in the upload batch. */
|
||||
export function assertExportTransportFiles(files: Express.Multer.File[]): void {
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No transit permit documents uploaded');
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeExportTransportFieldNames(
|
||||
files: Express.Multer.File[],
|
||||
): Express.Multer.File[] {
|
||||
return files.map((file, index) => ({
|
||||
...file,
|
||||
fieldname: `export_transport_document_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Replace all export transport documents on a booking with a new multi-file batch. */
|
||||
export async function persistExportTransportUploads(
|
||||
store: DeclarationFileStore,
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<void> {
|
||||
const normalized = normalizeExportTransportFieldNames(files);
|
||||
assertExportTransportFiles(normalized);
|
||||
|
||||
const existing = await store.findByResource(bookingId, 'bookings');
|
||||
await Promise.all(
|
||||
existing
|
||||
.filter((f) => f.code && isExportTransportFileCode(f.code))
|
||||
.map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
normalized.map((file, index) =>
|
||||
store.upload({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: `export_transport_document_${index}`,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Require at least one T1 transport document in the upload batch. */
|
||||
export function assertT1TransportFiles(files: Express.Multer.File[]): void {
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No T1 transport documents uploaded');
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeT1TransportFieldNames(
|
||||
files: Express.Multer.File[],
|
||||
): Express.Multer.File[] {
|
||||
return files.map((file, index) => ({
|
||||
...file,
|
||||
fieldname: `t1_transport_document_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Replace all T1 transport documents on a booking with a new multi-file batch. */
|
||||
export async function persistT1TransportUploads(
|
||||
store: DeclarationFileStore,
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<void> {
|
||||
const normalized = normalizeT1TransportFieldNames(files);
|
||||
assertT1TransportFiles(normalized);
|
||||
|
||||
const existing = await store.findByResource(bookingId, 'bookings');
|
||||
await Promise.all(
|
||||
existing
|
||||
.filter((f) => f.code && isT1TransportFileCode(f.code))
|
||||
.map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
normalized.map((file, index) =>
|
||||
store.upload({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: `t1_transport_document_${index}`,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseDutyRequiredForm(value: string | boolean | undefined): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === undefined || value === '') return false;
|
||||
return value === 'true' || value === '1';
|
||||
}
|
||||
|
||||
type DjQueueMilestone = {
|
||||
ownerRegion?: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type DjQueueCycle = {
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
roHoldReason?: string | null;
|
||||
} | null | undefined;
|
||||
|
||||
/** Whether a customs clearance item belongs on the persistent GL Djibouti list. */
|
||||
export function belongsOnDjClearanceQueue(
|
||||
tradeDirection: string | null | undefined,
|
||||
cycle: DjQueueCycle,
|
||||
milestones: DjQueueMilestone[],
|
||||
extras?: {
|
||||
roHoldReason?: string | null;
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
},
|
||||
): boolean {
|
||||
const roHold = cycle?.roHoldReason ?? extras?.roHoldReason;
|
||||
if (roHold) return true;
|
||||
|
||||
const hasDjActivity = milestones.some(
|
||||
(m) => m.ownerRegion === 'DJ' && (m.status === 'COMPLETED' || m.status === 'PENDING'),
|
||||
);
|
||||
if (hasDjActivity) return true;
|
||||
|
||||
// Import DO upload is un-gated — Djibouti GL must see import customs items from
|
||||
// the start, not only after Ethiopia finalizes pre-clearance.
|
||||
if (tradeDirection === 'IMPORT') return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Contract statuses for persistent phased customs clearance lists (ET + DJ). */
|
||||
export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
'FULLY_EXECUTED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
] as const;
|
||||
|
||||
/** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */
|
||||
export function belongsOnEtClearanceQueue(milestones: DjQueueMilestone[]): boolean {
|
||||
return milestones.some((m) => m.status === 'PENDING' || m.status === 'COMPLETED');
|
||||
}
|
||||
|
||||
/** Contract statuses that may appear on the GL Djibouti clearance list (includes post-booking). */
|
||||
export const DJ_CONTRACT_QUEUE_STATUSES = PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES;
|
||||
|
||||
/** Booking statuses for persistent phased customs clearance lists (ET + DJ). */
|
||||
export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY',
|
||||
'FULLY_EXECUTED',
|
||||
'OPERATION_REQUEST_PENDING',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'ROAD_DISPATCH_PENDING',
|
||||
'IN_TRANSIT',
|
||||
'PAID',
|
||||
'COMPLETED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
] as const;
|
||||
|
||||
/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */
|
||||
export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES;
|
||||
|
||||
/** Build labeled phased-customs file rows from resource files. */
|
||||
export function buildWorkflowFiles(
|
||||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||
tradeDirection: string,
|
||||
): ClearanceWorkflowFile[] {
|
||||
const fileByCode = new Map(
|
||||
files.filter((f) => f.code).map((f) => [f.code as string, f]),
|
||||
);
|
||||
const out: ClearanceWorkflowFile[] = [];
|
||||
const included = new Set<string>();
|
||||
|
||||
for (const entry of catalogEntriesForTradeDirection(tradeDirection)) {
|
||||
const file = fileByCode.get(entry.code) ?? null;
|
||||
if (!file) continue;
|
||||
included.add(entry.code);
|
||||
out.push({
|
||||
code: entry.code,
|
||||
label: entry.label,
|
||||
uploadedBy: entry.uploadedBy,
|
||||
category: entry.category,
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
}
|
||||
|
||||
const extraDeclarations = files
|
||||
.filter((f) => f.code && isDeclarationFileCode(f.code) && !included.has(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||
|
||||
extraDeclarations.forEach((file, index) => {
|
||||
if (!file.code) return;
|
||||
included.add(file.code);
|
||||
out.push({
|
||||
code: file.code,
|
||||
label: declarationFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'declaration',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
});
|
||||
|
||||
if (tradeDirection === 'IMPORT') {
|
||||
const extraTransit = files
|
||||
.filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||
|
||||
extraTransit.forEach((file, index) => {
|
||||
if (!file.code) return;
|
||||
included.add(file.code);
|
||||
out.push({
|
||||
code: file.code,
|
||||
label: transitPermitFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'transit',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
});
|
||||
|
||||
const extraT1 = files
|
||||
.filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||
|
||||
extraT1.forEach((file, index) => {
|
||||
if (!file.code) return;
|
||||
included.add(file.code);
|
||||
out.push({
|
||||
code: file.code,
|
||||
label: t1TransportFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_dj',
|
||||
category: 'djibouti',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
const extraExportTransport = files
|
||||
.filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||
|
||||
extraExportTransport.forEach((file, index) => {
|
||||
if (!file.code) return;
|
||||
included.add(file.code);
|
||||
out.push({
|
||||
code: file.code,
|
||||
label: exportTransportFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'transit',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -61,6 +61,14 @@ export class FilesService {
|
||||
return this.upload(input);
|
||||
}
|
||||
|
||||
async deleteByCode(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
code: string,
|
||||
): Promise<void> {
|
||||
await this.filesRepository.deleteByCode(resourceId, resource, code);
|
||||
}
|
||||
|
||||
async uploadMany(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
|
||||
@@ -48,7 +48,7 @@ export class FirstMileInvoiceService {
|
||||
}
|
||||
|
||||
// Fetch the booking to get the companyId and companyProfileId
|
||||
const fm = record.booking ? record : (await this.firstMileRepo.findById(record.bookingId, { relations: { booking: true } }));
|
||||
const fm = record.booking ? record : (await this.firstMileRepo.findById(record.id, { relations: { booking: true } }));
|
||||
if (!fm) return null;
|
||||
if (!fm.booking?.companyId) {
|
||||
this.logger.warn(
|
||||
|
||||
@@ -16,7 +16,7 @@ import { FirstMileService } from './first-mile.service';
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]),
|
||||
BillingModule,
|
||||
forwardRef(() => BillingModule),
|
||||
forwardRef(() => BookingsModule),
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
|
||||
@@ -2,19 +2,18 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundExc
|
||||
import { FindOptionsWhere, In } from 'typeorm';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
|
||||
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
|
||||
import { FirstMileRepository } from './first-mile.repository';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { InvoiceEventPayload } from '../billing/billing.service';
|
||||
import { BookingsRepository } from "../bookings/bookings.repository";
|
||||
import { DriversService } from "../drivers/drivers.service";
|
||||
import { SmsClientService } from "../notifications/sms-client.service";
|
||||
import { VehiclesService } from "../vehicles/vehicles.service";
|
||||
import { CreateFirstMileDto } from "./dto/create-first-mile.dto";
|
||||
import { UpdateFirstMileDto } from "./dto/update-first-mile.dto";
|
||||
import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity";
|
||||
import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity";
|
||||
import { FirstMileRepository } from "./first-mile.repository";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { InvoiceEventPayload } from "../billing/billing.service";
|
||||
|
||||
type FirstMileListFilter = {
|
||||
status?: FirstMileStatus;
|
||||
@@ -27,10 +26,10 @@ type FirstMileListFilter = {
|
||||
};
|
||||
|
||||
const SORTABLE_FIELDS: (keyof FirstMile)[] = [
|
||||
'status',
|
||||
'advancedPayment',
|
||||
'remainingPayment',
|
||||
'createdAt',
|
||||
"status",
|
||||
"advancedPayment",
|
||||
"remainingPayment",
|
||||
"createdAt",
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
@@ -51,19 +50,21 @@ export class FirstMileService {
|
||||
* paid before any first-mile work proceeds. Throws if the reference is
|
||||
* unknown or the booking has not reached PAID status.
|
||||
*/
|
||||
async acceptBooking(bookingId: string): Promise<FirstMile> {
|
||||
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId, {
|
||||
relations: { serviceType: true },
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.acceptEligibleBooking(booking);
|
||||
}
|
||||
|
||||
async acceptBookingByReference(bookingReference: string): Promise<FirstMile> {
|
||||
async acceptBookingByReference(
|
||||
bookingReference: string,
|
||||
): Promise<FirstMile | null> {
|
||||
const [booking] = await this.bookingsRepository.findAll({
|
||||
where: { reference: bookingReference },
|
||||
relations: { serviceType: true },
|
||||
@@ -90,19 +91,17 @@ export class FirstMileService {
|
||||
tradeDirection?: string | null;
|
||||
firstMilePickupAddress?: string | null;
|
||||
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||
}): Promise<FirstMile> {
|
||||
const label = booking.reference ?? booking.id;
|
||||
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
throw new BadRequestException(`Booking ${label} is not paid`);
|
||||
}): Promise<FirstMile | null> {
|
||||
if (booking.paymentStatus !== "PAID") {
|
||||
return null;
|
||||
}
|
||||
if (!this.bookingRequestsFirstMile(booking)) {
|
||||
throw new BadRequestException(`Booking ${label} does not require a first mile`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = await this.findByBookingId(booking.id);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Booking ${label} already has a first-mile assignment`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.create({
|
||||
@@ -118,8 +117,9 @@ export class FirstMileService {
|
||||
const pageSize = filter.pageSize ?? 50;
|
||||
const sortBy = SORTABLE_FIELDS.includes(filter.sortBy as keyof FirstMile)
|
||||
? (filter.sortBy as keyof FirstMile)
|
||||
: 'createdAt';
|
||||
const sortOrder = filter.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
|
||||
: "createdAt";
|
||||
const sortOrder =
|
||||
filter.sortOrder?.toUpperCase() === "ASC" ? "ASC" : "DESC";
|
||||
|
||||
const where: FindOptionsWhere<FirstMile> = {};
|
||||
if (filter.status) where.status = filter.status;
|
||||
@@ -129,7 +129,13 @@ export class FirstMileService {
|
||||
const [data, total] = await this.firstMileRepository.findAndCount({
|
||||
where,
|
||||
relations: {
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
|
||||
booking: {
|
||||
company: true,
|
||||
serviceType: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
cargoType: true,
|
||||
},
|
||||
vehicle: true,
|
||||
},
|
||||
order: { [sortBy]: sortOrder },
|
||||
@@ -151,8 +157,12 @@ export class FirstMileService {
|
||||
@OnEvent("firstmile.invoice.paid")
|
||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
try {
|
||||
await this.firstMileRepository.update(payload.sourceId, { paid: true } as any);
|
||||
this.logger.log(`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`);
|
||||
await this.firstMileRepository.update(payload.sourceId, {
|
||||
paid: true,
|
||||
} as any);
|
||||
this.logger.log(
|
||||
`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`,
|
||||
@@ -163,7 +173,13 @@ export class FirstMileService {
|
||||
async findById(id: string): Promise<FirstMile> {
|
||||
const record = await this.firstMileRepository.findById(id, {
|
||||
relations: {
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
|
||||
booking: {
|
||||
company: true,
|
||||
serviceType: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
cargoType: true,
|
||||
},
|
||||
vehicle: true,
|
||||
},
|
||||
});
|
||||
@@ -183,7 +199,7 @@ export class FirstMileService {
|
||||
|
||||
const record = await this.firstMileRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||
status: dto.status ?? "READY_TO_TRANSIT",
|
||||
advancedPayment: dto.advancedPayment ?? 0,
|
||||
remainingPayment: dto.remainingPayment ?? 0,
|
||||
estimatedKm: dto.estimatedKm ?? null,
|
||||
@@ -203,7 +219,13 @@ export class FirstMileService {
|
||||
const [records] = await this.firstMileRepository.findAndCount({
|
||||
where: { bookingId },
|
||||
relations: {
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
|
||||
booking: {
|
||||
company: true,
|
||||
serviceType: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
cargoType: true,
|
||||
},
|
||||
vehicle: true,
|
||||
},
|
||||
take: 1,
|
||||
@@ -230,9 +252,15 @@ export class FirstMileService {
|
||||
const updated = await this.firstMileRepository.update(id, {
|
||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
...(dto.advancedPayment !== undefined ? { advancedPayment: dto.advancedPayment } : {}),
|
||||
...(dto.remainingPayment !== undefined ? { remainingPayment: dto.remainingPayment } : {}),
|
||||
...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
|
||||
...(dto.advancedPayment !== undefined
|
||||
? { advancedPayment: dto.advancedPayment }
|
||||
: {}),
|
||||
...(dto.remainingPayment !== undefined
|
||||
? { remainingPayment: dto.remainingPayment }
|
||||
: {}),
|
||||
...(dto.estimatedKm !== undefined
|
||||
? { estimatedKm: dto.estimatedKm }
|
||||
: {}),
|
||||
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
|
||||
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
|
||||
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
|
||||
@@ -301,33 +329,56 @@ export class FirstMileService {
|
||||
try {
|
||||
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||
if (!vehicle.assignedDriverId) {
|
||||
this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
|
||||
this.logger.warn(
|
||||
`Vehicle ${vehicleId} has no assigned driver — skipping SMS`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const driver = await this.driversService.findById(vehicle.assignedDriverId);
|
||||
const driver = await this.driversService.findById(
|
||||
vehicle.assignedDriverId,
|
||||
);
|
||||
if (!driver.phoneNumber) {
|
||||
this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
|
||||
this.logger.warn(
|
||||
`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
|
||||
const booking = (
|
||||
record as FirstMile & {
|
||||
booking?: {
|
||||
reference?: string;
|
||||
firstMilePickupAddress?: string | null;
|
||||
originYard?: { label?: string } | null;
|
||||
};
|
||||
}
|
||||
).booking;
|
||||
|
||||
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
|
||||
const driverName =
|
||||
`${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim();
|
||||
const message =
|
||||
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
|
||||
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
|
||||
(booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') +
|
||||
(booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : '');
|
||||
(booking?.firstMilePickupAddress
|
||||
? `Pickup: ${booking.firstMilePickupAddress}. `
|
||||
: "") +
|
||||
(booking?.originYard?.label
|
||||
? `Destination: ${booking.originYard.label}.`
|
||||
: "");
|
||||
|
||||
void this.smsClient.sendSms({
|
||||
to: driver.phoneNumber,
|
||||
message,
|
||||
});
|
||||
|
||||
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
||||
this.logger.log(
|
||||
`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
|
||||
this.logger.error(
|
||||
`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +416,7 @@ export class FirstMileService {
|
||||
firstMileId,
|
||||
containerId: allocation.containerId,
|
||||
vehicleId: allocation.vehicleId,
|
||||
containerType: 'CONTAINER',
|
||||
containerType: "CONTAINER",
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -132,7 +132,17 @@ export class LastMileService {
|
||||
}
|
||||
|
||||
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
||||
<<<<<<< HEAD
|
||||
const record = await this.lastMileRepository.create({
|
||||
=======
|
||||
const [existing] = await this.lastMileRepository.findAll({
|
||||
where: { bookingId: dto.bookingId },
|
||||
take: 1,
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
return this.lastMileRepository.create({
|
||||
>>>>>>> 9d14414bf10079b38a04a709aa918c7e470dce34
|
||||
bookingId: dto.bookingId,
|
||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||
advancedPayment: dto.advancedPayment ?? 0,
|
||||
|
||||
@@ -4,22 +4,22 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto";
|
||||
import { PaymentService } from "./payment.service";
|
||||
|
||||
/**
|
||||
* Consumer side of the payment microservice's outbox relay.
|
||||
* Only the payment service may call this (shared SERVICE_AUTH_TOKEN).
|
||||
* WARNING: currently unauthenticated — anyone who can reach the API can mark
|
||||
* payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network.
|
||||
* Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless.
|
||||
* Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available;
|
||||
* this HTTP endpoint remains as a transport-agnostic fallback.
|
||||
*/
|
||||
@ApiTags("Internal Payments")
|
||||
@UseGuards(ServiceAuthGuard)
|
||||
@Public()
|
||||
@Controller("internal/payments")
|
||||
export class InternalPaymentController {
|
||||
constructor(private readonly paymentService: PaymentService) { }
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { BillingModule } from "../billing/billing.module";
|
||||
// import { FirstMileModule } from "../first-mile/first-mile.module";
|
||||
// import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
||||
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
|
||||
import { PaymentEntity } from "./entities/payment.entity";
|
||||
@@ -57,6 +59,8 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ConfigModule,
|
||||
forwardRef(() => BillingModule),
|
||||
// forwardRef(() => TrainSchedulingModule),
|
||||
// FirstMileModule,
|
||||
TypeOrmModule.forFeature([
|
||||
PaymentEntity,
|
||||
PaymentWebhookEventEntity,
|
||||
|
||||
@@ -189,48 +189,53 @@ export class PaymentService {
|
||||
* has stored the intent id, avoiding a settle-before-correlation race.
|
||||
*/
|
||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
provider: input.method as ProviderMethod,
|
||||
platform: input.platform,
|
||||
payerAccount: input.payerAccount,
|
||||
returnUrl:
|
||||
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||
failureUrl:
|
||||
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||
});
|
||||
try {
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
provider: input.method as ProviderMethod,
|
||||
platform: input.platform,
|
||||
payerAccount: input.payerAccount,
|
||||
returnUrl:
|
||||
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||
failureUrl:
|
||||
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||
});
|
||||
|
||||
const immediateSuccess =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
||||
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
|
||||
const immediateSuccess =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
||||
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
|
||||
|
||||
const intent = await this.upsertIntent(input, snapshot);
|
||||
const intent = await this.upsertIntent(input, snapshot);
|
||||
|
||||
if (immediateSuccess) {
|
||||
// Settle the projection but DO NOT notify billing — billing settles
|
||||
// inline once it has stored intentId on the invoice (see payInvoice),
|
||||
// avoiding a settle-before-correlation race.
|
||||
await this.markIntentSucceeded(intent.id, {
|
||||
if (immediateSuccess) {
|
||||
// Settle the projection but DO NOT notify billing — billing settles
|
||||
// inline once it has stored intentId on the invoice (see payInvoice),
|
||||
// avoiding a settle-before-correlation race.
|
||||
await this.markIntentSucceeded(intent.id, {
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt,
|
||||
notify: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
intentId: intent.id,
|
||||
// `intent` still reflects the projection status ("processing" on immediate
|
||||
// success — settlement is applied by the caller, not shown synchronously).
|
||||
response: this.formatIntentResponse(intent),
|
||||
immediateSuccess,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt,
|
||||
notify: false,
|
||||
});
|
||||
};
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
intentId: intent.id,
|
||||
// `intent` still reflects the projection status ("processing" on immediate
|
||||
// success — settlement is applied by the caller, not shown synchronously).
|
||||
response: this.formatIntentResponse(intent),
|
||||
immediateSuccess,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create or update the local intent projection from a provider snapshot. */
|
||||
|
||||
@@ -15,9 +15,9 @@ export enum PaymentMethodTypeEnum {
|
||||
}
|
||||
|
||||
export class InitiatePaymentDto {
|
||||
@ApiProperty({ example: "booking-uuid" })
|
||||
@ApiProperty({ example: "invoice-uuid" })
|
||||
@IsString()
|
||||
bookingId!: string;
|
||||
invoiceId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: PaymentMethodTypeEnum,
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { RouteStatus } from '../entities/route.entity';
|
||||
|
||||
export class CreateRouteMilestoneDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
distanceKm?: number;
|
||||
}
|
||||
|
||||
export class CreateRouteDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ type: [CreateRouteMilestoneDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(2)
|
||||
@@ -21,8 +33,8 @@ export class CreateRouteDto {
|
||||
@Type(() => CreateRouteMilestoneDto)
|
||||
milestones!: CreateRouteMilestoneDto[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
@IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])
|
||||
status?: RouteStatus;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import { RouteStatus } from '../entities/route.entity';
|
||||
|
||||
export class FilterRoutesDto {
|
||||
@ApiPropertyOptional()
|
||||
@ApiPropertyOptional({ description: 'Search origin/destination yard codes or names' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
@IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])
|
||||
status?: RouteStatus;
|
||||
}
|
||||
|
||||
@@ -23,4 +23,8 @@ export class RouteMilestone extends BaseEntity {
|
||||
|
||||
@Column({ name: 'sequence_no', type: 'int' })
|
||||
sequenceNo!: number;
|
||||
|
||||
/** Kilometres from the previous stop (0 for origin). */
|
||||
@Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
distanceKm?: number | null;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { RouteMilestone } from './route-milestone.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'routes' })
|
||||
@Index(['name'])
|
||||
@Index(['isActive'])
|
||||
export class Route extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 120, unique: true })
|
||||
name!: string;
|
||||
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'routes' })
|
||||
@Index(['status'])
|
||||
export class Route extends BaseEntity {
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||
originYardId!: string;
|
||||
|
||||
@@ -25,9 +23,24 @@ export class Route extends BaseEntity {
|
||||
@JoinColumn({ name: 'destination_yard_id' })
|
||||
destinationYard?: Yard;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
@Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' })
|
||||
status!: RouteStatus;
|
||||
|
||||
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
|
||||
milestones?: RouteMilestone[];
|
||||
}
|
||||
|
||||
export function formatRouteLabel(route: {
|
||||
originYard?: { code?: string; name?: string } | null;
|
||||
destinationYard?: { code?: string; name?: string } | null;
|
||||
}): string {
|
||||
const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin';
|
||||
const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination';
|
||||
return `${origin} → ${dest}`;
|
||||
}
|
||||
|
||||
export function totalRouteDistanceKm(
|
||||
milestones: Array<{ distanceKm?: number | string | null }>,
|
||||
): number {
|
||||
return milestones.reduce((sum, m) => sum + Number(m.distanceKm ?? 0), 0);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, ILike } from 'typeorm';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { CreateRouteDto } from './dto/create-route.dto';
|
||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||
import { RouteMilestone } from './entities/route-milestone.entity';
|
||||
import { Route } from './entities/route.entity';
|
||||
import { formatRouteLabel, Route } from './entities/route.entity';
|
||||
import { RoutesRepository } from './routes.repository';
|
||||
|
||||
@Injectable()
|
||||
@@ -16,11 +16,10 @@ export class RoutesService {
|
||||
private readonly routesRepository: RoutesRepository,
|
||||
) {}
|
||||
|
||||
findAll(filter: FilterRoutesDto): Promise<Route[]> {
|
||||
return this.routesRepository.findAll({
|
||||
async findAll(filter: FilterRoutesDto): Promise<Route[]> {
|
||||
const routes = await this.routesRepository.findAll({
|
||||
where: {
|
||||
...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}),
|
||||
...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}),
|
||||
...(filter.status ? { status: filter.status } : {}),
|
||||
},
|
||||
relations: {
|
||||
originYard: true,
|
||||
@@ -28,10 +27,33 @@ export class RoutesService {
|
||||
milestones: { yard: true },
|
||||
},
|
||||
order: {
|
||||
name: 'ASC',
|
||||
milestones: { sequenceNo: 'ASC' },
|
||||
},
|
||||
});
|
||||
|
||||
const sorted = [...routes].sort((a, b) =>
|
||||
formatRouteLabel(a).localeCompare(formatRouteLabel(b)),
|
||||
);
|
||||
|
||||
const query = filter.search?.trim().toLowerCase();
|
||||
if (!query) return sorted;
|
||||
|
||||
return sorted.filter((route) => {
|
||||
const haystack = [
|
||||
formatRouteLabel(route),
|
||||
route.originYard?.label,
|
||||
route.originYard?.code,
|
||||
route.destinationYard?.label,
|
||||
route.destinationYard?.code,
|
||||
...(route.milestones ?? []).map(
|
||||
(m) => m.yard?.label ?? m.yard?.code ?? m.yardId,
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Route> {
|
||||
@@ -53,16 +75,14 @@ export class RoutesService {
|
||||
}
|
||||
|
||||
async create(dto: CreateRouteDto): Promise<Route> {
|
||||
await this.validateRouteName(dto.name);
|
||||
const validated = await this.validateMilestones(dto.milestones);
|
||||
|
||||
const route = await this.dataSource.transaction(async (manager) => {
|
||||
const savedRoute = await manager.getRepository(Route).save(
|
||||
manager.getRepository(Route).create({
|
||||
name: dto.name.trim(),
|
||||
originYardId: validated.originYardId,
|
||||
destinationYardId: validated.destinationYardId,
|
||||
isActive: dto.isActive ?? true,
|
||||
status: dto.status ?? 'AVAILABLE',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -72,6 +92,7 @@ export class RoutesService {
|
||||
routeId: savedRoute.id,
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: milestone.sequenceNo,
|
||||
distanceKm: milestone.distanceKm,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -85,20 +106,16 @@ export class RoutesService {
|
||||
async update(id: string, dto: UpdateRouteDto): Promise<Route> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
if (dto.name && dto.name.trim() !== existing.name) {
|
||||
await this.validateRouteName(dto.name, id);
|
||||
}
|
||||
|
||||
const milestoneInput = dto.milestones
|
||||
? await this.validateMilestones(dto.milestones)
|
||||
: null;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(Route).update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
|
||||
destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId,
|
||||
isActive: dto.isActive ?? existing.isActive,
|
||||
destinationYardId:
|
||||
milestoneInput?.destinationYardId ?? existing.destinationYardId,
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
});
|
||||
|
||||
if (milestoneInput) {
|
||||
@@ -109,6 +126,7 @@ export class RoutesService {
|
||||
routeId: id,
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: milestone.sequenceNo,
|
||||
distanceKm: milestone.distanceKm,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -120,7 +138,9 @@ export class RoutesService {
|
||||
|
||||
async deactivate(id: string): Promise<Route> {
|
||||
await this.findById(id);
|
||||
const updated = await this.routesRepository.update(id, { isActive: false });
|
||||
const updated = await this.routesRepository.update(id, {
|
||||
status: 'STOP_WORKING',
|
||||
} as never);
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Route ${id} not found`);
|
||||
@@ -129,27 +149,32 @@ export class RoutesService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
private async validateRouteName(name: string, routeId?: string) {
|
||||
const trimmedName = name.trim();
|
||||
const [existing] = await this.routesRepository.findAll({ where: { name: trimmedName } });
|
||||
|
||||
if (existing && existing.id !== routeId) {
|
||||
throw new ConflictException(`Route name ${trimmedName} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateMilestones(milestones: Array<{ yardId: string }>) {
|
||||
private async validateMilestones(
|
||||
milestones: Array<{ yardId: string; distanceKm?: number }>,
|
||||
) {
|
||||
if (milestones.length < 2) {
|
||||
throw new BadRequestException('A route requires at least two yards');
|
||||
}
|
||||
|
||||
const normalized = milestones.map((milestone, index) => ({
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: index + 1,
|
||||
}));
|
||||
const normalized = milestones.map((milestone, index) => {
|
||||
const distanceKm =
|
||||
index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null;
|
||||
if (index > 0 && (distanceKm == null || distanceKm < 0)) {
|
||||
throw new BadRequestException(
|
||||
`Enter segment KM for stop ${index + 1} (from previous yard).`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: index + 1,
|
||||
distanceKm,
|
||||
};
|
||||
});
|
||||
|
||||
const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))];
|
||||
const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) });
|
||||
const yards = await this.dataSource
|
||||
.getRepository(Yard)
|
||||
.find({ where: uniqueYardIds.map((id) => ({ id })) });
|
||||
const yardIds = new Set(yards.map((yard) => yard.id));
|
||||
|
||||
for (const milestone of normalized) {
|
||||
|
||||
@@ -21,11 +21,6 @@ export class CreateCargoTypeDto {
|
||||
@IsUUID()
|
||||
parentGroupId?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showFreeTextBox?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -17,9 +17,6 @@ export class CargoType extends BaseEntity {
|
||||
@Column({ name: 'parent_group_id', type: 'uuid', nullable: true })
|
||||
parentGroupId?: string | null;
|
||||
|
||||
@Column({ name: 'show_free_text_box', type: 'boolean', default: false })
|
||||
showFreeTextBox!: boolean;
|
||||
|
||||
/**
|
||||
* How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM
|
||||
* (break-bulk). Nullable for container/legacy cargo, which is counted by
|
||||
|
||||
@@ -331,16 +331,14 @@ export class RuleEngineService {
|
||||
): Promise<BookingApprovalStep[]> {
|
||||
await this.ensureDefaultApprovalRules();
|
||||
|
||||
let requiresDirectorApproval = options.freightType === 'BULK';
|
||||
let requiresDirectorApproval = false;
|
||||
|
||||
if (options.cargoTypeId) {
|
||||
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
|
||||
if (!cargoType) {
|
||||
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
|
||||
}
|
||||
if (cargoType.requiresDirectorApproval) {
|
||||
requiresDirectorApproval = true;
|
||||
}
|
||||
requiresDirectorApproval = cargoType.requiresDirectorApproval;
|
||||
}
|
||||
|
||||
const chain = await this.approvalRulesRepo.findChainForCargo(
|
||||
|
||||
@@ -79,7 +79,6 @@ export class CargoTypesService {
|
||||
code,
|
||||
cargoTypeName: dto.cargoTypeName,
|
||||
parentGroupId: dto.parentGroupId ?? null,
|
||||
showFreeTextBox: dto.showFreeTextBox ?? false,
|
||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||
|
||||
@@ -4,24 +4,28 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { Cron, SchedulerRegistry } from "@nestjs/schedule";
|
||||
import { DataSource } from "typeorm";
|
||||
import { Freight } from "@edr/types";
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import { Freight } from "@edr/types";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { BookingsRepository } from "../bookings/bookings.repository";
|
||||
import { Locomotive } from "../locomotives/entities/locomotive.entity";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
|
||||
import { TrainSchedulesRepository } from "../train-schedules/train-schedules.repository";
|
||||
import { TrainScheduleBookingsRepository } from "../train-schedules/train-schedule-bookings.repository";
|
||||
import { TrainSchedulingGlobalRules } from "./entities/train-scheduling-global-rules.entity";
|
||||
import { BookingNotifierService } from "./booking-notifier.service";
|
||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from "./batch-window.util";
|
||||
|
||||
|
||||
import {
|
||||
BATCH_CRON,
|
||||
BATCH_TIMEZONE,
|
||||
@@ -34,8 +38,9 @@ import {
|
||||
bookingTrainLengthMeters,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from "./train-capacity.util";
|
||||
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
|
||||
} from './train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
|
||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||
interface Capacity {
|
||||
@@ -182,7 +187,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private readonly scheduler: SchedulerRegistry,
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly billing: BillingService,
|
||||
) { }
|
||||
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
|
||||
) {}
|
||||
|
||||
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -578,7 +586,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route?.name ?? null,
|
||||
routeName: s.route ? formatRouteLabel(s.route) : null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
destination:
|
||||
s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
@@ -662,7 +670,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route?.name ?? null,
|
||||
routeName: s.route ? formatRouteLabel(s.route) : null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
destination:
|
||||
s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
@@ -1069,6 +1077,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
Freight.InvoiceSource.Booking,
|
||||
booking.id,
|
||||
deadline,
|
||||
"PREPAID",
|
||||
);
|
||||
await this.notifier.payNow(booking, deadline);
|
||||
}
|
||||
@@ -1101,6 +1110,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
});
|
||||
this.notifier.secured(booking, reason);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
void this.markWagonAllocatedMilestone(booking.id);
|
||||
}
|
||||
|
||||
private async markWagonAllocatedMilestone(bookingId: string): Promise<void> {
|
||||
if (!this.milestoneService) return;
|
||||
try {
|
||||
await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED');
|
||||
} catch {
|
||||
// Booking may have no milestone rows (non-contract path).
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1120,7 +1139,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// Pay window closed before settlement → expire the booking's open invoice too
|
||||
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
|
||||
// source-agnostic.
|
||||
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id);
|
||||
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID");
|
||||
this.notifier.expired(booking);
|
||||
}
|
||||
|
||||
@@ -1167,6 +1186,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.billing.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
victim.id,
|
||||
"PREPAID",
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
import { IsDateString, IsIn, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export const IMPORT_DJIBOUTI_DOCUMENT_TYPES = [
|
||||
'GATE_PASS',
|
||||
'DELIVERY_ORDER',
|
||||
'PORT_INVOICE',
|
||||
'DJIBOUTI_T1',
|
||||
@@ -43,6 +44,26 @@ export class UploadImportDjiboutiDocumentDto {
|
||||
}
|
||||
|
||||
export class ImportDjiboutiActionDto {
|
||||
@ApiPropertyOptional({ description: 'Gate pass secured date/time. Defaults to now.' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
securedAt?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fileId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fileUrl?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reference?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
export type ImportDjiboutiDocumentType =
|
||||
| 'GATE_PASS'
|
||||
| 'DELIVERY_ORDER'
|
||||
| 'PORT_INVOICE'
|
||||
| 'DJIBOUTI_T1'
|
||||
|
||||
@@ -26,6 +26,7 @@ import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -51,6 +52,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
TrainSchedulesModule,
|
||||
forwardRef(() => WarehousesModule),
|
||||
RuleEngineModule,
|
||||
forwardRef(() => ContractsModule),
|
||||
],
|
||||
controllers: [TrainSchedulingController],
|
||||
providers: [
|
||||
|
||||
@@ -21,7 +21,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||
import { Route } from '../routes/entities/route.entity';
|
||||
import { formatRouteLabel, Route } from '../routes/entities/route.entity';
|
||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
@@ -54,7 +54,6 @@ import {
|
||||
type ImportDjiboutiDocumentType,
|
||||
} from './entities/import-djibouti-operation.entity';
|
||||
import {
|
||||
IMPORT_DJIBOUTI_DOCUMENT_TYPES,
|
||||
ImportDjiboutiActionDto,
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from './dto/import-djibouti-operation.dto';
|
||||
@@ -304,7 +303,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
||||
const route = await this.getActiveRoute(dto.routeId);
|
||||
const route = await this.getSchedulableRoute(dto.routeId);
|
||||
|
||||
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
||||
if (locomotiveIds.length < 2) {
|
||||
@@ -832,7 +831,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
async getImportDjiboutiOperation(scheduleId: string) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
return this.mapImportDjiboutiOperation(schedule, operation);
|
||||
}
|
||||
@@ -841,7 +840,7 @@ export class TrainSchedulingService {
|
||||
scheduleId: string,
|
||||
dto: UploadImportDjiboutiDocumentDto,
|
||||
) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
const documents = {
|
||||
...(operation.documents ?? {}),
|
||||
@@ -865,21 +864,30 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
const missing = this.missingImportDjiboutiDocuments(operation);
|
||||
if (missing.length) {
|
||||
throw new BadRequestException(`Gatepass cannot be granted until documents are uploaded: ${missing.join(', ')}`);
|
||||
const securedAt = dto.securedAt ? new Date(dto.securedAt) : new Date();
|
||||
const documents = { ...(operation.documents ?? {}) };
|
||||
if (dto.fileId || dto.fileUrl || dto.reference || dto.notes) {
|
||||
documents.GATE_PASS = {
|
||||
fileId: dto.fileId ?? null,
|
||||
fileUrl: dto.fileUrl ?? null,
|
||||
reference: dto.reference ?? null,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
uploadedBy: dto.performedBy ?? null,
|
||||
notes: dto.notes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
gatepassGrantedAt: operation.gatepassGrantedAt ?? new Date(),
|
||||
documents,
|
||||
gatepassGrantedAt: securedAt,
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[NOTIFY] Import gatepass granted for train ${schedule.trainNumber ?? schedule.id}; loading may proceed.`,
|
||||
`[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
|
||||
);
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
@@ -949,7 +957,7 @@ export class TrainSchedulingService {
|
||||
generatedAt: generatedAt.toISOString(),
|
||||
trainScheduleId: schedule.id,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
route: schedule.route?.name ?? null,
|
||||
route: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
totalBookings: schedule.scheduleBookings?.length ?? 0,
|
||||
@@ -1299,16 +1307,28 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||||
if (!this.isImportDjiboutiSchedule(schedule)) {
|
||||
throw new BadRequestException('This action applies only to IMPORT schedules originating from Djibouti');
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
private async getDjiboutiGatepassSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!this.isImportDjiboutiSchedule(schedule)) {
|
||||
throw new BadRequestException('Batch 7 actions apply only to IMPORT schedules originating from Djibouti');
|
||||
if (!this.isDjiboutiGatepassSchedule(schedule)) {
|
||||
throw new BadRequestException('Gate pass applies only to trains entering Djibouti Port on import or export routes');
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
private isDjiboutiGatepassSchedule(schedule: TrainSchedule): boolean {
|
||||
return this.isImportDjiboutiSchedule(schedule) || this.isExportDjiboutiSchedule(schedule);
|
||||
}
|
||||
|
||||
private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean {
|
||||
const direction =
|
||||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||||
@@ -1323,6 +1343,20 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
private isExportDjiboutiSchedule(schedule: TrainSchedule): boolean {
|
||||
const direction =
|
||||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||||
(schedule.originStation && schedule.destinationStation
|
||||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||||
: null);
|
||||
return (
|
||||
direction === 'EXPORT' &&
|
||||
this.isDjiboutiPortDestination(
|
||||
`${schedule.destinationStation?.code ?? ''} ${schedule.destinationStation?.label ?? ''}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise<ImportDjiboutiOperation> {
|
||||
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
||||
const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } });
|
||||
@@ -1331,8 +1365,8 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] {
|
||||
const documents = operation?.documents ?? {};
|
||||
return IMPORT_DJIBOUTI_DOCUMENT_TYPES.filter((type) => !documents[type]);
|
||||
void operation;
|
||||
return [];
|
||||
}
|
||||
|
||||
private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void {
|
||||
@@ -1343,6 +1377,7 @@ export class TrainSchedulingService {
|
||||
|
||||
private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) {
|
||||
const missingDocuments = this.missingImportDjiboutiDocuments(operation);
|
||||
const gatepassStatus = operation.gatepassGrantedAt ? 'SECURED' : 'NOT_SECURED';
|
||||
return {
|
||||
trainScheduleId: schedule.id,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
@@ -1350,6 +1385,7 @@ export class TrainSchedulingService {
|
||||
status: {
|
||||
documentsComplete: missingDocuments.length === 0,
|
||||
missingDocuments,
|
||||
gatepassStatus,
|
||||
gatepassGranted: Boolean(operation.gatepassGrantedAt),
|
||||
readyForLoading: Boolean(operation.readyForLoadingAt),
|
||||
loadedOnTrain: Boolean(operation.loadedOnTrainAt),
|
||||
@@ -1358,6 +1394,8 @@ export class TrainSchedulingService {
|
||||
},
|
||||
documents: operation.documents ?? {},
|
||||
gatepassGrantedAt: operation.gatepassGrantedAt ?? null,
|
||||
gatepassSecuredAt: operation.gatepassGrantedAt ?? null,
|
||||
gatepassStatus,
|
||||
readyForLoadingAt: operation.readyForLoadingAt ?? null,
|
||||
loadedOnTrainAt: operation.loadedOnTrainAt ?? null,
|
||||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null,
|
||||
@@ -2605,13 +2643,17 @@ export class TrainSchedulingService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
private async getActiveRoute(routeId: string) {
|
||||
private async getSchedulableRoute(routeId: string) {
|
||||
const route = await this.dataSource.getRepository(Route).findOne({
|
||||
where: { id: routeId },
|
||||
relations: { originYard: true, destinationYard: true },
|
||||
});
|
||||
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
|
||||
if (!route.isActive) throw new BadRequestException(`Route ${route.name} is inactive`);
|
||||
if (route.status !== 'AVAILABLE') {
|
||||
throw new BadRequestException(
|
||||
`Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`,
|
||||
);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
@@ -2656,7 +2698,7 @@ export class TrainSchedulingService {
|
||||
id: schedule.id,
|
||||
scheduleDate: schedule.scheduledDepartureDate,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
routeName: schedule.route?.name ?? null,
|
||||
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination:
|
||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
@@ -2691,7 +2733,7 @@ export class TrainSchedulingService {
|
||||
|
||||
/** AVAILABLE locomotives at the route's origin yard. */
|
||||
async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
|
||||
const route = await this.getActiveRoute(routeId);
|
||||
const route = await this.getSchedulableRoute(routeId);
|
||||
|
||||
const locomotives = await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE', currentYardId: route.originYardId },
|
||||
@@ -2933,7 +2975,9 @@ export class TrainSchedulingService {
|
||||
freightType: this.resolveScheduleFreightType(schedule),
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
route: schedule.route ? { id: schedule.route.id, name: schedule.route.name } : null,
|
||||
route: schedule.route
|
||||
? { id: schedule.route.id, name: formatRouteLabel(schedule.route) }
|
||||
: null,
|
||||
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
||||
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
||||
actualDepartureAt: schedule.actualDepartureAt ?? null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
import { ValidateNested } from 'class-validator';
|
||||
|
||||
export class TruckEntranceDto {
|
||||
@@ -90,6 +90,11 @@ export class TruckEntranceDto {
|
||||
@Min(0)
|
||||
grossWeightKg?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Whether the customer truck was weighed at receipt.' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
weighingRequired?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@@ -135,10 +140,11 @@ export class TruckEntranceDto {
|
||||
@IsString()
|
||||
truckType?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
entranceTareWeightKg!: number;
|
||||
entranceTareWeightKg?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
|
||||
|
||||
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
|
||||
|
||||
export class FeeRuleTierDto {
|
||||
@ApiProperty({ example: 4 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
fromDay!: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 4, description: 'Inclusive. Leave empty for an open-ended tier.' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
toDay?: number | null;
|
||||
|
||||
@ApiProperty({ example: 2500 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
ratePerDay!: number;
|
||||
}
|
||||
|
||||
export class CreateFeeRuleDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@@ -67,6 +86,13 @@ export class CreateFeeRuleDto {
|
||||
@Min(0)
|
||||
ratePerDay!: number;
|
||||
|
||||
@ApiPropertyOptional({ type: [FeeRuleTierDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FeeRuleTierDto)
|
||||
tiers?: FeeRuleTierDto[];
|
||||
|
||||
@ApiPropertyOptional({ default: 'USD' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -4,6 +4,12 @@ import { Column, Entity, Index } from 'typeorm';
|
||||
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
|
||||
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
|
||||
|
||||
export interface WarehouseFeeTier {
|
||||
fromDay: number;
|
||||
toDay: number | null;
|
||||
ratePerDay: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 5 — configurable storage / demurrage fee rules (no invoice/payment here — that is Batch 6).
|
||||
* The most specific active rule (highest `specificity` then lowest `priority`) applies to an item.
|
||||
@@ -54,6 +60,9 @@ export class WarehouseFeeRule extends BaseEntity {
|
||||
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
ratePerDay!: number;
|
||||
|
||||
@Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" })
|
||||
tiers!: WarehouseFeeTier[];
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
|
||||
currency!: string;
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface ImportTrainItemRow {
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
freightType: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
@@ -274,6 +275,7 @@ export class SchedulingReadFacade {
|
||||
w.wagon_number AS "wagonNumber",
|
||||
tsw.sequence_no AS "sequenceNo",
|
||||
wba.allocated_weight_tons AS "allocatedWeightTons",
|
||||
b.freight_type AS "freightType",
|
||||
(SELECT c.container_number FROM freight.containers c
|
||||
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
|
||||
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||
import { FeeRuleType, WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||
import { FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
|
||||
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||
|
||||
interface ItemAttributes {
|
||||
@@ -39,6 +39,15 @@ export interface FeePreview {
|
||||
containerCount: number;
|
||||
billableUnits: number;
|
||||
amount: number;
|
||||
tiers: Array<{
|
||||
fromDay: number;
|
||||
toDay: number | null;
|
||||
appliedFromDay: number;
|
||||
appliedToDay: number;
|
||||
days: number;
|
||||
ratePerDay: number;
|
||||
amount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
@@ -57,11 +66,16 @@ export class WarehouseFeeService {
|
||||
}
|
||||
|
||||
createRule(dto: CreateFeeRuleDto): Promise<WarehouseFeeRule> {
|
||||
return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...dto });
|
||||
return this.feeRuleRepository.create({
|
||||
isActive: true,
|
||||
priority: 100,
|
||||
currency: 'USD',
|
||||
...this.normalizeRuleInput(dto),
|
||||
});
|
||||
}
|
||||
|
||||
async updateRule(id: string, dto: UpdateFeeRuleDto): Promise<WarehouseFeeRule> {
|
||||
const updated = await this.feeRuleRepository.update(id, dto);
|
||||
const updated = await this.feeRuleRepository.update(id, this.normalizeRuleInput(dto));
|
||||
if (!updated) throw new NotFoundException(`Fee rule ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
@@ -70,6 +84,40 @@ export class WarehouseFeeService {
|
||||
return this.feeRuleRepository.softDelete(id);
|
||||
}
|
||||
|
||||
private normalizeRuleInput<T extends CreateFeeRuleDto | UpdateFeeRuleDto>(dto: T): T {
|
||||
if (dto.tiers === undefined) return dto;
|
||||
const tiers = (dto.tiers ?? [])
|
||||
.map((tier) => ({
|
||||
fromDay: Number(tier.fromDay),
|
||||
toDay: tier.toDay == null ? null : Number(tier.toDay),
|
||||
ratePerDay: Number(tier.ratePerDay),
|
||||
}))
|
||||
.filter((tier) => tier.fromDay > 0 || tier.toDay != null || tier.ratePerDay > 0);
|
||||
|
||||
for (const tier of tiers) {
|
||||
if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) {
|
||||
throw new BadRequestException('Fee tier from day must be a positive whole number.');
|
||||
}
|
||||
if (tier.toDay != null && (!Number.isInteger(tier.toDay) || tier.toDay < tier.fromDay)) {
|
||||
throw new BadRequestException('Fee tier to day must be empty or greater than/equal to from day.');
|
||||
}
|
||||
if (!Number.isFinite(tier.ratePerDay) || tier.ratePerDay < 0) {
|
||||
throw new BadRequestException('Fee tier rate per day must be zero or greater.');
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...tiers].sort((a, b) => a.fromDay - b.fromDay || (a.toDay ?? Infinity) - (b.toDay ?? Infinity));
|
||||
for (let i = 1; i < sorted.length; i += 1) {
|
||||
const prev = sorted[i - 1];
|
||||
const current = sorted[i];
|
||||
if (prev.toDay == null || current.fromDay <= prev.toDay) {
|
||||
throw new BadRequestException('Fee tiers cannot overlap. Use separate from/to day ranges.');
|
||||
}
|
||||
}
|
||||
|
||||
return { ...dto, tiers: sorted } as T;
|
||||
}
|
||||
|
||||
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.arrived_at AS "arrivedAt",
|
||||
@@ -82,16 +130,27 @@ export class WarehouseFeeService {
|
||||
w.facility_id AS "facilityId",
|
||||
b.freight_type AS "freightType",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
cgt.code AS "cargoTypeCode",
|
||||
ctt.code AS "containerTypeCode",
|
||||
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
|
||||
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
|
||||
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
|
||||
LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT bc.container_type_id
|
||||
FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = inv.booking_id
|
||||
AND bc.deleted_at IS NULL
|
||||
AND bc.container_type_id IS NOT NULL
|
||||
ORDER BY bc.created_at ASC
|
||||
LIMIT 1
|
||||
) booking_container_type ON true
|
||||
LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count
|
||||
FROM freight.booking_container bc
|
||||
@@ -108,16 +167,26 @@ export class WarehouseFeeService {
|
||||
private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null {
|
||||
// Returns specificity score (#matched non-null scope fields), or null if any constraint fails.
|
||||
let score = 0;
|
||||
const check = (ruleVal: string | null | undefined, itemVal: string | null) => {
|
||||
if (ruleVal == null) return true;
|
||||
if (itemVal != null && ruleVal.toUpperCase() === itemVal.toUpperCase()) {
|
||||
const normalized = (value: string | null | undefined) => value?.trim().toUpperCase() || null;
|
||||
const check = (
|
||||
ruleVal: string | null | undefined,
|
||||
itemVal: string | null,
|
||||
opts: { allowBoth?: boolean } = {},
|
||||
) => {
|
||||
const ruleCode = normalized(ruleVal);
|
||||
if (ruleCode == null || ruleCode === 'ANY' || ruleCode === 'ALL') return true;
|
||||
if (opts.allowBoth && ruleCode === 'BOTH') {
|
||||
score += 1;
|
||||
return true;
|
||||
}
|
||||
if (ruleCode === normalized(itemVal)) {
|
||||
score += 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!check(rule.freightType, item.freightType)) return null;
|
||||
if (!check(rule.tradeDirection, item.tradeDirection)) return null;
|
||||
if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null;
|
||||
if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null;
|
||||
if (!check(rule.containerType, item.containerTypeCode)) return null;
|
||||
if (!check(rule.facilityId, item.facilityId)) return null;
|
||||
@@ -153,6 +222,60 @@ export class WarehouseFeeService {
|
||||
return Math.round(amount * rate * 100) / 100;
|
||||
}
|
||||
|
||||
private calculateTieredAmount(
|
||||
tiers: WarehouseFeeTier[] | null | undefined,
|
||||
elapsedDays: number,
|
||||
containerCount: number,
|
||||
): {
|
||||
sourceAmount: number;
|
||||
billableUnits: number;
|
||||
chargeableDays: number;
|
||||
weightedRatePerDay: number;
|
||||
tiers: FeePreview['tiers'];
|
||||
} {
|
||||
const sourceTiers = (tiers ?? [])
|
||||
.map((tier) => ({
|
||||
fromDay: Number(tier.fromDay),
|
||||
toDay: tier.toDay == null ? null : Number(tier.toDay),
|
||||
ratePerDay: Number(tier.ratePerDay),
|
||||
}))
|
||||
.filter((tier) => Number.isFinite(tier.fromDay) && tier.fromDay > 0 && Number.isFinite(tier.ratePerDay))
|
||||
.sort((a, b) => a.fromDay - b.fromDay);
|
||||
|
||||
let sourceAmount = 0;
|
||||
let tierDays = 0;
|
||||
const appliedTiers: FeePreview['tiers'] = [];
|
||||
|
||||
for (const tier of sourceTiers) {
|
||||
if (elapsedDays < tier.fromDay) continue;
|
||||
const appliedFromDay = tier.fromDay;
|
||||
const appliedToDay = Math.min(elapsedDays, tier.toDay ?? elapsedDays);
|
||||
const days = Math.max(0, appliedToDay - appliedFromDay + 1);
|
||||
if (days <= 0) continue;
|
||||
|
||||
const amount = Math.round(days * containerCount * tier.ratePerDay * 100) / 100;
|
||||
sourceAmount += amount;
|
||||
tierDays += days;
|
||||
appliedTiers.push({
|
||||
fromDay: tier.fromDay,
|
||||
toDay: tier.toDay,
|
||||
appliedFromDay,
|
||||
appliedToDay,
|
||||
days,
|
||||
ratePerDay: tier.ratePerDay,
|
||||
amount,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sourceAmount: Math.round(sourceAmount * 100) / 100,
|
||||
billableUnits: tierDays * containerCount,
|
||||
chargeableDays: tierDays,
|
||||
weightedRatePerDay: tierDays > 0 ? Math.round((sourceAmount / tierDays / containerCount) * 100) / 100 : 0,
|
||||
tiers: appliedTiers,
|
||||
};
|
||||
}
|
||||
|
||||
private async compute(
|
||||
ruleType: FeeRuleType,
|
||||
rule: WarehouseFeeRule | null,
|
||||
@@ -176,13 +299,25 @@ export class WarehouseFeeService {
|
||||
const elapsedDays = start
|
||||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
|
||||
: 0;
|
||||
const chargeableDays = Math.max(0, elapsedDays - freeDays);
|
||||
const billableUnits = chargeableDays * containerCount;
|
||||
const sourceAmount = Math.round(billableUnits * ratePerDay * 100) / 100;
|
||||
const tiered = this.calculateTieredAmount(rule?.tiers, elapsedDays, containerCount);
|
||||
const hasTiers = Boolean(rule?.tiers?.length);
|
||||
const chargeableDays = hasTiers ? tiered.chargeableDays : Math.max(0, elapsedDays - freeDays);
|
||||
const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * containerCount;
|
||||
const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100;
|
||||
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
|
||||
const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay;
|
||||
const convertedRatePerDay = ruleCurrency
|
||||
? await this.convertAmount(ratePerDay, ruleCurrency, targetCurrency)
|
||||
? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency)
|
||||
: 0;
|
||||
const convertedTiers = ruleCurrency
|
||||
? await Promise.all(
|
||||
tiered.tiers.map(async (tier) => ({
|
||||
...tier,
|
||||
ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency),
|
||||
amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency),
|
||||
})),
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
ruleType,
|
||||
@@ -201,6 +336,7 @@ export class WarehouseFeeService {
|
||||
containerCount,
|
||||
billableUnits,
|
||||
amount,
|
||||
tiers: hasTiers ? convertedTiers : [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -84,9 +84,11 @@ export class WarehouseInspectionService {
|
||||
`SELECT inv.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress"
|
||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[inventoryId],
|
||||
@@ -98,7 +100,10 @@ export class WarehouseInspectionService {
|
||||
readyForPickupAt: new Date(),
|
||||
});
|
||||
|
||||
if (row.bookingReference && row.lastMileDeliveryAddress) {
|
||||
const hasLastMile =
|
||||
Boolean(row.lastMileDeliveryAddress?.trim?.()) || Boolean(row.serviceIncludesLastMile);
|
||||
|
||||
if (row.bookingReference && hasLastMile) {
|
||||
await this.lastMileService.acceptBooking(row.bookingReference);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,8 +139,18 @@ export class WarehouseInventoryController {
|
||||
|
||||
@Post('import/auto-unload-arrived-bookings')
|
||||
@ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' })
|
||||
autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; performedBy?: string }) {
|
||||
return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy);
|
||||
autoUnloadArrivedBookings(@Body() dto: {
|
||||
scheduleId: string;
|
||||
warehouseId?: string;
|
||||
performedBy?: string;
|
||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||
}) {
|
||||
return this.inventoryService.autoUnloadArrivedBookings(
|
||||
dto.scheduleId,
|
||||
dto.performedBy,
|
||||
dto.warehouseId,
|
||||
dto.assignments,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('import/unloaded-queue')
|
||||
|
||||
@@ -180,6 +180,10 @@ interface LocationRef {
|
||||
zoneId: string;
|
||||
}
|
||||
|
||||
interface BookingUnloadLocation extends LocationRef {
|
||||
bookingId: string;
|
||||
}
|
||||
|
||||
interface LocationNode {
|
||||
capacityWeight?: number | null;
|
||||
capacityContainers?: number | null;
|
||||
@@ -221,6 +225,11 @@ export interface EligibleBookingRow {
|
||||
firstMileDriverPhone: string | null;
|
||||
firstMileDriverLicenseNumber: string | null;
|
||||
firstMileTruckType: string | null;
|
||||
customerTruckPlateNumber: string | null;
|
||||
customerTruckDriverName: string | null;
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
}
|
||||
|
||||
export interface BulkReceiveResult {
|
||||
@@ -302,6 +311,11 @@ export interface ImportUnloadedRow {
|
||||
inspectionStatus: string | null;
|
||||
pickupOption: string;
|
||||
lastMileRequested: boolean;
|
||||
customerTruckPlateNumber: string | null;
|
||||
customerTruckDriverName: string | null;
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
@@ -504,8 +518,9 @@ export class WarehouseInventoryService {
|
||||
}));
|
||||
}
|
||||
|
||||
/** First warehouse that has at least one yard + zone (fallback location for auto-unload). */
|
||||
private async pickDefaultLocation(): Promise<DefaultLocation | null> {
|
||||
/** First matching warehouse that has at least one yard + zone (fallback location for auto-unload). */
|
||||
private async pickDefaultLocation(warehouseId?: string): Promise<DefaultLocation | null> {
|
||||
const params = warehouseId ? [warehouseId] : [];
|
||||
const [row]: DefaultLocation[] = await this.dataSource.query(
|
||||
`SELECT wh.id AS "warehouseId", wh.facility_id AS "facilityId",
|
||||
yard.id AS "yardId", zone.id AS "zoneId"
|
||||
@@ -513,8 +528,10 @@ export class WarehouseInventoryService {
|
||||
JOIN freight.warehouse_yards yard ON yard.warehouse_id = wh.id AND yard.deleted_at IS NULL
|
||||
JOIN freight.warehouse_zones zone ON zone.yard_id = yard.id AND zone.deleted_at IS NULL
|
||||
WHERE wh.deleted_at IS NULL
|
||||
${warehouseId ? 'AND wh.id = $1' : ''}
|
||||
ORDER BY wh.created_at ASC
|
||||
LIMIT 1`,
|
||||
LIMIT 1`,
|
||||
params,
|
||||
);
|
||||
return row ?? null;
|
||||
}
|
||||
@@ -592,6 +609,7 @@ export class WarehouseInventoryService {
|
||||
dto.warehouseId && dto.yardId && dto.zoneId
|
||||
? { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, facilityId: dto.facilityId ?? null }
|
||||
: null;
|
||||
if (!location && dto.warehouseId) location = await this.pickDefaultLocation(dto.warehouseId);
|
||||
if (!location) location = await this.pickDefaultLocation();
|
||||
if (!location) {
|
||||
throw new BadRequestException('No warehouse/yard/zone provided or configured for unloading');
|
||||
@@ -704,7 +722,12 @@ export class WarehouseInventoryService {
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType"
|
||||
v.vehicle_type AS "firstMileTruckType",
|
||||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
@@ -796,7 +819,12 @@ export class WarehouseInventoryService {
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType"
|
||||
v.vehicle_type AS "firstMileTruckType",
|
||||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
@@ -1041,9 +1069,16 @@ export class WarehouseInventoryService {
|
||||
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||||
ts.train_number AS "trainSchedule",
|
||||
inv.inspection_status AS "inspectionStatus",
|
||||
CASE WHEN b.last_mile_delivery_address IS NOT NULL
|
||||
CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)
|
||||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
||||
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
||||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
inv.status AS "currentStatus",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.release_order_reference AS "releaseOrderReference",
|
||||
@@ -1056,6 +1091,7 @@ export class WarehouseInventoryService {
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||||
@@ -1157,6 +1193,8 @@ export class WarehouseInventoryService {
|
||||
async autoUnloadArrivedBookings(
|
||||
scheduleId: string,
|
||||
performedBy?: string,
|
||||
warehouseId?: string,
|
||||
assignments: BookingUnloadLocation[] = [],
|
||||
): Promise<AutoUnloadArrivedResult> {
|
||||
const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] };
|
||||
|
||||
@@ -1203,7 +1241,21 @@ export class WarehouseInventoryService {
|
||||
[scheduleId],
|
||||
);
|
||||
|
||||
const fallback = await this.pickDefaultLocation();
|
||||
const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null;
|
||||
if (warehouseId && !requestedLocation) {
|
||||
throw new BadRequestException('Selected warehouse has no yard/zone configured for unloading');
|
||||
}
|
||||
const fallback = requestedLocation ?? (await this.pickDefaultLocation());
|
||||
const assignmentByBooking = new Map(
|
||||
assignments.map((assignment) => [
|
||||
assignment.bookingId,
|
||||
{
|
||||
warehouseId: assignment.warehouseId,
|
||||
yardId: assignment.yardId,
|
||||
zoneId: assignment.zoneId,
|
||||
} satisfies LocationRef,
|
||||
]),
|
||||
);
|
||||
const now = new Date();
|
||||
|
||||
for (const booking of bookings) {
|
||||
@@ -1223,6 +1275,8 @@ export class WarehouseInventoryService {
|
||||
|
||||
try {
|
||||
const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0];
|
||||
const assignedLocation = assignmentByBooking.get(booking.id) ?? null;
|
||||
const unloadLocation = assignedLocation ?? requestedLocation;
|
||||
|
||||
// Already unloaded or further along — leave it (do not regress the lifecycle).
|
||||
if (existing && existing.status !== 'RECEIVED') {
|
||||
@@ -1232,6 +1286,13 @@ export class WarehouseInventoryService {
|
||||
|
||||
if (existing) {
|
||||
await this.inventoryRepository.update(existing.id, {
|
||||
...(unloadLocation
|
||||
? {
|
||||
warehouseId: unloadLocation.warehouseId,
|
||||
yardId: unloadLocation.yardId,
|
||||
zoneId: unloadLocation.zoneId,
|
||||
}
|
||||
: {}),
|
||||
status: 'UNLOADED',
|
||||
unloadedAt: now,
|
||||
arrivedAt: existing.arrivedAt ?? now,
|
||||
@@ -1239,7 +1300,7 @@ export class WarehouseInventoryService {
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_UNLOADED',
|
||||
inventoryId: existing.id,
|
||||
warehouseId: existing.warehouseId,
|
||||
warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId,
|
||||
description: 'Unloaded from arrived import train',
|
||||
performedBy,
|
||||
});
|
||||
@@ -1254,7 +1315,7 @@ export class WarehouseInventoryService {
|
||||
tradeDirection: booking.tradeDirection,
|
||||
cargoTypeCode: booking.cargoTypeCode,
|
||||
});
|
||||
const location = allocated ?? fallback;
|
||||
const location = assignedLocation ?? requestedLocation ?? allocated ?? fallback;
|
||||
if (!location) {
|
||||
fail('No warehouse/yard/zone configured');
|
||||
continue;
|
||||
@@ -1336,6 +1397,16 @@ export class WarehouseInventoryService {
|
||||
if (!['ARRIVED', 'ARRIVED_AT_DJIBOUTI'].includes(schedule.status)) {
|
||||
throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`);
|
||||
}
|
||||
const [gatepass] = await this.dataSource.query(
|
||||
`SELECT gatepass_granted_at AS "gatepassSecuredAt"
|
||||
FROM freight.import_djibouti_operations
|
||||
WHERE train_schedule_id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[scheduleId],
|
||||
);
|
||||
if (!gatepass?.gatepassSecuredAt) {
|
||||
throw new BadRequestException('Djibouti Port entry blocked: gate pass status is NOT_SECURED');
|
||||
}
|
||||
|
||||
const items: Array<{
|
||||
bookingId: string;
|
||||
@@ -1631,13 +1702,17 @@ export class WarehouseInventoryService {
|
||||
if (!bookingId) return;
|
||||
const [booking] = await this.dataSource.query(
|
||||
`SELECT reference,
|
||||
last_mile_delivery_address AS "lastMileDeliveryAddress"
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!booking?.reference || !booking.lastMileDeliveryAddress) return;
|
||||
const hasLastMile =
|
||||
Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile);
|
||||
if (!booking?.reference || !hasLastMile) return;
|
||||
await this.lastMileService.acceptBooking(booking.reference);
|
||||
}
|
||||
|
||||
@@ -1955,11 +2030,19 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
|
||||
if (isTruckLeaving) {
|
||||
await this.invoices.assertClearanceAllowed(id);
|
||||
}
|
||||
const releaseDate = isTruckLeaving
|
||||
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
|
||||
: item.releaseDate ?? null;
|
||||
const reference = dto.reference?.trim() || (await this.generateReleaseReference(item));
|
||||
const exitInspectionNote = this.buildExitInspectionNote(dto);
|
||||
const reference = isTruckLeaving
|
||||
? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item))
|
||||
: dto.reference?.trim() || (await this.generateReleaseReference(item));
|
||||
const exitInspectionDto = isTruckLeaving
|
||||
? this.preserveTruckArrivalForExit(dto, item.notes)
|
||||
: dto;
|
||||
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
@@ -1967,6 +2050,17 @@ export class WarehouseInventoryService {
|
||||
releaseOrderReference: reference,
|
||||
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
|
||||
});
|
||||
if (!isTruckLeaving && item.bookingId) {
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings
|
||||
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND customer_truck_assigned_at IS NOT NULL
|
||||
AND deleted_at IS NULL`,
|
||||
[item.bookingId],
|
||||
);
|
||||
}
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RELEASED',
|
||||
@@ -2034,6 +2128,7 @@ export class WarehouseInventoryService {
|
||||
if (!row.releaseDate) {
|
||||
throw new BadRequestException('A release order must be issued before downloading the exit paper');
|
||||
}
|
||||
await this.invoices.assertClearanceAllowed(id);
|
||||
|
||||
const bookingReference = row?.bookingReference || 'N/A';
|
||||
const reference =
|
||||
@@ -2180,11 +2275,19 @@ export class WarehouseInventoryService {
|
||||
throw new BadRequestException('Please save your signature before approving delivery');
|
||||
}
|
||||
|
||||
const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> =
|
||||
const [item]: Array<{
|
||||
id: string;
|
||||
warehouseId: string | null;
|
||||
notes: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
customerTruckArrivedAt: string | null;
|
||||
}> =
|
||||
await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
inv.warehouse_id AS "warehouseId",
|
||||
inv.notes
|
||||
inv.notes,
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
b.customer_truck_arrived_at AS "customerTruckArrivedAt"
|
||||
FROM freight.warehouse_inventory inv
|
||||
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
WHERE inv.booking_id = $1
|
||||
@@ -2198,6 +2301,10 @@ export class WarehouseInventoryService {
|
||||
if (!item) {
|
||||
throw new BadRequestException('Delivery can be approved after warehouse inspection has passed');
|
||||
}
|
||||
if (item.customerTruckAssignedAt && !item.customerTruckArrivedAt) {
|
||||
throw new BadRequestException('Customer truck arrival must be recorded before delivery approval');
|
||||
}
|
||||
await this.invoices.assertClearanceAllowed(item.id);
|
||||
|
||||
const approvedAt = new Date();
|
||||
const approval = {
|
||||
@@ -2301,6 +2408,7 @@ export class WarehouseInventoryService {
|
||||
if (!row) {
|
||||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||||
}
|
||||
await this.invoices.assertClearanceAllowed(id);
|
||||
if (row.inspectionStatus !== 'PASSED') {
|
||||
throw new BadRequestException('Handover document is available after inspection has passed');
|
||||
}
|
||||
@@ -3302,8 +3410,13 @@ export class WarehouseInventoryService {
|
||||
if (!truckEntrance.driverPhone?.trim()) {
|
||||
throw new BadRequestException('Driver phone is required for entrance registration');
|
||||
}
|
||||
if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) {
|
||||
throw new BadRequestException('Entrance tare weight is required for entrance registration');
|
||||
if (truckEntrance.weighingRequired) {
|
||||
if (truckEntrance.grossWeightKg === undefined || Number(truckEntrance.grossWeightKg) < 0) {
|
||||
throw new BadRequestException('Gross weight is required when customer truck weighing is Yes');
|
||||
}
|
||||
if (truckEntrance.exitTareWeightKg === undefined || Number(truckEntrance.exitTareWeightKg) < 0) {
|
||||
throw new BadRequestException('Exit tare weight is required when customer truck weighing is Yes');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3325,6 +3438,11 @@ export class WarehouseInventoryService {
|
||||
firstMileDriverPhone?: string | null;
|
||||
firstMileDriverLicenseNumber?: string | null;
|
||||
firstMileTruckType?: string | null;
|
||||
customerTruckPlateNumber?: string | null;
|
||||
customerTruckDriverName?: string | null;
|
||||
customerTruckType?: string | null;
|
||||
customerTruckContainerNumber?: string | null;
|
||||
customerTruckAssignedAt?: string | null;
|
||||
},
|
||||
): TruckEntranceDto {
|
||||
return {
|
||||
@@ -3340,16 +3458,22 @@ export class WarehouseInventoryService {
|
||||
booking.containerQuantity !== undefined && booking.containerQuantity !== null
|
||||
? Number(booking.containerQuantity)
|
||||
: submitted.unitCount,
|
||||
grossWeightKg:
|
||||
booking.weight !== undefined && booking.weight !== null
|
||||
? Number(booking.weight)
|
||||
: submitted.grossWeightKg,
|
||||
truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber,
|
||||
grossWeightKg: submitted.grossWeightKg,
|
||||
truckPlateNumber:
|
||||
booking.firstMileTruckPlateNumber?.trim() ||
|
||||
booking.customerTruckPlateNumber?.trim() ||
|
||||
submitted.truckPlateNumber,
|
||||
trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber,
|
||||
driverName: booking.firstMileDriverName?.trim() || submitted.driverName,
|
||||
driverName:
|
||||
booking.firstMileDriverName?.trim() ||
|
||||
booking.customerTruckDriverName?.trim() ||
|
||||
submitted.driverName,
|
||||
driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone,
|
||||
driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber,
|
||||
truckType: booking.firstMileTruckType?.trim() || submitted.truckType,
|
||||
truckType:
|
||||
booking.firstMileTruckType?.trim() ||
|
||||
booking.customerTruckType?.trim() ||
|
||||
submitted.truckType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3543,6 +3667,24 @@ export class WarehouseInventoryService {
|
||||
return rows.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto {
|
||||
const inspection = this.extractExitInspectionNote(notes);
|
||||
if (!inspection) return dto;
|
||||
|
||||
return {
|
||||
...dto,
|
||||
truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
|
||||
trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber,
|
||||
driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName,
|
||||
driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense,
|
||||
driverPhone: this.extractExitInspectionLine(inspection, 'Driver Phone') || dto.driverPhone,
|
||||
truckType: this.extractExitInspectionLine(inspection, 'Truck Type') || dto.truckType,
|
||||
containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber,
|
||||
gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime,
|
||||
tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight,
|
||||
};
|
||||
}
|
||||
|
||||
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
|
||||
const trimmed = notes?.trim();
|
||||
if (!exitInspectionNote) return trimmed || null;
|
||||
@@ -3564,6 +3706,18 @@ export class WarehouseInventoryService {
|
||||
return notes.slice(index + marker.length).trim() || null;
|
||||
}
|
||||
|
||||
private extractExitInspectionLine(note: string | null | undefined, label: string): string | null {
|
||||
const match = note?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined {
|
||||
const value = this.extractExitInspectionLine(note, label)?.replace(/\s*kg$/i, '');
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
private extractReceiveSummary(notes?: string | null): string | null {
|
||||
if (!notes?.trim()) return null;
|
||||
const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes;
|
||||
@@ -3622,6 +3776,7 @@ export class WarehouseInventoryService {
|
||||
truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
|
||||
truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
|
||||
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null,
|
||||
truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null,
|
||||
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
|
||||
truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
|
||||
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res }
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
||||
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
|
||||
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||
|
||||
@@ -86,4 +87,10 @@ export class WarehouseInvoiceController {
|
||||
pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) {
|
||||
return this.invoiceService.pay(id, dto);
|
||||
}
|
||||
|
||||
@Post('warehouse-fee-invoices/:id/pay-online')
|
||||
@ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' })
|
||||
payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) {
|
||||
return this.invoiceService.initiatePayment(id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { Freight } from "@edr/types";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||||
import {
|
||||
BillingService,
|
||||
InvoiceEventPayload,
|
||||
InvoiceLineInput,
|
||||
} from "../billing/billing.service";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { InvoiceLine } from "../billing/entities/invoice-line.entity";
|
||||
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
} from '../billing/documents/invoice-document.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
} from "../billing/documents/invoice-document.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { WarehouseFeeService } from "./warehouse-fee.service";
|
||||
import {
|
||||
WarehouseFeeInvoiceView,
|
||||
WarehouseFeeType,
|
||||
WarehouseInvoiceItemView,
|
||||
WarehouseInvoiceStatus,
|
||||
WarehouseInvoiceType,
|
||||
} from './warehouse-invoice.types';
|
||||
} from "./warehouse-invoice.types";
|
||||
|
||||
interface GenerateOptions {
|
||||
confirmZero?: boolean;
|
||||
performedBy?: string;
|
||||
billingCurrency?: 'ETB' | 'USD';
|
||||
billingCurrency?: "ETB" | "USD";
|
||||
}
|
||||
|
||||
export interface PayInvoiceDto {
|
||||
@@ -45,7 +57,10 @@ const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
/** Global statuses considered an "active" invoice for per-inventory dedup. */
|
||||
const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid];
|
||||
const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [
|
||||
...BLOCKING_STATUSES,
|
||||
Freight.InvoiceStatus.Paid,
|
||||
];
|
||||
|
||||
export interface InvoiceDocumentDetails {
|
||||
bookingReference: string | null;
|
||||
@@ -120,10 +135,13 @@ export class WarehouseInvoiceService {
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
private readonly feeService: WarehouseFeeService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────
|
||||
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoiceDetail> {
|
||||
async generateForInventory(
|
||||
inventoryId: string,
|
||||
opts: GenerateOptions = {},
|
||||
): Promise<WarehouseFeeInvoiceDetail> {
|
||||
const [item] = await this.dataSource.query(
|
||||
`SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt",
|
||||
@@ -136,43 +154,53 @@ export class WarehouseInvoiceService {
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
|
||||
[inventoryId],
|
||||
);
|
||||
if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||
if (!item)
|
||||
throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||
|
||||
// Routing through the global invoice requires a billable company + profile,
|
||||
// both of which come from the inventory's booking.
|
||||
if (!item.companyId || !item.companyProfileId) {
|
||||
throw new BadRequestException(
|
||||
'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).',
|
||||
"Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).",
|
||||
);
|
||||
}
|
||||
|
||||
// Dedup: only one active (non-cancelled) invoice per inventory item.
|
||||
if (await this.hasActiveInvoice(inventoryId)) {
|
||||
throw new ConflictException(
|
||||
'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.',
|
||||
"An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.",
|
||||
);
|
||||
}
|
||||
|
||||
const billingCurrency = opts.billingCurrency === 'ETB' ? 'ETB' : 'USD';
|
||||
const previews = await this.feeService.previewForInventory(inventoryId, billingCurrency);
|
||||
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
const billingCurrency = opts.billingCurrency === "ETB" ? "ETB" : "USD";
|
||||
const previews = await this.feeService.previewForInventory(
|
||||
inventoryId,
|
||||
billingCurrency,
|
||||
);
|
||||
const isContainer = (item.freightType ?? "").toUpperCase() === "CONTAINER";
|
||||
|
||||
const items = previews
|
||||
.filter((p) => p.amount > 0)
|
||||
.map((p) => {
|
||||
const feeType: WarehouseFeeType =
|
||||
p.ruleType === 'STORAGE_FEE'
|
||||
? 'STORAGE_FEE'
|
||||
p.ruleType === "STORAGE_FEE"
|
||||
? "STORAGE_FEE"
|
||||
: isContainer
|
||||
? 'CONTAINER_DEMURRAGE'
|
||||
: 'BULK_DEMURRAGE';
|
||||
? "CONTAINER_DEMURRAGE"
|
||||
: "BULK_DEMURRAGE";
|
||||
return {
|
||||
feeRuleId: p.ruleId,
|
||||
feeType,
|
||||
description:
|
||||
p.ruleType === 'STORAGE_FEE'
|
||||
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`
|
||||
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`,
|
||||
p.ruleType === "STORAGE_FEE"
|
||||
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
|
||||
? " using tiered tariff"
|
||||
: ` after ${p.freeDays} free`
|
||||
}`
|
||||
: `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
|
||||
? " using tiered tariff"
|
||||
: ` after ${p.freeDays} free`
|
||||
}`,
|
||||
quantity: p.billableUnits,
|
||||
unitRate: p.ratePerDay,
|
||||
amount: p.amount,
|
||||
@@ -184,13 +212,19 @@ export class WarehouseInvoiceService {
|
||||
|
||||
const total = items.reduce((s, i) => s + i.amount, 0);
|
||||
if (total <= 0 && !opts.confirmZero) {
|
||||
throw new BadRequestException('No payable warehouse fee found for this item.');
|
||||
throw new BadRequestException(
|
||||
"No payable warehouse fee found for this item.",
|
||||
);
|
||||
}
|
||||
|
||||
const hasDemurrage = items.some((i) => i.feeType !== 'STORAGE_FEE');
|
||||
const hasStorage = items.some((i) => i.feeType === 'STORAGE_FEE');
|
||||
const hasDemurrage = items.some((i) => i.feeType !== "STORAGE_FEE");
|
||||
const hasStorage = items.some((i) => i.feeType === "STORAGE_FEE");
|
||||
const invoiceType: WarehouseInvoiceType =
|
||||
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
|
||||
hasDemurrage && hasStorage
|
||||
? "MIXED_WAREHOUSE_FEES"
|
||||
: hasStorage
|
||||
? "STORAGE_FEE"
|
||||
: "DEMURRAGE";
|
||||
|
||||
const lines: InvoiceLineInput[] = items.map((it) => ({
|
||||
chargeType: it.feeType,
|
||||
@@ -232,18 +266,23 @@ export class WarehouseInvoiceService {
|
||||
}
|
||||
|
||||
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoiceView[]> {
|
||||
return this.queryViews('AND i.source_id = $1', [inventoryId]);
|
||||
return this.queryViews("AND i.source_id = $1", [inventoryId]);
|
||||
}
|
||||
|
||||
listForBooking(bookingId: string): Promise<WarehouseFeeInvoiceView[]> {
|
||||
return this.queryViews('AND inv.booking_id = $1', [bookingId]);
|
||||
return this.queryViews("AND inv.booking_id = $1", [bookingId]);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
filter: Partial<
|
||||
Pick<
|
||||
WarehouseFeeInvoiceView,
|
||||
'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'
|
||||
| "status"
|
||||
| "invoiceType"
|
||||
| "warehouseId"
|
||||
| "facilityId"
|
||||
| "customerId"
|
||||
| "bookingId"
|
||||
>
|
||||
>,
|
||||
): Promise<WarehouseFeeInvoiceView[]> {
|
||||
@@ -254,41 +293,56 @@ export class WarehouseInvoiceService {
|
||||
conditions.push(sql(`$${params.length}`));
|
||||
};
|
||||
|
||||
if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus));
|
||||
if (filter.status)
|
||||
add(
|
||||
(p) => `i.status::text = ${p}`,
|
||||
this.toGlobalStatus(filter.status as WarehouseInvoiceStatus),
|
||||
);
|
||||
if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType);
|
||||
if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId);
|
||||
if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId);
|
||||
if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId);
|
||||
if (filter.warehouseId)
|
||||
add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId);
|
||||
if (filter.facilityId)
|
||||
add((p) => `w.facility_id = ${p}`, filter.facilityId);
|
||||
if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId);
|
||||
|
||||
return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params);
|
||||
return this.queryViews(conditions.map((c) => `AND ${c}`).join(" "), params);
|
||||
}
|
||||
|
||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE'));
|
||||
return this.invoiceDocuments.render(
|
||||
this.toDocumentModel(invoice, "INVOICE"),
|
||||
);
|
||||
}
|
||||
|
||||
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
if (Number(invoice.paidAmount) <= 0) {
|
||||
throw new BadRequestException('A receipt is available only after payment is recorded.');
|
||||
throw new BadRequestException(
|
||||
"A receipt is available only after payment is recorded.",
|
||||
);
|
||||
}
|
||||
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT'));
|
||||
return this.invoiceDocuments.render(
|
||||
this.toDocumentModel(invoice, "RECEIPT"),
|
||||
);
|
||||
}
|
||||
|
||||
// ── State changes ────────────────────────────────────────────────────────
|
||||
async cancel(id: string): Promise<WarehouseFeeInvoiceDetail> {
|
||||
const invoice = await this.loadWarehouseInvoice(id);
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException('A paid invoice cannot be cancelled.');
|
||||
throw new BadRequestException("A paid invoice cannot be cancelled.");
|
||||
}
|
||||
await this.billing.cancelInvoice(id);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Record a payment against the invoice (delegates settlement to billing). */
|
||||
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoiceDetail> {
|
||||
async pay(
|
||||
id: string,
|
||||
dto: PayInvoiceDto,
|
||||
): Promise<WarehouseFeeInvoiceDetail> {
|
||||
// Guard that this is a warehouse invoice before recording (404 otherwise).
|
||||
await this.loadWarehouseInvoice(id);
|
||||
await this.billing.recordPayment(id, {
|
||||
@@ -297,7 +351,10 @@ export class WarehouseInvoiceService {
|
||||
reference: dto.reference ?? null,
|
||||
metadata:
|
||||
dto.driverName || dto.driverPhone
|
||||
? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null }
|
||||
? {
|
||||
driverName: dto.driverName ?? null,
|
||||
driverPhone: dto.driverPhone ?? null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const detail = await this.findById(id);
|
||||
@@ -305,6 +362,22 @@ export class WarehouseInvoiceService {
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** Initiate a wallet/gateway payment for the invoice. */
|
||||
async initiatePayment(id: string, dto: GatewayPayInvoiceDto = {}) {
|
||||
const invoice = await this.loadWarehouseInvoice(id);
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException("Invoice is already fully paid.");
|
||||
}
|
||||
|
||||
return this.billing.payInvoice(invoice.id, {
|
||||
method: dto.method ?? (invoice.currency === "USD" ? "WAAFI" : "TELEBIRR"),
|
||||
platform: dto.platform ?? "web",
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl,
|
||||
failureUrl: dto.failureUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify on online (gateway) settlement — the domain side-effect of a warehouse
|
||||
* fee being paid through billing's payment flow. The counter {@link pay} path
|
||||
@@ -313,16 +386,20 @@ export class WarehouseInvoiceService {
|
||||
* counter settlement leaves it null. Skipping null-`paymentId` events avoids
|
||||
* double-notifying a counter payment that already sent its SMS.
|
||||
*/
|
||||
@OnEvent('warehouse.invoice.paid')
|
||||
@OnEvent("warehouse.invoice.paid")
|
||||
async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
if (!payload.paymentId) return;
|
||||
const detail = await this.findById(payload.invoiceId);
|
||||
await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) });
|
||||
await this.notifyWarehouseFeePayment(detail, {
|
||||
amount: Number(detail.totalAmount),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Release blocking ──────────────────────────────────────────────────────
|
||||
/** Returns the first unpaid invoice that blocks terminal release, or null. */
|
||||
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoiceView | null> {
|
||||
async findBlockingInvoice(
|
||||
inventoryId: string,
|
||||
): Promise<WarehouseFeeInvoiceView | null> {
|
||||
const blocking = await this.queryViews(
|
||||
`AND i.source_id = $1 AND i.status::text = ANY($2::text[])`,
|
||||
[inventoryId, BLOCKING_STATUSES],
|
||||
@@ -331,21 +408,31 @@ export class WarehouseInvoiceService {
|
||||
}
|
||||
|
||||
async assertClearanceAllowed(inventoryId: string): Promise<void> {
|
||||
const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]);
|
||||
const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||
const invoices = await this.queryViews("AND i.source_id = $1", [
|
||||
inventoryId,
|
||||
]);
|
||||
const blocking = invoices.find(
|
||||
(inv) => inv.status === "ISSUED" || inv.status === "PARTIALLY_PAID",
|
||||
);
|
||||
if (blocking) {
|
||||
throw new BadRequestException(
|
||||
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (invoices.some((inv) => inv.status === 'PAID')) return;
|
||||
if (invoices.some((inv) => inv.status === "PAID")) return;
|
||||
|
||||
const previews = await this.feeService.previewForInventory(inventoryId, 'USD');
|
||||
const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0);
|
||||
const previews = await this.feeService.previewForInventory(
|
||||
inventoryId,
|
||||
"USD",
|
||||
);
|
||||
const payableAmount = previews.reduce(
|
||||
(sum, fee) => sum + Number(fee.amount || 0),
|
||||
0,
|
||||
);
|
||||
if (payableAmount > 0) {
|
||||
throw new BadRequestException(
|
||||
'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.',
|
||||
"Generate and fully pay the warehouse demurrage/storage invoice before terminal release.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -353,7 +440,9 @@ export class WarehouseInvoiceService {
|
||||
// ── Internal: loading & projection ─────────────────────────────────────────
|
||||
|
||||
/** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */
|
||||
private async loadWarehouseInvoice(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
private async loadWarehouseInvoice(
|
||||
id: string,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const invoice = await this.billing.findById(id);
|
||||
if (invoice.source !== SOURCE) {
|
||||
throw new NotFoundException(`Invoice ${id} not found`);
|
||||
@@ -376,7 +465,10 @@ export class WarehouseInvoiceService {
|
||||
* Project warehouse-source global invoices into the historical view, joined to
|
||||
* their inventory item for the typed FKs. Powers every list/filter read.
|
||||
*/
|
||||
private async queryViews(extraWhere: string, params: unknown[]): Promise<WarehouseFeeInvoiceView[]> {
|
||||
private async queryViews(
|
||||
extraWhere: string,
|
||||
params: unknown[],
|
||||
): Promise<WarehouseFeeInvoiceView[]> {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId",
|
||||
i.source_id AS "sourceId", i.type, i.status,
|
||||
@@ -389,7 +481,7 @@ export class WarehouseInvoiceService {
|
||||
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
|
||||
w.facility_id AS "facilityId"
|
||||
FROM freight.invoices i
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.id::text = i.source_id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere}
|
||||
ORDER BY i.created_at DESC`,
|
||||
@@ -409,7 +501,10 @@ export class WarehouseInvoiceService {
|
||||
}
|
||||
|
||||
/** Reshape a global invoice (+ derived inventory context) into the warehouse view. */
|
||||
private buildView(inv: ViewSource, ctx: InventoryContext): WarehouseFeeInvoiceView {
|
||||
private buildView(
|
||||
inv: ViewSource,
|
||||
ctx: InventoryContext,
|
||||
): WarehouseFeeInvoiceView {
|
||||
const status = this.toWarehouseStatus(inv.status);
|
||||
return {
|
||||
id: inv.id,
|
||||
@@ -436,7 +531,7 @@ export class WarehouseInvoiceService {
|
||||
issuedAt: inv.issuedAt ?? null,
|
||||
dueDate: inv.dueAt ?? null,
|
||||
paidAt: inv.paidAt ?? null,
|
||||
cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null,
|
||||
cancelledAt: status === "CANCELLED" ? inv.updatedAt : null,
|
||||
payments: (inv.payments ?? []).map((p) => ({
|
||||
amount: Number(p.amount),
|
||||
method: p.method ?? null,
|
||||
@@ -458,7 +553,7 @@ export class WarehouseInvoiceService {
|
||||
return {
|
||||
feeRuleId: meta.feeRuleId ?? null,
|
||||
feeType: line.chargeType as WarehouseFeeType,
|
||||
description: line.description ?? '',
|
||||
description: line.description ?? "",
|
||||
quantity: Number(line.quantity),
|
||||
unitRate: Number(line.unitRate),
|
||||
amount: Number(line.amount),
|
||||
@@ -468,32 +563,36 @@ export class WarehouseInvoiceService {
|
||||
};
|
||||
}
|
||||
|
||||
private toWarehouseStatus(status: Freight.InvoiceStatus | string): WarehouseInvoiceStatus {
|
||||
private toWarehouseStatus(
|
||||
status: Freight.InvoiceStatus | string,
|
||||
): WarehouseInvoiceStatus {
|
||||
switch (status) {
|
||||
case Freight.InvoiceStatus.Draft:
|
||||
return 'DRAFT';
|
||||
return "DRAFT";
|
||||
case Freight.InvoiceStatus.PartiallyPaid:
|
||||
return 'PARTIALLY_PAID';
|
||||
return "PARTIALLY_PAID";
|
||||
case Freight.InvoiceStatus.Paid:
|
||||
return 'PAID';
|
||||
return "PAID";
|
||||
case Freight.InvoiceStatus.Cancelled:
|
||||
case Freight.InvoiceStatus.Refunded:
|
||||
return 'CANCELLED';
|
||||
return "CANCELLED";
|
||||
default:
|
||||
// Issued / Pending / Overdue → an issued, still-owed invoice.
|
||||
return 'ISSUED';
|
||||
return "ISSUED";
|
||||
}
|
||||
}
|
||||
|
||||
private toGlobalStatus(status: WarehouseInvoiceStatus): Freight.InvoiceStatus {
|
||||
private toGlobalStatus(
|
||||
status: WarehouseInvoiceStatus,
|
||||
): Freight.InvoiceStatus {
|
||||
switch (status) {
|
||||
case 'DRAFT':
|
||||
case "DRAFT":
|
||||
return Freight.InvoiceStatus.Draft;
|
||||
case 'PARTIALLY_PAID':
|
||||
case "PARTIALLY_PAID":
|
||||
return Freight.InvoiceStatus.PartiallyPaid;
|
||||
case 'PAID':
|
||||
case "PAID":
|
||||
return Freight.InvoiceStatus.Paid;
|
||||
case 'CANCELLED':
|
||||
case "CANCELLED":
|
||||
return Freight.InvoiceStatus.Cancelled;
|
||||
default:
|
||||
return Freight.InvoiceStatus.Issued;
|
||||
@@ -503,39 +602,54 @@ export class WarehouseInvoiceService {
|
||||
/** Map a warehouse fee invoice view onto the shared document model. */
|
||||
private toDocumentModel(
|
||||
invoice: WarehouseFeeInvoiceDetail,
|
||||
kind: 'INVOICE' | 'RECEIPT',
|
||||
kind: "INVOICE" | "RECEIPT",
|
||||
): InvoiceDocumentModel {
|
||||
const lastPayment = [...(invoice.payments ?? [])].pop();
|
||||
const date = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null;
|
||||
value
|
||||
? new Date(value as string | Date).toLocaleDateString("en-GB")
|
||||
: null;
|
||||
|
||||
return {
|
||||
kind,
|
||||
title: 'Warehouse Fee',
|
||||
title: "Warehouse Fee",
|
||||
documentNumber: invoice.invoiceNumber,
|
||||
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
||||
status: invoice.status,
|
||||
currency: invoice.currency,
|
||||
summary: [
|
||||
{ label: 'Status', value: invoice.status.replace(/_/g, ' ') },
|
||||
{ label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') },
|
||||
{ label: 'Booking reference', value: invoice.bookingReference ?? null },
|
||||
{ label: 'Customer', value: invoice.customerName ?? null },
|
||||
{ label: 'Inventory reference', value: invoice.inventoryReference ?? null },
|
||||
{ label: 'Inventory info', value: invoice.inventoryInfo ?? null },
|
||||
{ label: 'Clearance', value: invoice.clearanceStatus ?? null },
|
||||
{ label: 'Warehouse', value: invoice.warehouseName ?? null },
|
||||
{ label: "Status", value: invoice.status.replace(/_/g, " ") },
|
||||
{
|
||||
label: 'Yard / Zone',
|
||||
value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null,
|
||||
label: "Invoice type",
|
||||
value: invoice.invoiceType.replace(/_/g, " "),
|
||||
},
|
||||
{ label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` },
|
||||
{ label: "Booking reference", value: invoice.bookingReference ?? null },
|
||||
{ label: "Customer", value: invoice.customerName ?? null },
|
||||
{
|
||||
label: 'Payment',
|
||||
value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null,
|
||||
label: "Inventory reference",
|
||||
value: invoice.inventoryReference ?? null,
|
||||
},
|
||||
{ label: "Inventory info", value: invoice.inventoryInfo ?? null },
|
||||
{ label: "Clearance", value: invoice.clearanceStatus ?? null },
|
||||
{ label: "Warehouse", value: invoice.warehouseName ?? null },
|
||||
{
|
||||
label: "Yard / Zone",
|
||||
value:
|
||||
[invoice.yardName, invoice.zoneName].filter(Boolean).join(" / ") ||
|
||||
null,
|
||||
},
|
||||
{
|
||||
label: "Period",
|
||||
value: `${date(invoice.periodStart) ?? "-"} - ${date(invoice.periodEnd) ?? "-"}`,
|
||||
},
|
||||
{
|
||||
label: "Payment",
|
||||
value: lastPayment
|
||||
? `${lastPayment.method ?? "MANUAL"} / ${date(lastPayment.paidAt) ?? "-"}`
|
||||
: null,
|
||||
},
|
||||
],
|
||||
categoryHeader: 'Fee type',
|
||||
categoryHeader: "Fee type",
|
||||
lines: invoice.items.map((item) => ({
|
||||
description: item.description ?? null,
|
||||
category: item.feeType ?? null,
|
||||
@@ -545,17 +659,19 @@ export class WarehouseInvoiceService {
|
||||
currency: item.currency ?? invoice.currency,
|
||||
})),
|
||||
totals: [
|
||||
{ label: 'Subtotal', amount: Number(invoice.subtotalAmount) },
|
||||
{ label: 'Tax', amount: Number(invoice.taxAmount) },
|
||||
{ label: 'Total', amount: Number(invoice.totalAmount), grand: true },
|
||||
{ label: 'Paid', amount: Number(invoice.paidAmount) },
|
||||
{ label: 'Balance', amount: Number(invoice.balanceAmount) },
|
||||
{ label: "Subtotal", amount: Number(invoice.subtotalAmount) },
|
||||
{ label: "Tax", amount: Number(invoice.taxAmount) },
|
||||
{ label: "Total", amount: Number(invoice.totalAmount), grand: true },
|
||||
{ label: "Paid", amount: Number(invoice.paidAmount) },
|
||||
{ label: "Balance", amount: Number(invoice.balanceAmount) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Warehouse-specific display details, derived from the linked inventory item. */
|
||||
private async getInvoiceDocumentDetails(invoice: ViewSource): Promise<InvoiceDocumentDetails> {
|
||||
private async getInvoiceDocumentDetails(
|
||||
invoice: ViewSource,
|
||||
): Promise<InvoiceDocumentDetails> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT b.reference AS "bookingReference",
|
||||
company.name AS "customerName",
|
||||
@@ -591,12 +707,12 @@ export class WarehouseInvoiceService {
|
||||
[invoice.sourceId],
|
||||
);
|
||||
|
||||
const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID';
|
||||
const fullyPaid = this.toWarehouseStatus(invoice.status) === "PAID";
|
||||
const clearanceStatus = row?.releaseDate
|
||||
? 'RELEASE ISSUED'
|
||||
? "RELEASE ISSUED"
|
||||
: fullyPaid
|
||||
? 'FEE PAID - READY FOR RELEASE'
|
||||
: 'PENDING PAYMENT';
|
||||
? "FEE PAID - READY FOR RELEASE"
|
||||
: "PENDING PAYMENT";
|
||||
|
||||
return {
|
||||
bookingReference: row?.bookingReference ?? null,
|
||||
@@ -613,7 +729,9 @@ export class WarehouseInvoiceService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getInventoryContext(inventoryId: string): Promise<InventoryContext> {
|
||||
private async getInventoryContext(
|
||||
inventoryId: string,
|
||||
): Promise<InventoryContext> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
|
||||
@@ -701,55 +819,89 @@ export class WarehouseInvoiceService {
|
||||
};
|
||||
}
|
||||
|
||||
private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise<void> {
|
||||
private async sendSms(
|
||||
recipient: string | null | undefined,
|
||||
message: string,
|
||||
context: string,
|
||||
): Promise<void> {
|
||||
const phone = recipient?.trim();
|
||||
if (!phone) return;
|
||||
try {
|
||||
await this.notifications.directSend('sms', phone, message);
|
||||
await this.notifications.directSend("sms", phone, message);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send ${context} SMS to ${phone}: ${String(error)}`);
|
||||
this.logger.error(
|
||||
`Failed to send ${context} SMS to ${phone}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
|
||||
const customerName = contacts.customerName?.trim() || 'Customer';
|
||||
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
|
||||
private async notifyWarehouseFeeIssued(
|
||||
invoice: WarehouseFeeInvoiceView,
|
||||
): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(
|
||||
invoice.inventoryId,
|
||||
);
|
||||
const customerName = contacts.customerName?.trim() || "Customer";
|
||||
const bookingReference = contacts.bookingReference
|
||||
? ` Booking: ${contacts.bookingReference}.`
|
||||
: "";
|
||||
const cargo = contacts.containerNumber || contacts.cargoDescription;
|
||||
const cargoText = cargo ? ` Cargo: ${cargo}.` : '';
|
||||
const cargoText = cargo ? ` Cargo: ${cargo}.` : "";
|
||||
const message =
|
||||
`Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, ' ').toLowerCase()} fee ` +
|
||||
`Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ` +
|
||||
`${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` +
|
||||
`${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`;
|
||||
|
||||
await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`);
|
||||
await this.sendSms(
|
||||
contacts.customerPhone,
|
||||
message,
|
||||
`warehouse fee invoice ${invoice.invoiceNumber}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
|
||||
const customerName = contacts.customerName?.trim() || 'Customer';
|
||||
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
|
||||
private async notifyWarehouseFeePayment(
|
||||
invoice: WarehouseFeeInvoiceView,
|
||||
dto: PayInvoiceDto,
|
||||
): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(
|
||||
invoice.inventoryId,
|
||||
);
|
||||
const customerName = contacts.customerName?.trim() || "Customer";
|
||||
const bookingReference = contacts.bookingReference
|
||||
? ` Booking: ${contacts.bookingReference}.`
|
||||
: "";
|
||||
const statusText =
|
||||
invoice.status === 'PAID'
|
||||
? 'fully paid and ready for pickup release'
|
||||
invoice.status === "PAID"
|
||||
? "fully paid and ready for pickup release"
|
||||
: `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`;
|
||||
const customerMessage =
|
||||
`Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` +
|
||||
`was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`;
|
||||
|
||||
await this.sendSms(contacts.customerPhone, customerMessage, `warehouse fee payment ${invoice.invoiceNumber}`);
|
||||
await this.sendSms(
|
||||
contacts.customerPhone,
|
||||
customerMessage,
|
||||
`warehouse fee payment ${invoice.invoiceNumber}`,
|
||||
);
|
||||
|
||||
if (invoice.status !== 'PAID') return;
|
||||
if (invoice.status !== "PAID") return;
|
||||
|
||||
const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone;
|
||||
const driverName = dto.driverName?.trim() || contacts.driverName || 'Driver';
|
||||
const driverName =
|
||||
dto.driverName?.trim() || contacts.driverName || "Driver";
|
||||
const cargo = contacts.containerNumber || contacts.cargoDescription;
|
||||
const driverMessage =
|
||||
`Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` +
|
||||
(contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '') +
|
||||
(cargo ? ` Cargo: ${cargo}.` : '') +
|
||||
' Proceed with pickup after gate verification.';
|
||||
(contacts.bookingReference
|
||||
? ` Booking: ${contacts.bookingReference}.`
|
||||
: "") +
|
||||
(cargo ? ` Cargo: ${cargo}.` : "") +
|
||||
" Proceed with pickup after gate verification.";
|
||||
|
||||
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
|
||||
await this.sendSms(
|
||||
driverPhone,
|
||||
driverMessage,
|
||||
`warehouse pickup driver ${invoice.invoiceNumber}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user