Files
ERP/src/app/api/stock/adjustments/route.ts
T
denis_ireri 8817c0f52f Add a stock ledger, inter-branch transfers and a sync protocol that cannot jam
Stock (PRD 5.2): a branch's stock of a product is now the sum of its
StockMovement rows. Every movement names the document that caused it and
(type, refId) is unique, so replaying a sale, retrying a transfer or a
second M-Pesa callback never moves the same stock twice.

- Cash sales and confirmed M-Pesa payments deduct stock; purchases of a
  catalogue product add it.
- POST /api/stock/transfers moves stock between two branches (owner: any
  two; manager: out of their own) and refuses to send more than the
  sending branch holds. Double submits are applied once.
- POST /api/stock/adjustments records opening stock, breakage or a
  physical count (the ledger is corrected to the counted number).
- GET /api/stock returns every branch x product with LOW/OUT flags
  (per-product lowStockThreshold, default 10); GET /api/stock/movements
  is the history.

Sync (PRD 5.3/5.5): POST /api/transactions/cash now returns a result per
sale - saved, duplicate or rejected - instead of silently skipping
replays. The old client never learned a replayed sale had synced, so
rows piled up until the batch passed the 100-sale limit and every flush
failed with 400. Also:
- A sale can hold several products (one Transaction per line, grouped by
  saleId); the old single-product payload is still accepted.
- One bad sale is rejected on its own instead of failing the batch.
- A queued sale is only accepted from the cashier who recorded it, and
  stays with the branch where it happened.
- A product deactivated while offline is still recorded at its price; an
  invalid phone is dropped rather than losing the sale; the phone is now
  optional for cash sales.
- The price the device showed is kept and a difference is flagged
  (priceMismatch) instead of being silently overwritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:39:11 +03:00

84 lines
3.5 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Branch } from "@/lib/server/models/Branch";
import { Product } from "@/lib/server/models/Product";
import { StockMovement } from "@/lib/server/models/StockMovement";
import { authorize, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation";
import { clientRefId, recordMovement, stockOf } from "@/lib/server/stock";
export const runtime = "nodejs";
const adjustmentSchema = z.object({
clientUuid: z.string().uuid(),
branchId: objectId.optional(), // owners must give one; managers default to their own branch
productId: objectId,
// COUNT: "we physically counted N", so the ledger is corrected to N.
// DELTA: "add or remove N", e.g. opening stock, breakage, or a delivery
// that was not recorded as a purchase.
mode: z.enum(["COUNT", "DELTA"]),
quantity: z.number().int().min(-1_000_000).max(1_000_000),
note: z.string().trim().max(300).optional(),
});
// POST /api/stock/adjustments — managers (own branch) and owners (any branch).
export async function POST(req: NextRequest) {
await connectDb();
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const parsed = adjustmentSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const data = parsed.data;
if (data.mode === "COUNT" && data.quantity < 0) {
return NextResponse.json({ error: "A counted quantity cannot be negative" }, { status: 400 });
}
let branchId: string | null;
if (auth.role === "SUPER_ADMIN") {
branchId = data.branchId ?? null;
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
} else {
branchId = auth.branchId;
if (data.branchId && data.branchId !== auth.branchId) {
return NextResponse.json({ error: "Cannot adjust stock for another branch" }, { status: 403 });
}
}
if (!branchId) return NextResponse.json({ error: "No branch assignment for this account" }, { status: 403 });
const [branch, product] = await Promise.all([
Branch.exists({ _id: branchId, businessId: auth.businessId }),
Product.exists({ _id: data.productId, businessId: auth.businessId }),
]);
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
// A retried submit with the same clientUuid is a no-op.
const refId = clientRefId(auth.businessId, data.clientUuid);
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId });
if (!already) {
const before = await stockOf(auth.businessId, branchId, data.productId);
const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity;
if (delta !== 0) {
const label = data.mode === "COUNT" ? `Count: ${before} -> ${data.quantity}` : "Adjustment";
await recordMovement({
businessId: auth.businessId,
branchId,
productId: data.productId,
type: "ADJUSTMENT",
quantity: delta,
refId,
note: data.note ? `${label}. ${data.note}` : label,
recordedBy: auth.sub,
});
}
}
const quantity = await stockOf(auth.businessId, branchId, data.productId);
return NextResponse.json({ branchId, productId: data.productId, quantity }, { status: 201 });
}