WFDASH-WEBAPP/src/lib/server/supplier-side-lines-service.ts
Dave 2e55d21937 feat(collection): split short pickups on confirm, stop re-billing
Collection Confirm cuts a short line to what was collected (that part
invoices and closes) and creates the rest as a new pending line on the
same order, with Back order (same PO / straight to pool) on the process
page. Invoicing subtracts qty_invoiced so a part-invoiced line can't bill
twice. Supplier-side lines close at confirm when their order is billed,
and stragglers get a close button on Invoicing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 04:04:50 +02:00

128 lines
5.1 KiB
TypeScript

import { appendNote, type NoteActor } from "@/lib/note-feed";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
import { runWithAuditActor } from "@/lib/server/audit-context";
/**
* Supplier-side lines (`exclude_from_invoice = true`) never bill the
* customer. They used to close only when they rode along in the same
* QuickBooks batch as their order's billable lines. A component collected
* or confirmed after those lines were invoiced was left on the Invoicing
* page with "No billable lines" and no way off it (60 of them by
* 2026-10-22).
*
* Two ways out now:
* - automatic: Collection Confirm closes a supplier-side line straight
* to `invoiced` when every billable line on its order is already
* billed or further on (`billingDoneOrderIds`);
* - manual: the Invoicing page's "Close supplier-side lines" button on
* a card that has nothing left to bill (`closeSupplierSideLines`).
*/
/** Billable line states that mean the customer's invoice is done (or the line is gone). */
const BILLING_DONE_STATUSES = new Set([
"invoiced",
"with_driver",
"checked_in",
"delivered",
"completed",
"cancelled",
"removed",
]);
type SiblingRow = { id: string; order?: string; item_status?: string; exclude_from_invoice?: boolean };
/**
* Orders (from `orderIds`) whose billable lines are all billed already.
* An order with no billable line at all is NOT counted — there is nothing
* to say the customer side is finished. Rows in `pendingIds` are being
* moved on in the same request and count as still open.
*/
export function billingDoneOrderIdsFrom(
siblings: SiblingRow[],
orderIds: string[],
pendingIds: Set<string> = new Set(),
): Set<string> {
const done = new Set<string>();
for (const orderId of new Set(orderIds)) {
const billable = siblings.filter((s) => s.order === orderId && s.exclude_from_invoice !== true);
if (billable.length === 0) continue;
const allDone = billable.every(
(s) => !pendingIds.has(s.id) && BILLING_DONE_STATUSES.has(String(s.item_status ?? "")),
);
if (allDone) done.add(orderId);
}
return done;
}
type AdminPb = Awaited<ReturnType<typeof getAdminPocketBase>>;
export async function billingDoneOrderIds(
pb: AdminPb,
orderIds: string[],
pendingIds: Set<string> = new Set(),
): Promise<Set<string>> {
const unique = [...new Set(orderIds.filter(Boolean))];
if (unique.length === 0) return new Set();
const siblings = (await pb.collection(getOrderItemsCollectionName()).getFullList({
filter: unique.map((id) => `order="${id.replace(/"/g, '\\"')}"`).join(" || "),
fields: "id,order,item_status,exclude_from_invoice",
batch: 500,
})) as SiblingRow[];
return billingDoneOrderIdsFrom(siblings, unique, pendingIds);
}
export const SUPPLIER_SIDE_CLOSE_NOTE =
"Supplier-side line closed: it never goes on the customer invoice and the order's billable lines are already invoiced.";
export type CloseSupplierSideResult = {
closed: string[];
skipped: Array<{ id: string; reason: string }>;
};
/**
* Close supplier-side lines that are waiting on the Invoicing page.
* Refuses anything that isn't a `ready_invoice` supplier-side line, so a
* stale page can never close a billable line.
*/
export async function closeSupplierSideLines(
ids: string[],
actor: NoteActor,
): Promise<CloseSupplierSideResult> {
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 500 || ids.some((id) => typeof id !== "string" || !id.trim())) {
throw Object.assign(new Error("Choose between 1 and 500 lines."), { status: 400 });
}
return runWithAuditActor({ id: actor.id, name: actor.name, source: "web" }, async () => {
const pb = await getAdminPocketBase();
const collection = getOrderItemsCollectionName();
const rows = (await pb.collection(collection).getFullList({
filter: ids.map((id) => `id="${id.replace(/"/g, '\\"')}"`).join(" || "),
fields: "id,item_status,exclude_from_invoice,notes",
batch: 500,
})) as Array<{ id: string; item_status?: string; exclude_from_invoice?: boolean; notes?: string | null }>;
const result: CloseSupplierSideResult = { closed: [], skipped: [] };
const found = new Set(rows.map((r) => r.id));
for (const id of ids) if (!found.has(id)) result.skipped.push({ id, reason: "Line no longer exists." });
const batch = pb.createBatch();
const ts = new Date().toISOString();
for (const row of rows) {
if (row.exclude_from_invoice !== true) {
result.skipped.push({ id: row.id, reason: "Line bills the customer; invoice it instead." });
continue;
}
if (row.item_status !== "ready_invoice") {
result.skipped.push({ id: row.id, reason: `Line is ${row.item_status || "not ready to invoice"}.` });
continue;
}
batch.collection(collection).update(row.id, {
item_status: "invoiced",
notes: appendNote(row.notes, { ts, actor, category: "note", body: SUPPLIER_SIDE_CLOSE_NOTE }),
});
result.closed.push(row.id);
}
if (result.closed.length > 0) await batch.send();
return result;
});
}