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.');