feat(M09-D2): 完成商品订单与库存占用生命周期
This commit is contained in:
@@ -167,6 +167,26 @@ function execute(sql, params) {
|
||||
&& item.inventoryId === String(params[1]) && item.requestId === String(params[2]));
|
||||
return [row ? [{ ...row }] : [], []];
|
||||
}
|
||||
if (sql.includes('AS reservedQuantity') && sql.includes('inventorySaleDelta')) {
|
||||
const inventoryIds = new Set(params.slice(4).map(String));
|
||||
const balances = new Map();
|
||||
for (const item of state.ledgers) {
|
||||
if (item.tenantId !== String(params[0]) || item.storeId !== String(params[1])
|
||||
|| item.businessType !== String(params[2]) || item.businessId !== String(params[3])
|
||||
|| !inventoryIds.has(item.inventoryId)
|
||||
|| !['DEDUCT', 'RETURN'].includes(item.operation)) continue;
|
||||
const metadata = JSON.parse(item.metadata);
|
||||
const fallback = item.operation === 'DEDUCT'
|
||||
? -Number(metadata.inventoryReservationDelta ?? item.lockedDelta)
|
||||
: Number(item.availableDelta);
|
||||
balances.set(item.inventoryId,
|
||||
(balances.get(item.inventoryId) ?? 0)
|
||||
+ Number(metadata.inventorySaleDelta ?? fallback));
|
||||
}
|
||||
return [[...balances.entries()].map(([inventoryId, reservedQuantity]) => ({
|
||||
inventoryId, reservedQuantity
|
||||
})), []];
|
||||
}
|
||||
if (sql.includes('AS reservedQuantity') && sql.includes('business_type = ?')) {
|
||||
const inventoryIds = new Set(params.slice(4).map(String));
|
||||
const balances = new Map();
|
||||
@@ -376,6 +396,51 @@ assert.deepEqual(
|
||||
[8, 0]
|
||||
);
|
||||
|
||||
const returned = await service.returnMany({
|
||||
...batchBase, requestId: 'order-return-1', reason: '退款成功回补',
|
||||
items: [{ skuId: '5', quantity: 1 }]
|
||||
});
|
||||
assert.equal(returned.idempotent, false);
|
||||
assert.equal(findStock('7', '11', '5').availableQuantity, 9);
|
||||
const returnedReplay = await service.returnMany({
|
||||
...batchBase, requestId: 'order-return-1', reason: '退款成功回补',
|
||||
items: [{ skuId: '5', quantity: 1 }]
|
||||
});
|
||||
assert.equal(returnedReplay.idempotent, true);
|
||||
assert.equal(findStock('7', '11', '5').availableQuantity, 9);
|
||||
await assert.rejects(
|
||||
() => service.returnMany({
|
||||
...batchBase, requestId: 'order-return-1', reason: '退款成功回补',
|
||||
items: [{ skuId: '5', quantity: 2 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.returnMany({
|
||||
...batchBase, requestId: 'foreign-order-return', businessId: 'order-foreign',
|
||||
reason: '跨订单退款不得回补', items: [{ skuId: '5', quantity: 1 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_INSUFFICIENT_DEDUCTED'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.returnMany({
|
||||
...batchBase, requestId: 'order-return-too-many', reason: '超额退款不得回补',
|
||||
items: [{ skuId: '5', quantity: 2 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_INSUFFICIENT_DEDUCTED'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.returnMany({
|
||||
...batchBase, requestId: 'order-return-case', businessId: 'ORDER-500',
|
||||
reason: '业务号大小写敏感', items: [{ skuId: '5', quantity: 1 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_INSUFFICIENT_DEDUCTED'
|
||||
);
|
||||
|
||||
const beforeFailedBatch = structuredClone(state.stocks);
|
||||
await assert.rejects(
|
||||
() => service.lockMany({
|
||||
@@ -443,4 +508,4 @@ assert.ok(history.items.length >= 7);
|
||||
assert.ok(history.items.every((item) => item.metadata.idempotencyFingerprint));
|
||||
assert.equal(state.audits.length, state.ledgers.length);
|
||||
|
||||
console.log('PASS: M09-D1 inventory policy, inbound/adjust/stocktake/loss, ordered batch locks, idempotency, immutable ledger, scope and audit work.');
|
||||
console.log('PASS: M09-D1 inventory policy, batch reservation/sale return lifecycle, idempotency, immutable ledger, scope and audit work.');
|
||||
|
||||
@@ -129,6 +129,15 @@ const productInventoryDownSql = read(
|
||||
const productInventoryVerifySql = read(
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql'
|
||||
);
|
||||
const productOrderPaymentUpSql = read(
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql'
|
||||
);
|
||||
const productOrderPaymentDownSql = read(
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql'
|
||||
);
|
||||
const productOrderPaymentVerifySql = read(
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql'
|
||||
);
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -611,4 +620,68 @@ assert.match(productInventoryVerifySql, /fully_granted_product_roles/);
|
||||
assert.match(productInventoryVerifySql, /'uq_qipai_product_inventory_ledger_version'/);
|
||||
assert.match(productInventoryVerifySql, /'2026081107'/);
|
||||
|
||||
console.log('PASS: M01-B through M09-D1 migration contracts are present.');
|
||||
for (const table of [
|
||||
'qipai_product_orders',
|
||||
'qipai_product_order_items',
|
||||
'qipai_product_order_events',
|
||||
'qipai_product_payments',
|
||||
'qipai_product_refunds',
|
||||
'qipai_product_payment_callbacks',
|
||||
'qipai_product_refund_events'
|
||||
]) {
|
||||
assert.match(productOrderPaymentUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`));
|
||||
assert.match(productOrderPaymentDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}\\b`));
|
||||
assert.match(productOrderPaymentVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
assert.doesNotMatch(productOrderPaymentUpSql, /\bDECIMAL\b/i);
|
||||
for (const state of [
|
||||
'PENDING_PAYMENT', 'PAID', 'ACCEPTED', 'DELIVERING',
|
||||
'READY_FOR_SELF_SERVICE', 'COMPLETED', 'CANCELLED',
|
||||
'REFUNDING', 'REFUNDED', 'REFUND_FAILED'
|
||||
]) assert.match(productOrderPaymentUpSql, new RegExp(`'${state}'`));
|
||||
assert.match(productOrderPaymentUpSql, /fulfillment_mode = 'DELIVERY' AND room_order_id IS NOT NULL/);
|
||||
assert.match(productOrderPaymentUpSql, /fulfillment_mode = 'SELF_SERVICE' AND room_order_id IS NULL/);
|
||||
assert.match(productOrderPaymentUpSql, /subtotal_cents = unit_price_cents \* quantity/);
|
||||
for (const snapshotColumn of [
|
||||
'product_code', 'product_name', 'sku_code', 'sku_name',
|
||||
'unit_name', 'attributes_snapshot', 'unit_price_cents', 'quantity', 'subtotal_cents'
|
||||
]) assert.match(productOrderPaymentUpSql, new RegExp(`\\b${snapshotColumn}\\b`));
|
||||
assert.match(productOrderPaymentUpSql, /order_no VARCHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /payment_no VARCHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /refund_no VARCHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /callback_id VARCHAR\(128\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /inventory_business_id VARCHAR\(128\) COLLATE utf8mb4_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /request_fingerprint CHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_order_client_request/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_payment_client_request/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_payment_provider_id/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_payment_callback_provider/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_refund_client_request/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_refund_provider_id/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_payment_active_order/);
|
||||
assert.match(productOrderPaymentUpSql, /status IN \('PENDING', 'PROCESSING'\) THEN order_id/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_refund_active_payment/);
|
||||
assert.match(productOrderPaymentUpSql, /status IN \('PENDING', 'PROCESSING'\) THEN payment_id/);
|
||||
assert.match(productOrderPaymentUpSql, /fk_qipai_product_order_inventory_request/);
|
||||
assert.match(productOrderPaymentUpSql, /fk_qipai_product_refund_inventory_request/);
|
||||
assert.match(productOrderPaymentUpSql, /inventory_disposition = 'RESTOCK'/);
|
||||
assert.match(productOrderPaymentUpSql, /inventory_disposition = 'NO_RESTOCK'/);
|
||||
assert.match(productOrderPaymentUpSql, /inventory_status IN \('LOCKED', 'DEDUCTED', 'RELEASED', 'RETURNED'\)/);
|
||||
assert.match(productOrderPaymentUpSql, /'COMPENSATION_REQUIRED', 'REFUNDED'/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_order_items_no_update/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_order_items_no_delete/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_order_events_no_update/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_order_events_no_delete/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_refund_events_no_update/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_refund_events_no_delete/);
|
||||
for (const permission of ['goods.order.read', 'goods.order.manage']) {
|
||||
const pattern = new RegExp(permission.replace('.', '\\.'));
|
||||
assert.match(productOrderPaymentUpSql, pattern);
|
||||
assert.match(productOrderPaymentDownSql, pattern);
|
||||
assert.match(productOrderPaymentVerifySql, pattern);
|
||||
}
|
||||
assert.match(productOrderPaymentUpSql, /r\.code IN \('STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'\)/);
|
||||
assert.match(productOrderPaymentVerifySql, /fully_granted_goods_order_roles/);
|
||||
assert.match(productOrderPaymentVerifySql, /'2026081108'/);
|
||||
|
||||
console.log('PASS: M01-B through M09-D2 migration contracts are present.');
|
||||
|
||||
@@ -46,7 +46,8 @@ assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
|
||||
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
|
||||
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql/);
|
||||
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql/);
|
||||
assert.match(plan.file, /2026081107_m09d1_product_inventory_foundation\.up\.sql$/);
|
||||
assert.match(plan.file, /2026081107_m09d1_product_inventory_foundation\.up\.sql/);
|
||||
assert.match(plan.file, /2026081108_m09d2_product_order_payment_inventory\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
@@ -59,11 +60,20 @@ assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql/);
|
||||
assert.match(verifyPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.verify\.sql/);
|
||||
assert.match(
|
||||
verifyPlan.file,
|
||||
/2026081107_m09d1_product_inventory_foundation\.verify\.sql$/
|
||||
/2026081107_m09d1_product_inventory_foundation\.verify\.sql/
|
||||
);
|
||||
assert.match(
|
||||
verifyPlan.file,
|
||||
/2026081108_m09d2_product_order_payment_inventory\.verify\.sql$/
|
||||
);
|
||||
|
||||
const downPlan = await loadMigrationPlan('down');
|
||||
assert.match(downPlan.file, /^database\/migrations\/2026081107_m09d1_product_inventory_foundation\.down\.sql/);
|
||||
assert.match(downPlan.file, /^database\/migrations\/2026081108_m09d2_product_order_payment_inventory\.down\.sql/);
|
||||
assert.match(downPlan.file, /2026081107_m09d1_product_inventory_foundation\.down\.sql/);
|
||||
assert.ok(
|
||||
downPlan.file.indexOf('2026081108_m09d2_product_order_payment_inventory.down.sql')
|
||||
< downPlan.file.indexOf('2026081107_m09d1_product_inventory_foundation.down.sql')
|
||||
);
|
||||
assert.match(downPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.down\.sql/);
|
||||
assert.ok(
|
||||
downPlan.file.indexOf('2026081107_m09d1_product_inventory_foundation.down.sql')
|
||||
|
||||
@@ -54,6 +54,9 @@ import {
|
||||
import {
|
||||
InventoryError, InventoryService
|
||||
} from '../dist/inventory/inventory-service.js';
|
||||
import {
|
||||
ProductOrderError, ProductOrderService
|
||||
} from '../dist/products/product-order-service.js';
|
||||
import {
|
||||
executeMigrationPlan,
|
||||
loadMigrationPlan,
|
||||
@@ -107,6 +110,13 @@ const expectedTables = [
|
||||
'qipai_product_inventory',
|
||||
'qipai_product_inventory_ledger',
|
||||
'qipai_product_inventory_requests',
|
||||
'qipai_product_order_events',
|
||||
'qipai_product_order_items',
|
||||
'qipai_product_orders',
|
||||
'qipai_product_payment_callbacks',
|
||||
'qipai_product_payments',
|
||||
'qipai_product_refund_events',
|
||||
'qipai_product_refunds',
|
||||
'qipai_product_skus',
|
||||
'qipai_product_store_hours',
|
||||
'qipai_product_store_listings',
|
||||
@@ -160,11 +170,28 @@ async function assertProductInventoryMigrationRetry(pool, fullUpPlan) {
|
||||
repoRoot,
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation'
|
||||
);
|
||||
const [upSql, downSql] = await Promise.all([
|
||||
const productOrderMigrationBase = resolve(
|
||||
repoRoot,
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory'
|
||||
);
|
||||
const [upSql, downSql, productOrderDownSql] = await Promise.all([
|
||||
readFile(`${migrationBase}.up.sql`, 'utf8'),
|
||||
readFile(`${migrationBase}.down.sql`, 'utf8')
|
||||
readFile(`${migrationBase}.down.sql`, 'utf8'),
|
||||
readFile(`${productOrderMigrationBase}.down.sql`, 'utf8')
|
||||
]);
|
||||
const upStatements = splitSqlStatements(upSql);
|
||||
let productOrderDownAttempt = 0;
|
||||
const removeProductOrderDependents = async () => {
|
||||
productOrderDownAttempt += 1;
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: `${productOrderMigrationBase}.retry-${productOrderDownAttempt}.down.sql`,
|
||||
checksum: `m09d2-before-m09d1-retry-${productOrderDownAttempt}`,
|
||||
statements: splitSqlStatements(productOrderDownSql)
|
||||
});
|
||||
};
|
||||
|
||||
await removeProductOrderDependents();
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: `${migrationBase}.down.sql`,
|
||||
@@ -175,6 +202,7 @@ async function assertProductInventoryMigrationRetry(pool, fullUpPlan) {
|
||||
await executeMigrationPlan(pool, fullUpPlan);
|
||||
await executeMigrationPlan(pool, fullUpPlan);
|
||||
|
||||
await removeProductOrderDependents();
|
||||
const interruptedDownStatements = splitSqlStatements(downSql).slice(0, -1);
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
@@ -236,14 +264,14 @@ 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', '2026081107']
|
||||
'2026081006', '2026081107', '2026081108']
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -1931,7 +1959,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, '2026081107');
|
||||
assert.equal(overview.latestMigration.version, '2026081108');
|
||||
assert.ok(overview.counts.userCount > 0);
|
||||
await repository.updateTenant(actor, context.tenantId, {
|
||||
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||
@@ -3997,6 +4025,292 @@ async function assertProductInventoryFoundation(pool, context) {
|
||||
);
|
||||
}
|
||||
|
||||
async function assertProductOrderPaymentInventory(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 [customerRows] = 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 = 'CUSTOMER'
|
||||
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]
|
||||
);
|
||||
const [categoryRows] = await pool.query(
|
||||
`SELECT id FROM qipai_product_categories
|
||||
WHERE tenant_id = ? AND store_id = ? AND category_code = 'M09D1-DRINKS'
|
||||
AND status = 'ACTIVE' AND deleted_at IS NULL ORDER BY id LIMIT 1`,
|
||||
[context.tenantId, storeRows[0]?.id]
|
||||
);
|
||||
assert.ok(adminRows[0] && customerRows[0] && storeRows[0] && categoryRows[0],
|
||||
'M09-D2 requires the M09-D1 tenant, customer, store and category fixtures.');
|
||||
const adminId = String(adminRows[0].id);
|
||||
const customerId = String(customerRows[0].id);
|
||||
const storeId = String(storeRows[0].id);
|
||||
const categoryId = String(categoryRows[0].id);
|
||||
const rbac = new RbacRepository(pool);
|
||||
const adminAccess = await rbac.getAccessProfile(context.tenantId, adminId);
|
||||
for (const capability of ['goods.order.read', 'goods.order.manage']) {
|
||||
assert.ok(adminAccess.capabilities.includes(capability),
|
||||
`missing M09-D2 capability ${capability}`);
|
||||
}
|
||||
const adminActor = {
|
||||
tenantId: context.tenantId, userId: adminId, access: adminAccess,
|
||||
source: 'MANAGEMENT', traceId: 'm09d2-live-admin', ip: '127.0.0.1',
|
||||
userAgent: 'M09-D2 live MySQL admin test'
|
||||
};
|
||||
const customerActor = {
|
||||
tenantId: context.tenantId, userId: customerId,
|
||||
access: await rbac.getAccessProfile(context.tenantId, customerId),
|
||||
source: 'CUSTOMER', traceId: 'm09d2-live-customer', ip: '127.0.0.1',
|
||||
userAgent: 'M09-D2 live MySQL customer test'
|
||||
};
|
||||
const catalog = new ProductCatalogRepository(pool);
|
||||
const inventory = new InventoryService(pool);
|
||||
const service = new ProductOrderService(pool, inventory, {
|
||||
now: () => new Date('2026-08-10T15:00:00.000Z'),
|
||||
paymentHoldMinutes: 15
|
||||
});
|
||||
|
||||
const product = await catalog.createProduct(adminActor, {
|
||||
productCode: 'M09D2-ORDER-DRINK', name: 'M09D2 Order Drink', unitName: 'bottle',
|
||||
description: 'M09-D2 transactional product', coverUrl: '', images: [],
|
||||
deliveryEnabled: true, storageEnabled: true, status: 'ACTIVE', sortOrder: 2
|
||||
});
|
||||
const sku = await catalog.createSku(adminActor, product.productId, {
|
||||
skuCode: 'M09D2-ORDER-DRINK-500', name: '500ml', attributes: { volume: '500ml' },
|
||||
barcode: 'M09D2000001', imageUrl: '', salePriceCents: 700,
|
||||
marketPriceCents: 900, costPriceCents: 300,
|
||||
defaultInventoryPolicy: 'TRACKED', status: 'ACTIVE'
|
||||
});
|
||||
await catalog.putListing(adminActor, storeId, product.productId, 0, {
|
||||
categoryId, status: 'ACTIVE', fulfillmentMode: 'BOTH',
|
||||
salesStartAt: null, salesEndAt: null, sortOrder: 2
|
||||
});
|
||||
const configured = await inventory.configurePolicy(adminActor, {
|
||||
storeId, skuId: sku.skuId, requestId: 'm09d2-policy', reason: '订单测试库存策略',
|
||||
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 2
|
||||
});
|
||||
await inventory.inbound(adminActor, {
|
||||
storeId, skuId: sku.skuId, requestId: 'm09d2-inbound', reason: '订单测试入库',
|
||||
expectedVersion: configured.version, quantity: 20
|
||||
});
|
||||
|
||||
const firstInput = {
|
||||
storeId, requestId: 'm09d2-order-first', fulfillmentMode: 'SELF_SERVICE',
|
||||
roomOrderId: null, note: 'first order',
|
||||
items: [{ skuId: sku.skuId, quantity: 2, note: 'snapshot note' }]
|
||||
};
|
||||
const firstOrder = await service.create(customerActor, firstInput);
|
||||
assert.equal(firstOrder.status, 'PENDING_PAYMENT');
|
||||
assert.equal(firstOrder.totalAmountCents, 1400);
|
||||
assert.equal(firstOrder.items[0].productName, 'M09D2 Order Drink');
|
||||
assert.equal(firstOrder.items[0].unitPriceCents, 700);
|
||||
assert.equal((await service.create(customerActor, firstInput)).idempotent, true);
|
||||
await assert.rejects(
|
||||
() => service.create(customerActor, {
|
||||
...firstInput, items: [{ skuId: sku.skuId, quantity: 3, note: 'changed' }]
|
||||
}),
|
||||
(error) => error instanceof ProductOrderError
|
||||
&& error.code === 'PRODUCT_ORDER_IDEMPOTENCY_CONFLICT'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.create(customerActor, {
|
||||
...firstInput, requestId: 'm09d2-invalid-room-order', fulfillmentMode: 'DELIVERY',
|
||||
roomOrderId: '999999999999999999'
|
||||
}),
|
||||
(error) => error instanceof ProductOrderError
|
||||
&& error.code === 'PRODUCT_ORDER_ROOM_ORDER_INVALID'
|
||||
);
|
||||
let [stockRows] = await pool.query(
|
||||
`SELECT available_quantity AS availableQuantity, locked_quantity AS lockedQuantity
|
||||
FROM qipai_product_inventory
|
||||
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?`,
|
||||
[context.tenantId, storeId, sku.skuId]
|
||||
);
|
||||
assert.deepEqual({
|
||||
availableQuantity: Number(stockRows[0].availableQuantity),
|
||||
lockedQuantity: Number(stockRows[0].lockedQuantity)
|
||||
}, { availableQuantity: 18, lockedQuantity: 2 });
|
||||
|
||||
const firstPayment = await service.createPaymentForCustomer(
|
||||
customerActor, firstOrder.id, { requestId: 'm09d2-payment-first', provider: 'TEST' }
|
||||
);
|
||||
assert.equal((await service.createPaymentForCustomer(
|
||||
customerActor, firstOrder.id, { requestId: 'm09d2-payment-first', provider: 'TEST' }
|
||||
)).idempotent, true);
|
||||
await assert.rejects(
|
||||
() => service.createPaymentForCustomer(customerActor, firstOrder.id, {
|
||||
requestId: 'm09d2-payment-first', provider: 'WECHAT'
|
||||
}),
|
||||
(error) => error instanceof ProductOrderError
|
||||
&& error.code === 'PRODUCT_PAYMENT_IDEMPOTENCY_CONFLICT'
|
||||
);
|
||||
const paymentRace = await Promise.all([
|
||||
service.completeTestPaymentForCustomer(customerActor, firstPayment.id, {
|
||||
callbackId: 'm09d2-payment-first-callback-a', amountCents: 1400
|
||||
}),
|
||||
service.completeTestPaymentForCustomer(customerActor, firstPayment.id, {
|
||||
callbackId: 'm09d2-payment-first-callback-b', amountCents: 1400
|
||||
})
|
||||
]);
|
||||
assert.equal(paymentRace.filter((result) => result.idempotent === false).length, 1);
|
||||
assert.equal(paymentRace.filter((result) => result.idempotent === true).length, 1);
|
||||
[stockRows] = await pool.query(
|
||||
`SELECT available_quantity AS availableQuantity, locked_quantity AS lockedQuantity
|
||||
FROM qipai_product_inventory
|
||||
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?`,
|
||||
[context.tenantId, storeId, sku.skuId]
|
||||
);
|
||||
assert.deepEqual({
|
||||
availableQuantity: Number(stockRows[0].availableQuantity),
|
||||
lockedQuantity: Number(stockRows[0].lockedQuantity)
|
||||
}, { availableQuantity: 18, lockedQuantity: 0 });
|
||||
const [deductRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS total FROM qipai_product_inventory_ledger
|
||||
WHERE tenant_id = ? AND business_type = 'PRODUCT_ORDER'
|
||||
AND business_id = ? AND operation = 'DEDUCT'`,
|
||||
[context.tenantId, `PRODUCT_ORDER:${firstOrder.orderNo}`]
|
||||
);
|
||||
assert.equal(Number(deductRows[0].total), 1,
|
||||
'concurrent payment callbacks must deduct inventory once');
|
||||
|
||||
await service.managementAction(adminActor, firstOrder.id, {
|
||||
requestId: 'm09d2-first-accept', action: 'ACCEPT', reason: ''
|
||||
});
|
||||
await service.managementAction(adminActor, firstOrder.id, {
|
||||
requestId: 'm09d2-first-ready', action: 'MARK_READY', reason: ''
|
||||
});
|
||||
const completed = await service.managementAction(adminActor, firstOrder.id, {
|
||||
requestId: 'm09d2-first-complete', action: 'COMPLETE', reason: ''
|
||||
});
|
||||
assert.equal(completed.status, 'COMPLETED');
|
||||
const completedRefunding = await service.managementAction(adminActor, firstOrder.id, {
|
||||
requestId: 'm09d2-first-refund', action: 'REFUND', reason: '完成后退款不回库'
|
||||
});
|
||||
assert.equal(completedRefunding.refunds[0].inventoryDisposition, 'NO_RESTOCK');
|
||||
await service.completeTestRefundForManagement(
|
||||
adminActor, String(completedRefunding.refunds[0].id), {
|
||||
callbackId: 'm09d2-first-refund-callback', amountCents: 1400
|
||||
}
|
||||
);
|
||||
|
||||
const restockOrder = await service.create(customerActor, {
|
||||
...firstInput, requestId: 'm09d2-order-restock', note: 'restock order',
|
||||
items: [{ skuId: sku.skuId, quantity: 3, note: '' }]
|
||||
});
|
||||
const restockPayment = await service.createPaymentForCustomer(
|
||||
customerActor, restockOrder.id,
|
||||
{ requestId: 'm09d2-payment-restock', provider: 'TEST' }
|
||||
);
|
||||
await service.completeTestPaymentForCustomer(customerActor, restockPayment.id, {
|
||||
callbackId: 'm09d2-payment-restock-callback', amountCents: 2100
|
||||
});
|
||||
const restockRefunding = await service.managementAction(adminActor, restockOrder.id, {
|
||||
requestId: 'm09d2-restock-refund', action: 'REFUND', reason: '支付后取消回库'
|
||||
});
|
||||
const restockRefundId = String(restockRefunding.refunds[0].id);
|
||||
assert.equal(restockRefunding.refunds[0].inventoryDisposition, 'RESTOCK');
|
||||
const restocked = await service.completeTestRefundForManagement(
|
||||
adminActor, restockRefundId,
|
||||
{ callbackId: 'm09d2-restock-refund-callback', amountCents: 2100 }
|
||||
);
|
||||
assert.equal(restocked.status, 'SUCCEEDED');
|
||||
assert.equal((await service.completeTestRefundForManagement(
|
||||
adminActor, restockRefundId,
|
||||
{ callbackId: 'm09d2-restock-refund-callback', amountCents: 2100 }
|
||||
)).idempotent, true);
|
||||
|
||||
const cancelOrder = await service.create(customerActor, {
|
||||
...firstInput, requestId: 'm09d2-order-cancel', note: 'cancel order',
|
||||
items: [{ skuId: sku.skuId, quantity: 1, note: '' }]
|
||||
});
|
||||
const cancelled = await service.cancelForCustomer(customerActor, cancelOrder.id, {
|
||||
requestId: 'm09d2-order-cancel-action', reason: '顾客取消待支付订单'
|
||||
});
|
||||
assert.equal(cancelled.status, 'CANCELLED');
|
||||
assert.equal(cancelled.inventoryStatus, 'RELEASED');
|
||||
assert.equal((await service.cancelForCustomer(customerActor, cancelOrder.id, {
|
||||
requestId: 'm09d2-order-cancel-action', reason: '顾客取消待支付订单'
|
||||
})).status, 'CANCELLED');
|
||||
|
||||
const timeoutOrder = await service.create(customerActor, {
|
||||
...firstInput, requestId: 'm09d2-order-timeout', note: 'timeout order',
|
||||
items: [{ skuId: sku.skuId, quantity: 1, note: '' }]
|
||||
});
|
||||
await pool.query(
|
||||
`UPDATE qipai_product_orders SET expires_at = UTC_TIMESTAMP(3) - INTERVAL 1 SECOND
|
||||
WHERE tenant_id = ? AND id = ?`, [context.tenantId, timeoutOrder.id]
|
||||
);
|
||||
assert.ok((await service.expirePendingOrders()).expired >= 1);
|
||||
assert.equal((await service.getForCustomer(customerActor, timeoutOrder.id)).status, 'CANCELLED');
|
||||
|
||||
[stockRows] = await pool.query(
|
||||
`SELECT available_quantity AS availableQuantity, locked_quantity AS lockedQuantity
|
||||
FROM qipai_product_inventory
|
||||
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?`,
|
||||
[context.tenantId, storeId, sku.skuId]
|
||||
);
|
||||
assert.deepEqual({
|
||||
availableQuantity: Number(stockRows[0].availableQuantity),
|
||||
lockedQuantity: Number(stockRows[0].lockedQuantity)
|
||||
}, { availableQuantity: 18, lockedQuantity: 0 },
|
||||
'completed consumption stays deducted while paid cancellation and pending cancellation restore stock');
|
||||
|
||||
await assert.rejects(
|
||||
() => service.listForManagement({
|
||||
...adminActor,
|
||||
access: { roles: ['STAFF'], capabilities: ['goods.order.read'], storeIds: [] }
|
||||
}, { storeId, page: 1, pageSize: 20 }),
|
||||
(error) => error instanceof ProductOrderError
|
||||
&& error.code === 'PRODUCT_ORDER_STORE_SCOPE_FORBIDDEN'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`UPDATE qipai_product_order_items SET item_note = 'forbidden'
|
||||
WHERE tenant_id = ? AND order_id = ? LIMIT 1`,
|
||||
[context.tenantId, firstOrder.id]
|
||||
),
|
||||
(error) => /PRODUCT_ORDER_ITEM_IMMUTABLE/.test(error?.message ?? '')
|
||||
);
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`DELETE FROM qipai_product_order_events
|
||||
WHERE tenant_id = ? AND order_id = ? LIMIT 1`,
|
||||
[context.tenantId, firstOrder.id]
|
||||
),
|
||||
(error) => /PRODUCT_ORDER_EVENT_IMMUTABLE/.test(error?.message ?? '')
|
||||
);
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`UPDATE qipai_product_refund_events SET reason = 'forbidden'
|
||||
WHERE tenant_id = ? AND refund_id = ? LIMIT 1`,
|
||||
[context.tenantId, restockRefundId]
|
||||
),
|
||||
(error) => /PRODUCT_REFUND_EVENT_IMMUTABLE/.test(error?.message ?? '')
|
||||
);
|
||||
console.log(
|
||||
'PASS: M09-D2 order snapshots, concurrent payment deduct, cancellation release, '
|
||||
+ 'refund restock, timeout cleanup and immutable events are consistent.'
|
||||
);
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
|
||||
assert.match(
|
||||
@@ -4049,7 +4363,8 @@ try {
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' }
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' },
|
||||
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' }
|
||||
]);
|
||||
await assertTaskDurability(pool);
|
||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||
@@ -4073,13 +4388,14 @@ try {
|
||||
await assertIotMessages(pool, loginContext);
|
||||
await assertCleaningTaskTransactions(pool, loginContext);
|
||||
await assertProductInventoryFoundation(pool, loginContext);
|
||||
await assertProductOrderPaymentInventory(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-D1 migration tables.');
|
||||
console.log('PASS: down removed all M01-B through M09-D2 migration tables.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
@@ -4110,7 +4426,8 @@ try {
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' }
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' },
|
||||
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' }
|
||||
]);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: second up and verify restored the schema.');
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import Fastify from 'fastify';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import { ProductOrderError } from '../dist/products/product-order-service.js';
|
||||
import { registerProductOrderRoutes } from '../dist/routes/product-orders.js';
|
||||
|
||||
const secret = 'test-only-product-order-route-jwt-secret';
|
||||
const sessionId = '3a6ab573-1105-4bf9-b75b-e88e49eb3b82';
|
||||
const token = signAccessToken({
|
||||
sub: '21', sid: sessionId, tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
const headers = {
|
||||
authorization: `Bearer ${token}`,
|
||||
'x-trace-id': 'm09d2-product-order-route'
|
||||
};
|
||||
let currentAccess = { roles: ['CUSTOMER'], capabilities: [], storeIds: [] };
|
||||
const calls = [];
|
||||
const record = (method, result) => async (...args) => {
|
||||
calls.push({ method, args });
|
||||
return typeof result === 'function' ? result(...args) : result;
|
||||
};
|
||||
const order = {
|
||||
id: '101', storeId: '11', orderNo: 'PG101', status: 'PENDING_PAYMENT',
|
||||
fulfillmentMode: 'SELF_SERVICE', totalAmountCents: 500,
|
||||
inventoryStatus: 'LOCKED'
|
||||
};
|
||||
const service = {
|
||||
create: record('create', (_actor, input) => ({ ...order, storeId: input.storeId })),
|
||||
listForCustomer: record('listForCustomer', (_actor, input) => ({
|
||||
items: [order], total: 1, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
getForCustomer: record('getForCustomer', (_actor, orderId) => {
|
||||
if (orderId === '999') throw new ProductOrderError('PRODUCT_ORDER_NOT_FOUND');
|
||||
return order;
|
||||
}),
|
||||
cancelForCustomer: record('cancelForCustomer', () => ({ ...order, status: 'CANCELLED' })),
|
||||
listForManagement: record('listForManagement', (_actor, input) => ({
|
||||
items: [order], total: 1, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
getForManagement: record('getForManagement', () => order),
|
||||
managementAction: record('managementAction', (_actor, _id, input) => ({
|
||||
...order, status: input.action === 'ACCEPT' ? 'ACCEPTED' : order.status
|
||||
})),
|
||||
createPaymentForCustomer: record('createPaymentForCustomer', () => ({
|
||||
id: '401', orderId: '101', provider: 'TEST', status: 'PENDING', amountCents: 500
|
||||
})),
|
||||
completeTestPaymentForCustomer: record('completeTestPaymentForCustomer', () => ({
|
||||
paymentId: '401', orderId: '101', status: 'SUCCEEDED'
|
||||
})),
|
||||
completeTestRefundForManagement: record('completeTestRefundForManagement', () => ({
|
||||
refundId: '501', orderId: '101', status: 'SUCCEEDED'
|
||||
}))
|
||||
};
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
app.decorateRequest('traceId', '');
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
request.traceId = request.headers['x-trace-id'] || request.id;
|
||||
reply.header('x-trace-id', request.traceId);
|
||||
});
|
||||
await registerProductOrderRoutes(app, {
|
||||
service,
|
||||
authRepository: {
|
||||
async validateSession() {
|
||||
return {
|
||||
id: sessionId, tenantId: '7', platformAppId: '9',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
user: {
|
||||
id: '21', tenantId: '7', userType: 'CUSTOMER', status: 'ACTIVE',
|
||||
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
accessControl: { async getAccessProfile() { return currentAccess; } },
|
||||
jwtSecret: secret,
|
||||
testAdapterEnabled: true
|
||||
});
|
||||
|
||||
const unauthorized = await app.inject({ method: 'GET', url: '/app-api/product-orders' });
|
||||
assert.equal(unauthorized.statusCode, 401);
|
||||
assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID');
|
||||
|
||||
const invalidDelivery = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-orders', headers,
|
||||
payload: {
|
||||
storeId: '11', requestId: 'create-invalid', fulfillmentMode: 'DELIVERY',
|
||||
note: '', items: [{ skuId: '5', quantity: 1, note: '' }]
|
||||
}
|
||||
});
|
||||
assert.equal(invalidDelivery.statusCode, 400);
|
||||
assert.equal(invalidDelivery.json().code, 'PRODUCT_ORDER_INPUT_INVALID');
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-orders', headers,
|
||||
payload: {
|
||||
storeId: '11', requestId: 'create-order-1', fulfillmentMode: 'SELF_SERVICE',
|
||||
note: '少冰', items: [{ skuId: '5', quantity: 2, note: '' }]
|
||||
}
|
||||
});
|
||||
assert.equal(created.statusCode, 201);
|
||||
assert.equal(created.json().data.id, '101');
|
||||
const createCall = calls.find((call) => call.method === 'create');
|
||||
assert.equal(createCall.args[0].tenantId, '7');
|
||||
assert.equal(createCall.args[0].userId, '21');
|
||||
assert.equal(createCall.args[0].source, 'CUSTOMER');
|
||||
assert.equal(createCall.args[0].traceId, 'm09d2-product-order-route');
|
||||
|
||||
const listed = await app.inject({
|
||||
method: 'GET', url: '/app-api/product-orders?storeId=11&page=2&pageSize=10', headers
|
||||
});
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.equal(listed.json().data.page, 2);
|
||||
|
||||
const missing = await app.inject({
|
||||
method: 'GET', url: '/app-api/product-orders/999', headers
|
||||
});
|
||||
assert.equal(missing.statusCode, 404);
|
||||
assert.equal(missing.json().code, 'PRODUCT_ORDER_NOT_FOUND');
|
||||
|
||||
const payment = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-orders/101/payments', headers,
|
||||
payload: { requestId: 'payment-create-1', provider: 'TEST' }
|
||||
});
|
||||
assert.equal(payment.statusCode, 201);
|
||||
assert.equal(payment.json().data.id, '401');
|
||||
|
||||
const paid = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-payments/401/test-complete', headers,
|
||||
payload: { callbackId: 'payment-callback-1', amountCents: 500 }
|
||||
});
|
||||
assert.equal(paid.statusCode, 200);
|
||||
assert.equal(paid.json().data.status, 'SUCCEEDED');
|
||||
|
||||
const managementForbidden = await app.inject({
|
||||
method: 'GET', url: '/admin-api/product-orders?storeId=11', headers
|
||||
});
|
||||
assert.equal(managementForbidden.statusCode, 403);
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['goods.order.read'], storeIds: ['11']
|
||||
};
|
||||
const managementList = await app.inject({
|
||||
method: 'GET', url: '/app-api/management/product-orders?storeId=11', headers
|
||||
});
|
||||
assert.equal(managementList.statusCode, 200);
|
||||
const managementCall = calls.find((call) => call.method === 'listForManagement');
|
||||
assert.equal(managementCall.args[0].source, 'MANAGEMENT');
|
||||
|
||||
const readOnlyAction = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-orders/101/actions', headers,
|
||||
payload: { requestId: 'accept-order-1', action: 'ACCEPT', reason: '' }
|
||||
});
|
||||
assert.equal(readOnlyAction.statusCode, 403);
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['goods.order.read', 'goods.order.manage'], storeIds: ['11']
|
||||
};
|
||||
const accepted = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-orders/101/actions', headers,
|
||||
payload: { requestId: 'accept-order-1', action: 'ACCEPT', reason: '' }
|
||||
});
|
||||
assert.equal(accepted.statusCode, 200);
|
||||
assert.equal(accepted.json().data.status, 'ACCEPTED');
|
||||
|
||||
const refundCompleted = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-refunds/501/test-complete', headers,
|
||||
payload: { callbackId: 'refund-callback-1', amountCents: 500 }
|
||||
});
|
||||
assert.equal(refundCompleted.statusCode, 200);
|
||||
assert.equal(refundCompleted.json().data.status, 'SUCCEEDED');
|
||||
|
||||
await app.close();
|
||||
console.log('PASS: M09-D2 product order routes enforce customer ownership, management permissions and test payment boundaries.');
|
||||
@@ -0,0 +1,279 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ProductOrderError,
|
||||
ProductOrderService,
|
||||
productOrderFingerprint,
|
||||
resolveProductOrderTransition
|
||||
} from '../dist/products/product-order-service.js';
|
||||
|
||||
const fixedNow = new Date('2026-08-11T04:00:00.000Z');
|
||||
const actor = {
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
access: { roles: ['CUSTOMER'], capabilities: [], storeIds: [] },
|
||||
source: 'CUSTOMER',
|
||||
traceId: 'm09d2-product-order-test',
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'product-order-test'
|
||||
};
|
||||
const createInput = {
|
||||
storeId: '11',
|
||||
requestId: 'create-order-1',
|
||||
fulfillmentMode: 'SELF_SERVICE',
|
||||
roomOrderId: null,
|
||||
note: '少冰',
|
||||
items: [{ skuId: '5', quantity: 2, note: '分开放' }]
|
||||
};
|
||||
const fingerprint = productOrderFingerprint(createInput);
|
||||
|
||||
assert.equal(
|
||||
productOrderFingerprint({
|
||||
...createInput,
|
||||
items: [
|
||||
{ skuId: '6', quantity: 1, note: '' },
|
||||
{ skuId: '5', quantity: 2, note: '分开放' }
|
||||
]
|
||||
}),
|
||||
productOrderFingerprint({
|
||||
...createInput,
|
||||
items: [
|
||||
{ skuId: '5', quantity: 2, note: '分开放' },
|
||||
{ skuId: '6', quantity: 1, note: '' }
|
||||
]
|
||||
}),
|
||||
'semantic item ordering must not change the create fingerprint'
|
||||
);
|
||||
assert.notEqual(
|
||||
fingerprint,
|
||||
productOrderFingerprint({ ...createInput, note: '常温' }),
|
||||
'meaningful input changes must change the create fingerprint'
|
||||
);
|
||||
|
||||
assert.equal(resolveProductOrderTransition('PAID', 'DELIVERY', 'ACCEPT'), 'ACCEPTED');
|
||||
assert.equal(
|
||||
resolveProductOrderTransition('ACCEPTED', 'DELIVERY', 'START_DELIVERY'),
|
||||
'DELIVERING'
|
||||
);
|
||||
assert.equal(
|
||||
resolveProductOrderTransition('ACCEPTED', 'SELF_SERVICE', 'MARK_READY'),
|
||||
'READY_FOR_SELF_SERVICE'
|
||||
);
|
||||
assert.equal(resolveProductOrderTransition('DELIVERING', 'DELIVERY', 'COMPLETE'), 'COMPLETED');
|
||||
assert.throws(
|
||||
() => resolveProductOrderTransition('ACCEPTED', 'SELF_SERVICE', 'START_DELIVERY'),
|
||||
(error) => error instanceof ProductOrderError
|
||||
&& error.code === 'PRODUCT_ORDER_TRANSITION_NOT_ALLOWED'
|
||||
);
|
||||
|
||||
class ScriptedConnection {
|
||||
constructor(steps) {
|
||||
this.steps = [...steps];
|
||||
this.calls = [];
|
||||
this.committed = false;
|
||||
this.rolledBack = false;
|
||||
this.released = false;
|
||||
}
|
||||
|
||||
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);
|
||||
return step.result;
|
||||
}
|
||||
}
|
||||
|
||||
class ScriptedPool {
|
||||
constructor(directSteps, connections) {
|
||||
this.directSteps = [...directSteps];
|
||||
this.connections = [...connections];
|
||||
}
|
||||
|
||||
async execute(sql, params = []) {
|
||||
const step = this.directSteps.shift();
|
||||
assert.ok(step, `Unexpected direct SQL: ${sql}`);
|
||||
assert.match(sql, step.match);
|
||||
if (step.check) step.check(params, sql);
|
||||
return step.result;
|
||||
}
|
||||
|
||||
async getConnection() {
|
||||
const connection = this.connections.shift();
|
||||
assert.ok(connection, 'Unexpected transaction');
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
|
||||
const baseOrder = {
|
||||
id: '101', tenantId: '7', storeId: '11', memberId: '21', roomOrderId: null,
|
||||
orderNo: 'PG202608110001', clientRequestId: createInput.requestId,
|
||||
requestFingerprint: fingerprint, inventoryBusinessId: 'PRODUCT_ORDER:PG202608110001',
|
||||
inventoryRequestId: 'po.lock.abc', fulfillmentMode: 'SELF_SERVICE',
|
||||
status: 'PENDING_PAYMENT', itemCount: 1, totalQuantity: 2,
|
||||
totalAmountCents: 500, paidAmountCents: 0, refundedAmountCents: 0,
|
||||
orderNote: '少冰', inventoryStatus: 'LOCKED',
|
||||
expiresAt: new Date('2026-08-11T04:15:00.000Z'),
|
||||
paidAt: null, acceptedAt: null, deliveringAt: null, completedAt: null,
|
||||
cancelledAt: null, createdSource: 'MEMBER', createdBy: null,
|
||||
version: 1, createdAt: fixedNow, updatedAt: fixedNow
|
||||
};
|
||||
const itemView = [{
|
||||
id: '201', lineNo: 1, productId: '3', skuId: '5', productCode: 'TEA',
|
||||
productName: '茶饮', skuCode: 'TEA-L', skuName: '大杯', unitName: '杯',
|
||||
attributes: null, unitPriceCents: 250, quantity: 2, subtotalCents: 500,
|
||||
note: '分开放'
|
||||
}];
|
||||
const detailSteps = (order = baseOrder) => [
|
||||
{ match: /FROM qipai_product_orders o[\s\S]*o\.member_id = \?/, result: [[order], []] },
|
||||
{ match: /FROM qipai_product_order_items[\s\S]*ORDER BY line_no/, result: [itemView, []] },
|
||||
{ match: /FROM qipai_product_payments[\s\S]*ORDER BY id DESC/, result: [[], []] },
|
||||
{ match: /FROM qipai_product_refunds[\s\S]*ORDER BY id DESC/, result: [[], []] },
|
||||
{ match: /FROM qipai_product_order_events[\s\S]*ORDER BY version_after/, result: [[{
|
||||
id: '301', fromStatus: null, toStatus: order.status, action: 'CREATE',
|
||||
actorType: 'MEMBER', actorId: null, versionAfter: order.version,
|
||||
reason: '', createdAt: fixedNow
|
||||
}], []] }
|
||||
];
|
||||
|
||||
const createConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_product_orders o[\s\S]*client_request_id = \?[\s\S]*FOR UPDATE/, result: [[], []] },
|
||||
{ match: /FROM qipai_stores s[\s\S]*FOR SHARE/, result: [[{
|
||||
id: null, timezone: 'Asia/Shanghai', businessStatus: 'OPEN', salesStatus: null,
|
||||
manualPausedAt: null, manualPausedUntil: null
|
||||
}], []] },
|
||||
{ match: /FROM qipai_product_skus s[\s\S]*FOR SHARE/, result: [[{
|
||||
skuId: '5', productId: '3', skuCode: 'TEA-L', skuName: '大杯',
|
||||
attributesSnapshot: null, unitPriceCents: 250, productCode: 'TEA',
|
||||
productName: '茶饮', unitName: '杯', deliveryEnabled: 1,
|
||||
fulfillmentMode: 'BOTH', salesStartAt: null, salesEndAt: null
|
||||
}], []] },
|
||||
{
|
||||
match: /INSERT INTO qipai_product_orders/,
|
||||
result: [{ insertId: 101, affectedRows: 1 }, []],
|
||||
check(params) {
|
||||
assert.equal(params[0], '7');
|
||||
assert.equal(params[1], '11');
|
||||
assert.equal(params[2], '21');
|
||||
assert.equal(params[5], createInput.requestId);
|
||||
assert.equal(params[6], fingerprint);
|
||||
assert.equal(params[12], 500);
|
||||
}
|
||||
},
|
||||
{ match: /INSERT INTO qipai_product_order_items/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_product_order_events/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] },
|
||||
...detailSteps()
|
||||
]);
|
||||
const createPool = new ScriptedPool([
|
||||
{ match: /FROM qipai_product_orders o[\s\S]*client_request_id = \?/, result: [[], []] }
|
||||
], [createConnection]);
|
||||
let lockCall;
|
||||
const created = await new ProductOrderService(createPool, {
|
||||
async lockMany(input, connection) {
|
||||
lockCall = { input, connection };
|
||||
return { idempotent: false };
|
||||
},
|
||||
async releaseMany() { throw new Error('not expected'); },
|
||||
async deductMany() { throw new Error('not expected'); },
|
||||
async returnMany() { throw new Error('not expected'); }
|
||||
}, { now: () => fixedNow }).create(actor, createInput);
|
||||
assert.equal(created.id, '101');
|
||||
assert.equal(created.totalAmountCents, 500);
|
||||
assert.equal(created.items[0].skuId, '5');
|
||||
assert.equal(lockCall.input.businessType, 'PRODUCT_ORDER');
|
||||
assert.deepEqual(lockCall.input.items, [{ skuId: '5', quantity: 2 }]);
|
||||
assert.equal(lockCall.connection, createConnection);
|
||||
assert.equal(createConnection.committed, true);
|
||||
assert.equal(createConnection.rolledBack, false);
|
||||
assert.equal(createConnection.released, true);
|
||||
assert.equal(createConnection.steps.length, 0);
|
||||
|
||||
const noSqlPool = new ScriptedPool([], []);
|
||||
await assert.rejects(
|
||||
() => new ProductOrderService(noSqlPool, {}).create(actor, {
|
||||
...createInput,
|
||||
items: [
|
||||
{ skuId: '5', quantity: 1, note: '' },
|
||||
{ skuId: '5', quantity: 1, note: 'duplicate' }
|
||||
]
|
||||
}),
|
||||
(error) => error instanceof ProductOrderError && error.code === 'PRODUCT_ORDER_SKU_DUPLICATE'
|
||||
);
|
||||
|
||||
const pendingPayment = {
|
||||
id: '401', tenantId: '7', storeId: '11', orderId: '101',
|
||||
paymentNo: 'PP202608110001', clientRequestId: 'payment-create-1',
|
||||
requestFingerprint: 'a'.repeat(64), provider: 'TEST', channel: 'TEST',
|
||||
status: 'PENDING', amountCents: 500, refundedAmountCents: 0,
|
||||
expiresAt: baseOrder.expiresAt, paidAt: null, version: 1
|
||||
};
|
||||
const paymentConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_product_payment_callbacks[\s\S]*FOR UPDATE/, result: [[], []] },
|
||||
{ match: /FROM qipai_product_payments WHERE[\s\S]*FOR UPDATE/, result: [[pendingPayment], []] },
|
||||
{ match: /FROM qipai_product_orders o[\s\S]*FOR UPDATE/, result: [[baseOrder], []] },
|
||||
{ match: /INSERT INTO qipai_product_payment_callbacks/, result: [{ insertId: 501, affectedRows: 1 }, []] },
|
||||
{ match: /FROM qipai_product_order_items[\s\S]*GROUP BY sku_id/, result: [[{ skuId: '5', quantity: 2 }], []] },
|
||||
{ match: /UPDATE qipai_product_payments[\s\S]*SUCCEEDED/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /UPDATE qipai_product_orders[\s\S]*status = 'PAID'/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_product_order_events/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /UPDATE qipai_product_payment_callbacks/, result: [{ affectedRows: 1 }, []] }
|
||||
]);
|
||||
const paymentPool = new ScriptedPool([{
|
||||
match: /o\.member_id AS memberId[\s\S]*FROM qipai_product_payments p/,
|
||||
result: [[{ ...pendingPayment, memberId: '21' }], []]
|
||||
}], [paymentConnection]);
|
||||
let deductCall;
|
||||
const paymentResult = await new ProductOrderService(paymentPool, {
|
||||
async lockMany() { throw new Error('not expected'); },
|
||||
async releaseMany() { throw new Error('not expected'); },
|
||||
async deductMany(input, connection) { deductCall = { input, connection }; },
|
||||
async returnMany() { throw new Error('not expected'); }
|
||||
}, { now: () => fixedNow }).completeTestPaymentForCustomer(actor, '401', {
|
||||
callbackId: 'test-payment-callback-1', amountCents: 500
|
||||
});
|
||||
assert.deepEqual(paymentResult, {
|
||||
paymentId: '401', orderId: '101', status: 'SUCCEEDED', idempotent: false
|
||||
});
|
||||
assert.deepEqual(deductCall.input.items, [{ skuId: '5', quantity: 2 }]);
|
||||
assert.equal(deductCall.input.businessId, baseOrder.inventoryBusinessId);
|
||||
assert.equal(deductCall.connection, paymentConnection);
|
||||
assert.equal(paymentConnection.committed, true);
|
||||
assert.equal(paymentConnection.steps.length, 0);
|
||||
|
||||
const cancelledOrder = {
|
||||
...baseOrder, status: 'CANCELLED', inventoryStatus: 'RELEASED',
|
||||
cancelledAt: fixedNow, version: 2
|
||||
};
|
||||
const cancelConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_product_order_events[\s\S]*request_id = \?/, result: [[], []] },
|
||||
{ match: /FROM qipai_product_orders o[\s\S]*o\.member_id = \?[\s\S]*FOR UPDATE/, result: [[baseOrder], []] },
|
||||
{ match: /FROM qipai_product_order_items[\s\S]*GROUP BY sku_id/, result: [[{ skuId: '5', quantity: 2 }], []] },
|
||||
{ match: /UPDATE qipai_product_orders[\s\S]*CANCELLED/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /UPDATE qipai_product_payments[\s\S]*CLOSED/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_product_order_events/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] },
|
||||
...detailSteps(cancelledOrder)
|
||||
]);
|
||||
let releaseCall;
|
||||
const cancelled = await new ProductOrderService(new ScriptedPool([], [cancelConnection]), {
|
||||
async lockMany() { throw new Error('not expected'); },
|
||||
async releaseMany(input, connection) { releaseCall = { input, connection }; },
|
||||
async deductMany() { throw new Error('not expected'); },
|
||||
async returnMany() { throw new Error('not expected'); }
|
||||
}, { now: () => fixedNow }).cancelForCustomer(actor, '101', {
|
||||
requestId: 'cancel-order-1', reason: '顾客取消'
|
||||
});
|
||||
assert.equal(cancelled.status, 'CANCELLED');
|
||||
assert.equal(cancelled.inventoryStatus, 'RELEASED');
|
||||
assert.deepEqual(releaseCall.input.items, [{ skuId: '5', quantity: 2 }]);
|
||||
assert.equal(releaseCall.connection, cancelConnection);
|
||||
assert.equal(cancelConnection.committed, true);
|
||||
assert.equal(cancelConnection.steps.length, 0);
|
||||
|
||||
console.log('PASS: M09-D2 product order snapshot, transition, payment deduct and cancellation release service contracts work.');
|
||||
Reference in New Issue
Block a user