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