feat(M09-D1): 完成商品目录与库存流水底座

This commit is contained in:
Codex
2026-08-11 03:51:18 +08:00
parent 2de796143f
commit 93af128002
37 changed files with 14683 additions and 58 deletions
+1
View File
@@ -43,6 +43,7 @@ assert.equal(loginInput.tenantCode, 'demo');
assert.equal(login.json().data.user.phone, undefined);
assert.deepEqual(login.json().data.access.roles, ['TENANT_ADMIN']);
assert.ok(login.json().data.access.menus.includes('system'));
assert.ok(login.json().data.access.menus.includes('products'));
assert.match(login.json().data.accessToken, /^[^.]+\.[^.]+\.[^.]+$/);
assert.match(login.json().data.refreshToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/);
const successfulSessionId = loginInput.sessionId;
+208
View File
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import { signAccessToken } from '../dist/auth/jwt.js';
import { InventoryError } from '../dist/inventory/inventory-service.js';
import { registerInventoryRoutes } from '../dist/routes/inventory.js';
const secret = 'test-only-inventory-route-jwt-secret';
const sessionId = '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e';
const token = signAccessToken({
sub: '21', sid: sessionId, tid: '7', aid: '9', rv: 1
}, secret, 900);
const headers = {
authorization: `Bearer ${token}`,
'x-trace-id': 'm09d1-inventory-route'
};
const calls = [];
let currentAccess = { roles: ['STAFF'], capabilities: [], storeIds: ['11'] };
const mutationResult = (input, operation) => ({
inventoryId: '101',
ledgerId: '1001',
storeId: input.storeId,
skuId: input.skuId,
operation,
policyType: input.policyType ?? 'TRACKED',
availableQuantity: input.availableQuantity ?? 8,
lockedQuantity: 0,
lossQuantity: input.lossQuantity ?? 1,
lowStockThreshold: input.lowStockThreshold ?? 2,
version: 2,
idempotent: false
});
const record = (method, result) => async (...args) => {
calls.push({ method, args });
return typeof result === 'function' ? result(...args) : result;
};
const mutate = (operation) => record(operation.toLowerCase(), (_actor, input) => {
if (input.storeId === '12') throw new InventoryError('INVENTORY_STORE_SCOPE_FORBIDDEN');
if (input.requestId === 'insufficient') {
throw new InventoryError('INVENTORY_INSUFFICIENT_AVAILABLE');
}
return mutationResult(input, operation);
});
const service = {
listStocks: record('listStocks', (_actor, input) => ({
items: [], total: 0, page: input.page, pageSize: input.pageSize
})),
listLedger: record('listLedger', (_actor, input) => ({
items: [], total: 0, page: input.page, pageSize: input.pageSize
})),
configurePolicy: mutate('CONFIGURE'),
inbound: mutate('INBOUND'),
adjust: mutate('ADJUST'),
stocktake: mutate('STOCKTAKE'),
recordLoss: mutate('LOSS')
};
const app = Fastify({ logger: false });
app.decorateRequest('traceId', '');
app.addHook('onRequest', async (request, reply) => {
request.traceId = request.headers['x-trace-id'] || request.id;
reply.header('x-trace-id', request.traceId);
});
await registerInventoryRoutes(app, {
service,
authRepository: {
async validateSession() {
return {
id: sessionId,
tenantId: '7',
platformAppId: '9',
expiresAt: new Date(Date.now() + 60_000),
user: {
id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE',
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
}
};
}
},
accessControl: {
async getAccessProfile() { return currentAccess; }
},
jwtSecret: secret
});
const unauthorized = await app.inject({
method: 'GET', url: '/admin-api/inventory/stocks?storeId=11'
});
assert.equal(unauthorized.statusCode, 401);
assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID');
const forbidden = await app.inject({
method: 'GET', url: '/admin-api/inventory/stocks?storeId=11', headers
});
assert.equal(forbidden.statusCode, 403);
assert.equal(forbidden.json().code, 'INVENTORY_OPERATION_FORBIDDEN');
currentAccess = {
roles: ['STAFF'], capabilities: ['inventory.read'], storeIds: ['11']
};
const listed = await app.inject({
method: 'GET',
url: '/admin-api/inventory/stocks?storeId=11&page=2&pageSize=25&search=tea',
headers
});
assert.equal(listed.statusCode, 200);
assert.deepEqual(listed.json().data, { items: [], total: 0, page: 2, pageSize: 25 });
const listCall = calls.find((call) => call.method === 'listStocks');
assert.equal(listCall.args[0].tenantId, '7');
assert.equal(listCall.args[0].traceId, 'm09d1-inventory-route');
assert.deepEqual(listCall.args[1], {
storeId: '11', page: 2, pageSize: 25, search: 'tea'
});
const ledger = await app.inject({
method: 'GET',
url: '/admin-api/inventory/stocks/101/ledger?storeId=11&page=1&pageSize=10',
headers
});
assert.equal(ledger.statusCode, 200);
const ledgerCall = calls.find((call) => call.method === 'listLedger');
assert.equal(ledgerCall.args[1].inventoryId, '101');
const readOnlyWrite = await app.inject({
method: 'POST', url: '/admin-api/inventory/stocks/5/inbound', headers,
payload: { storeId: '11', requestId: 'inbound-1', reason: 'receive', quantity: 2 }
});
assert.equal(readOnlyWrite.statusCode, 403);
currentAccess = {
roles: ['STORE_ADMIN'], capabilities: ['inventory.adjust'], storeIds: ['11']
};
const invalid = await app.inject({
method: 'POST', url: '/admin-api/inventory/stocks/5/adjust', headers,
payload: {
storeId: '11', requestId: 'adjust-zero', reason: 'nothing',
availableDelta: 0, lossDelta: 0
}
});
assert.equal(invalid.statusCode, 400);
assert.equal(invalid.json().code, 'INVALID_INVENTORY_REQUEST');
const requests = [
['PUT', '/admin-api/inventory/stocks/5/policy', {
storeId: '11', requestId: 'policy-1', reason: 'configure',
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 2
}, 'configure'],
['POST', '/admin-api/inventory/stocks/5/inbound', {
storeId: '11', requestId: 'inbound-1', reason: 'receive', quantity: 2
}, 'inbound'],
['POST', '/admin-api/inventory/stocks/5/adjust', {
storeId: '11', requestId: 'adjust-1', reason: 'manual correction',
availableDelta: -1, lossDelta: 1
}, 'adjust'],
['POST', '/admin-api/inventory/stocks/5/stocktake', {
storeId: '11', requestId: 'stocktake-1', reason: 'count',
expectedVersion: 1, availableQuantity: 8, lossQuantity: 1
}, 'stocktake'],
['POST', '/admin-api/inventory/stocks/5/loss', {
storeId: '11', requestId: 'loss-1', reason: 'damaged', quantity: 1
}, 'loss']
];
for (const [method, url, payload, calledMethod] of requests) {
const response = await app.inject({ method, url, headers, payload });
assert.equal(response.statusCode, 200, `${method} ${url}`);
assert.equal(response.json().code, 0);
const call = calls.findLast((item) => item.method === calledMethod);
assert.equal(call.args[1].skuId, '5');
assert.equal(call.args[1].storeId, '11');
}
for (const [method, url, payload] of [
['PUT', '/admin-api/inventory/stocks/5/policy', {
storeId: '11', requestId: 'policy-no-version', reason: 'configure',
policyType: 'TRACKED', lowStockThreshold: 2
}],
['POST', '/admin-api/inventory/stocks/5/stocktake', {
storeId: '11', requestId: 'stocktake-no-version', reason: 'count',
availableQuantity: 8, lossQuantity: 1
}]
]) {
const response = await app.inject({ method, url, headers, payload });
assert.equal(response.statusCode, 400, `${method} ${url} requires expectedVersion`);
assert.equal(response.json().code, 'INVALID_INVENTORY_REQUEST');
}
const insufficient = await app.inject({
method: 'POST', url: '/admin-api/inventory/stocks/5/inbound', headers,
payload: { storeId: '11', requestId: 'insufficient', reason: 'conflict', quantity: 2 }
});
assert.equal(insufficient.statusCode, 409);
assert.equal(insufficient.json().code, 'INVENTORY_INSUFFICIENT_AVAILABLE');
const outOfScope = await app.inject({
method: 'POST', url: '/admin-api/inventory/stocks/5/loss', headers,
payload: { storeId: '12', requestId: 'loss-outside', reason: 'wrong store', quantity: 1 }
});
assert.equal(outOfScope.statusCode, 403);
assert.equal(outOfScope.json().code, 'INVENTORY_STORE_SCOPE_FORBIDDEN');
const internalRoute = await app.inject({
method: 'POST', url: '/admin-api/inventory/stocks/5/lock', headers,
payload: { storeId: '11', requestId: 'lock-1', reason: 'must stay internal', quantity: 1 }
});
assert.equal(internalRoute.statusCode, 404);
await app.close();
console.log('PASS: M09-D1 inventory routes enforce auth, inventory.adjust, validation, error mapping and HTTP boundaries.');
+446
View File
@@ -0,0 +1,446 @@
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.');
+76 -1
View File
@@ -120,6 +120,15 @@ const cleaningSettlementIntegrityDownSql = read(
const cleaningSettlementIntegrityVerifySql = read(
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql'
);
const productInventoryUpSql = read(
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql'
);
const productInventoryDownSql = read(
'database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql'
);
const productInventoryVerifySql = read(
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql'
);
const coreTables = [
'qipai_schema_migrations',
@@ -536,4 +545,70 @@ assert.match(cleaningSettlementIntegrityVerifySql, /'2026081006'/);
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
console.log('PASS: M01-B through M09-C migration contracts are present.');
for (const table of [
'qipai_product_categories',
'qipai_products',
'qipai_product_skus',
'qipai_product_store_listings',
'qipai_product_store_settings',
'qipai_product_store_hours',
'qipai_product_inventory',
'qipai_product_inventory_requests',
'qipai_product_inventory_ledger'
]) {
assert.match(productInventoryUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(productInventoryDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(productInventoryVerifySql, new RegExp(`'${table}'`));
}
assert.match(productInventoryUpSql, /ADD UNIQUE KEY uq_qipai_stores_tenant_id/);
assert.match(productInventoryUpSql, /ADD UNIQUE KEY uq_qipai_users_tenant_id/);
assert.match(productInventoryUpSql, /ADD COLUMN checksum CHAR\(64\)/);
assert.match(productInventoryUpSql, /store_id BIGINT UNSIGNED NOT NULL/);
assert.match(productInventoryUpSql, /uq_qipai_product_category_scope_id/);
assert.match(
productInventoryUpSql,
/FOREIGN KEY \(tenant_id, store_id, category_id\)[\s\S]*qipai_product_categories/
);
assert.match(productInventoryUpSql, /delivery_enabled TINYINT\(1\) NOT NULL DEFAULT 1/);
assert.match(productInventoryUpSql, /storage_enabled TINYINT\(1\) NOT NULL DEFAULT 0/);
assert.match(productInventoryUpSql, /default_inventory_policy VARCHAR\(16\)/);
assert.match(productInventoryUpSql, /default_inventory_policy IN \('TRACKED', 'UNLIMITED'\)/);
assert.match(productInventoryUpSql, /manual_paused_at DATETIME\(3\) NULL/);
assert.match(productInventoryUpSql, /manual_paused_until DATETIME\(3\) NULL/);
assert.match(productInventoryUpSql, /manual_pause_reason VARCHAR\(512\)/);
assert.match(productInventoryUpSql, /crosses_midnight TINYINT\(1\) NOT NULL DEFAULT 0/);
assert.match(productInventoryUpSql, /crosses_midnight = 1 AND close_minute <= open_minute/);
assert.match(productInventoryUpSql, /loss_quantity BIGINT UNSIGNED NOT NULL DEFAULT 0/);
assert.match(productInventoryUpSql, /loss_delta BIGINT NOT NULL DEFAULT 0/);
assert.match(productInventoryUpSql, /loss_after BIGINT UNSIGNED NOT NULL/);
for (const operation of [
'CONFIGURE', 'INBOUND', 'ADJUST', 'LOCK', 'RELEASE',
'DEDUCT', 'STOCKTAKE', 'LOSS', 'RETURN'
]) assert.match(productInventoryUpSql, new RegExp(`'${operation}'`));
assert.match(productInventoryUpSql, /uq_qipai_product_inventory_ledger_request/);
assert.match(productInventoryUpSql, /uq_qipai_product_inventory_request/);
assert.match(productInventoryUpSql, /fk_qipai_product_inventory_ledger_request/);
assert.match(productInventoryUpSql, /uq_qipai_product_inventory_ledger_version/);
assert.match(productInventoryUpSql, /qipai_product_inventory_ledger_no_update/);
assert.match(productInventoryUpSql, /qipai_product_inventory_ledger_no_delete/);
for (const permission of [
'product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust'
]) {
assert.match(productInventoryUpSql, new RegExp(permission.replace('.', '\\.')));
assert.match(productInventoryDownSql, new RegExp(permission.replace('.', '\\.')));
assert.match(productInventoryVerifySql, new RegExp(permission.replace('.', '\\.')));
}
assert.match(productInventoryUpSql, /r\.code = 'STAFF'/);
assert.match(productInventoryUpSql, /r\.code IN \('STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'\)/);
assert.match(productInventoryDownSql, /DROP INDEX uq_qipai_stores_tenant_id/);
assert.match(productInventoryDownSql, /DROP INDEX uq_qipai_users_tenant_id/);
assert.match(productInventoryDownSql, /DROP COLUMN checksum/);
assert.match(
productInventoryUpSql,
/FOREIGN KEY \(tenant_id, operator_id\) REFERENCES qipai_users\(tenant_id, id\)/
);
assert.match(productInventoryVerifySql, /fully_granted_product_roles/);
assert.match(productInventoryVerifySql, /'uq_qipai_product_inventory_ledger_version'/);
assert.match(productInventoryVerifySql, /'2026081107'/);
console.log('PASS: M01-B through M09-D1 migration contracts are present.');
+195 -2
View File
@@ -45,7 +45,8 @@ assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql/);
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql$/);
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql/);
assert.match(plan.file, /2026081107_m09d1_product_inventory_foundation\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -55,10 +56,19 @@ assert.match(verifyPlan.statements[91], /^SELECT index_name/);
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql/);
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql/);
assert.match(verifyPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.verify\.sql$/);
assert.match(verifyPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.verify\.sql/);
assert.match(
verifyPlan.file,
/2026081107_m09d1_product_inventory_foundation\.verify\.sql$/
);
const downPlan = await loadMigrationPlan('down');
assert.match(downPlan.file, /^database\/migrations\/2026081107_m09d1_product_inventory_foundation\.down\.sql/);
assert.match(downPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.down\.sql/);
assert.ok(
downPlan.file.indexOf('2026081107_m09d1_product_inventory_foundation.down.sql')
< downPlan.file.indexOf('2026081006_m09c_cleaning_settlement_integrity.down.sql')
);
assert.ok(
downPlan.file.indexOf('2026081006_m09c_cleaning_settlement_integrity.down.sql')
< downPlan.file.indexOf('2026081005_m09b_cleaning_rules.down.sql')
@@ -76,6 +86,51 @@ const dryRun = await executeMigrationPlan(fakePool, plan, true);
assert.equal(dryRun.executed, false);
assert.equal(calls.length, 0);
const lockCalls = [];
let connectionReleased = false;
const lockedResult = await executeMigrationPlan({
async query() {
throw new Error('pool.query must not run while a dedicated migration connection exists');
},
async getConnection() {
return {
async query(sql) {
lockCalls.push(sql);
if (sql.includes('GET_LOCK')) return [[{ acquired: 1 }], []];
if (sql.includes('RELEASE_LOCK')) return [[{ released: 1 }], []];
return [{ affectedRows: 2 }, []];
},
release() {
connectionReleased = true;
}
};
}
}, {
direction: 'down', file: 'locked.sql', checksum: 'locked', statements: ['SELECT 1']
});
assert.equal(lockedResult.affectedRows, 2);
assert.equal(connectionReleased, true);
assert.match(lockCalls[0], /GET_LOCK/);
assert.equal(lockCalls[1], 'SELECT 1');
assert.match(lockCalls[2], /RELEASE_LOCK/);
let timedOutConnectionReleased = false;
await assert.rejects(
() => executeMigrationPlan({
async query() {},
async getConnection() {
return {
async query() { return [[{ acquired: 0 }], []]; },
release() { timedOutConnectionReleased = true; }
};
}
}, {
direction: 'down', file: 'lock-timeout.sql', checksum: 'lock-timeout', statements: []
}),
/MIGRATION_LOCK_TIMEOUT/
);
assert.equal(timedOutConnectionReleased, true);
const liveResult = await executeMigrationPlan(fakePool, {
direction: 'up',
file: 'test.sql',
@@ -86,6 +141,144 @@ assert.equal(liveResult.executed, true);
assert.equal(liveResult.affectedRows, 2);
assert.deepEqual(calls, ['SELECT 1', 'SELECT 2']);
const triggerPlan = {
direction: 'up',
file: 'trigger.sql',
checksum: 'trigger',
statements: ['CREATE TRIGGER test_trigger BEFORE UPDATE ON test FOR EACH ROW SET @x = 1']
};
await assert.rejects(
() => executeMigrationPlan({
async query() { return [[{ logBin: 1, trustFunctionCreators: 0 }], []]; }
}, triggerPlan),
/MIGRATION_TRIGGER_PRIVILEGE_REQUIRED/
);
const triggerCalls = [];
await executeMigrationPlan({
async query(sql) {
triggerCalls.push(sql);
if (sql.includes('@@GLOBAL.log_bin')) {
return [[{ logBin: 1, trustFunctionCreators: 1 }], []];
}
return [{ affectedRows: 0 }, []];
}
}, triggerPlan);
assert.equal(triggerCalls.length, 2);
let indexAttempted = false;
const retryResult = await executeMigrationPlan({
async query(sql) {
if (sql.includes('ADD UNIQUE KEY uq_qipai_stores_tenant_id')) {
indexAttempted = true;
throw Object.assign(new Error('duplicate index'), { code: 'ER_DUP_KEYNAME' });
}
if (sql.includes('information_schema.statistics')) {
return [[{ nonUnique: 0, columns: 'tenant_id,id' }], []];
}
return [{ affectedRows: 1 }, []];
}
}, {
direction: 'up', file: 'retry.sql', checksum: 'retry',
statements: [
'ALTER TABLE qipai_stores ADD UNIQUE KEY uq_qipai_stores_tenant_id (tenant_id, id)',
'SELECT 1'
]
});
assert.equal(indexAttempted, true);
assert.equal(retryResult.affectedRows, 1);
await assert.rejects(
() => executeMigrationPlan({
async query(sql) {
if (sql.includes('ADD UNIQUE KEY')) {
throw Object.assign(new Error('wrong duplicate index'), { code: 'ER_DUP_KEYNAME' });
}
return [[{ nonUnique: 0, columns: 'id,tenant_id' }], []];
}
}, {
direction: 'up', file: 'retry-mismatch.sql', checksum: 'retry-mismatch',
statements: [
'ALTER TABLE qipai_stores ADD UNIQUE KEY uq_qipai_stores_tenant_id (tenant_id, id)'
]
}),
/wrong duplicate index/
);
const downRetryCalls = [];
const downRetryResult = await executeMigrationPlan({
async query(sql) {
downRetryCalls.push(sql);
if (/DROP INDEX uq_qipai_(?:stores|users)_tenant_id/.test(sql)
|| /DROP COLUMN checksum/.test(sql)) {
throw Object.assign(new Error('already removed'), { code: 'ER_CANT_DROP_FIELD_OR_KEY' });
}
if (sql.includes('information_schema.statistics')
|| sql.includes('information_schema.columns')) return [[], []];
return [{ affectedRows: 1 }, []];
}
}, {
direction: 'down', file: 'retry-down.sql', checksum: 'retry-down',
statements: [
'ALTER TABLE qipai_stores DROP INDEX uq_qipai_stores_tenant_id',
'ALTER TABLE qipai_users DROP INDEX uq_qipai_users_tenant_id',
'ALTER TABLE qipai_schema_migrations DROP COLUMN checksum',
'SELECT 1'
]
});
assert.equal(downRetryResult.affectedRows, 1);
assert.equal(downRetryCalls.filter((sql) => sql.includes('information_schema.')).length, 3);
await assert.rejects(
() => executeMigrationPlan({
async query(sql) {
if (sql.startsWith('ALTER TABLE')) {
throw Object.assign(new Error('drop target still exists'), {
code: 'ER_CANT_DROP_FIELD_OR_KEY'
});
}
return [[{ present: 1 }], []];
}
}, {
direction: 'down', file: 'retry-down-mismatch.sql', checksum: 'retry-down-mismatch',
statements: ['ALTER TABLE qipai_stores DROP INDEX uq_qipai_stores_tenant_id']
}),
/drop target still exists/
);
const versionedCalls = [];
const versionedPlan = {
direction: 'up', file: 'old.sql,new.sql', checksum: 'versioned',
statements: ['SELECT old', 'SELECT new'],
migrations: [
{ version: '1', name: 'old', file: 'old.sql', checksum: 'old-checksum', statements: ['SELECT old'] },
{ version: '2', name: 'new', file: 'new.sql', checksum: 'new-checksum', statements: ['SELECT new'] }
]
};
await executeMigrationPlan({
async query(sql, params = []) {
versionedCalls.push({ sql, params });
if (sql.startsWith('SELECT * FROM qipai_schema_migrations')) {
return [[{ version: '1', name: 'old', checksum: 'old-checksum' }], []];
}
if (sql.startsWith('SELECT version, name')) {
return [[{ version: '2', name: 'new' }], []];
}
if (sql.startsWith('UPDATE qipai_schema_migrations')) {
return [{ affectedRows: 1 }, []];
}
return [{ affectedRows: 1 }, []];
}
}, versionedPlan);
assert.equal(versionedCalls.some(({ sql }) => sql === 'SELECT old'), false);
assert.equal(versionedCalls.some(({ sql }) => sql === 'SELECT new'), true);
await assert.rejects(
() => executeMigrationPlan({
async query() {
return [[{ version: '1', name: 'old', checksum: 'changed' }], []];
}
}, versionedPlan),
/MIGRATION_CHECKSUM_MISMATCH/
);
await assert.rejects(
() => executeMigrationPlan({
async query() {
@@ -48,6 +48,12 @@ import { hashPassword } from '../dist/auth/password.js';
import {
CleaningTaskError, CleaningTaskRepository
} from '../dist/cleaning/cleaning-task-repository.js';
import {
ProductCatalogError, ProductCatalogRepository
} from '../dist/products/product-catalog-repository.js';
import {
InventoryError, InventoryService
} from '../dist/inventory/inventory-service.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -97,6 +103,15 @@ const expectedTables = [
'qipai_payments',
'qipai_permissions',
'qipai_platform_apps',
'qipai_product_categories',
'qipai_product_inventory',
'qipai_product_inventory_ledger',
'qipai_product_inventory_requests',
'qipai_product_skus',
'qipai_product_store_hours',
'qipai_product_store_listings',
'qipai_product_store_settings',
'qipai_products',
'qipai_profit_share_policies',
'qipai_profit_share_receivers',
'qipai_profit_shares',
@@ -140,18 +155,95 @@ async function readCoreTables(pool) {
return rows.map((row) => row.tableName);
}
async function assertProductInventoryMigrationRetry(pool, fullUpPlan) {
const migrationBase = resolve(
repoRoot,
'database/migrations/2026081107_m09d1_product_inventory_foundation'
);
const [upSql, downSql] = await Promise.all([
readFile(`${migrationBase}.up.sql`, 'utf8'),
readFile(`${migrationBase}.down.sql`, 'utf8')
]);
const upStatements = splitSqlStatements(upSql);
await executeMigrationPlan(pool, {
direction: 'down',
file: `${migrationBase}.down.sql`,
checksum: 'm09d1-crash-retry-down',
statements: splitSqlStatements(downSql)
});
await pool.query(upStatements[0]);
await executeMigrationPlan(pool, fullUpPlan);
await executeMigrationPlan(pool, fullUpPlan);
const interruptedDownStatements = splitSqlStatements(downSql).slice(0, -1);
await executeMigrationPlan(pool, {
direction: 'down',
file: `${migrationBase}.interrupted.down.sql`,
checksum: 'm09d1-interrupted-down',
statements: interruptedDownStatements
});
await executeMigrationPlan(pool, {
direction: 'down',
file: `${migrationBase}.down.sql`,
checksum: 'm09d1-crash-retry-down',
statements: splitSqlStatements(downSql)
});
await executeMigrationPlan(pool, fullUpPlan);
console.log(
'PASS: M09-C markers skipped old migrations and D1 recovered after interrupted up/down DDL.'
);
}
async function assertMigrationAdvisoryLock(pool) {
const lockExpression = "CONCAT('qipai:migrate:', LEFT(SHA2(DATABASE(), 256), 32))";
const blocker = await pool.getConnection();
let held = false;
let pending;
try {
const [rows] = await blocker.query(
`SELECT GET_LOCK(${lockExpression}, 0) AS acquired`
);
assert.equal(Number(rows[0]?.acquired ?? 0), 1);
held = true;
pending = executeMigrationPlan(pool, {
direction: 'down',
file: 'concurrent-migration-lock-probe.sql',
checksum: 'concurrent-migration-lock-probe',
statements: ['SELECT 1']
});
const state = await Promise.race([
pending.then(() => 'completed'),
new Promise((resolve) => setTimeout(() => resolve('blocked'), 100))
]);
assert.equal(state, 'blocked', 'a second migration connection must wait for the advisory lock');
const [releaseRows] = await blocker.query(
`SELECT RELEASE_LOCK(${lockExpression}) AS released`
);
assert.equal(Number(releaseRows[0]?.released ?? 0), 1);
held = false;
await pending;
console.log('PASS: concurrent migration processes are serialized by a database advisory lock.');
} finally {
if (held) await blocker.query(`SELECT RELEASE_LOCK(${lockExpression}) AS released`);
blocker.release();
if (pending) await pending.catch(() => undefined);
}
}
async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005',
'2026081006']
'2026081006', '2026081107']
);
return rows;
}
@@ -1839,7 +1931,7 @@ async function assertSystemOperations(pool, context) {
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
const overview = await repository.getSystemOverview(context.tenantId);
assert.equal(overview.tenant.id, context.tenantId);
assert.equal(overview.latestMigration.version, '2026081006');
assert.equal(overview.latestMigration.version, '2026081107');
assert.ok(overview.counts.userCount > 0);
await repository.updateTenant(actor, context.tenantId, {
name: overview.tenant.name, timezone: overview.tenant.timezone
@@ -3120,6 +3212,791 @@ async function assertCleaningTaskTransactions(pool, context) {
);
}
async function assertProductInventoryFoundation(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_roles ur
ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r
ON r.tenant_id = ur.tenant_id AND r.id = ur.role_id
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN'
AND u.deleted_at IS NULL
ORDER BY u.id LIMIT 1`,
[context.tenantId]
);
const [storeRows] = await pool.query(
`SELECT id FROM qipai_stores
WHERE tenant_id = ? AND name = 'M03A Store' AND deleted_at IS NULL
ORDER BY id LIMIT 1`,
[context.tenantId]
);
assert.ok(adminRows[0], 'M09-D1 requires the tenant administrator fixture.');
assert.ok(storeRows[0], 'M09-D1 requires the M03A store fixture.');
const adminId = String(adminRows[0].id);
const storeId = String(storeRows[0].id);
const rbac = new RbacRepository(pool);
const access = await rbac.getAccessProfile(context.tenantId, adminId);
for (const capability of [
'product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust'
]) assert.ok(access.capabilities.includes(capability), `missing M09-D1 capability ${capability}`);
const actor = {
tenantId: context.tenantId,
userId: adminId,
access,
traceId: 'm09d1-live-test',
ip: '127.0.0.1',
userAgent: 'M09-D1 live MySQL test'
};
const catalog = new ProductCatalogRepository(pool);
const inventory = new InventoryService(pool);
const categoryInput = {
parentId: null,
categoryCode: 'M09D1-DRINKS',
name: 'M09D1 Drinks',
description: 'Sanitized live category',
imageUrl: '',
status: 'ACTIVE',
sortOrder: 1
};
const category = await catalog.createCategory(actor, storeId, categoryInput);
const [foreignUserRows] = await pool.query(
`SELECT id FROM qipai_users WHERE tenant_id <> ? ORDER BY id LIMIT 1`,
[context.tenantId]
);
assert.ok(foreignUserRows[0], 'M09-D1 requires a foreign-tenant user fixture.');
await assert.rejects(
() => pool.query(
`INSERT INTO qipai_product_categories
(tenant_id, store_id, category_code, name, created_by, updated_by)
VALUES (?, ?, 'M09D1-FOREIGN-ACTOR', 'invalid actor', ?, ?)`,
[context.tenantId, storeId, foreignUserRows[0].id, foreignUserRows[0].id]
),
(error) => error?.code === 'ER_NO_REFERENCED_ROW_2'
);
await assert.rejects(
() => catalog.createCategory(actor, storeId, categoryInput),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_CATEGORY_CODE_CONFLICT'
);
const [otherStoreRows] = await pool.query(
`SELECT id FROM qipai_stores
WHERE tenant_id = ? AND id <> ? AND deleted_at IS NULL
ORDER BY id LIMIT 1`,
[context.tenantId, storeId]
);
assert.ok(otherStoreRows[0]);
const otherStoreId = String(otherStoreRows[0].id);
await catalog.createCategory(actor, otherStoreId, categoryInput);
const storeScopedActor = {
...actor,
access: {
roles: ['STORE_ADMIN'],
capabilities: [
'product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust'
],
storeIds: [storeId]
}
};
await assert.rejects(
() => catalog.listCategories(storeScopedActor, otherStoreId),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_STORE_SCOPE_FORBIDDEN'
);
const reusable = await catalog.createCategory(actor, storeId, {
...categoryInput,
categoryCode: 'M09D1-REUSABLE',
name: 'M09D1 Reusable'
});
await catalog.archiveCategory(actor, storeId, reusable.categoryId, reusable.version);
const reused = await catalog.createCategory(actor, storeId, {
...categoryInput,
categoryCode: 'M09D1-REUSABLE',
name: 'M09D1 Reused'
});
assert.notEqual(reused.categoryId, reusable.categoryId);
const productInput = {
productCode: 'M09D1-TEA',
name: 'M09D1 Tea',
unitName: 'bottle',
description: 'Sanitized live product',
coverUrl: '',
images: [],
deliveryEnabled: true,
storageEnabled: true,
status: 'ACTIVE',
sortOrder: 1
};
const product = await catalog.createProduct(actor, productInput);
await assert.rejects(
() => catalog.createProduct(actor, productInput),
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_CODE_CONFLICT'
);
await assert.rejects(
() => catalog.updateProduct(actor, product.productId, 99, {
...productInput,
name: 'M09D1 stale update'
}),
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_VERSION_CONFLICT'
);
const firstSkuInput = {
skuCode: 'M09D1-TEA-500',
name: '500ml',
attributes: { volume: '500ml' },
barcode: 'M09D1000001',
imageUrl: '',
salePriceCents: 600,
marketPriceCents: 800,
costPriceCents: 250,
defaultInventoryPolicy: 'TRACKED',
status: 'ACTIVE'
};
const secondSkuInput = {
...firstSkuInput,
skuCode: 'M09D1-TEA-330',
name: '330ml',
attributes: { volume: '330ml' },
barcode: 'M09D1000002',
salePriceCents: 500
};
const unlimitedSkuInput = {
...firstSkuInput,
skuCode: 'M09D1-TEA-GIFT',
name: 'gift entitlement',
attributes: { kind: 'gift' },
barcode: 'M09D1000003',
defaultInventoryPolicy: 'UNLIMITED',
salePriceCents: 100
};
const firstSku = await catalog.createSku(actor, product.productId, firstSkuInput);
const secondSku = await catalog.createSku(actor, product.productId, secondSkuInput);
const unlimitedSku = await catalog.createSku(actor, product.productId, unlimitedSkuInput);
await assert.rejects(
() => catalog.updateSku(actor, product.productId, firstSku.skuId, 99, firstSkuInput),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_SKU_VERSION_CONFLICT'
);
await assert.rejects(
() => pool.query(
`INSERT INTO qipai_product_skus
(tenant_id, product_id, sku_code, name, sale_price_cents,
default_inventory_policy, created_by, updated_by)
VALUES (?, ?, 'M09D1-AMOUNT-OVERFLOW', 'invalid amount', 100000001,
'TRACKED', ?, ?)`,
[context.tenantId, product.productId, adminId, adminId]
),
(error) => error?.code === 'ER_CHECK_CONSTRAINT_VIOLATED'
);
const disposableProductInput = {
...productInput,
productCode: 'M09D1-DISPOSABLE',
name: 'M09D1 Disposable'
};
const disposableProduct = await catalog.createProduct(actor, disposableProductInput);
const disposableSkuInput = {
...firstSkuInput,
skuCode: 'M09D1-DISPOSABLE-SKU',
name: 'disposable sku',
barcode: 'M09D1000099'
};
const disposableSku = await catalog.createSku(
actor, disposableProduct.productId, disposableSkuInput
);
await inventory.configurePolicy(actor, {
storeId,
skuId: disposableSku.skuId,
requestId: 'm09d1-disposable-policy',
reason: 'create zero inventory before archive',
expectedVersion: 0,
policyType: 'TRACKED',
lowStockThreshold: 0
});
await catalog.archiveSku(
actor, disposableProduct.productId, disposableSku.skuId, disposableSku.version
);
await assert.rejects(
() => inventory.inbound(actor, {
storeId,
skuId: disposableSku.skuId,
requestId: 'm09d1-archived-sku-inbound',
reason: 'archived sku must reject inventory writes',
quantity: 1
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_SKU_NOT_FOUND'
);
await catalog.archiveProduct(
actor, disposableProduct.productId, disposableProduct.version
);
const listing = await catalog.putListing(actor, storeId, product.productId, 0, {
categoryId: category.categoryId,
status: 'ACTIVE',
fulfillmentMode: 'BOTH',
salesStartAt: null,
salesEndAt: null,
sortOrder: 1
});
await assert.rejects(
() => catalog.putListing(actor, storeId, product.productId, 99, {
categoryId: category.categoryId,
status: 'ACTIVE',
fulfillmentMode: 'BOTH',
salesStartAt: null,
salesEndAt: null,
sortOrder: 2
}),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_LISTING_VERSION_CONFLICT'
);
assert.equal(listing.version, 1);
const overnightHours = [{
weekday: 1,
slotNo: 1,
openMinute: 22 * 60,
closeMinute: 2 * 60,
crossesMidnight: true
}];
const settings = await catalog.putStoreSettings(actor, storeId, 0, {
salesStatus: 'OPEN',
manualPaused: false,
manualPausedUntil: null,
manualPauseReason: '',
hours: overnightHours
}, new Date('2026-08-10T14:00:00.000Z'));
assert.equal(
(await catalog.getStoreSettings(
actor, storeId, new Date('2026-08-10T15:00:00.000Z')
)).openNow,
true,
'cross-midnight product hours must open on the configured weekday'
);
assert.equal(
(await catalog.getStoreSettings(
actor, storeId, new Date('2026-08-10T17:00:00.000Z')
)).openNow,
true,
'cross-midnight product hours must include the previous-day window'
);
await assert.rejects(
() => catalog.putStoreSettings(actor, storeId, settings.version, {
salesStatus: 'OPEN',
manualPaused: false,
manualPausedUntil: null,
manualPauseReason: '',
hours: [
...overnightHours,
{ weekday: 2, slotNo: 1, openMinute: 60, closeMinute: 180, crossesMidnight: false }
]
}),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_BUSINESS_HOUR_OVERLAP'
);
const paused = await catalog.putStoreSettings(actor, storeId, settings.version, {
salesStatus: 'OPEN',
manualPaused: true,
manualPausedUntil: null,
manualPauseReason: 'M09-D1 manual pause',
hours: overnightHours
}, new Date('2026-08-10T14:30:00.000Z'));
assert.equal(
(await catalog.getStoreSettings(
actor, storeId, new Date('2026-08-10T15:00:00.000Z')
)).openNow,
false
);
await catalog.putStoreSettings(actor, storeId, paused.version, {
salesStatus: 'OPEN',
manualPaused: false,
manualPausedUntil: null,
manualPauseReason: '',
hours: overnightHours
}, new Date('2026-08-10T14:40:00.000Z'));
const configuredFirst = await inventory.configurePolicy(actor, {
storeId,
skuId: firstSku.skuId,
requestId: 'm09d1-policy-first',
reason: 'configure tracked stock',
expectedVersion: 0,
policyType: 'TRACKED',
lowStockThreshold: 1
});
const firstInbound = await inventory.inbound(actor, {
storeId,
skuId: firstSku.skuId,
requestId: 'm09d1-inbound-first',
reason: 'initial inbound stock',
expectedVersion: configuredFirst.version,
quantity: 5
});
const configuredSecond = await inventory.configurePolicy(actor, {
storeId,
skuId: secondSku.skuId,
requestId: 'm09d1-policy-second',
reason: 'configure second tracked stock',
expectedVersion: 0,
policyType: 'TRACKED',
lowStockThreshold: 1
});
await inventory.inbound(actor, {
storeId,
skuId: secondSku.skuId,
requestId: 'm09d1-inbound-second',
reason: 'second inbound stock',
expectedVersion: configuredSecond.version,
quantity: 6
});
const configuredUnlimited = await inventory.configurePolicy(actor, {
storeId,
skuId: unlimitedSku.skuId,
requestId: 'm09d1-policy-unlimited',
reason: 'configure unlimited stock',
expectedVersion: 0,
policyType: 'UNLIMITED',
lowStockThreshold: 0
});
const unlimitedLockInput = {
tenantId: context.tenantId,
storeId,
items: [{ skuId: unlimitedSku.skuId, quantity: 2 }],
requestId: 'm09d1-unlimited-lock',
businessType: 'GOODS_ORDER_ITEM',
businessId: 'm09d1-unlimited-order-item',
traceId: 'm09d1-unlimited-lock',
operatorId: adminId,
reason: 'lock unlimited inventory entitlement'
};
const unlimitedLock = await inventory.lockMany(unlimitedLockInput);
await assert.rejects(
() => inventory.configurePolicy(actor, {
storeId,
skuId: unlimitedSku.skuId,
requestId: 'm09d1-unlimited-switch-active',
reason: 'active reservation must block policy switch',
expectedVersion: unlimitedLock.items[0].version,
policyType: 'TRACKED',
lowStockThreshold: 0
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_POLICY_HAS_ACTIVE_RESERVATIONS'
);
const unlimitedDeduct = await inventory.deductMany({
...unlimitedLockInput,
requestId: 'm09d1-unlimited-deduct',
traceId: 'm09d1-unlimited-deduct',
reason: 'settle unlimited inventory entitlement'
});
const switchedUnlimited = await inventory.configurePolicy(actor, {
storeId,
skuId: unlimitedSku.skuId,
requestId: 'm09d1-unlimited-switch-settled',
reason: 'switch after reservation settlement',
expectedVersion: unlimitedDeduct.items[0].version,
policyType: 'TRACKED',
lowStockThreshold: 0
});
assert.equal(configuredUnlimited.policyType, 'UNLIMITED');
assert.equal(switchedUnlimited.policyType, 'TRACKED');
const lockInputs = ['a', 'b'].map((suffix) => ({
tenantId: context.tenantId,
storeId,
items: [{ skuId: firstSku.skuId, quantity: 4 }],
requestId: `m09d1-concurrent-lock-${suffix}`,
businessType: 'GOODS_ORDER_ITEM',
businessId: `m09d1-order-item-${suffix}`,
traceId: `m09d1-concurrent-lock-${suffix}`,
operatorId: adminId,
ip: '127.0.0.1',
userAgent: 'M09-D1 concurrent inventory lock',
reason: 'concurrent inventory lock'
}));
const concurrentLocks = await Promise.allSettled(lockInputs.map((input) =>
inventory.lockMany(input)
));
const winningIndexes = concurrentLocks
.map((result, index) => result.status === 'fulfilled' ? index : -1)
.filter((index) => index >= 0);
const rejected = concurrentLocks.filter((result) => result.status === 'rejected');
assert.equal(winningIndexes.length, 1, 'concurrent inventory lock must have one winner');
assert.equal(rejected.length, 1, 'concurrent inventory lock must reject one contender');
assert.ok(rejected[0].reason instanceof InventoryError);
assert.equal(rejected[0].reason.code, 'INVENTORY_INSUFFICIENT_AVAILABLE');
const winningLock = lockInputs[winningIndexes[0]];
const replay = await inventory.lockMany(winningLock);
assert.equal(replay.idempotent, true);
await assert.rejects(
() => inventory.lockMany({
...winningLock,
items: [{ skuId: firstSku.skuId, quantity: 3 }]
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
);
await assert.rejects(
() => inventory.releaseMany({
...winningLock,
requestId: 'm09d1-cross-order-release',
businessId: 'm09d1-foreign-order-item',
items: [{ skuId: firstSku.skuId, quantity: 1 }],
reason: 'must not release another order reservation'
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_INSUFFICIENT_RESERVATION'
);
await inventory.releaseMany({
tenantId: context.tenantId,
storeId,
items: [{ skuId: firstSku.skuId, quantity: 2 }],
requestId: 'm09d1-release-two',
businessType: 'GOODS_ORDER_ITEM',
businessId: winningLock.businessId,
traceId: 'm09d1-release-two',
operatorId: adminId,
reason: 'release two locked units'
});
await inventory.deductMany({
tenantId: context.tenantId,
storeId,
items: [{ skuId: firstSku.skuId, quantity: 2 }],
requestId: 'm09d1-deduct-two',
businessType: 'GOODS_ORDER_ITEM',
businessId: winningLock.businessId,
traceId: 'm09d1-deduct-two',
operatorId: adminId,
reason: 'deduct two locked units'
});
const caseSensitiveRequestBase = {
tenantId: context.tenantId,
storeId,
items: [{ skuId: secondSku.skuId, quantity: 1 }],
businessType: 'GOODS_ORDER_ITEM',
businessId: 'm09d1-case-sensitive-request-order',
traceId: 'm09d1-case-sensitive-request',
operatorId: adminId,
reason: 'opaque request ids are case sensitive'
};
const upperCaseRequest = await inventory.lockMany({
...caseSensitiveRequestBase,
requestId: 'M09D1-Request-Case'
});
const lowerCaseRequest = await inventory.lockMany({
...caseSensitiveRequestBase,
requestId: 'm09d1-request-case'
});
assert.equal(upperCaseRequest.idempotent, false);
assert.equal(lowerCaseRequest.idempotent, false);
assert.notEqual(upperCaseRequest.items[0].ledgerId, lowerCaseRequest.items[0].ledgerId);
await inventory.releaseMany({
...caseSensitiveRequestBase,
requestId: 'm09d1-request-case-release',
items: [{ skuId: secondSku.skuId, quantity: 2 }],
reason: 'release both case-sensitive requests'
});
const businessCaseLock = await inventory.lockMany({
...caseSensitiveRequestBase,
requestId: 'm09d1-business-case-lock',
businessId: 'Order-A',
reason: 'lock with case-sensitive business id'
});
await assert.rejects(
() => inventory.releaseMany({
...caseSensitiveRequestBase,
requestId: 'm09d1-business-case-wrong-release',
businessId: 'order-a',
reason: 'business id case mismatch must reject'
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_INSUFFICIENT_RESERVATION'
);
await inventory.releaseMany({
...caseSensitiveRequestBase,
requestId: 'm09d1-business-case-release',
businessId: 'Order-A',
reason: 'release exact business id reservation'
});
assert.equal(businessCaseLock.idempotent, false);
const globalRequestInputs = [firstSku.skuId, secondSku.skuId].map((skuId) => ({
tenantId: context.tenantId,
storeId,
items: [{ skuId, quantity: 1 }],
requestId: 'm09d1-global-request-race',
businessType: 'GOODS_ORDER_ITEM',
businessId: 'm09d1-global-request-order',
traceId: `m09d1-global-request-${skuId}`,
operatorId: adminId,
reason: 'same request must not cover disjoint sku sets'
}));
const globalRequestRace = await Promise.allSettled(
globalRequestInputs.map((input) => inventory.lockMany(input))
);
const globalWinnerIndex = globalRequestRace.findIndex((result) => result.status === 'fulfilled');
const globalRejected = globalRequestRace.filter((result) => result.status === 'rejected');
assert.notEqual(globalWinnerIndex, -1, 'one global inventory request must win');
assert.equal(globalRejected.length, 1, 'disjoint reuse of one request id must reject once');
assert.ok(globalRejected[0].reason instanceof InventoryError);
assert.equal(globalRejected[0].reason.code, 'INVENTORY_IDEMPOTENCY_CONFLICT');
const globalWinner = globalRequestInputs[globalWinnerIndex];
await inventory.releaseMany({
...globalWinner,
requestId: 'm09d1-global-request-release',
traceId: 'm09d1-global-request-release',
reason: 'release the winning global request reservation'
});
const [secondVersionRows] = await pool.query(
`SELECT version FROM qipai_product_inventory
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?`,
[context.tenantId, storeId, secondSku.skuId]
);
const secondExpectedVersion = Number(secondVersionRows[0].version);
const policyRace = await Promise.allSettled([2, 3].map((lowStockThreshold, index) =>
inventory.configurePolicy(actor, {
storeId,
skuId: secondSku.skuId,
requestId: `m09d1-policy-race-${index}`,
reason: 'concurrent policy threshold update',
expectedVersion: secondExpectedVersion,
policyType: 'TRACKED',
lowStockThreshold
})
));
assert.equal(
policyRace.filter((result) => result.status === 'fulfilled').length,
1,
'one inventory policy CAS must win'
);
const rejectedPolicy = policyRace.find((result) => result.status === 'rejected');
assert.ok(rejectedPolicy?.reason instanceof InventoryError);
assert.equal(rejectedPolicy.reason.code, 'INVENTORY_VERSION_CONFLICT');
const [beforeAtomicRows] = await pool.query(
`SELECT sku_id AS skuId, available_quantity AS availableQuantity,
locked_quantity AS lockedQuantity, loss_quantity AS lossQuantity, version
FROM qipai_product_inventory
WHERE tenant_id = ? AND store_id = ? AND sku_id IN (?, ?)
ORDER BY sku_id`,
[context.tenantId, storeId, firstSku.skuId, secondSku.skuId]
);
await assert.rejects(
() => inventory.lockMany({
tenantId: context.tenantId,
storeId,
items: [
{ skuId: firstSku.skuId, quantity: 2 },
{ skuId: secondSku.skuId, quantity: 7 }
],
requestId: 'm09d1-atomic-reject',
businessType: 'GOODS_ORDER_ITEM',
businessId: 'm09d1-atomic-reject',
traceId: 'm09d1-atomic-reject',
operatorId: adminId,
reason: 'reject the complete batch'
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_INSUFFICIENT_AVAILABLE'
);
const [afterAtomicRows] = await pool.query(
`SELECT sku_id AS skuId, available_quantity AS availableQuantity,
locked_quantity AS lockedQuantity, loss_quantity AS lossQuantity, version
FROM qipai_product_inventory
WHERE tenant_id = ? AND store_id = ? AND sku_id IN (?, ?)
ORDER BY sku_id`,
[context.tenantId, storeId, firstSku.skuId, secondSku.skuId]
);
assert.deepEqual(afterAtomicRows, beforeAtomicRows, 'failed batch must not partially mutate stock');
await pool.query(
`UPDATE qipai_product_skus SET status = 'INACTIVE', version = version + 1
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, firstSku.skuId]
);
const inactiveSkuReplay = await inventory.lockMany(winningLock);
assert.equal(
inactiveSkuReplay.idempotent,
true,
'an exact inventory request replay must survive a later SKU deactivation'
);
await pool.query(
`UPDATE qipai_product_skus SET status = 'ACTIVE', version = version + 1
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, firstSku.skuId]
);
const [firstStockRows] = await pool.query(
`SELECT id, available_quantity AS availableQuantity,
locked_quantity AS lockedQuantity, loss_quantity AS lossQuantity, version
FROM qipai_product_inventory
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?`,
[context.tenantId, storeId, firstSku.skuId]
);
const firstStock = firstStockRows[0];
assert.deepEqual(
{
availableQuantity: Number(firstStock.availableQuantity),
lockedQuantity: Number(firstStock.lockedQuantity),
lossQuantity: Number(firstStock.lossQuantity)
},
{ availableQuantity: 3, lockedQuantity: 0, lossQuantity: 0 }
);
const loss = await inventory.recordLoss(actor, {
storeId,
skuId: firstSku.skuId,
requestId: 'm09d1-loss-one',
reason: 'record one damaged unit',
expectedVersion: Number(firstStock.version),
quantity: 1
});
const stocktakeRace = await Promise.allSettled([2, 1].map((availableQuantity, index) =>
inventory.stocktake(actor, {
storeId,
skuId: firstSku.skuId,
requestId: `m09d1-stocktake-${index}`,
reason: 'concurrent absolute stocktake',
expectedVersion: loss.version,
availableQuantity,
lossQuantity: 1
})
));
const stocktakeWinners = stocktakeRace.filter((result) => result.status === 'fulfilled');
const stocktakeRejected = stocktakeRace.filter((result) => result.status === 'rejected');
assert.equal(stocktakeWinners.length, 1, 'one absolute stocktake CAS must win');
assert.equal(stocktakeRejected.length, 1, 'one stale absolute stocktake CAS must reject');
assert.ok(stocktakeRejected[0].reason instanceof InventoryError);
assert.equal(stocktakeRejected[0].reason.code, 'INVENTORY_VERSION_CONFLICT');
const stocktake = stocktakeWinners[0].value;
await assert.rejects(
() => inventory.adjust(actor, {
storeId,
skuId: firstSku.skuId,
requestId: 'm09d1-negative-adjust',
reason: 'negative inventory must fail',
expectedVersion: stocktake.version,
availableDelta: -3,
lossDelta: 0
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_QUANTITY_OUT_OF_RANGE'
);
await assert.rejects(
() => inventory.inbound(actor, {
storeId,
skuId: firstSku.skuId,
requestId: 'm09d1-stale-inbound',
reason: 'stale version must fail',
expectedVersion: firstInbound.version,
quantity: 1
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_VERSION_CONFLICT'
);
await assert.rejects(
() => inventory.listStocks({
...storeScopedActor,
access: { ...storeScopedActor.access, storeIds: [otherStoreId] }
}, { storeId, page: 1, pageSize: 20 }),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_STORE_SCOPE_FORBIDDEN'
);
await assert.rejects(
() => pool.query(
`UPDATE qipai_product_inventory SET available_quantity = 1000000001
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, firstStock.id]
),
(error) => error?.code === 'ER_CHECK_CONSTRAINT_VIOLATED'
);
const [ledgerRows] = await pool.query(
`SELECT COALESCE(SUM(available_delta), 0) AS availableDelta,
COALESCE(SUM(locked_delta), 0) AS lockedDelta,
COALESCE(SUM(loss_delta), 0) AS lossDelta,
COUNT(*) AS ledgerCount,
COUNT(DISTINCT version_after) AS versionCount
FROM qipai_product_inventory_ledger
WHERE tenant_id = ? AND inventory_id = ?`,
[context.tenantId, firstStock.id]
);
const [finalStockRows] = await pool.query(
`SELECT available_quantity AS availableQuantity,
locked_quantity AS lockedQuantity, loss_quantity AS lossQuantity
FROM qipai_product_inventory
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, firstStock.id]
);
assert.deepEqual(
{
availableQuantity: Number(ledgerRows[0].availableDelta),
lockedQuantity: Number(ledgerRows[0].lockedDelta),
lossQuantity: Number(ledgerRows[0].lossDelta)
},
{
availableQuantity: Number(finalStockRows[0].availableQuantity),
lockedQuantity: Number(finalStockRows[0].lockedQuantity),
lossQuantity: Number(finalStockRows[0].lossQuantity)
}
);
assert.equal(Number(ledgerRows[0].ledgerCount), Number(ledgerRows[0].versionCount));
const [winningLedgerRows] = await pool.query(
`SELECT COUNT(*) AS total FROM qipai_product_inventory_ledger
WHERE tenant_id = ? AND inventory_id = ? AND request_id = ?`,
[context.tenantId, firstStock.id, winningLock.requestId]
);
assert.equal(Number(winningLedgerRows[0].total), 1);
await assert.rejects(
() => pool.query(
`UPDATE qipai_product_inventory_ledger SET reason = 'forbidden'
WHERE tenant_id = ? AND inventory_id = ? LIMIT 1`,
[context.tenantId, firstStock.id]
),
(error) => /PRODUCT_INVENTORY_LEDGER_IMMUTABLE/.test(error?.message ?? '')
);
await assert.rejects(
() => pool.query(
`DELETE FROM qipai_product_inventory_ledger
WHERE tenant_id = ? AND inventory_id = ? LIMIT 1`,
[context.tenantId, firstStock.id]
),
(error) => /PRODUCT_INVENTORY_LEDGER_IMMUTABLE/.test(error?.message ?? '')
);
const catalogView = await catalog.listStoreCatalog({
tenantId: context.tenantId,
storeId,
now: new Date('2026-08-10T15:00:00.000Z')
});
assert.equal(catalogView.salesOpen, true);
const publicSku = catalogView.categories[0]?.products[0]?.skus[0];
assert.equal(Object.hasOwn(publicSku ?? {}, 'costPriceCents'), false);
assert.equal(Object.hasOwn(publicSku ?? {}, 'availableQuantity'), false);
const [auditRows] = await pool.query(
`SELECT COUNT(*) AS total FROM qipai_audit_logs
WHERE tenant_id = ? AND (
action LIKE 'PRODUCT_%' OR action LIKE 'PRODUCT_INVENTORY_%'
)`,
[context.tenantId]
);
assert.ok(Number(auditRows[0].total) >= 10);
console.log(
'PASS: M09-D1 product catalog, cross-midnight hours, concurrent inventory lock, '
+ 'idempotency, reconciliation and immutable ledger are consistent.'
);
}
const config = loadConfig();
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
assert.match(
@@ -3142,6 +4019,9 @@ try {
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
await assertMigrationAdvisoryLock(pool);
await assertProductInventoryMigrationRetry(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
assert.deepEqual(await readCoreTables(pool), expectedTables);
assert.deepEqual(await readMigrationVersions(pool), [
{ version: '2026061601', name: 'm01b_core_schema' },
@@ -3168,7 +4048,8 @@ try {
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' },
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -3191,13 +4072,14 @@ try {
await assertDeviceTopology(pool, loginContext);
await assertIotMessages(pool, loginContext);
await assertCleaningTaskTransactions(pool, loginContext);
await assertProductInventoryFoundation(pool, loginContext);
await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M09-C migration tables.');
console.log('PASS: down removed all M01-B through M09-D1 migration tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -3227,7 +4109,8 @@ try {
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' },
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -3375,7 +4258,15 @@ try {
'settlement reversal preserves history and releases the share',
'processing payout blocks cancellation until provider-confirmed failure',
'collaborative task settles only after every member share is paid',
'settlement page and CSV export reuse identical query ordering'
'settlement page and CSV export reuse identical query ordering',
'store-scoped product category uniqueness and soft-delete code reuse',
'product and SKU optimistic version conflicts',
'cross-midnight product hours and manual pause precedence',
'concurrent inventory lock has one winner without negative stock',
'inventory request replay and fingerprint conflict',
'atomic multi-SKU inventory mutation rollback',
'inventory lock release deduct loss and stocktake reconciliation',
'immutable inventory ledger update and delete rejection'
]
}, null, 2));
} finally {
+681
View File
@@ -0,0 +1,681 @@
import assert from 'node:assert/strict';
import {
ProductCatalogError,
ProductCatalogRepository,
isProductStoreOpenAt,
validateBusinessHours
} from '../dist/products/product-catalog-repository.js';
const tenantActor = {
tenantId: '7',
userId: '21',
access: { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
traceId: 'product-catalog-test',
ip: '127.0.0.1',
userAgent: 'catalog-test'
};
const storeActor = {
...tenantActor,
userId: '22',
access: {
roles: ['STORE_ADMIN'],
capabilities: ['product.catalog.read', 'product.catalog.write'],
storeIds: ['11']
}
};
const mondayOvernight = [{
weekday: 1,
slotNo: 1,
openMinute: 22 * 60,
closeMinute: 2 * 60,
crossesMidnight: true
}];
validateBusinessHours(mondayOvernight);
const openSettings = {
timezone: 'Asia/Shanghai',
businessStatus: 'OPEN',
salesStatus: 'OPEN',
manualPausedAt: null,
manualPausedUntil: null,
hours: mondayOvernight
};
assert.equal(
isProductStoreOpenAt(openSettings, new Date('2026-08-10T15:00:00.000Z')),
true,
'Monday 23:00 should be inside the Monday overnight window'
);
assert.equal(
isProductStoreOpenAt(openSettings, new Date('2026-08-10T17:00:00.000Z')),
true,
'Tuesday 01:00 must be attributed to the previous-day overnight window'
);
assert.equal(
isProductStoreOpenAt(openSettings, new Date('2026-08-10T19:00:00.000Z')),
false
);
assert.equal(isProductStoreOpenAt({
...openSettings,
manualPausedAt: new Date('2026-08-10T14:00:00.000Z'),
manualPausedUntil: null
}, new Date('2026-08-10T15:00:00.000Z')), false, 'manual pause overrides hours');
assert.throws(
() => validateBusinessHours([
{ weekday: 1, slotNo: 1, openMinute: 540, closeMinute: 720, crossesMidnight: false },
{ weekday: 1, slotNo: 2, openMinute: 600, closeMinute: 780, crossesMidnight: false }
]),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_BUSINESS_HOUR_OVERLAP'
);
class ScriptedConnection {
constructor(steps) {
this.steps = [...steps];
this.committed = false;
this.rolledBack = false;
this.released = false;
this.calls = [];
}
async beginTransaction() {}
async commit() { this.committed = true; }
async rollback() { this.rolledBack = true; }
release() { this.released = true; }
async execute(sql, params = []) {
this.calls.push({ sql, params });
const step = this.steps.shift();
assert.ok(step, `Unexpected SQL: ${sql}`);
assert.match(sql, step.match);
if (step.check) step.check(params, sql);
if (step.error) throw step.error;
return step.result;
}
}
function repositoryFor(connection) {
return new ProductCatalogRepository({
async getConnection() { return connection; },
async execute(sql, params) { return connection.execute(sql, params); }
});
}
function repositoryForConnections(connections) {
const pending = [...connections];
return new ProductCatalogRepository({
async getConnection() {
const connection = pending.shift();
assert.ok(connection, 'Unexpected transaction');
return connection;
},
async execute() { throw new Error('This scenario must use transactional connections'); }
});
}
function categoryInput(parentId) {
return {
parentId,
categoryCode: 'DRINKS',
name: 'Cold drinks',
description: '',
imageUrl: '',
status: 'ACTIVE',
sortOrder: 1
};
}
const createConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{
match: /INSERT INTO qipai_product_categories/,
result: [{ insertId: 31, affectedRows: 1 }, []],
check(params) {
assert.equal(params[0], '7');
assert.equal(params[1], '11');
assert.equal(params[3], 'DRINKS');
}
},
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) {
assert.equal(params[2], 'PRODUCT_CATEGORY_CREATED');
assert.equal(params[3], 'PRODUCT_CATEGORY');
assert.equal(params[4], '31');
assert.deepEqual(JSON.parse(params[8]), { storeId: '11', version: 1 });
}
}
]);
const created = await repositoryFor(createConnection).createCategory(storeActor, '11', {
parentId: null,
categoryCode: 'DRINKS',
name: 'Drinks',
description: '',
imageUrl: '',
status: 'ACTIVE',
sortOrder: 1
});
assert.deepEqual(created, { categoryId: '31', version: 1 });
assert.equal(createConnection.committed, true);
assert.equal(createConnection.rolledBack, false);
assert.equal(createConnection.released, true);
const casConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{ match: /UPDATE qipai_product_categories/, result: [{ affectedRows: 0 }, []] },
{ match: /SELECT version FROM qipai_product_categories/, result: [[{ version: 3 }], []] }
]);
await assert.rejects(
() => repositoryFor(casConnection).updateCategory(storeActor, '11', '31', 2, {
parentId: null,
categoryCode: 'DRINKS',
name: 'Cold drinks',
description: '',
imageUrl: '',
status: 'ACTIVE',
sortOrder: 1
}),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_CATEGORY_VERSION_CONFLICT'
);
assert.equal(casConnection.committed, false);
assert.equal(casConnection.rolledBack, true);
assert.equal(casConnection.released, true);
for (const scenario of [
{
name: 'two-node cycle',
ancestors: [{ id: '32', parentId: '31' }]
},
{
name: 'multi-node cycle',
ancestors: [{ id: '32', parentId: '33' }, { id: '33', parentId: '31' }]
}
]) {
const cycleConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
...scenario.ancestors.map((ancestor, index) => ({
match: /FROM qipai_product_categories[\s\S]*FOR UPDATE/,
result: [[ancestor], []],
check(params, sql) {
assert.deepEqual(params, ['7', '11', index === 0 ? '32' : '33']);
assert.match(sql, /deleted_at IS NULL/);
}
}))
]);
await assert.rejects(
() => repositoryFor(cycleConnection).updateCategory(
storeActor, '11', '31', 2, categoryInput('32')
),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_CATEGORY_PARENT_CYCLE',
scenario.name
);
assert.equal(cycleConnection.rolledBack, true, scenario.name);
assert.equal(cycleConnection.steps.length, 0, scenario.name);
}
const selfParentConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] }
]);
await assert.rejects(
() => repositoryFor(selfParentConnection).updateCategory(
storeActor, '11', '31', 2, categoryInput('31')
),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_CATEGORY_PARENT_CYCLE'
);
assert.equal(selfParentConnection.rolledBack, true);
for (const invalidParent of ['cross-store parent', 'deleted parent']) {
const invalidParentConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{
match: /FROM qipai_product_categories[\s\S]*FOR UPDATE/,
result: [[], []],
check(params, sql) {
assert.deepEqual(params, ['7', '11', '32']);
assert.match(sql, /store_id = \?/);
assert.match(sql, /deleted_at IS NULL/);
}
}
]);
await assert.rejects(
() => repositoryFor(invalidParentConnection).updateCategory(
storeActor, '11', '31', 2, categoryInput('32')
),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_CATEGORY_PARENT_NOT_FOUND',
invalidParent
);
assert.equal(invalidParentConnection.rolledBack, true, invalidParent);
}
for (const [bucket, balances] of [
['available', { availableQuantity: 1, lockedQuantity: 0, lossQuantity: 0 }],
['locked', { availableQuantity: 0, lockedQuantity: 1, lossQuantity: 0 }],
['loss', { availableQuantity: 0, lockedQuantity: 0, lossQuantity: 1 }]
]) {
const stockConnection = new ScriptedConnection([
{
match: /FROM qipai_products[\s\S]*FOR UPDATE/,
result: [[{ id: '41', version: 4 }], []]
},
{
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{ id: '51', productId: '41', version: 2 }], []],
check(params, sql) {
assert.deepEqual(params, ['7', '41', '51']);
assert.match(sql, /product_id = \?/);
assert.match(sql, /deleted_at IS NULL/);
}
},
{
match: /FROM qipai_product_inventory[\s\S]*FOR UPDATE/,
result: [[{ id: '71', storeId: '11', skuId: '51', ...balances }], []],
check(params, sql) {
assert.deepEqual(params, ['7', '51']);
assert.match(sql, /ORDER BY store_id, id[\s\S]*FOR UPDATE/);
}
}
]);
await assert.rejects(
() => repositoryFor(stockConnection).archiveSku(tenantActor, '41', '51', 2),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_SKU_HAS_INVENTORY_STOCK',
`${bucket} stock must block SKU archival`
);
assert.equal(stockConnection.rolledBack, true, bucket);
assert.equal(stockConnection.steps.length, 0, bucket);
}
const skuVersionConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
{
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{ id: '51', productId: '41', version: 3 }], []]
}
]);
await assert.rejects(
() => repositoryFor(skuVersionConnection).archiveSku(tenantActor, '41', '51', 2),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_SKU_VERSION_CONFLICT'
);
assert.equal(skuVersionConnection.rolledBack, true);
const skuOwnershipConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
{
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[], []],
check(params) { assert.deepEqual(params, ['7', '41', '51']); }
}
]);
await assert.rejects(
() => repositoryFor(skuOwnershipConnection).archiveSku(tenantActor, '41', '51', 2),
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_SKU_NOT_FOUND'
);
const productStockConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 2 }], []] },
{
match: /FROM qipai_product_skus[\s\S]*ORDER BY id[\s\S]*FOR UPDATE/,
result: [[{ id: '51', productId: '41', version: 2 }], []]
},
{
match: /INNER JOIN qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{
id: '71', storeId: '11', skuId: '51',
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 2
}], []],
check(params, sql) {
assert.deepEqual(params, ['7', '41']);
assert.match(sql, /ORDER BY s.id, i.store_id, i.id[\s\S]*FOR UPDATE/);
}
}
]);
await assert.rejects(
() => repositoryFor(productStockConnection).archiveProduct(tenantActor, '41', 2),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_HAS_INVENTORY_STOCK'
);
assert.equal(productStockConnection.rolledBack, true);
const activeSkuConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 2 }], []] },
{
match: /deleted_at AS deletedAt[\s\S]*FROM qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{ id: '51', productId: '41', version: 2, deletedAt: null }], []]
},
{
match: /INNER JOIN qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{
id: '71', storeId: '11', skuId: '51',
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 0
}], []]
}
]);
await assert.rejects(
() => repositoryFor(activeSkuConnection).archiveProduct(tenantActor, '41', 2),
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_HAS_ACTIVE_SKUS'
);
assert.equal(activeSkuConnection.rolledBack, true);
const skuArchiveConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
{
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{ id: '51', productId: '41', version: 2 }], []]
},
{
match: /FROM qipai_product_inventory[\s\S]*FOR UPDATE/,
result: [[{
id: '71', storeId: '11', skuId: '51',
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 0
}], []]
},
{ match: /UPDATE qipai_product_skus/, result: [{ affectedRows: 1 }, []] },
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) {
assert.equal(params[2], 'PRODUCT_SKU_ARCHIVED');
assert.equal(params[4], '51');
}
}
]);
const productArchiveAfterSkuConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
{
match: /FROM qipai_product_skus[\s\S]*ORDER BY id[\s\S]*FOR UPDATE/,
result: [[{
id: '51', productId: '41', version: 3, deletedAt: new Date('2026-08-11T00:00:00Z')
}], []]
},
{
match: /INNER JOIN qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{
id: '71', storeId: '11', skuId: '51',
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 0
}], []]
},
{ match: /FROM qipai_product_store_listings/, result: [[{ total: 0 }], []] },
{
match: /UPDATE qipai_products/,
result: [{ affectedRows: 1 }, []],
check(params, sql) {
assert.deepEqual(params, ['21', '7', '41', 4]);
assert.match(sql, /deleted_at = UTC_TIMESTAMP/);
}
},
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.equal(params[2], 'PRODUCT_ARCHIVED'); }
}
]);
const reusedProductConnection = new ScriptedConnection([
{
match: /INSERT INTO qipai_products/,
result: [{ insertId: 42, affectedRows: 1 }, []],
check(params) { assert.equal(params[1], 'TEA'); }
},
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] }
]);
const reusedSkuConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '42', version: 1 }], []] },
{
match: /INSERT INTO qipai_product_skus/,
result: [{ insertId: 52, affectedRows: 1 }, []],
check(params) {
assert.equal(params[2], 'TEA-500');
assert.equal(params[5], '690000000001');
}
},
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] }
]);
const archiveAndReuseRepository = repositoryForConnections([
skuArchiveConnection,
productArchiveAfterSkuConnection,
reusedProductConnection,
reusedSkuConnection
]);
assert.deepEqual(
await archiveAndReuseRepository.archiveSku(tenantActor, '41', '51', 2),
{ skuId: '51', productId: '41', version: 3, archived: true }
);
assert.deepEqual(
await archiveAndReuseRepository.archiveProduct(tenantActor, '41', 4),
{ productId: '41', version: 5, archived: true }
);
assert.deepEqual(
await archiveAndReuseRepository.createProduct(tenantActor, {
productCode: 'TEA',
name: 'Tea replacement',
unitName: 'bottle',
description: '',
coverUrl: '',
images: [],
deliveryEnabled: true,
storageEnabled: false,
status: 'DRAFT',
sortOrder: 1
}),
{ productId: '42', version: 1 }
);
assert.deepEqual(
await archiveAndReuseRepository.createSku(tenantActor, '42', {
skuCode: 'TEA-500',
name: 'Tea 500ml replacement',
attributes: { size: '500ml' },
barcode: '690000000001',
imageUrl: '',
salePriceCents: 500,
marketPriceCents: 600,
costPriceCents: 200,
defaultInventoryPolicy: 'TRACKED',
status: 'ACTIVE'
}),
{ skuId: '52', productId: '42', version: 1 }
);
assert.equal(skuArchiveConnection.committed, true);
assert.equal(productArchiveAfterSkuConnection.committed, true);
assert.equal(reusedProductConnection.committed, true);
assert.equal(reusedSkuConnection.committed, true);
const settingsConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{ match: /FROM qipai_product_store_settings/, result: [[], []] },
{
match: /INSERT INTO qipai_product_store_settings/,
result: [{ insertId: 41, affectedRows: 1 }, []]
},
{ match: /UPDATE qipai_product_store_hours/, result: [{ affectedRows: 0 }, []] },
{
match: /INSERT INTO qipai_product_store_hours/,
result: [{ insertId: 51, affectedRows: 1 }, []],
check(params) {
assert.deepEqual(params.slice(3), [1, 1, 1320, 120, true]);
}
},
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) {
assert.equal(params[2], 'PRODUCT_STORE_SETTINGS_UPDATED');
}
}
]);
const pausedUntil = new Date('2026-08-12T00:00:00.000Z');
const settingsResult = await repositoryFor(settingsConnection).putStoreSettings(
storeActor,
'11',
0,
{
salesStatus: 'OPEN',
manualPaused: true,
manualPausedUntil: pausedUntil,
manualPauseReason: 'inventory count',
hours: mondayOvernight
},
new Date('2026-08-11T00:00:00.000Z')
);
assert.deepEqual(settingsResult, { settingsId: '41', storeId: '11', version: 1 });
assert.equal(settingsConnection.committed, true);
const firstListingArchive = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{
match: /SELECT id, version FROM qipai_product_store_listings[\s\S]*deleted_at IS NULL[\s\S]*FOR UPDATE/,
result: [[{ id: '61', version: 1 }], []]
},
{
match: /UPDATE qipai_product_store_listings/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.deepEqual(params, ['22', '7', '11', '41', '61', 1]); }
},
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.equal(params[4], '61'); }
}
]);
const rebuiltListing = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{
match: /FROM qipai_products[\s\S]*FOR UPDATE/,
result: [[{
id: '41', status: 'ACTIVE', deliveryEnabled: true, storageEnabled: true
}], []]
},
{ match: /FROM qipai_product_categories/, result: [[{ id: '31' }], []] },
{ match: /INSERT INTO qipai_product_store_listings/, result: [{ insertId: 62, affectedRows: 1 }, []] },
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.equal(params[4], '62'); }
}
]);
const secondListingArchive = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{
match: /SELECT id, version FROM qipai_product_store_listings[\s\S]*deleted_at IS NULL[\s\S]*FOR UPDATE/,
result: [[{ id: '62', version: 1 }], []]
},
{
match: /UPDATE qipai_product_store_listings/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.deepEqual(params, ['22', '7', '11', '41', '62', 1]); }
},
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.equal(params[4], '62'); }
}
]);
const listingLifecycleRepository = repositoryForConnections([
firstListingArchive, rebuiltListing, secondListingArchive
]);
assert.deepEqual(
await listingLifecycleRepository.archiveListing(storeActor, '11', '41', 1),
{ listingId: '61', storeId: '11', productId: '41', version: 2, archived: true }
);
assert.deepEqual(
await listingLifecycleRepository.putListing(storeActor, '11', '41', 0, {
categoryId: '31',
status: 'INACTIVE',
fulfillmentMode: 'SELF_SERVICE',
sortOrder: 1
}),
{ listingId: '62', storeId: '11', productId: '41', version: 1 }
);
assert.deepEqual(
await listingLifecycleRepository.archiveListing(storeActor, '11', '41', 1),
{ listingId: '62', storeId: '11', productId: '41', version: 2, archived: true }
);
assert.equal(firstListingArchive.committed, true);
assert.equal(rebuiltListing.committed, true);
assert.equal(secondListingArchive.committed, true);
const skuReadRow = {
id: '51',
productId: '41',
skuCode: 'TEA-500',
name: 'Tea 500ml',
attributesJson: '{"size":"500ml"}',
barcode: '690000000001',
imageUrl: '',
salePriceCents: 500,
marketPriceCents: 600,
costPriceCents: 200,
defaultInventoryPolicy: 'TRACKED',
status: 'ACTIVE',
version: 2,
createdAt: new Date('2026-08-11T00:00:00Z'),
updatedAt: new Date('2026-08-11T00:00:00Z')
};
function skuReadConnection() {
return new ScriptedConnection([
{ match: /SELECT id FROM qipai_products/, result: [[{ id: '41' }], []] },
{ match: /FROM qipai_product_skus/, result: [[skuReadRow], []] }
]);
}
const storeAdminSkus = await repositoryFor(skuReadConnection()).listSkus(storeActor, '41');
assert.equal(storeAdminSkus.length, 1);
assert.equal(Object.hasOwn(storeAdminSkus[0], 'costPriceCents'), false,
'store administrators must not receive product cost');
const tenantManagerSkus = await repositoryFor(skuReadConnection()).listSkus(tenantActor, '41');
assert.equal(tenantManagerSkus[0].costPriceCents, 200,
'tenant managers retain product cost visibility');
const neverUsedPool = {
async execute() { throw new Error('scope checks must happen before SQL'); },
async getConnection() { throw new Error('scope checks must happen before SQL'); }
};
const guardedRepository = new ProductCatalogRepository(neverUsedPool);
await assert.rejects(
() => guardedRepository.listProducts({
...storeActor,
access: {
roles: ['STAFF'], capabilities: ['product.catalog.read'], storeIds: ['11']
}
}),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN'
);
await assert.rejects(
() => guardedRepository.listSkus({
...storeActor,
access: {
roles: ['STORE_ADMIN'], capabilities: ['product.catalog.read'], storeIds: []
}
}, '41'),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN'
);
await assert.rejects(
() => guardedRepository.listCategories({
...storeActor,
access: { ...storeActor.access, storeIds: ['12'] }
}, '11'),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_STORE_SCOPE_FORBIDDEN'
);
await assert.rejects(
() => guardedRepository.createProduct(storeActor, {
productCode: 'TEA',
name: 'Tea',
unitName: 'bottle',
description: '',
coverUrl: '',
images: [],
deliveryEnabled: true,
storageEnabled: false,
status: 'DRAFT',
sortOrder: 1
}),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_TENANT_WRITE_FORBIDDEN'
);
console.log('PASS: M09-D1 product catalog covers global-read scope, cost redaction, category cycles, archive locks, listing lifecycle, hours, CAS and audit.');
+285
View File
@@ -0,0 +1,285 @@
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import { signAccessToken } from '../dist/auth/jwt.js';
import { ProductCatalogError } from '../dist/products/product-catalog-repository.js';
import { registerProductRoutes } from '../dist/routes/products.js';
const secret = 'test-only-product-route-jwt-secret-32';
const token = signAccessToken({
sub: '21',
sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7',
aid: '9',
rv: 1
}, secret, 900);
const calls = [];
let currentAccess = {
roles: ['TENANT_ADMIN'],
capabilities: ['tenant.manage', 'product.catalog.read', 'product.catalog.write'],
storeIds: []
};
const record = (method, result) => async (...args) => {
calls.push({ method, args });
return typeof result === 'function' ? result(...args) : result;
};
const repository = {
listCategories: record('listCategories', []),
createCategory: record('createCategory', { categoryId: '31', version: 1 }),
updateCategory: record('updateCategory', { categoryId: '31', version: 2 }),
archiveCategory: record('archiveCategory', { categoryId: '31', version: 3, archived: true }),
listProducts: record('listProducts', []),
createProduct: record('createProduct', { productId: '41', version: 1 }),
updateProduct: record('updateProduct', { productId: '41', version: 2 }),
archiveProduct: record('archiveProduct', { productId: '41', version: 3, archived: true }),
listSkus: record('listSkus', []),
createSku: record('createSku', { skuId: '51', productId: '41', version: 1 }),
updateSku: record('updateSku', { skuId: '51', productId: '41', version: 2 }),
archiveSku: record('archiveSku', { skuId: '51', productId: '41', version: 3, archived: true }),
listListings: record('listListings', []),
putListing: record('putListing', { listingId: '61', storeId: '11', productId: '41', version: 1 }),
archiveListing: record('archiveListing', {
listingId: '61', storeId: '11', productId: '41', version: 2, archived: true
}),
getStoreSettings: record('getStoreSettings', { storeId: '11', version: 0, hours: [] }),
putStoreSettings: record('putStoreSettings', { settingsId: '71', storeId: '11', version: 1 })
};
const app = Fastify({ logger: false });
app.decorateRequest('traceId', '');
app.addHook('onRequest', async (request, reply) => {
request.traceId = request.headers['x-trace-id'] || request.id;
reply.header('x-trace-id', request.traceId);
});
await registerProductRoutes(app, {
repository,
jwtSecret: secret,
authRepository: {
async validateSession() {
return {
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tenantId: '7',
platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: {
id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE',
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
}
};
}
},
accessControl: {
async getAccessProfile() { return currentAccess; }
}
});
const unauthorized = await app.inject({
method: 'GET', url: '/admin-api/products'
});
assert.equal(unauthorized.statusCode, 401);
assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID');
currentAccess = { roles: ['STAFF'], capabilities: [], storeIds: ['11'] };
const forbidden = await app.inject({
method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` }
});
assert.equal(forbidden.statusCode, 403);
assert.equal(forbidden.json().code, 'PRODUCT_READ_FORBIDDEN');
currentAccess = {
roles: ['STAFF'],
capabilities: ['product.catalog.read'],
storeIds: ['11']
};
const globalCallsBeforeStaff = calls.length;
const staffGlobalForbidden = await app.inject({
method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` }
});
assert.equal(staffGlobalForbidden.statusCode, 403);
assert.equal(staffGlobalForbidden.json().code, 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN');
assert.equal(calls.length, globalCallsBeforeStaff, 'forbidden global reads must not reach repository');
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/stores/11/product-listings?includeInactive=true',
headers: { authorization: `Bearer ${token}` }
})).statusCode, 200, 'store-scoped listing reads remain available to scoped staff');
currentAccess = {
roles: ['STORE_ADMIN'],
capabilities: ['product.catalog.read'],
storeIds: []
};
const emptyStoreAdminForbidden = await app.inject({
method: 'GET', url: '/admin-api/products/41/skus',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(emptyStoreAdminForbidden.statusCode, 403);
assert.equal(emptyStoreAdminForbidden.json().code, 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN');
currentAccess = {
roles: ['STORE_ADMIN'],
capabilities: ['product.catalog.read'],
storeIds: ['11']
};
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` }
})).statusCode, 200);
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/products/41/skus',
headers: { authorization: `Bearer ${token}` }
})).statusCode, 200);
currentAccess = {
roles: ['TENANT_ADMIN'],
capabilities: ['tenant.manage', 'product.catalog.read', 'product.catalog.write'],
storeIds: []
};
const authHeaders = { authorization: `Bearer ${token}`, 'x-trace-id': 'm09d1-product-route' };
const categoryPayload = {
parentId: null,
categoryCode: 'DRINKS',
name: 'Drinks',
description: '',
imageUrl: '',
status: 'ACTIVE',
sortOrder: 1
};
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/stores/11/product-categories?includeInactive=false',
headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'POST', url: '/admin-api/stores/11/product-categories',
headers: authHeaders, payload: categoryPayload
})).statusCode, 201);
assert.equal((await app.inject({
method: 'PUT', url: '/admin-api/stores/11/product-categories/31',
headers: authHeaders, payload: { ...categoryPayload, expectedVersion: 1 }
})).statusCode, 200);
assert.equal((await app.inject({
method: 'DELETE', url: '/admin-api/stores/11/product-categories/31?expectedVersion=2',
headers: authHeaders
})).statusCode, 200);
const productPayload = {
productCode: 'TEA',
name: 'Tea',
unitName: 'bottle',
description: '',
coverUrl: '',
images: [],
deliveryEnabled: true,
storageEnabled: true,
status: 'ACTIVE',
sortOrder: 1
};
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/products?status=ACTIVE&search=Tea', headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'POST', url: '/admin-api/products', headers: authHeaders, payload: productPayload
})).statusCode, 201);
assert.equal((await app.inject({
method: 'PUT', url: '/admin-api/products/41', headers: authHeaders,
payload: { ...productPayload, expectedVersion: 1 }
})).statusCode, 200);
assert.equal((await app.inject({
method: 'DELETE', url: '/admin-api/products/41?expectedVersion=2', headers: authHeaders
})).statusCode, 200);
const skuPayload = {
skuCode: 'TEA-500',
name: 'Tea 500ml',
attributes: { size: '500ml' },
barcode: '690000000001',
imageUrl: '',
salePriceCents: 500,
marketPriceCents: 600,
costPriceCents: 200,
defaultInventoryPolicy: 'TRACKED',
status: 'ACTIVE'
};
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/products/41/skus?includeInactive=true', headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'POST', url: '/admin-api/products/41/skus', headers: authHeaders, payload: skuPayload
})).statusCode, 201);
assert.equal((await app.inject({
method: 'PUT', url: '/admin-api/products/41/skus/51', headers: authHeaders,
payload: { ...skuPayload, expectedVersion: 1 }
})).statusCode, 200);
assert.equal((await app.inject({
method: 'DELETE', url: '/admin-api/products/41/skus/51?expectedVersion=2',
headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/stores/11/product-listings?includeInactive=true',
headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'PUT', url: '/admin-api/stores/11/product-listings/41', headers: authHeaders,
payload: {
expectedVersion: 0,
categoryId: '31',
status: 'ACTIVE',
fulfillmentMode: 'BOTH',
sortOrder: 1
}
})).statusCode, 200);
assert.equal((await app.inject({
method: 'DELETE', url: '/admin-api/stores/11/product-listings/41?expectedVersion=1',
headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/stores/11/product-sales-settings', headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'PUT', url: '/admin-api/stores/11/product-sales-settings', headers: authHeaders,
payload: {
expectedVersion: 0,
salesStatus: 'OPEN',
manualPaused: true,
manualPausedUntil: '2026-08-12T00:00:00.000Z',
manualPauseReason: 'inventory count',
hours: [{
weekday: 1, slotNo: 1, openMinute: 1320, closeMinute: 120, crossesMidnight: true
}]
}
})).statusCode, 200);
const createdSkuCall = calls.find((call) => call.method === 'createSku');
assert.equal(createdSkuCall.args[0].tenantId, '7');
assert.equal(createdSkuCall.args[0].traceId, 'm09d1-product-route');
assert.equal(createdSkuCall.args[2].defaultInventoryPolicy, 'TRACKED');
const settingsCall = calls.find((call) => call.method === 'putStoreSettings');
assert.equal(settingsCall.args[2], 0);
assert.equal(settingsCall.args[3].hours[0].crossesMidnight, true);
repository.updateProduct = async () => {
throw new ProductCatalogError('PRODUCT_VERSION_CONFLICT');
};
const conflict = await app.inject({
method: 'PUT', url: '/admin-api/products/41', headers: authHeaders,
payload: { ...productPayload, expectedVersion: 1 }
});
assert.equal(conflict.statusCode, 409);
assert.equal(conflict.json().code, 'PRODUCT_VERSION_CONFLICT');
const invalid = await app.inject({
method: 'PUT', url: '/admin-api/stores/11/product-sales-settings', headers: authHeaders,
payload: {
expectedVersion: 0,
salesStatus: 'OPEN',
manualPaused: true,
manualPauseReason: '',
hours: []
}
});
assert.equal(invalid.statusCode, 400);
assert.equal(invalid.json().code, 'INVALID_PRODUCT_REQUEST');
await app.close();
console.log('PASS: M09-D1 product routes cover global/store read boundaries, CRUD, CAS, auth and business settings.');
+4
View File
@@ -29,5 +29,9 @@ assert.match(calls.at(-1)[0], /s\.tenant_id = \?/);
assert.match(calls.at(-1)[0], /u\.tenant_id = \?/);
await repository.ensureCustomerRole('7', '21');
assert.ok(calls.some(([sql]) => sql.includes("r.code = 'STAFF'") && sql.includes('store.operation.read')));
assert.ok(calls.some(([sql]) => sql.includes("r.code = 'STAFF'")
&& sql.includes('product.catalog.read') && sql.includes('inventory.read')));
assert.ok(calls.some(([sql]) => sql.includes("r.code = 'STORE_ADMIN'")
&& sql.includes('product.catalog.write') && sql.includes('inventory.adjust')));
console.log('PASS: M02-C roles, capabilities and tenant-scoped store grants are present.');