import mongoose, { Types } from "mongoose"; import { StockMovement, type MovementType } from "@/lib/server/models/StockMovement"; import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch"; import { Product } from "@/lib/server/models/Product"; import { Purchase } from "@/lib/server/models/Purchase"; import type { ITransaction } from "@/lib/server/models/Transaction"; type Id = Types.ObjectId | string; export interface MovementInput { businessId: Id; branchId: Id; productId: Id; type: MovementType; quantity: number; // signed refId: string; note?: string | null; recordedBy?: Id | null; } /** * Write one ledger movement, idempotently: if a movement with the same * (type, refId) already exists nothing changes. Safe to call again on any * retry or replay. */ export async function recordMovement(m: MovementInput): Promise { await StockMovement.updateOne( { type: m.type, refId: m.refId }, { $setOnInsert: { businessId: m.businessId, branchId: m.branchId, productId: m.productId, quantity: m.quantity, note: m.note ?? null, recordedBy: m.recordedBy ?? null, }, }, { upsert: true } ); } /** * Deduct a completed sale from its branch's stock. Only SUCCESS sales move * stock — a PENDING or FAILED M-Pesa prompt never does. */ export async function recordSaleMovement( txn: Pick ): Promise { if (txn.paymentStatus !== "SUCCESS") return; await recordMovement({ businessId: txn.businessId, branchId: txn.branchId, productId: txn.productId, type: "SALE", quantity: -Math.abs(txn.quantity), refId: txn._id.toString(), recordedBy: txn.staffId, }); } /** Ledger refIds derived from client-generated keys are namespaced by tenant. */ export function clientRefId(businessId: Id, clientUuid: string): string { return `${businessId.toString()}:${clientUuid}`; } /** Current stock of one product at one branch (the sum of its movements). */ export async function stockOf(businessId: Id, branchId: Id, productId: Id): Promise { const [row] = await StockMovement.aggregate<{ total: number }>([ { $match: { businessId: new mongoose.Types.ObjectId(businessId.toString()), branchId: new mongoose.Types.ObjectId(branchId.toString()), productId: new mongoose.Types.ObjectId(productId.toString()), }, }, { $group: { _id: null, total: { $sum: "$quantity" } } }, ]); return row?.total ?? 0; } export type StockStatus = "OK" | "LOW" | "OUT"; export interface StockLevel { branchId: string; branchName: string; productId: string; productLabel: string; unit: string; quantity: number; lowStockThreshold: number; status: StockStatus; productActive: boolean; } export function stockStatus(quantity: number, threshold: number): StockStatus { if (quantity <= 0) return "OUT"; if (quantity <= threshold) return "LOW"; return "OK"; } /** * Stock of every active product at every active branch in scope — * including zero rows, so an owner sees "0" rather than a missing product. * A deactivated product is listed too wherever it still has stock, so * units bought before deactivation never silently disappear. A * negative quantity is reported as-is: it means sales were recorded * (typically offline) for stock the ledger never received, and the branch * needs a count/adjustment. */ export async function getStockLevels(businessId: Id, branchId?: Id | null): Promise { const bizObj = new mongoose.Types.ObjectId(businessId.toString()); const branchFilter: Record = { businessId: bizObj, ...ACTIVE_BRANCH }; if (branchId) branchFilter._id = new mongoose.Types.ObjectId(branchId.toString()); const [branches, products, totals] = await Promise.all([ Branch.find(branchFilter).sort({ branchName: 1 }).lean(), Product.find({ businessId: bizObj }).sort({ sortOrder: 1 }).lean(), StockMovement.aggregate<{ _id: { branchId: Types.ObjectId; productId: Types.ObjectId }; total: number }>([ { $match: branchId ? { businessId: bizObj, branchId: branchFilter._id } : { businessId: bizObj } }, { $group: { _id: { branchId: "$branchId", productId: "$productId" }, total: { $sum: "$quantity" } } }, ]), ]); const totalByKey = new Map(totals.map((t) => [`${t._id.branchId}:${t._id.productId}`, t.total])); const levels: StockLevel[] = []; for (const b of branches) { for (const p of products) { const quantity = totalByKey.get(`${b._id}:${p._id}`) ?? 0; if (!p.isActive && quantity === 0) continue; const lowStockThreshold = p.lowStockThreshold ?? 10; levels.push({ branchId: b._id.toString(), branchName: b.branchName, productId: p._id.toString(), productLabel: p.label, unit: p.unit ?? "L", quantity, lowStockThreshold, status: stockStatus(quantity, lowStockThreshold), productActive: p.isActive, }); } } return levels; } /** * Write the PURCHASE movement for every catalogue purchase that has none. * Purchases recorded before the stock ledger existed (or while a write * failed) otherwise never show up as stock. Idempotent: run it as often * as you like. Returns how many movements were added. */ export async function backfillPurchaseMovements(businessId?: Id): Promise { const filter: Record = { productId: { $ne: null } }; if (businessId) filter.businessId = new mongoose.Types.ObjectId(businessId.toString()); let added = 0; for await (const p of Purchase.find(filter).lean().cursor()) { const refId = p._id.toString(); if (await StockMovement.exists({ type: "PURCHASE", refId })) continue; await recordMovement({ businessId: p.businessId, branchId: p.branchId, productId: p.productId!, type: "PURCHASE", quantity: p.quantity, refId, note: `From ${p.supplierName} (backfilled)`, recordedBy: p.recordedBy, }); added += 1; } return added; }