feat(M09-D1): 完成商品目录与库存流水底座
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user