feat(invoices): show and search what an invoice was raised against

`source` named the subsystem and `sourceId` was a raw UUID, so the list
could not say which record an invoice belonged to, and search matched
only the invoice number and that UUID — nobody types a UUID.

Every source except a shipping-line credit hangs off a booking, directly
or through the warehouse/first-mile/last-mile record, so the list read
now resolves each row to a booking reference, GRN or shipping line and
sends it as `sourceRef`. Search spans the same ground plus the customer
name, with the raw sourceId still matchable so a pasted UUID keeps
working.
This commit is contained in:
Nathnael
2026-08-20 11:36:50 +00:00
parent 05efdd5d54
commit f0d66ce8f9
3 changed files with 291 additions and 132 deletions

View File

@@ -46,6 +46,29 @@ export interface PayInvoiceOptions {
failureUrl?: string;
}
/**
* What an invoice's `sourceId` actually points at, resolved for display.
*
* `source` alone ("warehouse", "booking", …) says which subsystem raised the
* invoice but nothing about *which* record, and `sourceId` is a raw UUID. Every
* source except a shipping-line credit hangs off a booking — directly
* (booking/clearance) or through the warehouse/first-mile/last-mile record —
* so the booking reference is the one label that identifies almost any row.
*/
export interface InvoiceSourceRef {
/** Booking behind the invoice, when there is one. Null for shipping-line credits. */
bookingId: string | null;
bookingReference: string | null;
tradeDirection: string | null;
/** Warehouse-sourced rows: the goods-received note the fees were raised against. */
grnNumber: string | null;
/** Shipping-line credit rows: `sourceId` is the line's own id, not a record's. */
shippingLineName: string | null;
}
/** Row shape of the backoffice invoice list: the entity plus its resolved source. */
export type InvoiceListRow = Invoice & { sourceRef: InvoiceSourceRef | null };
/** Booking context attached to a finance offline-USD invoice row. */
export interface OfflineUsdBookingInfo {
id: string;
@@ -236,8 +259,32 @@ export class BillingService {
qb.andWhere("invoice.status = :status", { status: filter.status });
}
if (filter.search) {
// Searches what the row actually shows: its number, who it bills, and
// the source record behind it (booking reference, GRN, shipping line).
// The raw `sourceId` stays matchable so a pasted UUID still resolves.
// Requires the `company` alias — every caller of this joins it.
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
`(invoice.invoiceNumber ILIKE :search
OR invoice.sourceId ILIKE :search
OR company.name ILIKE :search
OR EXISTS (
SELECT 1 FROM freight.bookings b
LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id
LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id
LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id
WHERE b.reference ILIKE :search
AND (b.id::text = invoice.source_id
OR wi.id::text = invoice.source_id
OR fm.id::text = invoice.source_id
OR lm.id::text = invoice.source_id))
OR EXISTS (
SELECT 1 FROM freight.warehouse_inventory wi2
WHERE wi2.id::text = invoice.source_id
AND wi2.grn_number ILIKE :search)
OR EXISTS (
SELECT 1 FROM freight.shipping_line_companies slc
WHERE slc.id::text = invoice.source_id
AND slc.name ILIKE :search))`,
{ search: `%${filter.search}%` },
);
}
@@ -261,7 +308,7 @@ export class BillingService {
/** Per-user trade-direction scope, applied via the source booking. */
tradeDirections?: string[];
} = {},
): Promise<{ items: Invoice[]; total: number }> {
): Promise<{ items: InvoiceListRow[]; total: number }> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
@@ -277,7 +324,92 @@ export class BillingService {
this.applyInvoiceFilters(qb, filter);
const [items, total] = await qb.getManyAndCount();
return { items: await this.attachShippingLineCompanies(items), total };
const withLines = await this.attachShippingLineCompanies(items);
return { items: await this.attachSourceRefs(withLines), total };
}
/**
* Resolve each row's `sourceId` to the record it points at, in one query for
* the whole page. `sourceId` is a bare varchar pointer with no FK and no
* relation to eager-load, and which table it addresses depends on `source` —
* so this walks every candidate table at once and lands on the booking
* through whichever one matched.
*
* `sourceId` is not always a UUID (EIMS self-test rows carry a slug), hence
* the shape guard before every cast — an unguarded `::uuid` throws on those.
*/
private async attachSourceRefs<T extends Invoice>(
invoices: T[],
): Promise<(T & { sourceRef: InvoiceSourceRef | null })[]> {
const sourceIds = [
...new Set(invoices.map((i) => i.sourceId).filter(Boolean)),
];
if (!sourceIds.length) {
return invoices.map((invoice) => ({ ...invoice, sourceRef: null }));
}
const rows: {
sourceId: string;
bookingId: string | null;
bookingReference: string | null;
tradeDirection: string | null;
grnNumber: string | null;
shippingLineName: string | null;
}[] = await this.dataSource.query(
`SELECT s.source_id AS "sourceId",
b.id::text AS "bookingId",
b.reference AS "bookingReference",
b.trade_direction AS "tradeDirection",
wi.grn_number AS "grnNumber",
slc.name AS "shippingLineName"
FROM unnest($1::text[]) AS s(source_id)
LEFT JOIN freight.warehouse_inventory wi
ON wi.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
THEN s.source_id::uuid END)
AND wi.deleted_at IS NULL
LEFT JOIN freight.first_mile fm
ON fm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
THEN s.source_id::uuid END)
AND fm.deleted_at IS NULL
LEFT JOIN freight.last_mile lm
ON lm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
THEN s.source_id::uuid END)
AND lm.deleted_at IS NULL
LEFT JOIN freight.bookings b
ON b.id = COALESCE(wi.booking_id, fm.booking_id, lm.booking_id,
CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
THEN s.source_id::uuid END)
AND b.deleted_at IS NULL
LEFT JOIN freight.shipping_line_companies slc
ON slc.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
THEN s.source_id::uuid END)
AND slc.deleted_at IS NULL`,
[sourceIds],
);
const bySourceId = new Map(rows.map((r) => [r.sourceId, r]));
return invoices.map((invoice) => {
const row = bySourceId.get(invoice.sourceId);
const sourceRef: InvoiceSourceRef | null = row
? {
bookingId: row.bookingId,
bookingReference: row.bookingReference,
tradeDirection: row.tradeDirection,
grnNumber: row.grnNumber,
shippingLineName: row.shippingLineName,
}
: null;
// Nothing resolved (an EIMS self-test row, a deleted record) → null,
// and the UI falls back to the plain source label.
const resolved =
sourceRef &&
(sourceRef.bookingId ||
sourceRef.grnNumber ||
sourceRef.shippingLineName)
? sourceRef
: null;
return { ...invoice, sourceRef: resolved };
});
}
/**
@@ -336,6 +468,9 @@ export class BillingService {
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
// Joined, not selected: `applyInvoiceFilters` searches the customer name,
// so the alias has to exist even though the summary only sums money.
.leftJoin("invoice.company", "company")
.select("invoice.currency", "currency")
.addSelect("SUM(invoice.paidAmount)", "collected")
.groupBy("invoice.currency");