import mongoose, { Types } from "mongoose"; import { StockMovement, type MovementType } from "@/lib/server/models/StockMovement"; import { Purchase } from "@/lib/server/models/Purchase"; /** * Cost of goods sold, first-in-first-out, per branch. * * The stock ledger is replayed in order. Each branch keeps, per product, a * queue of cost layers (quantity at a unit cost). Stock coming in adds a * layer; stock going out takes from the oldest layers first: * * PURCHASE in, at the purchase's unit cost * TRANSFER_IN in, at the cost of the layers the sending branch gave up * ADJUSTMENT + in (opening stock, a count that found more), at an * estimated cost: the latest purchase price for the product * at that time, or the first one after it if none yet * SALE out: its cost is cost of goods sold * TRANSFER_OUT out: its cost travels with the stock to the other branch * ADJUSTMENT - out (breakage, a count that found less): its cost is a * stock loss, reported separately from cost of goods * * Stock can go negative (an offline sale recorded before the stock it came * from). The units sold short wait as a deficit and are costed by the next * stock that arrives at that branch, which is where they must have come * from. Units still uncosted at the end are reported as unitsWithoutCost. * * The replay reads the whole history, in order, so a sale is costed against * what was actually on hand when it happened, whatever period is being * reported; only events inside the period (and branch) are added up. A sale * made short in the period is costed by stock that arrived later, even * after the period ended. */ type Id = Types.ObjectId | string; interface Layer { qty: number; unitCost: number | null; // null: cost unknown } interface Deficit { qty: number; kind: "sale" | "loss" | "transfer"; counts: boolean; // inside the reported period and branch } interface Position { layers: Layer[]; deficits: Deficit[]; } export interface CostOfGoods { costOfGoodsSold: number; unitsCosted: number; // units sold in the period that have a cost unitsWithoutCost: number; // units sold in the period whose cost is unknown stockLosses: number; // cost of stock written off by counts or adjustments in the period } // Process transfers' outgoing side before the incoming side, so the cost is known. const TYPE_ORDER: Record = { PURCHASE: 0, ADJUSTMENT: 1, TRANSFER_OUT: 2, TRANSFER_IN: 3, SALE: 4, }; const round2 = (n: number) => Math.round(n * 100) / 100; /** Take `qty` units from the front of `layers`; returns what was taken and the shortfall. */ function take(layers: Layer[], qty: number): { taken: Layer[]; short: number } { const taken: Layer[] = []; let need = qty; while (need > 0 && layers.length > 0) { const head = layers[0]; const n = Math.min(head.qty, need); taken.push({ qty: n, unitCost: head.unitCost }); head.qty -= n; need -= n; if (head.qty === 0) layers.shift(); } return { taken, short: need }; } export async function costOfGoodsSold( businessId: Id, period: { from: Date; to: Date; branchId?: Id | null } ): Promise { const biz = new mongoose.Types.ObjectId(businessId.toString()); const branchFilter = period.branchId ? period.branchId.toString() : null; const [movements, purchases] = await Promise.all([ StockMovement.find({ businessId: biz }) .select("branchId productId type quantity refId createdAt") .sort({ createdAt: 1, _id: 1 }) .lean(), Purchase.find({ businessId: biz, productId: { $ne: null } }) .select("productId unitCost createdAt") .sort({ createdAt: 1 }) .lean(), ]); movements.sort( (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || TYPE_ORDER[a.type] - TYPE_ORDER[b.type] ); const purchaseCost = new Map(purchases.map((p) => [p._id.toString(), p.unitCost])); const priceHistory = new Map(); for (const p of purchases) { const key = p.productId!.toString(); if (!priceHistory.has(key)) priceHistory.set(key, []); priceHistory.get(key)!.push({ at: p.createdAt.getTime(), unitCost: p.unitCost }); } const estimateCost = (productId: string, at: number): number | null => { const history = priceHistory.get(productId); if (!history || history.length === 0) return null; let latest: number | null = null; for (const h of history) { if (h.at <= at) latest = h.unitCost; else break; } return latest ?? history[0].unitCost; }; const positions = new Map(); const position = (branch: string, product: string) => { const key = `${branch}:${product}`; if (!positions.has(key)) positions.set(key, { layers: [], deficits: [] }); return positions.get(key)!; }; const inTransit = new Map(); // transfer refId -> layers given up by the sender const result = { costOfGoodsSold: 0, unitsCosted: 0, unitsWithoutCost: 0, stockLosses: 0 }; const charge = (kind: Deficit["kind"], layers: Layer[]) => { for (const l of layers) { if (kind === "sale") { if (l.unitCost === null) result.unitsWithoutCost += l.qty; else { result.costOfGoodsSold += l.qty * l.unitCost; result.unitsCosted += l.qty; } } else if (kind === "loss" && l.unitCost !== null) { result.stockLosses += l.qty * l.unitCost; } } }; for (const m of movements) { const branch = m.branchId.toString(); const product = m.productId.toString(); const pos = position(branch, product); const at = m.createdAt.getTime(); const counts = at >= period.from.getTime() && at < period.to.getTime() && (!branchFilter || branch === branchFilter); if (m.quantity > 0) { let incoming: Layer[]; if (m.type === "PURCHASE") incoming = [{ qty: m.quantity, unitCost: purchaseCost.get(m.refId) ?? null }]; else if (m.type === "TRANSFER_IN") incoming = (inTransit.get(m.refId) ?? [{ qty: m.quantity, unitCost: null }]).map((l) => ({ ...l })); else incoming = [{ qty: m.quantity, unitCost: estimateCost(product, at) }]; // Stock that was already sold short is covered first, at this stock's cost. while (pos.deficits.length > 0 && incoming.length > 0) { const d = pos.deficits[0]; const { taken, short } = take(incoming, d.qty); if (d.counts) charge(d.kind, taken); d.qty = short; if (d.qty === 0) pos.deficits.shift(); } pos.layers.push(...incoming); } else if (m.quantity < 0) { const kind: Deficit["kind"] = m.type === "SALE" ? "sale" : m.type === "TRANSFER_OUT" ? "transfer" : "loss"; const { taken, short } = take(pos.layers, -m.quantity); if (kind === "transfer") { // The receiving branch inherits this cost; any shortfall arrives with no known cost. inTransit.set(m.refId, short > 0 ? [...taken, { qty: short, unitCost: null }] : taken); } else if (counts) { charge(kind, taken); } if (short > 0) pos.deficits.push({ qty: short, kind, counts: counts && kind !== "transfer" }); } } // Short sales in the period that no later stock has covered yet. for (const pos of positions.values()) { for (const d of pos.deficits) if (d.counts && d.kind === "sale") result.unitsWithoutCost += d.qty; } return { costOfGoodsSold: round2(result.costOfGoodsSold), unitsCosted: result.unitsCosted, unitsWithoutCost: result.unitsWithoutCost, stockLosses: round2(result.stockLosses), }; }