- 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
412 lines
12 KiB
TypeScript
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 "";
|
|
}
|