返回 CodeWhale
operator.ts
根目录 / web / lib / merch / operator.ts
1 import { MerchError, object, parseConfig } from "./model";
2 import { yoycolRequest } from "./yoycol";
3 import type { MerchEnv, Quote } from "./types";
4
5 type Row = { id: string; quote_json: string; payment_state: string; fulfillment_state: string; supplier_order_id: string | null; updated_at: number };
6 function id(value: unknown) {
7 if (typeof value !== "string" || !/^[0-9a-f-]{36}$/.test(value)) throw new MerchError(422, "invalid_order", "Select an existing paid order.");
8 return value;
9 }
10 function supplierId(value: unknown) {
11 if ((typeof value === "number" && Number.isSafeInteger(value) && value > 0) || (typeof value === "string" && /^[1-9]\d{0,18}$/.test(value))) return String(value);
12 throw new MerchError(409, "supplier_receipt_invalid", "Reconcile the supplier order before retrying.");
13 }
14 /** Preview uses frozen approved mappings. Contributor identity is never sent to Yoycol. */
15 export function supplierRequest(value: Quote) {
16 const a = value.input.address;
17 return {
18 storeOrderSn: "CW-" + value.quoteId, shippingLevelCode: value.shippingLevelCode,
19 autoSwitchShippingLevel: false, currency: "USD",
20 address: { firstName: a.name, lastName: "", phone: a.phone, country: value.input.country, province: a.region, city: a.city, address1: a.line1, address2: a.line2, zip: a.postalCode },
21 items: [{ thirdItemId: value.quoteId, skuCode: value.skuCode, designCode: value.designCode, quantity: value.input.quantity, source: "productTemplate" }],
22 };
23 }
24 /** Only the authenticated maintainer route may invoke this handler. Funding stays in Yoycol. */
25 export async function operatorAction(env: MerchEnv, raw: unknown) {
26 const input = object(raw), config = parseConfig(env.MERCH_CONFIG_JSON), db = env.MERCH_DB;
27 if (!config || !db) throw new MerchError(503, "operator_unavailable", "Commerce storage is not configured.");
28 if (input.action === "list") return db.prepare("SELECT id,payment_state,fulfillment_state,supplier_order_id,updated_at FROM merch_orders WHERE payment_state='paid' ORDER BY updated_at DESC LIMIT 100").all();
29 const orderId = id(input.orderId);
30 const row = await db.prepare("SELECT * FROM merch_orders WHERE id=?").bind(orderId).first<Row>();
31 if (!row || row.payment_state !== "paid") throw new MerchError(409, "not_paid", "Confirm the Stripe payment before fulfillment.");
32 const value = JSON.parse(row.quote_json) as Quote;
33 if (input.action === "preview") return { orderId, state: row.fulfillment_state, supplierRequest: supplierRequest(value), expectedProductionUsd: value.vendorProductionUsd, estimatedShippingUsd: value.vendorShippingUsd, note: "Review address, final supplier costs and print. Creation does not pay or start production." };
34 if (input.action === "submit") {
35 if (config.mode !== "live" || value.mode !== "live" || config.supplierSubmissionEnabled !== true || input.confirmation !== "CREATE_UNPAID_SUPPLIER_ORDER") throw new MerchError(409, "submission_disabled", "Supplier submission requires an explicitly enabled live operator workflow and a live paid receipt.");
36 const locked = await db.prepare("UPDATE merch_orders SET fulfillment_state='supplier_submitting',updated_at=? WHERE id=? AND payment_state='paid' AND fulfillment_state='awaiting_supplier_review' AND supplier_order_id IS NULL").bind(Date.now(), orderId).run();
37 if (locked.meta.changes !== 1) throw new MerchError(409, "already_submitted", "This order is already submitted or needs reconciliation.");
38 try {
39 const receipt = object(await yoycolRequest(env, "POST", "/api/2025/open/v4/orders", {}, supplierRequest(value)));
40 const vendorId = supplierId(receipt.orderId);
41 if (receipt.storeOrderSn !== "CW-" + orderId || receipt.currency !== "USD" || typeof receipt.orderRealAmount !== "number" || !Number.isFinite(receipt.orderRealAmount) || receipt.orderRealAmount < 0) throw new MerchError(409, "supplier_receipt_invalid", "The supplier receipt needs reconciliation.");
42 await db.prepare("UPDATE merch_orders SET supplier_order_id=?,supplier_receipt_json=?,fulfillment_state='supplier_cost_review',updated_at=? WHERE id=? AND fulfillment_state='supplier_submitting'").bind(vendorId, JSON.stringify(receipt), Date.now(), orderId).run();
43 return { orderId, supplierOrderId: vendorId, state: "supplier_cost_review", receipt, note: "Review final shipping and tax; fund manually in Yoycol after approval. No supplier payment was made here." };
44 } catch {
45 // A timeout or malformed receipt can follow a successful remote write. Never blindly POST again.
46 await db.prepare("UPDATE merch_orders SET fulfillment_state='reconciliation_hold',updated_at=? WHERE id=? AND fulfillment_state='supplier_submitting'").bind(Date.now(), orderId).run();
47 throw new MerchError(409, "reconciliation_required", "Check Yoycol for CW-" + orderId + ". Do not resubmit this order.");
48 }
49 }
50 if (input.action === "reconcile" || input.action === "refresh") {
51 if (row.fulfillment_state === "supplier_submitting" && (input.action !== "reconcile" || Date.now() - row.updated_at < 60000)) throw new MerchError(409, "submission_pending", "Wait for the current submission or reconcile it after confirming the supplier receipt.");
52 const vendorId = supplierId(input.action === "reconcile" ? input.supplierOrderId : row.supplier_order_id);
53 const detail = object(await yoycolRequest(env, "GET", "/api/2025/open/v4/orders/" + vendorId));
54 const receipt = object(detail.order);
55 if (receipt.storeOrderSn !== "CW-" + orderId || supplierId(receipt.orderId) !== vendorId) throw new MerchError(409, "supplier_receipt_invalid", "That supplier order does not match this receipt.");
56 const tracking = await yoycolRequest(env, "GET", "/api/2025/open/v4/orders/" + vendorId + "/tracking");
57 await db.prepare("UPDATE merch_orders SET supplier_order_id=?,supplier_receipt_json=?,tracking_json=?,fulfillment_state='supplier_status_recorded',updated_at=? WHERE id=? AND payment_state='paid'").bind(vendorId, JSON.stringify(receipt), JSON.stringify(tracking), Date.now(), orderId).run();
58 return { orderId, receipt, tracking, note: "Raw supplier status is recorded; numeric status codes are not interpreted as paid, shipped or delivered." };
59 }
60 throw new MerchError(422, "unknown_action", "Select a supported operator action.");
61 }
62
62 lines TYPESCRIPT