修复订单重复推送走兑换逻辑bug, 修复任务关闭失败bug
This commit is contained in:
@@ -116,6 +116,41 @@ export async function findLatestSuccessfulMessageDeliveryByTask(taskId, channel)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function findLatestSuccessfulMessageDelivery({
|
||||
provider = '',
|
||||
platform = '',
|
||||
shopId = '',
|
||||
platformOrderId = '',
|
||||
channel = '',
|
||||
claimUrl = '',
|
||||
} = {}) {
|
||||
const result = await query(
|
||||
`
|
||||
SELECT *
|
||||
FROM message_deliveries
|
||||
WHERE provider = $1
|
||||
AND platform = $2
|
||||
AND shop_id = $3
|
||||
AND platform_order_id = $4
|
||||
AND channel = $5
|
||||
AND claim_url = $6
|
||||
AND status = 'success'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
platformOrderId,
|
||||
channel,
|
||||
claimUrl,
|
||||
],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function listMessageDeliveries({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
|
||||
@@ -1,13 +1,173 @@
|
||||
// @ts-check
|
||||
|
||||
import { query } from '../db/client.js'
|
||||
import { query, withTransaction } from '../db/client.js'
|
||||
|
||||
/** @typedef {import('../types/repository-inputs.js').OrderItemReplaceInput} OrderItemReplaceInput */
|
||||
/** @typedef {import('../types/repository-rows.js').OrderItemRow} OrderItemRow */
|
||||
|
||||
/** @returns {Promise<OrderItemRow[]>} */
|
||||
export async function listOrderItemsByOrderId(orderId) {
|
||||
const result = await query(
|
||||
return listOrderItemsByOrderIdWithExecutor(query, orderId)
|
||||
}
|
||||
|
||||
/** @returns {Promise<OrderItemRow[]>} */
|
||||
/** @param {OrderItemReplaceInput[]} items */
|
||||
export async function replaceOrderItems(orderId, items) {
|
||||
return withTransaction(async (client) => {
|
||||
const executor = client.query.bind(client)
|
||||
const existingItems = await listOrderItemsByOrderIdWithExecutor(executor, orderId)
|
||||
const plan = resolveOrderItemSyncPlan(existingItems, items)
|
||||
|
||||
for (const update of plan.updates) {
|
||||
await executor(
|
||||
`
|
||||
UPDATE order_items
|
||||
SET
|
||||
sku_code = $1,
|
||||
sku_name = $2,
|
||||
quantity = $3,
|
||||
spec_json = $4::jsonb,
|
||||
item_snapshot_json = $5::jsonb,
|
||||
updated_at = $6
|
||||
WHERE id = $7
|
||||
`,
|
||||
[
|
||||
update.item.skuCode,
|
||||
update.item.skuName,
|
||||
update.item.quantity,
|
||||
update.item.specJson || '{}',
|
||||
update.item.itemSnapshotJson || update.item.specJson || '{}',
|
||||
update.item.updatedAt,
|
||||
update.orderItemId,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
for (const create of plan.creates) {
|
||||
await executor(
|
||||
`
|
||||
INSERT INTO order_items (
|
||||
order_id,
|
||||
sku_code,
|
||||
sku_name,
|
||||
quantity,
|
||||
spec_json,
|
||||
item_snapshot_json,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8)
|
||||
`,
|
||||
[
|
||||
Number(orderId),
|
||||
create.skuCode,
|
||||
create.skuName,
|
||||
create.quantity,
|
||||
create.specJson || '{}',
|
||||
create.itemSnapshotJson || create.specJson || '{}',
|
||||
create.createdAt,
|
||||
create.updatedAt,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
if (plan.deletes.length > 0) {
|
||||
const deletableIds = await listDeletableOrderItemIdsWithExecutor(executor, plan.deletes)
|
||||
|
||||
if (deletableIds.length > 0) {
|
||||
await executor(
|
||||
'DELETE FROM order_items WHERE id = ANY($1::bigint[])',
|
||||
[deletableIds],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return listOrderItemsByOrderIdWithExecutor(executor, orderId)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {OrderItemRow[]} existingItems
|
||||
* @param {OrderItemReplaceInput[]} nextItems
|
||||
*/
|
||||
export function resolveOrderItemSyncPlan(existingItems, nextItems) {
|
||||
const normalizedExisting = Array.isArray(existingItems) ? existingItems : []
|
||||
const normalizedNext = Array.isArray(nextItems) ? nextItems : []
|
||||
const unmatchedExisting = [...normalizedExisting]
|
||||
const updates = []
|
||||
const creates = []
|
||||
|
||||
for (const item of normalizedNext) {
|
||||
const matchedIndex = findMatchingExistingOrderItemIndex(unmatchedExisting, item)
|
||||
|
||||
if (matchedIndex >= 0) {
|
||||
const matched = unmatchedExisting.splice(matchedIndex, 1)[0]
|
||||
updates.push({
|
||||
orderItemId: matched.id,
|
||||
item,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (unmatchedExisting.length > 0) {
|
||||
const matched = unmatchedExisting.shift()
|
||||
updates.push({
|
||||
orderItemId: matched.id,
|
||||
item,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
creates.push(item)
|
||||
}
|
||||
|
||||
return {
|
||||
updates,
|
||||
creates,
|
||||
deletes: unmatchedExisting.map((item) => Number(item.id)).filter((value) => value > 0),
|
||||
}
|
||||
}
|
||||
|
||||
function findMatchingExistingOrderItemIndex(existingItems, nextItem) {
|
||||
const nextIdentity = buildOrderItemIdentity(nextItem)
|
||||
|
||||
return existingItems.findIndex((item) => buildOrderItemIdentity(item) === nextIdentity)
|
||||
}
|
||||
|
||||
function buildOrderItemIdentity(item) {
|
||||
return [
|
||||
String(item?.skuCode ?? item?.sku_code ?? '').trim(),
|
||||
String(item?.skuName ?? item?.sku_name ?? '').trim(),
|
||||
String(Math.max(1, Number(item?.quantity || 1))),
|
||||
].join('::')
|
||||
}
|
||||
|
||||
async function listDeletableOrderItemIdsWithExecutor(executor, orderItemIds) {
|
||||
const normalizedIds = Array.isArray(orderItemIds)
|
||||
? orderItemIds.map((value) => Number(value)).filter((value) => value > 0)
|
||||
: []
|
||||
|
||||
if (normalizedIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result = await executor(
|
||||
`
|
||||
SELECT oi.id
|
||||
FROM order_items oi
|
||||
LEFT JOIN fulfillment_tasks ft ON ft.order_item_id = oi.id
|
||||
WHERE oi.id = ANY($1::bigint[])
|
||||
GROUP BY oi.id
|
||||
HAVING COUNT(ft.id) = 0
|
||||
ORDER BY oi.id ASC
|
||||
`,
|
||||
[normalizedIds],
|
||||
)
|
||||
|
||||
return result.rows.map((row) => Number(row.id)).filter((value) => value > 0)
|
||||
}
|
||||
|
||||
async function listOrderItemsByOrderIdWithExecutor(executor, orderId) {
|
||||
const result = await executor(
|
||||
`
|
||||
SELECT *
|
||||
FROM order_items
|
||||
@@ -20,41 +180,6 @@ export async function listOrderItemsByOrderId(orderId) {
|
||||
return /** @type {OrderItemRow[]} */ (result.rows)
|
||||
}
|
||||
|
||||
/** @returns {Promise<OrderItemRow[]>} */
|
||||
/** @param {OrderItemReplaceInput[]} items */
|
||||
export async function replaceOrderItems(orderId, items) {
|
||||
await query('DELETE FROM order_items WHERE order_id = $1', [Number(orderId)])
|
||||
|
||||
for (const item of items) {
|
||||
await query(
|
||||
`
|
||||
INSERT INTO order_items (
|
||||
order_id,
|
||||
sku_code,
|
||||
sku_name,
|
||||
quantity,
|
||||
spec_json,
|
||||
item_snapshot_json,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8)
|
||||
`,
|
||||
[
|
||||
Number(orderId),
|
||||
item.skuCode,
|
||||
item.skuName,
|
||||
item.quantity,
|
||||
item.specJson || '{}',
|
||||
item.itemSnapshotJson || item.specJson || '{}',
|
||||
item.createdAt,
|
||||
item.updatedAt,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
return listOrderItemsByOrderId(orderId)
|
||||
}
|
||||
|
||||
/** @returns {Promise<OrderItemRow | null>} */
|
||||
export async function getOrderItemById(orderItemId) {
|
||||
const result = await query('SELECT * FROM order_items WHERE id = $1 LIMIT 1', [Number(orderItemId)])
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { resolveOrderItemSyncPlan } from './order-item-repo.js'
|
||||
|
||||
test('resolveOrderItemSyncPlan preserves matching order item ids across repeated webhook syncs', () => {
|
||||
const existingItems = [
|
||||
{
|
||||
id: 15,
|
||||
sku_code: 'sku-a',
|
||||
sku_name: '礼包 A',
|
||||
quantity: 1,
|
||||
},
|
||||
]
|
||||
const nextItems = [
|
||||
{
|
||||
skuCode: 'sku-a',
|
||||
skuName: '礼包 A',
|
||||
quantity: 1,
|
||||
specJson: '{}',
|
||||
itemSnapshotJson: '{}',
|
||||
createdAt: '2026-04-14T12:00:00.000Z',
|
||||
updatedAt: '2026-04-14T12:00:00.000Z',
|
||||
},
|
||||
]
|
||||
|
||||
const plan = resolveOrderItemSyncPlan(existingItems, nextItems)
|
||||
|
||||
assert.deepEqual(plan.updates, [
|
||||
{
|
||||
orderItemId: 15,
|
||||
item: nextItems[0],
|
||||
},
|
||||
])
|
||||
assert.deepEqual(plan.creates, [])
|
||||
assert.deepEqual(plan.deletes, [])
|
||||
})
|
||||
|
||||
test('resolveOrderItemSyncPlan matches by identity before falling back to position', () => {
|
||||
const existingItems = [
|
||||
{ id: 21, sku_code: 'sku-a', sku_name: '礼包 A', quantity: 1 },
|
||||
{ id: 22, sku_code: 'sku-b', sku_name: '礼包 B', quantity: 1 },
|
||||
]
|
||||
const nextItems = [
|
||||
{
|
||||
skuCode: 'sku-b',
|
||||
skuName: '礼包 B',
|
||||
quantity: 1,
|
||||
specJson: '{}',
|
||||
itemSnapshotJson: '{}',
|
||||
createdAt: '2026-04-14T12:00:00.000Z',
|
||||
updatedAt: '2026-04-14T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
skuCode: 'sku-a',
|
||||
skuName: '礼包 A',
|
||||
quantity: 1,
|
||||
specJson: '{}',
|
||||
itemSnapshotJson: '{}',
|
||||
createdAt: '2026-04-14T12:00:00.000Z',
|
||||
updatedAt: '2026-04-14T12:00:00.000Z',
|
||||
},
|
||||
]
|
||||
|
||||
const plan = resolveOrderItemSyncPlan(existingItems, nextItems)
|
||||
|
||||
assert.deepEqual(plan.updates.map((item) => item.orderItemId), [22, 21])
|
||||
assert.deepEqual(plan.creates, [])
|
||||
assert.deepEqual(plan.deletes, [])
|
||||
})
|
||||
|
||||
test('resolveOrderItemSyncPlan keeps surplus existing ids for conditional cleanup instead of recreating everything', () => {
|
||||
const existingItems = [
|
||||
{ id: 31, sku_code: 'sku-a', sku_name: '礼包 A', quantity: 1 },
|
||||
{ id: 32, sku_code: 'sku-b', sku_name: '礼包 B', quantity: 1 },
|
||||
]
|
||||
const nextItems = [
|
||||
{
|
||||
skuCode: 'sku-a',
|
||||
skuName: '礼包 A',
|
||||
quantity: 1,
|
||||
specJson: '{}',
|
||||
itemSnapshotJson: '{}',
|
||||
createdAt: '2026-04-14T12:00:00.000Z',
|
||||
updatedAt: '2026-04-14T12:00:00.000Z',
|
||||
},
|
||||
]
|
||||
|
||||
const plan = resolveOrderItemSyncPlan(existingItems, nextItems)
|
||||
|
||||
assert.deepEqual(plan.updates.map((item) => item.orderItemId), [31])
|
||||
assert.deepEqual(plan.creates, [])
|
||||
assert.deepEqual(plan.deletes, [32])
|
||||
})
|
||||
Reference in New Issue
Block a user