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 { getAdminPocketBase } from "./pocketbase-admin";
|
|
|
|
|
import { getOrderItemsCollectionName } from "./orders-service";
|
|
|
|
|
|
|
|
|
|
const ORDER_COLLECTION = "orders";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Merge-safe reader for the `order_item_details` JSON blob. Other
|
|
|
|
|
* features (invoicing, collection-confirm) write their own keys into
|
|
|
|
|
* the same field (`invoice`, `print`, `deliveryNote`), so the split
|
|
|
|
|
* marker must never clobber them — always merge and re-stringify.
|
|
|
|
|
*/
|
|
|
|
|
function parseDetails(raw: unknown): Record<string, unknown> {
|
|
|
|
|
if (!raw) return {};
|
|
|
|
|
if (typeof raw === "object" && !Array.isArray(raw)) {
|
|
|
|
|
return raw as Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
if (typeof raw !== "string") return {};
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
|
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
|
|
|
return parsed as Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Not valid JSON — treat as empty.
|
|
|
|
|
}
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Thrown when an order_item can't be split (not found, wrong status,
|
|
|
|
|
* or no remainder to split off). Carries an HTTP status so the route
|
|
|
|
|
* handler can map it to the right response code:
|
|
|
|
|
*
|
|
|
|
|
* - 404 — item missing
|
|
|
|
|
* - 409 — item is past the editable window (already PO'd / collected / …)
|
|
|
|
|
* - 422 — order_qty is 0, missing, or ≥ quantity (no remainder exists)
|
|
|
|
|
*/
|
|
|
|
|
export class OrderItemSplitError extends Error {
|
|
|
|
|
readonly status: number;
|
|
|
|
|
constructor(message: string, status: number) {
|
|
|
|
|
super(message);
|
|
|
|
|
this.name = "OrderItemSplitError";
|
|
|
|
|
this.status = status;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-11 23:13:46 +00:00
|
|
|
/**
|
|
|
|
|
* Thrown by `confirmOrderItemsCollected` when a row can't be confirmed.
|
|
|
|
|
* Mirrors `OrderItemSplitError`: a small dedicated subclass with a
|
|
|
|
|
* `status` property the route handler can map directly to an HTTP code.
|
|
|
|
|
*
|
|
|
|
|
* - 404 — row missing
|
|
|
|
|
* - 409 — row is past the editable window (already collected / delivered / invoiced / …)
|
|
|
|
|
* - 422 — quantity inputs are inconsistent (qty > remaining, etc.)
|
|
|
|
|
*/
|
|
|
|
|
export class OrderItemConfirmError extends Error {
|
|
|
|
|
readonly code: string;
|
|
|
|
|
readonly status: number;
|
|
|
|
|
constructor(message: string, code: string, status: number) {
|
|
|
|
|
super(message);
|
|
|
|
|
this.name = "OrderItemConfirmError";
|
|
|
|
|
this.code = code;
|
|
|
|
|
this.status = status;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 22:51:24 +00:00
|
|
|
/**
|
|
|
|
|
* Canonical whitelist of fields a caller may patch on an
|
|
|
|
|
* `order_items` row. This is the **agent / MCP** surface — it is
|
|
|
|
|
* intentionally broader than the operator popover whitelist
|
|
|
|
|
* (`PATCHABLE_FIELDS` in `src/app/api/v1/order-items/[id]/route.ts`)
|
|
|
|
|
* because the agent reconciles invoices, walks statuses through
|
|
|
|
|
* the lifecycle, and overwrites supplier SKUs during PO
|
|
|
|
|
* matching. The REST popover keeps its narrower whitelist because
|
|
|
|
|
* it doesn't need the lifecycle / invoice fields.
|
|
|
|
|
*
|
|
|
|
|
* Both the REST PATCH route and the MCP `order_items.update`
|
|
|
|
|
* dispatcher import from here so a field accepted by one path
|
|
|
|
|
* but not the other can't drift silently. Anything outside this
|
|
|
|
|
* set returns `forbidden_field` with the rejected key names so
|
|
|
|
|
* the caller knows exactly what was rejected.
|
|
|
|
|
*
|
|
|
|
|
* Controlled / immutable fields (`id`, `created`, `updated`,
|
|
|
|
|
* `order`, `purchase_order`, `quote_item`, `product`) are
|
|
|
|
|
* excluded. `category` mirrors `QUOTE_ITEM_PATCH_FIELDS` and is
|
|
|
|
|
* a PB relation to `categories`. `unit` was added because
|
|
|
|
|
* invoice reconciliation often exposes a different shipping
|
|
|
|
|
* unit than the order was placed in (e.g. the supplier sends
|
|
|
|
|
* "BOX OF 12" where the PO said "EA").
|
|
|
|
|
*/
|
|
|
|
|
export const ORDER_ITEM_PATCHABLE_FIELDS = [
|
|
|
|
|
"cost_price",
|
|
|
|
|
"selling_price",
|
|
|
|
|
"quantity",
|
|
|
|
|
"sku",
|
|
|
|
|
"unit",
|
|
|
|
|
"category",
|
|
|
|
|
"description",
|
|
|
|
|
"supplier",
|
|
|
|
|
"markup",
|
|
|
|
|
"markup_percent",
|
|
|
|
|
"pricing_source",
|
|
|
|
|
"pricing_notes",
|
|
|
|
|
"matched_price_list_id",
|
|
|
|
|
"user_edited",
|
|
|
|
|
"notes",
|
|
|
|
|
"qty_processed",
|
|
|
|
|
"qty_assigned",
|
|
|
|
|
"qty_collected",
|
|
|
|
|
"qty_verified",
|
|
|
|
|
"qty_stocked",
|
|
|
|
|
"qty_picked",
|
|
|
|
|
"qty_loaded",
|
|
|
|
|
"qty_delivered",
|
|
|
|
|
"qty_invoiced",
|
|
|
|
|
"qty_cancelled",
|
|
|
|
|
"supplier_invoice_number",
|
|
|
|
|
"customer_invoice_number",
|
|
|
|
|
"collection_date",
|
|
|
|
|
"collected_date",
|
|
|
|
|
"verified_date",
|
|
|
|
|
"delivered_date",
|
|
|
|
|
"invoiced_date",
|
|
|
|
|
] as const;
|
|
|
|
|
|
2026-08-11 23:13:46 +00:00
|
|
|
/**
|
|
|
|
|
* Statuses from which an order_item can transition to `collected` via
|
|
|
|
|
* the agent-driven confirm path. Mirrors the stock-side
|
|
|
|
|
* `markLineCollected` rule (`collection_pool | assigned_to_driver`):
|
|
|
|
|
* the customer line is eligible while it's still in the pre-pickup
|
|
|
|
|
* pool or already bound to a driver. Anything past `collected` is
|
|
|
|
|
* downstream and refuses (you don't want to overwrite an invoiced or
|
|
|
|
|
* delivered status by accident).
|
|
|
|
|
*/
|
|
|
|
|
const CONFIRM_ELIGIBLE_STATUSES = new Set(["collection_pool", "assigned"]);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Audit-note entry appended by `confirmOrderItemsCollected`. Mirrors the
|
|
|
|
|
* shape used by the existing collection-confirm / appendCostNote helpers
|
|
|
|
|
* (ts / actorId / actorName / category / body) so the rendered feed in
|
|
|
|
|
* the UI parses these entries the same way.
|
|
|
|
|
*/
|
|
|
|
|
type ConfirmAuditEntry = {
|
|
|
|
|
id: string;
|
|
|
|
|
ts: string;
|
|
|
|
|
actorId: string;
|
|
|
|
|
actorName: string;
|
|
|
|
|
category: "collection";
|
|
|
|
|
body: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Read existing notes (any category, well-formed) so the new entry is
|
|
|
|
|
* merged into the feed rather than replacing the whole array. Lenient on
|
|
|
|
|
* category — we keep `note`, `problem`, `cost`, `confirm`, etc. so the
|
|
|
|
|
* operator-side audit trail stays intact.
|
|
|
|
|
*/
|
|
|
|
|
function parseStoredNotesForMerge(
|
|
|
|
|
raw: string | null | undefined,
|
|
|
|
|
): Array<Record<string, unknown>> {
|
|
|
|
|
const trimmed = (raw ?? "").trim();
|
|
|
|
|
if (!trimmed) return [];
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(trimmed);
|
|
|
|
|
if (!Array.isArray(parsed)) return [];
|
|
|
|
|
return parsed.filter(
|
|
|
|
|
(entry): entry is Record<string, unknown> =>
|
|
|
|
|
!!entry && typeof entry === "object",
|
|
|
|
|
);
|
|
|
|
|
} catch {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Result for a single row passed to `confirmOrderItemsCollected`.
|
|
|
|
|
* Mirrors the per-row shape used by `bulk_update_quote_items` /
|
|
|
|
|
* `stock_purchase_items.bulk_update` so the agent can switch on
|
|
|
|
|
* `result.status` (or `result.reason`) consistently.
|
|
|
|
|
*/
|
|
|
|
|
export type ConfirmCollectedRowResult = {
|
|
|
|
|
id: string;
|
|
|
|
|
status: "ok" | "skipped" | "failed";
|
|
|
|
|
reason?: string;
|
|
|
|
|
item?: Record<string, unknown>;
|
|
|
|
|
previous?: {
|
|
|
|
|
item_status: string | null;
|
|
|
|
|
qty_collected: number;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Confirm collection of customer order_items without requiring a driver
|
|
|
|
|
* assignment.
|
|
|
|
|
*
|
|
|
|
|
* Background: the in-UI collection-pool page forces a `drv_id`
|
|
|
|
|
* assignment before "Mark Collected" becomes available. The data layer
|
|
|
|
|
* doesn't enforce that coupling (see `src/lib/server/order-item-status.ts`
|
|
|
|
|
* — drv_id and drv_status are intentionally decoupled), so external
|
|
|
|
|
* systems (the agent API, n8n workflows, future integrations) need a
|
|
|
|
|
* way to mark items collected straight from `collection_pool`. This
|
|
|
|
|
* service is that path.
|
|
|
|
|
*
|
|
|
|
|
* For each id:
|
|
|
|
|
* 1. Reads the row to get `item_status`, `quantity`, and the
|
|
|
|
|
* existing `qty_collected` (so a partial-confirm math holds).
|
|
|
|
|
* 2. Validates: row exists, `item_status` ∈ {`collection_pool`,
|
|
|
|
|
* `assigned`}, and the requested `qty_collected` (when supplied)
|
|
|
|
|
* is ≤ the remaining qty. Throws structured `OrderItemConfirmError`
|
|
|
|
|
* on any miss.
|
|
|
|
|
* 3. Patches `item_status="collected"`, `drv_status="collected"`,
|
|
|
|
|
* `drv_collected_at` (today), `collected_date` (today),
|
|
|
|
|
* `qty_collected` (defaults to remaining), and appends a
|
|
|
|
|
* `category: "collection"` audit note tagged with the actor.
|
|
|
|
|
*
|
|
|
|
|
* Per-row independence: a failure on one id leaves the others
|
|
|
|
|
* untouched and surfaces the reason in the result entry. Returns
|
|
|
|
|
* `{ results: ConfirmCollectedRowResult[], confirmed: string[],
|
|
|
|
|
* failed: { id, reason }[] }` so callers can summarise either side.
|
|
|
|
|
*
|
|
|
|
|
* Empty `ids` is a no-op (returns `{ results: [], confirmed: [],
|
|
|
|
|
* failed: [] }`).
|
|
|
|
|
*/
|
|
|
|
|
export async function confirmOrderItemsCollected(
|
|
|
|
|
ids: string[],
|
|
|
|
|
args: {
|
|
|
|
|
actor: { id: string; name: string };
|
|
|
|
|
note?: string;
|
|
|
|
|
quantity_collected?: Record<string, number>;
|
|
|
|
|
collected_at?: string;
|
|
|
|
|
},
|
|
|
|
|
): Promise<{
|
|
|
|
|
results: ConfirmCollectedRowResult[];
|
|
|
|
|
confirmed: string[];
|
|
|
|
|
failed: { id: string; reason: string }[];
|
|
|
|
|
}> {
|
|
|
|
|
if (ids.length === 0) {
|
|
|
|
|
return { results: [], confirmed: [], failed: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pb = await getAdminPocketBase();
|
|
|
|
|
const collectionName = getOrderItemsCollectionName();
|
|
|
|
|
const collectedAt = (args.collected_at ?? new Date().toISOString()).slice(0, 10);
|
|
|
|
|
const actorId = String(args.actor?.id ?? "").trim();
|
|
|
|
|
const actorName = String(args.actor?.name ?? "").trim();
|
|
|
|
|
if (!actorId || !actorName) {
|
|
|
|
|
throw new OrderItemConfirmError(
|
|
|
|
|
"actor.{id,name} are required for confirm_collected.",
|
|
|
|
|
"missing_actor",
|
|
|
|
|
400,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const results: ConfirmCollectedRowResult[] = [];
|
|
|
|
|
const confirmed: string[] = [];
|
|
|
|
|
const failed: { id: string; reason: string }[] = [];
|
|
|
|
|
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
let row: Record<string, unknown>;
|
|
|
|
|
try {
|
|
|
|
|
row = (await pb.collection(collectionName).getOne(id)) as Record<string, unknown>;
|
|
|
|
|
} catch {
|
|
|
|
|
results.push({ id, status: "failed", reason: "not_found" });
|
|
|
|
|
failed.push({ id, reason: "not_found" });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentStatus = (row.item_status as string | null) ?? null;
|
|
|
|
|
if (!currentStatus || !CONFIRM_ELIGIBLE_STATUSES.has(currentStatus)) {
|
|
|
|
|
const reason = `wrong_status: cannot confirm collection from "${currentStatus ?? "null"}" (expected collection_pool or assigned)`;
|
|
|
|
|
results.push({ id, status: "failed", reason });
|
|
|
|
|
failed.push({ id, reason });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const quantity = Number(row.quantity ?? 0);
|
|
|
|
|
const existingQtyCollected = Number(row.qty_collected ?? 0);
|
|
|
|
|
const remaining = Math.max(0, quantity - existingQtyCollected);
|
|
|
|
|
const override = args.quantity_collected?.[id];
|
|
|
|
|
let nextQtyCollected = existingQtyCollected;
|
|
|
|
|
if (typeof override === "number") {
|
|
|
|
|
if (!Number.isFinite(override) || override < 0) {
|
|
|
|
|
const reason = "invalid_quantity_collected: must be a non-negative finite number";
|
|
|
|
|
results.push({ id, status: "failed", reason });
|
|
|
|
|
failed.push({ id, reason });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (override > remaining) {
|
|
|
|
|
const reason = `qty_overrun: requested ${override} > remaining ${remaining}`;
|
|
|
|
|
results.push({ id, status: "failed", reason });
|
|
|
|
|
failed.push({ id, reason });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
nextQtyCollected = existingQtyCollected + override;
|
|
|
|
|
} else if (remaining > 0) {
|
|
|
|
|
nextQtyCollected = quantity;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ts = new Date().toISOString();
|
|
|
|
|
const noteBody = args.note?.trim()
|
|
|
|
|
? `${args.note.trim()} (collected ${nextQtyCollected - existingQtyCollected || remaining} unit${nextQtyCollected - existingQtyCollected === 1 ? "" : "s"})`
|
|
|
|
|
: `Confirmed collection without driver assignment — ${nextQtyCollected - existingQtyCollected || remaining} unit${nextQtyCollected - existingQtyCollected === 1 ? "" : "s"} added to qty_collected (now ${nextQtyCollected}/${quantity}).`;
|
|
|
|
|
const entry: ConfirmAuditEntry = {
|
|
|
|
|
id: `confirm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
|
|
|
ts,
|
|
|
|
|
actorId,
|
|
|
|
|
actorName,
|
|
|
|
|
category: "collection",
|
|
|
|
|
body: noteBody,
|
|
|
|
|
};
|
|
|
|
|
const existingNotes = parseStoredNotesForMerge(
|
|
|
|
|
(row.notes as string | null | undefined) ?? "",
|
|
|
|
|
);
|
|
|
|
|
const mergedNotes = JSON.stringify([...existingNotes, entry]);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const updated = (await pb.collection(collectionName).update(id, {
|
|
|
|
|
item_status: "collected",
|
|
|
|
|
drv_status: "collected",
|
|
|
|
|
drv_collected_at: collectedAt,
|
|
|
|
|
collected_date: collectedAt,
|
|
|
|
|
qty_collected: nextQtyCollected,
|
|
|
|
|
notes: mergedNotes,
|
|
|
|
|
})) as Record<string, unknown>;
|
|
|
|
|
results.push({
|
|
|
|
|
id,
|
|
|
|
|
status: "ok",
|
|
|
|
|
item: updated,
|
|
|
|
|
previous: {
|
|
|
|
|
item_status: currentStatus,
|
|
|
|
|
qty_collected: existingQtyCollected,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
confirmed.push(id);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
const reason = `pb_error: ${message}`;
|
|
|
|
|
results.push({ id, status: "failed", reason });
|
|
|
|
|
failed.push({ id, reason });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { results, confirmed, failed };
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/**
|
|
|
|
|
* Splits a shorted order line into its remainder.
|
|
|
|
|
*
|
|
|
|
|
* Background: the process page lets the operator enter a supplier-facing
|
|
|
|
|
* `order_qty` that is LOWER than the customer-facing `quantity` (e.g.
|
|
|
|
|
* customer ordered 100, first supplier can only fulfil 40). The operator
|
|
|
|
|
* then wants a second line for the remaining 60 so it can be sent to a
|
|
|
|
|
* different supplier. This function creates that remainder line.
|
|
|
|
|
*
|
|
|
|
|
* Rules:
|
|
|
|
|
* - The original line keeps its `order_qty` untouched — that number is
|
|
|
|
|
* exactly what the first supplier's PO will print. Its customer-facing
|
|
|
|
|
* `quantity` is reduced to match `order_qty` so the two lines sum to
|
|
|
|
|
* the original request (12 + 8 = 20, not 20 + 8 = 28) and the split
|
|
|
|
|
* button disappears (order_qty == quantity → no shortfall).
|
|
|
|
|
* - The remainder line is created with `supplier = null` so it sorts to
|
|
|
|
|
* the top of the process page (un-assigned pin) and flags as
|
|
|
|
|
* "needs supplier" for the operator to route to the next supplier.
|
|
|
|
|
* - The remainder copies the pricing + audit-trail fields from the
|
|
|
|
|
* original (selling_price, cost_price, markup, sku, pricing_source,
|
|
|
|
|
* matched_price_list_id, pricing_notes) so the operator doesn't
|
|
|
|
|
* re-enter them, but clears `quote_item` (the original already owns
|
|
|
|
|
* the quote match) and starts `item_status = "pending"` so the
|
|
|
|
|
* standard "needs PO" workflow picks it up.
|
|
|
|
|
* - The split is only valid while `order_qty` is in (0, quantity) —
|
|
|
|
|
* a zero/blank order_qty means the operator deliberately ordered
|
|
|
|
|
* nothing from this supplier (no remainder to split), and an
|
|
|
|
|
* order_qty ≥ quantity means there is no shortfall.
|
|
|
|
|
* - The parent order must exist so the remainder line isn't orphaned.
|
|
|
|
|
*/
|
|
|
|
|
export async function splitOrderItemRemainder(itemId: string): Promise<{
|
|
|
|
|
original: Record<string, unknown>;
|
|
|
|
|
remainder: Record<string, unknown>;
|
|
|
|
|
remainderQty: number;
|
|
|
|
|
}> {
|
|
|
|
|
const pb = await getAdminPocketBase();
|
|
|
|
|
const orderItemsCollection = getOrderItemsCollectionName();
|
|
|
|
|
|
|
|
|
|
let item: Record<string, unknown>;
|
|
|
|
|
try {
|
|
|
|
|
item = (await pb.collection(orderItemsCollection).getOne(itemId)) as Record<string, unknown>;
|
|
|
|
|
} catch {
|
|
|
|
|
throw new OrderItemSplitError("Order item not found.", 404);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const status = String(item.item_status ?? "");
|
|
|
|
|
if (!["pending", "processed"].includes(status)) {
|
|
|
|
|
throw new OrderItemSplitError(
|
|
|
|
|
`Cannot split an item in status "${status}" — only pending/processed lines are still editable.`,
|
|
|
|
|
409,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const quantity = Number(item.quantity ?? 0);
|
|
|
|
|
const orderQty = item.order_qty === null || item.order_qty === undefined
|
|
|
|
|
? Number(item.quantity ?? 0)
|
|
|
|
|
: Number(item.order_qty);
|
|
|
|
|
|
|
|
|
|
// order_qty 0 (or missing → defaulted to quantity) means there is no
|
|
|
|
|
// shortfall to split. Explicitly exclude 0: the operator told us they
|
|
|
|
|
// don't want this supplier's line at all — splitting would create a
|
|
|
|
|
// bogus remainder.
|
|
|
|
|
if (!Number.isFinite(orderQty) || orderQty <= 0) {
|
|
|
|
|
throw new OrderItemSplitError(
|
|
|
|
|
"Cannot split this line — the supplier quantity is 0. Enter a quantity above 0 for the first supplier first.",
|
|
|
|
|
422,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (!Number.isFinite(quantity) || orderQty >= quantity) {
|
|
|
|
|
throw new OrderItemSplitError(
|
|
|
|
|
"Cannot split this line — the supplier quantity already covers the full customer quantity.",
|
|
|
|
|
422,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const remainderQty = quantity - orderQty;
|
|
|
|
|
const splitAt = new Date().toISOString();
|
|
|
|
|
|
|
|
|
|
// Verify the parent order exists so the remainder line is never
|
|
|
|
|
// orphaned (PB would happily create it against a bogus id).
|
|
|
|
|
const orderId = item.order as string | undefined;
|
|
|
|
|
if (!orderId) {
|
|
|
|
|
throw new OrderItemSplitError(
|
|
|
|
|
"Cannot split this line — it has no parent order.",
|
|
|
|
|
409,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await pb.collection(ORDER_COLLECTION).getOne(orderId, { fields: "id" });
|
|
|
|
|
} catch {
|
|
|
|
|
throw new OrderItemSplitError("Parent order not found.", 404);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Refuse to create a duplicate remainder. A previous split already
|
|
|
|
|
// produced a supplier-less line for the exact shortfall — clicking
|
|
|
|
|
// split again (e.g. a stale button on a line split before the
|
|
|
|
|
// quantity-reduce change) would double the order total to 28.
|
|
|
|
|
// Matched on order + no supplier + quantity == remainder + same sku
|
|
|
|
|
// (falling back to description when the line has no sku).
|
|
|
|
|
const siblings = (await pb.collection(orderItemsCollection).getFullList({
|
|
|
|
|
filter: `order = "${orderId}"`,
|
|
|
|
|
sort: "created",
|
|
|
|
|
})) as Record<string, unknown>[];
|
|
|
|
|
const alreadySplit = siblings.some(
|
|
|
|
|
(s) =>
|
|
|
|
|
s.id !== itemId &&
|
|
|
|
|
!s.supplier &&
|
|
|
|
|
Number(s.quantity ?? 0) === remainderQty &&
|
|
|
|
|
((s.sku && s.sku === item.sku) ||
|
|
|
|
|
(!s.sku && s.description === item.description)),
|
|
|
|
|
);
|
|
|
|
|
if (alreadySplit) {
|
|
|
|
|
throw new OrderItemSplitError(
|
|
|
|
|
"This line was already split — a remainder line for the shortfall already exists.",
|
|
|
|
|
409,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const remainder = (await pb.collection(orderItemsCollection).create({
|
|
|
|
|
order: orderId,
|
|
|
|
|
description: item.description || "",
|
|
|
|
|
po_description: item.po_description || item.description || "",
|
|
|
|
|
quantity: remainderQty,
|
|
|
|
|
order_qty: remainderQty,
|
|
|
|
|
selling_price: item.selling_price ?? null,
|
|
|
|
|
cost_price: Number(item.cost_price ?? 0),
|
|
|
|
|
markup: Number(item.markup ?? 0),
|
|
|
|
|
markup_percent: Number(item.markup ?? 0),
|
|
|
|
|
supplier: null,
|
2026-08-17 12:14:27 +00:00
|
|
|
// `null` rather than `""` — PB relation fields reject empty strings
|
|
|
|
|
// in stricter SDK builds; the remainder is intentionally unlinked.
|
|
|
|
|
purchase_order: null,
|
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
|
|
|
item_status: "pending",
|
|
|
|
|
in_stock: !!item.in_stock,
|
|
|
|
|
notes: item.notes || "",
|
|
|
|
|
// Split marker — lets the process page badge this line as the
|
|
|
|
|
// remainder of its source so splits are spottable. Stored in the
|
|
|
|
|
// shared `order_item_details` JSON blob (merged with any existing
|
|
|
|
|
// keys), the same field invoicing/collection write to.
|
|
|
|
|
order_item_details: JSON.stringify({
|
|
|
|
|
split: {
|
|
|
|
|
sourceItemId: itemId,
|
|
|
|
|
at: splitAt,
|
|
|
|
|
remainderQty,
|
|
|
|
|
fromOrderQty: orderQty,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
// Pricing audit-trail — carried over so the operator doesn't
|
|
|
|
|
// re-enter them when routing the remainder to the next supplier.
|
|
|
|
|
// `quote_item` is deliberately NOT copied: the original line owns
|
|
|
|
|
// the quote match and the remainder is a second procurement, not a
|
|
|
|
|
// second quote line.
|
|
|
|
|
sku: item.sku || null,
|
|
|
|
|
pricing_source: item.pricing_source || null,
|
|
|
|
|
matched_price_list_id: item.matched_price_list_id || null,
|
|
|
|
|
pricing_notes: item.pricing_notes || null,
|
2026-08-17 12:14:27 +00:00
|
|
|
// Defensive: carry the original's category tag so category filters
|
|
|
|
|
// on the order detail page don't silently drop the new line. Only
|
|
|
|
|
// written when present — schema lists it as optional.
|
|
|
|
|
...(item.category ? { category: item.category } : {}),
|
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
|
|
|
})) as Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
// Reduce the original's customer-facing `quantity` to the supplier
|
|
|
|
|
// qty so the two lines sum to the original request (12 + 8 = 20) and
|
|
|
|
|
// the split button hides (order_qty == quantity). Keep `order_qty`
|
|
|
|
|
// — that is what the first supplier's PO prints. Also stamp the
|
|
|
|
|
// source-side split marker so the original is badged too.
|
|
|
|
|
const original = (await pb.collection(orderItemsCollection).update(itemId, {
|
|
|
|
|
quantity: orderQty,
|
|
|
|
|
order_item_details: JSON.stringify({
|
|
|
|
|
...parseDetails(item.order_item_details),
|
|
|
|
|
split: {
|
|
|
|
|
remainderItemId: remainder.id,
|
|
|
|
|
at: splitAt,
|
|
|
|
|
remainderQty,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
})) as Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
return { original, remainder, remainderQty };
|
|
|
|
|
}
|