- /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
135 lines
No EOL
4.7 KiB
TypeScript
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();
|
|
} |