This commit adds several key features, improvements, and cleanups across the codebase: - Add `order_items.confirm_collected` MCP/REST endpoint to mark order items as collected without requiring driver assignment - Add standalone order creation and order split APIs for flexible order management - Update order list sort to oldest-first to align with user workflow of working through oldest orders first - Improve order search to show cross-tab matches and display source tab indicators on search results - Add customer name similarity check to warn about mismatched QuickBooks customers during invoice creation - Remove supplier name column from delivery note PDFs and clean up related data passing across delivery note routes - Add validation and debug logging to ItemIntelPopover for missing item ID on apply actions - Remove unused OrderItemContextPopoverTrigger component from order detail page - Add delivery note regeneration script and update test suites for new and existing functionality - Update documentation and add test coverage for the new collection confirm tool
148 lines
5.9 KiB
TypeScript
148 lines
5.9 KiB
TypeScript
import "./smoke/_env";
|
|
|
|
import { writeFile, mkdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin";
|
|
import { renderDeliveryNotePdf } from "../src/lib/pdf/render";
|
|
import { resolveDeliveryNoteLineQty } from "../src/lib/pdf/delivery-note-document";
|
|
import {
|
|
fetchLetterhead,
|
|
letterheadForCompany,
|
|
loadLetterheadLogoDataUri,
|
|
} from "../src/lib/letterhead";
|
|
|
|
type Item = {
|
|
id: string;
|
|
description?: string | null;
|
|
delivery_note_number?: string | null;
|
|
order_item_details?: unknown;
|
|
order_qty?: number | null;
|
|
qty_collected?: number | null;
|
|
order_item_attachements?: unknown;
|
|
expand?: {
|
|
order?: {
|
|
order_number?: string | null;
|
|
expand?: {
|
|
company?: { company?: string | null };
|
|
customer?: { customer?: string | null };
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
type Attachment = { token: string; filename: string };
|
|
|
|
type Result = {
|
|
deliveryNoteNumber: string;
|
|
items: number;
|
|
regenerated: number;
|
|
errors: string[];
|
|
};
|
|
|
|
function readDateField(value: unknown, key: string): string {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
|
|
const candidate = (value as Record<string, unknown>)[key];
|
|
return typeof candidate === "string" && candidate.length >= 10 ? candidate.slice(0, 10) : "";
|
|
}
|
|
|
|
function readAttachments(value: unknown): string[] {
|
|
if (typeof value === "string" && value.trim()) return [value.trim()];
|
|
if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
|
|
return [];
|
|
}
|
|
|
|
function filenameFromToken(token: string): string {
|
|
const match = token.match(/^[^_]+_[^_]+_(.+)$/);
|
|
return match?.[1] ?? token;
|
|
}
|
|
|
|
function isDeliveryNotePdf(filename: string, deliveryNoteNumber: string): boolean {
|
|
return filename.replace(/_[a-z0-9]{10,}(?=\.pdf$)/i, "").toLowerCase() === `${deliveryNoteNumber.toLowerCase()}.pdf`;
|
|
}
|
|
|
|
function parseArgs() {
|
|
const args = process.argv.slice(2);
|
|
const dryRun = args.includes("--dry-run");
|
|
const limitIndex = args.indexOf("--limit");
|
|
const limit = limitIndex >= 0 ? Number(args[limitIndex + 1]) : Infinity;
|
|
const explicit = args.flatMap((arg, index) => (arg === "--dn" ? [args[index + 1]] : []));
|
|
return { dryRun, limit: Number.isFinite(limit) && limit > 0 ? limit : Infinity, explicit };
|
|
}
|
|
|
|
async function main() {
|
|
const { dryRun, limit, explicit } = parseArgs();
|
|
const pb = await getAdminPocketBase();
|
|
const items = await pb.collection("order_items").getFullList<Item>({
|
|
filter: 'delivery_note_number != ""',
|
|
sort: "delivery_note_number,created",
|
|
expand: "order.company,order.customer",
|
|
});
|
|
const grouped = new Map<string, Item[]>();
|
|
for (const item of items) {
|
|
const number = item.delivery_note_number?.trim();
|
|
if (!number || (explicit.length > 0 && !explicit.includes(number))) continue;
|
|
const group = grouped.get(number) ?? [];
|
|
group.push(item);
|
|
grouped.set(number, group);
|
|
}
|
|
const groups = [...grouped.entries()].slice(0, limit);
|
|
if (groups.length === 0) throw new Error("No existing delivery notes matched.");
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
const reportDir = path.resolve(process.cwd(), "reports");
|
|
await mkdir(reportDir, { recursive: true });
|
|
const results: Result[] = [];
|
|
|
|
for (const [deliveryNoteNumber, noteItems] of groups) {
|
|
const result: Result = { deliveryNoteNumber, items: noteItems.length, regenerated: 0, errors: [] };
|
|
try {
|
|
const first = noteItems[0];
|
|
const order = first.expand?.order;
|
|
const companyName = order?.expand?.company?.company ?? "";
|
|
const letterheadCode = letterheadForCompany(companyName) ?? "CNL";
|
|
const [letterhead, logoDataUri] = await Promise.all([
|
|
fetchLetterhead(letterheadCode),
|
|
loadLetterheadLogoDataUri(letterheadCode),
|
|
]);
|
|
const pdfBuffer = await renderDeliveryNotePdf({
|
|
header: {
|
|
deliveryNoteNumber,
|
|
dateIso: readDateField(first.order_item_details, "deliveryNoteDate") || new Date().toISOString().slice(0, 10),
|
|
orderNumber: order?.order_number ?? "",
|
|
companyName,
|
|
customerName: order?.expand?.customer?.customer ?? "",
|
|
},
|
|
lines: noteItems.map((item) => ({
|
|
id: item.id,
|
|
description: (item.description ?? "").trim() || "(no description)",
|
|
qty: resolveDeliveryNoteLineQty({ orderQty: item.order_qty, qtyCollected: item.qty_collected }),
|
|
})),
|
|
letterhead,
|
|
logoDataUri,
|
|
});
|
|
if (!dryRun) {
|
|
for (const item of noteItems) {
|
|
const existing: Attachment[] = readAttachments(item.order_item_attachements).map((token) => ({ token, filename: filenameFromToken(token) }));
|
|
const preserved = existing.filter((attachment) => !isDeliveryNotePdf(attachment.filename, deliveryNoteNumber));
|
|
const form = new FormData();
|
|
for (const attachment of preserved) form.append("order_item_attachements", attachment.token);
|
|
form.append("order_item_attachements", new Blob([new Uint8Array(pdfBuffer)], { type: "application/pdf" }), `${deliveryNoteNumber}.pdf`);
|
|
await pb.collection("order_items").update(item.id, form);
|
|
result.regenerated += 1;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
}
|
|
results.push(result);
|
|
process.stdout.write(`${dryRun ? "Would regenerate" : "Regenerated"} ${deliveryNoteNumber}: ${result.items} item(s)${result.errors.length ? ` — ${result.errors.join("; ")}` : ""}\n`);
|
|
}
|
|
|
|
await writeFile(path.join(reportDir, `delivery-note-regen-${timestamp}.json`), JSON.stringify({ dryRun, results }, null, 2));
|
|
if (results.some((result) => result.errors.length > 0)) process.exitCode = 1;
|
|
}
|
|
|
|
void main().catch((error) => {
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exitCode = 1;
|
|
});
|