2026-07-07 15:22:03 +00:00
|
|
|
import { getAdminPocketBase } from "./pocketbase-admin";
|
|
|
|
|
import { buildDefaultPoEmailBody } from "./po-email-template";
|
feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components
Co-authored-by: Orca <help@stably.ai>
2026-08-03 07:33:45 +00:00
|
|
|
import { fetchLetterhead, loadLetterheadLogoDataUri } from "@/lib/letterhead";
|
|
|
|
|
import { renderPurchaseOrderPdf } from "@/lib/pdf/render";
|
|
|
|
|
import type {
|
|
|
|
|
Order,
|
|
|
|
|
OrderItem,
|
|
|
|
|
PurchaseOrder,
|
|
|
|
|
PurchaseOrderDocumentEntry,
|
|
|
|
|
Supplier,
|
|
|
|
|
} from "@/lib/types";
|
2026-07-07 15:22:03 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Server-side Purchase Order pipeline used by the API route and the n8n
|
|
|
|
|
* workflow that does the actual SMTP send.
|
|
|
|
|
*
|
|
|
|
|
* Mirrors the pattern established by `quotes-service.ts` — pre-send
|
|
|
|
|
* validation + status flip happens here synchronously; the SMTP send
|
|
|
|
|
* itself runs in n8n, fired either by:
|
|
|
|
|
* - the operator clicking Send PO (this route), or
|
|
|
|
|
* - the scheduled cron (n8n polls `purchase_orders` where status="draft"
|
|
|
|
|
* AND created < now-15min, fires the same flow).
|
|
|
|
|
*
|
|
|
|
|
* Status lifecycle: draft → sending → sent
|
|
|
|
|
* └→ mail_failed (retryable)
|
|
|
|
|
* └→ cancelled (terminal)
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const PO_COLLECTION = "purchase_orders";
|
|
|
|
|
const SUPPLIERS_COLLECTION = "suppliers";
|
|
|
|
|
const ORDER_ITEMS_COLLECTION = "order_items";
|
|
|
|
|
|
2026-08-04 18:05:52 +00:00
|
|
|
/**
|
|
|
|
|
* Statuses the operator is allowed to send from.
|
|
|
|
|
*
|
|
|
|
|
* `sent` is intentionally included: it lets the operator resend a PO
|
|
|
|
|
* after a successful send (wrong address, didn't reach the supplier,
|
|
|
|
|
* supplier asked for it again). The route and modal label the action
|
|
|
|
|
* "Resend" for both `sent` and `mail_failed` so the operator doesn't
|
|
|
|
|
* have to wonder which state the PO is in.
|
|
|
|
|
*
|
|
|
|
|
* `cancelled` and `sending` are NOT sendable:
|
|
|
|
|
* - `cancelled` is terminal — the operator actively decided to
|
|
|
|
|
* kill this PO. Letting them send from there would silently undo
|
|
|
|
|
* that decision. If they change their mind they have to flip
|
|
|
|
|
* status manually.
|
|
|
|
|
* - `sending` means an n8n run is in flight; double-clicking Send
|
|
|
|
|
* while one is in flight would race the operator's first send
|
|
|
|
|
* to completion (and potentially send the same email twice).
|
|
|
|
|
* The UI shows a "Sending…" disabled button in that state.
|
|
|
|
|
*/
|
|
|
|
|
const SENDABLE_STATUSES = new Set(["draft", "mail_failed", "sent"]);
|
2026-07-07 15:22:03 +00:00
|
|
|
|
|
|
|
|
/** Pretty loose email check — SMTP will reject truly malformed addresses. */
|
|
|
|
|
function looksLikeEmail(s: string): boolean {
|
|
|
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SendPurchaseOrderEmailPayload {
|
|
|
|
|
/** Optional override for the primary To: address. Defaults to supplier.email. */
|
|
|
|
|
to?: string;
|
|
|
|
|
cc: string;
|
|
|
|
|
bcc: string;
|
|
|
|
|
subject: string;
|
|
|
|
|
message: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SendPurchaseOrderEmailFailure {
|
|
|
|
|
code:
|
|
|
|
|
| "no_recipient"
|
|
|
|
|
| "invalid_recipient"
|
|
|
|
|
| "no_pdf"
|
2026-08-03 13:47:01 +00:00
|
|
|
| "no_line_items"
|
2026-07-07 15:22:03 +00:00
|
|
|
| "invalid_status"
|
|
|
|
|
| "purchase_order_not_found"
|
|
|
|
|
| "supplier_not_found"
|
|
|
|
|
| "supplier_no_email";
|
|
|
|
|
message: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SendPurchaseOrderEmailResult {
|
|
|
|
|
purchaseOrder: PurchaseOrder;
|
|
|
|
|
/** Null on success; populated when pre-send validation rejected the send. */
|
|
|
|
|
failure: SendPurchaseOrderEmailFailure | null;
|
|
|
|
|
/**
|
|
|
|
|
* True when the operator typed an email that didn't match the
|
|
|
|
|
* supplier's stored email and we updated supplier.email to match.
|
|
|
|
|
* Front-end uses this to surface a "Updated the supplier email to
|
|
|
|
|
* match" confirmation in the toast.
|
|
|
|
|
*/
|
|
|
|
|
supplierEmailUpdated?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Pre-send validation + status flip for a Purchase Order outbound email.
|
|
|
|
|
*
|
|
|
|
|
* The flow mirrors `sendQuoteEmail` exactly:
|
|
|
|
|
* 1. Load the PO + supplier (separately, getOne doesn't expand).
|
|
|
|
|
* 2. Validate status (must be draft or mail_failed).
|
|
|
|
|
* 3. Validate supplier email is real (not empty, looks like an email).
|
|
|
|
|
* 4. Validate operator's CC/BCC entries.
|
|
|
|
|
* 5. Validate PO has at least one line item linked — without lines the
|
|
|
|
|
* email body would be empty and the supplier would have no idea
|
|
|
|
|
* what to deliver.
|
|
|
|
|
* 6. Flip status to `sending`, persist composition, clear previous
|
|
|
|
|
* error. The actual SMTP send runs in n8n.
|
|
|
|
|
*
|
|
|
|
|
* IMPORTANT: we do NOT generate the RFC 5322 Message-ID here. n8n
|
|
|
|
|
* generates it at SMTP time and writes it back on success/failure via
|
|
|
|
|
* `recordPurchaseOrderSendOutcome`. This keeps the Message-ID
|
|
|
|
|
* guarantee (unique per message) in the sender's hands.
|
|
|
|
|
*/
|
|
|
|
|
export async function sendPurchaseOrderEmail(
|
|
|
|
|
poId: string,
|
|
|
|
|
payload: SendPurchaseOrderEmailPayload
|
|
|
|
|
): Promise<SendPurchaseOrderEmailResult> {
|
|
|
|
|
const pb = await getAdminPocketBase();
|
|
|
|
|
|
|
|
|
|
// ── 1. Load PO + supplier ─────────────────────────────────────────
|
|
|
|
|
let po: PurchaseOrder;
|
|
|
|
|
try {
|
|
|
|
|
po = await pb.collection(PO_COLLECTION).getOne<PurchaseOrder>(poId);
|
|
|
|
|
} catch {
|
|
|
|
|
return {
|
|
|
|
|
purchaseOrder: null as unknown as PurchaseOrder,
|
|
|
|
|
failure: {
|
|
|
|
|
code: "purchase_order_not_found",
|
|
|
|
|
message: "Purchase Order does not exist.",
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let supplier: Supplier | null = null;
|
|
|
|
|
if (po.supplier) {
|
|
|
|
|
try {
|
|
|
|
|
supplier = (await pb
|
|
|
|
|
.collection(SUPPLIERS_COLLECTION)
|
|
|
|
|
.getOne(po.supplier)) as Supplier;
|
|
|
|
|
} catch {
|
|
|
|
|
return {
|
|
|
|
|
purchaseOrder: po,
|
|
|
|
|
failure: {
|
|
|
|
|
code: "supplier_not_found",
|
|
|
|
|
message: "Supplier record not found for this PO.",
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 2. Validate status ────────────────────────────────────────────
|
|
|
|
|
if (!SENDABLE_STATUSES.has(po.status)) {
|
|
|
|
|
return markPreSendFailure(pb, po, {
|
|
|
|
|
code: "invalid_status",
|
|
|
|
|
message: `Cannot send from status "${po.status}". Allowed: ${[...SENDABLE_STATUSES].join(", ")}.`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 3. Validate supplier email ────────────────────────────────────
|
|
|
|
|
if (!supplier) {
|
|
|
|
|
return markPreSendFailure(pb, po, {
|
|
|
|
|
code: "supplier_not_found",
|
|
|
|
|
message: "This PO has no supplier attached.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const primary = (payload.to || supplier.email || "").trim();
|
|
|
|
|
if (!looksLikeEmail(primary)) {
|
|
|
|
|
return markPreSendFailure(pb, po, {
|
|
|
|
|
code: "supplier_no_email",
|
|
|
|
|
message:
|
|
|
|
|
"No valid recipient email on this PO (supplier.email is empty and no override was provided).",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 4. Validate CC / BCC entries ──────────────────────────────────
|
|
|
|
|
for (const list of [payload.cc, payload.bcc]) {
|
|
|
|
|
for (const raw of (list || "").split(",")) {
|
|
|
|
|
const v = raw.trim();
|
|
|
|
|
if (v && !looksLikeEmail(v)) {
|
|
|
|
|
return markPreSendFailure(pb, po, {
|
|
|
|
|
code: "invalid_recipient",
|
|
|
|
|
message: `"${v}" doesn't look like an email address.`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 5. Validate PO has at least one linked line item ──────────────
|
|
|
|
|
const linkedItems = await pb
|
|
|
|
|
.collection(ORDER_ITEMS_COLLECTION)
|
|
|
|
|
.getList(1, 1, {
|
|
|
|
|
filter: `purchase_order = "${poId}"`,
|
|
|
|
|
});
|
|
|
|
|
if (linkedItems.totalItems === 0) {
|
|
|
|
|
return markPreSendFailure(pb, po, {
|
2026-08-03 13:47:01 +00:00
|
|
|
code: "no_line_items",
|
2026-07-07 15:22:03 +00:00
|
|
|
message:
|
|
|
|
|
"This PO has no line items attached. Re-link items on the order detail page before sending.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 13:47:01 +00:00
|
|
|
// ── 5b. Validate the PO has a generated PDF attached. ─────────────
|
|
|
|
|
// The route fires the n8n webhook which downloads the PO PDF
|
|
|
|
|
// from PB by filename. If neither `documents` nor
|
|
|
|
|
// `documents_history` resolves to a usable filename, the
|
|
|
|
|
// download 404s and the workflow surfaces to the operator as
|
|
|
|
|
// "Webhook returned HTTP 500". Reject the send here instead
|
|
|
|
|
// — clearer error, no wasted webhook round-trip.
|
|
|
|
|
//
|
|
|
|
|
// The fallback chain matches `send-email/route.ts`'s
|
|
|
|
|
// `latestFilename` computation so the service and the route
|
|
|
|
|
// agree on what counts as "the latest PDF". Mirrors the
|
|
|
|
|
// service-layer → route-layer duplication pattern already
|
|
|
|
|
// used in stock-purchases + dispatch.
|
|
|
|
|
//
|
|
|
|
|
// `documents_history` can contain legacy `[null]` entries
|
|
|
|
|
// (PO was created before the array was filled in); filter
|
|
|
|
|
// those out before calling `.find()` / accessing `.file` —
|
|
|
|
|
// otherwise the whole send throws on a malformed row.
|
|
|
|
|
const documentsField = po.documents;
|
|
|
|
|
const documentsList: string[] = Array.isArray(documentsField)
|
|
|
|
|
? documentsField
|
|
|
|
|
: typeof documentsField === "string" && documentsField
|
|
|
|
|
? [documentsField]
|
|
|
|
|
: [];
|
|
|
|
|
const documentsHistory = (po.documents_history ?? []).filter(
|
|
|
|
|
(h): h is NonNullable<typeof h> => h != null,
|
|
|
|
|
);
|
|
|
|
|
const latestFilename =
|
|
|
|
|
documentsList[documentsList.length - 1] ??
|
|
|
|
|
(documentsHistory.find((h) => h.status === "current") ??
|
|
|
|
|
documentsHistory[documentsHistory.length - 1])?.file ??
|
|
|
|
|
"";
|
|
|
|
|
if (!latestFilename) {
|
|
|
|
|
return markPreSendFailure(pb, po, {
|
|
|
|
|
code: "no_pdf",
|
|
|
|
|
message:
|
|
|
|
|
"This PO has no generated PDF attached. Open the order detail page and regenerate the document before sending.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 15:22:03 +00:00
|
|
|
// ── 5b. Generic body default ───────────────────────────────
|
|
|
|
|
// When the operator leaves the message blank, substitute a
|
|
|
|
|
// professional supplier-personalised template. SMTP relays
|
|
|
|
|
// commonly strip attachments from multipart/alternative bodies
|
|
|
|
|
// that have no body section (RFC 2046 violation), so an empty
|
|
|
|
|
// body is also the direct cause of "no PDF in inbox" reports.
|
|
|
|
|
//
|
|
|
|
|
// IMPORTANT: the default body deliberately omits ANY
|
|
|
|
|
// end-customer context (company name, customer rep, customer's
|
|
|
|
|
// PO reference). If the supplier knows who the order is for,
|
|
|
|
|
// they'll start going direct — that's the entire margin model.
|
|
|
|
|
// Operators who need to add specifics can still type their
|
|
|
|
|
// own message in the modal; we only fill the blank.
|
|
|
|
|
//
|
|
|
|
|
// We pass a minimal context: PO number, supplier name, and
|
|
|
|
|
// the line count (useful for the supplier's receiving check).
|
|
|
|
|
// ── 6. Persist composition + flip status to `sending`.
|
|
|
|
|
// We DO flip status synchronously so the operator's button hides
|
|
|
|
|
// immediately (no "click 50 times" bug) AND so the cron pass can
|
|
|
|
|
// distinguish "manually sent + awaiting n8n" from "still draft".
|
|
|
|
|
// The webhook pickup is still immediate (fired by the route right
|
|
|
|
|
// after this returns); if it misses, the cron pass picks up any
|
|
|
|
|
// `status="sending"` PO older than 15min and re-fires.
|
|
|
|
|
//
|
|
|
|
|
// `email_payload.to` stores the operator's override. Empty
|
|
|
|
|
// string = "fall back to supplier.email" (the actual send-time
|
|
|
|
|
// resolution happens in n8n, which reads `email_payload.to` or
|
|
|
|
|
// falls through to `expand.supplier.email`).
|
|
|
|
|
const lineCount = linkedItems.totalItems;
|
|
|
|
|
const defaultBody = buildDefaultPoEmailBody({
|
|
|
|
|
poNumber: po.po_number || po.id,
|
|
|
|
|
supplierName: supplier.suppliername || "",
|
|
|
|
|
lineCount,
|
|
|
|
|
});
|
|
|
|
|
const operatorMessage = (payload.message || "").trim();
|
|
|
|
|
|
|
|
|
|
// When the operator types their own message, that's what the supplier
|
|
|
|
|
// sees — both HTML and plain-text forms are produced by wrapping the
|
|
|
|
|
// operator's text in a minimal paragraph so it renders cleanly either
|
|
|
|
|
// way. When the operator leaves it blank, fall through to the default
|
|
|
|
|
// builder's HTML/plain pair.
|
|
|
|
|
const operatorHtml = operatorMessage
|
|
|
|
|
? `<p style="margin:0 0 16px 0;font-size:14px;color:#111111;line-height:1.55;">${operatorMessage
|
|
|
|
|
.replace(/&/g, "&")
|
|
|
|
|
.replace(/</g, "<")
|
|
|
|
|
.replace(/>/g, ">")
|
|
|
|
|
.replace(/\n/g, "<br>")}</p>`
|
|
|
|
|
: "";
|
|
|
|
|
const body = operatorMessage
|
|
|
|
|
? { text: operatorMessage, html: operatorHtml }
|
|
|
|
|
: defaultBody;
|
|
|
|
|
|
|
|
|
|
const payload_json = {
|
|
|
|
|
to: (payload.to || "").trim(),
|
|
|
|
|
subject: payload.subject || `Purchase Order ${po.po_number || po.id}`,
|
|
|
|
|
body: body.html,
|
|
|
|
|
bodyText: body.text,
|
|
|
|
|
cc: payload.cc || "",
|
|
|
|
|
bcc: payload.bcc || "",
|
|
|
|
|
sentMessageId: po.email_payload?.sentMessageId,
|
|
|
|
|
sentAt: po.email_payload?.sentAt,
|
|
|
|
|
supplierReplyMessageId: po.email_payload?.supplierReplyMessageId,
|
|
|
|
|
supplierReplyPreview: po.email_payload?.supplierReplyPreview,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updated = (await pb.collection(PO_COLLECTION).update(poId, {
|
|
|
|
|
status: "sending",
|
|
|
|
|
email_error: "",
|
|
|
|
|
email_payload: payload_json,
|
|
|
|
|
})) as PurchaseOrder;
|
|
|
|
|
|
|
|
|
|
// ── 7. Supplier email auto-update.
|
|
|
|
|
// If the operator typed a NEW email address that doesn't match
|
|
|
|
|
// supplier.email, persist it to the supplier record so the next
|
|
|
|
|
// PO to this supplier prefills correctly. Only writes when:
|
|
|
|
|
// - the override is non-empty (empty = "use supplier default")
|
|
|
|
|
// - the override is a valid email
|
|
|
|
|
// - the override differs from the supplier's current email
|
|
|
|
|
// We never blank out a supplier.email — empty overrides are
|
|
|
|
|
// honoured at send-time but never overwrite the source of truth.
|
|
|
|
|
let supplierEmailUpdated = false;
|
|
|
|
|
const override = (payload.to || "").trim();
|
|
|
|
|
if (
|
|
|
|
|
override &&
|
|
|
|
|
looksLikeEmail(override) &&
|
|
|
|
|
override !== (supplier.email || "").trim()
|
|
|
|
|
) {
|
|
|
|
|
try {
|
|
|
|
|
await pb.collection(SUPPLIERS_COLLECTION).update(supplier.id, {
|
|
|
|
|
email: override,
|
|
|
|
|
});
|
|
|
|
|
supplierEmailUpdated = true;
|
|
|
|
|
} catch (supErr) {
|
|
|
|
|
// Non-fatal — the send itself succeeds. The supplier.email update
|
|
|
|
|
// is a convenience for future POs; if it fails (PB permission,
|
|
|
|
|
// concurrent edit), we just log and move on.
|
|
|
|
|
console.warn(
|
|
|
|
|
`[po-send-email] failed to persist supplier.email update for ${supplier.id}:`,
|
|
|
|
|
supErr
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { purchaseOrder: updated, failure: null, supplierEmailUpdated };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Retry an outbound send after a `mail_failed`. Same as
|
|
|
|
|
* `sendPurchaseOrderEmail` but reuses the previously persisted
|
|
|
|
|
* composition (`email_payload`) without re-reading the modal.
|
|
|
|
|
*/
|
|
|
|
|
export async function retryPurchaseOrderEmail(
|
|
|
|
|
poId: string
|
|
|
|
|
): Promise<SendPurchaseOrderEmailResult> {
|
|
|
|
|
const pb = await getAdminPocketBase();
|
|
|
|
|
|
|
|
|
|
let po: PurchaseOrder;
|
|
|
|
|
try {
|
|
|
|
|
po = await pb.collection(PO_COLLECTION).getOne<PurchaseOrder>(poId);
|
|
|
|
|
} catch {
|
|
|
|
|
return {
|
|
|
|
|
purchaseOrder: null as unknown as PurchaseOrder,
|
|
|
|
|
failure: {
|
|
|
|
|
code: "purchase_order_not_found",
|
|
|
|
|
message: "Purchase Order does not exist.",
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (po.status !== "mail_failed") {
|
|
|
|
|
return markPreSendFailure(pb, po, {
|
|
|
|
|
code: "invalid_status",
|
|
|
|
|
message: `Cannot retry from status "${po.status}". Only mail_failed POs can be retried.`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Retry is fire-and-forget: clear the previous error, persist a
|
|
|
|
|
// "retry requested" hint into the payload, return the PO. n8n's
|
|
|
|
|
// webhook handler does the actual state machine (status flip +
|
|
|
|
|
// SMTP send + outcome write-back).
|
|
|
|
|
const updated = (await pb.collection(PO_COLLECTION).update(poId, {
|
|
|
|
|
email_error: "",
|
|
|
|
|
})) as PurchaseOrder;
|
|
|
|
|
|
|
|
|
|
return { purchaseOrder: updated, failure: null };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Synchronously flip a PO to `mail_failed` + write the failure reason.
|
|
|
|
|
* Used when pre-send validation rejects the send — we never reach n8n
|
|
|
|
|
* on this path, so the failure must be recorded here.
|
|
|
|
|
*/
|
|
|
|
|
async function markPreSendFailure(
|
|
|
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
|
|
|
po: PurchaseOrder,
|
|
|
|
|
failure: SendPurchaseOrderEmailFailure
|
|
|
|
|
): Promise<SendPurchaseOrderEmailResult> {
|
|
|
|
|
const updated = (await pb.collection(PO_COLLECTION).update(po.id, {
|
|
|
|
|
status: "mail_failed",
|
|
|
|
|
email_error: `[pre-send] ${failure.message}`,
|
|
|
|
|
})) as PurchaseOrder;
|
|
|
|
|
return { purchaseOrder: updated, failure };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Records the *outcome* of an SMTP send attempt. Called by the n8n
|
|
|
|
|
* `Purchase Order Outbound Send` workflow (or any other worker that's
|
|
|
|
|
* been handed the send).
|
|
|
|
|
*
|
2026-08-03 12:10:34 +00:00
|
|
|
* `outcome = "success"` flips status to `sent` + stamps delivery fields.
|
|
|
|
|
* It deliberately does NOT touch linked `order_items.item_status` — the
|
|
|
|
|
* operator marks items ready-to-collect manually from the order page /
|
|
|
|
|
* stock-orders page after the supplier confirms, which then drops them
|
|
|
|
|
* off the buyer-tasks PO Sent tab. Auto-flipping on send would hide the
|
|
|
|
|
* item before the supplier has even confirmed.
|
2026-07-07 15:22:03 +00:00
|
|
|
*
|
|
|
|
|
* `outcome = "failure"` flips to `mail_failed` + stamps error.
|
|
|
|
|
*
|
|
|
|
|
* This is intentionally separate from `sendPurchaseOrderEmail` so the
|
|
|
|
|
* n8n path doesn't have to replay validation — by the time n8n runs,
|
|
|
|
|
* pre-send checks already passed.
|
|
|
|
|
*/
|
|
|
|
|
export interface RecordPoSendOutcomeInput {
|
|
|
|
|
poId: string;
|
|
|
|
|
outcome: "success" | "failure";
|
|
|
|
|
/** SMTP-issued Message-ID. Stored on success AND failure. */
|
|
|
|
|
messageId?: string;
|
|
|
|
|
/** Failure reason (SMTP response text). Required when outcome = "failure". */
|
|
|
|
|
errorMessage?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function recordPurchaseOrderSendOutcome(
|
|
|
|
|
input: RecordPoSendOutcomeInput
|
|
|
|
|
): Promise<PurchaseOrder> {
|
|
|
|
|
const pb = await getAdminPocketBase();
|
|
|
|
|
|
|
|
|
|
if (input.outcome === "success") {
|
|
|
|
|
const sentAt = new Date().toISOString();
|
|
|
|
|
const po = await pb.collection(PO_COLLECTION).getOne<PurchaseOrder>(input.poId);
|
|
|
|
|
const payload = {
|
|
|
|
|
...(po.email_payload || {}),
|
|
|
|
|
sentAt,
|
|
|
|
|
sentMessageId: input.messageId || po.email_payload?.sentMessageId,
|
|
|
|
|
};
|
|
|
|
|
const patch: Record<string, unknown> = {
|
|
|
|
|
status: "sent",
|
|
|
|
|
email_error: "",
|
|
|
|
|
email_payload: payload,
|
|
|
|
|
};
|
|
|
|
|
if (input.messageId) patch.email_message_id = input.messageId;
|
|
|
|
|
|
|
|
|
|
const updated = (await pb.collection(PO_COLLECTION).update(
|
|
|
|
|
input.poId,
|
|
|
|
|
patch
|
|
|
|
|
)) as PurchaseOrder;
|
|
|
|
|
|
2026-08-03 12:10:34 +00:00
|
|
|
// Read the linked order_items to keep the back-link on the PO in
|
|
|
|
|
// sync. `order_items.purchase_order` is the canonical direction;
|
|
|
|
|
// `purchase_orders.order_item` is the mirror that surfaces in the
|
|
|
|
|
// PB admin UI and lets any future PO-side render read the items
|
|
|
|
|
// without an extra query.
|
2026-07-07 15:22:03 +00:00
|
|
|
//
|
2026-08-03 12:10:34 +00:00
|
|
|
// We deliberately do NOT touch `item_status` here. Previously this
|
|
|
|
|
// function auto-flipped linked items from `po_generated` to
|
|
|
|
|
// `collection_pool` on send, which dropped them off buyer-tasks
|
|
|
|
|
// immediately — before the supplier had even confirmed. The
|
|
|
|
|
// operator now marks items ready-to-collect from the order page /
|
|
|
|
|
// stock-orders page after the supplier commits, and only that
|
|
|
|
|
// explicit flip removes them from the buyer-tasks PO Sent tab.
|
|
|
|
|
//
|
|
|
|
|
// The filter intentionally doesn't restrict by `item_status` —
|
|
|
|
|
// we want every item linked to this PO regardless of where it is
|
|
|
|
|
// in the lifecycle, so the back-link array stays accurate.
|
2026-07-07 15:22:03 +00:00
|
|
|
const linked = await pb.collection(ORDER_ITEMS_COLLECTION).getFullList({
|
2026-08-03 12:10:34 +00:00
|
|
|
filter: `purchase_order = "${input.poId}"`,
|
2026-07-07 15:22:03 +00:00
|
|
|
fields: "id",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await pb.collection(PO_COLLECTION).update(input.poId, {
|
|
|
|
|
order_item: linked.map((it) => it.id),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return updated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// outcome = "failure"
|
|
|
|
|
const reason = (input.errorMessage || "Unknown SMTP failure").slice(0, 990);
|
|
|
|
|
const patch: Record<string, unknown> = {
|
|
|
|
|
status: "mail_failed",
|
|
|
|
|
email_error: `[smtp] ${reason}`,
|
|
|
|
|
};
|
|
|
|
|
if (input.messageId) patch.email_message_id = input.messageId;
|
|
|
|
|
return (await pb.collection(PO_COLLECTION).update(input.poId, patch)) as PurchaseOrder;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scan for POs in `status="draft"` older than `olderThanMs`. Used by
|
|
|
|
|
* the n8n cron workflow (`WFDASH — Purchase Order Send (Cron).js`)
|
|
|
|
|
* which fires every 15 minutes and dispatches each candidate.
|
|
|
|
|
*
|
|
|
|
|
* The cron doesn't read from PocketBase directly — it asks the
|
|
|
|
|
* dashboard to do the scan, so the only credentials it needs are
|
|
|
|
|
* AGENT_API_KEY (Bearer) + DASHBOARD_URL. This sidesteps the
|
|
|
|
|
* `N8N_BLOCK_ENV_ACCESS_IN_NODE=true` problem in our docker-compose
|
|
|
|
|
* (Code nodes can't read $env.* at runtime).
|
|
|
|
|
*
|
|
|
|
|
* Returns the list (possibly empty) plus a `now` timestamp the
|
|
|
|
|
* caller can echo back in logs.
|
|
|
|
|
*/
|
|
|
|
|
export interface StalePurchaseOrder {
|
|
|
|
|
id: string;
|
|
|
|
|
poNumber: string;
|
|
|
|
|
supplierName: string;
|
|
|
|
|
supplierEmail: string;
|
|
|
|
|
ageMinutes: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface FindStalePurchaseOrdersResult {
|
|
|
|
|
items: StalePurchaseOrder[];
|
|
|
|
|
scanned: number;
|
|
|
|
|
now: string;
|
|
|
|
|
olderThanMs: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function findStalePurchaseOrders(
|
|
|
|
|
olderThanMs: number = 15 * 60 * 1000,
|
|
|
|
|
limit: number = 50,
|
|
|
|
|
): Promise<FindStalePurchaseOrdersResult> {
|
|
|
|
|
const pb = await getAdminPocketBase();
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const cutoff = new Date(now.getTime() - olderThanMs).toISOString();
|
|
|
|
|
const list = await pb.collection(PO_COLLECTION).getList<{
|
|
|
|
|
id: string;
|
|
|
|
|
po_number: string;
|
|
|
|
|
supplier: string;
|
|
|
|
|
expand?: { supplier?: { suppliername?: string; email?: string } };
|
|
|
|
|
created: string;
|
|
|
|
|
}>(1, limit, {
|
|
|
|
|
filter: `status="draft" && created<"${cutoff}"`,
|
|
|
|
|
sort: "created",
|
|
|
|
|
fields: "id,po_number,supplier,created",
|
|
|
|
|
expand: "supplier",
|
|
|
|
|
});
|
|
|
|
|
const items: StalePurchaseOrder[] = list.items.map((p) => {
|
|
|
|
|
const ageMs = now.getTime() - new Date(p.created).getTime();
|
|
|
|
|
return {
|
|
|
|
|
id: p.id,
|
|
|
|
|
poNumber: p.po_number || "",
|
|
|
|
|
supplierName: p.expand?.supplier?.suppliername || "",
|
|
|
|
|
supplierEmail: p.expand?.supplier?.email || "",
|
|
|
|
|
ageMinutes: Math.round(ageMs / 60000),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
items,
|
|
|
|
|
scanned: list.totalItems,
|
|
|
|
|
now: now.toISOString(),
|
|
|
|
|
olderThanMs,
|
|
|
|
|
};
|
feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components
Co-authored-by: Orca <help@stably.ai>
2026-08-03 07:33:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ============================================================================
|
|
|
|
|
* PDF generation
|
|
|
|
|
* ========================================================================= */
|
|
|
|
|
|
|
|
|
|
/** Validation error from generatePurchaseOrderPdf — caller maps to HTTP 422. */
|
|
|
|
|
export class PurchaseOrderPdfValidationError extends Error {
|
|
|
|
|
constructor(
|
|
|
|
|
message: string,
|
|
|
|
|
public readonly code:
|
|
|
|
|
| "missing_relations"
|
|
|
|
|
| "no_items"
|
|
|
|
|
| "no_letterhead"
|
|
|
|
|
| "purchase_order_not_found",
|
|
|
|
|
public readonly details?: {
|
|
|
|
|
poId?: string;
|
|
|
|
|
orderId?: string;
|
|
|
|
|
supplierId?: string;
|
|
|
|
|
},
|
|
|
|
|
) {
|
|
|
|
|
super(message);
|
|
|
|
|
this.name = "PurchaseOrderPdfValidationError";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface GeneratePurchaseOrderPdfInput {
|
|
|
|
|
/** Free-text reason written to `documents_history.reason`. v1 ignores it. */
|
|
|
|
|
notes?: string;
|
|
|
|
|
/** Operator id/name for the audit trail. Optional. */
|
|
|
|
|
generatedBy?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface GeneratePurchaseOrderPdfResult {
|
|
|
|
|
pdfId: string;
|
|
|
|
|
version: number;
|
|
|
|
|
filename: string;
|
|
|
|
|
downloadUrl: string;
|
|
|
|
|
total: number;
|
|
|
|
|
generatedAt: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Server-side render of a Purchase Order PDF and append it to
|
|
|
|
|
* `purchase_orders.documents` (file) + `documents_history` (json).
|
|
|
|
|
*
|
|
|
|
|
* Shared by the explicit `/api/v1/purchase-orders/[id]/pdf` route
|
|
|
|
|
* (operator clicks "Regenerate PDF") and the buyer-tasks auto-PDF
|
|
|
|
|
* path (`generatePoForGroup` calls this right after creating the
|
|
|
|
|
* draft PO record so the operator's "Generate PO" click produces a
|
|
|
|
|
* file ready to attach on Send).
|
|
|
|
|
*
|
|
|
|
|
* What it does NOT do: flip the PO to `sent`. The PDF lifecycle is
|
|
|
|
|
* decoupled from the email lifecycle — this used to flip to "sent"
|
|
|
|
|
* as a legacy shortcut, but caused the send-inbox/outbox state to
|
|
|
|
|
* drift when operators regenerated a PDF without intending to send.
|
|
|
|
|
* The PO stays in `draft` until the operator explicitly hits Send
|
|
|
|
|
* (or the cron fires).
|
|
|
|
|
*
|
2026-08-04 18:05:52 +00:00
|
|
|
* Status transitions:
|
|
|
|
|
* - v1 generation from a brand-new PO: status left alone (already
|
|
|
|
|
* `draft` per the process page).
|
|
|
|
|
* - v≥2 (regenerate): if the PO was previously `sent` or
|
|
|
|
|
* `mail_failed`, flip status back to `draft` and clear
|
|
|
|
|
* `email_error`. This is what the operator wants when they
|
|
|
|
|
* regenerate after a bad send — they want to re-send. The
|
|
|
|
|
* previous send's `email_message_id` + `email_payload.sentAt`
|
|
|
|
|
* are preserved as the audit trail.
|
|
|
|
|
* - `cancelled` is intentionally NOT touched — an explicit cancel
|
|
|
|
|
* is an operator decision ("this PO is dead"); resurrecting it
|
|
|
|
|
* on regenerate would silently undo that. The operator must
|
|
|
|
|
* flip status manually if they want to re-send after a cancel.
|
|
|
|
|
*
|
feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components
Co-authored-by: Orca <help@stably.ai>
2026-08-03 07:33:45 +00:00
|
|
|
* Throws `PurchaseOrderPdfValidationError` for user-actionable
|
|
|
|
|
* preconditions (missing relations, no items, missing letterhead)
|
|
|
|
|
* so the caller can map them to HTTP 422. Other errors propagate.
|
|
|
|
|
*/
|
|
|
|
|
export async function generatePurchaseOrderPdf(
|
|
|
|
|
poId: string,
|
|
|
|
|
input: GeneratePurchaseOrderPdfInput = {},
|
|
|
|
|
): Promise<GeneratePurchaseOrderPdfResult> {
|
|
|
|
|
const pb = await getAdminPocketBase();
|
|
|
|
|
|
|
|
|
|
// 1. PO + order + supplier (all expanded server-side)
|
|
|
|
|
let po: PurchaseOrder;
|
|
|
|
|
try {
|
|
|
|
|
po = await pb.collection(PO_COLLECTION).getOne<PurchaseOrder>(poId, {
|
|
|
|
|
expand: "order,supplier",
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
throw new PurchaseOrderPdfValidationError(
|
|
|
|
|
"Purchase order does not exist.",
|
|
|
|
|
"purchase_order_not_found",
|
|
|
|
|
{ poId },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const order = po.expand?.order as Order | undefined;
|
|
|
|
|
const supplier = po.expand?.supplier as Supplier | undefined;
|
|
|
|
|
if (!order || !supplier) {
|
|
|
|
|
throw new PurchaseOrderPdfValidationError(
|
|
|
|
|
"Purchase order is missing the order or supplier relation. Cannot render PDF.",
|
|
|
|
|
"missing_relations",
|
|
|
|
|
{
|
|
|
|
|
poId: po.id,
|
|
|
|
|
orderId: po.order || undefined,
|
|
|
|
|
supplierId: po.supplier || undefined,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Items linked to this PO
|
|
|
|
|
const items = await pb
|
|
|
|
|
.collection(ORDER_ITEMS_COLLECTION)
|
|
|
|
|
.getFullList<OrderItem>({
|
|
|
|
|
filter: `purchase_order = "${poId}"`,
|
|
|
|
|
sort: "created",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (items.length === 0) {
|
|
|
|
|
throw new PurchaseOrderPdfValidationError(
|
|
|
|
|
"This PO has no line items attached. Re-create the PO from the process page " +
|
|
|
|
|
"before generating a PDF.",
|
|
|
|
|
"no_items",
|
|
|
|
|
{ poId: po.id },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Letterhead — always CNL
|
|
|
|
|
let letterhead;
|
|
|
|
|
try {
|
|
|
|
|
letterhead = await fetchLetterhead("CNL");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
throw new PurchaseOrderPdfValidationError(
|
|
|
|
|
err instanceof Error
|
|
|
|
|
? err.message
|
|
|
|
|
: "CNL letterhead not found in letterhead_settings.",
|
|
|
|
|
"no_letterhead",
|
|
|
|
|
{ poId: po.id, orderId: order.id },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. Logo
|
|
|
|
|
const logoDataUri = await loadLetterheadLogoDataUri("CNL");
|
|
|
|
|
|
|
|
|
|
// 5. Compute totals (locked snapshot for storage). Uses the
|
|
|
|
|
// supplier-facing `order_qty` so shorted lines don't accidentally
|
|
|
|
|
// inflate the total — mirrors the per-line Qty column in the PDF.
|
|
|
|
|
const subtotal =
|
|
|
|
|
Math.round(
|
|
|
|
|
items.reduce(
|
|
|
|
|
(s, it) =>
|
|
|
|
|
s +
|
|
|
|
|
(Number(it.order_qty ?? it.quantity) || 0) *
|
|
|
|
|
(Number(it.cost_price) || 0),
|
|
|
|
|
0,
|
|
|
|
|
) * 100,
|
|
|
|
|
) / 100;
|
|
|
|
|
|
|
|
|
|
// 6. Next version number
|
|
|
|
|
const history: PurchaseOrderDocumentEntry[] = po.documents_history ?? [];
|
|
|
|
|
const nextVersion =
|
|
|
|
|
history.length > 0 ? Math.max(...history.map((h) => h.version)) + 1 : 1;
|
|
|
|
|
|
|
|
|
|
// 7. Render PDF
|
|
|
|
|
const generatedAtIso = new Date().toISOString();
|
|
|
|
|
const pdfBuffer = await renderPurchaseOrderPdf({
|
|
|
|
|
purchaseOrder: po,
|
|
|
|
|
items,
|
|
|
|
|
order,
|
|
|
|
|
supplier,
|
|
|
|
|
letterhead,
|
|
|
|
|
logoDataUri,
|
|
|
|
|
generatedAtIso,
|
|
|
|
|
totalAmount: subtotal,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 8. Append PDF to purchase_orders.documents (multipart)
|
|
|
|
|
const safePo = String(po.po_number || poId).replace(/[^\w.-]/g, "_");
|
|
|
|
|
const filename = `PO-CNL-${safePo}-v${nextVersion}.pdf`;
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append(
|
|
|
|
|
"documents",
|
|
|
|
|
new Blob([new Uint8Array(pdfBuffer)], { type: "application/pdf" }),
|
|
|
|
|
filename,
|
|
|
|
|
);
|
|
|
|
|
const updated = await pb.collection(PO_COLLECTION).update<{
|
|
|
|
|
documents: string[] | string;
|
|
|
|
|
}>(poId, formData);
|
|
|
|
|
const docsRaw = updated?.documents ?? [];
|
|
|
|
|
const docs = Array.isArray(docsRaw) ? docsRaw : [docsRaw];
|
|
|
|
|
const newToken = docs[docs.length - 1] ?? "";
|
|
|
|
|
|
|
|
|
|
// 9. Append history entry — mark previous "current" as "superseded"
|
|
|
|
|
const newHistoryBase: PurchaseOrderDocumentEntry[] = history.map((h) =>
|
|
|
|
|
h.status === "current" ? { ...h, status: "superseded" } : h,
|
|
|
|
|
);
|
|
|
|
|
const newHistoryEntry: PurchaseOrderDocumentEntry = {
|
|
|
|
|
version: nextVersion,
|
|
|
|
|
file: filename,
|
|
|
|
|
token: newToken,
|
|
|
|
|
generatedAt: generatedAtIso,
|
|
|
|
|
generatedBy: input.generatedBy || "",
|
|
|
|
|
reason: (input.notes || "").slice(0, 500),
|
|
|
|
|
status: "current",
|
|
|
|
|
};
|
|
|
|
|
await pb.collection(PO_COLLECTION).update(poId, {
|
|
|
|
|
documents_history: [...newHistoryBase, newHistoryEntry],
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-04 18:05:52 +00:00
|
|
|
// 10. Persist total_amount. On v≥2 (regenerate) we ALSO reset the
|
|
|
|
|
// status to `draft` and clear `email_error` so the operator can
|
|
|
|
|
// re-send the new version. v1 generation from a brand-new PO
|
|
|
|
|
// leaves status alone (it's already `draft` per the process page).
|
|
|
|
|
//
|
|
|
|
|
// Why reset on regenerate but not on first generate? After the
|
|
|
|
|
// first send (status="sent" or "mail_failed"), the operator
|
|
|
|
|
// regenerates specifically because they want to send a new
|
|
|
|
|
// version — and the existing "Send" button only renders for
|
|
|
|
|
// `draft` / `mail_failed` (`sent` POs had no way to resend
|
|
|
|
|
// before this change). Resetting to `draft` makes the Send button
|
|
|
|
|
// reappear AND clears the stale `email_error` from the prior
|
|
|
|
|
// failure so it doesn't show in the toast on the new send.
|
|
|
|
|
//
|
|
|
|
|
// We deliberately preserve `email_message_id` and
|
|
|
|
|
// `email_payload.sentAt` — those are the audit trail of the
|
|
|
|
|
// previous send (what went out, when, with which Message-ID).
|
|
|
|
|
// Wiping them would lose the record of "v1 was sent to
|
|
|
|
|
// supplier@example.com on 2026-08-01, then regenerated as v2".
|
|
|
|
|
//
|
|
|
|
|
// `cancelled` POs are an explicit operator decision ("this PO
|
|
|
|
|
// is dead, don't send it") — a regenerate should NOT silently
|
|
|
|
|
// resurrect them. The operator must flip status back to
|
|
|
|
|
// `draft` manually if they want to re-send after a cancel.
|
|
|
|
|
const finalStatus = po.status;
|
|
|
|
|
const resetFields: Record<string, unknown> = {
|
feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components
Co-authored-by: Orca <help@stably.ai>
2026-08-03 07:33:45 +00:00
|
|
|
total_amount: subtotal,
|
2026-08-04 18:05:52 +00:00
|
|
|
};
|
|
|
|
|
if (nextVersion >= 2 && finalStatus !== "cancelled") {
|
|
|
|
|
resetFields.status = "draft";
|
|
|
|
|
resetFields.email_error = "";
|
|
|
|
|
}
|
|
|
|
|
await pb.collection(PO_COLLECTION).update(poId, resetFields);
|
feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components
Co-authored-by: Orca <help@stably.ai>
2026-08-03 07:33:45 +00:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
pdfId: poId,
|
|
|
|
|
version: nextVersion,
|
|
|
|
|
filename,
|
|
|
|
|
downloadUrl: `/api/v1/files/purchase_orders/${poId}/${filename}`,
|
|
|
|
|
total: subtotal,
|
|
|
|
|
generatedAt: generatedAtIso,
|
|
|
|
|
};
|
2026-07-07 15:22:03 +00:00
|
|
|
}
|