diff --git a/docs/reference/api.md b/docs/reference/api.md index 3aecca90f..cc30f8496 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -236,15 +236,15 @@ Router: `main/routes/recipes.ts`. Full path: `/api/recipes`. Router: `main/routes/supplies.ts`. Full path: `/api/supplies`. -| Method | Path | Authorization | Parameters | Response | -| -------- | ---------------- | -------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/` | `ROLE_ACCESS.ownerManager` | query: `?include_inactive`, `?low_stock`, `?search` | Supply list. `?low_stock` and `?include_inactive` narrow it. | -| `POST` | `/` | `ROLE_ACCESS.ownerManager` | body: `name`, `base_unit`, `stock_quantity`, `low_stock_threshold`, `is_active` | - | -| `GET` | `/movements` | `ROLE_ACCESS.ownerManager` | query: `?supply_id`, `?movement_type`, `?search`, `?before_id`, `?per_page` | Supply movement ledger, newest first; `?search` matches the supply name, reason, or actor across the whole ledger; `?before_id` pages backwards. | -| `GET` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id` | - | -| `PUT` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `name`, `is_active`, `low_stock_threshold` | - | -| `DELETE` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id` | - | -| `POST` | `/:id/movements` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `movement_type`, `quantity`, `unit`, `reason` | Body `movement_type`, `quantity`, `unit`, `reason`. | +| Method | Path | Authorization | Parameters | Response | +| -------- | ---------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/` | `ROLE_ACCESS.ownerManager` | query: `?include_inactive`, `?low_stock`, `?search` | Supply list. `?low_stock` and `?include_inactive` narrow it. | +| `POST` | `/` | `ROLE_ACCESS.ownerManager` | body: `name`, `base_unit`, `stock_quantity`, `low_stock_threshold`, `unit_cost`, `unit_cost_unit`, `is_active` | - | +| `GET` | `/movements` | `ROLE_ACCESS.ownerManager` | query: `?supply_id`, `?movement_type`, `?search`, `?before_id`, `?per_page` | Supply movement ledger, newest first; `?search` matches the supply name, reason, or actor across the whole ledger; `?before_id` pages backwards. | +| `GET` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id` | - | +| `PUT` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `name`, `is_active`, `low_stock_threshold`, `unit_cost`, `unit_cost_unit` | - | +| `DELETE` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id` | - | +| `POST` | `/:id/movements` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `movement_type`, `quantity`, `unit`, `unit_cost`, `total_cost`, `reason` | Body `movement_type`, `quantity`, `unit`, `reason`, plus optional `unit_cost` or `total_cost` on `receive` movements. | ### Inventory movements @@ -841,11 +841,11 @@ day-boundary rules. | ------ | --------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/daily-stats` | `ROLE_ACCESS.ownerManager` | none | Today only; no date parameter. | | `GET` | `/summary` | `ROLE_ACCESS.ownerManager` | query: `?date` | Summary for one business date. Order, bill, collection, and payment-method totals exclude cancelled orders; `ordersByStatus` counts all statuses. | -| `GET` | `/financial-summary` | `ROLE_ACCESS.owner` | query: `?start_date`, `?end_date` | Owner-only collection summary and refund audit for a range. Refunds are attributed to the original bill payment date, so gross, refund, net, and payment-method totals reconcile over the selected period. | +| `GET` | `/financial-summary` | `ROLE_ACCESS.owner` | query: `?start_date`, `?end_date` | Owner-only collection summary, refund audit, and period profitability summary (`profitability`) for a range. Refunds are attributed to the original bill payment date, so gross, refund, net, and payment-method totals reconcile over the selected period. | | `GET` | `/tax-components` | `ROLE_ACCESS.ownerManager` | query: `?start_date`, `?end_date` | - | | `GET` | `/price-exceptions` | `reports.view` | query: `?start_date`, `?end_date`, `?shift_id` | `{ priceExceptions: { startDate, endDate, shiftId, shifts, count, complimentaryCount, totalDifference, rows } }`. Each row is a non-cancelled line with a manual price override whose override time falls in the range: order, item, menu price, billed price, difference, reason, who changed it, and when. `shift_id` narrows the list to that cash shift's open-to-close window (404 when unknown); `shifts` lists up to 20 shifts overlapping the range. | | `GET` | `/sales` | `ROLE_ACCESS.ownerManager` | query: `?start_date`, `?end_date` | Daily order and sales series, paid payment-method totals, and order-type totals over a business-date range, excluding cancelled orders. | -| `GET` | `/topProducts` | `ROLE_ACCESS.ownerManager` | query: `?start_date`, `?end_date`, `?limit` | Top products by quantity over a business-date range, excluding cancelled orders and cancelled, voided, void-adjustment, or refunded items. | +| `GET` | `/topProducts` | `ROLE_ACCESS.ownerManager` | query: `?start_date`, `?end_date`, `?limit` | Top products by quantity over a business-date range, excluding cancelled orders and cancelled, voided, void-adjustment, or refunded items. When the caller also holds `reports.financial.view`, each product row includes ex-tax profitability metrics and the response includes period `profitability`. | | `GET` | `/recentOrders` | `ROLE_ACCESS.ownerManager` | query: `?limit`, `?date`, `?start_date`, `?end_date` | - | | `GET` | `/tables` | `ROLE_ACCESS.ownerManager` | none | Table-level utilisation and non-cancelled order totals for the current business date; no date parameter. | | `GET` | `/insights` | `ROLE_ACCESS.ownerManager` | query: `?days` | - | @@ -871,9 +871,7 @@ summary metric field order, and the Items and Payments columns. | `part` | `summary`, `items`, or `payments` | Required when `format=csv`; ignored for `xlsx`. Missing on CSV returns `400`. | - `format=xlsx` returns `200` with `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` and `Content-Disposition: attachment; filename="daily-sales-YYYY-MM-DD.xlsx"`. Money cells are numeric with a currency-fraction-digit number format; the workbook holds no formulas. -- `format=csv&part=summary` returns `text/csv` with header `metric,value`. -- `format=csv&part=items` returns `text/csv` with header `product_id,product_name,product_sku,quantity,gross_item_sales,item_discounts,net_item_sales,tax_amount`. -- `format=csv&part=payments` returns `text/csv` with header `bill_number,order_number,payment_time,method,amount,transaction_id`. +- `format=csv` returns `text/csv` for the requested `part` (`summary` has header `metric,value`; `items` and `payments` use the column headers in [daily sales export](daily-sales-export.md#file-formats)). Errors: `400` for an invalid `format` or a CSV without `part`; `401` unauthenticated; `403` non-owner; `409` for a concurrent-write conflict; `500` otherwise. CSV cells that would start with `=`, `+`, diff --git a/docs/reference/daily-sales-export.md b/docs/reference/daily-sales-export.md index 84e9b0535..b836e4543 100644 --- a/docs/reference/daily-sales-export.md +++ b/docs/reference/daily-sales-export.md @@ -28,7 +28,7 @@ Backend owns all logic; the frontend never computes totals. - **`net_collected = gross_collected - refunds_issued`.** - **Counts:** both `order_count` (distinct orders) and `paid_bill_count`. - **Payment methods:** `paymentMethodBreakdown(paidOnly, refunds-by-created_at, keyByPaidAt)` so `Σ payment totals = net_collected`. Only totals are exported (no per-method `count` - that count covers payment+refund lines, not payments). Summary keys use stable snake_case (`payment_visa_terminal_2` for `Visa Terminal 2`). -- **Items:** products on paid bills, split via `bill_items`; exclude `status IN ('cancelled','voided','void_adjustment')`; include `refunded` originals (they were collected; cash reversal lives only in `refunds_issued`). Group by `product_id` + snapshot `product_name` + `product_sku`. Add-ons fold into the parent item. Order-level discounts are Summary-only. +- **Items:** products on paid bills, split via `bill_items`; exclude `status IN ('cancelled','voided','void_adjustment')`; include `refunded` originals (they were collected; cash reversal lives only in `refunds_issued`). Group by `product_id` + snapshot `product_name` + `product_sku`. Add-ons fold into the parent item. Order-level discounts are Summary-only. Ingredient cost, profit, and margin use frozen `order_items.ingredient_cost` on lines with `ingredient_cost_known = 1` (allocated by `bi.quantity / oi.quantity` on split bills) against ex-tax net item sales of those known-cost lines (`known_cost_net_item_sales`); `cost_status` and `cost_coverage_status` are `known`, `partial`, or `unknown` (when `unknown`, cost, profit, and margin are `null`; see [product invariants](product-invariants.md#supplies-stock-and-recipe-depletion)). - **Payments:** one row per `bills.payment_details` ledger line of every bill paid in the window, including lines recorded on an earlier day for a bill settled in this one. `payment_time` is the line's timestamp in the store timezone (`YYYY-MM-DD HH:MM:SS`); `method` resolves custom methods to their current name; `transaction_id` is the stored external reference verbatim (empty when none), so card lines can be matched against the card terminal's batch report. Refunds are not rows here. - **`discount_total`** = order-level (`bills.discount_amount`) + item-level discounts. - **No** tips, no alternate refund attribution, no `net_ex_tax` metric. @@ -45,17 +45,17 @@ Backend owns all logic; the frontend never computes totals. ## File formats -- **XLSX:** sheets `Summary`, `Items`, then `Payments`. Money cells are numeric with `moneyNumFmt(fractionDigits)`; no formulas. Frozen header row. +- **XLSX:** sheets `Summary`, `Items`, then `Payments`. Money cells are numeric with `moneyNumFmt(fractionDigits)` and percentage cells (`*_percent`) use `'0.00'`; `null` cost, profit, and margin values are empty string `""`; no formulas. Frozen header row. - **CSV:** stable English snake_case headers; ASCII `.` decimals; formula-neutralized via shared `toCsvRow`. - **Filenames:** `daily-sales-YYYY-MM-DD.xlsx`, `daily-sales-YYYY-MM-DD-summary.csv`, `daily-sales-YYYY-MM-DD-items.csv`, `daily-sales-YYYY-MM-DD-payments.csv` (from `dailySalesExportFilename`). ### Summary metric field order -`business_date`, `timezone`, `business_day_start`, `currency`, `order_count`, `paid_bill_count`, `gross_collected`, `refunds_issued`, `net_collected`, `tax_total`, `discount_total`, `service_charge_total`, `packaging_charge_total`, `delivery_charge_total`, then one `payment_` total per method (snake_case key via `dailySalesPaymentMetricKey`). +`business_date`, `timezone`, `business_day_start`, `currency`, `order_count`, `paid_bill_count`, `gross_collected`, `refunds_issued`, `net_collected`, `tax_total`, `discount_total`, `service_charge_total`, `packaging_charge_total`, `delivery_charge_total`, `known_cost_net_item_sales`, `ingredient_cost_total`, `gross_profit_total`, `gross_margin_percent`, `cost_coverage_status`, then one `payment_` total per method (snake_case key via `dailySalesPaymentMetricKey`). ### Items columns -`product_id`, `product_name`, `product_sku`, `quantity`, `gross_item_sales`, `item_discounts`, `net_item_sales`, `tax_amount`. +`product_id`, `product_name`, `product_sku`, `quantity`, `gross_item_sales`, `item_discounts`, `net_item_sales`, `tax_amount`, `ingredient_cost`, `profit`, `margin_percent`, `cost_status`. ### Payments columns diff --git a/docs/reference/product-invariants.md b/docs/reference/product-invariants.md index 1c4ee381e..74bb635aa 100644 --- a/docs/reference/product-invariants.md +++ b/docs/reference/product-invariants.md @@ -326,16 +326,39 @@ tables (`supplies`, `supply_movements`) with a signed ledger, separate from prod to later sales after a recipe becomes active. Portions must be positive finite numbers; catalog writes and database imports reject invalid supplied values, and recipe snapshot creation rejects an invalid portion before depletion. +8. **Supply purchase costs use Weighted Average Cost (WAC) per base unit and stay separate from the + expenses ledger.** A supply's `unit_cost` is stored in major currency units per `base_unit` + (rounded to 10 decimal places so sub-minor-unit gram and millilitre costs never collapse to zero, + including 0-decimal currencies such as JPY). When a `receive` movement includes a batch cost + (`unit_cost` per movement unit or `total_cost` for the batch), it is normalized to `base_unit`. + If `stock_before <= 0` or `supplies.unit_cost IS NULL`, `unit_cost` resets to the new batch cost; + otherwise it blends by WAC (`(stock_before * old_cost + delta * batch_cost) / stock_after`). + Unpriced `receive` movements leave `supplies.unit_cost` unchanged. Recording a supply receive cost + updates inventory valuation only and never creates an `expenses` row. +9. **Order-item ingredient cost is recipe-derived, frozen at sale time, and never understated.** + When an order line is created, `buildRecipeSnapshot()` computes the line's ingredient cost from + its active recipe and variant `recipe_multiplier`, rounds the line total to the tenant currency's + fraction digits (`0`, `2`, or `3`), and freezes `ingredient_cost` and `ingredient_cost_known` on + `order_items` alongside `recipe_snapshot`. If a product has no active recipe or **any** recipe + component supply has `unit_cost IS NULL`, `ingredient_cost` is `NULL` and `ingredient_cost_known` + is `0` ("cost unknown" - never a partial cost and never 100% margin); legacy `products.cost` is + not used. Subsequent supply cost or recipe edits never alter existing `order_items`, and pending + or started quantity reductions scale the frozen cost from the line's held `recipe_snapshot`. In + sales reports and daily sales exports (gated by `reports.financial.view` and + `reports.daily-sales.export`), profit and margin are computed strictly over ex-tax net item sales + of known-cost lines (`known_cost_revenue`). **Reason:** POS availability during a stockout matters more than ledger neatness. A store that cannot sell because flour has not been counted in yet is worse than a negative row to reconcile -later. Snapshots keep historical cancellations correct under recipe edits, mirroring the existing -`tax_snapshot` pattern. +later. Snapshots keep historical cancellations and historical profitability reports accurate under +recipe or supply cost edits, mirroring the existing `tax_snapshot` pattern. **Enforced by:** `applySupplyStockChange()` in `main/services/supplies.ts`, which does not clamp; -`buildRecipeSnapshot()` and `applyRecipeSnapshot()` in `main/services/recipes.ts`; the order +`buildRecipeSnapshot()`, `scaleRecipeSnapshot()`, and `applyRecipeSnapshot()` in +`main/services/recipes.ts`; the order creation, item append, order and item cancel, item edit, and item restore paths in -`main/routes/orders.ts`. Restore acts only on a non-superseded `cancelled` item, re-deducts its +`main/routes/orders.ts`; and profitability aggregation in `main/routes/reports.ts` and +`main/services/daily-sales-export.ts`. Restore acts only on a non-superseded `cancelled` item, re-deducts its inventory and recipe components, and returns it to `pending`; a `voided` or superseded item is never restored. The portion comes from @@ -348,6 +371,7 @@ before depletion. The migration gives existing variants a default portion of 1 i ```sh npm run test:recipe-order-lifecycle # deplete, restore, void-no-restore, snapshot immutability +npm run test:ingredient-cost # WAC, negative-stock reset, frozen cost, profitability reports npm run test:order-item-edit # pending vs started item edits, waste, superseded restore rejection npm run test:supplies-service # negative stock, ledger, pagination npm run test:product-variants-orders # half / whole / double portions across create, append, void diff --git a/frontend/e2e/database-export-import.spec.ts b/frontend/e2e/database-export-import.spec.ts index 7209463ae..2469a8e36 100644 --- a/frontend/e2e/database-export-import.spec.ts +++ b/frontend/e2e/database-export-import.spec.ts @@ -211,7 +211,7 @@ test.describe('Database Settings JSON Export and Import', () => { const contaminatedPayload = { app: 'FloDesktop', - schema_version: '116', + schema_version: '117', data: { settings: [], categories: [], diff --git a/frontend/src/app/(dashboard)/dashboard/page.tsx b/frontend/src/app/(dashboard)/dashboard/page.tsx index 3c7c12b3d..60326de3f 100644 --- a/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -90,6 +90,18 @@ interface RefundActivity { approved_by_name: string; } +interface PeriodProfitability { + total_quantity: number; + net_revenue: number; + known_cost_revenue: number; + known_cost_quantity: number; + unknown_cost_quantity: number; + cost_status: 'known' | 'partial' | 'unknown'; + ingredient_cost: number | null; + profit: number | null; + margin_percent: number | null; +} + interface FinancialSummary { startDate: string; endDate: string; @@ -101,14 +113,23 @@ interface FinancialSummary { averageOrderValue: number; paymentMethods: PaymentMethodBreakdown[]; refunds: RefundActivity[]; + profitability?: PeriodProfitability; } interface TopProduct { - product_id: number; + product_id: number | string; product_name: string; total_quantity: number; total_revenue: number; order_count: number; + net_revenue?: number; + known_cost_revenue?: number; + known_cost_quantity?: number; + unknown_cost_quantity?: number; + cost_status?: 'known' | 'partial' | 'unknown'; + ingredient_cost?: number | null; + profit?: number | null; + margin_percent?: number | null; } interface RecentOrder { @@ -811,6 +832,57 @@ export default function DashboardPage() { )} + {canViewFinancials && financialSummary?.profitability && ( +
+
+ + + +
+

