mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user