137 lines
9.7 KiB
JavaScript
137 lines
9.7 KiB
JavaScript
/** All inventory mutations commit quantity, ledger and replay identity together. */
|
|
const OPERATIONS = "stock_movement_operations";
|
|
const STOCK = "stock_items";
|
|
const LEDGER = "stock_ledger";
|
|
function fail(message, status) { throw new ApiError(status || 409, message); }
|
|
function plain(record) { return JSON.parse(JSON.stringify(record)); }
|
|
function text(value, max) { return String(value === undefined || value === null ? "" : value).trim().slice(0, max || 200); }
|
|
function finite(value, label) { if (typeof value !== "number" || !isFinite(value)) fail(label + " must be a finite number.", 422); return value; }
|
|
function stable(value) {
|
|
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
if (Array.isArray(value)) return "[" + value.map(stable).join(",") + "]";
|
|
return "{" + Object.keys(value).sort().filter((k) => value[k] !== undefined).map((k) => JSON.stringify(k) + ":" + stable(value[k])).join(",") + "}";
|
|
}
|
|
function lookup(app, name, id) { try { return app.findRecordById(name, id); } catch (_) { fail(name + " record not found.", 404); } }
|
|
function findOperation(app, field, value) {
|
|
try { return app.findFirstRecordByFilter(OPERATIONS, field + " = {:" + (field === "observation_key" ? "observation" : "operation") + "}", field === "observation_key" ? { observation: value } : { operation: value }); }
|
|
catch (_) { return null; }
|
|
}
|
|
function assertSchema(app) {
|
|
const fields = { stock_items: ["current_qty", "stock_revision"], stock_movement_operations: ["operation_id", "stock_item", "payload_hash", "observation_key", "result"] };
|
|
Object.keys(fields).forEach((name) => {
|
|
let collection; try { collection = app.findCollectionByNameOrId(name); } catch (_) { fail("Stock movements require the PocketBase migration and hooks.", 503); }
|
|
fields[name].forEach((field) => {
|
|
let present; try { present = collection.fields.getByName(field); } catch (_) { present = null; }
|
|
if (!present) fail("Stock movements require the PocketBase migration (" + name + "." + field + ").", 503);
|
|
});
|
|
});
|
|
}
|
|
function validatePlan(app, input) {
|
|
if (!input.countPlan) return null;
|
|
const plan = lookup(app, "stock_quantity_plans", text(input.countPlan.id));
|
|
if (plan.getString("owner") !== input.actor.id) fail("This quantity plan belongs to another actor.", 403);
|
|
const rows = plain(plan).rows;
|
|
const row = Array.isArray(rows) ? rows.find((r) => r.row_ref === input.countPlan.rowRef) : null;
|
|
if (!row || row.id !== input.itemId || plan.getString("mode") !== input.mode || Number(input.qty) !== Number(input.mode === "add" ? row.delta : row.after) || Number(input.expectedRevision) !== Number(row.revision)) fail("The quantity plan no longer matches this operation.");
|
|
if (input.operationId !== plan.id + "_" + row.row_ref) fail("The quantity plan operation reference is invalid.");
|
|
if (["pending", "partial", "applied"].indexOf(plan.getString("status")) < 0) fail("This quantity plan is no longer applicable.");
|
|
const countId = plan.getString("count");
|
|
let observation = "";
|
|
if (countId) {
|
|
const count = lookup(app, "stock_counts", countId);
|
|
if (count.getString("owner") !== input.actor.id || count.getString("status") !== "ready" || Number(count.get("revision")) !== Number(plan.get("count_revision")) || Number(count.get("review_revision") || 0) !== Number(plan.get("review_revision") || 0)) fail("The stock count sheet changed or is no longer ready. Generate a fresh review.");
|
|
observation = countId + "_" + row.row_ref;
|
|
if (findOperation(app, "observation_key", observation)) fail("This physical count was already applied. Start a new count for a recount.");
|
|
}
|
|
return { plan: plan, row: row, observation: observation };
|
|
}
|
|
function command(event, raw) {
|
|
assertSchema(event.app);
|
|
const input = raw || {};
|
|
if (!input.actor || typeof input.actor.id !== "string" || !text(input.actor.name)) fail("A stock movement actor is required.", 422);
|
|
if (input.mode !== "add" && input.mode !== "set") fail("Stock movement mode must be set or add.", 422);
|
|
finite(input.qty, "Quantity");
|
|
if (!text(input.itemId) || !text(input.operationId) || String(input.operationId).length > 200) fail("A stock item and stable operation id are required.", 422);
|
|
if (input.expectedRevision !== undefined && (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0)) fail("Stock revision must be a non-negative integer.", 422);
|
|
if (input.mode === "set" && input.expectedRevision === undefined) fail("An absolute count needs the stock revision it was reviewed against.", 422);
|
|
const payloadHash = $security.sha256(stable(input));
|
|
let result;
|
|
event.app.runInTransaction((app) => {
|
|
const prior = findOperation(app, "operation_id", input.operationId);
|
|
if (prior) {
|
|
if (prior.getString("payload_hash") !== payloadHash) fail("This operation id was already used for a different stock change.");
|
|
result = Object.assign({}, plain(prior).result, { replayed: true });
|
|
return;
|
|
}
|
|
const binding = validatePlan(app, input);
|
|
const item = lookup(app, STOCK, input.itemId);
|
|
if (binding && (String(binding.row.unit || "ea") !== String(item.get("unit") || "ea") || String(binding.row.sku || "") !== String(item.get("sku") || "") || String(binding.row.description || "") !== String(item.get("description") || item.get("name") || ""))) fail("The stock item identity or unit changed after this review. Refresh and approve a new plan.");
|
|
const before = Number(item.get("current_qty") || 0);
|
|
const revision = Number(item.get("stock_revision") || 0);
|
|
if (input.expectedRevision !== undefined && input.expectedRevision !== revision) fail("Stock changed after this review. Refresh and approve a new plan.");
|
|
if (input.requireActive !== false && item.get("is_active") === false) fail("This stock item is inactive.");
|
|
const after = input.mode === "set" ? input.qty : before + input.qty;
|
|
finite(after, "Resulting quantity");
|
|
if (input.minimumQty !== undefined && after < finite(input.minimumQty, "Minimum quantity")) fail("There is not enough stock to fulfil this request.");
|
|
const delta = after - before;
|
|
const patch = input.patch || {};
|
|
const allowed = ["sku", "name", "description", "unit", "location", "supplier", "is_active", "notes", "min_qty", "target_qty", "cost_price", "selling_price", "markup", "cost_history", "category"];
|
|
Object.keys(patch).forEach((key) => { if (allowed.indexOf(key) < 0) fail("Forbidden stock movement patch field: " + key, 422); item.set(key, patch[key]); });
|
|
let ledgerId;
|
|
if (delta !== 0) {
|
|
const ledger = new Record(app.findCollectionByNameOrId(LEDGER));
|
|
ledger.set("stock_item", input.itemId);
|
|
ledger.set("change_qty", delta);
|
|
ledger.set("reason", text(input.reason) || "adjustment");
|
|
ledger.set("reference_type", text(input.referenceType) || "manual");
|
|
ledger.set("reference_id", text(input.referenceId));
|
|
ledger.set("notes", text(input.notes, 8000) || text(input.actor.name) + " changed stock " + before + " → " + after);
|
|
ledger.set("created_by", /^[a-z0-9]{15}$/i.test(input.actor.id) ? input.actor.id : "");
|
|
if (input.unitCost !== undefined) ledger.set("unit_cost", finite(input.unitCost, "Unit cost"));
|
|
app.save(ledger);
|
|
ledgerId = ledger.id;
|
|
item.set("current_qty", after);
|
|
item.set("stock_revision", revision + 1);
|
|
}
|
|
if (delta !== 0 || Object.keys(patch).length) app.save(item);
|
|
result = { item: plain(item), previousQty: before, newQty: after, delta: delta, revision: delta === 0 ? revision : revision + 1, replayed: false };
|
|
if (ledgerId) result.ledgerId = ledgerId;
|
|
const operation = new Record(app.findCollectionByNameOrId(OPERATIONS));
|
|
operation.set("operation_id", input.operationId);
|
|
operation.set("stock_item", input.itemId);
|
|
operation.set("payload_hash", payloadHash);
|
|
operation.set("observation_key", binding ? binding.observation : "");
|
|
operation.set("result", result);
|
|
app.save(operation);
|
|
if (binding) {
|
|
const existing = plain(binding.plan).results;
|
|
const rows = Array.isArray(existing) ? existing : [];
|
|
rows.push(Object.assign({ row_ref: binding.row.row_ref }, result));
|
|
binding.plan.set("results", rows);
|
|
binding.plan.set("status", rows.length >= plain(binding.plan).rows.length ? "applied" : "partial");
|
|
app.save(binding.plan);
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
function protectUpdate(e) {
|
|
const body = e.requestInfo().body || {};
|
|
if (Object.keys(body).some((key) => /^(current_qty|stock_revision)([+-])?$/.test(key))) throw new ForbiddenError("Use the transactional stock movement command to change inventory.");
|
|
return e.next();
|
|
}
|
|
function protectCreate(e) {
|
|
const body = e.requestInfo().body || {};
|
|
if (Object.keys(body).some((key) => /^(current_qty|stock_revision)[+-]$/.test(key)) || Number(body.current_qty || 0) !== 0 || Number(body.stock_revision || 0) !== 0) throw new ForbiddenError("Create stock at zero, then use the transactional opening balance command.");
|
|
return e.next();
|
|
}
|
|
function protectLedger(e) {
|
|
const body = e.requestInfo().body || {};
|
|
if (Object.keys(body).some((key) => /^change_qty[+-]$/.test(key)) || Number(body.change_qty || 0) !== 0) throw new ForbiddenError("Use the transactional stock movement command to write quantity ledger rows.");
|
|
return e.next();
|
|
}
|
|
function protectLedgerUpdate(e) {
|
|
const body = e.requestInfo().body || {};
|
|
if (Object.keys(body).some((key) => /^(change_qty|stock_item|reason|reference_type|reference_id|created_by|unit_cost)([+-])?$/.test(key))) throw new ForbiddenError("Quantity ledger rows are immutable.");
|
|
return e.next();
|
|
}
|
|
module.exports = { protectLedgerUpdate: protectLedgerUpdate, command: command, assertSchema: assertSchema, protectUpdate: protectUpdate, protectCreate: protectCreate, protectLedger: protectLedger };
|