diff --git a/apps/backend/src/repositories/order-repo.test.ts b/apps/backend/src/repositories/order-repo.test.ts index a11700c9..043593ee 100644 --- a/apps/backend/src/repositories/order-repo.test.ts +++ b/apps/backend/src/repositories/order-repo.test.ts @@ -15,8 +15,8 @@ test('订单列表综合筛选下推到总数和分页查询', async () => { executorKey: 'affiliate_dash', payStatus: 'paid', taskStatus: 'manual_review', - dateFrom: '2026-09-01T00:00:00.000Z', - dateTo: '2026-09-02T23:59:59.999Z', + dateFrom: '2026-09-01', + dateTo: '2026-09-02', }) assert.equal(result.total, 3) @@ -24,25 +24,37 @@ test('订单列表综合筛选下推到总数和分页查询', async () => { assert.match(calls[0]?.sql || '', /o\.platform_order_id.*ILIKE \$1/s) assert.match(calls[0]?.sql || '', /oi\.sku_code.*oi\.sku_name.*ILIKE \$1/s) assert.match(calls[0]?.sql || '', /kuaishouSendCode'.*itemTitle.*kuaishouSendCode'.*skuNick/s) - assert.match(calls[0]?.sql || '', /o\.provider = \$2/) - assert.match(calls[0]?.sql || '', /o\.pay_status = \$3/) - assert.match(calls[0]?.sql || '', /ft\.task_status = \$4/) - assert.match(calls[0]?.sql || '', /ft\.executor_key = \$5/) - assert.match(calls[0]?.sql || '', /o\.created_at >= \$6/) - assert.match(calls[0]?.sql || '', /o\.created_at <= \$7/) - assert.deepEqual(calls[0]?.params, [ - '%皮肤店%', - '91kaquan', - 'paid', - 'manual_review', - 'affiliate_dash', - '2026-09-01T00:00:00.000Z', - '2026-09-02T23:59:59.999Z', - ]) - assert.match(calls[1]?.sql || '', /LIMIT \$8 OFFSET \$9/) + assert.match(calls[0]?.sql || '', /o\.provider IN \('91kaquan', 'open_91', '91'\)/) + assert.match(calls[0]?.sql || '', /o\.pay_status = \$2/) + assert.match(calls[0]?.sql || '', /ft\.task_status = \$3/) + assert.match(calls[0]?.sql || '', /ft\.executor_key = \$4/) + // 有关键词时不应用日期过滤 + assert.doesNotMatch(calls[0]?.sql || '', /paid_at/i) + assert.deepEqual(calls[0]?.params, ['%皮肤店%', 'paid', 'manual_review', 'affiliate_dash']) + assert.match(calls[1]?.sql || '', /LIMIT \$5 OFFSET \$6/) assert.deepEqual(calls[1]?.params, [...(calls[0]?.params || []), 10, 10]) }) +test('订单列表无关键词时按支付时间与中国时区日期筛选', async () => { + const calls: Array<{ sql: string; params: unknown[] }> = [] + + await listOrdersWithExecutor(createExecutor(calls, 0), { + dateFrom: '2026-09-01', + dateTo: '2026-09-07', + }) + + assert.match( + calls[0]?.sql || '', + /COALESCE\(o\.paid_at, o\.created_at\) >= \(\$1::date\)::timestamp AT TIME ZONE 'Asia\/Shanghai'/, + ) + assert.match( + calls[0]?.sql || '', + /COALESCE\(o\.paid_at, o\.created_at\) < \(\(\$2::date \+ 1\)::timestamp AT TIME ZONE 'Asia\/Shanghai'\)/, + ) + assert.deepEqual(calls[0]?.params, ['2026-09-01', '2026-09-07']) + assert.match(calls[1]?.sql || '', /ORDER BY COALESCE\(o\.paid_at, o\.created_at\) DESC/) +}) + test('订单列表保留订单号和精确 SKU 旧筛选参数兼容', async () => { const calls: Array<{ sql: string; params: unknown[] }> = [] diff --git a/apps/backend/src/repositories/order-repo.ts b/apps/backend/src/repositories/order-repo.ts index 6d7b1e5e..3d452be8 100644 --- a/apps/backend/src/repositories/order-repo.ts +++ b/apps/backend/src/repositories/order-repo.ts @@ -295,8 +295,7 @@ export async function listOrdersWithExecutor( } if (provider) { - params.push(provider) - filters.push(`o.provider = $${params.length}`) + appendProviderFilter(filters, params, provider) } if (platformOrderId) { @@ -339,14 +338,20 @@ export async function listOrdersWithExecutor( ) } - if (dateFrom) { - params.push(dateFrom) - filters.push(`o.created_at >= $${params.length}`) + // 有关键词时不限日期,避免默认近 N 天挡住搜单号/商品。 + const applyDateFilter = Boolean(dateFrom || dateTo) && !String(keyword || '').trim() + if (applyDateFilter && dateFrom) { + params.push(String(dateFrom).trim().slice(0, 10)) + filters.push( + `COALESCE(o.paid_at, o.created_at) >= ($${params.length}::date)::timestamp AT TIME ZONE 'Asia/Shanghai'`, + ) } - if (dateTo) { - params.push(dateTo) - filters.push(`o.created_at <= $${params.length}`) + if (applyDateFilter && dateTo) { + params.push(String(dateTo).trim().slice(0, 10)) + filters.push( + `COALESCE(o.paid_at, o.created_at) < (($${params.length}::date + 1)::timestamp AT TIME ZONE 'Asia/Shanghai')`, + ) } const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' @@ -362,7 +367,7 @@ export async function listOrdersWithExecutor( SELECT o.* FROM orders o ${whereClause} - ORDER BY o.id DESC + ORDER BY COALESCE(o.paid_at, o.created_at) DESC NULLS LAST, o.id DESC LIMIT $${params.length - 1} OFFSET $${params.length} `, params, @@ -373,3 +378,31 @@ export async function listOrdersWithExecutor( total: Number(totalResult.rows[0]?.total || 0), } } + +/** 来源渠道筛选:兼容历史 provider / platform 多种写法。 */ +function appendProviderFilter(filters: string[], params: unknown[], provider: string) { + const normalized = String(provider || '').trim().toLowerCase() + if (!normalized) { + return + } + + if (normalized === 'pdd') { + filters.push(`(o.provider = 'pdd' OR o.platform = 'pdd')`) + return + } + + if (normalized === '91' || normalized === '91kaquan' || normalized === 'open_91') { + filters.push( + `(o.provider IN ('91kaquan', 'open_91', '91') OR o.platform IN ('91kaquan', '91'))`, + ) + return + } + + if (normalized === 'kuaishou' || normalized === 'kuaishou-industry') { + filters.push(`(o.provider LIKE 'kuaishou%' OR o.platform LIKE 'kuaishou%')`) + return + } + + params.push(provider) + filters.push(`(o.provider = $${params.length} OR o.platform = $${params.length})`) +} diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 26bfd5a9..3369d383 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -24,6 +24,7 @@ import { createHttpError } from '../../utils/http.js' import { formatFenToAmount, normalizeFen } from '../../utils/money.js' import { normalizeDateQuery, + normalizeDateString, normalizePage, normalizePageSize, safeParseJson, @@ -94,8 +95,8 @@ export async function getAdminOrders( payStatus: String(query.payStatus || '').trim(), taskStatus: String(query.taskStatus || '').trim(), skuCode: String(query.skuCode || '').trim(), - dateFrom: normalizeDateQuery(query.dateFrom), - dateTo: normalizeDateQuery(query.dateTo, true), + dateFrom: normalizeDateString(query.dateFrom), + dateTo: normalizeDateString(query.dateTo), }) const orderIds = items.map((item) => Number(item.id)).filter((id) => id > 0) diff --git a/apps/backend/src/services/order/order-service.ts b/apps/backend/src/services/order/order-service.ts index d820a6ec..8fd2e986 100644 --- a/apps/backend/src/services/order/order-service.ts +++ b/apps/backend/src/services/order/order-service.ts @@ -156,7 +156,8 @@ export async function upsertOrderFromSource( : await createOrderWithExecutor(executor, { ...basePayload, ...mergedPayload, - createdAt: now, + // 优先用平台支付/成团时间,避免积压同步把历史单排成「刚刚入库」。 + createdAt: String(event.paidAt || now).trim() || now, updatedAt: now, }) diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 4a3fa4c0..a4b93d2f 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -53,6 +53,7 @@ import { } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' +import dayjs from '@/lib/dayjs' const SIDEBAR_COLLAPSED_KEY = 'react-admin-sidebar-collapsed' const NOTIFICATION_SOUND_KEY = 'admin-notification-sound-enabled' @@ -493,35 +494,82 @@ function patchAdminOrderCaches( queryClient: ReturnType, event: RealtimeEvent, ) { - const item = event.data as { orderId?: unknown; platformOrderId?: unknown; payStatus?: unknown } + const item = event.data as { + orderId?: unknown + platformOrderId?: unknown + payStatus?: unknown + paidAt?: unknown + createdAt?: unknown + provider?: unknown + shopName?: unknown + itemTitle?: unknown + itemSummary?: unknown + } for (const [queryKey, current] of queryClient.getQueriesData({ queryKey: ['admin-orders'] })) { const params = isRecord(queryKey[1]) ? queryKey[1] : {} const page = Number(params.page || 1) - const matches = - (!String(params.platformOrderId || '').trim() || - String(item.platformOrderId || '') === String(params.platformOrderId || '').trim()) && + const pageSize = Math.max(1, Number(params.pageSize || 20) || 20) + const keyword = String(params.keyword || '').trim() + const matchesFilters = + matchesAdminOrderKeyword(item, keyword) && + matchesAdminOrderProvider(item, String(params.provider || '').trim()) && (!String(params.payStatus || '').trim() || - String(item.payStatus || '') === String(params.payStatus || '').trim()) - const shouldInsert = event.operation === 'upsert' && page === 1 && matches && !params.skuCode - const operation = event.operation === 'remove' || !matches ? 'remove' : 'upsert' + String(item.payStatus || '') === String(params.payStatus || '').trim()) && + // 有关键词时不限日期;否则实时插入也要落在当前日期筛选内。 + (keyword ? true : matchesAdminOrderDateRange(item, params)) + const shouldInsert = + event.operation === 'upsert' && page === 1 && matchesFilters && !params.skuCode + const operation = event.operation === 'remove' || !matchesFilters ? 'remove' : 'upsert' queryClient.setQueryData( queryKey, - patchAdminOrderListEnvelope(current, { ...event, operation }, shouldInsert), + patchAdminOrderListEnvelope(current, { ...event, operation }, shouldInsert, pageSize), ) } } -function patchAdminOrderListEnvelope(current: unknown, event: RealtimeEvent, insert = false) { +function patchAdminOrderListEnvelope( + current: unknown, + event: RealtimeEvent, + insert = false, + pageSize = 20, +) { if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { return current } const id = Number((event.data as { orderId?: unknown }).orderId || event.entityId) const items = current.data.items as unknown[] - const nextItems = + const existed = items.some((entry) => Number((entry as { orderId?: unknown })?.orderId) === id) + let nextItems = event.operation === 'remove' ? items.filter((entry) => Number((entry as { orderId?: unknown })?.orderId) !== id) : upsertById(items, event.data, 'orderId', insert) - return { ...current, data: { ...current.data, items: nextItems } } + + if (insert && nextItems.length > pageSize) { + nextItems = nextItems.slice(0, pageSize) + } + + const pagination = isRecord(current.data.pagination) ? current.data.pagination : null + let nextPagination = pagination + if (pagination && insert && !existed) { + nextPagination = { + ...pagination, + total: Number(pagination.total || 0) + 1, + } + } else if (pagination && event.operation === 'remove' && existed) { + nextPagination = { + ...pagination, + total: Math.max(0, Number(pagination.total || 0) - 1), + } + } + + return { + ...current, + data: { + ...current.data, + items: nextItems, + ...(nextPagination ? { pagination: nextPagination } : {}), + }, + } } function patchWorkOrderListEnvelope(current: unknown, event: RealtimeEvent, insert = false) { @@ -560,6 +608,51 @@ function upsertById(items: unknown[], nextItem: unknown, idKey: string, insert = return items.map((item, itemIndex) => (itemIndex === index ? nextItem : item)) } +function matchesAdminOrderKeyword( + item: { + platformOrderId?: unknown + shopName?: unknown + itemTitle?: unknown + itemSummary?: unknown + }, + keyword: string, +) { + const normalizedKeyword = keyword.trim().toLowerCase() + if (!normalizedKeyword) return true + return [item.platformOrderId, item.shopName, item.itemTitle, item.itemSummary] + .map((value) => String(value || '').toLowerCase()) + .some((value) => value.includes(normalizedKeyword)) +} + +function matchesAdminOrderProvider(item: { provider?: unknown }, provider: string) { + const normalized = provider.trim().toLowerCase() + if (!normalized) return true + const value = String(item.provider || '').toLowerCase() + if (normalized === 'pdd') return value === 'pdd' + if (normalized === '91' || normalized === '91kaquan' || normalized === 'open_91') { + return ['91kaquan', 'open_91', '91'].includes(value) + } + if (normalized === 'kuaishou' || normalized === 'kuaishou-industry') { + return value.startsWith('kuaishou') + } + return value === normalized +} + +function matchesAdminOrderDateRange( + item: { paidAt?: unknown; createdAt?: unknown }, + params: Record, +) { + const dateFrom = String(params.dateFrom || '').trim().slice(0, 10) + const dateTo = String(params.dateTo || '').trim().slice(0, 10) + if (!dateFrom && !dateTo) return true + const raw = String(item.paidAt || item.createdAt || '').trim() + if (!raw) return false + const day = dayjs(raw).format('YYYY-MM-DD') + if (dateFrom && day < dateFrom) return false + if (dateTo && day > dateTo) return false + return true +} + function matchesWorkOrderKeyword( item: { productName?: unknown; platformOrderId?: unknown }, keyword: string, diff --git a/apps/frontend/src/pages/admin/AdminOrdersPage.tsx b/apps/frontend/src/pages/admin/AdminOrdersPage.tsx index 291b2f9f..f7fadfc1 100644 --- a/apps/frontend/src/pages/admin/AdminOrdersPage.tsx +++ b/apps/frontend/src/pages/admin/AdminOrdersPage.tsx @@ -13,6 +13,7 @@ import { fetchAdminOrders } from '@/services/admin' import type { AdminOrderListItem } from '@/types/admin' import { adminOrderExecutorOptions, + adminOrderProviderOptions, adminPayStatusOptions, adminTaskStatusOptions, } from '@/utils/admin-options' @@ -25,20 +26,46 @@ import { formatAdminDateTime } from '@/utils/admin-time' type OrderFilterForm = { keyword?: string + provider?: string executorKey?: string payStatus?: string taskStatus?: string dateRange?: unknown } +/** 默认展示近 7 天(含今天)。 */ +function resolveDefaultOrderDateRange() { + const today = dayjs() + return { + dateFrom: today.subtract(6, 'day').format('YYYY-MM-DD'), + dateTo: today.format('YYYY-MM-DD'), + } +} + export default function AdminOrdersPage() { const navigate = useNavigate() const [form] = Form.useForm() const [searchParams, setSearchParams] = useSearchParams() const page = Number(searchParams.get('page') || 1) || 1 const pageSize = resolveAdminPageSize(searchParams.get('pageSize'), ADMIN_DEFAULT_PAGE_SIZE) + + // 首次进入(仅有分页参数)时写入默认近 7 天,避免一把拉出全库。 + useEffect(() => { + const meaningfulKeys = [...searchParams.keys()].filter( + (key) => key !== 'page' && key !== 'pageSize', + ) + if (meaningfulKeys.length > 0) { + return + } + const { dateFrom, dateTo } = resolveDefaultOrderDateRange() + setSearchParams(cleanParams({ page: 1, pageSize, dateFrom, dateTo }), { replace: true }) + // 仅首屏引导默认日期,避免与后续筛选互相打架。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + const filters = { keyword: searchParams.get('keyword') || '', + provider: searchParams.get('provider') || '', executorKey: searchParams.get('executorKey') || '', payStatus: searchParams.get('payStatus') || '', taskStatus: searchParams.get('taskStatus') || '', @@ -54,20 +81,31 @@ export default function AdminOrdersPage() { filters.executorKey, filters.keyword, filters.payStatus, + filters.provider, filters.taskStatus, page, pageSize, ], ) + + // 等待默认日期写入 URL 后再请求,避免首屏无日期打出全库。 + const hasBootstrappedFilters = + Boolean(filters.dateFrom && filters.dateTo) || + Boolean(filters.keyword) || + searchParams.get('allTime') === '1' || + Boolean(filters.provider || filters.executorKey || filters.payStatus || filters.taskStatus) + const query = useQuery({ queryKey: ['admin-orders', queryParams], queryFn: () => fetchAdminOrders(queryParams), + enabled: hasBootstrappedFilters, }) const data = query.data?.data useEffect(() => { form.setFieldsValue({ keyword: filters.keyword || undefined, + provider: filters.provider || undefined, executorKey: filters.executorKey || undefined, payStatus: filters.payStatus || undefined, taskStatus: filters.taskStatus || undefined, @@ -82,6 +120,7 @@ export default function AdminOrdersPage() { filters.executorKey, filters.keyword, filters.payStatus, + filters.provider, filters.taskStatus, form, ]) @@ -185,14 +224,14 @@ export default function AdminOrdersPage() { ), }, { - title: '金额 / 时间', + title: '金额 / 支付时间', minWidth: 180, render: (_, row) => (
{row.totalAmount || '0.00'} {row.currency} - {formatAdminDateTime(row.createdAt)} + {formatAdminDateTime(row.paidAt || row.createdAt)}
), }, @@ -200,29 +239,50 @@ export default function AdminOrdersPage() { function applyFilters(values: OrderFilterForm) { const { dateRange, ...restValues } = values - const [dateFrom, dateTo] = Array.isArray(dateRange) + const keyword = String(restValues.keyword || '').trim() + const [rawFrom, rawTo] = Array.isArray(dateRange) ? dateRange.map((item: { format?: (pattern: string) => string }) => item?.format ? item.format('YYYY-MM-DD') : '', ) : ['', ''] - setSearchParams(cleanParams({ ...restValues, dateFrom, dateTo, page: 1, pageSize })) + // 有关键词时不强制日期(后端也会忽略日期);清空日期且无关键词则查全部时间。 + const dateFrom = rawFrom || '' + const dateTo = rawTo || '' + const allTime = !dateFrom && !dateTo && !keyword ? '1' : '' + + setSearchParams( + cleanParams({ + ...restValues, + keyword, + dateFrom, + dateTo, + allTime, + page: 1, + pageSize, + }), + ) } function resetFilters() { + const { dateFrom, dateTo } = resolveDefaultOrderDateRange() form.setFieldsValue({ keyword: undefined, + provider: undefined, executorKey: undefined, payStatus: undefined, taskStatus: undefined, - dateRange: undefined, + dateRange: [dayjs(dateFrom), dayjs(dateTo)], }) - setSearchParams(cleanParams({ page: 1, pageSize })) + setSearchParams(cleanParams({ page: 1, pageSize, dateFrom, dateTo })) } return (
- + @@ -234,8 +294,16 @@ export default function AdminOrdersPage() { + + + item.value)} + style={{ minWidth: 130 }} /> @@ -260,12 +328,12 @@ export default function AdminOrdersPage() { showSearch optionFilterProp="label" placeholder="任务状态" - options={adminTaskStatusOptions} - style={{ minWidth: 170 }} + options={adminTaskStatusOptions.filter((item) => item.value)} + style={{ minWidth: 150 }} /> - + @@ -283,7 +351,7 @@ export default function AdminOrdersPage() { rowKey="orderId" - loading={query.isLoading || query.isFetching} + loading={query.isLoading || query.isFetching || !hasBootstrappedFilters} columns={columns} dataSource={data?.items || []} scroll={{ x: 900 }} @@ -292,7 +360,14 @@ export default function AdminOrdersPage() { pageSize: data?.pagination.pageSize || pageSize, total: data?.pagination.total || 0, onChange: (nextPage, nextPageSize) => { - setSearchParams(cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize })) + setSearchParams( + cleanParams({ + ...filters, + allTime: searchParams.get('allTime') || '', + page: nextPage, + pageSize: nextPageSize, + }), + ) }, })} /> diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index ac202edc..5ab38ed5 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -6,6 +6,12 @@ export const adminPayStatusOptions = [ { label: '已退款', value: 'refunded' }, ] +export const adminOrderProviderOptions = [ + { label: '拼多多', value: 'pdd' }, + { label: '91卡券', value: '91kaquan' }, + { label: '快手', value: 'kuaishou' }, +] + export const adminOrderExecutorOptions = [ { label: '联盟发货', value: 'affiliate_dash' }, { label: '行业凭证', value: 'kuaishou-industry' },