fix(delivery-notes): a counter note's quantity IS its collected quantity

`qty_collected` is read by the invoice path to decide how many units
to bill. A counter delivery note has no parent order: the customer is
standing at the counter taking goods off the shelf, so the collection
has ALREADY happened — there is no driver pickup to record, and it can
even have happened before the system existed. Those notes were being
created with `qty_collected` left at 0, so every one of them invoiced
as a zero-quantity line, which QuickBooks rejects (n8n 400 -> the
opaque 502 the operator saw).

Two writes, both scoped to parentless notes:

1. `createAdHocDeliveryNote` stamps `qty_collected = quantity` on each
   new item. The goods have been handed over; that IS the collection.

2. Editing a counter note's quantity keeps them in step, so when the
   customer says "actually make it five more" the DN editor, the quote
   and the invoice all agree. This is the loop the operator described:
   edit -> quote -> invoice, with the quantity carried through.

   Order-linked notes are deliberately left alone. There
   `qty_collected` means "the driver has this many in the van", and
   overwriting it with the order qty would bill for goods that have
   not arrived. `orders/process` already stamps collected=quantity for
   the in-stock flip; this is the same rule applied to the counter
   path that had been missed.

Four tests, including that an order-linked note's collected qty is
NOT touched. Verified they bite by removing both guards: 2 fail.

Not included: 315 existing parentless items still have
qty_collected <= 0. A backfill is a production data change, so it
waits for the operator's go-ahead.

Full suite 3824 passing, tsc clean.
This commit is contained in:
Dave 2026-10-09 11:35:01 +02:00
parent 74680e456d
commit 848c483929
2 changed files with 85 additions and 0 deletions

View file

@ -173,6 +173,7 @@ import {
markDeliveryNoteInvoiced,
markDeliveryNoteCompletedByInvoice,
createAdHocDeliveryNote,
updateDeliveryNoteItemPricing,
generateDeliveryNoteNumberServer,
getDeliveryNoteDetail,
} from "./delivery-notes-service";
@ -658,6 +659,63 @@ describe("generateDeliveryNoteNumberServer", () => {
});
});
describe("qty_collected on counter delivery notes", () => {
// A counter note is a customer taking goods off the shelf. There is
// no driver pickup, so the collection has already happened by the
// time the note exists. `qty_collected` is what the invoice path
// reads to decide how many units to bill, and leaving it at 0 made
// every counter invoice a zero-quantity line, which QuickBooks
// rejects (n8n 400 -> an opaque 502 at the operator).
it("stamps qty_collected = quantity on ad-hoc items at creation", async () => {
await createAdHocDeliveryNote({
customer: "customer-1",
items: [
{ description: "Husqvarna blower", quantity: 11, cost_price: 100 },
{ description: "Slideway oil", quantity: 1, cost_price: 50 },
],
});
const items = [...getOrCreate("order_items").values()] as Array<
Record<string, unknown>
>;
expect(items).toHaveLength(2);
for (const item of items) {
expect(item.qty_collected).toBe(item.quantity);
}
});
it("follows the quantity when a COUNTER note is edited", async () => {
// No parent order -> the note quantity IS the collected quantity.
seedDeliveryNote({ order: "" });
const oiId = seedOrderItem("DN26071401", { order: null, quantity: 10 });
await updateDeliveryNoteItemPricing({ itemId: oiId, quantity: 5 });
const item = getOrCreate("order_items").get(oiId) as Record<string, unknown>;
expect(item.quantity).toBe(5);
expect(item.qty_collected).toBe(5);
});
it("leaves an ORDER-linked note's collected qty alone", async () => {
// Order-linked notes are owned by the driver pickup flow, where
// qty_collected means "in the van". Overwriting it with the order
// qty would bill for goods that have not arrived.
seedDeliveryNote({ order: "order-1" });
const oiId = seedOrderItem("DN26071401", { order: "order-1", quantity: 10 });
await updateDeliveryNoteItemPricing({ itemId: oiId, quantity: 5 });
const item = getOrCreate("order_items").get(oiId) as Record<string, unknown>;
expect(item.quantity).toBe(5);
expect(item.qty_collected).toBeUndefined();
});
it("does not touch collected when only the price changes", async () => {
seedDeliveryNote({ order: "" });
const oiId = seedOrderItem("DN26071401", { order: null, quantity: 10 });
await updateDeliveryNoteItemPricing({ itemId: oiId, cost_price: 250 });
const item = getOrCreate("order_items").get(oiId) as Record<string, unknown>;
expect(item.cost_price).toBe(250);
expect(item.qty_collected).toBeUndefined();
});
});
describe("createAdHocDeliveryNote", () => {
it("creates a delivery_notes header + one order_item per line (no parent order)", async () => {
const result = await createAdHocDeliveryNote({

View file

@ -1277,6 +1277,18 @@ export async function createAdHocDeliveryNote(
delivery_note_number: deliveryNoteNumber,
description: it.description.trim(),
quantity: Number(it.quantity),
// Ad-hoc = counter sale. The customer is standing at the
// counter taking the goods, so the collection has ALREADY
// happened by the time this row exists — there is no driver
// pickup to record. `qty_collected` is what the invoice path
// reads to decide how many units to bill, and leaving it 0
// produced a zero-quantity invoice line, which QuickBooks
// rejects (n8n 400 -> an opaque 502 at the operator).
//
// "It can even be collected before we even had the system" —
// which is exactly the case: nothing here is ever picked up by
// a driver, so the note quantity IS the collected quantity.
qty_collected: Number(it.quantity),
supplier: it.supplier ?? null,
cost_price: Number(it.cost_price ?? 0),
selling_price:
@ -2200,6 +2212,21 @@ export async function updateDeliveryNoteItemPricing(
}
if (hasQty) patch.quantity = Number(input.quantity);
// A COUNTER delivery note has no parent order, which means the
// goods left the shelf and were handed over — the collection
// already happened. `qty_collected` is the field the invoice path
// reads to decide how many units to bill, so leaving it at 0 makes
// the invoice come out as a zero-quantity line, which QuickBooks
// rejects outright (n8n 400, surfaced as an opaque 502).
//
// Only for parentless notes: an order-linked note is owned by the
// driver pickup flow, where `qty_collected` means "the driver has
// this many in the van" and overwriting it with the order qty would
// bill the customer for goods that have not arrived.
if (hasQty && !dn.order) {
patch.qty_collected = Number(input.quantity);
}
// Stamp the audit tag on every pricing change. Operator-
// typed values are tagged "manual" so downstream surfaces
// (process page, sprint rows) can flag it for re-review if