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:
parent
a41653ce87
commit
c55676bff0
6 changed files with 43 additions and 11 deletions
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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") } }),
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
Loading…
Reference in a new issue