Products like fish are sold per weighing (4.2 ons, 5.6 ons), which the order line could not represent: quantity is INTEGER and prices are always computed as quantity * unit_price. Model one weighing as one order line. quantity stays INTEGER and keeps meaning "how many items"; the measured amount goes into a new nullable order_items.weight, and the line is priced weight * unit_price. Two weighings of the same product are two lines, never merged into one. Keeping quantity integral avoids float comparisons in void, refund and split bill, where accumulated rounding error would silently misbehave — "1.4 + 1.4 + 1.4" is not 4.2 in float64, which would leave a fully paid split-bill item marked unpaid. BillableQuantity() is now the single place that decides between weight and count; every price and cost calculation goes through it. Missing one would bill a 4.2 ons fish as a single ons — wrong money, no error. Two database constraints back the design: a weighed line always carries a positive weight, and its quantity is pinned to 1. The latter also makes void all-or-nothing for weighed lines, so the row-splitting branch can never produce a zero-weight remainder row. Also wires product.unit_id through the API, which was previously not settable at all, and corrects the misleading comment on the request's unit_price field — that value has never been used; price always comes from the database. Design notes and the audit of every price multiplication site are in docs/rfc-weight-based-products.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
31 lines
1.3 KiB
SQL
31 lines
1.3 KiB
SQL
-- Weight-based products (e.g. fish sold per ons/kg).
|
|
-- One weighing = one order_items row: quantity stays 1, the weight goes in `weight`.
|
|
|
|
ALTER TABLE products
|
|
ADD COLUMN sell_by VARCHAR(20) NOT NULL DEFAULT 'unit';
|
|
|
|
ALTER TABLE products
|
|
ADD CONSTRAINT chk_products_sell_by
|
|
CHECK (sell_by IN ('unit', 'weight'));
|
|
|
|
ALTER TABLE order_items
|
|
ADD COLUMN weight DECIMAL(12,3),
|
|
ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT;
|
|
|
|
-- A weighed line always carries a positive weight...
|
|
ALTER TABLE order_items
|
|
ADD CONSTRAINT chk_order_items_weight_positive
|
|
CHECK (weight IS NULL OR weight > 0);
|
|
|
|
-- ...and always represents exactly one weighing, so its quantity is pinned to 1.
|
|
-- This is what makes billable quantity unambiguous and keeps void all-or-nothing.
|
|
ALTER TABLE order_items
|
|
ADD CONSTRAINT chk_order_items_weight_single_line
|
|
CHECK (weight IS NULL OR quantity = 1);
|
|
|
|
CREATE INDEX idx_order_items_unit_id ON order_items(unit_id);
|
|
|
|
COMMENT ON COLUMN products.sell_by IS 'How the product is sold: unit (discrete count) or weight (weighed per transaction)';
|
|
COMMENT ON COLUMN order_items.weight IS 'Weighed amount in unit_id units; NULL for unit-priced products. Price is weight * unit_price.';
|
|
COMMENT ON COLUMN order_items.unit_id IS 'Snapshot of the product unit at sale time, so historical lines keep their meaning';
|