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>
128 lines
5.1 KiB
TypeScript
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;
|
|
});
|
|
}
|