Cost sales first-in-first-out per branch instead of at an average
Cost of goods sold used each product's average buying price across the whole business. Ten jerrycans bought at 100 and ten at 140, with 15 sold, cost 1,800 instead of the 1,700 actually paid for those units; a branch that bought dearer was costed like one that bought cheaper; and units moved between branches lost their cost. src/lib/server/costing.ts replays the stock ledger in order with cost layers per branch and product: - purchases add stock at their unit cost; transfers carry the sending branch's layers to the receiving branch; opening stock and upward counts are valued at the product's purchase price at the time; - sales take the oldest layers first, and that is cost of goods sold; - stock written off by counts or adjustments is a separate "stock losses" figure, now deducted in net profit; - a sale recorded before its stock (stock gone negative) is costed by the next stock to arrive at that branch, even after the period ended; until then it is reported as units with no purchase cost on record. The report shows the method and a Stock losses card. Sales recorded before the stock ledger existed are counted as uncosted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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<MovementType, number> = {
|
||||
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<CostOfGoods> {
|
||||
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<string, { at: number; unitCost: number }[]>();
|
||||
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<string, Position>();
|
||||
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<string, Layer[]>(); // 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user