feat(M09-D2): 完成商品订单与库存占用生命周期
This commit is contained in:
@@ -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