Merge pull request #1105 from Tria-plc/dev

Freight Deployment
This commit is contained in:
marshal
2026-08-04 12:52:08 +03:00
committed by GitHub
45 changed files with 1562 additions and 1156 deletions

View File

@@ -30,6 +30,12 @@ FREIGHT_PORTAL_URL=http://localhost:5173
# Point these at the freight portal's public payment result routes.
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure
# Drain tail (minutes) added to every booking pay window before anything expires:
# settlement is asynchronous, so a payment made in the window's last seconds lands
# after the deadline. Nothing is expired, no wagons are resold and no window cycle
# concludes until the tail passes. Defaults to 5 when unset.
FREIGHT_PAYMENT_DRAIN_MINUTES=5
# JWT (used by @tria-plc/api-common SharedAuthModule)
JWT_SECRET=
JWT_ACCESS_TOKEN_SECRET=

View File

@@ -119,6 +119,7 @@ export class ContractDocumentViewModelBuilder {
const dynamicSource = await this.contractTemplates.findActiveForContract(
contract.tradeDirection,
contract.freightType,
contract.customsClearingEnabled,
);
dynamicTemplate = dynamicSource
? {

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class EmptyContainerReturnedBy3210000000000 implements MigrationInterface {
name = 'EmptyContainerReturnedBy3210000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
ADD COLUMN IF NOT EXISTS returned_by varchar(20) NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
DROP COLUMN IF EXISTS returned_by
`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class EmptyContainerReturnStatusHistory3220000000000 implements MigrationInterface {
name = 'EmptyContainerReturnStatusHistory3220000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
ADD COLUMN IF NOT EXISTS status_history jsonb NOT NULL DEFAULT '[]'::jsonb
`);
await queryRunner.query(`
UPDATE freight.empty_container_returns
SET status_history = jsonb_build_array(
jsonb_build_object('status', status, 'changedAt', created_at, 'performedBy', performed_by)
)
WHERE status_history = '[]'::jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
DROP COLUMN IF EXISTS status_history
`);
}
}

View File

@@ -0,0 +1,101 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
/**
* The codes that gain a customs variant. Intercity is deliberately absent: it
* is a domestic Ethiopian movement that crosses no border, so it has no customs
* leg and keeps its single unsuffixed template.
*/
const SPLIT_CODES = [
'IMPORT_BULK',
'EXPORT_BULK',
'IMPORT_CONTAINER',
'EXPORT_CONTAINER',
];
/**
* Split the four cross-border contract templates into eight — one `_CUSTOMS`
* and one `_NO_CUSTOMS` variant each — so the generated contract document
* reflects whether EDR clears customs on the Client's behalf. Together with the
* two untouched intercity templates the table ends up with ten rows.
*
* The four existing rows are RENAMED to `<code>_NO_CUSTOMS` rather than
* replaced, so any article text staff already edited through the template
* editor survives. The four `_CUSTOMS` rows are then inserted from the seed
* (the same base pack plus the customs-clearing articles).
*
* Idempotent: the rename is guarded on the legacy code still existing, and the
* insert is ON CONFLICT (code) DO NOTHING.
*/
export class SplitContractTemplatesByCustoms3230000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Carry each legacy row over to its _NO_CUSTOMS code, preserving edits.
// Guarded so a re-run (or a DB already holding the new code) is a no-op.
for (const legacy of SPLIT_CODES) {
await queryRunner.query(
`
UPDATE freight.contract_templates
SET code = $2, updated_at = now()
WHERE code = $1
AND NOT EXISTS (
SELECT 1 FROM freight.contract_templates WHERE code = $2
);
`,
[legacy, `${legacy}_NO_CUSTOMS`],
);
}
// 2. Seed anything still missing — the six _CUSTOMS rows on an existing DB,
// or all twelve on a database that never held the legacy codes.
for (const seed of CONTRACT_TEMPLATE_DEFAULTS) {
const articles = seed.articles.map((article, index) => ({
...article,
order: index + 1,
}));
await queryRunner.query(
`
INSERT INTO freight.contract_templates
(code, name, description, document_title, whereas_clauses, articles)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)
ON CONFLICT (code) DO NOTHING;
`,
[
seed.code,
seed.name,
seed.description,
seed.documentTitle,
JSON.stringify(seed.whereasClauses),
JSON.stringify(articles),
],
);
}
}
/**
* Drop the _CUSTOMS rows and fold the _NO_CUSTOMS rows back onto the legacy
* codes, returning the table to six templates. The two intercity rows were
* never touched by up(), so they need no reversal.
*/
public async down(queryRunner: QueryRunner): Promise<void> {
for (const legacy of SPLIT_CODES) {
await queryRunner.query(
`DELETE FROM freight.contract_templates WHERE code = $1;`,
[`${legacy}_CUSTOMS`],
);
await queryRunner.query(
`
UPDATE freight.contract_templates
SET code = $1, updated_at = now()
WHERE code = $2
AND NOT EXISTS (
SELECT 1 FROM freight.contract_templates WHERE code = $1
);
`,
[legacy, `${legacy}_NO_CUSTOMS`],
);
}
}
}

View File

