diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 6f1bc1e4..4b81675c 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -453,6 +453,12 @@ export type ListInput = { /** 后台待办跳转时按工单主键精确定位 */ workOrderId?: number keyword?: string + /** + * 后台按店铺筛选(精确匹配,可多选)。 + * 来源是 `orders.shop_name`:工单只存 `order_id`,`material.source.shopName` 只是货源快照, + * 两者并不等价,所以这里统一按订单归属店铺过滤。 + */ + shopNames?: string[] /** 打手类型筛选:internal 内部打手 / external 外部打手 */ workerType?: string /** 后台打手列表按主键精确定位。 */ diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 70208330..ca6e376c 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -347,6 +347,7 @@ export async function listWorkOrders({ status = '', workOrderId = 0, keyword = '', + shopNames = [], workerId = 0, workerLevelIds = [], categoryId = 0, @@ -370,6 +371,7 @@ export async function listWorkOrders({ statuses: effectiveStatuses, workOrderId, keyword, + shopNames, workerId, workerLevelIds, ...(resolvedWorkerIds ? { workerIds: resolvedWorkerIds } : {}), @@ -455,6 +457,26 @@ export async function listWorkOrders({ } } +/** + * 后台「店铺」筛选的候选值。 + * + * 口径是「订单归属店铺」(orders.shop_name),只列真正挂在工单上的店铺—— + * 订单里存在但没同步出工单的店铺如果出现在下拉里,选了必然是空列表。 + * 固定全量去重、不随其它筛选变化,避免用户选完店铺后候选自己缩水。 + */ +export async function listWorkOrderShopNames(): Promise { + const result = await query<{ shop_name: string }>( + `SELECT DISTINCT BTRIM(o.shop_name) AS shop_name + FROM work_orders wo + INNER JOIN orders o ON o.id = wo.order_id + WHERE BTRIM(o.shop_name) <> '' + ORDER BY shop_name ASC`, + ) + return result.rows + .map((row) => String(row.shop_name || '').trim()) + .filter((shopName) => shopName.length > 0) +} + /** 只返回筛选范围总数,不加载工单 JSON、取消事件和当前页关联字段。 */ export async function countWorkOrders(input: ListInput = {}): Promise { const resolvedWorkerIds = await resolveWorkerLevelFilterIds(input.workerLevelIds) @@ -1077,6 +1099,7 @@ export function buildWorkOrderWhere({ statuses = [], workOrderId = 0, keyword = '', + shopNames = [], workerId = 0, workerLevelIds = [], workerIds, @@ -1275,6 +1298,22 @@ export function buildWorkOrderWhere({ filters.push(`${workOrderSearchExpression()} LIKE LOWER($${params.length})`) } } + + const normalizedShopNames = [ + ...new Set(shopNames.map((item) => String(item || '').trim()).filter(Boolean)), + ] + if (normalizedShopNames.length > 0) { + // 店铺口径是「订单归属店铺」:工单只存 order_id,必须回查 orders.shop_name。 + // 下拉选项本身就是真实值,这里做精确匹配,并用 BTRIM 兼容历史脏数据的前后空格 + // (表达式索引见 103_work_order_shop_filter_index.sql)。 + params.push(normalizedShopNames) + filters.push(`wo.order_id IN ( + SELECT shop_order.id + FROM orders shop_order + WHERE BTRIM(shop_order.shop_name) = ANY($${params.length}::text[]) + )`) + } + if (workerId) { params.push(workerId) filters.push(`( diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index d8e99250..53266cb7 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -14,6 +14,7 @@ import { getAdminWorkOrderEvents, getAdminWorkOrderSharing, listAdminMockCustomShops, + listAdminWorkOrderShopNames, listAdminWorkOrders, pinAdminWorkOrder, publishAdminWorkOrder, @@ -47,6 +48,17 @@ router.get( }), ) +// 「店铺」筛选下拉:固定全量候选,不随列表筛选变化(前端 Select 自带搜索过滤)。 +router.get( + '/worker-platform/orders/shop-options', + requireAdminPermission('worker_order.view'), + createJsonHandler(() => listAdminWorkOrderShopNames(), { + successMessage: 'ok', + errorMessage: '读取店铺筛选选项失败', + scope: '[admin/worker-platform/orders/shop-options]', + }), +) + router.get( '/worker-platform/orders/mock/custom-shops', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts index e99f8334..7a3876b9 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts @@ -3,6 +3,7 @@ import { listConsignmentInventoriesByIds, listWorkOrderEventsByOrderId, listWorkOrderSharesByOrderIds, + listWorkOrderShopNames, listWorkOrders, sumPendingUnfreezeByOrderIds, type ConsignmentInventoryRow, @@ -49,6 +50,7 @@ function buildWorkOrderStatisticsCacheKey(filters: JsonObject): string { filters.status || '', filters.workOrderId || 0, filters.keyword || '', + filters.shopNames || [], filters.workerSharingId || 0, filters.workerId || 0, filters.workerLevelIds || [], @@ -92,6 +94,7 @@ export function resolveAdminWorkOrderListFilters(query: JsonObject = {}): ListIn workOrderId: 0, categoryId: 0, keyword, + shopNames: [], workerSharingId: 0, workerLevelIds: [], vipEvidencePending: false, @@ -106,6 +109,8 @@ export function resolveAdminWorkOrderListFilters(query: JsonObject = {}): ListIn .map(normalizeOptionalId) .filter((levelId): levelId is number => levelId !== null) const statuses = normalizeStatuses(query.status) + // 店铺来自 orders.shop_name,前端按逗号拼串传参,复用同一套数组解析(去重 + trim)。 + const shopNames = normalizeStatuses(query.shopNames ?? query.shop_names) const vipEvidencePending = normalizeBoolean(query.vipEvidencePending, false) const createdFrom = normalizeDateString(query.createdFrom) const createdTo = normalizeDateString(query.createdTo) @@ -116,6 +121,7 @@ export function resolveAdminWorkOrderListFilters(query: JsonObject = {}): ListIn workOrderId, categoryId, keyword, + shopNames, workerSharingId: workerId, workerLevelIds, vipEvidencePending, @@ -182,3 +188,13 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { statistics, } } + +/** + * 后台工单「店铺」筛选的下拉候选。 + * + * 固定全量去重(不随状态/日期等筛选变化),下拉里带搜索框由前端过滤即可; + * 数据口径见 repository 的 listWorkOrderShopNames(orders.shop_name,精确匹配)。 + */ +export async function listAdminWorkOrderShopNames(): Promise<{ items: string[] }> { + return { items: await listWorkOrderShopNames() } +} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index b7bd06b9..15949767 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -60,6 +60,7 @@ import { fetchAdminWorkCategories, fetchAdminSession, fetchAdminWorkOrderSharing, + fetchAdminWorkOrderShopOptions, fetchAdminWorkerLevels, fetchAdminWorkerUsers, fetchAdminWorkOrders, @@ -205,6 +206,18 @@ function isConsignmentRevocableOrder(row: WorkOrder) { ) } +/** + * 快手渠道自动生成的占位店铺名(`快手店铺-`)。 + * + * 这类名字不是人工维护的门店,只是同步订单时没拿到店铺名兜底生成的; + * 后台「店铺」筛选中把它们隐藏掉,避免下拉里混进一串数字店铺。 + */ +const AUTO_KUAISHOU_SHOP_NAME_PATTERN = /^快手店铺[--]?\s*\d*$/ + +function isAutoGeneratedKuaishouShopName(name: string) { + return AUTO_KUAISHOU_SHOP_NAME_PATTERN.test(String(name || '').trim()) +} + export default function WorkOrdersPanel() { const { message, modal } = App.useApp() const navigate = useNavigate() @@ -266,6 +279,7 @@ export default function WorkOrdersPanel() { const [createdRange, setCreatedRange] = useState<[string, string] | null>(null) const [keywordInput, setKeywordInput] = useState('') const [workerId, setWorkerId] = useState() + const [shopNames, setShopNames] = useState([]) const [categoryId, setCategoryId] = useState() const [workerLevelIds, setWorkerLevelIds] = useState([]) const [acceptanceMode, setAcceptanceMode] = useState() @@ -369,6 +383,7 @@ export default function WorkOrdersPanel() { setKeyword('') setKeywordInput('') setWorkerId(undefined) + setShopNames([]) setWorkerLevelIds([]) // 定位工单是**排他**条件(wo.id = X):只留它一个,其它筛选一并清掉, // 否则之后按单号搜索时会被残留的接单人员/分类/日期/验收模式再次挡掉。 @@ -395,6 +410,7 @@ export default function WorkOrdersPanel() { { statuses: statuses.join(','), keyword, + shopNames: shopNames.join(','), workerId, categoryId, workerLevelIds: workerLevelIds.join(','), @@ -417,6 +433,7 @@ export default function WorkOrdersPanel() { workOrderId: targetWorkOrderId, categoryId, keyword, + shopNames: shopNames.length > 0 ? shopNames.join(',') : undefined, workerId, workerLevelIds: showWorkerLevelFilter && workerLevelIds.length > 0 ? workerLevelIds.join(',') : undefined, @@ -490,6 +507,12 @@ export default function WorkOrdersPanel() { queryFn: () => fetchAdminWorkerLevels(), enabled: showWorkerLevelFilter, }) + // 店铺候选固定全量(不随其它筛选变化),下拉本身带搜索框做本地过滤。 + const shopOptionsQuery = useQuery({ + queryKey: ['admin-worker-platform-order-shop-options'], + queryFn: fetchAdminWorkOrderShopOptions, + staleTime: 60_000, + }) const filterWorkersQuery = useQuery({ queryKey: ['admin-worker-platform-workers', 'order-filter', filterWorkerKeyword], queryFn: () => @@ -713,6 +736,9 @@ export default function WorkOrdersPanel() { queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'], }), + queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-order-shop-options'], + }), queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'], }), @@ -1095,6 +1121,10 @@ export default function WorkOrdersPanel() { setFilterWorkerKeyword('') cleared.push('接单人员') } + if (shopNames.length > 0) { + setShopNames([]) + cleared.push('店铺') + } if (categoryId) { setCategoryId(undefined) cleared.push('分类') @@ -1130,6 +1160,7 @@ export default function WorkOrdersPanel() { setKeyword('') setStatuses([]) setWorkerId(undefined) + setShopNames([]) setCategoryId(undefined) setWorkerLevelIds([]) setAcceptanceMode(undefined) @@ -1841,12 +1872,12 @@ export default function WorkOrdersPanel() { ) : null}
- +
{ const nextVal = event.target.value setKeywordInput(nextVal) @@ -1857,12 +1888,33 @@ export default function WorkOrdersPanel() { }} onPressEnter={searchOrders} /> + ({ @@ -1879,8 +1931,8 @@ export default function WorkOrdersPanel() { showSearch filterOption={false} value={workerId} - placeholder="全部接单人员" - style={{ width: 180 }} + placeholder="接单人员" + className="worker-orders-filter-select" loading={filterWorkersQuery.isLoading} onSearch={setFilterWorkerKeyword} options={(filterWorkersQuery.data?.data.items || []).map((worker) => ({ @@ -1901,8 +1953,8 @@ export default function WorkOrdersPanel() { showSearch optionFilterProp="label" value={categoryId} - placeholder="全部接单分类" - style={{ width: 180 }} + placeholder="接单分类" + className="worker-orders-filter-select" loading={categoriesQuery.isLoading} options={categoryOptions} onChange={(nextCategoryId) => { @@ -1915,8 +1967,8 @@ export default function WorkOrdersPanel() { mode="multiple" allowClear value={workerLevelIds} - placeholder="全部打手 VIP 等级" - style={{ minWidth: 180, maxWidth: 320 }} + placeholder="VIP 等级" + className="worker-orders-filter-select" maxTagCount="responsive" loading={workerLevelsQuery.isLoading} options={(workerLevelsQuery.data?.data.items || []).map((level) => ({ @@ -1932,8 +1984,8 @@ export default function WorkOrdersPanel() {