WFDASH-WEBAPP/src/app/(app)/collection-confirm/view-model.ts
Dave 48053a6205 feat: add support for stock purchase attachments
- update backend service to fetch stock purchase attachments
- refactor dedupe logic to avoid filename collisions between customer and stock attachments
- adjust PoCard to generate correct file URLs for stock attachments
- update test expectations for the modified service query
2026-08-14 18:25:05 +02:00

412 lines
12 KiB
TypeScript

export type NoteCategory = "collection" | "note" | "problem" | "quantity" | "confirm" | "cost";
export type NoteBody = string | { prevCost?: number | null; newCost?: number; [k: string]: unknown };
export type NoteEntry = {
ts: string;
actorId: string;
actorName: string;
category: NoteCategory;
body: NoteBody;
};
export type ConfirmActor = {
id: string;
name: string;
email?: string;
};
export type RawCollectionRow = {
id: string;
drv_collected_at?: string | null;
};
export type RowGuardInput = {
problem_flag?: boolean | null;
notes?: string | null;
};
export type InvoiceDetails = {
date: string;
number: string;
setBy: string;
setByName: string;
setAt: string;
};
export type ConfirmOptions = {
actor: ConfirmActor;
invoice: { date: string; number: string };
/**
* Optional cost override to write in the same patch as the
* `ready_invoice` status flip. When provided AND different from the
* current `row.cost_price`, the patch includes `cost_price:
* opts.costOverride`. When omitted (or equal to the current value),
* `cost_price` is not part of the patch — the caller can rely on
* absence to mean "don't touch the cost".
*/
costOverride?: number;
};
export type ConfirmPatch = {
item_status: string;
notes: string;
order_item_details: string;
cost_price?: number;
};
export type GroupableRow = {
id: string;
poNumber?: string | null;
};
export type PurchaseOrderGroup<T extends GroupableRow> = {
poNumber: string;
rows: T[];
};
const NOTE_CATEGORIES: NoteCategory[] = ["collection", "note", "problem", "quantity", "confirm", "cost"];
function isNoteEntry(value: unknown): value is NoteEntry {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
const bodyOk =
typeof candidate.body === "string" ||
(candidate.body !== null && typeof candidate.body === "object");
return (
typeof candidate.ts === "string" &&
typeof candidate.actorId === "string" &&
typeof candidate.actorName === "string" &&
bodyOk &&
typeof candidate.category === "string" &&
NOTE_CATEGORIES.includes(candidate.category as NoteCategory)
);
}
function parseStoredNotes(value: string): NoteEntry[] {
const trimmed = value.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isNoteEntry);
} catch {
return [];
}
}
export function parseStoredObject(value: string | Record<string, unknown> | null | undefined): Record<string, unknown> {
if (value === null || value === undefined) return {};
if (typeof value === "object") {
if (Array.isArray(value)) return {};
return value;
}
if (typeof value !== "string") return {};
const trimmed = value.trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return parsed as Record<string, unknown>;
} catch {
return {};
}
}
function isInvoiceDetails(value: unknown): value is InvoiceDetails {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.date === "string" &&
typeof candidate.number === "string" &&
typeof candidate.setBy === "string" &&
typeof candidate.setByName === "string" &&
typeof candidate.setAt === "string"
);
}
export function parseNotesFeed(rawNotes: string | null | undefined, row: RawCollectionRow): NoteEntry[] {
const stored = parseStoredNotes(rawNotes ?? "");
if (stored.length > 0) {
return [...stored].sort((a, b) => b.ts.localeCompare(a.ts));
}
if (row.drv_collected_at) {
return [
{
ts: row.drv_collected_at,
actorId: "driver",
actorName: "Driver",
category: "collection",
body: "Collected",
},
];
}
return [];
}
export function appendNote(
rawNotes: string | null | undefined,
entry: { ts: string; actor: ConfirmActor; category: NoteCategory; body: string },
): string {
const existing = parseStoredNotes(rawNotes ?? "");
const next: NoteEntry[] = [
...existing,
{
ts: entry.ts,
actorId: entry.actor.id,
actorName: entry.actor.name,
category: entry.category,
body: entry.body,
},
];
return JSON.stringify(next);
}
/**
* Append a "confirming note" — a note tagged with `category: "confirm"`.
* Used by the Add Note dialog when a problem row needs to be unlocked
* before it can be invoiced. Same shape as `appendNote`, but the
* category is forced so callers can't accidentally stamp a regular
* note where a confirming one is required.
*/
export function appendConfirmingNote(
rawNotes: string | null | undefined,
entry: { ts: string; actor: ConfirmActor; body: string },
): string {
return appendNote(rawNotes, { ...entry, category: "confirm" });
}
export function canConfirmRow(row: RowGuardInput, _actorName: string): boolean {
if (!row.problem_flag) return true;
const entries = parseStoredNotes(row.notes ?? "");
return entries.some((entry) => entry.category === "confirm");
}
export function nextItemStatusForConfirm(current: string): string {
if (current === "collected") return "ready_invoice";
return current;
}
export function readInvoiceDetails(raw: string | Record<string, unknown> | null | undefined): InvoiceDetails | null {
const stored = parseStoredObject(raw ?? "");
const candidate = stored.invoice;
if (!isInvoiceDetails(candidate)) return null;
return candidate;
}
export function writeInvoiceDetails(raw: string | Record<string, unknown> | null | undefined, invoice: InvoiceDetails): string {
const next = { ...parseStoredObject(raw ?? ""), invoice };
return JSON.stringify(next);
}
export function buildConfirmPatch(
row: {
id: string;
item_status?: string;
notes?: string | null;
order_item_details?: string | null;
cost_price?: number | null;
},
opts: ConfirmOptions,
): ConfirmPatch {
const ts = new Date().toISOString();
const confirmBody = buildConfirmNote(opts.invoice);
const notes = appendConfirmingNote(row.notes, {
ts,
actor: opts.actor,
body: confirmBody,
});
const order_item_details = writeInvoiceDetails(row.order_item_details, {
date: opts.invoice.date,
number: opts.invoice.number,
setBy: opts.actor.id,
setByName: opts.actor.name,
setAt: ts,
});
const patch: ConfirmPatch = {
item_status: nextItemStatusForConfirm(row.item_status ?? ""),
notes,
order_item_details,
};
if (
typeof opts.costOverride === "number" &&
opts.costOverride !== (row.cost_price ?? null)
) {
patch.cost_price = opts.costOverride;
}
return patch;
}
export function buildConfirmNote(invoice: Pick<InvoiceDetails, "date" | "number">): string {
return `Confirmed for invoicing — ${invoice.number} (${invoice.date})`;
}
export function groupRowsByPurchaseOrder<T extends GroupableRow>(rows: T[]): PurchaseOrderGroup<T>[] {
const order: string[] = [];
const map = new Map<string, T[]>();
for (const row of rows) {
const key = (row.poNumber ?? "").trim();
if (!map.has(key)) {
order.push(key);
map.set(key, []);
}
map.get(key)!.push(row);
}
return order.map((poNumber) => ({ poNumber, rows: map.get(poNumber)! }));
}
export type ReturnPatchInput = {
currentNotes: string | null | undefined;
actor: ConfirmActor;
reason: string;
ts: string;
};
export type ReturnPatch = {
item_status: string;
drv_id: string;
drv_status: string;
assigned_at: null;
invoice_status: null;
notes: string;
};
export function buildReturnPatch(input: ReturnPatchInput): ReturnPatch {
const body = `Returned to pool. ${input.reason.trim()}`.trimEnd();
const notes = appendNote(input.currentNotes, {
ts: input.ts,
actor: input.actor,
category: "note",
body,
});
return {
item_status: "collection_pool",
drv_id: "",
drv_status: "",
assigned_at: null,
invoice_status: null,
notes,
};
}
/**
* Append a cost audit entry unless the previous and new costs are equal.
*
* The return value is always a string: a JSON-encoded notes feed for a
* genuine cost change, or the original `rawNotes` unchanged on a no-op.
* Nullish `rawNotes` is normalized to an empty string for the no-op case.
*/
export function appendCostNote(
rawNotes: string | null | undefined,
actor: ConfirmActor,
prevCost: number | null,
newCost: number,
): string {
if (prevCost === newCost) return rawNotes ?? "";
const existing = parseStoredNotes(rawNotes ?? "");
const entry = {
id: `cost-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
ts: new Date().toISOString(),
actorId: actor.id,
actorName: actor.name,
category: "cost" as const,
body: { prevCost, newCost },
};
return JSON.stringify([...existing, entry]);
}
export type DedupedAttachment = {
filename: string;
/**
* IDs of the source rows that this attachment appears on. For
* customer rows these are `order_items.id`s; the chip's preview URL
* uses `/api/v1/files/order_items/{id}/{filename}`.
*/
itemIds: string[];
/**
* For stock-source rows the actual file lives on the PARENT
* `stock_purchases.additional_attachments` field — not on the
* line itself. `stockPurchaseId` is the id to use in the chip
* preview URL: `/api/v1/files/stock_purchases/{id}/{filename}`.
* Undefined for customer-only attachments.
*/
stockPurchaseId?: string;
};
export function dedupeAttachments(
rows: Array<{
id?: string;
order_item_attachements?: string[] | null;
source?: "stock" | "customer";
stock_purchase_id?: string | null;
stock_purchase_attachments?: string[] | null;
}>,
): DedupedAttachment[] {
// Order rows write to `order_items.order_item_attachements`; stock
// rows write to the parent `stock_purchases.additional_attachments`.
// Two separate maps so a filename that appears on both (rare but
// possible — the same file might be uploaded to both) doesn't get
// collapsed into the wrong preview URL.
const customerByFilename = new Map<string, string[]>();
const stockByFilename = new Map<
string,
{ itemIds: string[]; stockPurchaseId?: string }
>();
for (const row of rows) {
const seen = new Set<string>();
for (const filename of row.order_item_attachements ?? []) {
if (seen.has(filename)) continue;
seen.add(filename);
const bucket = customerByFilename.get(filename) ?? [];
if (row.id) bucket.push(row.id);
customerByFilename.set(filename, bucket);
}
if (row.stock_purchase_attachments && row.stock_purchase_attachments.length > 0) {
const stockSeen = new Set<string>();
const stockPurchaseId = row.stock_purchase_id ?? undefined;
for (const filename of row.stock_purchase_attachments) {
if (stockSeen.has(filename)) continue;
stockSeen.add(filename);
const existing = stockByFilename.get(filename) ?? {
itemIds: [],
stockPurchaseId,
};
if (row.id) existing.itemIds.push(row.id);
// Use the row's stock_purchase_id even if existing already has
// one — the parent PO is the same across the card.
if (stockPurchaseId) existing.stockPurchaseId = stockPurchaseId;
stockByFilename.set(filename, existing);
}
}
}
const out: DedupedAttachment[] = [];
for (const [filename, itemIds] of customerByFilename.entries()) {
out.push({ filename, itemIds });
}
for (const [filename, { itemIds, stockPurchaseId }] of stockByFilename.entries()) {
out.push({ filename, itemIds, stockPurchaseId });
}
return out;
}
/**
* Render a `NoteBody` as a human-readable string. Most categories
* (`collection`, `note`, `problem`, `quantity`, `confirm`) carry a
* plain string body; `cost` carries `{ prevCost, newCost }`. The UI
* just needs a string it can drop into a `<p>` tag.
*/
export function formatNoteBody(body: NoteBody): string {
if (typeof body === "string") return body;
if (body && typeof body === "object") {
if ("prevCost" in body || "newCost" in body) {
const prev = (body as { prevCost?: number | null }).prevCost;
const next = (body as { newCost?: number }).newCost;
if (typeof next === "number") {
const prevLabel = typeof prev === "number" ? String(prev) : "none";
return `Cost changed: ${prevLabel} → ${next}`;
}
}
return JSON.stringify(body);
}
return "";
}