447 lines
19 KiB
JavaScript
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.');
|