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