diff --git a/scripts/n8n/po-build-callback.js b/scripts/n8n/po-build-callback.js index a89e0b8..e6df124 100644 --- a/scripts/n8n/po-build-callback.js +++ b/scripts/n8n/po-build-callback.js @@ -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' }, diff --git a/scripts/n8n/po-ingest.patch.json b/scripts/n8n/po-ingest.patch.json index 3aa0cae..4f0853b 100644 --- a/scripts/n8n/po-ingest.patch.json +++ b/scripts/n8n/po-ingest.patch.json @@ -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" diff --git a/src/app/api/purchase-orders/process-batch/route.test.ts b/src/app/api/purchase-orders/process-batch/route.test.ts index 87c1be0..3bc61ce 100644 --- a/src/app/api/purchase-orders/process-batch/route.test.ts +++ b/src/app/api/purchase-orders/process-batch/route.test.ts @@ -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") } }), diff --git a/src/app/api/purchase-orders/process-batch/route.ts b/src/app/api/purchase-orders/process-batch/route.ts index 2052290..4f5c8d3 100644 --- a/src/app/api/purchase-orders/process-batch/route.ts +++ b/src/app/api/purchase-orders/process-batch/route.ts @@ -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[], }, diff --git a/src/lib/purchasing/move-to-purchase-orders.ts b/src/lib/purchasing/move-to-purchase-orders.ts index 22335e2..d2c3be5 100644 --- a/src/lib/purchasing/move-to-purchase-orders.ts +++ b/src/lib/purchasing/move-to-purchase-orders.ts @@ -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)=>hash('sha256',parts.join('|')).slice(0,15); @@ -49,7 +49,7 @@ export async function moveToPurchaseOrders(ctx:PurchasingContext,input:Recordrow.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{ diff --git a/src/lib/purchasing/purchase-orders.ts b/src/lib/purchasing/purchase-orders.ts index 7752139..e370dee 100644 --- a/src/lib/purchasing/purchase-orders.ts +++ b/src/lib/purchasing/purchase-orders.ts @@ -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{ - 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={}; - 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};