JEI_Import_Documents-App/app/lib/invoice-docs.ts
Mavis 5f73d2ea71 feat: Fedex import flow, orders + invoice APIs, payment bundles
- /fedex: batch create, document upload/replace/delete, save shipment, imported list
- /api/orders, /api/invoices, /api/pb-file, /api/pb/refresh-token, /api/stock-codes
- /api/payments/bundle: generate + download-all remittance bundles
- shared app/components/DocCell.tsx and app/lib/{invoice-docs,pb-fetch,pb-token}.ts
- recon: invoice reconciliation reports and PowerShell fix-plan scripts
- .gitignore: exclude one-off scripts/_* debugging artefacts, recon/pdfs+txts, data backups
2026-10-18 17:27:40 +02:00

135 lines
No EOL
4.7 KiB
TypeScript

// app/lib/invoice-docs.ts
// Resolves which import documents belong to each invoice.
//
// Chain: JEI_invoices <- JEI_fedex_shipments.invoice -> shipments.batch
// -> JEI_fedex_batch_documents.batch (doc_type + file)
//
// Invoices that were imported together sit on the same Fedex import batch and
// therefore share one set of documents. That falls out of the join - no
// per-invoice special casing.
//
// THREE bulk queries total, run sequentially. Never Promise.all here: the
// PocketBase SDK cancels concurrent requests on its shared singleton, which is
// what emptied the Imported table earlier.
import { getPb, pbFileUrl } from "./pb";
export const DOC_TYPES = [
"waybill",
"sad500",
"sad507",
"bill_of_entry",
"edi",
"clearing_instructions",
] as const;
export type DocType = (typeof DOC_TYPES)[number];
/** Column order used on both the Invoices and Unpaid tabs. */
export const DOC_COLUMNS: { key: DocType; label: string }[] = [
{ key: "waybill", label: "Waybill" },
{ key: "sad500", label: "SAD500" },
{ key: "sad507", label: "SAD507" },
{ key: "bill_of_entry", label: "Bill of Entry" },
{ key: "edi", label: "EDI" },
{ key: "clearing_instructions", label: "Clearing instr." },
];
export type DocLink = { id: string; name: string; url: string };
export type DocsByType = Record<DocType, DocLink[]>;
/** An invoice with nothing uploaded still needs every key present, so the
* renderer can index it blindly and print NONE. */
export function emptyDocs(): DocsByType {
return DOC_TYPES.reduce((acc, k) => {
acc[k] = [];
return acc;
}, {} as DocsByType);
}
/** PocketBase returns a single file field as a string, but normalise anyway so
* a future maxSelect change can't silently drop a document. */
function asNames(value: unknown): string[] {
if (Array.isArray(value)) return value.filter(Boolean).map(String);
if (typeof value === "string" && value.trim()) return [value];
return [];
}
export type InvoiceDocIndex = {
/** invoice record id -> documents, one bucket per doc_type */
docs: Map<string, DocsByType>;
/** invoice record id -> Fedex waybill number */
waybills: Map<string, string>;
};
export async function buildInvoiceDocIndex(): Promise<InvoiceDocIndex> {
const empty: InvoiceDocIndex = { docs: new Map(), waybills: new Map() };
const pb = getPb();
// 1) invoices -> import batches (+ the waybill number shown in its own column)
const batchByInvoice = new Map<string, Set<string>>();
const waybills = new Map<string, string>();
try {
const ships = await pb.collection("JEI_fedex_shipments").getList(1, 5000, {
fields: "id,invoice,batch,waybill_no",
});
for (const s of ships.items) {
if (!s.invoice) continue; // a shipment with no invoice still owns documents
if (s.waybill_no && !waybills.has(s.invoice)) waybills.set(s.invoice, s.waybill_no);
if (!s.batch) continue;
if (!batchByInvoice.has(s.invoice)) batchByInvoice.set(s.invoice, new Set());
batchByInvoice.get(s.invoice)!.add(s.batch);
}
} catch (_) {
return { docs: new Map(), waybills };
}
if (batchByInvoice.size === 0) return { docs: new Map(), waybills };
// 2) import batches -> documents, grouped by doc_type
const docsByBatch = new Map<string, DocsByType>();
try {
const docs = await pb.collection("JEI_fedex_batch_documents").getList(1, 5000, {
fields: "id,batch,doc_type,file",
});
for (const d of docs.items) {
if (!d.batch) continue;
const type = d.doc_type as DocType;
if (!DOC_TYPES.includes(type)) continue; // 'other' is not one of the columns
const names = asNames(d.file);
if (names.length === 0) continue;
if (!docsByBatch.has(d.batch)) docsByBatch.set(d.batch, emptyDocs());
const bucket = docsByBatch.get(d.batch)!;
for (const name of names) {
bucket[type].push({
id: d.id,
name,
// Keep the stored filename so a download keeps its real name.
url: pbFileUrl("JEI_fedex_batch_documents", d.id, "file", name),
});
}
}
} catch (_) {
return { docs: new Map(), waybills };
}
// 3) fan the batch documents out to every invoice on that batch
const docs = new Map<string, DocsByType>();
for (const [invoiceId, batchIds] of batchByInvoice) {
const merged = emptyDocs();
for (const bid of batchIds) {
const bucket = docsByBatch.get(bid);
if (!bucket) continue;
for (const t of DOC_TYPES) merged[t].push(...bucket[t]);
}
docs.set(invoiceId, merged);
}
return { docs, waybills };
}
/** Documents for one invoice, or an empty set when it has no import batch. */
export function docsFor(index: InvoiceDocIndex, invoiceId: string): DocsByType {
return index.docs.get(invoiceId) ?? emptyDocs();
}