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