Files
qipai/backend/tests/inventory-service.test.mjs
T

447 lines
19 KiB
JavaScript

import assert from 'node:assert/strict';
import {
InventoryError,
InventoryService
} from '../dist/inventory/inventory-service.js';
const state = {
stocks: [
stock('101', '5', 10, 0, 1, 2, 'TRACKED'),
stock('102', '2', 4, 0, 0, 1, 'TRACKED'),
stock('103', '9', 0, 0, 0, 0, 'UNLIMITED')
],
skus: new Map([
['2', 'TRACKED'], ['4', 'TRACKED'], ['5', 'TRACKED'], ['9', 'UNLIMITED']
]),
inactiveSkus: new Set(),
archivedSkus: new Set(),
requests: [],
ledgers: [],
audits: [],
lockOrder: [],
nextStockId: 104,
nextLedgerId: 1001,
commits: 0,
rollbacks: 0
};
function stock(id, skuId, availableQuantity, lockedQuantity, lossQuantity,
lowStockThreshold, policyType) {
return {
id, tenantId: '7', storeId: '11', skuId, policyType,
availableQuantity, lockedQuantity, lossQuantity, lowStockThreshold, version: 1
};
}
function cloneMutableState() {
return structuredClone({
stocks: state.stocks,
requests: state.requests,
ledgers: state.ledgers,
audits: state.audits,
nextStockId: state.nextStockId,
nextLedgerId: state.nextLedgerId
});
}
let transactionSnapshot = null;
const pool = {
async getConnection() { return connection; },
async execute(sql, params = []) { return execute(sql, params); }
};
const connection = {
async beginTransaction() { transactionSnapshot = cloneMutableState(); },
async commit() { state.commits += 1; transactionSnapshot = null; },
async rollback() {
state.rollbacks += 1;
if (transactionSnapshot) {
state.stocks = transactionSnapshot.stocks;
state.requests = transactionSnapshot.requests;
state.ledgers = transactionSnapshot.ledgers;
state.audits = transactionSnapshot.audits;
state.nextStockId = transactionSnapshot.nextStockId;
state.nextLedgerId = transactionSnapshot.nextLedgerId;
}
transactionSnapshot = null;
},
release() {},
async execute(sql, params = []) { return execute(sql, params); }
};
function execute(sql, params) {
if (/\b(UPDATE|DELETE)\s+qipai_product_inventory_ledger/i.test(sql)) {
throw new Error('immutable ledger was mutated');
}
if (sql.includes('SELECT COUNT(*) AS total') && sql.includes('qipai_product_inventory_ledger')) {
const matches = state.ledgers.filter((item) => item.tenantId === String(params[0])
&& item.storeId === String(params[1]) && item.inventoryId === String(params[2]));
return [[{ total: matches.length }], []];
}
if (sql.includes('FROM qipai_product_inventory_ledger')
&& sql.includes('ORDER BY id DESC')) {
const rows = state.ledgers.filter((item) => item.tenantId === String(params[0])
&& item.storeId === String(params[1]) && item.inventoryId === String(params[2]))
.sort((left, right) => Number(right.id) - Number(left.id));
return [rows, []];
}
if (sql.includes('SELECT COUNT(*) AS total') && sql.includes('qipai_product_inventory i')) {
const matches = state.stocks.filter((item) => item.tenantId === String(params[0])
&& item.storeId === String(params[1]));
return [[{ total: matches.length }], []];
}
if (sql.includes('FROM qipai_product_inventory i') && sql.includes('ORDER BY p.sort_order')) {
const rows = state.stocks.filter((item) => item.tenantId === String(params[0])
&& item.storeId === String(params[1])).map((item) => ({
...item,
skuCode: `SKU-${item.skuId}`,
skuName: `规格 ${item.skuId}`,
productId: `8${item.skuId}`,
productCode: `P-${item.skuId}`,
productName: `商品 ${item.skuId}`,
salePriceCents: 1200
}));
return [rows, []];
}
if (sql.includes('INSERT IGNORE INTO qipai_product_inventory')) {
const tenantId = String(params[0]);
const storeId = String(params[3]);
const skuId = String(params[5]);
const existing = findStock(tenantId, storeId, skuId);
if (existing || tenantId !== '7' || storeId !== '11' || !state.skus.has(skuId)) {
return [{ affectedRows: 0 }, []];
}
state.stocks.push({
id: String(state.nextStockId++), tenantId, storeId, skuId,
policyType: state.skus.get(skuId), availableQuantity: 0, lockedQuantity: 0,
lossQuantity: 0, lowStockThreshold: 0, version: 1
});
return [{ affectedRows: 1 }, []];
}
if (sql.includes('INSERT INTO qipai_product_inventory_requests')) {
const tenantId = String(params[0]);
const requestId = String(params[2]);
if (state.requests.some((item) => item.tenantId === tenantId
&& item.requestId === requestId)) {
throw Object.assign(new Error('duplicate inventory request'), { code: 'ER_DUP_ENTRY' });
}
state.requests.push({
tenantId, storeId: String(params[1]), requestId,
operation: params[3], businessType: String(params[4]), businessId: String(params[5]),
fingerprint: String(params[6]), itemCount: Number(params[7])
});
return [{ insertId: state.requests.length, affectedRows: 1 }, []];
}
if (sql.includes('FROM qipai_product_inventory_requests') && sql.includes('FOR UPDATE')) {
const row = state.requests.find((item) => item.tenantId === String(params[0])
&& item.requestId === String(params[1]));
return [row ? [{ ...row }] : [], []];
}
if (sql.includes('SELECT product_id AS productId')
&& sql.includes('FROM qipai_product_skus')) {
const skuId = String(params[1]);
return [state.skus.has(skuId) ? [{ productId: `8${skuId}` }] : [], []];
}
if (sql.includes('SELECT id FROM qipai_products') && sql.includes('FOR SHARE')) {
return [[{ id: String(params[1]) }], []];
}
if (sql.includes('SELECT id FROM qipai_product_skus') && sql.includes('FOR SHARE')) {
const skuId = String(params[2]);
const requiresActive = sql.includes("status = 'ACTIVE'");
return [state.skus.has(skuId) && !state.archivedSkus.has(skuId)
&& (!requiresActive || !state.inactiveSkus.has(skuId))
? [{ id: skuId }] : [], []];
}
if (sql.includes('SELECT id FROM qipai_product_inventory')) {
const found = findStock(String(params[0]), String(params[1]), String(params[2]));
return [found ? [{ id: found.id }] : [], []];
}
if (sql.includes('FROM qipai_product_inventory') && sql.includes('FOR UPDATE')) {
const skuId = String(params[2]);
state.lockOrder.push(skuId);
const found = findStock(String(params[0]), String(params[1]), skuId);
return [found ? [{ ...found }] : [], []];
}
if (sql.includes('FROM qipai_product_inventory_ledger')
&& sql.includes('request_id = ?')) {
const row = state.ledgers.find((item) => item.tenantId === String(params[0])
&& item.inventoryId === String(params[1]) && item.requestId === String(params[2]));
return [row ? [{ ...row }] : [], []];
}
if (sql.includes('AS reservedQuantity') && sql.includes('business_type = ?')) {
const inventoryIds = new Set(params.slice(4).map(String));
const balances = new Map();
for (const item of state.ledgers) {
if (item.tenantId !== String(params[0]) || item.storeId !== String(params[1])
|| item.businessType !== String(params[2]) || item.businessId !== String(params[3])
|| !inventoryIds.has(item.inventoryId)) continue;
const metadata = JSON.parse(item.metadata);
const reservationDelta = metadata.inventoryReservationDelta ?? Number(item.lockedDelta);
balances.set(item.inventoryId,
(balances.get(item.inventoryId) ?? 0) + Number(reservationDelta));
}
return [[...balances.entries()].map(([inventoryId, reservedQuantity]) => ({
inventoryId, reservedQuantity
})), []];
}
if (sql.includes('AS reservedQuantity')
&& sql.includes('WHERE tenant_id = ? AND inventory_id = ?')) {
const reservedQuantity = state.ledgers
.filter((item) => item.tenantId === String(params[0])
&& item.inventoryId === String(params[1]))
.reduce((total, item) => {
const metadata = JSON.parse(item.metadata);
return total + Number(metadata.inventoryReservationDelta ?? item.lockedDelta);
}, 0);
return [[{ reservedQuantity }], []];
}
if (sql.startsWith('UPDATE qipai_product_inventory')) {
const found = state.stocks.find((item) => item.tenantId === String(params[7])
&& item.id === String(params[8]) && item.version === Number(params[9]));
if (!found) return [{ affectedRows: 0 }, []];
Object.assign(found, {
policyType: params[0], availableQuantity: Number(params[1]),
lockedQuantity: Number(params[2]), lossQuantity: Number(params[3]),
lowStockThreshold: Number(params[4]), version: Number(params[5])
});
return [{ affectedRows: 1 }, []];
}
if (sql.includes('INSERT INTO qipai_product_inventory_ledger')) {
const duplicate = state.ledgers.some((item) => item.tenantId === String(params[0])
&& item.inventoryId === String(params[1]) && item.requestId === String(params[4]));
if (duplicate) throw new Error('duplicate inventory ledger request');
const row = {
id: String(state.nextLedgerId++),
tenantId: String(params[0]), inventoryId: String(params[1]),
storeId: String(params[2]), skuId: String(params[3]), requestId: String(params[4]),
businessType: String(params[5]), businessId: String(params[6]), operation: params[7],
availableDelta: Number(params[8]), lockedDelta: Number(params[9]),
lossDelta: Number(params[10]), availableAfter: Number(params[11]),
lockedAfter: Number(params[12]), lossAfter: Number(params[13]),
versionAfter: Number(params[14]), operatorId: params[15] == null ? null : String(params[15]),
traceId: String(params[16]), reason: String(params[17]), metadata: String(params[18]),
createdAt: new Date()
};
state.ledgers.push(row);
return [{ insertId: row.id, affectedRows: 1 }, []];
}
if (sql.includes('INSERT INTO qipai_audit_logs')) {
state.audits.push({ action: params[3], resourceId: String(params[4]), metadata: params[8] });
return [{ insertId: state.audits.length, affectedRows: 1 }, []];
}
throw new Error(`Unexpected SQL: ${sql}`);
}
function findStock(tenantId, storeId, skuId) {
return state.stocks.find((item) => item.tenantId === tenantId
&& item.storeId === storeId && item.skuId === skuId);
}
const manager = {
tenantId: '7', userId: '21',
access: { roles: ['STORE_ADMIN'], capabilities: ['inventory.read', 'inventory.adjust'], storeIds: ['11'] },
traceId: 'inventory-test', ip: '127.0.0.1', userAgent: 'inventory-test-agent'
};
const service = new InventoryService(pool);
const listed = await service.listStocks(manager, { storeId: '11', page: 1, pageSize: 20 });
assert.equal(listed.total, 3);
assert.equal(listed.items[0].tenantId, '7');
assert.equal(listed.items[0].lossQuantity, 1);
const inboundInput = {
storeId: '11', skuId: '5', requestId: 'admin-inbound-1', reason: '首批入库',
expectedVersion: 1, quantity: 5
};
const inbound = await service.inbound(manager, inboundInput);
assert.equal(inbound.availableQuantity, 15);
assert.equal(inbound.version, 2);
const auditCountAfterInbound = state.audits.length;
const replay = await service.inbound(manager, inboundInput);
assert.equal(replay.idempotent, true);
assert.equal(replay.availableQuantity, 15);
assert.equal(state.audits.length, auditCountAfterInbound);
await assert.rejects(
() => service.inbound(manager, { ...inboundInput, quantity: 6 }),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
);
const adjusted = await service.adjust(manager, {
storeId: '11', skuId: '5', requestId: 'admin-adjust-1', reason: '人工纠偏',
expectedVersion: 2, availableDelta: -2, lossDelta: 1
});
assert.deepEqual(
[adjusted.availableQuantity, adjusted.lossQuantity, adjusted.version],
[13, 2, 3]
);
const counted = await service.stocktake(manager, {
storeId: '11', skuId: '5', requestId: 'admin-stocktake-1', reason: '闭店盘点',
expectedVersion: 3, availableQuantity: 12, lossQuantity: 3
});
assert.deepEqual(
[counted.availableQuantity, counted.lossQuantity, counted.version],
[12, 3, 4]
);
const lost = await service.recordLoss(manager, {
storeId: '11', skuId: '5', requestId: 'admin-loss-1', reason: '包装破损',
expectedVersion: 4, quantity: 2
});
assert.deepEqual([lost.availableQuantity, lost.lossQuantity, lost.version], [10, 5, 5]);
const configured = await service.configurePolicy(manager, {
storeId: '11', skuId: '4', requestId: 'admin-policy-1', reason: '启用库存追踪',
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 3
});
assert.equal(configured.policyType, 'TRACKED');
assert.equal(configured.lowStockThreshold, 3);
assert.ok(findStock('7', '11', '4'));
const reconfigured = await service.configurePolicy(manager, {
storeId: '11', skuId: '4', requestId: 'admin-policy-2', reason: 'raise warning threshold',
policyType: 'TRACKED', lowStockThreshold: 7, expectedVersion: 2
});
assert.equal(reconfigured.lowStockThreshold, 7);
const configuredReplay = await service.configurePolicy(manager, {
storeId: '11', skuId: '4', requestId: 'admin-policy-1', reason: '启用库存追踪',
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 3
});
assert.equal(configuredReplay.idempotent, true);
assert.equal(configuredReplay.lowStockThreshold, 3);
await assert.rejects(
() => service.configurePolicy(manager, {
storeId: '11', skuId: '5', requestId: 'admin-policy-unlimited', reason: '错误切换',
policyType: 'UNLIMITED', lowStockThreshold: 0, expectedVersion: 5
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_POLICY_STOCK_NOT_ZERO'
);
const scopedOut = { ...manager, access: { ...manager.access, storeIds: ['12'] } };
await assert.rejects(
() => service.listStocks(scopedOut, { storeId: '11', page: 1, pageSize: 20 }),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_STORE_SCOPE_FORBIDDEN'
);
state.lockOrder.length = 0;
const batchBase = {
tenantId: '7', storeId: '11', requestId: 'order-lock-1',
businessType: 'PRODUCT_ORDER', businessId: 'order-500', traceId: 'order-lock-trace',
operatorId: '21', reason: '创建商品订单', metadata: { source: 'ORDER' },
items: [{ skuId: '5', quantity: 3 }, { skuId: '2', quantity: 1 }, { skuId: '2', quantity: 1 }]
};
const locked = await service.lockMany(batchBase);
assert.equal(locked.idempotent, false);
assert.deepEqual(state.lockOrder.slice(-2), ['2', '5']);
assert.deepEqual(
[findStock('7', '11', '2').availableQuantity, findStock('7', '11', '2').lockedQuantity],
[2, 2]
);
assert.deepEqual(
[findStock('7', '11', '5').availableQuantity, findStock('7', '11', '5').lockedQuantity],
[7, 3]
);
const ledgerCountAfterLock = state.ledgers.length;
const lockedReplay = await service.lockMany(batchBase);
assert.equal(lockedReplay.idempotent, true);
assert.equal(state.ledgers.length, ledgerCountAfterLock);
await assert.rejects(
() => service.lockMany({ ...batchBase, items: [{ skuId: '5', quantity: 4 }, { skuId: '2', quantity: 2 }] }),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
);
await assert.rejects(
() => service.lockMany({ ...batchBase, items: [{ skuId: '9', quantity: 1 }] }),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
);
await assert.rejects(
() => service.releaseMany({
...batchBase, requestId: 'foreign-order-release', businessId: 'order-foreign',
items: [{ skuId: '5', quantity: 1 }]
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_INSUFFICIENT_RESERVATION'
);
await service.releaseMany({
...batchBase, requestId: 'order-release-1', reason: '部分取消',
items: [{ skuId: '5', quantity: 1 }, { skuId: '2', quantity: 1 }]
});
await service.deductMany({
...batchBase, requestId: 'order-deduct-1', reason: '支付确认扣减',
items: [{ skuId: '5', quantity: 2 }, { skuId: '2', quantity: 1 }]
});
assert.deepEqual(
[findStock('7', '11', '2').availableQuantity, findStock('7', '11', '2').lockedQuantity],
[3, 0]
);
assert.deepEqual(
[findStock('7', '11', '5').availableQuantity, findStock('7', '11', '5').lockedQuantity],
[8, 0]
);
const beforeFailedBatch = structuredClone(state.stocks);
await assert.rejects(
() => service.lockMany({
...batchBase, requestId: 'order-lock-too-many', businessId: 'order-501',
items: [{ skuId: '5', quantity: 999 }]
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_INSUFFICIENT_AVAILABLE'
);
assert.deepEqual(state.stocks, beforeFailedBatch);
assert.ok(state.rollbacks > 0);
const unlimited = await service.lockMany({
...batchBase, requestId: 'order-unlimited-lock', businessId: 'order-502',
items: [{ skuId: '9', quantity: 999 }]
});
assert.equal(unlimited.items[0].availableQuantity, 0);
assert.equal(unlimited.items[0].lockedQuantity, 0);
await assert.rejects(
() => service.configurePolicy(manager, {
storeId: '11', skuId: '9', requestId: 'unlimited-switch-active', reason: '切换追踪',
expectedVersion: unlimited.items[0].version, policyType: 'TRACKED', lowStockThreshold: 0
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_POLICY_HAS_ACTIVE_RESERVATIONS'
);
const unlimitedDeduct = await service.deductMany({
...batchBase, requestId: 'order-unlimited-deduct', businessId: 'order-502',
items: [{ skuId: '9', quantity: 999 }]
});
const trackedAfterUnlimited = await service.configurePolicy(manager, {
storeId: '11', skuId: '9', requestId: 'unlimited-switch-settled', reason: '切换追踪',
expectedVersion: unlimitedDeduct.items[0].version,
policyType: 'TRACKED', lowStockThreshold: 0
});
assert.equal(trackedAfterUnlimited.policyType, 'TRACKED');
state.inactiveSkus.add('2');
const inactiveReplay = await service.lockMany(batchBase);
assert.equal(inactiveReplay.idempotent, true);
await assert.rejects(
() => service.lockMany({
...batchBase, requestId: 'inactive-sku-lock', businessId: 'order-503',
items: [{ skuId: '2', quantity: 1 }]
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_SKU_NOT_SELLABLE'
);
state.inactiveSkus.delete('2');
state.archivedSkus.add('5');
const archivedAdminReplay = await service.inbound(manager, inboundInput);
assert.equal(archivedAdminReplay.idempotent, true);
await assert.rejects(
() => service.inbound(manager, {
storeId: '11', skuId: '5', requestId: 'archived-sku-inbound',
reason: '归档规格不得重新入库', quantity: 1
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_SKU_NOT_FOUND'
);
state.archivedSkus.delete('5');
const history = await service.listLedger(manager, {
storeId: '11', inventoryId: '101', page: 1, pageSize: 100
});
assert.ok(history.items.length >= 7);
assert.ok(history.items.every((item) => item.metadata.idempotencyFingerprint));
assert.equal(state.audits.length, state.ledgers.length);
console.log('PASS: M09-D1 inventory policy, inbound/adjust/stocktake/loss, ordered batch locks, idempotency, immutable ledger, scope and audit work.');