fix: correct quote expiry handling for PocketBase's 0 default
PocketBase returns 0 for unset number fields, which broke the original null-coalescing check in effectiveValidityDays, leading to PDFs showing "Valid until: today" for all new quotes created via email ingest. Changes include: - Update effectiveValidityDays to treat 0, negatives, null/undefined as falling back to 30-day default - Add test cases for 0 and negative validity_days inputs - Explicitly set validity_days to QUOTE_VALID_DAYS when creating new quotes - Add one-time idempotent migration script to backfill legacy invalid validity values - Refactor item intel popover patch logic to use PocketBase SDK for authenticated requests - Add n8n workflow for AI-powered item insight matching
This commit is contained in:
parent
608a6ab978
commit
397df8e2b2
7 changed files with 943 additions and 10 deletions
|
|
@ -0,0 +1,293 @@
|
|||
{
|
||||
"name": "POPULATE AI INSIGHTS",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
-368,
|
||||
-32
|
||||
],
|
||||
"id": "5fcddf3e-573e-474a-a688-5272b188c0e6",
|
||||
"name": "When clicking ‘Execute workflow’"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "=Analyze the following JSON. I am looking for items where the description, name, or equivalent field is a match or high-confidence product match for: {{ $json.description }}\n\nCRITICAL MATCHING RULES:\n- Include exact matches.\n- Include close semantic variations of the exact same pack size (e.g., if targeting \"20L\", include matches for \"20 LT\" or \"20 Litre\").\n- STRIKTLY EXCLUDE different pack sizes or bulk configurations (e.g., if targeting \"20L\", do NOT match \"210L\", \"1000L\", \"20X500ML\", or \"12X1LT\").\n- Ignore minor specification differences like \"SF/CF4\" as long as the base product and pack size are the exact same.\n\nReturn a valid JSON object containing ONLY the arrays and the specific items that match this item based on the rules above.\n\nCRITICAL INSTRUCTIONS:\n- Output ONLY raw JSON. \n- DO NOT wrap the output in markdown blocks.\n- DO NOT include backticks (```).\n- DO NOT write any explanations, greetings, or conversational text. \n- If you write anything other than the raw JSON object, the system will break.\n\nData to filter:\n{{ JSON.stringify($json.ai_item_insights, null, 2) }}",
|
||||
"hasOutputParser": true,
|
||||
"needsFallback": true,
|
||||
"options": {}
|
||||
},
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 3.1,
|
||||
"position": [
|
||||
112,
|
||||
-32
|
||||
],
|
||||
"id": "96b4f602-97d6-4aec-a920-ba2e09f5c4f3",
|
||||
"name": "AI Agent"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": "MiniMax-M2.1",
|
||||
"options": {}
|
||||
},
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatMinimax",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
-32,
|
||||
240
|
||||
],
|
||||
"id": "05947ad8-ec67-49f2-aeef-68bd61a82b4d",
|
||||
"name": "MiniMax Chat Model",
|
||||
"credentials": {
|
||||
"minimaxApi": {
|
||||
"id": "kn97KHCrkyTm3v2g",
|
||||
"name": "MiniMax account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"schemaType": "manual",
|
||||
"inputSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"stock_list\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the stock list. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"supplier_history\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the supplier history. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"price_list\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the price list. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"recent_quote\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the recent quotes. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"recent_order\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the recent orders. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n }\n },\n \"required\": [\n \"stock_list\",\n \"supplier_history\"\n ]\n}",
|
||||
"autoFix": true
|
||||
},
|
||||
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
|
||||
"typeVersion": 1.3,
|
||||
"position": [
|
||||
288,
|
||||
176
|
||||
],
|
||||
"id": "3548a5a7-25b8-4a4e-8e09-60a5282c6022",
|
||||
"name": "Structured Output Parser"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": {
|
||||
"__rl": true,
|
||||
"value": "google/gemma-4-e2b",
|
||||
"mode": "list",
|
||||
"cachedResultName": "google/gemma-4-e2b"
|
||||
},
|
||||
"responsesApiEnabled": false,
|
||||
"options": {}
|
||||
},
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||
"typeVersion": 1.3,
|
||||
"position": [
|
||||
144,
|
||||
240
|
||||
],
|
||||
"id": "b566b192-2cfe-4f22-8ca3-863ae295d4c4",
|
||||
"name": "OpenAI Chat Model1",
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "LcoRpE8OF7QrpByT",
|
||||
"name": "OpenAI account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": "MiniMax-M2.1",
|
||||
"options": {}
|
||||
},
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatMinimax",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
288,
|
||||
320
|
||||
],
|
||||
"id": "19f05b98-850b-4bc6-9ffb-3681a708584d",
|
||||
"name": "MiniMax Chat Model1",
|
||||
"credentials": {
|
||||
"minimaxApi": {
|
||||
"id": "kn97KHCrkyTm3v2g",
|
||||
"name": "MiniMax account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"resource": "quote_items",
|
||||
"operation": "update",
|
||||
"elementId": "={{ $('Search quote_items').item.json.id }}",
|
||||
"parameters": {
|
||||
"allElements": false,
|
||||
"elementsPerPage": 1
|
||||
},
|
||||
"fields": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "44822f7e-b731-463b-a1f5-d46484853e11",
|
||||
"name": "ai_item_insights",
|
||||
"value": "={{ $json.ai_item_insights }}",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requestOptions": {}
|
||||
},
|
||||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
912,
|
||||
-32
|
||||
],
|
||||
"id": "6c8e5ae6-b56b-4468-afe4-947e13274f0e",
|
||||
"name": "Update quote_items",
|
||||
"credentials": {
|
||||
"pocketbaseHttpApi": {
|
||||
"id": "hLwB1QPIJEWsuVyW",
|
||||
"name": "PocketBase HTTP account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const allSearch = $('Search quote_items').all();\nconsole.log(\"Search returned \" + allSearch.length + \" items\");\nif (allSearch.length > 0) {\n console.log(\"First item keys: \" + Object.keys(allSearch[0]).join(\", \"));\n console.log(\"First item id: \" + allSearch[0].id);\n console.log(\"First item description: \" + allSearch[0].description);\n}\n\nconst items = $('Search quote_items').all();\nconst llmOutput = ($input.all()[0] && $input.all()[0].json && $input.all()[0].json.output) || {\n stock_list: [],\n supplier_history: [],\n price_list: [],\n recent_quote: [],\n recent_order: [],\n};\n\nif (!items || items.length === 0) {\n console.log(\"WARN: no items returned from Search\");\n return [{ json: { _status: \"no_items\" } }];\n}\n\nconst src = items[0];\nconsole.log(\"Processing item: \" + (src.id || \"UNKNOWN\") + \" \" + (src.description || \"\"));\n\nreturn items.map((src) => ({\n json: {\n ai_item_insights: {\n item: {\n id: src.id || \"\",\n collection: src.collectionName || src.collectionId || \"quote_items\",\n description: src.description || \"\",\n sku: src.sku || \"\",\n quantity: Number(src.quantity) || 0,\n },\n stock_list: (llmOutput.stock_list || []).filter((r) => r && r.stock_item_id),\n supplier_history: (llmOutput.supplier_history || []).filter((r) => r && r.supplier_id),\n price_list: (llmOutput.price_list || []).filter((r) => r && r.sku),\n recent_quote: (llmOutput.recent_quote || []).filter((r) => r && r.quote_item_id),\n recent_order: (llmOutput.recent_order || []).filter((r) => r && r.order_item_id),\n verified: {\n at: new Date().toISOString(),\n by: \"n8n:ai-verifier\",\n removed_count: Number(llmOutput.removed_count) || 0,\n notes: llmOutput.notes || \"\",\n },\n },\n },\n}));"
|
||||
},
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
624,
|
||||
-32
|
||||
],
|
||||
"id": "835e218b-b10b-4f3e-a39d-5ea76af364b9",
|
||||
"name": "Code in JavaScript"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"resource": "quote_items",
|
||||
"parameters": {
|
||||
"allElements": false,
|
||||
"elementsPerPage": 1,
|
||||
"filter": "ai_item_insights !~ 'verified'"
|
||||
},
|
||||
"requestOptions": {}
|
||||
},
|
||||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
-144,
|
||||
-32
|
||||
],
|
||||
"id": "96180b56-34a5-4778-aff3-0a219fbc7051",
|
||||
"name": "Search quote_items",
|
||||
"credentials": {
|
||||
"pocketbaseHttpApi": {
|
||||
"id": "hLwB1QPIJEWsuVyW",
|
||||
"name": "PocketBase HTTP account"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pinData": {},
|
||||
"connections": {
|
||||
"When clicking ‘Execute workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Search quote_items",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"MiniMax Chat Model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "AI Agent",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AI Agent": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Code in JavaScript",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Structured Output Parser": {
|
||||
"ai_outputParser": [
|
||||
[
|
||||
{
|
||||
"node": "AI Agent",
|
||||
"type": "ai_outputParser",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"OpenAI Chat Model1": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "AI Agent",
|
||||
"type": "ai_languageModel",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"MiniMax Chat Model1": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "Structured Output Parser",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code in JavaScript": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Update quote_items",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Search quote_items": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AI Agent",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1",
|
||||
"binaryMode": "separate",
|
||||
"availableInMCP": false
|
||||
},
|
||||
"versionId": "d2297962-055d-4324-bdb4-1f8c93f1c390",
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "05bcfa3b50919141d3600718ff4b7b2b52a3883525b05573a27ba5ef437ac7fb"
|
||||
},
|
||||
"nodeGroups": [],
|
||||
"id": "paqlZICDbXbmY61V",
|
||||
"tags": []
|
||||
}
|
||||
|
|
@ -0,0 +1,442 @@
|
|||
{
|
||||
"name": "POPULATE AI INSIGHTS",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
-368,
|
||||
-32
|
||||
],
|
||||
"id": "5fcddf3e-573e-474a-a688-5272b188c0e6",
|
||||
"name": "When clicking ‘Execute workflow’"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "=Analyze the following JSON. I am looking for items where the description, name, or equivalent field is a match or high-confidence product match for: {{ $json.description }}\n\nCRITICAL MATCHING RULES:\n- Include exact matches.\n- Include close semantic variations of the exact same pack size (e.g., if targeting \"20L\", include matches for \"20 LT\" or \"20 Litre\").\n- STRIKTLY EXCLUDE different pack sizes or bulk configurations (e.g., if targeting \"20L\", do NOT match \"210L\", \"1000L\", \"20X500ML\", or \"12X1LT\").\n- Ignore minor specification differences like \"SF/CF4\" as long as the base product and pack size are the exact same.\n\nReturn a valid JSON object containing ONLY the arrays and the specific items that match this item based on the rules above.\n\nCRITICAL INSTRUCTIONS:\n- Output ONLY raw JSON. \n- DO NOT wrap the output in markdown blocks.\n- DO NOT include backticks (```).\n- DO NOT write any explanations, greetings, or conversational text. \n- If you write anything other than the raw JSON object, the system will break.\n\nData to filter:\n{{ JSON.stringify($json.ai_item_insights, null, 2) }}",
|
||||
"hasOutputParser": true,
|
||||
"needsFallback": true,
|
||||
"options": {}
|
||||
},
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 3.1,
|
||||
"position": [
|
||||
112,
|
||||
-32
|
||||
],
|
||||
"id": "96b4f602-97d6-4aec-a920-ba2e09f5c4f3",
|
||||
"name": "AI Agent"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": "MiniMax-M2.1",
|
||||
"options": {}
|
||||
},
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatMinimax",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
-32,
|
||||
240
|
||||
],
|
||||
"id": "05947ad8-ec67-49f2-aeef-68bd61a82b4d",
|
||||
"name": "MiniMax Chat Model",
|
||||
"credentials": {
|
||||
"minimaxApi": {
|
||||
"id": "kn97KHCrkyTm3v2g",
|
||||
"name": "MiniMax account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"schemaType": "manual",
|
||||
"inputSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"stock_list\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the stock list. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"supplier_history\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the supplier history. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"price_list\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the price list. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"recent_quote\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the recent quotes. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"recent_order\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the recent orders. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n }\n },\n \"required\": [\n \"stock_list\",\n \"supplier_history\"\n ]\n}",
|
||||
"autoFix": true
|
||||
},
|
||||
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
|
||||
"typeVersion": 1.3,
|
||||
"position": [
|
||||
288,
|
||||
176
|
||||
],
|
||||
"id": "3548a5a7-25b8-4a4e-8e09-60a5282c6022",
|
||||
"name": "Structured Output Parser"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": {
|
||||
"__rl": true,
|
||||
"value": "google/gemma-4-e2b",
|
||||
"mode": "list",
|
||||
"cachedResultName": "google/gemma-4-e2b"
|
||||
},
|
||||
"responsesApiEnabled": false,
|
||||
"options": {}
|
||||
},
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||
"typeVersion": 1.3,
|
||||
"position": [
|
||||
144,
|
||||
240
|
||||
],
|
||||
"id": "b566b192-2cfe-4f22-8ca3-863ae295d4c4",
|
||||
"name": "OpenAI Chat Model1",
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "LcoRpE8OF7QrpByT",
|
||||
"name": "OpenAI account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": "MiniMax-M2.1",
|
||||
"options": {}
|
||||
},
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatMinimax",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
288,
|
||||
320
|
||||
],
|
||||
"id": "19f05b98-850b-4bc6-9ffb-3681a708584d",
|
||||
"name": "MiniMax Chat Model1",
|
||||
"credentials": {
|
||||
"minimaxApi": {
|
||||
"id": "kn97KHCrkyTm3v2g",
|
||||
"name": "MiniMax account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"resource": "quote_items",
|
||||
"operation": "update",
|
||||
"elementId": "={{ $('Search quote_items').item.json.id }}",
|
||||
"parameters": {
|
||||
"allElements": false,
|
||||
"elementsPerPage": 1
|
||||
},
|
||||
"fields": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "44822f7e-b731-463b-a1f5-d46484853e11",
|
||||
"name": "ai_item_insights",
|
||||
"value": "={\n \"name\": \"ai_item_insights\",\n \"value\": \"={{ $json.ai_item_insights }}\",\n \"type\": \"object\"\n}",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requestOptions": {}
|
||||
},
|
||||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
912,
|
||||
-32
|
||||
],
|
||||
"id": "6c8e5ae6-b56b-4468-afe4-947e13274f0e",
|
||||
"name": "Update quote_items",
|
||||
"credentials": {
|
||||
"pocketbaseHttpApi": {
|
||||
"id": "hLwB1QPIJEWsuVyW",
|
||||
"name": "PocketBase HTTP account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"resource": "quote_items",
|
||||
"parameters": {
|
||||
"allElements": false,
|
||||
"elementsPerPage": 1,
|
||||
"filter": "ai_item_insights !~ 'Verified'"
|
||||
},
|
||||
"requestOptions": {}
|
||||
},
|
||||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
-112,
|
||||
-32
|
||||
],
|
||||
"id": "c24cf6c7-9b10-4388-aed6-aa6e706f44bd",
|
||||
"name": "Search quote_items",
|
||||
"credentials": {
|
||||
"pocketbaseHttpApi": {
|
||||
"id": "hLwB1QPIJEWsuVyW",
|
||||
"name": "PocketBase HTTP account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const items = $('Search quote_items').all();\nconst llmOutput = $input.all()[0].json.output; // whatever the AI Agent emitted\n\nconst updates = items.map((src) => ({\n json: {\n ai_item_insights: {\n // Static header from the source row\n item: {\n id: src.id,\n collection: src.collectionName,\n description: src.description,\n sku: src.sku || '',\n quantity: src.quantity || 0,\n },\n // LLM-filtered sections (truncated)\n stock_list: (llmOutput.stock_list || []).filter((r) => r.stock_item_id),\n supplier_history: (llmOutput.supplier_history || []).filter((r) => r.supplier_id),\n price_list: (llmOutput.price_list || []).filter((r) => r.sku),\n recent_quote: (llmOutput.recent_quote || []).filter((r) => r.quote_item_id),\n recent_order: (llmOutput.recent_order || []).filter((r) => r.order_item_id),\n // Verification metadata\n verified: {\n at: new Date().toISOString(),\n by: 'n8n:ai-verifier',\n removed_count: ((llmOutput.removed_count) || 0),\n notes: llmOutput.notes || '',\n },\n },\n },\n}));\n\nreturn updates;"
|
||||
},
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
624,
|
||||
-32
|
||||
],
|
||||
"id": "835e218b-b10b-4f3e-a39d-5ea76af364b9",
|
||||
"name": "Code in JavaScript"
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"When clicking ‘Execute workflow’": [
|
||||
{
|
||||
"json": {},
|
||||
"pairedItem": {
|
||||
"item": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"AI Agent": [
|
||||
{
|
||||
"json": {
|
||||
"output": {
|
||||
"stock_list": [
|
||||
{
|
||||
"cost_price": 990,
|
||||
"description": "SAE 50 - 20L",
|
||||
"min_qty": 0,
|
||||
"name": "SAE 50 - 20L",
|
||||
"qty": 2,
|
||||
"score": 1,
|
||||
"selling_price": 0,
|
||||
"sku": "SAE50SF/CF4-20",
|
||||
"status": "in_stock",
|
||||
"stock_item_id": "kxrpa6lqda58xzl",
|
||||
"supplier_id": "pn93z9minsdhavt",
|
||||
"supplier_name": "AFRIQ LUBE AND SERVICES CC"
|
||||
}
|
||||
],
|
||||
"supplier_history": [
|
||||
{
|
||||
"days_ago": 10,
|
||||
"last_bought_at": "2026-07-31 12:20:09.438Z",
|
||||
"last_cost": 990,
|
||||
"last_purchase_number": "PO26073109STK",
|
||||
"stock_item_id": "kxrpa6lqda58xzl",
|
||||
"stock_item_name": "SAE 50 - 20L",
|
||||
"stock_item_sku": "SAE50SF/CF4-20",
|
||||
"supplier_id": "pn93z9minsdhavt",
|
||||
"supplier_name": "AFRIQ LUBE AND SERVICES CC",
|
||||
"times_bought": 5
|
||||
}
|
||||
],
|
||||
"price_list": [
|
||||
{
|
||||
"brand": "AFRIQ",
|
||||
"category": "Lubricants",
|
||||
"cost_price": 879,
|
||||
"description": "SAE 50 SF/CF4 - 20 LT",
|
||||
"score": 0.667,
|
||||
"selling_price": 879,
|
||||
"sku": "AFRIQ-ENGINEOILS-SAE_50_SF_CF4-20LT",
|
||||
"source_file": "AFRIQ-RESELLERS-2026-05",
|
||||
"supplier": "AFRIQ LUBE AND SERVICES CC",
|
||||
"updated": "2026-08-10 12:33:36.175Z"
|
||||
}
|
||||
],
|
||||
"recent_quote": [],
|
||||
"recent_order": []
|
||||
}
|
||||
},
|
||||
"pairedItem": {
|
||||
"item": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"Search quote_items": [
|
||||
{
|
||||
"json": {
|
||||
"ai_item_insights": {
|
||||
"recent_order": [
|
||||
{
|
||||
"cost_price": 70,
|
||||
"days_ago": 12,
|
||||
"description": "M22 X 130 STUD",
|
||||
"item_status": "delivered",
|
||||
"order_id": "83w6worlzraohdq",
|
||||
"order_item_id": "3yyylccvtcn1bfq",
|
||||
"order_number": "73652",
|
||||
"quantity": 100,
|
||||
"score": 1,
|
||||
"selling_price": 83.94,
|
||||
"supplier_id": "rlhxhaw73dxmsu3",
|
||||
"supplier_name": "CNL - IN STOCK",
|
||||
"updated": "2026-07-29 22:30:36.558Z"
|
||||
}
|
||||
],
|
||||
"recent_quote": [
|
||||
{
|
||||
"company": "Ultra Reconditioners",
|
||||
"cost_price": 43.2,
|
||||
"customer_name": "Ultra Reconditioners",
|
||||
"days_ago": 15,
|
||||
"description": "M22 X 130 STUDS\r\n",
|
||||
"quantity": 1,
|
||||
"quote_id": "dczyn2f5ukt3rjt",
|
||||
"quote_item_id": "43vmjzg165lug33",
|
||||
"quote_number": "Q007945",
|
||||
"quote_status": "approved",
|
||||
"quoted_at": "2026-07-26 22:44:04.337Z",
|
||||
"score": 0.667,
|
||||
"selling_price": 74.25
|
||||
}
|
||||
],
|
||||
"stock_list": [],
|
||||
"supplier_history": []
|
||||
},
|
||||
"approved_in_batch": false,
|
||||
"category": "4emvaanzogj0586",
|
||||
"claim_expires_at": "",
|
||||
"claim_status": "unclaimed",
|
||||
"claimed_at": "",
|
||||
"claimed_by": "",
|
||||
"collectionId": "pbc_470828888",
|
||||
"collectionName": "quote_items",
|
||||
"cost_price": 70,
|
||||
"created": "2026-07-26 22:12:14.145Z",
|
||||
"description": "M22 X 130 STUD ",
|
||||
"id": "6fil34avzkhnl2a",
|
||||
"is_processed": true,
|
||||
"markup": 19.91,
|
||||
"matched_price_list_id": "",
|
||||
"meiliesearch": true,
|
||||
"migration_meta": {
|
||||
"migrated_at": "2026-07-26T22:12:13.869876+00:00",
|
||||
"wfdash_quote_item_id": 32294
|
||||
},
|
||||
"moved_to_quote": "",
|
||||
"notes": "",
|
||||
"outbound_message_id": "",
|
||||
"outbound_sent_at": "",
|
||||
"pricing_notes": "",
|
||||
"pricing_source": "",
|
||||
"quantity": 100,
|
||||
"quote": "biuwwj1aiek9lrj",
|
||||
"quote_description": "",
|
||||
"quote_number": "Q010474",
|
||||
"quote_pdf_id": "",
|
||||
"quote_reference": "",
|
||||
"selling_price": 83.94,
|
||||
"sku": "",
|
||||
"supplier": "",
|
||||
"supplier_replied_at": "",
|
||||
"supplier_reply_count": 0,
|
||||
"updated": "2026-08-11 00:44:42.641Z",
|
||||
"user_edited": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking ‘Execute workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Search quote_items",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"MiniMax Chat Model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "AI Agent",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AI Agent": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Code in JavaScript",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Structured Output Parser": {
|
||||
"ai_outputParser": [
|
||||
[
|
||||
{
|
||||
"node": "AI Agent",
|
||||
"type": "ai_outputParser",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"OpenAI Chat Model1": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "AI Agent",
|
||||
"type": "ai_languageModel",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"MiniMax Chat Model1": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "Structured Output Parser",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Search quote_items": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AI Agent",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code in JavaScript": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Update quote_items",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1",
|
||||
"binaryMode": "separate",
|
||||
"availableInMCP": false
|
||||
},
|
||||
"versionId": "56a896a4-bf88-4e9e-85ef-99cfc467c6c4",
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "05bcfa3b50919141d3600718ff4b7b2b52a3883525b05573a27ba5ef437ac7fb"
|
||||
},
|
||||
"nodeGroups": [],
|
||||
"id": "paqlZICDbXbmY61V",
|
||||
"tags": []
|
||||
}
|
||||
153
scripts/migrations/2026-08-09-backfill-quote-validity-days.ts
Normal file
153
scripts/migrations/2026-08-09-backfill-quote-validity-days.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/* eslint-disable */
|
||||
/**
|
||||
* One-off data repair: stamp `validity_days = 30` on every quote
|
||||
* where the field is missing, null, or zero.
|
||||
*
|
||||
* ## Background
|
||||
*
|
||||
* PocketBase returns `0` (not `null`) for an un-set number field. The
|
||||
* PDF formatter originally used `quote.validity_days ?? 30`, which
|
||||
* does *not* treat 0 as nullish. Every quote created via the email
|
||||
* ingest path (`createQuoteFromEmail`) before this fix shipped
|
||||
* ended up with `validity_days = 0`, so the generated PDF said
|
||||
* "Valid until: today".
|
||||
*
|
||||
* This script does a one-shot patch to bring every existing quote
|
||||
* to the package default. Quotes where an operator had explicitly
|
||||
* set a value (any positive number) are left untouched.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* Dry run (default — safe to run anytime):
|
||||
*
|
||||
* npx tsx scripts/migrations/2026-08-09-backfill-quote-validity-days.ts
|
||||
*
|
||||
* Apply the repair:
|
||||
*
|
||||
* npx tsx scripts/migrations/2026-08-09-backfill-quote-validity-days.ts --apply
|
||||
*
|
||||
* ## Idempotency
|
||||
*
|
||||
* The filter `validity_days = null || validity_days <= 0` only matches
|
||||
* quotes that need fixing. Re-running after a successful apply
|
||||
* updates zero rows and exits cleanly.
|
||||
*/
|
||||
import PocketBase from "pocketbase";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
function loadEnvFile(name: string) {
|
||||
try {
|
||||
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
|
||||
if (m && !process.env[m[1]]) {
|
||||
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// missing file — fine
|
||||
}
|
||||
}
|
||||
loadEnvFile(".env");
|
||||
loadEnvFile(".env.local");
|
||||
|
||||
const QUOTE_VALID_DAYS = 30;
|
||||
const APPLY = process.argv.includes("--apply");
|
||||
const PAGE_SIZE = 500;
|
||||
|
||||
async function main() {
|
||||
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
|
||||
const email = process.env.PB_ADMIN_EMAIL;
|
||||
const password = process.env.PB_ADMIN_PASSWORD;
|
||||
if (!url || !email || !password) {
|
||||
throw new Error(
|
||||
"Missing NEXT_PUBLIC_POCKETBASE_URL / PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD in env",
|
||||
);
|
||||
}
|
||||
|
||||
const pb = new PocketBase(url);
|
||||
await pb.admins.authWithPassword(email, password);
|
||||
|
||||
// Match every quote that's missing / unset / zero / negative on
|
||||
// validity_days. The shape mirrors `effectiveValidityDays`'s
|
||||
// nullish handling so we patch exactly the rows the PDF code
|
||||
// would currently fall back on.
|
||||
const filter = 'validity_days = null || validity_days <= 0';
|
||||
const all = (await pb.collection("quotes").getFullList({
|
||||
filter,
|
||||
fields: "id,quote_number,validity_days,quote_date",
|
||||
sort: "quote_number",
|
||||
})) as Array<{
|
||||
id: string;
|
||||
quote_number: string;
|
||||
validity_days: number | null;
|
||||
quote_date: string;
|
||||
}>;
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log("No quotes with missing/0 validity_days — nothing to repair.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${all.length} quote(s) with missing or 0 validity_days.`);
|
||||
console.log("");
|
||||
console.log("First 20 rows that would be patched:");
|
||||
for (const q of all.slice(0, 20)) {
|
||||
console.log(
|
||||
` ${q.quote_number.padEnd(16)} was=${String(q.validity_days).padStart(4)} → ${QUOTE_VALID_DAYS} (date=${q.quote_date || "—"})`,
|
||||
);
|
||||
}
|
||||
if (all.length > 20) {
|
||||
console.log(` ... and ${all.length - 20} more`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
if (!APPLY) {
|
||||
console.log("DRY RUN — pass --apply to perform the repair.");
|
||||
console.log("");
|
||||
console.log("Re-run with: npx tsx scripts/migrations/2026-08-09-backfill-quote-validity-days.ts --apply");
|
||||
return;
|
||||
}
|
||||
|
||||
// PB doesn't expose a batch-update collection endpoint via the SDK
|
||||
// for arbitrary fields. Update each row one-by-one. With 200 rows
|
||||
// worst case this takes ~30 s.
|
||||
console.log("Patching...");
|
||||
let patched = 0;
|
||||
let failed = 0;
|
||||
for (const q of all) {
|
||||
try {
|
||||
await pb.collection("quotes").update(q.id, { validity_days: QUOTE_VALID_DAYS });
|
||||
patched++;
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.error(` ✗ ${q.quote_number} (${q.id}) failed:`, err);
|
||||
}
|
||||
}
|
||||
console.log(` ✓ Patched ${patched} quote(s).`);
|
||||
if (failed > 0) {
|
||||
console.error(` ✗ ${failed} quote(s) failed.`);
|
||||
}
|
||||
|
||||
// Re-query to confirm zero rows remain in the "needs-fixing" set.
|
||||
const remaining = await pb.collection("quotes").getFullList({
|
||||
filter,
|
||||
fields: "id",
|
||||
});
|
||||
console.log("");
|
||||
console.log(`Repair complete. Quotes still in the "needs fixing" set: ${remaining.length}`);
|
||||
if (remaining.length > 0) {
|
||||
console.error(
|
||||
"WARNING: some quotes still have validity_days = 0/null — investigate before re-running.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -25,8 +25,10 @@ import {
|
|||
CheckCircle2,
|
||||
CircleDashed,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { supplierColor } from "@/lib/supplier-color";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
import type { ItemIntel, ItemIntelVerified } from "@/lib/types";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -138,15 +140,28 @@ export function ItemIntelPopover({
|
|||
? `/api/v1/quote_items/${intel.item?.id ?? itemId}`
|
||||
: `/api/v1/order-items/${intel.item?.id ?? itemId}`);
|
||||
|
||||
/** PATCH the parent quote/order item with the given payload. */
|
||||
function applyPatch(patch: Record<string, unknown>) {
|
||||
fetch(resolvedPatchUrl, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
.then((r) => (r.ok ? onApplied?.(patch) : null))
|
||||
.catch(() => {});
|
||||
/** PATCH the parent quote/order item with the given payload via the
|
||||
* user's PB session (so the browser's cookie auth works). The
|
||||
* agent-authenticated `/api/v1/quote_items/[id]` route requires
|
||||
* the AGENT_API_KEY bearer, which the browser doesn't carry. */
|
||||
async function applyPatch(patch: Record<string, unknown>) {
|
||||
const summary =
|
||||
patch.description
|
||||
? String(patch.description)
|
||||
: patch.cost_price
|
||||
? `cost R ${Number(patch.cost_price).toFixed(2)}`
|
||||
: "pricing";
|
||||
try {
|
||||
const pb = getPocketBase();
|
||||
const targetId = intel?.item?.id ?? itemId;
|
||||
await pb.collection(collection).update(targetId, patch);
|
||||
toast.success(`Applied ${summary}`);
|
||||
onApplied?.(patch);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "unknown error";
|
||||
toast.error(`Apply failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -120,6 +120,17 @@ describe("effectiveValidityDays", () => {
|
|||
it("falls back to QUOTE_VALID_DAYS (30) when undefined", () => {
|
||||
expect(effectiveValidityDays({})).toBe(QUOTE_VALID_DAYS);
|
||||
});
|
||||
// PB defaults an unset number field to 0 (not null). Pre-fix every
|
||||
// quote shipped with validity_days=0, which the original `??`
|
||||
// passed through and produced a "Valid until: today" PDF.
|
||||
it("falls back to QUOTE_VALID_DAYS (30) when 0 (PocketBase default)", () => {
|
||||
expect(effectiveValidityDays({ validity_days: 0 })).toBe(QUOTE_VALID_DAYS);
|
||||
});
|
||||
it("falls back to QUOTE_VALID_DAYS (30) when negative", () => {
|
||||
// Defensive — no real path writes this, but a misconfigured
|
||||
// input shouldn't ever produce a pre-date expiry.
|
||||
expect(effectiveValidityDays({ validity_days: -7 })).toBe(QUOTE_VALID_DAYS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("poSubtotal", () => {
|
||||
|
|
|
|||
|
|
@ -95,9 +95,18 @@ export function effectiveQuoteDate(
|
|||
* Effective validity period in days. Prefers `validity_days` on the
|
||||
* quote (lets the office extend to 2 months etc.), falls back to the
|
||||
* package default.
|
||||
*
|
||||
* Treats `0` as nullish as well as `null` / `undefined` because
|
||||
* PocketBase defaults un-set number fields to `0` rather than `null`,
|
||||
* so every quote created before this fix landed carries
|
||||
* `validity_days = 0` even though no operator set it. The `??`
|
||||
* operator alone would pass `0` through and produce a "Valid until:
|
||||
* today" PDF. We use a strict positive check instead so missing /
|
||||
* unset / zero all fall back to the package default.
|
||||
*/
|
||||
export function effectiveValidityDays(
|
||||
quote: { validity_days?: number | null }
|
||||
): number {
|
||||
return quote.validity_days ?? QUOTE_VALID_DAYS;
|
||||
const v = quote.validity_days;
|
||||
return typeof v === "number" && v > 0 ? v : QUOTE_VALID_DAYS;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { getAdminPocketBase } from "./pocketbase-admin";
|
||||
import { QUOTE_VALID_DAYS } from "@/lib/pdf/format";
|
||||
import {
|
||||
PLACEHOLDER_CUSTOMER_IDS,
|
||||
PLACEHOLDER_CUSTOMER_NAMES,
|
||||
|
|
@ -1155,6 +1156,14 @@ export async function createQuoteFromEmail(
|
|||
const quoteNumber = await generateQuoteNumberServer();
|
||||
|
||||
// ── 5. Create the quote.
|
||||
//
|
||||
// Default `validity_days` to `QUOTE_VALID_DAYS` (30) explicitly:
|
||||
// PocketBase returns `0` for unset number fields, and the PDF
|
||||
// formatter's `??` falls through on `0` — so every quote created
|
||||
// before this fix landed shipped with a "Valid until: today"
|
||||
// expiry. Writing the default here keeps new quotes consistent
|
||||
// regardless of any old data. Legacy rows with `0` are patched by
|
||||
// the one-time backfill in `scripts/migrations/...`.
|
||||
const quote = await pb.collection(QUOTES_COLLECTION).create({
|
||||
quote_number: quoteNumber,
|
||||
customer: customer.id,
|
||||
|
|
@ -1162,6 +1171,7 @@ export async function createQuoteFromEmail(
|
|||
description: payload.subject,
|
||||
status: "new",
|
||||
notes: "",
|
||||
validity_days: QUOTE_VALID_DAYS,
|
||||
email_sender: fromEmail,
|
||||
email_subject: payload.subject,
|
||||
email_body: payload.body_plain || payload.body_html || "",
|
||||
|
|
|
|||
Loading…
Reference in a new issue