{t('ingredientCost')}

+

+ {financialSummary.profitability.ingredient_cost !== null + ? fmt(Number(financialSummary.profitability.ingredient_cost)) + : t('costUnknown')} +

+
+
+
+ + + +
+

{t('grossProfit')}

+

+ {financialSummary.profitability.profit !== null + ? fmt(Number(financialSummary.profitability.profit)) + : t('costUnknown')} +

+
+
+
+ + + +
+

{t('grossMargin')}

+
+

+ {financialSummary.profitability.margin_percent !== null + ? `${financialSummary.profitability.margin_percent}%` + : t('costUnknown')} +

+ {financialSummary.profitability.cost_status === 'partial' && ( + + {t('partialCost')} + + )} +
+
+
+
+ )} +
{/* Recent Orders */}
@@ -912,12 +984,19 @@ export default function DashboardPage() { {topProducts.map((product) => (
- - {product.product_name} - +
+ + {product.product_name} + + {canViewFinancials && product.cost_status === 'partial' && ( + + {t('partialCost')} + + )} +

{t('productSoldOrders', { quantity: product.total_quantity, @@ -925,9 +1004,39 @@ export default function DashboardPage() { })}

- - {fmt(Number(product.total_revenue))} - +
+ + {fmt(Number(product.total_revenue))} + + {canViewFinancials && product.cost_status && ( +
+ {product.cost_status === 'unknown' || + product.ingredient_cost === null || + product.ingredient_cost === undefined ? ( + {t('costUnknown')} + ) : ( + + {t('ingredientCostLabel', { + amount: fmt(Number(product.ingredient_cost)), + })} + {product.profit !== null && product.profit !== undefined && ( + <> + {' · '} + {t('profitMarginLabel', { + profit: fmt(Number(product.profit)), + margin: + product.margin_percent !== null && + product.margin_percent !== undefined + ? `${product.margin_percent}%` + : '—', + })} + + )} + + )} +
+ )} +
))}
diff --git a/frontend/src/app/(dashboard)/inventory/page.tsx b/frontend/src/app/(dashboard)/inventory/page.tsx index 8a572ba77..18e405d60 100644 --- a/frontend/src/app/(dashboard)/inventory/page.tsx +++ b/frontend/src/app/(dashboard)/inventory/page.tsx @@ -20,6 +20,7 @@ import { Scale, } from 'lucide-react'; import { useTranslations } from 'use-intl'; +import { useFormatCurrency } from '@/hooks/useFormatCurrency'; import { useFormatDate } from '@/hooks/useFormatDate'; import { useFormatNumber } from '@/hooks/useFormatNumber'; import { tenantCan } from '@/lib/permissions'; @@ -33,6 +34,7 @@ interface Supply { base_unit: SupplyUnit; stock_quantity: number; low_stock_threshold: number | null; + unit_cost: number | null; is_active: number; is_low_stock?: number; } @@ -45,6 +47,9 @@ interface SupplyMovement { movement_type: string; unit: SupplyUnit; stock_after: number; + unit_cost?: number | null; + total_cost?: number | null; + unit_cost_after?: number | null; reason: string | null; actor_name: string | null; created_at: string; @@ -57,6 +62,8 @@ interface RecipeItem { quantity: number; unit: SupplyUnit; quantity_in_base: number; + supply_unit_cost?: number | null; + component_cost?: number | null; } interface Recipe { @@ -65,12 +72,25 @@ interface Recipe { product_name: string | null; yield_quantity: number; is_active: number; + cost_known?: boolean; + total_cost?: number | null; + unit_cost?: number | null; items: RecipeItem[]; } interface Product { id: string; name: string; + price?: number; +} + +function convertUnitClient(quantity: number, from: SupplyUnit, to: SupplyUnit): number | null { + if (from === to) return quantity; + if (from === 'kg' && to === 'g') return quantity * 1000; + if (from === 'g' && to === 'kg') return quantity / 1000; + if (from === 'l' && to === 'ml') return quantity * 1000; + if (from === 'ml' && to === 'l') return quantity / 1000; + return null; } const movementTypeIcons: Record = { @@ -88,6 +108,7 @@ export default function InventoryPage() { const tNav = useTranslations('nav'); const tCommon = useTranslations('common'); const { formatDate } = useFormatDate(); + const fmt = useFormatCurrency(); const fmtNum = useFormatNumber(); const canManage = tenantCan(currentTenant, 'inventory.manage'); @@ -116,6 +137,8 @@ export default function InventoryPage() { base_unit: 'each' as SupplyUnit, stock_quantity: '0', low_stock_threshold: '', + unit_cost: '', + unit_cost_unit: 'each' as SupplyUnit, }); const [movementSupply, setMovementSupply] = useState(null); @@ -123,6 +146,8 @@ export default function InventoryPage() { movement_type: 'receive', quantity: '', unit: 'each' as SupplyUnit, + cost_mode: 'unit_cost' as 'unit_cost' | 'total_cost', + cost_amount: '', reason: '', }); @@ -234,7 +259,14 @@ export default function InventoryPage() { const openAddSupply = () => { setEditingSupply(null); - setSupplyForm({ name: '', base_unit: 'each', stock_quantity: '0', low_stock_threshold: '' }); + setSupplyForm({ + name: '', + base_unit: 'each', + stock_quantity: '0', + low_stock_threshold: '', + unit_cost: '', + unit_cost_unit: 'each', + }); setShowSupplyForm(true); }; @@ -245,6 +277,8 @@ export default function InventoryPage() { base_unit: s.base_unit, stock_quantity: String(s.stock_quantity), low_stock_threshold: s.low_stock_threshold === null ? '' : String(s.low_stock_threshold), + unit_cost: s.unit_cost === null || s.unit_cost === undefined ? '' : String(s.unit_cost), + unit_cost_unit: s.base_unit, }); setShowSupplyForm(true); }; @@ -252,17 +286,22 @@ export default function InventoryPage() { const handleSaveSupply = async (e: React.FormEvent) => { e.preventDefault(); try { + const unitCostVal = supplyForm.unit_cost === '' ? null : Number(supplyForm.unit_cost); const payload = { name: supplyForm.name, base_unit: supplyForm.base_unit, stock_quantity: Number(supplyForm.stock_quantity), low_stock_threshold: supplyForm.low_stock_threshold === '' ? null : Number(supplyForm.low_stock_threshold), + unit_cost: unitCostVal, + unit_cost_unit: supplyForm.unit_cost_unit, }; if (editingSupply) { await api.put(`/supplies/${editingSupply.id}`, { name: payload.name, low_stock_threshold: payload.low_stock_threshold, + unit_cost: payload.unit_cost, + unit_cost_unit: payload.unit_cost_unit, }); toast.success(tCommon('save')); } else { @@ -284,15 +323,32 @@ export default function InventoryPage() { e.preventDefault(); if (!movementSupply) return; try { + const costNum = + movementForm.movement_type === 'receive' && movementForm.cost_amount !== '' + ? Number(movementForm.cost_amount) + : undefined; await api.post(`/supplies/${movementSupply.id}/movements`, { movement_type: movementForm.movement_type, quantity: Number(movementForm.quantity), unit: movementForm.unit, + ...(costNum !== undefined && movementForm.cost_mode === 'unit_cost' + ? { unit_cost: costNum } + : {}), + ...(costNum !== undefined && movementForm.cost_mode === 'total_cost' + ? { total_cost: costNum } + : {}), reason: movementForm.reason || undefined, }); toast.success(tCommon('save')); setMovementSupply(null); - setMovementForm({ movement_type: 'receive', quantity: '', unit: 'each', reason: '' }); + setMovementForm({ + movement_type: 'receive', + quantity: '', + unit: 'each', + cost_mode: 'unit_cost', + cost_amount: '', + reason: '', + }); refresh(); } catch (err: unknown) { const message = @@ -478,6 +534,9 @@ export default function InventoryPage() { {t('stock')} + + {t('unitCost')} + {t('threshold')} @@ -506,6 +565,33 @@ export default function InventoryPage() { > {fmtNum(Number(s.stock_quantity))} {s.base_unit} + + {s.unit_cost === null || s.unit_cost === undefined ? ( + {t('costUnknown')} + ) : (s.base_unit === 'g' || s.base_unit === 'ml') && + Number(s.unit_cost) > 0 && + fmt(Number(s.unit_cost)) === fmt(0) ? ( +
+ + {fmt(Number(s.unit_cost) * 1000)} / {s.base_unit === 'g' ? 'kg' : 'L'} + +
+ ) : ( +
+ + {fmt(Number(s.unit_cost))} / {s.base_unit} + + {(s.base_unit === 'g' || s.base_unit === 'ml') && + Number(s.unit_cost) > 0 && + Number(s.unit_cost) < 1 && ( +
+ ({fmt(Number(s.unit_cost) * 1000)} /{' '} + {s.base_unit === 'g' ? 'kg' : 'L'}) +
+ )} +
+ )} + {s.low_stock_threshold === null ? '—' : fmtNum(Number(s.low_stock_threshold))} @@ -523,6 +609,8 @@ export default function InventoryPage() { movement_type: 'receive', quantity: '', unit: s.base_unit, + cost_mode: 'unit_cost', + cost_amount: '', reason: '', }); }} @@ -585,49 +673,87 @@ export default function InventoryPage() { {t('components')} + + {t('recipeCost')} + {t('actions')} - {visibleRecipes.map((r) => ( - - - {r.product_name || r.product_id} - - {fmtNum(Number(r.yield_quantity))} - -
    - {r.items.map((item) => ( -
  • - {item.supply_name}: {fmtNum(Number(item.quantity))} {item.unit} -
  • - ))} -
- - -
- - -
- - - ))} + {visibleRecipes.map((r) => { + const productObj = products.find((p) => p.id === r.product_id); + const sellPrice = + productObj?.price !== undefined ? Number(productObj.price) : null; + const unitCost = + r.cost_known && r.unit_cost !== null && r.unit_cost !== undefined + ? Number(r.unit_cost) + : null; + const marginPct = + unitCost !== null && sellPrice !== null && sellPrice > 0 + ? Math.round(((sellPrice - unitCost) / sellPrice) * 1000) / 10 + : null; + return ( + + + {r.product_name || r.product_id} + + + {fmtNum(Number(r.yield_quantity))} + + +
    + {r.items.map((item) => ( +
  • + {item.supply_name}: {fmtNum(Number(item.quantity))} {item.unit} + {item.component_cost !== null && + item.component_cost !== undefined && ( + + ({fmt(Number(item.component_cost))}) + + )} +
  • + ))} +
+ + + {unitCost !== null ? ( +
+ {fmt(unitCost)} + {marginPct !== null && ( +
+ {fmt(sellPrice! - unitCost)} ({marginPct}%) +
+ )} +
+ ) : ( + {t('costUnknown')} + )} + + +
+ + +
+ + + ); + })} {visibleRecipes.length === 0 && ( @@ -721,8 +847,17 @@ export default function InventoryPage() { - {Number(m.quantity_delta) > 0 ? '+' : ''} - {fmtNum(Number(m.quantity_delta))} {m.unit} +
+ {Number(m.quantity_delta) > 0 ? '+' : ''} + {fmtNum(Number(m.quantity_delta))} {m.unit} +
+ {m.movement_type === 'receive' && + m.total_cost !== null && + m.total_cost !== undefined && ( +
+ {fmt(Number(m.total_cost))} +
+ )} {fmtNum(Number(m.stock_after))} {m.unit} @@ -774,9 +909,14 @@ export default function InventoryPage() { <> setSupplyForm({ ...supplyForm, unit_cost: e.target.value })} + className="flex-1 px-3 py-2 border rounded-lg outline-none focus:ring-2 focus:ring-brand" + /> + +
+ {movementForm.movement_type === 'receive' && ( +
+ + + setMovementForm({ ...movementForm, cost_amount: e.target.value }) + } + className="flex-1 min-w-0 px-3 py-2 border rounded-lg outline-none focus:ring-2 focus:ring-brand" + /> +
+ )} ))} + {recipeForm.items.length > 0 && ( +
+ {t('recipeCost')}: + + {(() => { + const yieldQty = Number(recipeForm.yield_quantity); + if (!(yieldQty > 0)) return t('costUnknown'); + let total = 0; + for (const entry of recipeForm.items) { + const sup = supplies.find((s) => s.id === entry.supply_id); + const qty = Number(entry.quantity); + if ( + !sup || + sup.unit_cost === null || + sup.unit_cost === undefined || + !(qty > 0) + ) { + return t('costUnknown'); + } + const converted = convertUnitClient(qty, entry.unit, sup.base_unit); + if (converted === null) return t('costUnknown'); + total += converted * Number(sup.unit_cost); + } + return fmt(total / yieldQty); + })()} + +
+ )}