feat(M09-D2): 完成商品订单与库存占用生命周期

This commit is contained in:
Codex
2026-08-11 07:04:23 +08:00
parent 93af128002
commit 1a3ea7bdf0
30 changed files with 3773 additions and 91 deletions
+66 -1
View File
@@ -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.');
+74 -1
View File
@@ -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.');
+13 -3
View File
@@ -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.');
+174
View File
@@ -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.');