Address the 11 October test report findings

The initial test report (run before the pilot-readiness changes) raised
seven findings and a retest plan. This closes the ones still open.

- F-01: a business on its free trial may run 2 active branches whatever
  its tier, so an owner can try transfers and per-branch stock. Only
  active branches count towards any limit.
- F-06: owners can rename branches and deactivate/reactivate them from
  the Branches page. A branch can only be deactivated once it holds no
  stock and no active staff; its history stays in every report, and it
  leaves every picker.
- F-04: the Products page lists deactivated products under a toggle with
  Reactivate. Deactivating warns about stock still on hand, which stays
  on the Stock page so it can be transferred or counted out.
- F-05: products carry a unit (L, ml, kg, g, pcs, tray, pack) instead of
  everything being litres.
- CAT-12: product prices must be above zero and names are unique per
  business, ignoring case.
- F-03: a reversed report period is refused by the page and the API, and
  the page labels which period the cards show. Report dates are Nairobi
  business days.
- F-07: cost of goods sold is units sold x average buying price. Stock
  bought in the period is reported separately, with a cash surplus line,
  so unsold stock no longer shows as a loss.
- STA-04: staff and new branch managers can be invited with a one-time,
  7-day link; the person sets their own password. Managers can issue a
  new link for their own cashiers.
- OFF-02 / POS-02: the till shows each product's stock and deducts every
  sale immediately, online or offline; it warns before selling more than
  the recorded stock, and has a Clear sale button.
- npm run backfill:stock turns purchases recorded before the stock ledger
  into stock, so existing tenants (and the report's ZERRO fixtures) start
  from what they bought.

tests/retest-plan.test.ts runs the report's completion and retest plan
and checks every balance and total it predicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:29:21 +03:00
parent cb962466b3
commit f865837b27
42 changed files with 1486 additions and 193 deletions
+40 -5
View File
@@ -1,7 +1,8 @@
import mongoose, { Types } from "mongoose";
import { StockMovement, type MovementType } from "@/lib/server/models/StockMovement";
import { Branch } from "@/lib/server/models/Branch";
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;
@@ -89,6 +90,7 @@ export interface StockLevel {
quantity: number;
lowStockThreshold: number;
status: StockStatus;
productActive: boolean;
}
export function stockStatus(quantity: number, threshold: number): StockStatus {
@@ -98,20 +100,22 @@ export function stockStatus(quantity: number, threshold: number): StockStatus {
}
/**
* Stock of every active product at every branch in scope — including
* zero rows, so an owner sees "0" rather than a missing product. A
* 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<StockLevel[]> {
const bizObj = new mongoose.Types.ObjectId(businessId.toString());
const branchFilter: Record<string, unknown> = { businessId: bizObj };
const branchFilter: Record<string, unknown> = { 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, isActive: true }).sort({ sortOrder: 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" } } },
@@ -123,6 +127,7 @@ export async function getStockLevels(businessId: Id, branchId?: Id | null): Prom
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(),
@@ -133,8 +138,38 @@ export async function getStockLevels(businessId: Id, branchId?: Id | null): Prom
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<number> {
const filter: Record<string, unknown> = { 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;
}