fix(po): photographed POs get their order date from the PO Creation Date

Text PDFs already get it from pdftotext; photos and scans have no text,
so 4 of 5 undated POs were photos that do print a PO Creation Date.
- n8n PO reader (stored patch): asked for order_date = 'PO Creation
  Date' (day/month/year), never Required By or Printed; the callback
  passes it on.
- process-batch accepts extracted.order_date (YYYY-MM-DD); the PDF text
  wins when both exist.
- create_purchase_order / update_purchase_order / move_to_purchase_orders
  take order_date, so agents record it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-11 00:45:14 +02:00
parent a41653ce87
commit c55676bff0
6 changed files with 43 additions and 11 deletions

View file

@ -27,6 +27,8 @@ if (!po_number_normalized) {
}
const required_by_date = parsed.required_by_date || undefined;
// "PO Creation Date" printed on the PO: when it was ordered. Money is dated by it.
const order_date = /^\d{4}-\d{2}-\d{2}$/.test(String(parsed.order_date || '')) ? String(parsed.order_date) : undefined;
const ordered_by = parsed.ordered_by || undefined;
const rawItems = Array.isArray(parsed.items) ? parsed.items : [];
const items = rawItems.map(it => {
@ -67,6 +69,7 @@ return {
supplier,
project_code,
required_by_date,
order_date,
ordered_by,
items,
raw_ocr: { source: 'ai-agent', model: 'MiniMax-M3' },

View file

@ -120,7 +120,7 @@
"parameters": {
"text": "={{ $json.text || ($json.content && $json.content[0] && $json.content[0].text) || $json.output || \"\" }}",
"schemaType": "manual",
"inputSchema": "{\n \"type\": \"object\",\n \"required\": [\n \"po_number\",\n \"supplier\",\n \"project_code\",\n \"items\"\n ],\n \"properties\": {\n \"po_number\": {\n \"type\": \"string\",\n \"description\": \"Unique PO number printed on the document\"\n },\n \"supplier\": {\n \"type\": \"string\",\n \"description\": \"The supplier is the company receiving the order in the 'To' or supplier/vendor field (for example Kerbing Consultant). Mega Infrastructure (Pty) Ltd in the page heading is the buyer placing the order, not the supplier. Never invent a missing supplier. Return an empty string only if the recipient is unreadable.\"\n },\n \"project_code\": {\n \"type\": \"string\",\n \"description\": \"Printed project reference if present; use an empty string when missing. The upload already has a site.\"\n },\n \"required_by_date\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Required-by date in ISO YYYY-MM-DD format, or null if not stated\"\n },\n \"ordered_by\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Person who placed the order, or null if not shown\"\n },\n \"items\": {\n \"type\": \"array\",\n \"description\": \"Line items in the PO\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\n \"description\",\n \"qty\",\n \"rate\"\n ],\n \"properties\": {\n \"description\": {\n \"type\": \"string\",\n \"description\": \"Item description / part name\"\n },\n \"qty\": {\n \"type\": \"number\",\n \"description\": \"Quantity ordered (can be 0 or greater)\"\n },\n \"rate\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"description\": \"Unit rate excluding VAT (must be 0 or greater)\"\n },\n \"unit\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Unit of measure, or null if not specified\"\n },\n \"material_code\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Internal material code, or null if not present\"\n },\n \"cost_code\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Cost code or GL account, or null if not present\"\n },\n \"disc_percent\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"maximum\": 100,\n \"nullable\": true,\n \"description\": \"Discount percentage 0-100, or null if not applied\"\n },\n \"vat_percent\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"maximum\": 100,\n \"nullable\": true,\n \"description\": \"VAT percentage 0-100, or null if not applied\"\n }\n }\n }\n }\n }\n}",
"inputSchema": "{\n \"type\": \"object\",\n \"required\": [\n \"po_number\",\n \"supplier\",\n \"project_code\",\n \"items\"\n ],\n \"properties\": {\n \"po_number\": {\n \"type\": \"string\",\n \"description\": \"Unique PO number printed on the document\"\n },\n \"supplier\": {\n \"type\": \"string\",\n \"description\": \"The supplier is the company receiving the order in the 'To' or supplier/vendor field (for example Kerbing Consultant). Mega Infrastructure (Pty) Ltd in the page heading is the buyer placing the order, not the supplier. Never invent a missing supplier. Return an empty string only if the recipient is unreadable.\"\n },\n \"project_code\": {\n \"type\": \"string\",\n \"description\": \"Printed project reference if present; use an empty string when missing. The upload already has a site.\"\n },\n \"required_by_date\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Required-by date in ISO YYYY-MM-DD format, or null if not stated\"\n },\n \"order_date\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"The 'PO Creation Date' (order date) printed on the PO, in ISO YYYY-MM-DD format. Dates on our POs are day/month/year (15/09/2026 = 2026-09-15). Not the 'Required By' date and not the 'Printed' date. Null only if no creation/order date is printed.\"\n },\n \"ordered_by\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Person who placed the order, or null if not shown\"\n },\n \"items\": {\n \"type\": \"array\",\n \"description\": \"Line items in the PO\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\n \"description\",\n \"qty\",\n \"rate\"\n ],\n \"properties\": {\n \"description\": {\n \"type\": \"string\",\n \"description\": \"Item description / part name\"\n },\n \"qty\": {\n \"type\": \"number\",\n \"description\": \"Quantity ordered (can be 0 or greater)\"\n },\n \"rate\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"description\": \"Unit rate excluding VAT (must be 0 or greater)\"\n },\n \"unit\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Unit of measure, or null if not specified\"\n },\n \"material_code\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Internal material code, or null if not present\"\n },\n \"cost_code\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Cost code or GL account, or null if not present\"\n },\n \"disc_percent\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"maximum\": 100,\n \"nullable\": true,\n \"description\": \"Discount percentage 0-100, or null if not applied\"\n },\n \"vat_percent\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"maximum\": 100,\n \"nullable\": true,\n \"description\": \"VAT percentage 0-100, or null if not applied\"\n }\n }\n }\n }\n }\n}",
"options": {
"systemPromptTemplate": "Extract the purchase order header and every line item from the document text. The supplier is the company receiving the order in the 'To' or supplier/vendor field (for example Kerbing Consultant). Mega Infrastructure (Pty) Ltd in the page heading is the buyer placing the order, not the supplier. Never invent a missing supplier. Read supplier from 'To'. Use the printed required-by date in YYYY-MM-DD. Preserve material_code and cost_code for every row. Only po_number and usable items are needed to process; use an empty string for an unreadable supplier or missing project_code. Never invent values."
}
@ -132,7 +132,7 @@
"nodeName": "Structured Output Parser1",
"parameters": {
"schemaType": "manual",
"inputSchema": "{\n \"type\": \"object\",\n \"required\": [\n \"po_number\",\n \"supplier\",\n \"project_code\",\n \"items\"\n ],\n \"properties\": {\n \"po_number\": {\n \"type\": \"string\",\n \"description\": \"Unique PO number printed on the document\"\n },\n \"supplier\": {\n \"type\": \"string\",\n \"description\": \"The supplier is the company receiving the order in the 'To' or supplier/vendor field (for example Kerbing Consultant). Mega Infrastructure (Pty) Ltd in the page heading is the buyer placing the order, not the supplier. Never invent a missing supplier. Return an empty string only if the recipient is unreadable.\"\n },\n \"project_code\": {\n \"type\": \"string\",\n \"description\": \"Printed project reference if present; use an empty string when missing. The upload already has a site.\"\n },\n \"required_by_date\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Required-by date in ISO YYYY-MM-DD format, or null if not stated\"\n },\n \"ordered_by\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Person who placed the order, or null if not shown\"\n },\n \"items\": {\n \"type\": \"array\",\n \"description\": \"Line items in the PO\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\n \"description\",\n \"qty\",\n \"rate\"\n ],\n \"properties\": {\n \"description\": {\n \"type\": \"string\",\n \"description\": \"Item description / part name\"\n },\n \"qty\": {\n \"type\": \"number\",\n \"description\": \"Quantity ordered (can be 0 or greater)\"\n },\n \"rate\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"description\": \"Unit rate excluding VAT (must be 0 or greater)\"\n },\n \"unit\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Unit of measure, or null if not specified\"\n },\n \"material_code\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Internal material code, or null if not present\"\n },\n \"cost_code\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Cost code or GL account, or null if not present\"\n },\n \"disc_percent\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"maximum\": 100,\n \"nullable\": true,\n \"description\": \"Discount percentage 0-100, or null if not applied\"\n },\n \"vat_percent\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"maximum\": 100,\n \"nullable\": true,\n \"description\": \"VAT percentage 0-100, or null if not applied\"\n }\n }\n }\n }\n }\n}",
"inputSchema": "{\n \"type\": \"object\",\n \"required\": [\n \"po_number\",\n \"supplier\",\n \"project_code\",\n \"items\"\n ],\n \"properties\": {\n \"po_number\": {\n \"type\": \"string\",\n \"description\": \"Unique PO number printed on the document\"\n },\n \"supplier\": {\n \"type\": \"string\",\n \"description\": \"The supplier is the company receiving the order in the 'To' or supplier/vendor field (for example Kerbing Consultant). Mega Infrastructure (Pty) Ltd in the page heading is the buyer placing the order, not the supplier. Never invent a missing supplier. Return an empty string only if the recipient is unreadable.\"\n },\n \"project_code\": {\n \"type\": \"string\",\n \"description\": \"Printed project reference if present; use an empty string when missing. The upload already has a site.\"\n },\n \"required_by_date\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Required-by date in ISO YYYY-MM-DD format, or null if not stated\"\n },\n \"order_date\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"The 'PO Creation Date' (order date) printed on the PO, in ISO YYYY-MM-DD format. Dates on our POs are day/month/year (15/09/2026 = 2026-09-15). Not the 'Required By' date and not the 'Printed' date. Null only if no creation/order date is printed.\"\n },\n \"ordered_by\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Person who placed the order, or null if not shown\"\n },\n \"items\": {\n \"type\": \"array\",\n \"description\": \"Line items in the PO\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\n \"description\",\n \"qty\",\n \"rate\"\n ],\n \"properties\": {\n \"description\": {\n \"type\": \"string\",\n \"description\": \"Item description / part name\"\n },\n \"qty\": {\n \"type\": \"number\",\n \"description\": \"Quantity ordered (can be 0 or greater)\"\n },\n \"rate\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"description\": \"Unit rate excluding VAT (must be 0 or greater)\"\n },\n \"unit\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Unit of measure, or null if not specified\"\n },\n \"material_code\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Internal material code, or null if not present\"\n },\n \"cost_code\": {\n \"type\": \"string\",\n \"nullable\": true,\n \"description\": \"Cost code or GL account, or null if not present\"\n },\n \"disc_percent\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"maximum\": 100,\n \"nullable\": true,\n \"description\": \"Discount percentage 0-100, or null if not applied\"\n },\n \"vat_percent\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"maximum\": 100,\n \"nullable\": true,\n \"description\": \"VAT percentage 0-100, or null if not applied\"\n }\n }\n }\n }\n }\n}",
"autoFix": "={{ true }}"
},
"replace": true
@ -145,7 +145,7 @@
"text": "={{ \"Extract the purchase order data from the attached image.\" }}",
"hasOutputParser": true,
"options": {
"systemMessage": "You are a purchase order parser. Given the attached PDF or scanned image, extract the purchase order header and line items.\n\nReturn ONLY a JSON object (no markdown, no commentary) matching this exact schema:\n\n{\n \"po_number\": string,\n \"supplier\": string,\n \"project_code\": string,\n \"required_by_date\": \"YYYY-MM-DD\" | null,\n \"ordered_by\": string | null,\n \"items\": [\n {\n \"description\": string,\n \"qty\": number,\n \"rate\": number,\n \"unit\": string | null,\n \"material_code\": string | null,\n \"cost_code\": string | null,\n \"disc_percent\": number | null,\n \"vat_percent\": number | null\n }\n ]\n}\n\nRules:\n- Use null (not \"\") for missing optional fields.\n- disc_percent and vat_percent must be 0-100 if provided.\n- Drop any line item missing description, qty, or rate.\n- po_number and usable items are required. If supplier or project_code is unreadable, return an empty string. The supplier is the company receiving the order in the 'To' or supplier/vendor field (for example Kerbing Consultant). Mega Infrastructure (Pty) Ltd in the page heading is the buyer placing the order, not the supplier. Never invent a missing supplier.",
"systemMessage": "You are a purchase order parser. Given the attached PDF or scanned image, extract the purchase order header and line items.\n\nReturn ONLY a JSON object (no markdown, no commentary) matching this exact schema:\n\n{\n \"po_number\": string,\n \"supplier\": string,\n \"project_code\": string,\n \"order_date\": \"YYYY-MM-DD\" | null,\n \"required_by_date\": \"YYYY-MM-DD\" | null,\n \"ordered_by\": string | null,\n \"items\": [\n {\n \"description\": string,\n \"qty\": number,\n \"rate\": number,\n \"unit\": string | null,\n \"material_code\": string | null,\n \"cost_code\": string | null,\n \"disc_percent\": number | null,\n \"vat_percent\": number | null\n }\n ]\n}\n\nRules:\n- Use null (not \"\") for missing optional fields.\n- disc_percent and vat_percent must be 0-100 if provided.\n- Drop any line item missing description, qty, or rate.\n- po_number and usable items are required. If supplier or project_code is unreadable, return an empty string. The supplier is the company receiving the order in the 'To' or supplier/vendor field (for example Kerbing Consultant). Mega Infrastructure (Pty) Ltd in the page heading is the buyer placing the order, not the supplier. Never invent a missing supplier.\n- order_date is the 'PO Creation Date' printed on the PO (day/month/year on our POs: 15/09/2026 = 2026-09-15). It is when the order was placed. Never use the 'Required By' date or the 'Printed' date for it. Read it from photos and scans too.",
"passthroughBinaryImages": "={{ true }}",
"passthroughBinaryPdfs": "={{ false }}"
}
@ -260,7 +260,7 @@
},
"conditions": [
{
"leftValue": "={{ ($('Build Callback Payload').item.json.extracted.supplier.trim().replace(/\\s+/g, ' ').toUpperCase()).length > 0 && ($('Build Callback Payload').item.json.extracted.supplier.trim().replace(/\\s+/g, ' ').toUpperCase()).length <= 200 && ![\"—\", \"-\", \"N/A\", \"NA\", \"UNKNOWN\", \"NOT SPECIFIED\", \"NOT SPECIFIED IN DOCUMENT\", \"NULL\"].includes($('Build Callback Payload').item.json.extracted.supplier.trim().replace(/\\s+/g, ' ').toUpperCase()) }}",
"leftValue": "={{ ($('Build Callback Payload').item.json.extracted.supplier.trim().replace(/\\s+/g, ' ').toUpperCase()).length > 0 && ($('Build Callback Payload').item.json.extracted.supplier.trim().replace(/\\s+/g, ' ').toUpperCase()).length <= 200 && ![\"\u2014\", \"-\", \"N/A\", \"NA\", \"UNKNOWN\", \"NOT SPECIFIED\", \"NOT SPECIFIED IN DOCUMENT\", \"NULL\"].includes($('Build Callback Payload').item.json.extracted.supplier.trim().replace(/\\s+/g, ' ').toUpperCase()) }}",
"operator": {
"type": "boolean",
"operation": "equals"

View file

@ -141,6 +141,25 @@ describe("POST /api/purchase-orders/process-batch", () => {
expect(updateMock.mock.calls.find(c=>c[1]?.purchase_order_number==="MEGCPO9392")?.[1]).not.toHaveProperty("order_date");
});
it("uses the creation date the reader saw on a photographed PO, but the PDF text wins when both exist", async () => {
getOneMock.mockResolvedValue({id:ID,site:"site1",processing_status:"processing"});
getFullListMock.mockResolvedValue([]);
// A photo: no PDF text to read, so the AI-read creation date is used.
expect((await POST(postWithValidAuth({purchase_order_id:ID,extracted:{...validExtraction("MEGCPO9393"),order_date:"2026-09-15"}}))).status).toBe(200);
expect(updateMock.mock.calls.find(c=>c[1]?.purchase_order_number==="MEGCPO9393")?.[1]).toMatchObject({order_date:"2026-09-15 00:00:00.000Z"});
updateMock.mockReset();
vi.mocked(fetchPurchaseOrderDocument).mockResolvedValueOnce({bytes:Buffer.from("%PDF"),mime:"application/pdf",filename:"po.pdf"});
vi.mocked(readPoOrderDate).mockResolvedValueOnce("2026-09-14");
expect((await POST(postWithValidAuth({purchase_order_id:ID,extracted:{...validExtraction("MEGCPO9394"),order_date:"2026-09-15"}}))).status).toBe(200);
expect(updateMock.mock.calls.find(c=>c[1]?.purchase_order_number==="MEGCPO9394")?.[1]).toMatchObject({order_date:"2026-09-14 00:00:00.000Z"});
});
it("rejects an order date that is not YYYY-MM-DD", async () => {
getOneMock.mockResolvedValue({id:ID,site:"site1",processing_status:"processing"});
const res=await POST(postWithValidAuth({purchase_order_id:ID,extracted:{...validExtraction("MEGCPO9395"),order_date:"15/09/2026"}}));
expect(res.status).toBe(400);
});
it("returns 401 when x-ingest-secret is missing", async () => {
const res = await POST(
makeRequest({ body: { purchase_order_id: ID, extracted: validExtraction("MEGCPO9199") } }),

View file

@ -33,6 +33,8 @@ type ExtractedPurchaseOrder = {
project_code?: string;
supplier?: string;
required_by_date?: string;
/** "PO Creation Date" as the reader saw it (YYYY-MM-DD): the only date on a photographed PO. */
order_date?: string;
ordered_by?: string;
items: ExtractedItem[];
};
@ -287,6 +289,9 @@ export async function POST(request: Request) {
} catch (err) {
console.warn("[po-process] order date not read", { purchase_order_id: purchaseOrderId, err: String(err) });
}
// A photographed PO has no text layer; then the creation date the reader
// saw on the photo is the order date.
orderDate = orderDate ?? extracted.order_date ?? null;
try {
await pb.collection("purchase_orders").update(purchaseOrderId, {
purchase_order_number: extracted.purchase_order_number,
@ -403,6 +408,10 @@ function validateExtracted(input: unknown): ValidationResult {
) {
details.required_by_date = "must be ISO date YYYY-MM-DD";
}
if (p.order_date !== undefined && p.order_date !== null && p.order_date !== "" &&
(typeof p.order_date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(p.order_date))) {
details.order_date = "must be ISO date YYYY-MM-DD";
}
if (p.ordered_by !== undefined && typeof p.ordered_by !== "string") {
details.ordered_by = "must be a string";
}
@ -467,6 +476,7 @@ function validateExtracted(input: unknown): ValidationResult {
project_code: p.project_code as string | undefined,
supplier: p.supplier as string | undefined,
required_by_date: p.required_by_date as string | undefined,
order_date: typeof p.order_date === "string" && p.order_date ? p.order_date : undefined,
ordered_by: p.ordered_by as string | undefined,
items: p.items as ExtractedItem[],
},

View file

@ -7,7 +7,7 @@ import {PurchasingError} from './errors';
import {PurchaseOrderItemSchema,itemFields,orderTotals} from './purchase-orders';
import {setAsideWithinLease} from './document-disposition';
const OrderSchema=z.object({number:z.string().trim().min(1).max(200),supplierId:RecordId,required_by_date:z.string().datetime().optional(),ordered_by:z.string().max(200).optional(),items:z.array(PurchaseOrderItemSchema).min(1).max(45)}).strict();
const OrderSchema=z.object({number:z.string().trim().min(1).max(200),supplierId:RecordId,required_by_date:z.string().datetime().optional(),order_date:z.string().datetime().optional(),ordered_by:z.string().max(200).optional(),items:z.array(PurchaseOrderItemSchema).min(1).max(45)}).strict();
export const MoveToPurchaseOrdersSchema=MutationSchema.extend({batchId:RecordId,id:RecordId,reason:z.string().trim().min(1).max(2000),order:OrderSchema.optional()}).strict();
/** Buffered commits run once, so a clock-seeded id cannot collide on a legitimate repeat. */
const auditId=(...parts:Array<string|number>)=>hash('sha256',parts.join('|')).slice(0,15);
@ -49,7 +49,7 @@ export async function moveToPurchaseOrders(ctx:PurchasingContext,input:Record<st
if(existing.some(row=>row.purchase_order_number===order.number))throw new PurchasingError('purchase_order_exists',409);
const supplier=await ctx.pb.collection('suppliers').getOne(order.supplierId,{fields:'id,name'});
const items=order.items.map(itemFields),totals=orderTotals(items);
await lease.create('purchase_orders',{...shared,site:batch.site,purchase_order_number:order.number,supplier_ref:order.supplierId,supplier:supplier.name??'',required_by_date:order.required_by_date??null,ordered_by:order.ordered_by??'',processing_status:'processed',is_verified:false,...totals});
await lease.create('purchase_orders',{...shared,site:batch.site,purchase_order_number:order.number,supplier_ref:order.supplierId,supplier:supplier.name??'',required_by_date:order.required_by_date??null,order_date:order.order_date??null,ordered_by:order.ordered_by??'',processing_status:'processed',is_verified:false,...totals});
for(const [index,item] of items.entries())await lease.create('purchase_order_items',{id:hash('sha256',`${root}:item:${index}`).slice(0,15),purchase_order:id,...item});
number=order.number;
}else{

View file

@ -8,15 +8,15 @@ import {RecordId} from './documents';
import {PurchasingError} from './errors';
import {computeItemAmount,validatePurchaseOrderItemEdit,type PurchaseOrderItemPayload} from '@/lib/purchase-order-schema';
export const PurchaseOrderItemSchema=z.object({description:z.string().min(1).max(2000),qty:z.number().finite().min(0),rate:z.number().finite().min(0),unit:z.string().max(100).optional(),material_code:z.string().max(200).optional(),cost_code:z.string().max(200).optional(),disc_percent:z.number().finite().min(0).max(100).optional(),vat_percent:z.number().finite().min(0).max(100).optional()}).strict();
export const CreatePurchaseOrderSchema=MutationSchema.extend({site:RecordId,supplierId:RecordId,number:z.string().trim().min(1).max(200),required_by_date:z.string().datetime().optional(),ordered_by:z.string().max(200).optional(),items:z.array(PurchaseOrderItemSchema).min(1).max(45)}).strict();
export const UpdatePurchaseOrderSchema=MutationSchema.extend({id:RecordId,purchase_order_number:z.string().trim().min(1).max(200).optional(),supplierId:RecordId.optional(),required_by_date:z.string().datetime().nullable().optional(),ordered_by:z.string().max(200).optional()}).strict();
export const CreatePurchaseOrderSchema=MutationSchema.extend({site:RecordId,supplierId:RecordId,number:z.string().trim().min(1).max(200),required_by_date:z.string().datetime().optional(),order_date:z.string().datetime().optional(),ordered_by:z.string().max(200).optional(),items:z.array(PurchaseOrderItemSchema).min(1).max(45)}).strict();
export const UpdatePurchaseOrderSchema=MutationSchema.extend({id:RecordId,purchase_order_number:z.string().trim().min(1).max(200).optional(),supplierId:RecordId.optional(),required_by_date:z.string().datetime().nullable().optional(),order_date:z.string().datetime().nullable().optional(),ordered_by:z.string().max(200).optional()}).strict();
export const AddPurchaseOrderItemSchema=MutationSchema.extend({id:RecordId,item:PurchaseOrderItemSchema}).strict();
export const UpdatePurchaseOrderItemSchema=AddPurchaseOrderItemSchema.extend({itemId:RecordId}).strict();
export const VerifyPurchaseOrderSchema=MutationSchema.extend({id:RecordId,verified:z.boolean()}).strict();
export type PurchaseOrderAction='create_purchase_order'|'update_purchase_order'|'add_purchase_order_item'|'update_purchase_order_item'|'set_purchase_order_verification';
const schemas={create_purchase_order:CreatePurchaseOrderSchema,update_purchase_order:UpdatePurchaseOrderSchema,add_purchase_order_item:AddPurchaseOrderItemSchema,update_purchase_order_item:UpdatePurchaseOrderItemSchema,set_purchase_order_verification:VerifyPurchaseOrderSchema};
export async function requireEditableOrder(pb:PurchasingContext['pb'],id:string):Promise<RecordModel>{
const po=await pb.collection('purchase_orders').getOne(id,{fields:'id,site,supplier_ref,processing_status,is_verified,purchase_order_number,required_by_date,ordered_by,total_value,items_count'});
const po=await pb.collection('purchase_orders').getOne(id,{fields:'id,site,supplier_ref,processing_status,is_verified,purchase_order_number,required_by_date,order_date,ordered_by,total_value,items_count'});
if(['pending','processing','failed'].includes(po.processing_status))throw new PurchasingError('po_processing_incomplete',409);
if(po.is_verified)throw new PurchasingError('po_verified',409);return po;
}
@ -39,7 +39,7 @@ async function performOrderMutation(ctx:PurchasingContext,lease:BatchLeaseContex
const input=CreatePurchaseOrderSchema.parse(args);
const [,supplier]=await Promise.all([pb.collection('sites').getOne(input.site),pb.collection('suppliers').getOne(input.supplierId,{fields:'id,name'})]);
const items=input.items.map(itemFields),totals=orderTotals(items);
await lease.create('purchase_orders',{id,site:input.site,purchase_order_number:input.number,supplier_ref:input.supplierId,supplier:supplier.name??'',required_by_date:input.required_by_date??null,ordered_by:input.ordered_by??'',processing_status:'processed',is_verified:false,...totals});
await lease.create('purchase_orders',{id,site:input.site,purchase_order_number:input.number,supplier_ref:input.supplierId,supplier:supplier.name??'',required_by_date:input.required_by_date??null,order_date:input.order_date??null,ordered_by:input.ordered_by??'',processing_status:'processed',is_verified:false,...totals});
const itemIds:string[]=[];
for(const [index,item] of items.entries()){const itemId=hash('sha256',`${root}:item:${index}`).slice(0,15);itemIds.push(itemId);await lease.create('purchase_order_items',{id:itemId,purchase_order:id,...item});}
return {id,itemIds,...totals};
@ -52,7 +52,7 @@ async function performOrderMutation(ctx:PurchasingContext,lease:BatchLeaseContex
const po=await requireEditableOrder(pb,id);
if(action==='update_purchase_order'){
const input=UpdatePurchaseOrderSchema.parse(args);const after:Record<string,unknown>={};
for(const key of ['purchase_order_number','required_by_date','ordered_by'] as const)if(input[key]!==undefined)after[key]=input[key];
for(const key of ['purchase_order_number','required_by_date','order_date','ordered_by'] as const)if(input[key]!==undefined)after[key]=input[key];
if(input.supplierId){await pb.collection('suppliers').getOne(input.supplierId,{fields:'id'});after.supplier_ref=input.supplierId;}
if(!Object.keys(after).length)throw new PurchasingError('empty_update');
await lease.update('purchase_orders',id,after);return {id,before:po,after};