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
@@ -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 {