283 lines
12 KiB
JavaScript
283 lines
12 KiB
JavaScript
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 IGNORE INTO qipai_outbox_events/, result: [{ insertId: 701, 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: /INSERT IGNORE INTO qipai_outbox_events/, result: [{ insertId: 702, 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 IGNORE INTO qipai_outbox_events/, result: [{ insertId: 703, 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.');
|