@@ -203,6 +203,129 @@ describe("BillingService.markInvoiceAsPaid", () => {
});
});
/**
* A gateway success can land after the pay window AND its drain tail (relay
* backlog, payment-api restart, a CBE bill paid at a counter). The money is
* captured either way, so the settle lookup must accept an EXPIRED invoice —
* matching only OPEN_STATUSES used to drop it silently, leaving a debited
* customer with an EXPIRED invoice, an EXPIRED booking and no alert.
*/
describe("BillingService.settleByPaymentId", () => {
function serviceFor(invoice: Record<string, unknown> | null) {
const mg = {
findOne: jest.fn().mockResolvedValue(invoice),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
// The lookup is by paymentId ALONE — status is judged on the resolved row,
// so a stale capture can never skip past a newer invoice to an older one.
const findOne = jest.fn(({ where }: { where: Record<string, unknown> }) => {
expect(where).toEqual({ paymentId: "pay-1" });
return Promise.resolve(invoice);
});
const dataSource = {
getRepository: () => ({ findOne }),
transaction: (cb: (mg: unknown) => unknown) => cb(mg),
manager: mg,
};
const service = new BillingService(
dataSource as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
return { service, mg, events };
}
it("settles an EXPIRED invoice — the money was already captured", async () => {
const { service, mg, events } = serviceFor({
id: "inv-1",
status: Freight.InvoiceStatus.Expired,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
paidAt: null,
});
const settled = await service.settleByPaymentId("pay-1", "txn-1");
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
expect.objectContaining({
status: Freight.InvoiceStatus.Paid,
paidAmount: 1500,
balanceAmount: 0,
}),
);
// The domain reacts to this — it is what revives the expired booking.
expect(events.emitAsync).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.objectContaining({ invoiceId: "inv-1" }),
);
});
it("still settles an open (PENDING) invoice", async () => {
const { service, mg } = serviceFor({
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
paidAt: null,
});
await service.settleByPaymentId("pay-1");
expect(mg.update).toHaveBeenCalled();
});
/**
* `upsertIntent` keeps ONE local payments row per booking reference, so every
* invoice the booking was ever charged on carries the same `paymentId`. A
* capture from a lapsed first attempt must not reach back past the invoice the
* customer actually paid and settle the older EXPIRED one — that would mark two
* invoices paid off a single payment.
*/
it("no-ops when the booking's newest invoice is already PAID", async () => {
const { service, mg, events } = serviceFor({
id: "inv-2",
status: Freight.InvoiceStatus.Paid,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
});
expect(await service.settleByPaymentId("pay-1")).toBeNull();
expect(mg.update).not.toHaveBeenCalled();
expect(events.emitAsync).not.toHaveBeenCalled();
});
it.each([
["CANCELLED", Freight.InvoiceStatus.Cancelled],
["REFUNDED", Freight.InvoiceStatus.Refunded],
])("does not settle a %s invoice — that is a refund case", async (
_label,
status,
) => {
const { service, mg, events } = serviceFor({
id: "inv-1",
status,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
});
expect(await service.settleByPaymentId("pay-1")).toBeNull();
expect(mg.update).not.toHaveBeenCalled();
expect(events.emitAsync).not.toHaveBeenCalled();
});
});
describe("BillingService.recordPayment", () => {
function serviceFor(invoice: Record<string, unknown> | null) {
const mg = {

View File

@@ -1173,6 +1173,25 @@ export class BillingService {
* `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain
* to advance on. Idempotent — no-op when no open invoice is linked (already
* settled, or settled inline by {@link payInvoice}).
*
* EXPIRED is settleable HERE and only here: this is the gateway path, so the
* money is already captured and we are recording a fait accompli. A success can
* land after the pay window plus its drain tail (relay backlog, payment-api
* restart, a CBE bill paid at a counter) — matching only `OPEN_STATUSES` used to
* drop it silently, leaving a debited customer with an EXPIRED invoice and no
* alert. The manual/offline path ({@link recordPayment}) keeps its EXPIRED guard:
* a teller must not accept cash against a lapsed invoice.
*
* The status is checked on the RESOLVED invoice, never inside the lookup.
* `paymentId` is freight's local intent projection, and `upsertIntent` keeps ONE
* row per booking reference across every pay attempt — so a booking that was
* re-invoiced after a lapsed attempt has SEVERAL invoices carrying the same
* `paymentId`. Filtering by status inside the query would let a late capture from
* attempt 1 skip past the already-PAID attempt-2 invoice and settle the older
* EXPIRED one, marking two invoices paid off a single capture. Resolving the
* newest invoice first and then asking whether IT is settleable makes the answer
* "this booking's money is already recorded" instead. CANCELLED/REFUNDED are
* refund cases, not settlements, and are logged rather than settled.
*/
async settleByPaymentId(
paymentId: string,
@@ -1180,11 +1199,31 @@ export class BillingService {
paidAt?: Date,
): Promise<Invoice | null> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { paymentId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
where: { paymentId },
// NULLS LAST: a DRAFT invoice has no issuedAt and Postgres sorts NULLs
// first on DESC, which would hand back an unissued invoice.
order: { issuedAt: { direction: "DESC", nulls: "LAST" } },
});
if (!invoice) return null;
const settleable: Freight.InvoiceStatus[] = [
...OPEN_STATUSES,
Freight.InvoiceStatus.Expired,
];
if (!settleable.includes(invoice.status)) {
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
// inline by payInvoice). Anything else means money was captured with
// nowhere to land — that needs a person, so say so loudly.
if (invoice.status !== Freight.InvoiceStatus.Paid) {
this.logger.error(
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` +
`(${invoice.id}) is ${invoice.status} — nothing settled. The capture ` +
`needs a refund or a manual settlement.`,
);
}
return null;
}
return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, {
providerTxnId,
paidAt,

View File

@@ -102,6 +102,10 @@ export class BookingInvoiceService {
);
switch (payload.type) {
case "PREPAID":
// Before advancing: if this invoice belonged to a partial offer that
// lapsed before the settlement landed, revive it, or the booking boards
// whole having paid only the offered part.
await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId);
await this.advanceBookingOnPayment(payload.sourceId);
break;
default:
@@ -164,15 +168,24 @@ export class BookingInvoiceService {
// may have moved on or been terminated between invoicing and settlement.
// Only advance one that is still awaiting payment: no-op when already PAID,
// and refuse to advance a booking in a terminal/advanced status
// (CANCELLED/REJECTED/EXPIRED or already past the payment gate) so we never
// rewrite its status or re-run allocation.
// (CANCELLED/REJECTED or already past the payment gate) so we never rewrite
// its status or re-run allocation.
//
// EXPIRED is NOT in that list: settlement is async, so a payment can land
// after the pay window and its drain tail (relay backlog, payment-api
// restart, a CBE bill paid at a counter). The money was captured, so it gets
// exactly the same treatment as an in-window payment — the booking becomes
// PAID and ensurePaidBookingAllocated re-places it via
// replaceStrandedPaidBooking (a same-day train with room, or a manual-assign
// log). Leaving EXPIRED here debited the customer for nothing. CANCELLED and
// REJECTED stay: a person terminated those, so a payment against them is a
// refund case, not a boarding.
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
return;
}
const TERMINAL_OR_ADVANCED_STATUSES: string[] = [
"CANCELLED",
"REJECTED",
"EXPIRED",
"IN_TRANSIT",
"ARRIVED",
"COMPLETED",

View File

@@ -927,13 +927,6 @@ export class BookingTransitionService {
"OPERATION_CHANGES_REQUESTED",
]);
// A company sitting on another unpaid hold commits nothing new — this is
// the moment export capacity locks, so the lock applies here too.
// Government bookings allocate without paying and are exempt.
if (!booking.isGovernment) {
await this.bookingsService.assertNoUnpaidHold(booking.companyId);
}
// A bare initiated instance (clearance-first flow) carries no cargo or
// price — it must go through the contract completion endpoint, which
// persists cargo, prices, invoices and only then lands here itself.

View File

@@ -1396,16 +1396,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/** Open unpaid holds (wagons reserved, pay window running) for a company. */
countUnpaidHoldsForCompany(companyId: string): Promise<number> {
return this.repository.count({
where: {
companyId,
status: In(['SELECTED_FOR_BATCH', 'AWAITING_PAYMENT']),
},
});
}
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository

View File

@@ -28,6 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -239,6 +240,10 @@ export class BookingsService {
*/
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
// The sheet attests that EDR has taken custody. For export that happens at
// cargo receipt (GRN), so the GRN is required even when wagons are already
// allocated — an allocation is a plan, not possession.
await assertExportReceivedWithGrn(this.dataSource, booking);
let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
COALESCE(wt.code, wt.name) AS "wagonType",
@@ -291,7 +296,10 @@ export class BookingsService {
LEFT JOIN freight.containers c
ON c.id = inv.container_id AND c.deleted_at IS NULL
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
AND COALESCE(NULLIF(TRIM(inv.grn_number), ''), '') <> ''
AND COALESCE(
NULLIF(TRIM(inv.grn_number), ''),
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) IS NOT NULL
ORDER BY inv.created_at`,
[bookingId],
)
@@ -909,24 +917,6 @@ export class BookingsService {
return result.booking;
}
/**
* A company with an open unpaid hold (SELECTED_FOR_BATCH — wagons reserved,
* pay window running) may not take more capacity until it pays or the hold
* dies: otherwise one customer can lock a train's wagons over and over
* without ever paying. EXPIRED / CANCELLED holds free the lock.
*/
async assertNoUnpaidHold(companyId?: string | null): Promise<void> {
if (!companyId) return;
const holds =
await this.bookingsRepository.countUnpaidHoldsForCompany(companyId);
if (holds > 0) {
throw new ConflictException(
'You already have a booking waiting for payment. Pay it or cancel it ' +
'before making a new booking.',
);
}
}
/** Create a new freight booking. */
async create(
dto: CreateBookingDto,
@@ -989,10 +979,6 @@ export class BookingsService {
companyId = company.id;
}
// Government bookings allocate without paying, so the unpaid-hold lock
// only applies to commercial companies.
if (!isGovernment) await this.assertNoUnpaidHold(companyId);
if (dto.trainScheduleId) {
// Staff manual pin: the schedule must be OPEN and on the same route.
const schedule = await this.dataSource

View File

@@ -0,0 +1,66 @@
import {
CONTRACT_TEMPLATE_CODES,
contractTemplateCodeFor,
} from './entities/contract-template.entity';
import { CONTRACT_TEMPLATE_DEFAULTS } from '../../seed/data/contract-template-defaults';
describe('contractTemplateCodeFor', () => {
it('splits import and export by the customs flag', () => {
expect(contractTemplateCodeFor('IMPORT', 'BULK', true)).toBe('IMPORT_BULK_CUSTOMS');
expect(contractTemplateCodeFor('IMPORT', 'BULK', false)).toBe('IMPORT_BULK_NO_CUSTOMS');
expect(contractTemplateCodeFor('EXPORT', 'CONTAINER', true)).toBe(
'EXPORT_CONTAINER_CUSTOMS',
);
expect(contractTemplateCodeFor('EXPORT', 'CONTAINER', false)).toBe(
'EXPORT_CONTAINER_NO_CUSTOMS',
);
});
it('never gives intercity a customs variant — it crosses no border', () => {
for (const flag of [true, false, null, undefined]) {
expect(contractTemplateCodeFor('DOMESTIC', 'BULK', flag)).toBe('INTERCITY_BULK');
expect(contractTemplateCodeFor('DOMESTIC', 'CONTAINER', flag)).toBe(
'INTERCITY_CONTAINER',
);
}
});
it('treats a missing customs flag as no customs on cross-border contracts', () => {
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', null)).toBe(
'IMPORT_CONTAINER_NO_CUSTOMS',
);
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', undefined)).toBe(
'IMPORT_CONTAINER_NO_CUSTOMS',
);
});
it('only ever resolves to a code that exists', () => {
const directions = ['IMPORT', 'EXPORT', 'DOMESTIC', null];
const freights = ['BULK', 'CONTAINER', 'BREAK_BULK', null];
for (const d of directions) {
for (const f of freights) {
for (const c of [true, false]) {
expect(CONTRACT_TEMPLATE_CODES).toContain(contractTemplateCodeFor(d, f, c));
}
}
}
});
});
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
it('seeds exactly the ten declared codes, once each', () => {
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
expect(seeded).toHaveLength(10);
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
});
it('gives every _CUSTOMS template the customs articles and no other one', () => {
for (const seed of CONTRACT_TEMPLATE_DEFAULTS) {
const hasCustomsArticle = seed.articles.some((a) => a.id === 'customs-clearing');
// Note "_NO_CUSTOMS" also ends with "_CUSTOMS" — exclude it explicitly.
const isCustomsVariant =
seed.code.endsWith('_CUSTOMS') && !seed.code.endsWith('_NO_CUSTOMS');
expect(hasCustomsArticle).toBe(isCustomsVariant);
}
});
});

View File

@@ -25,11 +25,19 @@ function seededTemplate(code: string): ContractTemplate {
}
describe("contractTemplateCodeFor", () => {
it("maps every direction/freight pair to one of the six codes", () => {
expect(contractTemplateCodeFor("IMPORT", "BULK")).toBe("IMPORT_BULK");
expect(contractTemplateCodeFor("EXPORT", "CONTAINER")).toBe("EXPORT_CONTAINER");
expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER")).toBe("INTERCITY_CONTAINER");
expect(contractTemplateCodeFor("DOMESTIC", "BULK")).toBe("INTERCITY_BULK");
it("maps every direction/freight/customs triple to one of the ten codes", () => {
expect(contractTemplateCodeFor("IMPORT", "BULK", true)).toBe("IMPORT_BULK_CUSTOMS");
expect(contractTemplateCodeFor("IMPORT", "BULK", false)).toBe(
"IMPORT_BULK_NO_CUSTOMS",
);
expect(contractTemplateCodeFor("EXPORT", "CONTAINER", true)).toBe(
"EXPORT_CONTAINER_CUSTOMS",
);
// Intercity is domestic — no border, so no customs variant either way.
expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER", true)).toBe(
"INTERCITY_CONTAINER",
);
expect(contractTemplateCodeFor("DOMESTIC", "BULK", false)).toBe("INTERCITY_BULK");
expect(contractTemplateCodeFor(null, null)).toBe("INTERCITY_CONTAINER");
});
});
@@ -60,7 +68,7 @@ describe("ContractTemplatesService.preview", () => {
);
it("interpolates {{contractYear}} inside seeded article bodies", async () => {
const { html } = await service.preview("IMPORT_BULK");
const { html } = await service.preview("IMPORT_BULK_CUSTOMS");
expect(html).toContain(`August 31, ${new Date().getFullYear()}`);
});
});

View File

@@ -24,13 +24,22 @@ import {
contractTemplateCodeFor,
} from "./entities/contract-template.entity";
/** Registry keys used to derive labels for the mock preview per template code. */
/**
* Registry keys used to derive labels for the mock preview per template code.
* The registry's FORWARDING scope carries the customs/clearing clause pack, so
* the `_CUSTOMS` codes preview against it and `_NO_CUSTOMS` against
* TRANSPORT_ONLY.
*/
const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
IMPORT_BULK: "IMP_BULK_USD_FORWARDING",
EXPORT_BULK: "EXP_BULK_USD_TRANSPORT_ONLY",
IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING",
IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY",
EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING",
EXPORT_BULK_NO_CUSTOMS: "EXP_BULK_USD_TRANSPORT_ONLY",
INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY",
IMPORT_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY",
EXPORT_CONTAINER: "EXP_CON_USD_FORWARDING",
IMPORT_CONTAINER_CUSTOMS: "IMP_CON_USD_FORWARDING",
IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY",
EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING",
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
};
@@ -59,14 +68,19 @@ export class ContractTemplatesService {
/**
* The active template used when generating a contract document for the given
* direction/freight pair; null when missing or deactivated (the renderer then
* falls back to the built-in generic layout).
* direction/freight/customs triple; null when missing or deactivated (the
* renderer then falls back to the built-in generic layout).
*/
async findActiveForContract(
tradeDirection?: string | null,
freightType?: string | null,
customsClearingEnabled?: boolean | null,
): Promise<ContractTemplate | null> {
const code = contractTemplateCodeFor(tradeDirection, freightType);
const code = contractTemplateCodeFor(
tradeDirection,
freightType,
customsClearingEnabled,
);
const template = await this.repository.findByCode(code);
return template?.isActive ? template : null;
}

View File

@@ -2,17 +2,30 @@ import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index } from "typeorm";
/**
* The six canonical contract document templates, one per
* (trade direction × freight type) combination. Contracts store DOMESTIC for
* intercity movements; the template layer labels those INTERCITY to match the
* commercial vocabulary used on the printed documents.
* The ten canonical contract document templates. Import and export split by
* customs clearing (× freight type = 8); intercity does not, because it is a
* purely domestic Ethiopian movement that crosses no border and therefore has
* no customs leg at all (× freight type = 2).
*
* Contracts store DOMESTIC for intercity movements; the template layer labels
* those INTERCITY to match the commercial vocabulary used on the printed
* documents.
*
* The `_CUSTOMS` variant is issued when the contract has customs clearing
* enabled (the Service Provider clears in Djibouti/Ethiopia on the Client's
* behalf); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
* its own declarations.
*/
export const CONTRACT_TEMPLATE_CODES = [
"IMPORT_BULK",
"EXPORT_BULK",
"IMPORT_BULK_CUSTOMS",
"IMPORT_BULK_NO_CUSTOMS",
"EXPORT_BULK_CUSTOMS",
"EXPORT_BULK_NO_CUSTOMS",
"INTERCITY_BULK",
"IMPORT_CONTAINER",
"EXPORT_CONTAINER",
"IMPORT_CONTAINER_CUSTOMS",
"IMPORT_CONTAINER_NO_CUSTOMS",
"EXPORT_CONTAINER_CUSTOMS",
"EXPORT_CONTAINER_NO_CUSTOMS",
"INTERCITY_CONTAINER",
] as const;
@@ -33,10 +46,19 @@ export interface ContractTemplateArticle {
order: number;
}
/** Map a contract's stored direction/freight pair onto a template code. */
/**
* Map a contract's stored direction/freight/customs triple onto a template
* code. `customsClearingEnabled` is treated as false when absent so an older
* contract row with a null flag still resolves to a real template rather than
* falling through to the generic layout.
*
* Intercity is domestic and has no customs leg, so it resolves to a single
* unsuffixed code regardless of the flag.
*/
export function contractTemplateCodeFor(
tradeDirection?: string | null,
freightType?: string | null,
customsClearingEnabled?: boolean | null,
): ContractTemplateCode {
const direction =
tradeDirection === "IMPORT"
@@ -46,7 +68,11 @@ export function contractTemplateCodeFor(
: "INTERCITY";
const freight =
(freightType ?? "").toUpperCase().includes("BULK") ? "BULK" : "CONTAINER";
return `${direction}_${freight}` as ContractTemplateCode;
if (direction === "INTERCITY") {
return `INTERCITY_${freight}` as ContractTemplateCode;
}
const customs = customsClearingEnabled ? "CUSTOMS" : "NO_CUSTOMS";
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
}
@Entity({ schema: "freight", name: "contract_templates" })

View File

@@ -109,23 +109,6 @@ export class ContractBookingService {
private readonly bookingTransitionService: BookingTransitionService,
) {}
/**
* Mirrors BookingsService.assertNoUnpaidHold for the contract booking paths:
* a company sitting on an unpaid hold (SELECTED_FOR_BATCH) books nothing new
* until it pays or the hold dies.
*/
private async assertNoUnpaidHold(companyId?: string | null): Promise<void> {
if (!companyId) return;
const holds =
await this.bookingsRepository.countUnpaidHoldsForCompany(companyId);
if (holds > 0) {
throw new ConflictException(
'You already have a booking waiting for payment. Pay it or cancel it ' +
'before making a new booking.',
);
}
}
async createUnderContract(
contractId: string,
dto: CreateBookingUnderContractDto,
@@ -186,8 +169,6 @@ export class ContractBookingService {
// remainder; the customer cannot start any other booking on the contract.
// If the remainder splits again the same rule repeats until the cap is
// exhausted and the contract completes.
await this.assertNoUnpaidHold(contract.companyId);
if (contract.contractKind === 'ONE_TIME') {
if (await this.hasSplitBooking(contractId)) {
await this.assertExactRemainder(contract, dto);
@@ -475,7 +456,6 @@ export class ContractBookingService {
);
}
}
await this.assertNoUnpaidHold(contract.companyId);
const route = await this.resolveRoute(contract, dto.contractRouteId);

View File

@@ -423,6 +423,7 @@ export class ContractTransitionService {
const active = await this.contractTemplates.findActiveForContract(
contract.tradeDirection,
contract.freightType,
contract.customsClearingEnabled,
);
if (!active) return null;
return {

View File

@@ -169,6 +169,11 @@ export class CreateEmptyContainerReturnDto {
@IsOptional()
@IsString()
performedBy?: string;
@ApiPropertyOptional({ enum: ['EDR', 'CUSTOMER'] })
@IsOptional()
@IsIn(['EDR', 'CUSTOMER'])
returnedBy?: 'EDR' | 'CUSTOMER';
}
export class UpdateEmptyContainerReturnStatusDto extends ImportOperationActionDto {

View File

@@ -53,4 +53,14 @@ export class EmptyContainerReturn extends BaseEntity {
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
performedBy?: string | null;
@Column({ name: 'returned_by', type: 'varchar', length: 20, nullable: true })
returnedBy?: 'EDR' | 'CUSTOMER' | null;
@Column({ name: 'status_history', type: 'jsonb', default: () => "'[]'" })
statusHistory!: Array<{
status: EmptyContainerReturnStatus;
changedAt: string;
performedBy: string | null;
}>;
}

View File

@@ -149,18 +149,23 @@ export class ImportOperationsService {
}
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
return this.emptyReturns.save(
this.emptyReturns.create({
containerNumber: dto.containerNumber,
bookingId: dto.bookingId ?? null,
customerId: dto.customerId ?? null,
returnDate: dto.returnDate ? new Date(dto.returnDate) : new Date(),
returnDate,
facility: dto.facility ?? null,
yard: dto.yard ?? null,
zone: dto.zone ?? null,
condition: dto.condition ?? null,
handoverNote: dto.handoverNote ?? null,
performedBy: dto.performedBy ?? null,
returnedBy: dto.returnedBy ?? null,
statusHistory: [
{ status: 'RETURNED', changedAt: returnDate.toISOString(), performedBy: dto.performedBy ?? null },
],
}),
);
}
@@ -175,6 +180,10 @@ export class ImportOperationsService {
wagonAllocationReference: dto.wagonAllocationReference ?? row.wagonAllocationReference ?? null,
handoverNote: dto.handoverNote ?? row.handoverNote ?? null,
performedBy: dto.performedBy ?? row.performedBy ?? null,
statusHistory: [
...(row.statusHistory ?? []),
{ status: dto.status, changedAt: new Date().toISOString(), performedBy: dto.performedBy ?? row.performedBy ?? null },
],
});
return this.emptyReturns.findOneOrFail({ where: { id } });
}

View File

@@ -7,11 +7,51 @@
* (BookingWindowService) drives all timing off that config.
*/
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
export const BATCH_TIMEZONE = "Africa/Addis_Ababa";
/** How long before the pay deadline the one reminder notification goes out. */
export const PAYMENT_REMINDER_LEAD_MS = 10 * 60_000;
/** Drain tail applied to every pay window when FREIGHT_PAYMENT_DRAIN_MINUTES is unset. */
export const DEFAULT_PAYMENT_DRAIN_MINUTES = 7;
/**
* Drain tail on every pay window, in ms. Read per call so the env var can be
* changed without a rebuild (and so tests can set it).
*/
export function paymentDrainMs(): number {
// An empty/blank value is UNSET, not zero — a bare `FREIGHT_PAYMENT_DRAIN_MINUTES=`
// left in a .env must not silently disable the drain (Number('') is 0).
const raw = process.env.FREIGHT_PAYMENT_DRAIN_MINUTES?.trim();
const minutes = raw ? Number(raw) : NaN;
return (
(Number.isFinite(minutes) && minutes >= 0
? minutes
: DEFAULT_PAYMENT_DRAIN_MINUTES) * 60_000
);
}
/**
* A pay window AND its drain tail have closed.
*
* Settlement is asynchronous (provider confirm → payment-api → outbox relay), so
* a payment made in the last seconds of the window lands after `paymentDeadline`.
* The drain defers the WHOLE expiry pipeline — wagons stay held, the waiting list
* is not promoted, the window cycle does not conclude — so that settlement still
* has a live booking to land on. A payment that arrives even later is not lost
* either: it settles the expired invoice and revives the booking (see
* BillingService.settleByPaymentId + BookingInvoiceService.advanceBookingOnPayment).
*
* No deadline ⇒ never lapsed; callers decide what an unknown deadline means.
*/
export function payWindowLapsed(
deadline: Date | null | undefined,
now: number,
drainMs: number = paymentDrainMs(),
): boolean {
return deadline != null && deadline.getTime() + drainMs <= now;
}
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
export const DEFAULT_WAGONS_PER_BOOKING = 1;

View File

@@ -1,7 +1,14 @@
import { BookingBatchService } from './booking-batch.service';
import { paymentDrainMs } from './booking-batch.constants';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonStockLedger } from './wagon-stock-ledger.util';
/**
* A pay window closed long enough ago to be past its drain tail as well — i.e.
* genuinely expirable. Inside the tail nothing expires (see payWindowLapsed).
*/
const fullyLapsedDeadline = () => new Date(Date.now() - 60_000 - paymentDrainMs());
describe('BookingBatchService — PAID reconcile', () => {
const scheduleId = 'schedule-1';
const bookingId = 'booking-1';
@@ -856,7 +863,7 @@ describe('BookingBatchService — PAID reconcile', () => {
// One reservation whose pay window lapsed, and one booking on the waiting list.
const lapsed = booking('lapsed', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: new Date(Date.now() - 60_000),
paymentDeadline: fullyLapsedDeadline(),
});
const waiting = booking('waiting', 10, { trainScheduleId: null });
@@ -888,10 +895,41 @@ describe('BookingBatchService — PAID reconcile', () => {
expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting');
});
it('holds a reservation whose deadline passed but whose drain tail has not', async () => {
// Settlement is asynchronous, so a payment made in the window's last
// seconds lands after the deadline. Expiring here would free the wagons
// out from under it — the swallowed-payment finding.
const draining = booking('draining', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: new Date(Date.now() - 60_000),
});
const waiting = booking('waiting', 10, { trainScheduleId: null });
bookingsRepository.findReservedForSchedule
.mockResolvedValueOnce([draining])
.mockResolvedValue([]);
bookingsRepository.findBatchPoolByCorridorDay
.mockResolvedValueOnce([waiting])
.mockResolvedValue([]);
const byId: Record<string, Booking> = { draining, waiting };
dataSource
.getRepository()
.findOne.mockImplementation(
async (opts: { where?: { id?: string } }) =>
byId[opts?.where?.id ?? ''] ?? null,
);
await service.settleDueReservations(trainId);
expect(notifier.expired).not.toHaveBeenCalled();
// ...and its wagons were NOT handed to the waiting list either.
expect(notifier.payNow).not.toHaveBeenCalled();
});
it('serialises concurrent settles so the same reservation is not settled twice', async () => {
const lapsed = booking('lapsed', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: new Date(Date.now() - 60_000),
paymentDeadline: fullyLapsedDeadline(),
});
// Both callers read the reservation; the lock must stop the second from
// acting on rows the first already expired. (The PAYMENT transition and the
@@ -922,7 +960,7 @@ describe('BookingBatchService — PAID reconcile', () => {
it('never expires a reservation whose payment landed — allocates it instead', async () => {
const latePaid = booking('late-paid', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: new Date(Date.now() - 60_000),
paymentDeadline: fullyLapsedDeadline(),
});
bookingsRepository.findReservedForSchedule
.mockResolvedValueOnce([latePaid])
@@ -1036,7 +1074,7 @@ describe('BookingBatchService — PAID reconcile', () => {
...(waiting as unknown as Record<string, unknown>),
status: 'SELECTED_FOR_BATCH',
trainScheduleId: exportScheduleId,
paymentDeadline: new Date(Date.now() - 60_000),
paymentDeadline: fullyLapsedDeadline(),
originYardId: 'yard-a',
destinationYardId: 'yard-b',
priorityScore: 0,

View File

@@ -64,6 +64,8 @@ import {
DEFAULT_CONTAINER_WAGON_TARE_TONS,
DEFAULT_WAGONS_PER_BOOKING,
PAYMENT_REMINDER_LEAD_MS,
payWindowLapsed,
paymentDrainMs,
} from "./booking-batch.constants";
import {
LocomotiveLimits,
@@ -701,6 +703,15 @@ export class BookingBatchService implements OnModuleInit {
await this.ensurePaidBookingAllocated(bookingId);
}
/**
* A late settlement paid a partial offer whose window had lapsed — bring the
* offer back so `ensurePaidBookingAllocated`'s applySplit still reduces the
* booking to what was actually bought. No-op without the split feature.
*/
async reviveOfferForInvoice(invoiceId: string): Promise<void> {
await this.splitService?.reviveOfferForInvoice(invoiceId);
}
/**
* Day-level backstop for stranded PAID bookings: reconcilePaidUnlinked is
* keyed on train_schedule_id, so a booking whose hold was expired (schedule
@@ -2745,11 +2756,13 @@ export class BookingBatchService implements OnModuleInit {
const isPaid = (b: Booking) =>
b.paymentStatus === "PAID" || b.status === "PAID";
// Deadline is the line — no fixed slack. A payment that beat the deadline
// but whose webhook is late is caught by expire()'s gateway reconcile.
// The deadline carries a drain tail (payWindowLapsed): settlement is async,
// so a payment made in the window's last seconds lands after it. Nothing is
// expired until the tail passes. expire()'s gateway reconcile is the second
// line of defence, not the first.
const isExpired = (b: Booking) =>
b.paymentDeadline
? b.paymentDeadline.getTime() <= now
? payWindowLapsed(b.paymentDeadline, now)
: expireUnpaidUnknownDeadline;
for (const booking of reserved) {
@@ -4748,11 +4761,12 @@ export class BookingBatchService implements OnModuleInit {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
// Lazy-expiry guard: a hold whose deadline lapsed no longer blocks
// capacity, even before the 10s sweep flips it to EXPIRED — availability
// shown to the next customer is honest between ticks. A late capture the
// gateway reconcile later confirms lands as PAID and, if the wagons went
// meanwhile, degrades to WAITING_FOR_WAGON for manual placement.
// Lazy-expiry guard: a hold whose deadline AND drain tail lapsed no longer
// blocks capacity, even before the 10s sweep flips it to EXPIRED —
// availability shown to the next customer is honest between ticks. The drain
// has to be honoured here too: releasing the wagons at the raw deadline
// would resell them to someone else while the paying customer's settlement
// is still in flight, stranding it into WAITING_FOR_WAGON.
const deadlineCutoff = Date.now();
const reserved = (
await this.bookingsRepository.findReservedForSchedule(schedule.id)
@@ -4760,8 +4774,7 @@ export class BookingBatchService implements OnModuleInit {
(b) =>
b.paymentStatus === "PAID" ||
b.status === "PAID" ||
b.paymentDeadline == null ||
b.paymentDeadline.getTime() > deadlineCutoff,
!payWindowLapsed(b.paymentDeadline, deadlineCutoff),
);
for (const b of [...allocated, ...reserved]) {
budget.subtract(
@@ -4836,7 +4849,9 @@ export class BookingBatchService implements OnModuleInit {
}
/**
* A reservation on this schedule still has time left to pay.
* A reservation on this schedule still has time left to pay — including its
* drain tail, so the cycle cannot conclude out from under a settlement that is
* still in flight.
*
* The PAYMENT phase ends a hair BEFORE its own reservations do: `paymentPhaseEndsAt`
* is stamped when the phase starts, then `reserve()` gives each booking
@@ -4857,7 +4872,7 @@ export class BookingBatchService implements OnModuleInit {
b.paymentStatus !== "PAID" &&
b.status !== "PAID" &&
b.paymentDeadline != null &&
b.paymentDeadline.getTime() > now,
!payWindowLapsed(b.paymentDeadline, now),
);
}
@@ -5066,7 +5081,10 @@ export class BookingBatchService implements OnModuleInit {
*/
private armSettle(scheduleId: string): void {
void this.scheduleById(scheduleId)
// + drain tail: firing at the raw deadline is a guaranteed no-op pass now
// that nothing expires until the tail passes.
.then((schedule) => this.paymentWindowMsFor(schedule))
.then((windowMs: number) => windowMs + paymentDrainMs())
.then((delayMs: number) => {
this.removeTimeout(scheduleId);
const handle = setTimeout(() => {

View File

@@ -302,6 +302,19 @@ export class BookingSplitService {
.update({ bookingId, status: 'OFFERED' }, { status: 'EXPIRED' });
}
/**
* A late gateway settlement paid the invoice of an offer whose window had
* already lapsed. The customer bought the offered part, so the offer is live
* again and {@link applySplit} must reduce the booking to it — otherwise the
* booking boards WHOLE having paid only the offered portion. Keyed on the paid
* invoice, never on the booking: an offer that simply timed out stays dead.
*/
async reviveOfferForInvoice(invoiceId: string): Promise<void> {
await this.dataSource
.getRepository(BookingBatchOffer)
.update({ invoiceId, status: 'EXPIRED' }, { status: 'OFFERED' });
}
async findOpenOffer(bookingId: string): Promise<BookingBatchOffer | null> {
return this.dataSource.getRepository(BookingBatchOffer).findOne({
where: { bookingId, status: 'OFFERED' },

View File

@@ -0,0 +1,67 @@
import {
DEFAULT_PAYMENT_DRAIN_MINUTES,
paymentDrainMs,
payWindowLapsed,
} from "./booking-batch.constants";
/**
* The drain tail is what keeps a pay window's LAST payment from being thrown
* away: settlement is asynchronous (provider confirm → payment-api → outbox
* relay), so a payment made in the window's final seconds lands after
* `paymentDeadline`. Nothing may expire, no wagons may be resold and no window
* cycle may conclude until the tail has passed.
*/
describe("payWindowLapsed — pay-window drain tail", () => {
const deadline = new Date("2026-08-02T22:04:41Z");
const at = (offsetMs: number) => deadline.getTime() + offsetMs;
const MIN = 60_000;
afterEach(() => {
delete process.env.FREIGHT_PAYMENT_DRAIN_MINUTES;
});
it("is not lapsed before the deadline", () => {
expect(payWindowLapsed(deadline, at(-1 * MIN))).toBe(false);
});
it("is not lapsed inside the drain tail", () => {
// The reproduced finding: settled ~7 minutes late. With the default 5-minute
// tail the booking is still live at 4 minutes.
expect(payWindowLapsed(deadline, at(4 * MIN))).toBe(false);
expect(payWindowLapsed(deadline, at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN - 1))).toBe(
false,
);
});
it("is lapsed once the tail passes", () => {
expect(payWindowLapsed(deadline, at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN))).toBe(
true,
);
expect(payWindowLapsed(deadline, at(7 * MIN))).toBe(true);
});
it("never lapses without a deadline — callers decide what unknown means", () => {
expect(payWindowLapsed(null, at(60 * MIN))).toBe(false);
expect(payWindowLapsed(undefined, at(60 * MIN))).toBe(false);
});
it("honours FREIGHT_PAYMENT_DRAIN_MINUTES", () => {
process.env.FREIGHT_PAYMENT_DRAIN_MINUTES = "20";
expect(paymentDrainMs()).toBe(20 * MIN);
expect(payWindowLapsed(deadline, at(10 * MIN))).toBe(false);
expect(payWindowLapsed(deadline, at(20 * MIN))).toBe(true);
});
it("allows an explicit zero drain (old deadline-is-the-line behaviour)", () => {
process.env.FREIGHT_PAYMENT_DRAIN_MINUTES = "0";
expect(paymentDrainMs()).toBe(0);
expect(payWindowLapsed(deadline, at(0))).toBe(true);
});
it("falls back to the default on garbage or negative values", () => {
for (const bad of ["", "abc", "-3"]) {
process.env.FREIGHT_PAYMENT_DRAIN_MINUTES = bad;
expect(paymentDrainMs()).toBe(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN);
}
});
});

View File

@@ -4,7 +4,7 @@ import type {
} from "../../modules/contract-templates/entities/contract-template.entity";
/**
* Default article packs for the six contract templates, transcribed from the
* Default article packs for the ten contract templates, transcribed from the
* signed EDR contract documents (test/contrat_docs). Article bodies use the
* dynamic-article text format: one clause per line, "- " prefix for bullets
* nested under the previous clause, single-line body = plain paragraph.
@@ -20,6 +20,13 @@ export interface ContractTemplateSeed {
articles: Array<Omit<ContractTemplateArticle, "order">>;
}
/**
* A base pack keyed by direction/freight only. Each one is transcribed from a
* signed EDR contract and is split at the bottom of this file into the
* `_CUSTOMS` / `_NO_CUSTOMS` pair the template table actually stores.
*/
type ContractTemplateBase = Omit<ContractTemplateSeed, "code">;
const a = (id: string, title: string, body: string): Omit<ContractTemplateArticle, "order"> => ({
id,
title,
@@ -28,8 +35,7 @@ const a = (id: string, title: string, body: string): Omit<ContractTemplateArticl
/* ────────────────────────────── IMPORT / BULK ────────────────────────────── */
const IMPORT_BULK: ContractTemplateSeed = {
code: "IMPORT_BULK",
const IMPORT_BULK_BASE: ContractTemplateBase = {
name: "Bulk Import Contract",
description:
"Import of bulk cargo (e.g. steel billets) from Djibouti (DMP/Nagad) to Galaan Multipurpose Port with customs clearance and optional last-mile delivery.",
@@ -158,8 +164,7 @@ The governing law shall be the laws of the Federal Democratic Republic of Ethiop
/* ────────────────────────────── EXPORT / BULK ────────────────────────────── */
const EXPORT_BULK: ContractTemplateSeed = {
code: "EXPORT_BULK",
const EXPORT_BULK_BASE: ContractTemplateBase = {
name: "Bulk Export Contract",
description:
"Export of bulk cargo (e.g. livestock) by railway from Ethiopian loading stations to Nagad railway freight yard, Djibouti.",
@@ -294,8 +299,7 @@ The governing law shall be the laws of the Federal Democratic Republic of Ethiop
/* ──────────────────────────── INTERCITY / BULK ───────────────────────────── */
const INTERCITY_BULK: ContractTemplateSeed = {
code: "INTERCITY_BULK",
const INTERCITY_BULK_BASE: ContractTemplateBase = {
name: "Bulk Intercity Contract",
description:
"Domestic (intercity) bulk cargo transportation by railway between Ethiopian freight yards, e.g. Dire Dawa to Sebeta.",
@@ -418,8 +422,7 @@ The governing law shall be the laws of the Federal Democratic Republic of Ethiop
/* ──────────────────────────── IMPORT / CONTAINER ─────────────────────────── */
const IMPORT_CONTAINER: ContractTemplateSeed = {
code: "IMPORT_CONTAINER",
const IMPORT_CONTAINER_BASE: ContractTemplateBase = {
name: "Container Import Contract",
description:
"Import container transport by railway from SGTD (Djibouti) to Dire Dawa, Modjo dry port, or Galaan Multipurpose Port, with empty-container return.",
@@ -568,8 +571,7 @@ If unresolved, disputes shall be taken to the Federal Court in Addis Ababa.`,
/* ──────────────────────────── EXPORT / CONTAINER ─────────────────────────── */
const EXPORT_CONTAINER: ContractTemplateSeed = {
code: "EXPORT_CONTAINER",
const EXPORT_CONTAINER_BASE: ContractTemplateBase = {
name: "Container Export Contract",
description:
"Export container transport, freight forwarding, and customs clearing from Galaan Multipurpose Port or Modjo dry port to SGTD container freight station (Djibouti).",
@@ -717,8 +719,7 @@ The signatories confirm that they are fully authorized to sign and execute this
/* ─────────────────────────── INTERCITY / CONTAINER ───────────────────────── */
const INTERCITY_CONTAINER: ContractTemplateSeed = {
code: "INTERCITY_CONTAINER",
const INTERCITY_CONTAINER_BASE: ContractTemplateBase = {
name: "Container Intercity Contract",
description:
"Domestic (intercity) container transport by railway between Ethiopian terminals — Galaan Multipurpose Port, Modjo dry port, and Dire Dawa — including empty repositioning.",
@@ -854,11 +855,67 @@ If unresolved, disputes shall be taken to the Federal Court in Addis Ababa.`,
],
};
export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [
IMPORT_BULK,
EXPORT_BULK,
INTERCITY_BULK,
IMPORT_CONTAINER,
EXPORT_CONTAINER,
INTERCITY_CONTAINER,
/* ─────────────────────── CUSTOMS / NO-CUSTOMS SPLIT ──────────────────────── */
/**
* Articles appended to the `_CUSTOMS` variant of every base pack. The signed
* source documents fold customs duties into the body prose rather than a
* dedicated article, so these state the clearing obligations explicitly for the
* contracts where EDR clears on the Client's behalf.
*/
const CUSTOMS_ARTICLES: Array<Omit<ContractTemplateArticle, "order">> = [
a(
"customs-clearing",
"Customs Clearing Services",
`The Service Provider shall carry out customs clearing on behalf of the Client for the cargo covered by this Agreement, including declaration, lodgement, and follow-up at the customs stations of Djibouti and Ethiopia as applicable to the agreed corridor.
The Service Provider shall act only within the authority granted by the Client and shall not amend a declaration without the Client's written instruction.
Customs duties, taxes, and any government charges assessed on the cargo remain payable by the Client and are not included in the freight price; the Service Provider shall settle them on the Client's behalf only where the Client has placed the corresponding funds in advance.
The Service Provider shall hand over all customs documents obtained in the course of clearing to the Client upon completion of each shipment.`,
),
a(
"customs-client-duties",
"Client Obligations for Customs Clearing",
`Grant the Service Provider a duly signed and stamped power of attorney authorising it to act as the Client's customs agent for the duration of this Agreement.
Submit every document required for declaration (commercial invoice, packing list, bill of lading or airway bill, permits, certificates of origin, and any authority-specific licence) within one (1) calendar day of the Service Provider's request.
Warrant that the declared description, quantity, value, and tariff classification of the cargo are complete and accurate.
Bear any penalty, demurrage, storage, or re-inspection cost arising from incorrect, incomplete, or late Client-supplied information or documentation.
Settle assessed duties and taxes within the period notified by the Service Provider, failing which the Service Provider may suspend clearing and the cargo shall remain at the Client's risk and cost.`,
),
];
/** Build the stored `_CUSTOMS` / `_NO_CUSTOMS` pair for one base pack. */
function splitByCustoms(
base: ContractTemplateBase,
codeStem: string,
): ContractTemplateSeed[] {
return [
{
...base,
code: `${codeStem}_CUSTOMS` as ContractTemplateCode,
name: `${base.name} (with customs clearing)`,
description: `${base.description} Customs clearing is performed by the Service Provider.`,
articles: [...base.articles, ...CUSTOMS_ARTICLES],
},
{
...base,
code: `${codeStem}_NO_CUSTOMS` as ContractTemplateCode,
name: `${base.name} (without customs clearing)`,
description: `${base.description} Customs clearing is handled by the Client.`,
articles: [...base.articles],
},
];
}
/**
* Ten templates: import and export each split by customs clearing, intercity
* not split at all — it is a domestic Ethiopian movement that crosses no
* border, so there is no customs leg to contract for.
*/
export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [
...splitByCustoms(IMPORT_BULK_BASE, "IMPORT_BULK"),
...splitByCustoms(EXPORT_BULK_BASE, "EXPORT_BULK"),
{ ...INTERCITY_BULK_BASE, code: "INTERCITY_BULK" },
...splitByCustoms(IMPORT_CONTAINER_BASE, "IMPORT_CONTAINER"),
...splitByCustoms(EXPORT_CONTAINER_BASE, "EXPORT_CONTAINER"),
{ ...INTERCITY_CONTAINER_BASE, code: "INTERCITY_CONTAINER" },
];

View File

@@ -435,12 +435,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
icon: <Send />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory?direction=IMPORT",

View File

@@ -1,13 +1,11 @@
import { useMemo, useState } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
import { Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
import { Coins, Truck } from "lucide-react";
import { api } from "@/services/api";
import { warehouseService } from "@/services/warehouse.service";
import { lastMileService } from "@/services/last-mile.service";
import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal";
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
import { groupByBooking, TruckRows } from "@/pages/warehouses/ImportTrucksPage";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
@@ -15,43 +13,14 @@ import { MetricTile } from "./MetricTile";
const money = (amount: number, currency: string) =>
`${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
const fmt = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString() : "—");
function inspectionLabel(status: string | null | undefined): { text: string; color: string } {
if (!status) return { text: "Pending", color: "gray" };
if (status === "PASSED") return { text: "Passed", color: "edr-green" };
if (status === "FAILED") return { text: "Failed", color: "red" };
return { text: status, color: "gray" };
}
interface TruckRow {
key: string;
plate: string;
driver: string | null;
truckType: string | null;
containers: string[];
warehouseArrived: string | null;
warehouseDeparted: string | null;
destinationArrived: string | null;
returned: string | null;
detentionOpen: boolean;
detentionDays: number | null;
detentionAmount: number | null;
hasDetentionRule: boolean;
inspection: { text: string; color: string };
}
/**
* Every truck tied to a booking's last mile — EDR-dispatched or customer
* self-haul (a booking only ever uses one), each with its own warehouse-gate
* and destination-detention clocks, plus the booking's cargo-side cost totals
* (storage/demurrage/double handling — billed per row internally, always
* shown here as one booking-level total). Detention stays EDR-only; customer
* self-haul rows show "—" since EDR only bills detention on its own fleet.
* Cargo costs (booking-level totals) plus the same truck-import block the
* Unloaded Queue's "Import trucks" view uses — Truck Arrival/Leaving, Exit
* paper, Handover, Inspect, Detention times, Warehouse gate times — reused
* as-is so this tab never drifts from that queue's behavior.
*/
export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
const [feeModalOpen, setFeeModalOpen] = useState(false);
const [detentionModalOpen, setDetentionModalOpen] = useState(false);
const inventoryQuery = useQuery(
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
@@ -59,47 +28,27 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
const inventoryItems = inventoryQuery.data ?? [];
const latestInventory = inventoryItems[0] ?? null;
const edrTrucksQuery = useQuery({
queryKey: ["booking-edr-trucks", bookingId],
queryFn: () => warehouseService.getLastMileTrucks(bookingId),
});
const edrTrucks = edrTrucksQuery.data ?? [];
const customerTrucksQuery = useQuery({
queryKey: ["booking-customer-trucks", bookingId],
queryFn: () => warehouseService.getCustomerTrucks(bookingId),
enabled: edrTrucksQuery.isSuccess && edrTrucks.length === 0,
});
const customerTrucks = customerTrucksQuery.data ?? [];
const mode: "EDR" | "CUSTOMER" | "NONE" =
edrTrucks.length > 0 ? "EDR" : customerTrucks.length > 0 ? "CUSTOMER" : "NONE";
const containerItemsQuery = useQuery({
queryKey: ["booking-container-items-for-trucks", bookingId],
queryFn: () => warehouseService.getContainerItems(bookingId),
});
const inspectionByContainer = new Map(
(containerItemsQuery.data ?? []).map((c) => [c.containerNumber, c.inspectionStatus]),
// Same query key as the Unloaded Queue page — shares its cache instead of
// refetching the whole queue when it's already loaded elsewhere.
const unloadedQuery = useQuery(api.warehouses.importUnloadedQueue.queryOptions({}));
const bookingRows = useMemo(
() => (unloadedQuery.data ?? []).filter((row) => row.bookingId === bookingId),
[unloadedQuery.data, bookingId],
);
const lastMileId = edrTrucks[0]?.lastMileId ?? null;
const detentionPreviewQuery = useQuery({
queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId],
queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data),
enabled: Boolean(lastMileId),
});
const detentionPreview = detentionPreviewQuery.data;
const detentionByVehicle = new Map(
(detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]),
);
const lastMileRecordQuery = useQuery({
queryKey: ["last-mile-record-for-trucks-tab", lastMileId],
queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data),
enabled: Boolean(lastMileId),
});
const group = useMemo(() => {
const [existing] = groupByBooking(bookingRows);
return (
existing ?? {
bookingId,
bookingReference: bookingId,
customerName: null,
trainSchedule: null,
status: "NONE",
arrivalTime: null,
rows: [],
}
);
}, [bookingRows, bookingId]);
// Booking-level cost strip: same per-row fee preview the accrual dashboard
// and FeePreviewModal already use, summed across every inventory row on
@@ -114,61 +63,7 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
const sumByType = (type: string) =>
allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0);
const rows: TruckRow[] = useMemo(() => {
if (mode === "EDR") {
return edrTrucks.map((t) => {
const g = detentionByVehicle.get(t.vehicleId);
return {
key: t.vehicleId,
plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
driver: t.driverName,
truckType: t.truckType,
containers: t.containerNumber ? [t.containerNumber] : [],
warehouseArrived: t.arrivedAt,
warehouseDeparted: t.departedAt,
destinationArrived: g?.startDate ?? null,
returned: g?.endIsOpen ? null : g?.endDate ?? null,
detentionOpen: Boolean(g?.endIsOpen),
detentionDays: g?.chargeableDays ?? null,
detentionAmount: g?.amount ?? null,
hasDetentionRule: Boolean(g?.ruleId),
inspection: inspectionLabel(t.containerNumber ? inspectionByContainer.get(t.containerNumber) : undefined),
};
});
}
if (mode === "CUSTOMER") {
return customerTrucks.map((t) => {
const containers = (t.containers ?? []).map((c) => c.containerNumber);
const statuses = new Set(containers.map((cn) => inspectionByContainer.get(cn) ?? null));
const inspection =
containers.length === 0
? inspectionLabel(undefined)
: statuses.size > 1
? { text: "Mixed", color: "yellow" }
: inspectionLabel([...statuses][0]);
return {
key: t.id,
plate: t.plateNumber,
driver: t.driverName,
truckType: t.truckType,
containers,
warehouseArrived: t.arrivedAt ?? null,
warehouseDeparted: t.departedAt ?? null,
destinationArrived: null,
returned: null,
detentionOpen: false,
detentionDays: null,
detentionAmount: null,
hasDetentionRule: false,
inspection,
};
});
}
return [];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, edrTrucks, customerTrucks, inspectionByContainer, detentionByVehicle]);
if (inventoryQuery.isLoading || edrTrucksQuery.isLoading) {
if (inventoryQuery.isLoading || unloadedQuery.isLoading) {
return (
<Center py={60}>
<Group gap={10}>
@@ -201,87 +96,14 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
</SimpleGrid>
</SectionCard>
<SectionCard
icon={Truck}
title="Trucks"
subtitle={
mode === "EDR" ? "EDR Last Mile" : mode === "CUSTOMER" ? "Customer Self-Haul" : undefined
}
accent="grape"
extra={
mode === "EDR" && (
<Button size="xs" variant="light" onClick={() => setDetentionModalOpen(true)}>
Detention times
</Button>
)
}
>
{rows.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
No trucks assigned to this booking's last mile yet.
</Text>
) : (
<Table.ScrollContainer minWidth={1000}>
<Table verticalSpacing="xs" fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Plate</Table.Th>
<Table.Th>Driver</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Container(s)</Table.Th>
<Table.Th>Wh. arrived</Table.Th>
<Table.Th>Wh. departed</Table.Th>
<Table.Th>Dest. arrived</Table.Th>
<Table.Th>Returned</Table.Th>
<Table.Th>Detention</Table.Th>
<Table.Th>Inspection</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={r.key}>
<Table.Td>{r.plate}</Table.Td>
<Table.Td>{r.driver ?? "—"}</Table.Td>
<Table.Td>{r.truckType ?? "—"}</Table.Td>
<Table.Td>{r.containers.length ? r.containers.join(", ") : "—"}</Table.Td>
<Table.Td>{fmt(r.warehouseArrived)}</Table.Td>
<Table.Td>{fmt(r.warehouseDeparted)}</Table.Td>
<Table.Td>{fmt(r.destinationArrived)}</Table.Td>
<Table.Td>
{r.detentionOpen ? (
<Badge size="xs" color="orange" variant="light">
still out
</Badge>
) : (
fmt(r.returned)
)}
</Table.Td>
<Table.Td>
{mode !== "EDR" || r.detentionDays == null ? (
"—"
) : (
<>
{r.detentionDays}d · {money(r.detentionAmount ?? 0, detentionPreview?.currency ?? "USD")}
{!r.hasDetentionRule && (
<Text span size="xs" c="red">
{" "}
· no rule
</Text>
)}
</>
)}
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={r.inspection.color}>
{r.inspection.text}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<SectionCard icon={Truck} title="Trucks" accent="grape">
<Table.ScrollContainer minWidth={1100}>
<Table verticalSpacing="xs" fz="xs">
<Table.Tbody>
<TruckRows group={group} />
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</SectionCard>
<FeePreviewModal
@@ -289,13 +111,6 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
onClose={() => setFeeModalOpen(false)}
inventoryId={latestInventory?.id ?? null}
/>
{mode === "EDR" && (
<TruckDetentionModal
opened={detentionModalOpen}
onClose={() => setDetentionModalOpen(false)}
record={lastMileRecordQuery.data ?? null}
/>
)}
</Stack>
);
}

View File

@@ -0,0 +1,54 @@
import { useState, type MouseEvent } from 'react';
import { Button } from '@mantine/core';
import { FileText } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import { extractDownloadErrorMessage } from './options';
import { openPdfBlob } from './pdf';
/** Opens (or downloads) an inventory item's GRN document — same button everywhere it appears. */
export function GrnDocumentButton({
inventoryId,
grnNumber,
}: {
inventoryId: string;
grnNumber?: string | null;
}) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (!grnNumber) {
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
return;
}
setLoading(true);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadGrnDocument(inventoryId);
const opened = openPdfBlob(response.data, `grn-${grnNumber}.pdf`, pdfWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
} finally {
setLoading(false);
}
};
return (
<Button
size="compact-xs"
variant="subtle"
color="teal"
leftSection={<FileText size={12} />}
disabled={!grnNumber}
loading={loading}
onClick={openDocument}
>
{grnNumber ?? 'No GRN'}
</Button>
);
}

View File

@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useState, type MouseEvent } from 'react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import {
ActionIcon,
Alert,
@@ -71,6 +71,7 @@ import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal';
import { ContainerItemsModal } from './ContainerItemsModal';
import { FeePreviewModal } from './FeePreviewModal';
import { GrnDocumentButton } from './GrnDocumentButton';
import { InspectionReportModal } from './InspectionReportModal';
import { InventoryDetailModal } from './InventoryDetailModal';
import { InventoryHistoryModal } from './InventoryHistoryModal';
@@ -98,44 +99,6 @@ interface ReceiveInventoryModalProps {
onReceived?: () => void;
}
function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; grnNumber?: string | null }) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (!grnNumber) {
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
return;
}
setLoading(true);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadGrnDocument(inventoryId);
const opened = openPdfBlob(response.data, `grn-${grnNumber}.pdf`, pdfWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
} finally {
setLoading(false);
}
};
return (
<Button
size="compact-xs"
variant="subtle"
color="teal"
leftSection={<FileText size={12} />}
disabled={!grnNumber}
loading={loading}
onClick={openDocument}
>
{grnNumber ?? 'No GRN'}
</Button>
);
}
interface Location {
warehouseId: string;
@@ -1513,7 +1476,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
</Table.Td>
<Table.Td ta="right">
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
Inspect / Report
Inspection / Report
</Button>
</Table.Td>
</Table.Tr>
@@ -2228,7 +2191,7 @@ const getPendingUnloadBookings = (train: ImportTrain) =>
const isFullyUnloaded = (train: ImportTrain) =>
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
function ImportArriveQueueTab({
export function ImportArriveQueueTab({
enabled,
onChanged,
}: {
@@ -2769,7 +2732,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
{r.handoverDocumentReference ? 'View handover' : 'Handover'}
</Menu.Item>
)}
<Menu.Item onClick={() => setInspectId(r.id)}>Inspect / report</Menu.Item>
<Menu.Item onClick={() => setInspectId(r.id)}>Inspection / Report</Menu.Item>
{/* Double handling is decided once the goods are off
the wagon (every row here is unloaded) — Yes is
what makes the fee rule bill this booking. */}

View File

@@ -1,9 +1,7 @@
import { Fragment, useState, type MouseEvent } from 'react';
import { Fragment, useState } from 'react';
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ChevronDown, ChevronRight, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import {
getNextInventoryAction,
type InventoryAction,
@@ -11,8 +9,8 @@ import {
} from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { TruckBreakdownRow } from './TruckBreakdownRow';
import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
import { openPdfBlob } from './pdf';
import { GrnDocumentButton } from './GrnDocumentButton';
import { formatDate, formatNumber, humanizeEnum } from './options';
interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
@@ -64,45 +62,6 @@ const noteLineValue = (notes: string | null | undefined, label: string) => {
const handoverDocumentReference = (item: WarehouseInventoryItem) =>
item.handoverDocumentReference ?? noteLineValue(item.notes, 'Handover Reference');
function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (!item.grnNumber) {
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
return;
}
setLoading(true);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadGrnDocument(item.id);
const opened = openPdfBlob(response.data, `grn-${item.grnNumber}.pdf`, pdfWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
} finally {
setLoading(false);
}
};
return (
<Button
size="compact-xs"
variant="subtle"
color="teal"
leftSection={<FileText size={12} />}
disabled={!item.grnNumber}
loading={loading}
onClick={openDocument}
>
{item.grnNumber ?? 'No GRN'}
</Button>
);
}
export function WarehouseInventoryTable({
items,
busyId,
@@ -239,7 +198,7 @@ export function WarehouseInventoryTable({
)}
</Table.Td>
<Table.Td>
<GrnDocumentButton item={item} />
<GrnDocumentButton inventoryId={item.id} grnNumber={item.grnNumber} />
</Table.Td>
<Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td>
<Table.Td>{item.warehouse?.code ?? '-'}</Table.Td>
@@ -341,7 +300,7 @@ export function WarehouseInventoryTable({
</Tooltip>
)}
{onDownloadBundle && item.grnNumber && (
<Tooltip label="Download document bundle (GRN + gate clearance + handover)" withArrow>
<Tooltip label="Download document bundle (GRN + exit paper + handover)" withArrow>
<ActionIcon variant="subtle" color="grape" onClick={() => onDownloadBundle(item)}>
<Download size={16} />
</ActionIcon>

View File

@@ -43,8 +43,19 @@ function templateDirection(code: ContractTemplate["code"]): string {
return code.split("_")[0];
}
// Codes are DIRECTION_FREIGHT_{CUSTOMS,NO_CUSTOMS}, so the freight segment is
// the second one — never the suffix.
function isBulk(code: ContractTemplate["code"]): boolean {
return code.endsWith("_BULK");
return code.split("_")[1] === "BULK";
}
// Intercity is domestic and crosses no border, so it has no customs variant at
// all — hence null rather than false, which would wrongly read as a deliberate
// "client clears its own customs" choice.
function customsVariant(code: ContractTemplate["code"]): boolean | null {
if (code.endsWith("_NO_CUSTOMS")) return false;
if (code.endsWith("_CUSTOMS")) return true;
return null;
}
function formatUpdated(value: string): string {
@@ -66,12 +77,12 @@ export default function ContractTemplatesPage() {
<PageContainer>
<PageHeader
title="Contract templates"
subtitle="The six contract documents generated when a contract is approved — one per trade direction and freight type. Articles are fully editable."
subtitle="The ten contract documents generated when a contract is approved — one per trade direction, freight type, and customs-clearing option. Intercity is domestic, so it has no customs variant. Articles are fully editable."
/>
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
{isLoading
? Array.from({ length: 6 }, (_, i) => <TemplateCardSkeleton key={i} />)
? Array.from({ length: 10 }, (_, i) => <TemplateCardSkeleton key={i} />)
: (templates ?? []).map((template) => (
<TemplateCard
key={template.code}
@@ -104,6 +115,7 @@ function TemplateCard({
}) {
const direction = templateDirection(template.code);
const bulk = isBulk(template.code);
const customs = customsVariant(template.code);
return (
<Card
@@ -139,13 +151,29 @@ function TemplateCard({
</Text>
</Group>
</Group>
{!template.isActive && (
<Tooltip label="Not used for new contracts" withArrow>
<Badge size="sm" variant="light" color="red">
Inactive
</Badge>
</Tooltip>
)}
<Group gap={6} wrap="nowrap">
{customs !== null && (
<Tooltip
label={
customs
? "Used when the contract has customs clearing enabled"
: "Used when the client handles its own customs clearing"
}
withArrow
>
<Badge size="sm" variant="light" color={customs ? "teal" : "gray"}>
{customs ? "With customs" : "No customs"}
</Badge>
</Tooltip>
)}
{!template.isActive && (
<Tooltip label="Not used for new contracts" withArrow>
<Badge size="sm" variant="light" color="red">
Inactive
</Badge>
</Tooltip>
)}
</Group>
</Group>
{/* Name + description */}

View File

@@ -1,338 +1,11 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import {
Badge,
Button,
Card,
Group,
Loader,
Select,
Stack,
Table,
Text,
} from '@mantine/core';
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
import { Card } from '@mantine/core';
import { PageContainer, PageHeader } from '@/components/page';
import {
VisualEmptyState,
WarehouseOpsKpiStrip,
formatDate,
formatNumber,
warehousesAtStation,
yardsForBooking,
} from '@/components/warehouses';
import {
useAutoUnloadArrivedBookings,
useAllWarehouseYards,
useAllWarehouseZones,
useImportArriveQueue,
useImportTrainItems,
useWarehouses,
} from '@/hooks/useWarehouses';
import { useToast } from '@/hooks/use-toast';
import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem, Warehouse, WarehouseYard, WarehouseZone } from '@/types/warehouse';
type UnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string };
type AssignmentDraft = Partial<Omit<UnloadAssignment, 'bookingId'>>;
const getErrorMessage = (error: unknown) => {
if (error && typeof error === 'object' && 'response' in error) {
const response = (error as { response?: { data?: { message?: unknown } } }).response;
const message = response?.data?.message;
if (Array.isArray(message)) return message.join(', ');
if (typeof message === 'string') return message;
}
return error instanceof Error ? error.message : undefined;
};
const getPendingUnloadBookings = (train: ImportTrain) =>
train.pendingUnloadBookings ?? train.totalBookings;
const isFullyUnloaded = (train: ImportTrain) =>
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
const isContainerFreight = (freightType: string | null | undefined) =>
(freightType ?? '').toUpperCase() === 'CONTAINER';
function isUnloadPending(item: ImportTrainItem) {
return !item.currentStatus || item.currentStatus === 'RECEIVED';
}
function ImportTrainDetailRows({
train,
warehouses,
yards,
zones,
assignments,
onAssignmentChange,
onReadyChange,
}: {
train: ImportTrain;
warehouses: Warehouse[];
yards: WarehouseYard[];
zones: WarehouseZone[];
assignments: Record<string, AssignmentDraft>;
onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void;
onReadyChange: (ready: boolean) => void;
}) {
const { data: items = [], isLoading } = useImportTrainItems(train.scheduleId);
// A train only ever unloads at the warehouse actually sitting at its
// destination station — Indode's train never offers Sebeta's warehouse.
const scopedWarehouses = useMemo(
() => warehousesAtStation(warehouses, train.destinationStationId),
[warehouses, train.destinationStationId],
);
const warehouseOptions = useMemo(
() => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
[scopedWarehouses],
);
// With exactly one warehouse at the station there is nothing to choose —
// pre-fill it so staff only has to pick yard/zone, not re-discover Indode.
useEffect(() => {
if (scopedWarehouses.length !== 1) return;
const onlyWarehouseId = scopedWarehouses[0].id;
items.filter(isUnloadPending).forEach((item) => {
if (!assignments[item.bookingId]?.warehouseId) {
onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId });
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopedWarehouses, items]);
// Once a booking's warehouse is known, its yard (and then zone) follow from
// what the cargo actually is — a Wheat booking only ever has one candidate
// yard (Dry Bulk) once Indode's real yard layout is configured, so staff
// never see a picker for something that isn't actually a choice.
useEffect(() => {
items.filter(isUnloadPending).forEach((item) => {
const draft = assignments[item.bookingId];
if (!draft?.warehouseId) return;
if (!draft.yardId) {
const candidateYards = yardsForBooking(yards, {
warehouseId: draft.warehouseId,
freightType: item.freightType,
tradeDirection: 'IMPORT',
cargoTypeCode: item.cargoTypeCode,
});
if (candidateYards.length === 1) {
onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id });
}
return;
}
if (!draft.zoneId) {
const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId);
if (candidateZones.length === 1) {
onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id });
}
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignments, items, yards, zones]);
useEffect(() => {
const pending = items.filter(isUnloadPending);
onReadyChange(
pending.length > 0 &&
pending.every((item) => {
const draft = assignments[item.bookingId];
return Boolean(draft?.warehouseId && draft.yardId && draft.zoneId);
}),
);
}, [assignments, items]);
if (isLoading) {
return (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
);
}
if (items.length === 0) {
return (
<Text c="dimmed" ta="center" py="md" size="sm">
No assigned bookings found for this train.
</Text>
);
}
return (
<Table highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Container</Table.Th>
<Table.Th>Cargo</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Arrival</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Inspection</Table.Th>
<Table.Th>Pickup</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item: ImportTrainItem) => {
const draft = assignments[item.bookingId] ?? {};
const yardOptions = yardsForBooking(yards, {
warehouseId: draft.warehouseId,
freightType: item.freightType,
tradeDirection: 'IMPORT',
cargoTypeCode: item.cargoTypeCode,
}).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
// The yard is already scoped to what this cargo can go into — a
// zone's own type always matches its parent yard's purpose (see the
// Indode seed migration), so no separate zone-type filter is needed.
const zoneOptions = zones
.filter((zone) => zone.yardId === draft.yardId)
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
const pending = isUnloadPending(item);
return (
<Table.Tr key={item.bookingId}>
<Table.Td>
<Text size="sm" fw={600}>
{item.bookingReference ?? item.bookingId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>{item.customerName ?? '—'}</Table.Td>
<Table.Td>{item.containerNumber ?? '—'}</Table.Td>
<Table.Td>{item.cargoType ?? '—'}</Table.Td>
<Table.Td>{formatNumber(item.weight)}</Table.Td>
<Table.Td>{formatDate(item.arrivalTime)}</Table.Td>
<Table.Td>
<Badge variant="light" color={item.currentStatus === 'UNLOADED' ? 'green' : 'orange'} size="sm">
{item.currentStatus ?? 'PENDING'}
</Badge>
</Table.Td>
<Table.Td>
<Select
placeholder="Warehouse"
data={warehouseOptions}
value={draft.warehouseId ?? null}
onChange={(value) => onAssignmentChange(item.bookingId, { warehouseId: value ?? undefined })}
searchable
disabled={!pending}
w={210}
/>
</Table.Td>
<Table.Td>
<Select
placeholder={isContainerFreight(item.freightType) ? 'Container yard' : 'Bulk yard'}
data={yardOptions}
value={draft.yardId ?? null}
onChange={(value) =>
onAssignmentChange(item.bookingId, { ...draft, yardId: value ?? undefined, zoneId: undefined })
}
searchable
disabled={!pending || !draft.warehouseId}
w={190}
/>
</Table.Td>
<Table.Td>
<Select
placeholder={isContainerFreight(item.freightType) ? 'Container zone' : 'Bulk zone'}
data={zoneOptions}
value={draft.zoneId ?? null}
onChange={(value) => onAssignmentChange(item.bookingId, { ...draft, zoneId: value ?? undefined })}
searchable
disabled={!pending || !draft.yardId}
w={190}
/>
</Table.Td>
<Table.Td>
<Badge variant="light" color={item.inspectionStatus === 'PASSED' ? 'green' : 'gray'} size="sm">
{item.inspectionStatus ?? 'Not inspected'}
</Badge>
</Table.Td>
<Table.Td>{item.pickupOption.replace(/_/g, ' ')}</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
);
}
import { WarehouseOpsKpiStrip } from '@/components/warehouses';
import { ImportArriveQueueTab } from '@/components/warehouses/ReceiveInventoryModal';
/** Arrived import trains awaiting unload into warehouse inventory. */
export default function ArrivalQueuePage() {
const { toast } = useToast();
const { data: trains = [], isLoading } = useImportArriveQueue();
const { data: warehouses = [], isLoading: warehousesLoading } = useWarehouses({ status: 'ACTIVE' });
const { data: yards = [] } = useAllWarehouseYards();
const { data: zones = [] } = useAllWarehouseZones();
const autoUnload = useAutoUnloadArrivedBookings();
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<Record<string, Record<string, AssignmentDraft>>>({});
const [readyBySchedule, setReadyBySchedule] = useState<Record<string, boolean>>({});
const unloadTrain = async (train: ImportTrain) => {
const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {})
.filter((entry): entry is [string, Required<AssignmentDraft>] =>
Boolean(entry[1].warehouseId && entry[1].yardId && entry[1].zoneId),
)
.map(([bookingId, draft]) => ({
bookingId,
warehouseId: draft.warehouseId,
yardId: draft.yardId,
zoneId: draft.zoneId,
}));
if (!readyBySchedule[train.scheduleId] || assignments.length === 0) {
toast({
variant: 'destructive',
title: 'Assign locations',
description: 'Select warehouse, yard and zone for each pending booking before unloading.',
});
return;
}
if (isFullyUnloaded(train)) {
toast({
title: 'Already unloaded',
description: `${train.trainNumber ?? 'This train'} has no remaining bookings to auto unload.`,
});
return;
}
setBusyScheduleId(train.scheduleId);
try {
const res = (await autoUnload.mutateAsync({ scheduleId: train.scheduleId, assignments })) as {
data: AutoUnloadArrivedResult;
};
const result = res.data;
const alreadyUnloaded = result.unloadedCount === 0 && result.skippedCount > 0 && result.failedCount === 0;
const firstReason = result.results.find((item) => item.reason)?.reason;
const details = [
result.skippedCount ? `${result.skippedCount} skipped` : '',
result.failedCount ? `${result.failedCount} failed` : '',
]
.filter(Boolean)
.join(', ');
toast({
title: alreadyUnloaded ? 'Already unloaded' : `${result.unloadedCount} booking(s) unloaded`,
description: alreadyUnloaded
? firstReason ?? `${train.trainNumber ?? 'Train'} is already in warehouse inventory.`
: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`,
});
} catch (error) {
toast({
variant: 'destructive',
title: 'Auto unload failed',
description: getErrorMessage(error),
});
} finally {
setBusyScheduleId(null);
}
};
return (
<PageContainer>
<PageHeader
@@ -344,136 +17,7 @@ export default function ArrivalQueuePage() {
<WarehouseOpsKpiStrip />
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md">
<Stack gap={2}>
<Text fw={600}>{trains.length} arrived import train(s)</Text>
<Text size="sm" c="dimmed">
Open a train, assign each booking to a warehouse yard and zone, then unload it.
</Text>
</Stack>
</Group>
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : trains.length === 0 ? (
<VisualEmptyState
variant="container"
title="No arrived import trains"
description="Import trains appear here once their train schedule status is ARRIVED."
/>
) : (
<Table.ScrollContainer minWidth={1150}>
<Table verticalSpacing="sm" highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Train</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Origin</Table.Th>
<Table.Th>Destination</Table.Th>
<Table.Th>Arrival</Table.Th>
<Table.Th ta="center">Bookings</Table.Th>
<Table.Th ta="center">Containers</Table.Th>
<Table.Th ta="center">Cargoes</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{trains.map((train: ImportTrain) => {
const isOpen = openScheduleId === train.scheduleId;
const fullyUnloaded = isFullyUnloaded(train);
const unloadedBookings = train.unloadedBookings ?? train.totalBookings - getPendingUnloadBookings(train);
return (
<Fragment key={train.scheduleId}>
<Table.Tr>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={700}>
{train.trainNumber ?? '—'}
</Text>
<Text size="xs" c="dimmed">
{train.scheduleId.slice(0, 8)}
</Text>
</Stack>
</Table.Td>
<Table.Td>{train.route ?? '—'}</Table.Td>
<Table.Td>{train.origin ?? '—'}</Table.Td>
<Table.Td>{train.destination ?? '—'}</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(train.arrivalTime)}</Text>
</Table.Td>
<Table.Td ta="center">{train.totalBookings}</Table.Td>
<Table.Td ta="center">{train.totalContainers}</Table.Td>
<Table.Td ta="center">{train.totalCargoes}</Table.Td>
<Table.Td>
<Stack gap={2}>
<Badge variant="light" color="teal" size="sm">
{train.status}
</Badge>
<Text size="xs" c="dimmed">
{Math.max(unloadedBookings, 0)}/{train.totalBookings} unloaded
</Text>
</Stack>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="light"
leftSection={isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
onClick={() => setOpenScheduleId(isOpen ? null : train.scheduleId)}
>
Open
</Button>
<Button
size="compact-xs"
color={fullyUnloaded ? 'gray' : 'orange'}
leftSection={busyScheduleId === train.scheduleId ? <PackageOpen size={14} /> : <Truck size={14} />}
loading={busyScheduleId === train.scheduleId}
disabled={fullyUnloaded || train.totalBookings === 0 || !readyBySchedule[train.scheduleId] || warehousesLoading}
onClick={() => unloadTrain(train)}
>
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload'}
</Button>
</Group>
</Table.Td>
</Table.Tr>
{isOpen && (
<Table.Tr>
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
<ImportTrainDetailRows
train={train}
warehouses={warehouses}
yards={yards}
zones={zones}
assignments={assignmentsBySchedule[train.scheduleId] ?? {}}
onAssignmentChange={(bookingId, draft) =>
setAssignmentsBySchedule((current) => ({
...current,
[train.scheduleId]: {
...(current[train.scheduleId] ?? {}),
[bookingId]: draft.warehouseId
? draft
: {},
},
}))
}
onReadyChange={(ready) =>
setReadyBySchedule((current) => ({ ...current, [train.scheduleId]: ready }))
}
/>
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<ImportArriveQueueTab enabled />
</Card>
</PageContainer>
);

View File

@@ -17,7 +17,7 @@ import {
Select,
Checkbox,
} from "@mantine/core";
import { ChevronDown, ChevronRight } from "lucide-react";
import { ChevronDown, ChevronRight, History } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
@@ -27,9 +27,30 @@ import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
import { api } from "@/services/api";
import { warehouseService } from "@/services/warehouse.service";
import { importOperationsService } from "@/services/importOperations.service";
import type { EmptyContainerReturnStatus } from "@/types/importOperations";
type ReturnType = "all" | "edr" | "customer";
const RETURN_STATUS_ORDER: EmptyContainerReturnStatus[] = [
"RETURNED",
"ASSIGNED_STORAGE",
"DOCUMENTATION_CLEARED",
"WAGON_ALLOCATED",
"TRANSPORTED_TO_DJIBOUTI",
"HANDOVER_ISSUED",
"COMPLETED",
];
const RETURN_STATUS_LABEL: Record<EmptyContainerReturnStatus, string> = {
RETURNED: "Returned",
ASSIGNED_STORAGE: "Assigned Storage",
DOCUMENTATION_CLEARED: "Documentation Cleared",
WAGON_ALLOCATED: "Wagon Allocated",
TRANSPORTED_TO_DJIBOUTI: "Transported to Djibouti",
HANDOVER_ISSUED: "Handover Issued",
COMPLETED: "Completed",
};
interface ContainerReturnRow {
key: string;
containerNumber: string;
@@ -61,6 +82,7 @@ export default function ContainerReturnsPage() {
const [returnModalOpen, setReturnModalOpen] = useState(false);
const [standaloneModalOpen, setStandaloneModalOpen] = useState(false);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [historyRow, setHistoryRow] = useState<any | null>(null);
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
queryKey: ["import-unloaded-queue"],
@@ -164,6 +186,11 @@ export default function ContainerReturnsPage() {
enabled: bookingIds.length > 0 && !queueLoading,
});
const filteredReturnedContainers = useMemo(() => {
if (filterType === "all") return returnedContainers;
return returnedContainers.filter((ret: any) => ret.returnedBy === filterType.toUpperCase());
}, [returnedContainers, filterType]);
const allGroups = useMemo(() => containerReturnsQuery.data ?? [], [containerReturnsQuery.data]);
const filteredGroups = useMemo(() => {
if (filterType === "all") return allGroups;
@@ -202,6 +229,7 @@ export default function ContainerReturnsPage() {
facility: container.warehouse,
condition: container.condition,
handoverNote: container.handoverNote,
returnedBy: truck.returnType,
});
results.push(result);
}
@@ -223,6 +251,26 @@ export default function ContainerReturnsPage() {
},
});
const advanceStatusMutation = useMutation({
mutationFn: (id: string) => {
const current = returnedContainers.find((r: any) => r.id === id);
const nextIndex = RETURN_STATUS_ORDER.indexOf(current?.status ?? "RETURNED") + 1;
const status = RETURN_STATUS_ORDER[nextIndex] ?? "COMPLETED";
return importOperationsService.updateEmptyReturnStatus(id, { status });
},
onSuccess: () => {
toast({ title: "Return status updated" });
qc.invalidateQueries({ queryKey: ["empty-container-returns"] });
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Failed to update return status",
description: error?.response?.data?.message || error?.message,
});
},
});
const activeGroup = activeKey ? (filteredGroups.find((g) => `${g.returnType.toLowerCase()}-${g.bookingId}` === activeKey) ?? null) : null;
if (queueLoading || containerReturnsQuery.isLoading) {
@@ -257,7 +305,7 @@ export default function ContainerReturnsPage() {
</Button>
</Group>
{returnedContainers.length > 0 && (
{filteredReturnedContainers.length > 0 && (
<>
<Text fw={600} mb="xs">Returned Containers</Text>
<Table.ScrollContainer minWidth={1000} mb="lg">
@@ -266,27 +314,60 @@ export default function ContainerReturnsPage() {
<Table.Tr>
<Table.Th>Container Number</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Returned By</Table.Th>
<Table.Th>Returned Date</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Condition</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Action</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{returnedContainers.map((ret: any) => (
<Table.Tr key={ret.id}>
<Table.Td>{ret.containerNumber}</Table.Td>
<Table.Td>{ret.bookingId ? "Associated" : "—"}</Table.Td>
<Table.Td>{ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"}</Table.Td>
<Table.Td>{ret.facility || "—"}</Table.Td>
<Table.Td>{ret.yard || "—"}</Table.Td>
<Table.Td>{ret.condition || "—"}</Table.Td>
<Table.Td>
<Badge size="sm">{ret.status}</Badge>
</Table.Td>
</Table.Tr>
))}
{filteredReturnedContainers.map((ret: any) => {
const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1];
return (
<Table.Tr key={ret.id}>
<Table.Td>{ret.containerNumber}</Table.Td>
<Table.Td>{ret.bookingId ? "Associated" : "—"}</Table.Td>
<Table.Td>
{ret.returnedBy ? (
<Badge size="sm" color={ret.returnedBy === "EDR" ? "edr-green" : "blue"}>
{ret.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
</Badge>
) : (
"—"
)}
</Table.Td>
<Table.Td>{ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"}</Table.Td>
<Table.Td>{ret.facility || "—"}</Table.Td>
<Table.Td>{ret.yard || "—"}</Table.Td>
<Table.Td>{ret.condition || "—"}</Table.Td>
<Table.Td>
<Badge size="sm">{RETURN_STATUS_LABEL[ret.status as EmptyContainerReturnStatus] ?? ret.status}</Badge>
</Table.Td>
<Table.Td ta="right">
<Group gap="xs" justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryRow(ret)} title="View status history">
<History size={14} />
</ActionIcon>
{nextStatus ? (
<Button
size="xs"
variant="light"
loading={advanceStatusMutation.isPending && advanceStatusMutation.variables === ret.id}
onClick={() => advanceStatusMutation.mutate(ret.id)}
>
Advance to {RETURN_STATUS_LABEL[nextStatus]}
</Button>
) : (
<Text size="xs" c="dimmed">Done</Text>
)}
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
@@ -404,6 +485,23 @@ export default function ContainerReturnsPage() {
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
loading={createReturnsMutation.isPending}
/>
<Modal opened={!!historyRow} onClose={() => setHistoryRow(null)} title="Status History" size="sm">
{historyRow && (
<Stack gap="sm">
<Text fw={600}>{historyRow.containerNumber}</Text>
{(historyRow.statusHistory ?? []).map((entry: any, idx: number) => (
<Group key={idx} justify="space-between">
<Badge size="sm">{RETURN_STATUS_LABEL[entry.status as EmptyContainerReturnStatus] ?? entry.status}</Badge>
<Text size="sm" c="dimmed">{new Date(entry.changedAt).toLocaleString()}</Text>
</Group>
))}
{!(historyRow.statusHistory ?? []).length && (
<Text size="sm" c="dimmed">No history recorded.</Text>
)}
</Stack>
)}
</Modal>
</PageContainer>
);
}

View File

@@ -82,7 +82,7 @@ const money = (amount: number, currency: string) =>
const formatTime = (iso: string | null | undefined) =>
iso ? new Date(iso).toLocaleString() : "—";
interface BookingGroup {
export interface BookingGroup {
bookingId: string;
bookingReference: string;
customerName: string | null;
@@ -93,7 +93,7 @@ interface BookingGroup {
}
/** One collapsed line per booking; its inventory rows travel with it for the fee lookup. */
function groupByBooking(items: ImportUnloadedItem[]): BookingGroup[] {
export function groupByBooking(items: ImportUnloadedItem[]): BookingGroup[] {
const groups = new Map<string, BookingGroup>();
for (const item of items) {
if (!item.bookingId) continue;
@@ -140,7 +140,7 @@ interface TruckRow {
* Haulage mode is decided by which list comes back non-empty — a booking is
* either EDR last mile or customer self-haul, never both.
*/
function TruckRows({ group }: { group: BookingGroup }) {
export function TruckRows({ group }: { group: BookingGroup }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [busy, setBusy] = useState(false);
@@ -423,14 +423,7 @@ function TruckRows({ group }: { group: BookingGroup }) {
disabled={!primaryId}
onClick={() => openRelease(t)}
>
Truck Arrival
</Menu.Item>
<Menu.Item
leftSection={<Truck size={14} />}
disabled={!primaryId}
onClick={() => openRelease(t)}
>
Truck Leaving
{t.arrivedAt ? 'Truck Arrival / Leaving' : 'Truck Arrival'}
</Menu.Item>
<Menu.Divider />
<Menu.Item
@@ -452,7 +445,7 @@ function TruckRows({ group }: { group: BookingGroup }) {
disabled={!primaryId}
onClick={() => setInspectId(primaryId)}
>
Inspect / report
Inspection / Report
</Menu.Item>
{isEdr && (
<>

View File

@@ -11,12 +11,18 @@ export interface ContractTemplateArticle {
export interface ContractTemplate {
id: string;
// Import/export split by customs clearing; intercity is domestic, crosses no
// border, and so has a single template.
code:
| "IMPORT_BULK"
| "EXPORT_BULK"
| "IMPORT_BULK_CUSTOMS"
| "IMPORT_BULK_NO_CUSTOMS"
| "EXPORT_BULK_CUSTOMS"
| "EXPORT_BULK_NO_CUSTOMS"
| "INTERCITY_BULK"
| "IMPORT_CONTAINER"
| "EXPORT_CONTAINER"
| "IMPORT_CONTAINER_CUSTOMS"
| "IMPORT_CONTAINER_NO_CUSTOMS"
| "EXPORT_CONTAINER_CUSTOMS"
| "EXPORT_CONTAINER_NO_CUSTOMS"
| "INTERCITY_CONTAINER";
name: string;
description?: string | null;

View File

@@ -102,6 +102,12 @@ export interface EmptyContainerReturn {
status: EmptyContainerReturnStatus;
wagonAllocationReference: string | null;
performedBy: string | null;
returnedBy: 'EDR' | 'CUSTOMER' | null;
statusHistory: Array<{
status: EmptyContainerReturnStatus;
changedAt: string;
performedBy: string | null;
}>;
}
export interface CreateEmptyContainerReturnPayload {
@@ -115,6 +121,7 @@ export interface CreateEmptyContainerReturnPayload {
condition?: string;
handoverNote?: string;
performedBy?: string;
returnedBy?: 'EDR' | 'CUSTOMER';
}
export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperationActionPayload {

View File

@@ -145,12 +145,38 @@ export function paymentStatusLabel(status?: string | null): string {
}
/**
* Payment status pill. Once the booking's own lifecycle status has moved past
* payment (PAID or later — stage ≥ 3 in STATUS_CONFIG), payment is a settled
* fact: show "Paid" even if a stale/lagging `paymentStatus` value says
* otherwise, rather than surface a contradictory "Paid booking, pending
* payment" row.
* Booking statuses that are only reachable at or after the payment gate.
* Settlement writes `status` and `paymentStatus` in one transaction
* (booking-invoice.service.ts `advanceBookingOnPayment`), so these are a
* backstop for a stale/lagging `paymentStatus` — not the primary signal.
*/
const PAID_OR_LATER_STATUSES = new Set([
"PAID",
"TRUCK_ASSIGNED",
"IN_TRANSIT",
"ARRIVED",
"COMPLETED",
]);
/**
* Payment status pill. `paymentStatus` is authoritative; the status set above
* only covers a lagging read, so a booking past the payment gate never shows a
* contradictory "Paid booking, pending payment" row.
*
* Note the set is explicit rather than derived from `STATUS_CONFIG.stage` —
* stage is a portal timeline grouping, and stage 3 lumps pre-payment clearance
* statuses (AWAITING_DOCUMENTS, CLEARANCE_READY, SIGNED_CUSTOMER, OPERATION_*)
* in with genuinely post-payment ones, which made every freshly initiated
* contract booking render as "Paid".
*/
export function effectivePaymentStatus(
status?: string | null,
bookingStatus?: string | null,
): string | null | undefined {
const settled = bookingStatus ? PAID_OR_LATER_STATUSES.has(bookingStatus) : false;
return settled ? "PAID" : status;
}
export function PaymentBadge({
status,
bookingStatus,
@@ -158,8 +184,7 @@ export function PaymentBadge({
status?: string | null;
bookingStatus?: string | null;
}) {
const settled = bookingStatus ? (STATUS_CONFIG[bookingStatus]?.stage ?? 0) >= 3 : false;
const effective = settled ? "PAID" : status;
const effective = effectivePaymentStatus(status, bookingStatus);
if (!effective) return <Text fz={13} c="dimmed"></Text>;
return (
<Badge

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { effectivePaymentStatus } from "./booking-display";
describe("effectivePaymentStatus", () => {
it("keeps PENDING for pre-payment statuses", () => {
// Regression: these all sit at STATUS_CONFIG stage 3, so the old
// `stage >= 3` heuristic rendered every freshly initiated contract
// booking as "Paid".
for (const s of [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"SELECTED_FOR_BATCH",
"OPERATION_REQUEST_PENDING",
"PNR_GENERATED",
]) {
expect(effectivePaymentStatus("PENDING", s)).toBe("PENDING");
}
});
it("shows PAID once the booking is at or past the payment gate", () => {
for (const s of ["PAID", "TRUCK_ASSIGNED", "IN_TRANSIT", "ARRIVED", "COMPLETED"]) {
expect(effectivePaymentStatus("PENDING", s)).toBe("PAID");
}
});
it("passes the real payment status through when the booking status is absent", () => {
expect(effectivePaymentStatus("PAID", null)).toBe("PAID");
expect(effectivePaymentStatus("FAILED", undefined)).toBe("FAILED");
expect(effectivePaymentStatus(null, null)).toBeNull();
});
});

View File

@@ -45,8 +45,8 @@
"@nestjs/websockets": "^11.1.27",
"@prisma/client": "^6.19.3",
"@sendgrid/mail": "^8.1.0",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.9.tgz",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
"@types/bcrypt": "^6.0.0",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",

View File

@@ -163,3 +163,8 @@ services:
# RABBITMQ_ENABLED stays "false" (base stack) — it only gates the SMS/email
# clients, which must remain off. The payment consumer is wired by
# PAYMENT_RABBITMQ_URL alone.
# Drain tail on every pay window. Production defaults to 5 minutes; a
# reservation here lives ~60s, so 5 would push every natural expiry past
# the suite's 180s timeouts. One minute keeps the tail real and observable
# (src/expired-invoice-late-settle.it.ts asserts both sides of it).
FREIGHT_PAYMENT_DRAIN_MINUTES: "1"

View File

@@ -57,6 +57,7 @@ gateway-mock-it`) — the code is a read-only mount, not baked into an image.
| `src/concurrency.it.ts` | duplicate callbacks, two intents on one invoice, wagon budget, pay-window gate |
| `src/authz.it.ts` | cross-tenant isolation, login audience, service-token gates |
| `src/cbe-bill.it.ts` | inbound CBE Unified Bill: token → query (hops into freight) → payment |
| `src/expired-invoice-late-settle.it.ts` | pay-window drain tail; a settlement landing after the hold expired still pays the invoice and revives the booking |
| `src/bulk-import-full-train.it.ts` | six wheat bookings fill 54 wagons, then gate pass → T1 → dispatch → corridor → arrival → customs tail |
| `src/bulk-import-waiting-expiry.it.ts` | exact-fill trio selected, waiting three expire with the day |
| `src/bulk-import-split-promote.it.ts` | partial offer, split on settlement, expiry promotion, exact-remainder rebooking |
@@ -131,6 +132,16 @@ what it actually does and says so in a comment, so a fix fails loudly:
the phased path (`customs_clearing_enabled` on the contract) avoids it — which
is why S8's customs tenant is Path B.
- **A live intent makes a hold unexpirable here** (`expired-invoice-late-settle`).
Reconcile-before-expire live-queries every non-FAILED intent; the mock answers
`PROCESSING` for an unpaid order, which the payment API reads as "money in
flight" → `unverifiable` → never expire on unknown. So while a CBE Birr intent
is open, NOTHING retires the reservation — not the settle tick, not the staff
`bookings/:id/expire` override (it runs the same guard). Producing the
expired-invoice-with-a-payment case therefore needs the intent retired first
(`status = 'FAILED'`, which reconcile skips), after which a webhook still
late-captures it (`applyProviderResult`).
## Gotchas
- **One unpaid hold per company.** `assertNoUnpaidHold` blocks a company with a

View File

@@ -0,0 +1,175 @@
/**
* The swallowed late payment
* (docs/dev-testing/finding-expired-invoice-swallows-payment.md).
*
* Settlement is asynchronous — customer taps pay → provider confirms → payment
* API enqueues → relay delivers — so the success can land after the booking
* window cron has already expired the invoice. Before the fix the money simply
* vanished: the intent flipped to SUCCEEDED, `settleByPaymentId` matched no OPEN
* invoice and returned null, the relay was told `processed: true`, and the
* customer was left debited with an EXPIRED invoice and an EXPIRED booking.
*
* Two layers are asserted here:
* 1. the drain tail — a deadline that just passed does NOT expire anything
* (FREIGHT_PAYMENT_DRAIN_MINUTES, 1 in this stack);
* 2. the backstop — a settlement that lands after the drain is treated exactly
* like an in-window payment: invoice PAID, booking PAID.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { apiOk, chief, closeDb, db, gateway, poll } from "./client";
import {
createImportSchedule,
currentInvoice,
departureAt,
ensureCorridorRoute,
forceWindowOpen,
freightPayment,
gatewayIntent,
invoiceForBooking,
payInvoice,
prepareBooking,
releaseUnpaidHolds,
resetCorridorDay,
runBatch,
type ReadyBooking,
} from "./flows";
const DEPARTURE = departureAt(4);
const STAMP = String(Date.now());
/** The stack runs a 1-minute tail (FREIGHT_PAYMENT_DRAIN_MINUTES, docker-compose.it.yaml). */
const DRAIN_MS = 60_000;
/** Long enough for several 10s settle ticks, comfortably inside the tail. */
const INSIDE_TAIL_MS = DRAIN_MS * 0.4;
const bookingRow = async (id: string) =>
(
await db<{ status: string; payment_status: string; train_schedule_id: string | null }>(
`SELECT status, payment_status, train_schedule_id FROM freight.bookings WHERE id = $1`,
[id],
)
)[0];
describe("a payment that lands after the pay window is not swallowed", () => {
let booking: ReadyBooking;
let invoiceId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const schedule = await createImportSchedule({ departure: DEPARTURE });
await forceWindowOpen(schedule.id, 45);
booking = await prepareBooking({
suffix: "LATE1",
departure: DEPARTURE,
runStamp: STAMP,
isoSeed: 0,
twenty: 2,
});
await runBatch(schedule.id);
invoiceId = (await invoiceForBooking(booking.bookingId)).id;
});
afterAll(closeDb);
it("holds the reservation while the drain tail runs", async () => {
// Deadline just behind now(): the settle tick sees it every 10s and must
// leave it alone — this is the customer who tapped pay in the last seconds.
await db(
`UPDATE freight.bookings SET payment_deadline = now() - interval '5 seconds'
WHERE id = $1`,
[booking.bookingId],
);
await new Promise((r) => setTimeout(r, INSIDE_TAIL_MS));
const held = await bookingRow(booking.bookingId);
expect(held.status).toBe("SELECTED_FOR_BATCH");
// The wagons are still HIS — releasing them at the raw deadline would sell
// them to the next customer while his settlement is still in flight.
expect(held.train_schedule_id).toBeTruthy();
expect((await currentInvoice(invoiceId)).status).not.toBe("EXPIRED");
}, 60_000);
it("expires the hold while the customer's payment is still in flight", async () => {
// Open the intent first — payInvoice refuses once dueAt is behind us, which
// is the point: no NEW payment may start, only an in-flight one may land.
const res = await payInvoice(invoiceId, { method: "CBE_BIRR" });
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
const intent = await gatewayIntent(booking.bookingId);
expect(intent.status).toBe("REQUIRES_ACTION");
expect((await currentInvoice(invoiceId)).payment_id).toBeTruthy();
// The provider reported a failure and we retired the intent — the same shape
// the payment API's own sweep writes. This is what makes the hold expirable:
// reconcile-before-expire skips FAILED candidates (intents.service.ts:412)
// and answers a clean "not paid". While the intent is live the mock answers
// PROCESSING → `unverifiable` → NOTHING expires the hold, not the settle tick
// and not staff. The money can still land afterwards: `applyProviderResult`
// registers a late capture on a retired intent (intents.service.ts:576).
await db(
`UPDATE edr_payment.payment_intent SET status = 'FAILED' WHERE id = $1`,
[intent.id],
);
await apiOk(chief, "post", `/api/train-scheduling/bookings/${booking.bookingId}/expire`);
const expired = await poll<{ status: string }>(
"invoice EXPIRED with the hold",
`SELECT status FROM freight.invoices WHERE id = $1`,
[invoiceId],
(row) => row?.status === "EXPIRED",
{ attempts: 20, intervalMs: 2000 },
);
expect(expired.status).toBe("EXPIRED");
expect((await bookingRow(booking.bookingId)).status).toBe("EXPIRED");
// The settlement correlation key survives the expiry — `paymentId` is what
// settleByPaymentId looks the invoice up by when the money finally lands.
const linked = (await currentInvoice(invoiceId)).payment_id!;
expect((await freightPayment(linked)).merchant_order_id).toBe(
intent.merchant_order_id,
);
}, 180_000);
it("settles the EXPIRED invoice and revives the booking when the money lands", async () => {
const intent = await gatewayIntent(booking.bookingId);
const res = await gateway.webhook({ merchantOrderId: intent.merchant_order_id });
expect(res.body.delivered).toBe(200);
const settled = await poll<{ status: string }>(
"payment intent SUCCEEDED",
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
[intent.id],
(row) => row?.status === "SUCCEEDED",
{ attempts: 20, intervalMs: 1000 },
);
expect(settled.status).toBe("SUCCEEDED");
// The bug: this row used to sit at EXPIRED with paid_amount null forever,
// because settleByPaymentId only matched OPEN_STATUSES.
const invoice = await poll<{ status: string; paid_amount: string; balance_amount: string }>(
"expired invoice settled by the late payment",
`SELECT status, paid_amount, balance_amount FROM freight.invoices WHERE id = $1`,
[invoiceId],
(row) => row?.status === "PAID",
{ attempts: 30, intervalMs: 2000 },
);
expect(Number(invoice.paid_amount)).toBeGreaterThan(0);
expect(Number(invoice.balance_amount)).toBe(0);
// …and the second swallow point: advanceBookingOnPayment used to refuse an
// EXPIRED booking outright, so the money landed on a booking that stayed dead.
const revived = await poll<{ status: string }>(
"expired booking revived by the late payment",
`SELECT status FROM freight.bookings WHERE id = $1`,
[booking.bookingId],
(row) => row?.status === "PAID",
{ attempts: 30, intervalMs: 2000 },
);
expect(revived.status).toBe("PAID");
expect((await bookingRow(booking.bookingId)).payment_status).toBe("PAID");
}, 180_000);
});

442
pnpm-lock.yaml generated
View File

@@ -595,7 +595,7 @@ importers:
version: 5.101.0(react@19.2.6)
'@tria-plc/iamui':
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)
'@vis.gl/react-google-maps':
specifier: ^1.8.3
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -821,11 +821,11 @@ importers:
specifier: ^8.1.0
version: 8.1.6
'@tria-plc/api-common':
specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz
version: file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3)
specifier: file:../../local-packages/tria-plc-api-common-1.6.0.tgz
version: file:local-packages/tria-plc-api-common-1.6.0.tgz(2b4e99ab22f78c34e7861d649f4ff29b)
'@tria-plc/iamapi-common':
specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.9.tgz
version: file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999)
specifier: file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz
version: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(cc085a020c559b355f168432c579a024)
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
@@ -4631,23 +4631,6 @@ packages:
'@tootallnate/quickjs-emscripten@0.23.0':
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz':
resolution: {integrity: sha512-cHlo96Wh3ET8qHjq5mevJwqitFRynU17KzEpE5fz0efCs9NIw7N3K6L+bYwConOCP7jbIROe/2rkG9ToY/AA3w==, tarball: file:local-packages/tria-plc-api-common-1.4.3.tgz}
version: 1.4.3
peerDependencies:
'@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0
'@nestjs/jwt': ^11.0.0
'@nestjs/microservices': ^11.0.0
'@nestjs/passport': ^11.0.0
'@nestjs/swagger': ^11.0.0
'@nestjs/throttler': ^6.0.0
'@nestjs/typeorm': ^11.0.0
'@tria-plc/iamapi-common': ^0.1.0
reflect-metadata: ^0.2.0
rxjs: ^7.8.0
typeorm: ^0.3.0
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.6.0.tgz':
resolution: {integrity: sha512-SZomla65xesBQZ12n8xH+9eX0TRbXNWToQ3SNURLhP1zlHJWUMTVHoRXTd5zWoe4mqah2Lr83L8ueHERsqCTFw==, tarball: file:local-packages/tria-plc-api-common-1.6.0.tgz}
version: 1.6.0
@@ -4664,28 +4647,6 @@ packages:
rxjs: ^7.8.0
typeorm: ^0.3.0
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.9.tgz':
resolution: {integrity: sha512-Y6SDEJUR4NcwLXrRJFZ+SbknpczybMwp59cR000vjFEu09RClLr5Gzv8TaJbOeDytyhdgjkZriVNPb2Dt6tipA==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.9.tgz}
version: 0.7.9
engines: {node: '>=20'}
peerDependencies:
'@nestjs/axios': ^4.0.0
'@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0
'@nestjs/jwt': ^11.0.0
'@nestjs/microservices': ^11.0.0
'@nestjs/passport': ^11.0.0
'@nestjs/swagger': ^11.0.0
'@nestjs/throttler': ^6.0.0
'@nestjs/typeorm': ^11.0.0
'@tria-plc/api-common': '*'
axios: ^1.9.0
class-transformer: ^0.5.1
class-validator: ^0.14.1
reflect-metadata: ^0.2.0
rxjs: ^7.8.0
typeorm: ^0.3.0
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz':
resolution: {integrity: sha512-rfHSXOm/0VUMTj7HrvYysrEAqxItqfPs4Rd35qWJyaAk+snF1wTKFRVz6cRGPoQNj8fhplkVAefnvuKBuHT+xQ==, tarball: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz}
version: 1.0.0
@@ -5598,9 +5559,6 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
api-common@1.2.2:
resolution: {integrity: sha512-2A3NpFNlOOvPY8Vq+g8Pokj6PdMk8fJpg58JpI/QuSXeB9JrS4YbRgzdexPBnJIUCNDHnjr+7S1vOsRRctHzyw==}
app-root-path@3.1.0:
resolution: {integrity: sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==}
engines: {node: '>= 6.0.0'}
@@ -5741,9 +5699,6 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
async@2.6.4:
resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
@@ -6302,10 +6257,6 @@ packages:
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
colors@1.0.3:
resolution: {integrity: sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==}
engines: {node: '>=0.1.90'}
colors@1.4.0:
resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==}
engines: {node: '>=0.1.90'}
@@ -6549,10 +6500,6 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
cycle@1.0.3:
resolution: {integrity: sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA==}
engines: {node: '>=0.4.0'}
cypress@15.18.1:
resolution: {integrity: sha512-JtkTVtUE2lvLYgZCaug+Uai0H9IqsJirlBO49c87QwG0bJUGvAUVBz1EJve0b0oaYP244Ew9M0BkrHpcqkYxmw==}
engines: {node: ^20.1.0 || ^22.0.0 || >=24.0.0}
@@ -6995,12 +6942,6 @@ packages:
resolution: {integrity: sha512-VyjaKxUmeDX/m2lxm/aknsJ1GWDWUO2Ze2Ad8S1Pb9dykAm9TjSKp5CjrNyltYqZ5W/PO6TInAmO2/BfwMyT1g==}
engines: {node: '>=0.10.0'}
error-tojson@0.0.1:
resolution: {integrity: sha512-zhtlVKgW0CgzltibgAlgi6oljh7L8k7jo61NuATFXodGMI2aiqAusW5FaiAtMcT8OLzdPkfhqh34I2XjxPI+Aw==}
errors@0.3.0:
resolution: {integrity: sha512-/4VTzspBdKkY8DE7VnjGYdHaSZdnQqQyOwYv3o2lwaKLhTvQmVATmoUCvFIFLVrn5kJqDHZl5ZltOu1Bit8rvg==}
es-abstract@1.24.2:
resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
engines: {node: '>= 0.4'}
@@ -7340,10 +7281,6 @@ packages:
resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==}
engines: {'0': node >=0.6.0}
eyes@0.1.8:
resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==}
engines: {node: '> 0.1.90'}
falsey@0.3.2:
resolution: {integrity: sha512-lxEuefF5MBIVDmE6XeqCdM4BWk1+vYmGZtkbKZ/VFcg6uBBw6fXNEbWmxCjDdQlFc9hy450nkiWwM3VAW6G1qg==}
engines: {node: '>=0.10.0'}
@@ -8742,9 +8679,6 @@ packages:
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
jwt-decode@2.2.0:
resolution: {integrity: sha512-86GgN2vzfUu7m9Wcj63iUkuDzFNYFVmjeDm2GzWpUk+opB0pEpMsw6ePCMrhYkumz2C1ihqtZzOMAg7FiXcNoQ==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -9271,9 +9205,6 @@ packages:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
minimist@0.0.8:
resolution: {integrity: sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -9292,11 +9223,6 @@ packages:
resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==}
engines: {node: '>=0.10.0'}
mkdirp@0.5.1:
resolution: {integrity: sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==}
deprecated: Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)
hasBin: true
mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
@@ -9860,10 +9786,6 @@ packages:
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
pkginfo@0.4.1:
resolution: {integrity: sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ==}
engines: {node: '>= 0.4.0'}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
@@ -10814,9 +10736,6 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
short-id-gen@1.1.2:
resolution: {integrity: sha512-rIxGIHcAhbf8jCgB6LYTeFC8jXffu4m0g+SXTljxGLkNhlMAq4jgQYPxvURtIX+tyqAx8YXuuh7j1qDVxJSZIA==}
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
@@ -10979,9 +10898,6 @@ packages:
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
stack-trace@0.0.10:
resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
stack-utils@2.0.6:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'}
@@ -12105,15 +12021,6 @@ packages:
engines: {node: '>=8'}
hasBin: true
winston-daily-rotate-file@1.7.2:
resolution: {integrity: sha512-bUkpSyWuDZVD2L7Ci/JrH09sIeqpwhQvmDrIAJ9PhUaewIbv9FTDTCvFnE2AFIIfDcTm7+AKiEKK4EP5lRL3fg==}
peerDependencies:
winston: 2.x
winston@2.4.7:
resolution: {integrity: sha512-vLB4BqzCKDnnZH9PHGoS2ycawueX4HLqENXQitvFHczhgW2vFpSOn31LZtVr1KU8YTw7DS4tM+cqyovxo8taVg==}
engines: {node: '>= 0.10.0'}
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
@@ -12391,11 +12298,11 @@ snapshots:
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -12430,7 +12337,7 @@ snapshots:
'@babel/helper-optimise-call-expression': 7.29.7
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/traverse': 7.29.7
semver: 6.3.1
transitivePeerDependencies:
- supports-color
@@ -12439,7 +12346,14 @@ snapshots:
'@babel/helper-member-expression-to-functions@7.29.7':
dependencies:
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-module-imports@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -12454,9 +12368,9 @@ snapshots:
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/helper-module-imports': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/traverse': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -12471,13 +12385,13 @@ snapshots:
'@babel/core': 7.29.7
'@babel/helper-member-expression-to-functions': 7.29.7
'@babel/helper-optimise-call-expression': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/traverse': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
dependencies:
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -12630,6 +12544,18 @@ snapshots:
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@babel/traverse@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-globals': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/types': 7.29.7
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
'@babel/traverse@7.29.7(supports-color@5.5.0)':
dependencies:
'@babel/code-frame': 7.29.7
@@ -12855,7 +12781,7 @@ snapshots:
'@emotion/babel-plugin@11.13.5':
dependencies:
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/helper-module-imports': 7.29.7
'@babel/runtime': 7.29.7
'@emotion/hash': 0.9.2
'@emotion/memoize': 0.9.0
@@ -13021,7 +12947,7 @@ snapshots:
'@eslint/eslintrc@2.1.4':
dependencies:
ajv: 6.15.0
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
espree: 9.6.1
globals: 13.24.0
ignore: 5.3.2
@@ -13181,7 +13107,7 @@ snapshots:
'@humanwhocodes/config-array@0.13.0':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -14378,7 +14304,7 @@ snapshots:
'@puppeteer/browsers@2.13.2':
dependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
extract-zip: 2.0.1
progress: 2.0.3
proxy-agent: 6.5.0
@@ -16446,7 +16372,7 @@ snapshots:
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
token-types: 6.1.2
transitivePeerDependencies:
- supports-color
@@ -16455,7 +16381,7 @@ snapshots:
'@tootallnate/quickjs-emscripten@0.23.0': {}
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3)':
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.6.0.tgz(2b4e99ab22f78c34e7861d649f4ff29b)':
dependencies:
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -16466,7 +16392,6 @@ snapshots:
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999)
argon2: 0.43.1
axios: 1.17.0
change-case: 5.4.4
@@ -16542,7 +16467,7 @@ snapshots:
- debug
- supports-color
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999)':
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(cc085a020c559b355f168432c579a024)':
dependencies:
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -16553,8 +16478,7 @@ snapshots:
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3)
api-common: 1.2.2
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.6.0.tgz(2b4e99ab22f78c34e7861d649f4ff29b)
argon2: 0.43.1
axios: 1.17.0
class-transformer: 0.5.1
@@ -16735,6 +16659,130 @@ snapshots:
- utf-8-validate
- vite
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)':
dependencies:
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
'@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6))
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 7.17.8(react@19.2.6)
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6)
'@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf/renderer': 4.5.1(react@19.2.6)
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
'@tabler/icons-react': 3.44.0(react@19.2.6)
'@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
'@tanstack/react-query': 5.101.0(react@19.2.6)
'@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6)
'@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3)
'@types/dompurify': 3.2.0
'@types/node': 24.13.1
'@types/tinymce': 4.6.9
axios: 1.17.0
class-variance-authority: 0.7.1
clsx: 2.1.1
cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
date-fns: 3.6.0
dayjs: 1.11.21
dompurify: 3.4.8
ethiopian-calendar-date-converter: 2.1.6
ethiopian-calendar-new: 1.1.0
file-type: 18.7.0
framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
html2canvas: 1.4.1
i18next: 25.10.10(typescript@5.9.3)
i18next-browser-languagedetector: 8.2.1
jquery: 3.7.1
js-cookie: 3.0.8
jspdf: 3.0.4
lodash: 4.18.1
lucide-react: 0.513.0(react@19.2.6)
mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d)
next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
path: 0.12.7
pdf-lib: 1.17.1
qs: 6.15.2
react: 19.2.6
react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6)
react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
react-dropzone: 14.4.1(react@19.2.6)
react-hook-form: 7.77.0(react@19.2.6)
react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react-icons: 5.6.0(react@19.2.6)
react-image-crop: 11.0.10(react@19.2.6)
react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1)
react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)
rollup-plugin-visualizer: 7.0.1(rollup@4.61.1)
socket.io-client: 4.8.3
sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwind-merge: 3.6.0
tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0)
tailwindcss: 4.3.0
tailwindcss-animate: 1.0.7(tailwindcss@4.3.0)
tinymce: 7.9.3
url: 0.11.4
vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
xlsx: 0.18.5
zod: 3.25.76
transitivePeerDependencies:
- '@babel/core'
- '@emotion/is-prop-valid'
- '@mui/icons-material'
- '@mui/material'
- '@mui/x-date-pickers'
- '@types/prop-types'
- '@types/react'
- '@types/react-dom'
- bufferutil
- debug
- pdfjs-dist
- prop-types
- react-is
- react-native
- redux
- rolldown
- rollup
- supports-color
- typescript
- utf-8-validate
- vite
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
@@ -17113,7 +17161,7 @@ snapshots:
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.60.1
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
eslint: 8.57.1
typescript: 5.9.3
transitivePeerDependencies:
@@ -17123,7 +17171,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
'@typescript-eslint/types': 8.60.1
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -17142,7 +17190,7 @@ snapshots:
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
eslint: 8.57.1
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
@@ -17157,7 +17205,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/visitor-keys': 8.60.1
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
minimatch: 10.2.5
semver: 7.8.2
tinyglobby: 0.2.17
@@ -17446,7 +17494,7 @@ snapshots:
agent-base@6.0.2:
dependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@@ -17686,17 +17734,6 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.2
api-common@1.2.2:
dependencies:
error-tojson: 0.0.1
errors: 0.3.0
jwt-decode: 2.2.0
moment: 2.30.1
pkginfo: 0.4.1
short-id-gen: 1.1.2
winston: 2.4.7
winston-daily-rotate-file: 1.7.2(winston@2.4.7)
app-root-path@3.1.0: {}
append-field@1.0.0: {}
@@ -17872,10 +17909,6 @@ snapshots:
async-function@1.0.0: {}
async@2.6.4:
dependencies:
lodash: 4.18.1
async@3.2.6: {}
asynckit@0.4.0: {}
@@ -17970,6 +18003,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0):
dependencies:
'@babel/helper-annotate-as-pure': 7.29.7
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
picomatch: 4.0.4
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
transitivePeerDependencies:
- supports-color
babel-polyfill@6.26.0:
dependencies:
babel-runtime: 6.26.0
@@ -18123,7 +18166,7 @@ snapshots:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
@@ -18503,8 +18546,6 @@ snapshots:
colorette@2.0.20: {}
colors@1.0.3: {}
colors@1.4.0:
optional: true
@@ -18738,8 +18779,6 @@ snapshots:
csstype@3.2.3: {}
cycle@1.0.3: {}
cypress@15.18.1:
dependencies:
'@cypress/request': 4.0.1
@@ -19124,7 +19163,7 @@ snapshots:
engine.io-client@6.6.5:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
engine.io-parser: 5.2.3
ws: 8.20.1
xmlhttprequest-ssl: 2.1.2
@@ -19144,7 +19183,7 @@ snapshots:
base64id: 2.0.0
cookie: 0.7.2
cors: 2.8.6
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
engine.io-parser: 5.2.3
ws: 8.21.0
transitivePeerDependencies:
@@ -19187,10 +19226,6 @@ snapshots:
error-symbol@0.1.0: {}
error-tojson@0.0.1: {}
errors@0.3.0: {}
es-abstract@1.24.2:
dependencies:
array-buffer-byte-length: 1.0.2
@@ -19377,7 +19412,7 @@ snapshots:
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
eslint: 8.57.1
get-tsconfig: 4.14.0
is-bun-module: 2.0.0
@@ -19505,7 +19540,7 @@ snapshots:
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@@ -19735,7 +19770,7 @@ snapshots:
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
@@ -19788,7 +19823,7 @@ snapshots:
extract-zip@2.0.1:
dependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
get-stream: 5.2.0
yauzl: 2.10.0
optionalDependencies:
@@ -19798,8 +19833,6 @@ snapshots:
extsprintf@1.3.0: {}
eyes@0.1.8: {}
falsey@0.3.2:
dependencies:
kind-of: 5.1.0
@@ -19941,7 +19974,7 @@ snapshots:
finalhandler@2.1.1:
dependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
@@ -20187,7 +20220,7 @@ snapshots:
dependencies:
basic-ftp: 5.3.1
data-uri-to-buffer: 6.0.2
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@@ -20468,7 +20501,7 @@ snapshots:
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@@ -20481,14 +20514,14 @@ snapshots:
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@@ -20913,7 +20946,7 @@ snapshots:
istanbul-lib-source-maps@4.0.1:
dependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
istanbul-lib-coverage: 3.2.2
source-map: 0.6.1
transitivePeerDependencies:
@@ -21445,8 +21478,6 @@ snapshots:
jwa: 2.0.1
safe-buffer: 5.2.1
jwt-decode@2.2.0: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -21561,7 +21592,7 @@ snapshots:
dependencies:
chalk: 5.6.2
commander: 13.1.0
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
execa: 8.0.1
lilconfig: 3.1.3
listr2: 8.3.3
@@ -21912,8 +21943,6 @@ snapshots:
dependencies:
brace-expansion: 2.1.1
minimist@0.0.8: {}
minimist@1.2.8: {}
minio@7.1.3:
@@ -21942,10 +21971,6 @@ snapshots:
for-in: 1.0.2
is-extendable: 1.0.1
mkdirp@0.5.1:
dependencies:
minimist: 0.0.8
mkdirp@0.5.6:
dependencies:
minimist: 1.2.8
@@ -22364,7 +22389,7 @@ snapshots:
dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0
agent-base: 7.1.4
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
get-uri: 6.0.5
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
@@ -22554,8 +22579,6 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
pkginfo@0.4.1: {}
playwright-core@1.61.1: {}
playwright@1.61.1:
@@ -22704,7 +22727,7 @@ snapshots:
proxy-agent@6.5.0:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
lru-cache: 7.18.3
@@ -22733,7 +22756,7 @@ snapshots:
dependencies:
'@puppeteer/browsers': 2.13.2
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
devtools-protocol: 0.0.1608973
typed-query-selector: 2.12.2
webdriver-bidi-protocol: 0.4.1
@@ -22986,6 +23009,15 @@ snapshots:
- '@babel/core'
- react-is
react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
dependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
transitivePeerDependencies:
- '@babel/core'
- react-is
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
dependencies:
date-fns: 3.6.0
@@ -23559,7 +23591,7 @@ snapshots:
router@2.2.0:
dependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
@@ -23677,7 +23709,7 @@ snapshots:
send@1.2.1:
dependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@@ -23809,8 +23841,6 @@ snapshots:
shebang-regex@3.0.0: {}
short-id-gen@1.1.2: {}
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
@@ -23895,7 +23925,7 @@ snapshots:
socket.io-adapter@2.5.8:
dependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
@@ -23905,7 +23935,7 @@ snapshots:
socket.io-client@4.8.3:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
engine.io-client: 6.6.5
socket.io-parser: 4.2.6
transitivePeerDependencies:
@@ -23916,7 +23946,7 @@ snapshots:
socket.io-parser@4.2.6:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@@ -23925,7 +23955,7 @@ snapshots:
accepts: 1.3.8
base64id: 2.0.0
cors: 2.8.6
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
engine.io: 6.6.9
socket.io-adapter: 2.5.8
socket.io-parser: 4.2.6
@@ -23937,7 +23967,7 @@ snapshots:
socks-proxy-agent@8.0.5:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
socks: 2.8.9
transitivePeerDependencies:
- supports-color
@@ -24012,8 +24042,6 @@ snapshots:
stable-hash@0.0.5: {}
stack-trace@0.0.10: {}
stack-utils@2.0.6:
dependencies:
escape-string-regexp: 2.0.0
@@ -24217,6 +24245,24 @@ snapshots:
transitivePeerDependencies:
- '@babel/core'
styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
dependencies:
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@emotion/is-prop-valid': 1.4.0
'@emotion/stylis': 0.8.5
'@emotion/unitless': 0.7.5
babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0)
css-to-react-native: 3.2.0
hoist-non-react-statics: 3.3.2
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-is: 19.2.7
shallowequal: 1.1.0
supports-color: 5.5.0
transitivePeerDependencies:
- '@babel/core'
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
dependencies:
client-only: 0.0.1
@@ -24242,7 +24288,7 @@ snapshots:
dependencies:
component-emitter: 1.3.1
cookiejar: 2.1.4
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
fast-safe-stringify: 2.1.1
form-data: 4.0.5
formidable: 3.5.4
@@ -24751,7 +24797,7 @@ snapshots:
app-root-path: 3.1.0
buffer: 6.0.3
dayjs: 1.11.21
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
dedent: 1.7.2(babel-plugin-macros@3.1.0)
dotenv: 16.6.1
glob: 10.5.0
@@ -24775,7 +24821,7 @@ snapshots:
app-root-path: 3.1.0
buffer: 6.0.3
dayjs: 1.11.21
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
dedent: 1.7.2(babel-plugin-macros@3.1.0)
dotenv: 16.6.1
glob: 10.5.0
@@ -25078,7 +25124,7 @@ snapshots:
vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0):
dependencies:
cac: 6.7.14
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
@@ -25096,7 +25142,7 @@ snapshots:
vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
dependencies:
cac: 6.7.14
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
@@ -25143,7 +25189,7 @@ snapshots:
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.3.3
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 1.1.2
@@ -25179,7 +25225,7 @@ snapshots:
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.3.3
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 1.1.2
@@ -25369,20 +25415,6 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
winston-daily-rotate-file@1.7.2(winston@2.4.7):
dependencies:
mkdirp: 0.5.1
winston: 2.4.7
winston@2.4.7:
dependencies:
async: 2.6.4
colors: 1.0.3
cycle: 1.0.3
eyes: 0.1.8
isstream: 0.1.2
stack-trace: 0.0.10
wmf@1.0.2: {}
word-wrap@1.2.5: {}

13
sonar-project.properties Normal file
View File

@@ -0,0 +1,13 @@
sonar.projectKey=edr-platform
sonar.projectName=EDR Platform
sonar.sources=apps,packages
sonar.exclusions=**/node_modules/**,**/dist/**,**/build/**,**/*.spec.ts,**/*.test.ts,**/*.e2e-spec.ts,**/coverage/**,**/.turbo/**,e2e/**,integration/**
sonar.tests=apps,packages
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts
sonar.javascript.lcov.reportPaths=apps/*/coverage/lcov.info,apps/edr-freight-web/*/coverage/lcov.info,apps/edr-passenger-web/*/coverage/lcov.info,packages/*/coverage/lcov.info
sonar.typescript.lcov.reportPaths=apps/*/coverage/lcov.info,apps/edr-freight-web/*/coverage/lcov.info,apps/edr-passenger-web/*/coverage/lcov.info,packages/*/coverage/lcov.info
sonar.sourceEncoding=UTF-8