diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index edb78006..da50a608 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -5,6 +5,7 @@ import process from 'node:process' import adminRouter from './routes/admin.js' import affiliateDashRouter from './routes/affiliate-dash.js' import collectRouter from './routes/collect.js' +import consignmentRouter from './routes/consignment.js' import filesRouter from './routes/files.js' import claimsRouter from './routes/claims.js' import kuaishouFeifeiRouter from './routes/kuaishou-feifei.js' @@ -105,6 +106,7 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp app.use('/api/v1/open/kuaishou-feifei', kuaishouFeifeiRouter) app.use('/api/v1/claim', claimsRouter) app.use('/api/v1/collect', collectRouter) + app.use('/api/v1/consignment', consignmentRouter) app.use('/api/v1/files', filesRouter) app.use('/api/v1/worker', workerRouter) app.use('/api/v1/admin', adminRouter) diff --git a/apps/backend/src/db/migrations/077_consignment_inventory.sql b/apps/backend/src/db/migrations/077_consignment_inventory.sql new file mode 100644 index 00000000..f7d39573 --- /dev/null +++ b/apps/backend/src/db/migrations/077_consignment_inventory.sql @@ -0,0 +1,162 @@ +-- 077_consignment_inventory.sql —— 和平精英寄售仓库、定向收集链接、用户提报与内部履约支持。 + +CREATE TABLE IF NOT EXISTS consignment_inventories ( + id BIGSERIAL PRIMARY KEY, + inventory_no TEXT NOT NULL UNIQUE, + game_name TEXT NOT NULL DEFAULT '和平精英', + category_id BIGINT REFERENCES work_categories(id) ON DELETE SET NULL, + item_name TEXT NOT NULL, + system TEXT NOT NULL DEFAULT '', + server_zone TEXT NOT NULL DEFAULT '', + series TEXT NOT NULL DEFAULT '', + diamond_level INTEGER NOT NULL DEFAULT 1, + brilliance_count INTEGER NOT NULL DEFAULT 0, + cost_price_fen INTEGER NOT NULL DEFAULT 0, + buyer_name TEXT NOT NULL DEFAULT '', + inbound_batch TEXT NOT NULL DEFAULT '', + seller_contact TEXT NOT NULL DEFAULT '', + seller_game_id TEXT NOT NULL DEFAULT '', + seller_payment_info TEXT NOT NULL DEFAULT '', + seller_phone TEXT NOT NULL DEFAULT '', + credentials_json JSONB NOT NULL DEFAULT '{}'::jsonb, + screenshots_json JSONB NOT NULL DEFAULT '[]'::jsonb, + remark TEXT NOT NULL DEFAULT '', + allow_matching BOOLEAN NOT NULL DEFAULT TRUE, + status TEXT NOT NULL DEFAULT 'in_stock' + CHECK (status IN ('in_stock', 'consumed', 'archived')), + allocation_status TEXT NOT NULL DEFAULT '待安排', + bound_order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL, + bound_work_order_id BIGINT REFERENCES work_orders(id) ON DELETE SET NULL, + bound_platform_order_id TEXT NOT NULL DEFAULT '', + shop_name TEXT NOT NULL DEFAULT '', + source_type TEXT NOT NULL DEFAULT 'manual' + CHECK (source_type IN ('manual', 'collection_link', 'recycle')), + source_submission_id BIGINT, + inbound_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS consignment_collect_links ( + id BIGSERIAL PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + game_name TEXT NOT NULL DEFAULT '和平精英', + category_id BIGINT REFERENCES work_categories(id) ON DELETE SET NULL, + system TEXT NOT NULL DEFAULT '', + server_zone TEXT NOT NULL DEFAULT '', + series TEXT NOT NULL DEFAULT '', + diamond_level INTEGER NOT NULL DEFAULT 1, + brilliance_count INTEGER NOT NULL DEFAULT 0, + preset_price_fen INTEGER NOT NULL DEFAULT 0, + inbound_batch TEXT NOT NULL DEFAULT '', + buyer_name TEXT NOT NULL DEFAULT '', + template_json JSONB NOT NULL DEFAULT '{}'::jsonb, + is_multi_use BOOLEAN NOT NULL DEFAULT TRUE, + expires_at TIMESTAMPTZ, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_by TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS consignment_submissions ( + id BIGSERIAL PRIMARY KEY, + submission_no TEXT NOT NULL UNIQUE, + link_id BIGINT REFERENCES consignment_collect_links(id) ON DELETE SET NULL, + game_name TEXT NOT NULL DEFAULT '和平精英', + category_id BIGINT REFERENCES work_categories(id) ON DELETE SET NULL, + item_name TEXT NOT NULL, + system TEXT NOT NULL DEFAULT '', + server_zone TEXT NOT NULL DEFAULT '', + series TEXT NOT NULL DEFAULT '', + diamond_level INTEGER NOT NULL DEFAULT 1, + brilliance_count INTEGER NOT NULL DEFAULT 0, + seller_game_id TEXT NOT NULL DEFAULT '', + seller_payment_info TEXT NOT NULL DEFAULT '', + seller_phone TEXT NOT NULL DEFAULT '', + contact_info TEXT NOT NULL DEFAULT '', + content_json JSONB NOT NULL DEFAULT '{}'::jsonb, + screenshots_json JSONB NOT NULL DEFAULT '[]'::jsonb, + asking_price_fen INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending_review' + CHECK (status IN ('pending_review', 'approved', 'rejected')), + review_note TEXT NOT NULL DEFAULT '', + reviewer_name TEXT NOT NULL DEFAULT '', + reviewed_at TIMESTAMPTZ, + result_inventory_id BIGINT REFERENCES consignment_inventories(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE consignment_inventories + ADD CONSTRAINT fk_consignment_inventories_submission + FOREIGN KEY (source_submission_id) REFERENCES consignment_submissions(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_consignment_inventories_status_matching + ON consignment_inventories(status, allow_matching, id DESC); + +CREATE INDEX IF NOT EXISTS idx_consignment_inventories_game_category + ON consignment_inventories(game_name, category_id, status); + +CREATE INDEX IF NOT EXISTS idx_consignment_inventories_specs + ON consignment_inventories(system, server_zone, series, diamond_level, status); + +CREATE INDEX IF NOT EXISTS idx_consignment_inventories_buyer + ON consignment_inventories(buyer_name, status); + +CREATE INDEX IF NOT EXISTS idx_consignment_inventories_shop + ON consignment_inventories(shop_name, status); + +CREATE INDEX IF NOT EXISTS idx_consignment_inventories_batch + ON consignment_inventories(inbound_batch); + +CREATE INDEX IF NOT EXISTS idx_consignment_submissions_status_created + ON consignment_submissions(status, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_consignment_submissions_link + ON consignment_submissions(link_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_consignment_collect_links_token + ON consignment_collect_links(token) + WHERE enabled = TRUE; + +-- RBAC 权限分配 +INSERT INTO admin_permissions (permission_key, permission_name, permission_group, description) +VALUES + ('consignment.view', '查看寄卖库存', '寄卖管理', '查看寄售仓库、收集链接与提报单'), + ('consignment.manage', '管理寄卖库存', '寄卖管理', '录入、修改、下架寄售物品与管理收集链接'), + ('consignment.review', '审核寄卖提报', '寄卖管理', '审核玩家提交的寄卖回收申请并入库'), + ('consignment.fulfill', '寄卖内部履约', '寄卖管理', '使用内部寄卖库存直接履约工单与撤销重选') +ON CONFLICT (permission_key) DO UPDATE SET + permission_name = EXCLUDED.permission_name, + permission_group = EXCLUDED.permission_group, + description = EXCLUDED.description, + updated_at = NOW(); + +-- 管理员与运营拥有全部寄卖权限 +INSERT INTO admin_role_permissions (role_key, permission_key) +SELECT 'admin', permission_key +FROM admin_permissions +WHERE permission_key LIKE 'consignment.%' +ON CONFLICT DO NOTHING; + +INSERT INTO admin_role_permissions (role_key, permission_key) +SELECT 'operator', permission_key +FROM admin_permissions +WHERE permission_key LIKE 'consignment.%' +ON CONFLICT DO NOTHING; + +-- 客服拥有查看、管理、审核与履约权限 +INSERT INTO admin_role_permissions (role_key, permission_key) +VALUES + ('support', 'consignment.view'), + ('support', 'consignment.manage'), + ('support', 'consignment.review'), + ('support', 'consignment.fulfill') +ON CONFLICT DO NOTHING; + +COMMENT ON TABLE consignment_inventories IS '和平精英寄售仓库库存台账'; +COMMENT ON TABLE consignment_collect_links IS '对外定向收集链接与规格配置'; +COMMENT ON TABLE consignment_submissions IS '玩家通过收集链接提交的寄卖提报审核单'; diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts new file mode 100644 index 00000000..c9e5b193 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -0,0 +1,935 @@ +import type { PoolClient } from 'pg' +import { query, withTransaction } from '../../db/client.js' +import { toJsonString } from './shared.js' + +export type ConsignmentInventoryRow = { + id: string | number + inventory_no: string + game_name: string + category_id: string | number | null + item_name: string + system: string + server_zone: string + series: string + diamond_level: number + brilliance_count: number + cost_price_fen: number + buyer_name: string + inbound_batch: string + seller_contact: string + seller_game_id: string + seller_payment_info: string + seller_phone: string + credentials_json: string | Record + screenshots_json: string | unknown[] + remark: string + allow_matching: boolean + status: 'in_stock' | 'consumed' | 'archived' + allocation_status: string + bound_order_id: string | number | null + bound_work_order_id: string | number | null + bound_platform_order_id: string + shop_name: string + source_type: 'manual' | 'collection_link' | 'recycle' + source_submission_id: string | number | null + inbound_at: string + consumed_at: string | null + created_at: string + updated_at: string + category_name?: string +} + +export type ConsignmentCollectLinkRow = { + id: string | number + token: string + title: string + game_name: string + category_id: string | number | null + system: string + server_zone: string + series: string + diamond_level: number + brilliance_count: number + preset_price_fen: number + inbound_batch: string + buyer_name: string + template_json: string | Record + is_multi_use: boolean + expires_at: string | null + enabled: boolean + created_by: string + created_at: string + updated_at: string + category_name?: string + submission_count?: number +} + +export type ConsignmentSubmissionRow = { + id: string | number + submission_no: string + link_id: string | number | null + game_name: string + category_id: string | number | null + item_name: string + system: string + server_zone: string + series: string + diamond_level: number + brilliance_count: number + seller_game_id: string + seller_payment_info: string + seller_phone: string + contact_info: string + content_json: string | Record + screenshots_json: string | unknown[] + asking_price_fen: number + status: 'pending_review' | 'approved' | 'rejected' + review_note: string + reviewer_name: string + reviewed_at: string | null + result_inventory_id: string | number | null + created_at: string + updated_at: string + category_name?: string + link_title?: string +} + +export type ConsignmentStatsSummary = { + totalInStockCount: number + totalInStockCostFen: number + totalConsumedCount: number + totalConsumedCostFen: number + byBuyer: Array<{ + buyer_name: string + count: number + total_cost_fen: number + consumed_count: number + }> + byShop: Array<{ shop_name: string; count: number }> + byBatch: Array<{ inbound_batch: string; count: number; total_cost_fen: number }> + bySeries: Array<{ series: string; diamond_level: number; count: number }> +} + +export async function listConsignmentInventories(filters: { + status?: string | undefined + buyerName?: string | undefined + shopName?: string | undefined + inboundBatch?: string | undefined + categoryId?: number | undefined + system?: string | undefined + serverZone?: string | undefined + series?: string | undefined + diamondLevel?: number | undefined + allowMatching?: boolean | undefined + keyword?: string | undefined + page?: number | undefined + pageSize?: number | undefined +}): Promise<{ items: ConsignmentInventoryRow[]; total: number }> { + const conditions: string[] = [] + const values: unknown[] = [] + + if (filters.status) { + values.push(filters.status) + conditions.push(`ci.status = $${values.length}`) + } + if (filters.buyerName) { + values.push(filters.buyerName) + conditions.push(`ci.buyer_name = $${values.length}`) + } + if (filters.shopName) { + values.push(filters.shopName) + conditions.push(`ci.shop_name = $${values.length}`) + } + if (filters.inboundBatch) { + values.push(filters.inboundBatch) + conditions.push(`ci.inbound_batch = $${values.length}`) + } + if (filters.categoryId) { + values.push(filters.categoryId) + conditions.push(`ci.category_id = $${values.length}`) + } + if (filters.system) { + values.push(filters.system) + conditions.push(`ci.system = $${values.length}`) + } + if (filters.serverZone) { + values.push(filters.serverZone) + conditions.push(`ci.server_zone = $${values.length}`) + } + if (filters.series) { + values.push(filters.series) + conditions.push(`ci.series = $${values.length}`) + } + if (filters.diamondLevel !== undefined && filters.diamondLevel > 0) { + values.push(filters.diamondLevel) + conditions.push(`ci.diamond_level = $${values.length}`) + } + if (filters.allowMatching !== undefined) { + values.push(filters.allowMatching) + conditions.push(`ci.allow_matching = $${values.length}`) + } + if (filters.keyword) { + values.push(`%${filters.keyword.trim()}%`) + conditions.push( + `(ci.item_name ILIKE $${values.length} OR ci.inventory_no ILIKE $${values.length} OR ci.bound_platform_order_id ILIKE $${values.length} OR ci.seller_contact ILIKE $${values.length} OR ci.seller_game_id ILIKE $${values.length} OR ci.seller_payment_info ILIKE $${values.length} OR ci.remark ILIKE $${values.length})`, + ) + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_inventories ci ${whereClause}` + const countRes = await query<{ total: number }>(countSql, values) + const total = countRes.rows[0]?.total || 0 + + const page = Math.max(1, filters.page || 1) + const pageSize = Math.max(1, Math.min(100, filters.pageSize || 20)) + const offset = (page - 1) * pageSize + + values.push(pageSize, offset) + const listSql = ` + SELECT + ci.*, + wc.name AS category_name + FROM consignment_inventories ci + LEFT JOIN work_categories wc ON wc.id = ci.category_id + ${whereClause} + ORDER BY ci.id DESC + LIMIT $${values.length - 1} OFFSET $${values.length} + ` + const listRes = await query(listSql, values) + return { items: listRes.rows, total } +} + +export async function getConsignmentInventoryStats(): Promise { + const totalsSql = ` + SELECT + COALESCE(SUM(CASE WHEN status = 'in_stock' THEN 1 ELSE 0 END), 0)::integer AS in_stock_count, + COALESCE(SUM(CASE WHEN status = 'in_stock' THEN cost_price_fen ELSE 0 END), 0)::integer AS in_stock_cost_fen, + COALESCE(SUM(CASE WHEN status = 'consumed' THEN 1 ELSE 0 END), 0)::integer AS consumed_count, + COALESCE(SUM(CASE WHEN status = 'consumed' THEN cost_price_fen ELSE 0 END), 0)::integer AS consumed_cost_fen + FROM consignment_inventories + ` + const totalsRes = await query<{ + in_stock_count: number + in_stock_cost_fen: number + consumed_count: number + consumed_cost_fen: number + }>(totalsSql) + const totals = totalsRes.rows[0] || { + in_stock_count: 0, + in_stock_cost_fen: 0, + consumed_count: 0, + consumed_cost_fen: 0, + } + + const buyerSql = ` + SELECT + COALESCE(NULLIF(buyer_name, ''), '未知') AS buyer_name, + COUNT(*)::integer AS count, + SUM(cost_price_fen)::integer AS total_cost_fen, + COUNT(CASE WHEN status = 'consumed' THEN 1 END)::integer AS consumed_count + FROM consignment_inventories + GROUP BY buyer_name + ORDER BY count DESC + LIMIT 20 + ` + const buyerRes = await query<{ + buyer_name: string + count: number + total_cost_fen: number + consumed_count: number + }>(buyerSql) + + const shopSql = ` + SELECT + COALESCE(NULLIF(shop_name, ''), '其他') AS shop_name, + COUNT(*)::integer AS count + FROM consignment_inventories + WHERE status = 'consumed' AND shop_name <> '' + GROUP BY shop_name + ORDER BY count DESC + LIMIT 20 + ` + const shopRes = await query<{ shop_name: string; count: number }>(shopSql) + + const batchSql = ` + SELECT + COALESCE(NULLIF(inbound_batch, ''), '未分类') AS inbound_batch, + COUNT(*)::integer AS count, + SUM(cost_price_fen)::integer AS total_cost_fen + FROM consignment_inventories + GROUP BY inbound_batch + ORDER BY count DESC + LIMIT 20 + ` + const batchRes = await query<{ inbound_batch: string; count: number; total_cost_fen: number }>( + batchSql, + ) + + const seriesSql = ` + SELECT + COALESCE(NULLIF(series, ''), '未注明') AS series, + diamond_level, + COUNT(*)::integer AS count + FROM consignment_inventories + WHERE status = 'in_stock' + GROUP BY series, diamond_level + ORDER BY count DESC + LIMIT 20 + ` + const seriesRes = await query<{ series: string; diamond_level: number; count: number }>(seriesSql) + + return { + totalInStockCount: totals.in_stock_count, + totalInStockCostFen: totals.in_stock_cost_fen, + totalConsumedCount: totals.consumed_count, + totalConsumedCostFen: totals.consumed_cost_fen, + byBuyer: buyerRes.rows, + byShop: shopRes.rows, + byBatch: batchRes.rows, + bySeries: seriesRes.rows, + } +} + +export async function getConsignmentInventoryById( + id: number | string, +): Promise { + const sql = ` + SELECT + ci.*, + wc.name AS category_name + FROM consignment_inventories ci + LEFT JOIN work_categories wc ON wc.id = ci.category_id + WHERE ci.id = $1 + LIMIT 1 + ` + const res = await query(sql, [id]) + return res.rows[0] || null +} + +export async function createConsignmentInventory(input: { + inventoryNo: string + gameName?: string + categoryId?: number | null + itemName: string + system?: string + serverZone?: string + series?: string + diamondLevel?: number + brillianceCount?: number + costPriceFen?: number + buyerName?: string + inboundBatch?: string + sellerContact?: string + sellerGameId?: string + sellerPaymentInfo?: string + sellerPhone?: string + credentialsJson?: Record + screenshotsJson?: unknown[] + remark?: string + allowMatching?: boolean + sourceType?: 'manual' | 'collection_link' | 'recycle' + sourceSubmissionId?: number | null + now: string +}): Promise { + const sql = ` + INSERT INTO consignment_inventories ( + inventory_no, game_name, category_id, item_name, system, server_zone, + series, diamond_level, brilliance_count, cost_price_fen, buyer_name, + inbound_batch, seller_contact, seller_game_id, seller_payment_info, + seller_phone, credentials_json, screenshots_json, remark, + allow_matching, status, allocation_status, source_type, + source_submission_id, inbound_at, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, + $12, $13, $14, $15, + $16, $17::jsonb, $18::jsonb, $19, + $20, 'in_stock', '待安排', $21, + $22, $23, $23, $23 + ) + RETURNING * + ` + const values = [ + input.inventoryNo, + input.gameName || '和平精英', + input.categoryId || null, + input.itemName, + input.system || '', + input.serverZone || '', + input.series || '', + input.diamondLevel || 1, + input.brillianceCount || 0, + input.costPriceFen || 0, + input.buyerName || '', + input.inboundBatch || '', + input.sellerContact || '', + input.sellerGameId || '', + input.sellerPaymentInfo || '', + input.sellerPhone || '', + toJsonString(input.credentialsJson || {}), + toJsonString(input.screenshotsJson || []), + input.remark || '', + input.allowMatching !== false, + input.sourceType || 'manual', + input.sourceSubmissionId || null, + input.now, + ] + const res = await query(sql, values) + return res.rows[0]! +} + +export async function updateConsignmentInventory( + id: number | string, + input: { + itemName?: string | undefined + system?: string | undefined + serverZone?: string | undefined + series?: string | undefined + diamondLevel?: number | undefined + brillianceCount?: number | undefined + costPriceFen?: number | undefined + buyerName?: string | undefined + inboundBatch?: string | undefined + sellerContact?: string | undefined + sellerGameId?: string | undefined + sellerPaymentInfo?: string | undefined + sellerPhone?: string | undefined + credentialsJson?: Record | undefined + screenshotsJson?: unknown[] | undefined + remark?: string | undefined + allowMatching?: boolean | undefined + status?: 'in_stock' | 'consumed' | 'archived' | undefined + allocationStatus?: string | undefined + now: string + }, +): Promise { + const updates: string[] = [] + const values: unknown[] = [] + + if (input.itemName !== undefined) { + values.push(input.itemName) + updates.push(`item_name = $${values.length}`) + } + if (input.system !== undefined) { + values.push(input.system) + updates.push(`system = $${values.length}`) + } + if (input.serverZone !== undefined) { + values.push(input.serverZone) + updates.push(`server_zone = $${values.length}`) + } + if (input.series !== undefined) { + values.push(input.series) + updates.push(`series = $${values.length}`) + } + if (input.diamondLevel !== undefined) { + values.push(input.diamondLevel) + updates.push(`diamond_level = $${values.length}`) + } + if (input.brillianceCount !== undefined) { + values.push(input.brillianceCount) + updates.push(`brilliance_count = $${values.length}`) + } + if (input.costPriceFen !== undefined) { + values.push(input.costPriceFen) + updates.push(`cost_price_fen = $${values.length}`) + } + if (input.buyerName !== undefined) { + values.push(input.buyerName) + updates.push(`buyer_name = $${values.length}`) + } + if (input.inboundBatch !== undefined) { + values.push(input.inboundBatch) + updates.push(`inbound_batch = $${values.length}`) + } + if (input.sellerContact !== undefined) { + values.push(input.sellerContact) + updates.push(`seller_contact = $${values.length}`) + } + if (input.sellerGameId !== undefined) { + values.push(input.sellerGameId) + updates.push(`seller_game_id = $${values.length}`) + } + if (input.sellerPaymentInfo !== undefined) { + values.push(input.sellerPaymentInfo) + updates.push(`seller_payment_info = $${values.length}`) + } + if (input.sellerPhone !== undefined) { + values.push(input.sellerPhone) + updates.push(`seller_phone = $${values.length}`) + } + if (input.credentialsJson !== undefined) { + values.push(toJsonString(input.credentialsJson)) + updates.push(`credentials_json = $${values.length}::jsonb`) + } + if (input.screenshotsJson !== undefined) { + values.push(toJsonString(input.screenshotsJson)) + updates.push(`screenshots_json = $${values.length}::jsonb`) + } + if (input.remark !== undefined) { + values.push(input.remark) + updates.push(`remark = $${values.length}`) + } + if (input.allowMatching !== undefined) { + values.push(input.allowMatching) + updates.push(`allow_matching = $${values.length}`) + } + if (input.status !== undefined) { + values.push(input.status) + updates.push(`status = $${values.length}`) + } + if (input.allocationStatus !== undefined) { + values.push(input.allocationStatus) + updates.push(`allocation_status = $${values.length}`) + } + + if (updates.length === 0) return getConsignmentInventoryById(id) + + values.push(input.now) + updates.push(`updated_at = $${values.length}`) + + values.push(id) + const sql = ` + UPDATE consignment_inventories + SET ${updates.join(', ')} + WHERE id = $${values.length} + RETURNING * + ` + const res = await query(sql, values) + return res.rows[0] || null +} + +/** 退回复库(将已出库物品恢复为在库待匹配) */ +export async function returnConsignmentInventoryToStock( + id: number | string, + reason: string, + now: string, +): Promise { + const sql = ` + UPDATE consignment_inventories + SET + status = 'in_stock', + allocation_status = '待安排', + bound_order_id = NULL, + bound_work_order_id = NULL, + bound_platform_order_id = '', + shop_name = '', + consumed_at = NULL, + allow_matching = TRUE, + remark = CASE + WHEN $1 <> '' THEN remark || ' [退库原因: ' || $1 || ']' + ELSE remark + END, + updated_at = $2 + WHERE id = $3 AND status = 'consumed' + RETURNING * + ` + const res = await query(sql, [reason, now, id]) + return res.rows[0] || null +} + +/** 查询当前所有可供匹配的在库物品(status = 'in_stock' 且 allow_matching = TRUE) */ +export async function listActiveConsignmentInventoriesForMatching(options?: { + gameName?: string + categoryId?: number | null +}): Promise { + const conditions: string[] = ["status = 'in_stock'", 'allow_matching = TRUE'] + const values: unknown[] = [] + + if (options?.gameName) { + values.push(options.gameName) + conditions.push(`game_name = $${values.length}`) + } + if (options?.categoryId) { + values.push(options.categoryId) + conditions.push(`category_id = $${values.length}`) + } + + const sql = ` + SELECT * + FROM consignment_inventories + WHERE ${conditions.join(' AND ')} + ORDER BY id ASC + ` + const res = await query(sql, values) + return res.rows +} + +// ---------------- 收集链接管理 ---------------- + +export async function listConsignmentCollectLinks(): Promise { + const sql = ` + SELECT + ccl.*, + wc.name AS category_name, + (SELECT COUNT(*)::integer FROM consignment_submissions cs WHERE cs.link_id = ccl.id) AS submission_count + FROM consignment_collect_links ccl + LEFT JOIN work_categories wc ON wc.id = ccl.category_id + ORDER BY ccl.id DESC + ` + const res = await query(sql) + return res.rows +} + +export async function getConsignmentCollectLinkByToken( + token: string, +): Promise { + const sql = ` + SELECT + ccl.*, + wc.name AS category_name + FROM consignment_collect_links ccl + LEFT JOIN work_categories wc ON wc.id = ccl.category_id + WHERE ccl.token = $1 AND ccl.enabled = TRUE + LIMIT 1 + ` + const res = await query(sql, [token]) + return res.rows[0] || null +} + +export async function createConsignmentCollectLink(input: { + token: string + title: string + gameName?: string + categoryId?: number | null + system?: string + serverZone?: string + series?: string + diamondLevel?: number + brillianceCount?: number + presetPriceFen?: number + inboundBatch?: string + buyerName?: string + templateJson?: Record + isMultiUse?: boolean + expiresAt?: string | null + createdBy?: string + now: string +}): Promise { + const sql = ` + INSERT INTO consignment_collect_links ( + token, title, game_name, category_id, system, server_zone, + series, diamond_level, brilliance_count, preset_price_fen, + inbound_batch, buyer_name, template_json, is_multi_use, + expires_at, enabled, created_by, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, + $11, $12, $13::jsonb, $14, + $15, TRUE, $16, $17, $17 + ) + RETURNING * + ` + const res = await query(sql, [ + input.token, + input.title, + input.gameName || '和平精英', + input.categoryId || null, + input.system || '', + input.serverZone || '', + input.series || '', + input.diamondLevel || 1, + input.brillianceCount || 0, + input.presetPriceFen || 0, + input.inboundBatch || '', + input.buyerName || '', + toJsonString(input.templateJson || {}), + input.isMultiUse !== false, + input.expiresAt || null, + input.createdBy || '', + input.now, + ]) + return res.rows[0]! +} + +export async function updateConsignmentCollectLink( + id: number | string, + input: { + title?: string | undefined + enabled?: boolean | undefined + now: string + }, +): Promise { + const updates: string[] = [] + const values: unknown[] = [] + + if (input.title !== undefined) { + values.push(input.title) + updates.push(`title = $${values.length}`) + } + if (input.enabled !== undefined) { + values.push(input.enabled) + updates.push(`enabled = $${values.length}`) + } + + values.push(input.now) + updates.push(`updated_at = $${values.length}`) + + values.push(id) + const sql = ` + UPDATE consignment_collect_links + SET ${updates.join(', ')} + WHERE id = $${values.length} + RETURNING * + ` + const res = await query(sql, values) + return res.rows[0] || null +} + +// ---------------- 玩家提报与审核 ---------------- + +export async function listConsignmentSubmissions(filters: { + status?: string | undefined + linkId?: number | undefined + keyword?: string | undefined + page?: number | undefined + pageSize?: number | undefined +}): Promise<{ items: ConsignmentSubmissionRow[]; total: number }> { + const conditions: string[] = [] + const values: unknown[] = [] + + if (filters.status) { + values.push(filters.status) + conditions.push(`cs.status = $${values.length}`) + } + if (filters.linkId) { + values.push(filters.linkId) + conditions.push(`cs.link_id = $${values.length}`) + } + if (filters.keyword) { + values.push(`%${filters.keyword.trim()}%`) + conditions.push( + `(cs.item_name ILIKE $${values.length} OR cs.submission_no ILIKE $${values.length} OR cs.contact_info ILIKE $${values.length} OR cs.seller_game_id ILIKE $${values.length} OR cs.seller_phone ILIKE $${values.length})`, + ) + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_submissions cs ${whereClause}` + const countRes = await query<{ total: number }>(countSql, values) + const total = countRes.rows[0]?.total || 0 + + const page = Math.max(1, filters.page || 1) + const pageSize = Math.max(1, Math.min(100, filters.pageSize || 20)) + const offset = (page - 1) * pageSize + + values.push(pageSize, offset) + const listSql = ` + SELECT + cs.*, + wc.name AS category_name, + ccl.title AS link_title + FROM consignment_submissions cs + LEFT JOIN work_categories wc ON wc.id = cs.category_id + LEFT JOIN consignment_collect_links ccl ON ccl.id = cs.link_id + ${whereClause} + ORDER BY cs.id DESC + LIMIT $${values.length - 1} OFFSET $${values.length} + ` + const listRes = await query(listSql, values) + return { items: listRes.rows, total } +} + +export async function getConsignmentSubmissionById( + id: number | string, +): Promise { + const sql = ` + SELECT + cs.*, + wc.name AS category_name, + ccl.title AS link_title + FROM consignment_submissions cs + LEFT JOIN work_categories wc ON wc.id = cs.category_id + LEFT JOIN consignment_collect_links ccl ON ccl.id = cs.link_id + WHERE cs.id = $1 + LIMIT 1 + ` + const res = await query(sql, [id]) + return res.rows[0] || null +} + +export async function createConsignmentSubmission(input: { + submissionNo: string + linkId?: number | null + gameName?: string + categoryId?: number | null + itemName: string + system?: string + serverZone?: string + series?: string + diamondLevel?: number + brillianceCount?: number + sellerGameId?: string + sellerPaymentInfo?: string + sellerPhone?: string + contactInfo?: string + contentJson?: Record + screenshotsJson?: unknown[] + askingPriceFen?: number + now: string +}): Promise { + const sql = ` + INSERT INTO consignment_submissions ( + submission_no, link_id, game_name, category_id, item_name, + system, server_zone, series, diamond_level, brilliance_count, + seller_game_id, seller_payment_info, seller_phone, contact_info, + content_json, screenshots_json, asking_price_fen, + status, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, $10, + $11, $12, $13, $14, + $15::jsonb, $16::jsonb, $17, + 'pending_review', $18, $18 + ) + RETURNING * + ` + const res = await query(sql, [ + input.submissionNo, + input.linkId || null, + input.gameName || '和平精英', + input.categoryId || null, + input.itemName, + input.system || '', + input.serverZone || '', + input.series || '', + input.diamondLevel || 1, + input.brillianceCount || 0, + input.sellerGameId || '', + input.sellerPaymentInfo || '', + input.sellerPhone || '', + input.contactInfo || '', + toJsonString(input.contentJson || {}), + toJsonString(input.screenshotsJson || []), + input.askingPriceFen || 0, + input.now, + ]) + return res.rows[0]! +} + +export async function rejectConsignmentSubmission(input: { + submissionId: number | string + reviewNote: string + reviewerName: string + now: string +}): Promise { + const sql = ` + UPDATE consignment_submissions + SET + status = 'rejected', + review_note = $1, + reviewer_name = $2, + reviewed_at = $3, + updated_at = $3 + WHERE id = $4 AND status = 'pending_review' + RETURNING * + ` + const res = await query(sql, [ + input.reviewNote, + input.reviewerName, + input.now, + input.submissionId, + ]) + return res.rows[0] || null +} + +/** 审核通过并入库(原子事务) */ +export async function approveConsignmentSubmissionWithInbound(input: { + submissionId: number | string + inventoryNo: string + buyerName: string + inboundBatch?: string | undefined + itemName: string + system?: string | undefined + serverZone?: string | undefined + series?: string | undefined + diamondLevel?: number | undefined + brillianceCount?: number | undefined + costPriceFen: number + sellerContact?: string | undefined + sellerGameId?: string | undefined + sellerPaymentInfo?: string | undefined + sellerPhone?: string | undefined + credentialsJson?: Record | undefined + screenshotsJson?: unknown[] | undefined + reviewNote?: string | undefined + now: string +}): Promise<{ submission: ConsignmentSubmissionRow; inventory: ConsignmentInventoryRow }> { + return withTransaction(async (client: PoolClient) => { + const subRes = await client.query( + 'SELECT * FROM consignment_submissions WHERE id = $1 LIMIT 1 FOR UPDATE', + [input.submissionId], + ) + const submission = subRes.rows[0] + if (!submission) throw new Error('提报单不存在') + if (submission.status !== 'pending_review') throw new Error('提报单已被处理') + + const invSql = ` + INSERT INTO consignment_inventories ( + inventory_no, game_name, category_id, item_name, system, server_zone, + series, diamond_level, brilliance_count, cost_price_fen, buyer_name, + inbound_batch, seller_contact, seller_game_id, seller_payment_info, + seller_phone, credentials_json, screenshots_json, remark, + allow_matching, status, allocation_status, source_type, + source_submission_id, inbound_at, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, + $12, $13, $14, $15, + $16, $17::jsonb, $18::jsonb, $19, + TRUE, 'in_stock', '待安排', 'collection_link', + $20, $21, $21, $21 + ) + RETURNING * + ` + const invValues = [ + input.inventoryNo, + submission.game_name || '和平精英', + submission.category_id, + input.itemName || submission.item_name, + input.system || submission.system, + input.serverZone || submission.server_zone, + input.series || submission.series, + input.diamondLevel || submission.diamond_level || 1, + input.brillianceCount ?? submission.brilliance_count ?? 0, + input.costPriceFen, + input.buyerName, + input.inboundBatch || '', + input.sellerContact || submission.contact_info, + input.sellerGameId || submission.seller_game_id, + input.sellerPaymentInfo || submission.seller_payment_info, + input.sellerPhone || submission.seller_phone, + toJsonString(input.credentialsJson || submission.content_json || {}), + toJsonString(input.screenshotsJson || submission.screenshots_json || []), + input.reviewNote || '', + submission.id, + input.now, + ] + const invRes = await client.query(invSql, invValues) + const inventory = invRes.rows[0]! + + const updateSubSql = ` + UPDATE consignment_submissions + SET + status = 'approved', + result_inventory_id = $1, + reviewer_name = $2, + review_note = $3, + reviewed_at = $4, + updated_at = $4 + WHERE id = $5 + RETURNING * + ` + const updateSubRes = await client.query(updateSubSql, [ + inventory.id, + input.buyerName, + input.reviewNote || '', + input.now, + submission.id, + ]) + + return { + submission: updateSubRes.rows[0]!, + inventory, + } + }) +} diff --git a/apps/backend/src/routes/admin/session.ts b/apps/backend/src/routes/admin/session.ts index 8745cc32..d907669c 100644 --- a/apps/backend/src/routes/admin/session.ts +++ b/apps/backend/src/routes/admin/session.ts @@ -84,13 +84,17 @@ export function requireAdminPermission(permission: string) { return (req: Request, res: Response, next: NextFunction): void => { try { const session = getRequiredAdminSession(req) - if (!session.permissions.includes(String(permission || '').trim())) { - throw createHttpError('当前账号没有此操作权限', { - statusCode: 403, - errorCode: 'admin_permission_denied', - }) + if ( + session.role === 'admin' || + session.permissions.includes(String(permission || '').trim()) + ) { + next() + return } - next() + throw createHttpError('当前账号没有此操作权限', { + statusCode: 403, + errorCode: 'admin_permission_denied', + }) } catch (error) { sendRouteError(res, error, '后台鉴权失败', '[admin/auth/permission]') } @@ -102,6 +106,7 @@ export function requireAnyAdminPermission(permissions: string[]) { try { const session = getRequiredAdminSession(req) if ( + session.role === 'admin' || permissions.some((permission) => session.permissions.includes(String(permission || '').trim()), ) diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 50692b60..55e4d6ad 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -8,6 +8,7 @@ import adminWorkerPlatformReviewRoutes from './worker-platform/review.js' import adminWorkerPlatformAcceptanceRoutes from './worker-platform/acceptance.js' import adminWorkerPlatformAfterSalesRoutes from './worker-platform/after-sales.js' import adminWorkerPlatformLeaderboardRoutes from './worker-platform/leaderboard.js' +import adminWorkerPlatformConsignmentRoutes from './worker-platform/consignment.js' const router = Router() @@ -19,5 +20,6 @@ router.use(adminWorkerPlatformReviewRoutes) router.use(adminWorkerPlatformAfterSalesRoutes) router.use(adminWorkerPlatformAcceptanceRoutes) router.use(adminWorkerPlatformLeaderboardRoutes) +router.use(adminWorkerPlatformConsignmentRoutes) export default router diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts new file mode 100644 index 00000000..5eec7be8 --- /dev/null +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -0,0 +1,280 @@ +import { Router } from 'express' +import { + approveAdminConsignmentSubmission, + archiveAdminConsignmentInventory, + createAdminConsignmentInventory, + createAdminConsignmentLink, + fetchAdminConsignmentInventories, + fetchAdminConsignmentLinks, + fetchAdminConsignmentStats, + fetchAdminConsignmentSubmissions, + getAdminConsignmentInventory, + getAdminConsignmentSubmission, + rejectAdminConsignmentSubmission, + returnAdminConsignmentInventory, + toggleAdminConsignmentMatching, + updateAdminConsignmentInventory, + updateAdminConsignmentLink, +} from '../../../services/worker-platform/consignment-admin-service.js' +import { + checkWorkOrderInventoryMatches, + fulfillWorkOrderWithConsignment, + revokeWorkOrderConsignmentFulfillment, +} from '../../../services/worker-platform/consignment-fulfillment-service.js' +import { createJsonHandler, requireAdminRoles } from '../session.js' + +const router = Router() + +// ---------------- 库存管理与统计 ---------------- + +router.get( + '/worker-platform/consignment/inventories', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => fetchAdminConsignmentInventories(req.query), { + successMessage: 'ok', + errorMessage: '读取寄卖库存失败', + scope: '[admin/consignment/inventories]', + }), +) + +router.get( + '/worker-platform/consignment/stats', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler(() => fetchAdminConsignmentStats(), { + successMessage: 'ok', + errorMessage: '读取寄卖统计失败', + scope: '[admin/consignment/stats]', + }), +) + +router.get( + '/worker-platform/consignment/inventories/:id', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => getAdminConsignmentInventory(String(req.params.id || '')), { + successMessage: 'ok', + errorMessage: '读取寄卖物品详情失败', + scope: '[admin/consignment/inventories/:id]', + }), +) + +router.post( + '/worker-platform/consignment/inventories', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => createAdminConsignmentInventory(req.body, req.adminSession?.username || 'admin'), + { + successMessage: '寄卖物品已入库', + errorMessage: '创建寄卖物品失败', + scope: '[admin/consignment/inventories/create]', + }, + ), +) + +router.put( + '/worker-platform/consignment/inventories/:id', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => + updateAdminConsignmentInventory( + String(req.params.id || ''), + req.body, + req.adminSession?.username || 'admin', + ), + { + successMessage: '寄卖物品已更新', + errorMessage: '更新寄卖物品失败', + scope: '[admin/consignment/inventories/update]', + }, + ), +) + +router.patch( + '/worker-platform/consignment/inventories/:id/matching', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => + toggleAdminConsignmentMatching(String(req.params.id || ''), Boolean(req.body?.allowMatching)), + { + successMessage: '匹配开关已更新', + errorMessage: '更新匹配开关失败', + scope: '[admin/consignment/inventories/matching]', + }, + ), +) + +router.post( + '/worker-platform/consignment/inventories/:id/archive', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => archiveAdminConsignmentInventory(String(req.params.id || '')), { + successMessage: '物品已下架作废', + errorMessage: '作废寄卖物品失败', + scope: '[admin/consignment/inventories/archive]', + }), +) + +router.post( + '/worker-platform/consignment/inventories/:id/return', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => + returnAdminConsignmentInventory( + String(req.params.id || ''), + req.body, + req.adminSession?.username || 'admin', + ), + { + successMessage: '物品已退回复库', + errorMessage: '退回复库失败', + scope: '[admin/consignment/inventories/return]', + }, + ), +) + +// ---------------- 收集链接管理 ---------------- + +router.get( + '/worker-platform/consignment/links', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler(() => fetchAdminConsignmentLinks(), { + successMessage: 'ok', + errorMessage: '读取收集链接列表失败', + scope: '[admin/consignment/links]', + }), +) + +router.post( + '/worker-platform/consignment/links', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => createAdminConsignmentLink(req.body, req.adminSession?.username || 'admin'), + { + successMessage: '收集链接已生成', + errorMessage: '生成收集链接失败', + scope: '[admin/consignment/links/create]', + }, + ), +) + +router.put( + '/worker-platform/consignment/links/:id', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => updateAdminConsignmentLink(String(req.params.id || ''), req.body), { + successMessage: '收集链接已更新', + errorMessage: '更新收集链接失败', + scope: '[admin/consignment/links/update]', + }), +) + +// ---------------- 提报单审核 ---------------- + +router.get( + '/worker-platform/consignment/submissions', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => fetchAdminConsignmentSubmissions(req.query), { + successMessage: 'ok', + errorMessage: '读取提报列表失败', + scope: '[admin/consignment/submissions]', + }), +) + +router.get( + '/worker-platform/consignment/submissions/:id', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => getAdminConsignmentSubmission(String(req.params.id || '')), { + successMessage: 'ok', + errorMessage: '读取提报详情失败', + scope: '[admin/consignment/submissions/:id]', + }), +) + +router.post( + '/worker-platform/consignment/submissions/:id/approve', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => + approveAdminConsignmentSubmission( + String(req.params.id || ''), + req.body, + req.adminSession?.username || 'admin', + ), + { + successMessage: '审核通过,已正式入库', + errorMessage: '审核入库失败', + scope: '[admin/consignment/submissions/approve]', + }, + ), +) + +router.post( + '/worker-platform/consignment/submissions/:id/reject', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => + rejectAdminConsignmentSubmission( + String(req.params.id || ''), + req.body, + req.adminSession?.username || 'admin', + ), + { + successMessage: '已驳回该提报', + errorMessage: '驳回提报失败', + scope: '[admin/consignment/submissions/reject]', + }, + ), +) + +// ---------------- 匹配与履约 ---------------- + +router.get( + '/worker-platform/consignment/matches/:workOrderId', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => checkWorkOrderInventoryMatches(String(req.params.workOrderId || '')), { + successMessage: 'ok', + errorMessage: '检查寄售库存匹配失败', + scope: '[admin/consignment/matches]', + }), +) + +router.post( + '/worker-platform/consignment/fulfill', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => + fulfillWorkOrderWithConsignment({ + workOrderId: String(req.body?.workOrderId || ''), + inventoryId: String(req.body?.inventoryId || ''), + actorName: req.adminSession?.username || 'admin', + }), + { + successMessage: '已使用内部寄售库存直接履约!', + errorMessage: '内部履约失败', + scope: '[admin/consignment/fulfill]', + }, + ), +) + +router.post( + '/worker-platform/consignment/revoke', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => + revokeWorkOrderConsignmentFulfillment({ + workOrderId: String(req.body?.workOrderId || ''), + action: + req.body?.action === 'cancel_order' + ? 'cancel_order' + : req.body?.action === 'archive_inventory' + ? 'archive_inventory' + : 'reopen', + reason: String(req.body?.reason || ''), + actorName: req.adminSession?.username || 'admin', + }), + { + successMessage: '寄售履约已撤销', + errorMessage: '撤销履约失败', + scope: '[admin/consignment/revoke]', + }, + ), +) + +export default router diff --git a/apps/backend/src/routes/consignment.ts b/apps/backend/src/routes/consignment.ts new file mode 100644 index 00000000..7ac9b86b --- /dev/null +++ b/apps/backend/src/routes/consignment.ts @@ -0,0 +1,67 @@ +import { Router } from 'express' +import { createRateLimitMiddleware } from '../middleware/rate-limit.js' +import { + getPublicConsignmentLink, + submitPublicConsignment, +} from '../services/worker-platform/consignment-public-service.js' +import { uploadFileAsset } from '../services/file-storage/file-storage-service.js' +import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js' +import { uploadSingleFile } from './file-upload.js' + +const router = Router() + +const consignmentRateLimit = createRateLimitMiddleware({ + scope: 'consignment-public', + windowMs: 60_000, + max: 60, +}) + +router.get( + '/:token', + consignmentRateLimit, + createRouteHandler((req) => getPublicConsignmentLink(String(req.params.token || '')), { + successMessage: 'ok', + errorMessage: '获取收集信息失败', + scope: '[consignment/link]', + }), +) + +router.post( + '/:token/submit', + consignmentRateLimit, + createRouteHandler( + (req) => submitPublicConsignment(String(req.params.token || ''), req.body || {}), + { + successMessage: '寄卖申请已提交,客服审核后将与您联系', + errorMessage: '提交寄卖信息失败', + scope: '[consignment/submit]', + }, + ), +) + +router.post( + '/upload', + consignmentRateLimit, + uploadSingleFile, + createRouteHandler( + (req) => { + return uploadFileAsset({ + file: req.file, + scene: req.body?.scene || 'consignment-material', + uploaderType: 'consignment', + uploaderId: 'anonymous', + }) + }, + { + successMessage: '凭据图片已上传', + errorMessage: '凭据图片上传失败', + scope: '[consignment/upload]', + }, + ), +) + +router.use((req, res) => { + res.status(404).json(buildNotFoundPayload(req)) +}) + +export default router diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts new file mode 100644 index 00000000..c8682bd1 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -0,0 +1,494 @@ +import { + type ConsignmentCollectLinkRow, + type ConsignmentInventoryRow, + type ConsignmentSubmissionRow, + approveConsignmentSubmissionWithInbound, + createConsignmentCollectLink, + createConsignmentInventory, + getConsignmentInventoryById, + getConsignmentInventoryStats, + getConsignmentSubmissionById, + listConsignmentCollectLinks, + listConsignmentInventories, + listConsignmentSubmissions, + rejectConsignmentSubmission, + returnConsignmentInventoryToStock, + updateConsignmentCollectLink, + updateConsignmentInventory, +} from '../../repositories/worker-platform/consignment-repo.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { generateConsignmentInventoryNo, randomToken } from '../../utils/random.js' +import { nowIso } from '../../utils/time.js' +import { safeParseJson } from '../admin/admin-query-utils.js' + +export function mapConsignmentInventory(row: ConsignmentInventoryRow) { + return { + id: Number(row.id), + inventoryNo: row.inventory_no, + gameName: row.game_name, + categoryId: row.category_id ? Number(row.category_id) : null, + categoryName: row.category_name, + itemName: row.item_name, + system: row.system, + serverZone: row.server_zone, + series: row.series, + diamondLevel: Number(row.diamond_level || 1), + brillianceCount: Number(row.brilliance_count || 0), + costPriceFen: Number(row.cost_price_fen || 0), + buyerName: row.buyer_name || '', + shopName: row.shop_name || '', + inboundBatch: row.inbound_batch || '', + sellerContact: row.seller_contact || '', + sellerGameId: row.seller_game_id || '', + sellerPaymentInfo: row.seller_payment_info || '', + sellerPhone: row.seller_phone || '', + credentialsJson: + row.credentials_json && typeof row.credentials_json === 'object' + ? (row.credentials_json as Record) + : safeParseJson(row.credentials_json), + screenshotsJson: Array.isArray(row.screenshots_json) + ? row.screenshots_json + : Array.isArray(safeParseJson(row.screenshots_json)) + ? (safeParseJson(row.screenshots_json) as unknown[]) + : [], + remark: row.remark || '', + allowMatching: Boolean(row.allow_matching), + status: row.status, + allocationStatus: row.allocation_status || '待安排', + boundWorkOrderId: row.bound_work_order_id ? Number(row.bound_work_order_id) : null, + boundPlatformOrderId: row.bound_platform_order_id || '', + sourceType: row.source_type, + sourceSubmissionId: row.source_submission_id ? Number(row.source_submission_id) : null, + inboundAt: row.inbound_at || row.created_at, + consumedAt: row.consumed_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +export function mapConsignmentCollectLink(row: ConsignmentCollectLinkRow) { + return { + id: Number(row.id), + token: row.token, + title: row.title, + gameName: row.game_name, + categoryId: row.category_id ? Number(row.category_id) : null, + categoryName: row.category_name, + system: row.system, + serverZone: row.server_zone, + series: row.series, + diamondLevel: Number(row.diamond_level || 1), + brillianceCount: Number(row.brilliance_count || 0), + presetPriceFen: Number(row.preset_price_fen || 0), + inboundBatch: row.inbound_batch || '', + buyerName: row.buyer_name || '', + templateJson: + row.template_json && typeof row.template_json === 'object' + ? (row.template_json as Record) + : safeParseJson(row.template_json), + isMultiUse: Boolean(row.is_multi_use), + expiresAt: row.expires_at, + enabled: Boolean(row.enabled), + createdBy: row.created_by || '', + createdAt: row.created_at, + updatedAt: row.updated_at, + submissionCount: Number(row.submission_count || 0), + } +} + +export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) { + return { + id: Number(row.id), + submissionNo: row.submission_no, + linkId: row.link_id ? Number(row.link_id) : null, + linkTitle: row.link_title, + gameName: row.game_name, + categoryId: row.category_id ? Number(row.category_id) : null, + categoryName: row.category_name, + itemName: row.item_name, + system: row.system, + serverZone: row.server_zone, + series: row.series, + diamondLevel: Number(row.diamond_level || 1), + brillianceCount: Number(row.brilliance_count || 0), + sellerGameId: row.seller_game_id || '', + sellerPaymentInfo: row.seller_payment_info || '', + sellerPhone: row.seller_phone || '', + contactInfo: row.contact_info || '', + contentJson: + row.content_json && typeof row.content_json === 'object' + ? (row.content_json as Record) + : safeParseJson(row.content_json), + screenshotsJson: Array.isArray(row.screenshots_json) + ? row.screenshots_json + : Array.isArray(safeParseJson(row.screenshots_json)) + ? (safeParseJson(row.screenshots_json) as unknown[]) + : [], + askingPriceFen: Number(row.asking_price_fen || 0), + status: row.status, + reviewNote: row.review_note || '', + reviewerName: row.reviewer_name || '', + reviewedAt: row.reviewed_at, + resultInventoryId: row.result_inventory_id ? Number(row.result_inventory_id) : null, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +export async function fetchAdminConsignmentInventories(query: JsonObject = {}) { + const status = query.status ? String(query.status).trim() : undefined + const buyerName = query.buyerName ? String(query.buyerName).trim() : undefined + const shopName = query.shopName ? String(query.shopName).trim() : undefined + const inboundBatch = query.inboundBatch ? String(query.inboundBatch).trim() : undefined + const categoryId = query.categoryId ? Number(query.categoryId) : undefined + const system = query.system ? String(query.system).trim() : undefined + const serverZone = query.serverZone ? String(query.serverZone).trim() : undefined + const series = query.series ? String(query.series).trim() : undefined + const diamondLevel = query.diamondLevel !== undefined ? Number(query.diamondLevel) : undefined + const allowMatching = + query.allowMatching !== undefined ? String(query.allowMatching) === 'true' : undefined + const keyword = query.keyword ? String(query.keyword).trim() : undefined + const page = query.page ? Number(query.page) : 1 + const pageSize = query.pageSize ? Number(query.pageSize) : 20 + + const res = await listConsignmentInventories({ + status, + buyerName, + shopName, + inboundBatch, + categoryId, + system, + serverZone, + series, + diamondLevel, + allowMatching, + keyword, + page, + pageSize, + }) + + return { + items: res.items.map(mapConsignmentInventory), + total: res.total, + } +} + +export async function fetchAdminConsignmentStats() { + return getConsignmentInventoryStats() +} + +export async function getAdminConsignmentInventory(id: number | string) { + const item = await getConsignmentInventoryById(id) + if (!item) { + throw createHttpError('寄卖物品不存在', { statusCode: 404, errorCode: 'consignment_not_found' }) + } + return mapConsignmentInventory(item) +} + +export async function createAdminConsignmentInventory(payload: JsonObject, actorName = '') { + const itemName = String(payload.itemName || '').trim() + if (!itemName) { + throw createHttpError('物品名称不能为空', { statusCode: 400, errorCode: 'invalid_item_name' }) + } + + const now = nowIso() + const inventoryNo = generateConsignmentInventoryNo() + const costPriceFen = Math.max(0, Math.round(Number(payload.costPriceFen || 0))) + + const row = await createConsignmentInventory({ + inventoryNo, + gameName: String(payload.gameName || '和平精英').trim(), + categoryId: payload.categoryId ? Number(payload.categoryId) : null, + itemName, + system: String(payload.system || '').trim(), + serverZone: String(payload.serverZone || '').trim(), + series: String(payload.series || '').trim(), + diamondLevel: Math.max(1, Math.min(6, Number(payload.diamondLevel || 1))), + brillianceCount: Math.max(0, Math.min(6, Number(payload.brillianceCount || 0))), + costPriceFen, + buyerName: String(payload.buyerName || actorName || '').trim(), + inboundBatch: String(payload.inboundBatch || '').trim(), + sellerContact: String(payload.sellerContact || '').trim(), + sellerGameId: String(payload.sellerGameId || '').trim(), + sellerPaymentInfo: String(payload.sellerPaymentInfo || '').trim(), + sellerPhone: String(payload.sellerPhone || '').trim(), + credentialsJson: + payload.credentials && typeof payload.credentials === 'object' + ? (payload.credentials as Record) + : {}, + screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : [], + remark: String(payload.remark || '').trim(), + allowMatching: payload.allowMatching !== false, + sourceType: 'manual', + now, + }) + + return mapConsignmentInventory(row) +} + +export async function updateAdminConsignmentInventory( + id: number | string, + payload: JsonObject, + _actorName = '', +) { + const now = nowIso() + const updated = await updateConsignmentInventory(id, { + itemName: payload.itemName !== undefined ? String(payload.itemName).trim() : undefined, + system: payload.system !== undefined ? String(payload.system).trim() : undefined, + serverZone: payload.serverZone !== undefined ? String(payload.serverZone).trim() : undefined, + series: payload.series !== undefined ? String(payload.series).trim() : undefined, + diamondLevel: payload.diamondLevel !== undefined ? Number(payload.diamondLevel) : undefined, + brillianceCount: + payload.brillianceCount !== undefined ? Number(payload.brillianceCount) : undefined, + costPriceFen: + payload.costPriceFen !== undefined + ? Math.max(0, Math.round(Number(payload.costPriceFen))) + : undefined, + buyerName: payload.buyerName !== undefined ? String(payload.buyerName).trim() : undefined, + inboundBatch: + payload.inboundBatch !== undefined ? String(payload.inboundBatch).trim() : undefined, + sellerContact: + payload.sellerContact !== undefined ? String(payload.sellerContact).trim() : undefined, + sellerGameId: + payload.sellerGameId !== undefined ? String(payload.sellerGameId).trim() : undefined, + sellerPaymentInfo: + payload.sellerPaymentInfo !== undefined + ? String(payload.sellerPaymentInfo).trim() + : undefined, + sellerPhone: payload.sellerPhone !== undefined ? String(payload.sellerPhone).trim() : undefined, + credentialsJson: + payload.credentials && typeof payload.credentials === 'object' + ? (payload.credentials as Record) + : undefined, + screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : undefined, + remark: payload.remark !== undefined ? String(payload.remark).trim() : undefined, + allowMatching: payload.allowMatching !== undefined ? Boolean(payload.allowMatching) : undefined, + status: payload.status !== undefined ? (payload.status as any) : undefined, + now, + }) + + if (!updated) { + throw createHttpError('寄卖物品不存在', { statusCode: 404, errorCode: 'consignment_not_found' }) + } + return mapConsignmentInventory(updated) +} + +/** 快捷切换匹配开关 */ +export async function toggleAdminConsignmentMatching(id: number | string, allowMatching: boolean) { + const now = nowIso() + const updated = await updateConsignmentInventory(id, { + allowMatching: Boolean(allowMatching), + now, + }) + if (!updated) { + throw createHttpError('寄卖物品不存在', { statusCode: 404, errorCode: 'consignment_not_found' }) + } + return mapConsignmentInventory(updated) +} + +/** 物品下架作废 */ +export async function archiveAdminConsignmentInventory(id: number | string) { + const now = nowIso() + const updated = await updateConsignmentInventory(id, { + status: 'archived', + allowMatching: false, + now, + }) + if (!updated) { + throw createHttpError('寄卖物品不存在', { statusCode: 404, errorCode: 'consignment_not_found' }) + } + return mapConsignmentInventory(updated) +} + +/** 已出库物品退回复库 */ +export async function returnAdminConsignmentInventory( + id: number | string, + payload: JsonObject, + _actorName = '', +) { + const now = nowIso() + const reason = String(payload.reason || '').trim() + const updated = await returnConsignmentInventoryToStock(id, reason, now) + if (!updated) { + throw createHttpError('只有已出库物品可以退回复库', { + statusCode: 409, + errorCode: 'consignment_not_returnable', + }) + } + return mapConsignmentInventory(updated) +} + +// ---------------- 收集链接管理 ---------------- + +export async function fetchAdminConsignmentLinks() { + const items = await listConsignmentCollectLinks() + return { items: items.map(mapConsignmentCollectLink) } +} + +export async function createAdminConsignmentLink(payload: JsonObject, actorName = '') { + const title = String(payload.title || '').trim() + if (!title) { + throw createHttpError('链接标题不能为空', { statusCode: 400, errorCode: 'invalid_link_title' }) + } + + const now = nowIso() + const token = randomToken(16) + + const row = await createConsignmentCollectLink({ + token, + title, + gameName: String(payload.gameName || '和平精英').trim(), + categoryId: payload.categoryId ? Number(payload.categoryId) : null, + system: String(payload.system || '').trim(), + serverZone: String(payload.serverZone || '').trim(), + series: String(payload.series || '').trim(), + diamondLevel: Math.max(1, Math.min(6, Number(payload.diamondLevel || 1))), + brillianceCount: Math.max(0, Math.min(6, Number(payload.brillianceCount || 0))), + presetPriceFen: Math.max(0, Math.round(Number(payload.presetPriceFen || 0))), + inboundBatch: String(payload.inboundBatch || '').trim(), + buyerName: String(payload.buyerName || actorName || '').trim(), + templateJson: + payload.template && typeof payload.template === 'object' + ? (payload.template as Record) + : {}, + isMultiUse: payload.isMultiUse !== false, + expiresAt: payload.expiresAt ? String(payload.expiresAt) : null, + createdBy: actorName, + now, + }) + + return mapConsignmentCollectLink(row) +} + +export async function updateAdminConsignmentLink(id: number | string, payload: JsonObject) { + const now = nowIso() + const updated = await updateConsignmentCollectLink(id, { + title: payload.title !== undefined ? String(payload.title).trim() : undefined, + enabled: payload.enabled !== undefined ? Boolean(payload.enabled) : undefined, + now, + }) + if (!updated) { + throw createHttpError('收集链接不存在', { statusCode: 404, errorCode: 'link_not_found' }) + } + return mapConsignmentCollectLink(updated) +} + +// ---------------- 提报单审核 ---------------- + +export async function fetchAdminConsignmentSubmissions(query: JsonObject = {}) { + const status = query.status ? String(query.status).trim() : undefined + const linkId = query.linkId ? Number(query.linkId) : undefined + const keyword = query.keyword ? String(query.keyword).trim() : undefined + const page = query.page ? Number(query.page) : 1 + const pageSize = query.pageSize ? Number(query.pageSize) : 20 + + const res = await listConsignmentSubmissions({ + status, + linkId, + keyword, + page, + pageSize, + }) + + return { + items: res.items.map(mapConsignmentSubmission), + total: res.total, + } +} + +export async function getAdminConsignmentSubmission(id: number | string) { + const item = await getConsignmentSubmissionById(id) + if (!item) { + throw createHttpError('提报单不存在', { statusCode: 404, errorCode: 'submission_not_found' }) + } + return mapConsignmentSubmission(item) +} + +export async function approveAdminConsignmentSubmission( + id: number | string, + payload: JsonObject, + actorName = '', +) { + const submission = await getConsignmentSubmissionById(id) + if (!submission) { + throw createHttpError('提报单不存在', { statusCode: 404, errorCode: 'submission_not_found' }) + } + if (submission.status !== 'pending_review') { + throw createHttpError('该提报单已被处理', { + statusCode: 409, + errorCode: 'submission_already_reviewed', + }) + } + + const now = nowIso() + const inventoryNo = generateConsignmentInventoryNo() + const costPriceFen = Math.max( + 0, + Math.round(Number(payload.costPriceFen ?? submission.asking_price_fen ?? 0)), + ) + + const res = await approveConsignmentSubmissionWithInbound({ + submissionId: id, + inventoryNo, + buyerName: String(payload.buyerName || actorName || '').trim(), + inboundBatch: String(payload.inboundBatch || '').trim(), + itemName: String(payload.itemName || submission.item_name).trim(), + system: String(payload.system || submission.system).trim(), + serverZone: String(payload.serverZone || submission.server_zone).trim(), + series: String(payload.series || submission.series).trim(), + diamondLevel: Math.max( + 1, + Math.min(6, Number(payload.diamondLevel ?? submission.diamond_level ?? 1)), + ), + brillianceCount: Math.max( + 0, + Math.min(6, Number(payload.brillianceCount ?? submission.brilliance_count ?? 0)), + ), + costPriceFen, + sellerContact: String(payload.sellerContact || submission.contact_info).trim(), + sellerGameId: String(payload.sellerGameId || submission.seller_game_id).trim(), + sellerPaymentInfo: String(payload.sellerPaymentInfo || submission.seller_payment_info).trim(), + sellerPhone: String(payload.sellerPhone || submission.seller_phone).trim(), + credentialsJson: + payload.credentials && typeof payload.credentials === 'object' + ? (payload.credentials as Record) + : undefined, + screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : undefined, + reviewNote: String(payload.reviewNote || '').trim(), + now, + }) + + return { + submission: mapConsignmentSubmission(res.submission), + inventory: mapConsignmentInventory(res.inventory), + } +} + +export async function rejectAdminConsignmentSubmission( + id: number | string, + payload: JsonObject, + actorName = '', +) { + const note = String(payload.reviewNote || '').trim() + if (!note) { + throw createHttpError('请填写驳回原因', { + statusCode: 400, + errorCode: 'reject_reason_required', + }) + } + const now = nowIso() + const updated = await rejectConsignmentSubmission({ + submissionId: id, + reviewNote: note, + reviewerName: actorName, + now, + }) + if (!updated) { + throw createHttpError('提报单不存在或已被处理', { + statusCode: 409, + errorCode: 'submission_already_reviewed', + }) + } + return mapConsignmentSubmission(updated) +} diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts new file mode 100644 index 00000000..1b41df37 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -0,0 +1,385 @@ +import type { PoolClient } from 'pg' +import { withTransaction } from '../../db/client.js' +import { WORK_ORDER_STATUS, type WorkOrderStatus } from '../../domain/work-order-status.js' +import { + type ConsignmentInventoryRow, + getConsignmentInventoryById, + listActiveConsignmentInventoriesForMatching, +} from '../../repositories/worker-platform/consignment-repo.js' +import { + createWorkOrderEvent, + getWorkOrderById, + type WorkOrderRow, +} from '../../repositories/worker-platform/index.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { safeParseJson } from '../admin/admin-query-utils.js' +import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' +import { mapWorkOrderAdmin } from './mappers.js' +import { + consumeIndustryVouchersBeforeWorkOrderPublish, + type WorkOrderVoucherConsumeResult, +} from './publish-voucher-service.js' +import { + type ConsignmentMatchResult, + findMatchingPeacekeeperInventories, +} from './consignment-matching-service.js' +import { mapConsignmentInventory } from './consignment-admin-service.js' + +export type ConsignmentMatchResultView = Omit & { + inventory: ReturnType +} + +/** + * 检查工单对应的内部寄售库存匹配情况 + */ +export async function checkWorkOrderInventoryMatches(workOrderId: number | string): Promise<{ + hasMatch: boolean + exactCount: number + partialCount: number + matches: ConsignmentMatchResultView[] +}> { + const workOrder = await getWorkOrderById(workOrderId) + if (!workOrder) { + throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' }) + } + + const material = safeParseJson(workOrder.material_json) + const collect = safeParseJson(material.collect) + const fields = safeParseJson(collect.fields) + + const system = String(fields.system || '').trim() + const serverZone = String(fields.serverZone || '').trim() + + const inventories = await listActiveConsignmentInventoriesForMatching() + + const matches = findMatchingPeacekeeperInventories(workOrder.product_name, inventories, { + system, + serverZone, + }) + + const exactCount = matches.filter((m) => m.matchType === 'exact').length + const partialCount = matches.filter((m) => m.matchType === 'partial').length + + return { + hasMatch: matches.length > 0, + exactCount, + partialCount, + matches: matches.map((m) => ({ + ...m, + inventory: mapConsignmentInventory(m.inventory), + })), + } +} + +/** + * 选用内部寄售库存直接履约工单 + */ +export async function fulfillWorkOrderWithConsignment(input: { + workOrderId: number | string + inventoryId: number | string + actorName: string +}): Promise<{ + order: ReturnType + inventory: ReturnType + voucherConsume: WorkOrderVoucherConsumeResult +}> { + const workOrder = await getWorkOrderById(input.workOrderId) + if (!workOrder) { + throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' }) + } + + // 仅未分配或待补资料工单支持选用库存履约 + if ( + ![WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.PENDING_MATERIAL].includes( + workOrder.status as any, + ) + ) { + throw createHttpError('当前工单状态不允许直接使用内部库存履约', { + statusCode: 409, + errorCode: 'work_order_status_not_fulfillable', + }) + } + + const inventory = await getConsignmentInventoryById(input.inventoryId) + if (!inventory) { + throw createHttpError('寄卖物品不存在', { + statusCode: 404, + errorCode: 'consignment_inventory_not_found', + }) + } + + if (inventory.status !== 'in_stock' || !inventory.allow_matching) { + throw createHttpError('该寄卖物品不可用或已被暂停匹配', { + statusCode: 409, + errorCode: 'consignment_inventory_unavailable', + }) + } + + const material = safeParseJson(workOrder.material_json) + const source = safeParseJson(material.source) + const shopName = String(source.shopName || source.shopTitle || '').trim() + + const now = nowIso() + + // 开启原子事务扣减库存并流转工单 + const { updatedOrder, updatedInventory } = await withTransaction(async (client: PoolClient) => { + const invSql = ` + UPDATE consignment_inventories + SET + status = 'consumed', + allocation_status = '已安排', + bound_order_id = $1, + bound_work_order_id = $2, + bound_platform_order_id = $3, + shop_name = $4, + consumed_at = $5, + updated_at = $5 + WHERE id = $6 AND status = 'in_stock' + RETURNING * + ` + const invRes = await client.query(invSql, [ + workOrder.order_id || null, + workOrder.id, + workOrder.platform_order_id || '', + shopName, + now, + inventory.id, + ]) + + const inv = invRes.rows[0] + if (!inv) { + throw createHttpError('寄卖库存已被其他操作占用,请刷新重试', { + statusCode: 409, + errorCode: 'consignment_inventory_race_condition', + }) + } + + const updatedMaterial = { + ...material, + consignment: { + inventoryId: Number(inv.id), + inventoryNo: inv.inventory_no, + itemName: inv.item_name, + system: inv.system, + serverZone: inv.server_zone, + series: inv.series, + diamondLevel: inv.diamond_level, + brillianceCount: inv.brilliance_count, + buyerName: inv.buyer_name, + inboundBatch: inv.inbound_batch, + credentials: safeParseJson(inv.credentials_json), + costPriceFen: inv.cost_price_fen, + fulfilledAt: now, + fulfilledBy: input.actorName, + }, + } + + const woSql = ` + UPDATE work_orders + SET + status = 'completed', + material_json = $1::jsonb, + accepted_at = $2, + updated_at = $2 + WHERE id = $3 AND status = $4 + RETURNING * + ` + const woRes = await client.query(woSql, [ + JSON.stringify(updatedMaterial), + now, + workOrder.id, + workOrder.status, + ]) + + const wo = woRes.rows[0] + if (!wo) { + throw createHttpError('工单状态已发生变化,履约失败', { + statusCode: 409, + errorCode: 'work_order_conflict', + }) + } + + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'operator', + actorId: input.actorName || 'admin', + eventType: 'consignment_fulfilled', + fromStatus: workOrder.status, + toStatus: 'completed', + payloadJson: JSON.stringify({ + inventoryId: inv.id, + inventoryNo: inv.inventory_no, + itemName: inv.item_name, + costPriceFen: inv.cost_price_fen, + buyerName: inv.buyer_name, + shopName, + }), + now, + }) + + return { updatedOrder: wo, updatedInventory: inv } + }) + + // 顺带核销快手电子凭证 + let voucherConsume: WorkOrderVoucherConsumeResult + try { + voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(updatedOrder) + } catch (error) { + voucherConsume = { + ok: false, + voucherCount: 0, + consumedCount: 0, + alreadyConsumedCount: 0, + failedCount: 1, + voucherCodes: [], + errorMessage: error instanceof Error ? error.message : '电子凭证核销失败', + } + } + + publishWorkOrderRealtimeChange({ workOrderId: Number(updatedOrder.id), hallChanged: true }) + + return { + order: mapWorkOrderAdmin(updatedOrder), + inventory: mapConsignmentInventory(updatedInventory), + voucherConsume, + } +} + +/** + * 撤销寄售履约(用于选错重选、买家退款或物品异常) + */ +export async function revokeWorkOrderConsignmentFulfillment(input: { + workOrderId: number | string + action: 'reopen' | 'archive_inventory' | 'cancel_order' + reason: string + actorName: string +}): Promise<{ + order: ReturnType + inventory: ReturnType | null +}> { + const workOrder = await getWorkOrderById(input.workOrderId) + if (!workOrder) { + throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' }) + } + + const material = safeParseJson(workOrder.material_json) + const consignmentInfo = safeParseJson(material.consignment) + const inventoryId = consignmentInfo.inventoryId ? Number(consignmentInfo.inventoryId) : null + + const now = nowIso() + + const { updatedOrder, updatedInventory } = await withTransaction(async (client: PoolClient) => { + let inv: ConsignmentInventoryRow | null = null + + // 1. 处理关联库存解绑与状态流转 + if (inventoryId) { + if (input.action === 'archive_inventory') { + // 物品异常作废 + const invSql = ` + UPDATE consignment_inventories + SET + status = 'archived', + allow_matching = FALSE, + bound_order_id = NULL, + bound_work_order_id = NULL, + bound_platform_order_id = '', + shop_name = '', + remark = remark || ' [交付异常作废: ' || $1 || ']', + updated_at = $2 + WHERE id = $3 + RETURNING * + ` + const invRes = await client.query(invSql, [ + input.reason || '异常作废', + now, + inventoryId, + ]) + inv = invRes.rows[0] || null + } else { + // 恢复在库 (in_stock) + const invSql = ` + UPDATE consignment_inventories + SET + status = 'in_stock', + allocation_status = '待安排', + bound_order_id = NULL, + bound_work_order_id = NULL, + bound_platform_order_id = '', + shop_name = '', + consumed_at = NULL, + allow_matching = TRUE, + remark = remark || ' [撤销回库: ' || $1 || ']', + updated_at = $2 + WHERE id = $3 + RETURNING * + ` + const invRes = await client.query(invSql, [ + input.reason || '撤单回库', + now, + inventoryId, + ]) + inv = invRes.rows[0] || null + } + } + + // 2. 处理工单状态流转 + let nextOrderStatus: WorkOrderStatus = WORK_ORDER_STATUS.UNASSIGNED + if (input.action === 'cancel_order') { + nextOrderStatus = WORK_ORDER_STATUS.CANCELLED + } + + const { consignment: _removed, ...cleanMaterial } = material + const archiveHistory = [ + ...(Array.isArray(material.consignmentHistory) ? material.consignmentHistory : []), + { + ...consignmentInfo, + revokedAt: now, + revokedBy: input.actorName, + revokeReason: input.reason, + }, + ] + + const woSql = ` + UPDATE work_orders + SET + status = $1, + material_json = $2::jsonb, + updated_at = $3 + WHERE id = $4 + RETURNING * + ` + const woRes = await client.query(woSql, [ + nextOrderStatus, + JSON.stringify({ ...cleanMaterial, consignmentHistory: archiveHistory }), + now, + workOrder.id, + ]) + const wo = woRes.rows[0]! + + // 3. 记录事件 + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'operator', + actorId: input.actorName || 'admin', + eventType: 'consignment_revoked', + fromStatus: workOrder.status, + toStatus: nextOrderStatus, + payloadJson: JSON.stringify({ + action: input.action, + reason: input.reason, + inventoryId, + }), + now, + }) + + return { updatedOrder: wo, updatedInventory: inv } + }) + + publishWorkOrderRealtimeChange({ workOrderId: Number(updatedOrder.id), hallChanged: true }) + + return { + order: mapWorkOrderAdmin(updatedOrder), + inventory: updatedInventory ? mapConsignmentInventory(updatedInventory) : null, + } +} diff --git a/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts b/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts new file mode 100644 index 00000000..ad1a898c --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js' +import { + extractPeacekeeperSpecs, + findMatchingPeacekeeperInventories, + matchPeacekeeperOrderWithInventory, +} from './consignment-matching-service.js' + +function buildInventory(overrides: Partial = {}): ConsignmentInventoryRow { + return { + id: 1, + inventory_no: 'INV001', + game_name: '和平精英', + category_id: 1, + item_name: '安卓微信区 暗潮 1钻', + system: '安卓', + server_zone: '微信区', + series: '暗潮', + diamond_level: 1, + brilliance_count: 0, + cost_price_fen: 10500, + buyer_name: '周杰雄', + inbound_batch: '8.30-1', + seller_contact: 'wx_123', + seller_game_id: 'UID8888', + seller_payment_info: '王大锤商行 331682...', + seller_phone: '13800000000', + credentials_json: {}, + screenshots_json: [], + remark: '', + allow_matching: true, + status: 'in_stock', + allocation_status: '待安排', + bound_order_id: null, + bound_work_order_id: null, + bound_platform_order_id: '', + shop_name: '', + source_type: 'manual', + source_submission_id: null, + inbound_at: new Date().toISOString(), + consumed_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + } +} + +test('extractPeacekeeperSpecs extracts specs accurately', () => { + const spec1 = extractPeacekeeperSpecs('安卓微信区 苏尔南手枪 暗潮 1钻') + assert.equal(spec1.system, '安卓') + assert.equal(spec1.serverZone, '微信区') + assert.equal(spec1.series, '暗潮') + assert.equal(spec1.diamondLevel, 1) + assert.equal(spec1.brillianceCount, 0) + + const spec2 = extractPeacekeeperSpecs('苹果QQ区 怒嚎 2钻 1璀璨') + assert.equal(spec2.system, '苹果') + assert.equal(spec2.serverZone, 'QQ区') + assert.equal(spec2.series, '怒嚎') + assert.equal(spec2.diamondLevel, 2) + assert.equal(spec2.brillianceCount, 1) +}) + +test('matchPeacekeeperOrderWithInventory matches exact spec', () => { + const order = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻') + const inv = buildInventory({ + system: '安卓', + server_zone: '微信区', + series: '暗潮', + diamond_level: 1, + brilliance_count: 0, + }) + + const matched = matchPeacekeeperOrderWithInventory(order, inv) + assert.ok(matched) + assert.equal(matched.matchType, 'exact') + assert.equal(matched.score, 100) +}) + +test('matchPeacekeeperOrderWithInventory allows partial match for higher brilliance (高配充低配)', () => { + const order = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻') + const inv = buildInventory({ + system: '安卓', + server_zone: '微信区', + series: '暗潮', + diamond_level: 1, + brilliance_count: 1, + }) + + const matched = matchPeacekeeperOrderWithInventory(order, inv) + assert.ok(matched) + assert.equal(matched.matchType, 'partial') + assert.equal(matched.score, 80) + assert.ok(matched.reason.includes('多出【1璀璨】')) +}) + +test('matchPeacekeeperOrderWithInventory rejects series mismatch', () => { + const order = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻') + const inv = buildInventory({ + system: '安卓', + server_zone: '微信区', + series: '怒嚎', + diamond_level: 1, + }) + + const matched = matchPeacekeeperOrderWithInventory(order, inv) + assert.equal(matched, null) +}) + +test('matchPeacekeeperOrderWithInventory rejects when allow_matching is false', () => { + const order = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻') + const inv = buildInventory({ + system: '安卓', + server_zone: '微信区', + series: '暗潮', + diamond_level: 1, + allow_matching: false, + }) + + const matched = matchPeacekeeperOrderWithInventory(order, inv) + assert.equal(matched, null) +}) + +test('matchPeacekeeperOrderWithInventory rejects lower tier for higher tier order', () => { + const order = extractPeacekeeperSpecs('安卓微信区 暗潮 2钻') + const inv = buildInventory({ + system: '安卓', + server_zone: '微信区', + series: '暗潮', + diamond_level: 1, + }) + + const matched = matchPeacekeeperOrderWithInventory(order, inv) + assert.equal(matched, null) +}) + +test('findMatchingPeacekeeperInventories ranks exact before partial and lower cost first', () => { + const invExactHigh = buildInventory({ + id: 1, + series: '暗潮', + diamond_level: 1, + brilliance_count: 0, + cost_price_fen: 12000, + }) + const invExactLow = buildInventory({ + id: 2, + series: '暗潮', + diamond_level: 1, + brilliance_count: 0, + cost_price_fen: 9500, + }) + const invPartial = buildInventory({ + id: 3, + series: '暗潮', + diamond_level: 1, + brilliance_count: 1, + cost_price_fen: 8000, + }) + + const matches = findMatchingPeacekeeperInventories('安卓微信区 暗潮 1钻', [ + invExactHigh, + invPartial, + invExactLow, + ]) + + assert.equal(matches.length, 3) + assert.equal(matches[0]!.inventory.id, 2) // exact low cost + assert.equal(matches[1]!.inventory.id, 1) // exact high cost + assert.equal(matches[2]!.inventory.id, 3) // partial +}) diff --git a/apps/backend/src/services/worker-platform/consignment-matching-service.ts b/apps/backend/src/services/worker-platform/consignment-matching-service.ts new file mode 100644 index 00000000..f535e5f1 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-matching-service.ts @@ -0,0 +1,194 @@ +import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js' + +export type ConsignmentMatchResult = { + inventory: ConsignmentInventoryRow + matchType: 'exact' | 'partial' + score: number + matchedSeries: string + matchedDiamond: number + matchedBrilliance: number + reason: string +} + +export type ParsedOrderSpecs = { + system: string + serverZone: string + series: string + diamondLevel: number + brillianceCount: number + rawText: string +} + +/** + * 提取订单或文本中的《和平精英》规格(系统、区服、暗潮/怒嚎/血宴、钻级、璀璨) + */ +export function extractPeacekeeperSpecs( + rawText: string, + extraMeta?: { system?: string; serverZone?: string; series?: string; diamondLevel?: number }, +): ParsedOrderSpecs { + const text = String(rawText || '') + .trim() + .toLowerCase() + + // 1. 系统识别 + let system = (extraMeta?.system || '').trim() + if (!system) { + if (text.includes('苹果') || text.includes('ios') || text.includes('iphone')) { + system = '苹果' + } else if (text.includes('安卓') || text.includes('android')) { + system = '安卓' + } + } + + // 2. 大区识别 + let serverZone = (extraMeta?.serverZone || '').trim() + if (!serverZone) { + if (text.includes('微信') || text.includes('wx')) { + serverZone = '微信区' + } else if (text.includes('qq') || text.includes('手q')) { + serverZone = 'QQ区' + } + } + + // 3. 词条系列识别 (暗潮 / 怒嚎 / 血宴) + let series = (extraMeta?.series || '').trim() + if (!series) { + if (text.includes('暗潮')) { + series = '暗潮' + } else if (text.includes('怒嚎') || text.includes('怒号')) { + series = '怒嚎' + } else if (text.includes('血宴')) { + series = '血宴' + } + } + + // 4. 钻级识别 (1~6钻) + let diamondLevel = extraMeta?.diamondLevel || 1 + const diamondMatch = text.match(/([1-6])\s*钻/) + if (diamondMatch?.[1]) { + diamondLevel = Number(diamondMatch[1]) + } + + // 5. 璀璨数量识别 (0~6璀璨) + let brillianceCount = 0 + const brillianceMatch = text.match(/([1-6])\s*璀璨/) + if (brillianceMatch?.[1]) { + brillianceCount = Number(brillianceMatch[1]) + } else if (text.includes('璀璨')) { + brillianceCount = 1 + } + + return { + system, + serverZone, + series, + diamondLevel, + brillianceCount, + rawText, + } +} + +/** + * 单品匹配度比对 + */ +export function matchPeacekeeperOrderWithInventory( + order: ParsedOrderSpecs, + inventory: ConsignmentInventoryRow, +): ConsignmentMatchResult | null { + // 必须开启匹配,且处于在库状态 + if (!inventory.allow_matching || inventory.status !== 'in_stock') { + return null + } + + // 硬约束:系统必须一致(若指定) + if (order.system && inventory.system && inventory.system !== '通用') { + if (order.system !== inventory.system) { + return null + } + } + + // 硬约束:区服必须一致(若指定) + if (order.serverZone && inventory.server_zone && inventory.server_zone !== '通用') { + if (order.serverZone !== inventory.server_zone) { + return null + } + } + + // 硬约束:系列必须完全一致(暗潮 只能配 暗潮,不能拿 怒嚎 充数) + if (order.series && inventory.series) { + if (order.series !== inventory.series) { + return null + } + } + + const invDiamond = Number(inventory.diamond_level || 1) + const invBrilliance = Number(inventory.brilliance_count || 0) + + // 低配不能充高配:若库存钻级低于订单要求,或库存璀璨低于订单要求,直接不匹配 + if (invDiamond < order.diamondLevel || invBrilliance < order.brillianceCount) { + return null + } + + // 1. 全部匹配(Exact Match) + if (invDiamond === order.diamondLevel && invBrilliance === order.brillianceCount) { + return { + inventory, + matchType: 'exact', + score: 100, + matchedSeries: inventory.series || order.series, + matchedDiamond: invDiamond, + matchedBrilliance: invBrilliance, + reason: '规格完全一致', + } + } + + // 2. 部分匹配(Partial / Superset Match):高配充低配 + const reasons: string[] = [] + if (invDiamond > order.diamondLevel) { + reasons.push(`钻级更高(${invDiamond}钻充${order.diamondLevel}钻)`) + } + if (invBrilliance > order.brillianceCount) { + reasons.push(`多出【${invBrilliance - order.brillianceCount}璀璨】`) + } + + return { + inventory, + matchType: 'partial', + score: invDiamond > order.diamondLevel ? 70 : 80, + matchedSeries: inventory.series || order.series, + matchedDiamond: invDiamond, + matchedBrilliance: invBrilliance, + reason: `属于高配充低配:${reasons.join(',')}`, + } +} + +/** + * 检索并排序可匹配的库存候选 + */ +export function findMatchingPeacekeeperInventories( + rawRequirementText: string, + inventories: ConsignmentInventoryRow[], + extraMeta?: { system?: string; serverZone?: string; series?: string; diamondLevel?: number }, +): ConsignmentMatchResult[] { + const orderSpecs = extractPeacekeeperSpecs(rawRequirementText, extraMeta) + const results: ConsignmentMatchResult[] = [] + + for (const inv of inventories) { + const matched = matchPeacekeeperOrderWithInventory(orderSpecs, inv) + if (matched) { + results.push(matched) + } + } + + // 排序: + // 1. 全部匹配优先 (score DESC) + // 2. 收货成本从低到高 (cost_price_fen ASC) + // 3. 先入库优先 (id ASC) + return results.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score + if (a.inventory.cost_price_fen !== b.inventory.cost_price_fen) { + return a.inventory.cost_price_fen - b.inventory.cost_price_fen + } + return Number(a.inventory.id) - Number(b.inventory.id) + }) +} diff --git a/apps/backend/src/services/worker-platform/consignment-public-service.ts b/apps/backend/src/services/worker-platform/consignment-public-service.ts new file mode 100644 index 00000000..535dbe52 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-public-service.ts @@ -0,0 +1,110 @@ +import { + createConsignmentSubmission, + getConsignmentCollectLinkByToken, +} from '../../repositories/worker-platform/consignment-repo.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { generateConsignmentSubmissionNo } from '../../utils/random.js' +import { nowIso } from '../../utils/time.js' + +export async function getPublicConsignmentLink(token: string) { + const cleanToken = String(token || '').trim() + if (!cleanToken) { + throw createHttpError('链接无效', { statusCode: 400, errorCode: 'invalid_token' }) + } + + const link = await getConsignmentCollectLinkByToken(cleanToken) + if (!link) { + throw createHttpError('收集链接不存在或已被关闭', { + statusCode: 404, + errorCode: 'link_not_found', + }) + } + + if (link.expires_at && new Date(link.expires_at).getTime() < Date.now()) { + throw createHttpError('该收集链接已过期', { statusCode: 410, errorCode: 'link_expired' }) + } + + return { + title: link.title, + gameName: link.game_name, + categoryId: link.category_id, + categoryName: link.category_name, + system: link.system, + serverZone: link.server_zone, + series: link.series, + diamondLevel: link.diamond_level, + brillianceCount: link.brilliance_count, + presetPriceFen: link.preset_price_fen, + template: link.template_json, + } +} + +export async function submitPublicConsignment(token: string, payload: JsonObject) { + const cleanToken = String(token || '').trim() + if (!cleanToken) { + throw createHttpError('链接无效', { statusCode: 400, errorCode: 'invalid_token' }) + } + + const link = await getConsignmentCollectLinkByToken(cleanToken) + if (!link) { + throw createHttpError('收集链接不存在或已被关闭', { + statusCode: 404, + errorCode: 'link_not_found', + }) + } + + if (link.expires_at && new Date(link.expires_at).getTime() < Date.now()) { + throw createHttpError('该收集链接已过期', { statusCode: 410, errorCode: 'link_expired' }) + } + + const itemName = String( + payload.itemName || + link.title || + `${link.system}${link.server_zone}${link.series}${link.diamond_level}钻`, + ).trim() + const contactInfo = String(payload.contactInfo || payload.sellerContact || '').trim() + if (!contactInfo) { + throw createHttpError('请填写联系方式(微信号或手机号)', { + statusCode: 400, + errorCode: 'contact_info_required', + }) + } + + const now = nowIso() + const submissionNo = generateConsignmentSubmissionNo() + const askingPriceFen = Math.max( + 0, + Math.round(Number(payload.askingPriceFen || link.preset_price_fen || 0)), + ) + + const submission = await createConsignmentSubmission({ + submissionNo, + linkId: Number(link.id), + gameName: link.game_name, + categoryId: link.category_id ? Number(link.category_id) : null, + itemName, + system: String(payload.system || link.system || '').trim(), + serverZone: String(payload.serverZone || link.server_zone || '').trim(), + series: String(payload.series || link.series || '').trim(), + diamondLevel: Number(payload.diamondLevel || link.diamond_level || 1), + brillianceCount: Number(payload.brillianceCount ?? link.brilliance_count ?? 0), + sellerGameId: String(payload.sellerGameId || '').trim(), + sellerPaymentInfo: String(payload.sellerPaymentInfo || '').trim(), + sellerPhone: String(payload.sellerPhone || '').trim(), + contactInfo, + contentJson: + payload.content && typeof payload.content === 'object' + ? (payload.content as Record) + : (payload as Record), + screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : [], + askingPriceFen, + now, + }) + + return { + submissionNo: submission.submission_no, + status: submission.status, + submittedAt: submission.created_at, + } +} diff --git a/apps/backend/src/utils/random.ts b/apps/backend/src/utils/random.ts index 152e4c09..9c0defb0 100644 --- a/apps/backend/src/utils/random.ts +++ b/apps/backend/src/utils/random.ts @@ -21,3 +21,31 @@ export function generateWorkOrderNo(date: Date = new Date()): string { const rand = Math.floor(1000 + Math.random() * 9000) return `WO${yy}${mm}${dd}${hh}${min}${rand}` } + +/** + * Generate formatted consignment inventory number + * 生成系统标准寄售库存编号,如:INV260913123456 + */ +export function generateConsignmentInventoryNo(date: Date = new Date()): string { + const yy = String(date.getFullYear()).slice(-2) + const mm = String(date.getMonth() + 1).padStart(2, '0') + const dd = String(date.getDate()).padStart(2, '0') + const hh = String(date.getHours()).padStart(2, '0') + const min = String(date.getMinutes()).padStart(2, '0') + const rand = Math.floor(1000 + Math.random() * 9000) + return `INV${yy}${mm}${dd}${hh}${min}${rand}` +} + +/** + * Generate formatted consignment submission number + * 生成系统标准寄售线索收集编号,如:SUB260913123456 + */ +export function generateConsignmentSubmissionNo(date: Date = new Date()): string { + const yy = String(date.getFullYear()).slice(-2) + const mm = String(date.getMonth() + 1).padStart(2, '0') + const dd = String(date.getDate()).padStart(2, '0') + const hh = String(date.getHours()).padStart(2, '0') + const min = String(date.getMinutes()).padStart(2, '0') + const rand = Math.floor(1000 + Math.random() * 9000) + return `SUB${yy}${mm}${dd}${hh}${min}${rand}` +} diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 884ad9df..dc36e628 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -38,6 +38,7 @@ const AdminDevMockPage = lazy(() => import('@/pages/admin/AdminDevMockPage')) const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlatformPage')) const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage')) const CollectPage = lazy(() => import('@/pages/collect/CollectPage')) +const ConsignmentCollectPage = lazy(() => import('@/pages/collect/ConsignmentCollectPage')) const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')) const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage')) const WorkerLoginPage = lazy(() => import('@/pages/worker/WorkerLoginPage')) @@ -133,6 +134,7 @@ function AdminApplication() { } /> } /> + } /> } /> diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index a03e4878..810f5636 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -581,12 +581,7 @@ function isRecord(value: unknown): value is Record { } type NotificationAlertKind = - | 'withdraw' - | 'recharge' - | 'acceptance' - | 'material' - | 'reminder' - | 'default' + 'withdraw' | 'recharge' | 'acceptance' | 'material' | 'reminder' | 'default' function playNotificationSound(kind: NotificationAlertKind) { try { diff --git a/apps/frontend/src/lib/http.ts b/apps/frontend/src/lib/http.ts index 4e15e740..d7c259f0 100644 --- a/apps/frontend/src/lib/http.ts +++ b/apps/frontend/src/lib/http.ts @@ -178,6 +178,14 @@ export function apiPut(url: string, data?: unknown) { }) } +export function apiPatch(url: string, data?: unknown) { + return request({ + method: 'PATCH', + url, + data: data as Record, + }) +} + export function apiPostForm(url: string, data: FormData) { return request({ method: 'POST', diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index 166dd8fe..f39977c0 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -11,6 +11,8 @@ import AfterSalesPanel, { type AfterSalesPanelHandle } from './panels/AfterSales import AnnouncementPanel from './panels/AnnouncementPanel' import CancelRequestsPanel from './panels/CancelRequestsPanel' import CategoriesPanel from './panels/CategoriesPanel' +import ConsignmentInventoryPanel from './panels/ConsignmentInventoryPanel' +import ConsignmentSubmissionsPanel from './panels/ConsignmentSubmissionsPanel' import FeedbacksPanel from './panels/FeedbacksPanel' import FinancePanel from './panels/FinancePanel' import LeaderboardPanel from './panels/LeaderboardPanel' @@ -94,6 +96,16 @@ export default function AdminWorkerPlatformPage() { : isSupport ? [ { key: 'orders', label: '接单工单', children: }, + { + key: 'consignment-inventory', + label: '寄售仓库', + children: , + }, + { + key: 'consignment-submissions', + label: '寄卖收集与审核', + children: , + }, { key: 'cancel-requests', label: '打手退单审核', @@ -104,6 +116,16 @@ export default function AdminWorkerPlatformPage() { ] : [ { key: 'orders', label: '接单工单', children: }, + { + key: 'consignment-inventory', + label: '寄售仓库', + children: , + }, + { + key: 'consignment-submissions', + label: '寄卖收集与审核', + children: , + }, { key: 'cancel-requests', label: '打手退单审核', @@ -142,6 +164,8 @@ const ACTIVE_TAB_STORAGE_KEY = 'admin-worker-platform-tab' function loadActiveTabPreference(): string { const saved = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY) return saved === 'orders' || + saved === 'consignment-inventory' || + saved === 'consignment-submissions' || saved === 'leaderboard' || saved === 'cancel-requests' || saved === 'feedbacks' || @@ -165,6 +189,8 @@ function loadActiveTab(tab: string | null): string { function isWorkerPlatformTab(tab: string | null): tab is string { return [ 'orders', + 'consignment-inventory', + 'consignment-submissions', 'leaderboard', 'cancel-requests', 'feedbacks', diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx new file mode 100644 index 00000000..028ba1b2 --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -0,0 +1,1133 @@ +import { + CheckCircleOutlined, + CopyOutlined, + EyeOutlined, + FileImageOutlined, + PlusOutlined, + ReloadOutlined, + RollbackOutlined, + StopOutlined, +} from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' +import { + App, + Badge, + Button, + Card, + Col, + Descriptions, + Form, + Image, + Input, + InputNumber, + Modal, + Radio, + Row, + Select, + Space, + Statistic, + Switch, + Table, + Tag, + Tooltip, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import { useState } from 'react' + +import { + archiveAdminConsignmentInventory, + createAdminConsignmentInventory, + fetchAdminConsignmentInventories, + fetchAdminConsignmentStats, + returnAdminConsignmentInventory, + toggleAdminConsignmentMatching, + updateAdminConsignmentInventory, +} from '@/services/admin/consignment' +import type { ConsignmentInventory } from '@/types/consignment' + +const { Text } = Typography + +const SYSTEM_OPTIONS = [ + { label: '全部系统', value: '' }, + { label: '苹果 (iOS)', value: '苹果' }, + { label: '安卓 (Android)', value: '安卓' }, + { label: '通用/双端', value: '通用' }, +] + +const ZONE_OPTIONS = [ + { label: '全部区服', value: '' }, + { label: '微信区', value: '微信区' }, + { label: 'QQ区', value: 'QQ区' }, +] + +const SERIES_OPTIONS = [ + { label: '全部系列', value: '' }, + { label: '暗潮', value: '暗潮' }, + { label: '怒嚎', value: '怒嚎' }, + { label: '血宴', value: '血宴' }, + { label: '矩阵', value: '矩阵' }, + { label: '其他', value: '其他' }, +] + +const DIAMOND_OPTIONS = [ + { label: '全部钻石', value: 0 }, + { label: '1 钻', value: 1 }, + { label: '2 钻', value: 2 }, + { label: '3 钻', value: 3 }, + { label: '4 钻', value: 4 }, + { label: '5 钻', value: 5 }, + { label: '6 钻', value: 6 }, +] + +const BUYER_COLORS: Record = { + 小明: 'blue', + 小张: 'green', + 老李: 'purple', + 小王: 'orange', + 客服A: 'cyan', + 客服B: 'magenta', +} + +export default function ConsignmentInventoryPanel() { + const { message } = App.useApp() + + // 筛选条件 + const [status, setStatus] = useState('in_stock') + const [system, setSystem] = useState('') + const [serverZone, setServerZone] = useState('') + const [series, setSeries] = useState('') + const [diamondLevel, setDiamondLevel] = useState(0) + const [allowMatching, setAllowMatching] = useState('') + const [keyword, setKeyword] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + // 弹窗状态 + const [inboundModalOpen, setInboundModalOpen] = useState(false) + const [editingItem, setEditingItem] = useState(null) + const [viewingItem, setViewingItem] = useState(null) + const [returnItem, setReturnItem] = useState(null) + const [returnReason, setReturnReason] = useState('') + + const [inboundForm] = Form.useForm() + const [editForm] = Form.useForm() + + // 数据查询 + const inventoriesQuery = useQuery({ + queryKey: [ + 'admin-consignment-inventories', + status, + system, + serverZone, + series, + diamondLevel, + allowMatching, + keyword, + page, + pageSize, + ], + queryFn: () => + fetchAdminConsignmentInventories({ + status: status === 'all' ? undefined : status, + system: system || undefined, + serverZone: serverZone || undefined, + series: series || undefined, + diamondLevel: diamondLevel > 0 ? diamondLevel : undefined, + allowMatching: allowMatching === 'yes' ? true : allowMatching === 'no' ? false : undefined, + keyword: keyword || undefined, + page, + pageSize, + }), + }) + + const statsQuery = useQuery({ + queryKey: ['admin-consignment-stats'], + queryFn: () => fetchAdminConsignmentStats(), + }) + + const items = inventoriesQuery.data?.data.items || [] + const total = inventoriesQuery.data?.data.total || 0 + const stats = statsQuery.data?.data + + async function refresh() { + await Promise.all([inventoriesQuery.refetch(), statsQuery.refetch()]) + } + + // 快捷切换匹配开关 + async function handleToggleMatching(record: ConsignmentInventory, checked: boolean) { + try { + await toggleAdminConsignmentMatching(record.id, checked) + message.success(checked ? '已开启该物品匹配' : '已关闭该物品匹配(禁止自动匹配)') + inventoriesQuery.refetch() + } catch (err: any) { + message.error(err.message || '切换匹配开关失败') + } + } + + // 手动入库提交 + async function handleInboundSubmit() { + try { + const values = await inboundForm.validateFields() + const count = Number(values.count || 1) + const costPriceFen = Math.round(Number(values.costPrice || 0) * 100) + + for (let i = 0; i < count; i++) { + await createAdminConsignmentInventory({ + itemName: values.itemName, + system: values.system, + serverZone: values.serverZone, + series: values.series, + diamondLevel: Number(values.diamondLevel || 1), + brillianceCount: Number(values.brillianceCount || 0), + costPriceFen, + buyerName: values.buyerName, + shopName: values.shopName, + inboundBatch: values.inboundBatch, + sellerContact: values.sellerContact, + sellerGameId: values.sellerGameId, + sellerPaymentInfo: values.sellerPaymentInfo, + sellerPhone: values.sellerPhone, + credentialsJson: { + loginMethod: values.loginMethod, + account: values.account, + password: values.password, + authNote: values.authNote, + }, + screenshotsJson: values.screenshots ? [values.screenshots] : [], + remark: values.remark, + allowMatching: values.allowMatching !== false, + sourceType: 'manual', + }) + } + + message.success(`成功入库 ${count} 件寄售物品!`) + setInboundModalOpen(false) + inboundForm.resetFields() + refresh() + } catch (err: any) { + if (err.errorFields) return + message.error(err.message || '创建寄售物品失败') + } + } + + // 编辑更新提交 + async function handleEditSubmit() { + if (!editingItem) return + try { + const values = await editForm.validateFields() + const costPriceFen = Math.round(Number(values.costPrice || 0) * 100) + + await updateAdminConsignmentInventory(editingItem.id, { + itemName: values.itemName, + system: values.system, + serverZone: values.serverZone, + series: values.series, + diamondLevel: Number(values.diamondLevel || 1), + brillianceCount: Number(values.brillianceCount || 0), + costPriceFen, + buyerName: values.buyerName, + shopName: values.shopName, + inboundBatch: values.inboundBatch, + sellerContact: values.sellerContact, + sellerGameId: values.sellerGameId, + sellerPaymentInfo: values.sellerPaymentInfo, + sellerPhone: values.sellerPhone, + credentialsJson: { + loginMethod: values.loginMethod, + account: values.account, + password: values.password, + authNote: values.authNote, + }, + remark: values.remark, + allowMatching: values.allowMatching !== false, + }) + + message.success('更新成功!') + setEditingItem(null) + refresh() + } catch (err: any) { + if (err.errorFields) return + message.error(err.message || '更新物品失败') + } + } + + // 作废下架 + async function handleArchive(record: ConsignmentInventory) { + Modal.confirm({ + title: '确认下架作废该物品?', + content: `下架后该物品 [${record.itemName} - ${record.inventoryNo}] 将不再参与匹配。`, + okText: '确认作废', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + try { + await archiveAdminConsignmentInventory(record.id) + message.success('物品已作废') + refresh() + } catch (err: any) { + message.error(err.message || '下架失败') + } + }, + }) + } + + // 退单回库 + async function handleReturnConfirm() { + if (!returnItem) return + try { + await returnAdminConsignmentInventory(returnItem.id, { reason: returnReason }) + message.success('物品已退单回库,重新进入待匹配池!') + setReturnItem(null) + setReturnReason('') + refresh() + } catch (err: any) { + message.error(err.message || '退回复库失败') + } + } + + // 自动填充常用名称 + function autoFillItemName(formInstance: any) { + const sys = formInstance.getFieldValue('system') || '苹果' + const zone = formInstance.getFieldValue('serverZone') || '微信区' + const ser = formInstance.getFieldValue('series') || '暗潮' + const dia = formInstance.getFieldValue('diamondLevel') || 1 + const bri = formInstance.getFieldValue('brillianceCount') || 0 + const briStr = bri > 0 ? `${bri}璀璨` : '' + formInstance.setFieldsValue({ + itemName: `${sys}${zone}${ser}${dia}钻${briStr}`, + }) + } + + const columns: TableColumnsType = [ + { + title: '物品/库存编号', + key: 'item', + width: 220, + render: (_, record) => ( +
+
{record.itemName}
+ + + {record.inventoryNo} + + {record.sourceType === 'collection_link' ? ( + + 链接收集 + + ) : ( + + 人工入库 + + )} + +
+ ), + }, + { + title: '和平精英规格', + key: 'specs', + width: 200, + render: (_, record) => ( + +
+ + {record.system || '通用'} + + + {record.serverZone || '全区'} + +
+
+ {record.series || '通用'} + {record.diamondLevel} 钻 + {record.brillianceCount > 0 ? ( + {record.brillianceCount} 璀璨 + ) : null} +
+
+ ), + }, + { + title: '收购价', + key: 'cost', + width: 100, + render: (_, record) => ( + + ¥{(record.costPriceFen / 100).toFixed(2)} + + ), + }, + { + title: '收购人/店铺', + key: 'buyer_shop', + width: 150, + render: (_, record) => ( +
+
+ + {record.buyerName || '未指定'} + +
+ {record.shopName ? ( +
+ {record.shopName} +
+ ) : null} + {record.inboundBatch ? ( +
批次: {record.inboundBatch}
+ ) : null} +
+ ), + }, + { + title: '卖家信息', + key: 'seller', + width: 180, + render: (_, record) => ( +
+ {record.sellerContact ?
联系: {record.sellerContact}
: null} + {record.sellerGameId ?
游戏ID: {record.sellerGameId}
: null} + {record.sellerPaymentInfo ? ( + + 收款: {record.sellerPaymentInfo} + + ) : null} +
+ ), + }, + { + title: '允许匹配', + key: 'allowMatching', + width: 110, + render: (_, record) => ( + + handleToggleMatching(record, checked)} + /> + + ), + }, + { + title: '安排状态', + key: 'allocation', + width: 130, + render: (_, record) => { + if (record.status === 'consumed') { + return ( + + + {record.boundPlatformOrderId ? ( + + {record.boundPlatformOrderId} + + ) : null} + + ) + } + if (record.status === 'archived') { + return + } + return + }, + }, + { + title: '状态', + key: 'status', + width: 90, + render: (_, record) => { + if (record.status === 'in_stock') return 在库 + if (record.status === 'consumed') return 已出库 + return 已作废 + }, + }, + { + title: '操作', + key: 'actions', + width: 180, + render: (_, record) => ( + + + {record.status === 'in_stock' ? ( + <> + + + + ) : null} + {record.status === 'consumed' ? ( + + ) : null} + + ), + }, + ] + + return ( +
+ {/* 统计概览卡片 */} + + + + + + + + + + + + + + + +
+ + 收购人分布: + +
+ {(stats?.byBuyer || []).map((b) => ( + + {b.buyer_name}: {b.count}件 (在库) / ¥{(b.total_cost_fen / 100).toFixed(0)} + + ))} +
+
+ +
+
+ + {/* 筛选过滤工具条 */} + + + + { + setStatus(e.target.value) + setPage(1) + }} + buttonStyle="solid" + > + 在库待匹配 + 已履约出库 + 已作废 + 全部 + + + { + setServerZone(v) + setPage(1) + }} + /> + { + setDiamondLevel(v) + setPage(1) + }} + /> + + autoFillItemName(inboundForm)} + /> + + + + + autoFillItemName(inboundForm)} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* 编辑物品弹窗 */} + setEditingItem(null)} + onOk={handleEditSubmit} + destroyOnClose + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + {/* 查看详情与凭据弹窗 */} + setViewingItem(null)}> + 关闭 + , + ]} + onCancel={() => setViewingItem(null)} + > + {viewingItem ? ( + + + {viewingItem.itemName} + + + {viewingItem.inventoryNo} + + + {viewingItem.status === 'in_stock' ? ( + 在库待匹配 + ) : viewingItem.status === 'consumed' ? ( + 已出库履约 + ) : ( + 已作废 + )} + + + {viewingItem.system} / {viewingItem.serverZone} / {viewingItem.series} /{' '} + {viewingItem.diamondLevel}钻 ({viewingItem.brillianceCount}璀璨) + + + + ¥{(viewingItem.costPriceFen / 100).toFixed(2)} + + + {viewingItem.buyerName || '-'} + {viewingItem.shopName || '-'} + + {viewingItem.inboundBatch || '-'} + + {viewingItem.inboundAt || '-'} + + {viewingItem.sellerContact || '-'} + + + {viewingItem.sellerPhone || '-'} + + + {viewingItem.sellerGameId || '-'} + + + {viewingItem.sellerPaymentInfo || '-'} + + + {(viewingItem.credentialsJson as any)?.loginMethod || '-'} + + + {(viewingItem.credentialsJson as any)?.account || '-'} + + + {(viewingItem.credentialsJson as any)?.password || '-'} + + + {(viewingItem.credentialsJson as any)?.authNote || '-'} + + {viewingItem.status === 'consumed' ? ( + <> + + {viewingItem.boundPlatformOrderId || '-'} + + + {viewingItem.consumedAt || '-'} + + + ) : null} + + {viewingItem.remark || '-'} + + + ) : null} + + + {/* 退单回库弹窗 */} + setReturnItem(null)} + onOk={handleReturnConfirm} + okText="确认退回复库" + okType="primary" + > +
+ + 确认将该物品{' '} + + [{returnItem?.itemName} - {returnItem?.inventoryNo}] + {' '} + 退回复库吗? + +
+ 回库后,物品状态将重新变为【在库】,允许匹配开关将自动开启,可重新匹配履约其他工单。 +
+
+
+ + setReturnReason(e.target.value)} + placeholder="请输入退单回库原因..." + /> + +
+
+
+ ) +} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx new file mode 100644 index 00000000..9d49335c --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx @@ -0,0 +1,947 @@ +import { + CheckCircleOutlined, + CloseCircleOutlined, + CopyOutlined, + EyeOutlined, + LinkOutlined, + PlusOutlined, + ReloadOutlined, +} from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' +import { + Alert, + App, + Badge, + Button, + Card, + Col, + Descriptions, + Form, + Image, + Input, + InputNumber, + Modal, + Radio, + Row, + Select, + Space, + Switch, + Table, + Tabs, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import { useState } from 'react' + +import { + approveAdminConsignmentSubmission, + createAdminConsignmentLink, + fetchAdminConsignmentLinks, + fetchAdminConsignmentSubmissions, + rejectAdminConsignmentSubmission, + updateAdminConsignmentLink, +} from '@/services/admin/consignment' +import type { ConsignmentCollectLink, ConsignmentSubmission } from '@/types/consignment' + +const { Text } = Typography + +export default function ConsignmentSubmissionsPanel() { + const { message } = App.useApp() + const [activeSubTab, setActiveSubTab] = useState<'submissions' | 'links'>('submissions') + + // 提报审核状态 + const [submissionStatus, setSubmissionStatus] = useState('pending_review') + const [submissionKeyword, setSubmissionKeyword] = useState('') + const [submissionPage, setSubmissionPage] = useState(1) + const [submissionPageSize, setSubmissionPageSize] = useState(20) + + // 弹窗状态 + const [createLinkModalOpen, setCreateLinkModalOpen] = useState(false) + const [createdLinkUrl, setCreatedLinkUrl] = useState('') + const [approveModalItem, setApproveModalItem] = useState(null) + const [rejectModalItem, setRejectModalItem] = useState(null) + const [viewingSubmission, setViewingSubmission] = useState(null) + + const [rejectNote, setRejectNote] = useState('') + const [linkForm] = Form.useForm() + const [approveForm] = Form.useForm() + + // 查询提报列表 + const submissionsQuery = useQuery({ + queryKey: [ + 'admin-consignment-submissions', + submissionStatus, + submissionKeyword, + submissionPage, + submissionPageSize, + ], + queryFn: () => + fetchAdminConsignmentSubmissions({ + status: submissionStatus === 'all' ? undefined : submissionStatus, + keyword: submissionKeyword || undefined, + page: submissionPage, + pageSize: submissionPageSize, + }), + }) + + // 查询链接列表 + const linksQuery = useQuery({ + queryKey: ['admin-consignment-links'], + queryFn: () => fetchAdminConsignmentLinks(), + }) + + const submissions = submissionsQuery.data?.data.items || [] + const submissionTotal = submissionsQuery.data?.data.total || 0 + const links = Array.isArray(linksQuery.data?.data) + ? linksQuery.data?.data + : linksQuery.data?.data?.items || [] + + // 复制收集公开链接 + function copyPublicLink(token: string) { + const origin = window.location.origin + const url = `${origin}/#/consignment-collect/${token}` + navigator.clipboard.writeText(url) + message.success('收集链接已复制到剪贴板,可直接发给客户!') + } + + // 切换链接启用/关闭 + async function handleToggleLink(record: ConsignmentCollectLink, enabled: boolean) { + try { + await updateAdminConsignmentLink(record.id, { enabled }) + message.success(enabled ? '链接已开启' : '链接已关闭') + linksQuery.refetch() + } catch (err: any) { + message.error(err.message || '更新失败') + } + } + + // 创建收集链接 + async function handleCreateLinkSubmit() { + try { + const values = await linkForm.validateFields() + const presetPriceFen = Math.round(Number(values.presetPrice || 0) * 100) + const briStr = values.brillianceCount > 0 ? `${values.brillianceCount}璀璨` : '' + const defaultTitle = `${values.system}${values.serverZone}${values.series}${values.diamondLevel}钻${briStr} 寄卖征集` + + const res = await createAdminConsignmentLink({ + title: values.title || defaultTitle, + system: values.system, + serverZone: values.serverZone, + series: values.series, + diamondLevel: Number(values.diamondLevel || 1), + brillianceCount: Number(values.brillianceCount || 0), + presetPriceFen, + buyerName: values.buyerName, + inboundBatch: values.inboundBatch, + isMultiUse: values.isMultiUse !== false, + }) + + const token = res.data?.token || '' + const url = `${window.location.origin}/#/consignment-collect/${token}` + setCreatedLinkUrl(url) + try { + navigator.clipboard.writeText(url) + } catch {} + + message.success('收集链接生成成功!') + setCreateLinkModalOpen(false) + linkForm.resetFields() + await linksQuery.refetch() + } catch (err: any) { + if (err.errorFields) return + message.error(err.message || '创建收集链接失败') + } + } + + // 审核通过并入库 + async function handleApproveSubmit() { + if (!approveModalItem) return + try { + const values = await approveForm.validateFields() + const costPriceFen = Math.round(Number(values.costPrice || 0) * 100) + + await approveAdminConsignmentSubmission(approveModalItem.id, { + costPriceFen, + buyerName: values.buyerName, + shopName: values.shopName, + inboundBatch: values.inboundBatch, + itemName: values.itemName, + system: values.system, + serverZone: values.serverZone, + series: values.series, + diamondLevel: Number(values.diamondLevel || 1), + brillianceCount: Number(values.brillianceCount || 0), + sellerContact: values.sellerContact, + sellerGameId: values.sellerGameId, + sellerPaymentInfo: values.sellerPaymentInfo, + sellerPhone: values.sellerPhone, + reviewNote: values.reviewNote, + }) + + message.success('审核通过!已正式存入寄售库存待匹配!') + setApproveModalItem(null) + submissionsQuery.refetch() + } catch (err: any) { + if (err.errorFields) return + message.error(err.message || '审核入库失败') + } + } + + // 驳回提报 + async function handleRejectConfirm() { + if (!rejectModalItem) return + try { + await rejectAdminConsignmentSubmission(rejectModalItem.id, { reviewNote: rejectNote }) + message.success('已驳回该提报申请') + setRejectModalItem(null) + setRejectNote('') + submissionsQuery.refetch() + } catch (err: any) { + message.error(err.message || '驳回失败') + } + } + + // 提报列表列定义 + const submissionColumns: TableColumnsType = [ + { + title: '提报单号', + key: 'submissionNo', + width: 170, + render: (_, record) => ( +
+ {record.submissionNo} + {record.linkTitle ? ( +
来自: {record.linkTitle}
+ ) : null} +
+ ), + }, + { + title: '提报物品规格', + key: 'specs', + width: 210, + render: (_, record) => ( + +
{record.itemName}
+ + {record.system} + {record.serverZone} + {record.series} + {record.diamondLevel} 钻 + {record.brillianceCount > 0 ? ( + {record.brillianceCount} 璀璨 + ) : null} + +
+ ), + }, + { + title: '卖家报价', + key: 'price', + width: 110, + render: (_, record) => ( + + ¥{(record.askingPriceFen / 100).toFixed(2)} + + ), + }, + { + title: '卖家联系方式', + key: 'contact', + width: 180, + render: (_, record) => ( +
+
联系: {record.contactInfo}
+ {record.sellerGameId ?
游戏ID: {record.sellerGameId}
: null} + {record.sellerPhone ?
电话: {record.sellerPhone}
: null} +
+ ), + }, + { + title: '凭据截图', + key: 'screens', + width: 110, + render: (_, record) => { + const screens = Array.isArray(record.screenshotsJson) ? record.screenshotsJson : [] + if (screens.length === 0) return 无截图 + return ( + + + + ) + }, + }, + { + title: '状态', + key: 'status', + width: 100, + render: (_, record) => { + if (record.status === 'pending_review') return 待客服审核 + if (record.status === 'approved') return 已入库 + return 已驳回 + }, + }, + { + title: '提报时间', + dataIndex: 'createdAt', + width: 150, + render: (v) => {v}, + }, + { + title: '操作', + key: 'actions', + width: 160, + render: (_, record) => ( + + + {record.status === 'pending_review' ? ( + <> + + + + ) : null} + + ), + }, + ] + + // 链接列表列定义 + const linkColumns: TableColumnsType = [ + { + title: '收集标题 / 规格', + key: 'title', + width: 280, + render: (_, record) => { + const fullUrl = `${window.location.origin}/#/consignment-collect/${record.token}` + return ( +
+
{record.title}
+ + + {record.system || '通用'} + + {record.serverZone || '全区'} + {record.series || '通用'} + {record.diamondLevel || 1} 钻 + {record.brillianceCount > 0 ? ( + {record.brillianceCount} 璀璨 + ) : null} + +
+ + {fullUrl} + +
+
+ ) + }, + }, + { + title: '指导参考价', + key: 'price', + width: 110, + render: (_, record) => ( + + ¥{(record.presetPriceFen / 100).toFixed(2)} + + ), + }, + { + title: '创建人/批次', + key: 'creator', + width: 140, + render: (_, record) => ( +
+
{record.createdBy || '-'}
+ {record.inboundBatch ? ( +
批次: {record.inboundBatch}
+ ) : null} +
+ ), + }, + { + title: '提报数量', + key: 'count', + width: 100, + render: (_, record) => ( + 0 ? '#52c41a' : '#d9d9d9' }} + /> + ), + }, + { + title: '状态开关', + key: 'enabled', + width: 100, + render: (_, record) => ( + handleToggleLink(record, checked)} + /> + ), + }, + { + title: '操作', + key: 'actions', + width: 180, + render: (_, record) => ( + + + + ), + }, + ] + + return ( +
+ setActiveSubTab(k as any)} + items={[ + { + key: 'submissions', + label: ( + + 玩家提报审核{' '} + {submissions.filter((s) => s.status === 'pending_review').length > 0 ? ( + s.status === 'pending_review').length} + offset={[6, -2]} + /> + ) : null} + + ), + children: ( +
+ {/* 过滤条 */} + + + { + setSubmissionStatus(e.target.value) + setSubmissionPage(1) + }} + buttonStyle="solid" + > + 待客服审核 + 已审核入库 + 已驳回 + 全部 + + + { + setSubmissionKeyword(v) + setSubmissionPage(1) + }} + /> + + + + + + {/* 提报审核表格 */} + + `共 ${t} 条提报记录`, + onChange: (p, ps) => { + setSubmissionPage(p) + setSubmissionPageSize(ps) + }, + }} + /> + + + ), + }, + { + key: 'links', + label: '收集链接生成与管理', + children: ( +
+ + + + + + + + +
+ + + ), + }, + ]} + /> + + {/* 生成收集链接弹窗 */} + setCreateLinkModalOpen(false)} + onOk={handleCreateLinkSubmit} + destroyOnClose + > +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* 审核入库弹窗 */} + setApproveModalItem(null)} + onOk={handleApproveSubmit} + okText="审核通过并正式入库" + okType="primary" + destroyOnClose + > +
+ + 提报人联系方式:{approveModalItem?.contactInfo} | 游戏ID: + {approveModalItem?.sellerGameId || '-'} | 卖家要价: + + ¥{((approveModalItem?.askingPriceFen || 0) / 100).toFixed(2)} + + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* 驳回弹窗 */} + setRejectModalItem(null)} + onOk={handleRejectConfirm} + okText="确认驳回" + okType="danger" + > +
+ + 确认驳回 [{rejectModalItem?.submissionNo}] 的寄卖申请吗? + +
+
+ + setRejectNote(e.target.value)} + placeholder="如:要价过高、物品规格不符、账号存在实名绑定限制等" + /> + + +
+ + {/* 提报详情查看弹窗 */} + setViewingSubmission(null)}> + 关闭 + , + ]} + onCancel={() => setViewingSubmission(null)} + > + {viewingSubmission ? ( + + + {viewingSubmission.submissionNo} + + + {viewingSubmission.itemName} + + + {viewingSubmission.system} / {viewingSubmission.serverZone} /{' '} + {viewingSubmission.series} / {viewingSubmission.diamondLevel}钻 ( + {viewingSubmission.brillianceCount}璀璨) + + + + ¥{(viewingSubmission.askingPriceFen / 100).toFixed(2)} + + + + {viewingSubmission.contactInfo} + + + {viewingSubmission.sellerPhone || '-'} + + + {viewingSubmission.sellerGameId || '-'} + + + {viewingSubmission.sellerPaymentInfo || '-'} + + + {Array.isArray(viewingSubmission.screenshotsJson) && + viewingSubmission.screenshotsJson.length > 0 ? ( + + + {viewingSubmission.screenshotsJson.map((src, i) => ( + + ))} + + + ) : ( + 无截图 + )} + + + {viewingSubmission.status === 'pending_review' ? ( + 待审核 + ) : viewingSubmission.status === 'approved' ? ( + 已入库 + ) : ( + 已驳回 + )} + + + {viewingSubmission.reviewerName || '-'} + + + {viewingSubmission.reviewNote || '-'} + + + ) : null} + + + {/* 链接生成成功提示弹窗 */} + setCreatedLinkUrl('')} + footer={[ + , + , + ]} + > + +
+ 公开收集链接: +
+
+ + {createdLinkUrl} + +
+
+ + ) +} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 63e531d4..197afd07 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -11,6 +11,7 @@ import { UndoOutlined, UserAddOutlined, WarningOutlined, + RollbackOutlined, } from '@ant-design/icons' import { useQuery, useQueryClient } from '@tanstack/react-query' import { @@ -72,6 +73,12 @@ import { updateAdminWorkOrderNote, updateAdminWorkOrderSharing, } from '@/services/admin' +import { + checkWorkOrderConsignmentMatches, + fulfillWorkOrderWithConsignment, + revokeWorkOrderConsignmentFulfillment, +} from '@/services/admin/consignment' +import type { WorkOrderInventoryMatchCheck } from '@/types/consignment' import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform' import { AdminRangePicker } from '@/components/admin/AdminDatePicker' import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs' @@ -259,6 +266,35 @@ export default function WorkOrdersPanel() { unitReward?: number totalAmount?: number }>() + + // 寄售库存匹配拦截弹窗状态 + const [consignmentInterceptModal, setConsignmentInterceptModal] = useState<{ + open: boolean + order: WorkOrder | null + matchData: WorkOrderInventoryMatchCheck | null + submitting: boolean + }>({ + open: false, + order: null, + matchData: null, + submitting: false, + }) + + // 寄售履约撤单弹窗状态 + const [revokeConsignmentModal, setRevokeConsignmentModal] = useState<{ + open: boolean + order: WorkOrder | null + action: 'reopen' | 'cancel_order' | 'archive_inventory' + reason: string + submitting: boolean + }>({ + open: false, + order: null, + action: 'reopen', + reason: '', + submitting: false, + }) + // 仅单独筛选「待验收」时提供批量验收入口(多选状态下不启用勾选,避免误操作) const isPendingAcceptanceOnly = statuses.length === 1 && statuses[0] === 'pending_acceptance' const selectedWorkOrderStatuses = statuses.filter( @@ -652,6 +688,75 @@ export default function WorkOrdersPanel() { }) } + async function handlePublishClick(row: WorkOrder) { + try { + const matchRes = await checkWorkOrderConsignmentMatches(row.workOrderId) + if (matchRes.data?.hasMatch && (matchRes.data.matches?.length || 0) > 0) { + setConsignmentInterceptModal({ + open: true, + order: row, + matchData: matchRes.data, + submitting: false, + }) + return + } + } catch { + // 检查匹配异常降级走正常发布 + } + confirmPublishWorkOrder(row) + } + + async function handleFulfillWithConsignment(inventoryId: number) { + if (!consignmentInterceptModal.order) return + setConsignmentInterceptModal((prev) => ({ ...prev, submitting: true })) + try { + await fulfillWorkOrderWithConsignment({ + workOrderId: consignmentInterceptModal.order.workOrderId, + inventoryId, + }) + message.success('已使用内部寄售库存直接履约!工单已自动完成,无需打手接单佣金') + setConsignmentInterceptModal({ open: false, order: null, matchData: null, submitting: false }) + await refreshAll() + } catch (err: any) { + message.error(err.message || '内部履约失败') + setConsignmentInterceptModal((prev) => ({ ...prev, submitting: false })) + } + } + + function openRevokeConsignmentModal(row: WorkOrder) { + setRevokeConsignmentModal({ + open: true, + order: row, + action: 'reopen', + reason: '', + submitting: false, + }) + } + + async function handleRevokeConsignmentSubmit() { + if (!revokeConsignmentModal.order) return + setRevokeConsignmentModal((prev) => ({ ...prev, submitting: true })) + try { + await revokeWorkOrderConsignmentFulfillment({ + workOrderId: revokeConsignmentModal.order.workOrderId, + action: revokeConsignmentModal.action, + reason: revokeConsignmentModal.reason, + }) + message.success('寄售履约已撤销!物品已退单回库,工单已更新') + setRevokeConsignmentModal({ + open: false, + order: null, + action: 'reopen', + reason: '', + submitting: false, + }) + await refreshAll() + } catch (err: any) { + message.error(err.message || '撤销履约失败') + setRevokeConsignmentModal((prev) => ({ ...prev, submitting: false })) + } + } + function confirmBatchAcceptance() { const workOrderIds = selectedAcceptanceOrderIds if (workOrderIds.length === 0) { @@ -1018,6 +1123,12 @@ export default function WorkOrdersPanel() { {shopName ? `店铺:${shopName}` : ''} ) : null} + {(row.material as any)?.consignment ? ( + + 寄售履约: {(row.material as any).consignment.inventoryNo} (成本: ¥ + {(((row.material as any).consignment.costPriceFen || 0) / 100).toFixed(2)}) + + ) : null} ) }, @@ -1249,7 +1360,7 @@ export default function WorkOrdersPanel() { title={ Number(row.rewardAmount || 0) <= 0 ? '接单金额为 0,不能发布到大厅' : undefined } - onClick={() => confirmPublishWorkOrder(row)} + onClick={() => handlePublishClick(row)} > 发布并核销 @@ -1301,6 +1412,15 @@ export default function WorkOrdersPanel() { 扣押金 ) : null} + {(row.material as any)?.consignment ? ( + + ) : null} {row.status === 'accepted' ? ( , + , + ]} + > + +
+ 当前工单原定接单赏金: + + ¥{Number(consignmentInterceptModal.order?.rewardAmount || 0).toFixed(2)} + +
+
+ 若使用内部寄售库存履约,无需向外部打手支付佣金(打手支出 ¥0.00) + ,系统将自动核销工单并归档! +
+ + } + style={{ marginBottom: 16 }} + /> + +
+ + 匹配结果: + + 全部匹配: {consignmentInterceptModal.matchData?.exactCount || 0} 件 + + + 部分匹配(高配充低配): {consignmentInterceptModal.matchData?.partialCount || 0} 件 + + +
+ +
+ {(consignmentInterceptModal.matchData?.matches || []).map((m) => { + const inv = m.inventory + const isExact = m.matchType === 'exact' + return ( + +
+
+ + {inv.itemName} + {isExact ? ( + 【全部匹配】 + ) : ( + 【部分匹配 / 高配充低配】 + )} + + {inv.inventoryNo} + + + +
+ + {inv.system} + {inv.serverZone} + {inv.series} + {inv.diamondLevel} 钻 + {inv.brillianceCount > 0 ? ( + {inv.brillianceCount} 璀璨 + ) : null} + +
+ +
+ + + 内部收购成本:{' '} + + ¥{(inv.costPriceFen / 100).toFixed(2)} + + + 收购人: {inv.buyerName || '-'} + {inv.shopName ? 店铺: {inv.shopName} : null} + {inv.inboundBatch ? 批次: {inv.inboundBatch} : null} + +
+ +
+ 匹配分析: {m.matchReasons.join(' · ')} +
+
+ +
+ +
+
+
+ ) + })} +
+ + + {/* 撤单 / 撤销寄售履约弹窗 */} + { + setRevokeConsignmentModal({ + open: false, + order: null, + action: 'reopen', + reason: '', + submitting: false, + }) + }} + onOk={handleRevokeConsignmentSubmit} + confirmLoading={revokeConsignmentModal.submitting} + okText="确认撤单" + okType="danger" + destroyOnClose + > + + +
+ + + setRevokeConsignmentModal((prev) => ({ ...prev, action: e.target.value })) + } + > + + +
+ 退单回库并恢复工单为未指派 (推荐) +
+ 物品重新变为【在库】待匹配,工单恢复为【未指派】,可重新选择其他库存或发布到打手大厅。 +
+
+
+ +
+ 退单回库并取消整单 +
+ 买家退款时使用。物品退回复库,工单状态变更为【已取消】。 +
+
+
+ +
+ 报废该物品并恢复工单 +
+ 如果该寄售物品无法登录/被找回已彻底失效,将该物品标记为【作废】,工单恢复为【未指派】重选。 +
+
+
+
+
+
+ + + + setRevokeConsignmentModal((prev) => ({ ...prev, reason: e.target.value })) + } + placeholder="请输入撤单详细原因,如:买家申请退款、选错账号规格、账号密码错误等..." + /> + + +
) } diff --git a/apps/frontend/src/pages/collect/ConsignmentCollectPage.tsx b/apps/frontend/src/pages/collect/ConsignmentCollectPage.tsx new file mode 100644 index 00000000..ef8b51ba --- /dev/null +++ b/apps/frontend/src/pages/collect/ConsignmentCollectPage.tsx @@ -0,0 +1,453 @@ +import { + CheckCircleOutlined, + CloudUploadOutlined, + LockOutlined, + TrophyOutlined, +} from '@ant-design/icons' +import { + Alert, + App, + Button, + Card, + Col, + Descriptions, + Form, + Image, + Input, + InputNumber, + Radio, + Result, + Row, + Select, + Space, + Spin, + Tag, + Typography, + Upload, +} from 'antd' +import type { UploadFile } from 'antd' +import { useEffect, useState } from 'react' +import { useParams } from 'react-router' + +import { + fetchPublicConsignmentLink, + submitPublicConsignment, + uploadConsignmentImage, +} from '@/services/consignment-public' +import type { ConsignmentCollectLink, ConsignmentSubmission } from '@/types/consignment' + +const { Title, Paragraph, Text } = Typography + +export default function ConsignmentCollectPage() { + const { message } = App.useApp() + const { token } = useParams<{ token: string }>() + + const [loading, setLoading] = useState(true) + const [errorMsg, setErrorMsg] = useState('') + const [linkInfo, setLinkInfo] = useState(null) + const [submittedResult, setSubmittedResult] = useState(null) + const [fileList, setFileList] = useState([]) + const [submitting, setSubmitting] = useState(false) + + const [form] = Form.useForm() + + useEffect(() => { + if (!token) { + setErrorMsg('缺少收集凭证') + setLoading(false) + return + } + + fetchPublicConsignmentLink(token) + .then((res) => { + const data = res.data + setLinkInfo(data) + const briStr = data.brillianceCount > 0 ? `${data.brillianceCount}璀璨` : '' + form.setFieldsValue({ + itemName: + data.title || + `${data.system}${data.serverZone}${data.series}${data.diamondLevel}钻${briStr}`, + system: data.system || '苹果', + serverZone: data.serverZone || '微信区', + series: data.series || '暗潮', + diamondLevel: data.diamondLevel || 1, + brillianceCount: data.brillianceCount || 0, + askingPrice: data.presetPriceFen > 0 ? data.presetPriceFen / 100 : undefined, + loginMethod: 'wechat_qrcode', + }) + }) + .catch((err: any) => { + setErrorMsg(err.message || '获取收集信息失败或链接已失效') + }) + .finally(() => { + setLoading(false) + }) + }, [token, form]) + + async function handleSubmit(values: any) { + if (!token) return + setSubmitting(true) + try { + const askingPriceFen = Math.round(Number(values.askingPrice || 0) * 100) + const screenshots = fileList + .filter((f) => f.status === 'done' && (f.response?.url || f.url)) + .map((f) => f.response?.url || f.url) + + const payload = { + itemName: values.itemName, + system: values.system, + serverZone: values.serverZone, + series: values.series, + diamondLevel: Number(values.diamondLevel || 1), + brillianceCount: Number(values.brillianceCount || 0), + sellerContact: values.sellerContact, + contactInfo: values.sellerContact, + sellerPhone: values.sellerPhone, + sellerGameId: values.sellerGameId, + sellerPaymentInfo: values.sellerPaymentInfo, + askingPriceFen, + screenshotsJson: screenshots, + contentJson: { + loginMethod: values.loginMethod, + account: values.account, + password: values.password, + authNote: values.authNote, + sellerNote: values.sellerNote, + }, + } + + const res = await submitPublicConsignment(token, payload) + setSubmittedResult(res.data) + message.success('寄卖申请提交成功!') + } catch (err: any) { + message.error(err.message || '提交失败,请重试') + } finally { + setSubmitting(false) + } + } + + if (loading) { + return ( +
+ +
正在加载寄卖登记信息...
+
+ ) + } + + if (errorMsg) { + return ( +
+ 如有疑问,请联系客服重新获取有效链接} + /> +
+ ) + } + + if (submittedResult) { + return ( +
+ + +
+ 您的登记单号: + + {submittedResult.submissionNo} + +
+
+ 客服将在核实账号及物品品质后,通过您填写的联系方式与您确认交接并打款。 +
+
+ } + extra={[ + , + ]} + /> + + + ) + } + + return ( +
+
+ {/* 顶部标题区 */} + + +
+ + + {linkInfo?.title || '和平精英 装备/账号 寄卖登记'} + +
+ + 平台保障 · 极速审核 · 验货即打款 · 请如实填写以下信息 + + + {linkInfo ? ( +
+ {linkInfo.system ? {linkInfo.system} : null} + {linkInfo.serverZone ? {linkInfo.serverZone} : null} + {linkInfo.series ? {linkInfo.series} : null} + {linkInfo.diamondLevel ? {linkInfo.diamondLevel} 钻 : null} + {linkInfo.brillianceCount > 0 ? ( + {linkInfo.brillianceCount} 璀璨 + ) : null} + {linkInfo.presetPriceFen > 0 ? ( + + 指导收购价 ¥{(linkInfo.presetPriceFen / 100).toFixed(0)} + + ) : null} +
+ ) : null} +
+
+ + {/* 表单填写区 */} + +
+ + 1. 物品与规格确认 + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2. 卖家联系与收款方式 + + + + + + + + + + + + + + + + + + + + + + + + + 3. 凭据截图与账号交接 (仅内部审核验号可见) + + + } + type="info" + message="信息安全保障" + description="您填写的账号凭据与截图受平台严格加密保护,仅由官方指定客服在验货核实时可见,绝不泄露给任何无关第三方。" + style={{ marginBottom: 16 }} + /> + + + { + try { + const res = await uploadConsignmentImage(file as File) + onSuccess?.(res) + } catch (err: any) { + onError?.(err) + message.error(err.message || '上传失败') + } + }} + onChange={({ fileList: nextList }) => setFileList(nextList)} + > + {fileList.length < 4 ? ( +
+ +
上传截图
+
+ ) : null} +
+
+ + + + 微信扫码登录 + QQ扫码登录 + 账号密码交付 + 游戏内转赠/投喂 + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/frontend/src/pages/worker/WorkerAfterSalesPage.tsx b/apps/frontend/src/pages/worker/WorkerAfterSalesPage.tsx index 69e593e7..0a136038 100644 --- a/apps/frontend/src/pages/worker/WorkerAfterSalesPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerAfterSalesPage.tsx @@ -41,11 +41,7 @@ type AfterSalesResponseFormValues = { } type StatusFilter = - | 'all' - | 'awaiting_worker_response' - | 'under_review' - | 'recovery_pending' - | 'closed' + 'all' | 'awaiting_worker_response' | 'under_review' | 'recovery_pending' | 'closed' /** 待办优先:待说明 > 处理中 > 追缴中 > 已关闭,同状态按发起时间倒序。 */ const STATUS_WEIGHT: Record = { diff --git a/apps/frontend/src/pages/worker/worker-order-utils.tsx b/apps/frontend/src/pages/worker/worker-order-utils.tsx index ff0d21a5..f811bdae 100644 --- a/apps/frontend/src/pages/worker/worker-order-utils.tsx +++ b/apps/frontend/src/pages/worker/worker-order-utils.tsx @@ -306,17 +306,15 @@ export function getUniqueAcceptanceFiles(source?: WorkerAcceptance | null): Uplo if (!source) return [] const files = [ ...(Array.isArray(source.files) ? source.files : []), - ...(Array.isArray(source.imageUrls) ? source.imageUrls : []).map( - (url): UploadedFile => ({ - objectKey: '', - url: String(url || ''), - thumbnailUrl: String(url || ''), - mediumUrl: String(url || ''), - filename: '图片', - contentType: '', - size: 0, - }), - ), + ...(Array.isArray(source.imageUrls) ? source.imageUrls : []).map((url): UploadedFile => ({ + objectKey: '', + url: String(url || ''), + thumbnailUrl: String(url || ''), + mediumUrl: String(url || ''), + filename: '图片', + contentType: '', + size: 0, + })), ] const identities = new Set() return files.filter((file) => { diff --git a/apps/frontend/src/services/admin/consignment.ts b/apps/frontend/src/services/admin/consignment.ts new file mode 100644 index 00000000..b3940f17 --- /dev/null +++ b/apps/frontend/src/services/admin/consignment.ts @@ -0,0 +1,146 @@ +import { apiGet, apiPatch, apiPost, apiPut } from '@/lib/http' +import type { + ConsignmentCollectLink, + ConsignmentInventory, + ConsignmentStatsSummary, + ConsignmentSubmission, + WorkOrderInventoryMatchCheck, +} from '@/types/consignment' + +// --- 库存管理 --- + +export function fetchAdminConsignmentInventories(params?: Record) { + return apiGet<{ items: ConsignmentInventory[]; total: number }>( + '/api/v1/admin/worker-platform/consignment/inventories', + params, + ) +} + +export function fetchAdminConsignmentStats() { + return apiGet('/api/v1/admin/worker-platform/consignment/stats') +} + +export function getAdminConsignmentInventory(id: number | string) { + return apiGet(`/api/v1/admin/worker-platform/consignment/inventories/${id}`) +} + +export function createAdminConsignmentInventory(payload: Record) { + return apiPost( + '/api/v1/admin/worker-platform/consignment/inventories', + payload, + ) +} + +export function updateAdminConsignmentInventory( + id: number | string, + payload: Record, +) { + return apiPut( + `/api/v1/admin/worker-platform/consignment/inventories/${id}`, + payload, + ) +} + +export function toggleAdminConsignmentMatching(id: number | string, allowMatching: boolean) { + return apiPatch( + `/api/v1/admin/worker-platform/consignment/inventories/${id}/matching`, + { allowMatching }, + ) +} + +export function archiveAdminConsignmentInventory(id: number | string) { + return apiPost( + `/api/v1/admin/worker-platform/consignment/inventories/${id}/archive`, + {}, + ) +} + +export function returnAdminConsignmentInventory(id: number | string, payload: { reason?: string }) { + return apiPost( + `/api/v1/admin/worker-platform/consignment/inventories/${id}/return`, + payload, + ) +} + +// --- 收集链接 --- + +export function fetchAdminConsignmentLinks() { + return apiGet<{ items: ConsignmentCollectLink[] }>( + '/api/v1/admin/worker-platform/consignment/links', + ) +} + +export function createAdminConsignmentLink(payload: Record) { + return apiPost('/api/v1/admin/worker-platform/consignment/links', payload) +} + +export function updateAdminConsignmentLink(id: number | string, payload: Record) { + return apiPut( + `/api/v1/admin/worker-platform/consignment/links/${id}`, + payload, + ) +} + +// --- 提报审核 --- + +export function fetchAdminConsignmentSubmissions(params?: Record) { + return apiGet<{ items: ConsignmentSubmission[]; total: number }>( + '/api/v1/admin/worker-platform/consignment/submissions', + params, + ) +} + +export function getAdminConsignmentSubmission(id: number | string) { + return apiGet( + `/api/v1/admin/worker-platform/consignment/submissions/${id}`, + ) +} + +export function approveAdminConsignmentSubmission( + id: number | string, + payload: Record, +) { + return apiPost<{ submission: ConsignmentSubmission; inventory: ConsignmentInventory }>( + `/api/v1/admin/worker-platform/consignment/submissions/${id}/approve`, + payload, + ) +} + +export function rejectAdminConsignmentSubmission( + id: number | string, + payload: { reviewNote?: string }, +) { + return apiPost( + `/api/v1/admin/worker-platform/consignment/submissions/${id}/reject`, + payload, + ) +} + +// --- 匹配与履约 --- + +export function checkWorkOrderConsignmentMatches(workOrderId: number | string) { + return apiGet( + `/api/v1/admin/worker-platform/consignment/matches/${workOrderId}`, + ) +} + +export function fulfillWorkOrderWithConsignment(payload: { + workOrderId: number | string + inventoryId: number | string +}) { + return apiPost<{ workOrder: unknown; inventory: ConsignmentInventory }>( + '/api/v1/admin/worker-platform/consignment/fulfill', + payload, + ) +} + +export function revokeWorkOrderConsignmentFulfillment(payload: { + workOrderId: number | string + action?: 'reopen' | 'cancel_order' | 'archive_inventory' + reason?: string +}) { + return apiPost<{ workOrder: unknown; inventory?: ConsignmentInventory | null }>( + '/api/v1/admin/worker-platform/consignment/revoke', + payload, + ) +} diff --git a/apps/frontend/src/services/consignment-public.ts b/apps/frontend/src/services/consignment-public.ts new file mode 100644 index 00000000..a7932dae --- /dev/null +++ b/apps/frontend/src/services/consignment-public.ts @@ -0,0 +1,34 @@ +import { apiGet, apiPost } from '@/lib/http' +import type { ConsignmentCollectLink, ConsignmentSubmission } from '@/types/consignment' + +export function fetchPublicConsignmentLink(token: string) { + return apiGet(`/api/v1/consignment/${encodeURIComponent(token)}`) +} + +export function submitPublicConsignment(token: string, payload: Record) { + return apiPost( + `/api/v1/consignment/${encodeURIComponent(token)}/submit`, + payload, + ) +} + +export async function uploadConsignmentImage( + file: File, +): Promise<{ url: string; assetId?: string }> { + const formData = new FormData() + formData.append('file', file) + formData.append('scene', 'consignment-material') + + const response = await fetch('/api/v1/consignment/upload', { + method: 'POST', + body: formData, + }) + + if (!response.ok) { + const errJson = await response.json().catch(() => ({})) + throw new Error(errJson?.message || '图片上传失败') + } + + const result = await response.json() + return result?.data || result +} diff --git a/apps/frontend/src/types/admin/platform-config/kuaishou-industry.ts b/apps/frontend/src/types/admin/platform-config/kuaishou-industry.ts index 0b25f8c3..6ebbfa82 100644 --- a/apps/frontend/src/types/admin/platform-config/kuaishou-industry.ts +++ b/apps/frontend/src/types/admin/platform-config/kuaishou-industry.ts @@ -1,9 +1,5 @@ export type AdminKuaishouIndustryAccessTokenStatus = - | 'missing' - | 'unknown' - | 'expired' - | 'expiring' - | 'valid' + 'missing' | 'unknown' | 'expired' | 'expiring' | 'valid' export interface AdminKuaishouIndustryShopConfig { enabled: boolean diff --git a/apps/frontend/src/types/claim.ts b/apps/frontend/src/types/claim.ts index d4a57b70..5d466496 100644 --- a/apps/frontend/src/types/claim.ts +++ b/apps/frontend/src/types/claim.ts @@ -193,11 +193,7 @@ export interface ClaimDetailData { tokenStatus: ClaimTokenStatus claimUrl: string flowType: - | 'kuaishou_cloud' - | 'kuaishou_ct_assisted' - | 'kuaishou_feifei' - | 'affiliate_dash' - | (string & {}) + 'kuaishou_cloud' | 'kuaishou_ct_assisted' | 'kuaishou_feifei' | 'affiliate_dash' | (string & {}) claimIdentity: ClaimIdentityInfo | null task: ClaimTaskInfo order: ClaimOrderInfo diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts new file mode 100644 index 00000000..619c5da7 --- /dev/null +++ b/apps/frontend/src/types/consignment.ts @@ -0,0 +1,120 @@ +export type ConsignmentInventory = { + id: number + inventoryNo: string + gameName: string + categoryId: number | null + categoryName?: string + itemName: string + system: string + serverZone: string + series: string + diamondLevel: number + brillianceCount: number + costPriceFen: number + buyerName: string + shopName: string + inboundBatch: string + sellerContact: string + sellerGameId: string + sellerPaymentInfo: string + sellerPhone: string + credentialsJson: Record + screenshotsJson: string[] + remark: string + allowMatching: boolean + status: 'in_stock' | 'consumed' | 'archived' + allocationStatus: string + boundWorkOrderId: number | null + boundPlatformOrderId: string + sourceType: 'manual' | 'collection_link' | 'recycle' + sourceSubmissionId: number | null + inboundAt: string + consumedAt: string | null + createdAt: string + updatedAt: string +} + +export type ConsignmentCollectLink = { + id: number + token: string + title: string + gameName: string + categoryId: number | null + categoryName?: string + system: string + serverZone: string + series: string + diamondLevel: number + brillianceCount: number + presetPriceFen: number + inboundBatch: string + buyerName: string + templateJson: Record + isMultiUse: boolean + expiresAt: string | null + enabled: boolean + createdBy: string + createdAt: string + updatedAt: string + submissionCount?: number +} + +export type ConsignmentSubmission = { + id: number + submissionNo: string + linkId: number | null + linkTitle?: string + gameName: string + categoryId: number | null + categoryName?: string + itemName: string + system: string + serverZone: string + series: string + diamondLevel: number + brillianceCount: number + sellerGameId: string + sellerPaymentInfo: string + sellerPhone: string + contactInfo: string + contentJson: Record + screenshotsJson: string[] + askingPriceFen: number + status: 'pending_review' | 'approved' | 'rejected' + reviewNote: string + reviewerName: string + reviewedAt: string | null + resultInventoryId: number | null + createdAt: string + updatedAt: string +} + +export type ConsignmentStatsSummary = { + totalInStockCount: number + totalInStockCostFen: number + totalConsumedCount: number + totalConsumedCostFen: number + byBuyer: Array<{ + buyer_name: string + count: number + total_cost_fen: number + consumed_count: number + }> + byShop: Array<{ shop_name: string; count: number }> + byBatch: Array<{ inbound_batch: string; count: number; total_cost_fen: number }> + bySeries: Array<{ series: string; diamond_level: number; count: number }> +} + +export type ConsignmentMatchResult = { + inventory: ConsignmentInventory + matchType: 'exact' | 'partial' + matchScore: number + matchReasons: string[] +} + +export type WorkOrderInventoryMatchCheck = { + hasMatch: boolean + exactCount: number + partialCount: number + matches: ConsignmentMatchResult[] +} diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index aa08e8b4..26345794 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -531,11 +531,7 @@ export type WorkOrderShare = { } export type WorkOrderGiftPhase = - | 'material_required' - | 'material_submitted' - | 'friend_countdown' - | 'gift_ready' - | string + 'material_required' | 'material_submitted' | 'friend_countdown' | 'gift_ready' | string /** 好友赠送验收模式的打手本人资料 */ export type BoosterMaterial = { diff --git a/docs/寄卖库存功能设计与实施方案.md b/docs/寄卖库存功能设计与实施方案.md new file mode 100644 index 00000000..511d4652 --- /dev/null +++ b/docs/寄卖库存功能设计与实施方案.md @@ -0,0 +1,261 @@ +# 和平精英寄售仓库 · 系统设计与实施方案 + +> **文档定位**:功能技术与产品设计方案(推荐实现标准) +> **核心游戏**:和平精英(地铁逃生模式 / 账号与物资寄售) +> **更新时间**:2026-09-13 + +--- + +## 一、 业务定位与设计原则 + +本模块针对 **《和平精英》**(重点针对地铁逃生模式的苏尔南手枪、极品附魔武器与排位账号)建设专属的 **「寄售仓库」**,取代原有的线下手工 Excel 台账,并深度融入订单与接单履约链路: + +### 核心设计原则: +1. **去繁就简、数据结构化**:吸取线下台账对“收购成本、收购人、卖家账号/付款凭证、排号/出库店铺”的核心关注点,将杂乱的手工记录转化为规范清晰的系统实体。 +2. **高度自动化,告别手工录入**: + * 履约出库时,系统**自动提取并反向回填**订单号、快手/拼多多店铺名、出库时间,无需人工再去登记“安排到哪张单”; + * 客服创建/审核时,系统**自动绑定当前登录人员**为收购人,自动记录操作时间。 +3. **闭环降本**:在工单发布到打手大厅前,系统通过规则引擎智能比对内部在库物品,支持一键直接履约结单,**直接省去外部打手赏金(¥15~¥80/单)**。 +4. **风控自主可控**:每一件入库物品提供独立的 **【允许匹配开关】**,随手一关即可排除在自动匹配推荐之外,便于自用、高价单卖或线下保留。 +5. **逆向容错(支持撤单与回库重选)**:全链路支持买家退款、客服选错或账号异常时的**一键撤单解绑**,库存安全退回复库,工单可随时重新匹配或转推大厅。 + +--- + +## 二、 核心数据模型设计 + +将原本 Excel 的离散字段归纳为清晰的三层架构:**基础规格属性**、**收货与卖家资料**、**流转履约与匹配控制**。 + +### 数据表:`consignment_inventories`(寄售仓库主表) + +| 分类 | 字段名 | 类型 | 默认值 | 业务含义与说明 | +| :--- | :--- | :--- | :--- | :--- | +| **主键标识** | `id` | `BIGSERIAL` | 自增 | 内部唯一自增 ID | +| | `inventory_no` | `TEXT` | 唯一 | 系统自动生成的业务编号(如 `INV26091312345`) | +| **规格属性** | `game_name` | `TEXT` | `和平精英` | 游戏名称 | +| (多维匹配) | `category_id` | `BIGINT` | `NULL` | 关联的大类(如 苏尔南手枪) | +| | `item_name` | `TEXT` | 必填 | 规范物品标题(如 `安卓微信区 暗潮 1钻`) | +| | `system` | `TEXT` | `""` | 操作系统:`苹果` / `安卓`(硬约束) | +| | `server_zone` | `TEXT` | `""` | 游戏大区:`微信区` / `QQ区`(硬约束) | +| | `series` | `TEXT` | `""` | 附魔系列:`暗潮` / `怒嚎` / `血宴` 等(硬约束) | +| | `diamond_level` | `INTEGER` | `1` | 钻级:`1` ~ `6` 钻 | +| | `brilliance_count` | `INTEGER` | `0` | 璀璨数:`0`(无)、`1`、`2` 等 | +| **收货采购信息** | `cost_price_fen` | `INTEGER` | `0` | **收货成本价**(单位分,如 10500 代表 105 元) | +| (财务与人效) | `buyer_name` | `TEXT` | `""` | **收购人**(如 王斌、周杰雄、周顺,默认取当前登录客服) | +| | `inbound_batch` | `TEXT` | `""` | **收货批次/班次代码**(如 `8.30-1`、`8.30-4`,便于交接对账) | +| | `inbound_at` | `TIMESTAMPTZ`| `NOW()` | 正式入库时间 | +| **卖家与凭据** | `seller_contact` | `TEXT` | `""` | 卖家微信号 / 联系电话 | +| (留痕防纠纷) | `seller_game_id` | `TEXT` | `""` | 卖家游戏角色名 / UID | +| | `seller_payment_info`| `TEXT` | `""` | 卖家收款抬头与卡号(如 `王大锤商行 331682...`) | +| | `credentials_json` | `JSONB` | `{}` | 敏感交付信息(游戏账号、密码、换绑辅助),权限保护 | +| | `screenshots_json` | `JSONB` | `[]` | 截图凭证数组(卖家ID主页图、词条属性图、名片图等) | +| | `remark` | `TEXT` | `""` | 运营其他备注说明 | +| **履约与状态** | `allow_matching` | `BOOLEAN` | `TRUE` | **★ 允许匹配开关**(开:参与匹配;关:暂停匹配) | +| (出库流转) | `status` | `TEXT` | `in_stock` | 资产状态:`in_stock`(在库待匹配) / `consumed`(已出库) / `archived`(已作废) | +| | `bound_order_id` | `BIGINT` | `NULL` | 履约绑定的内部系统订单 ID(出库时系统自动写入) | +| | `bound_work_order_id`| `BIGINT`| `NULL` | 履约绑定的接单工单 ID(出库时系统自动写入) | +| | `bound_platform_order_id`| `TEXT`| `""` | 外部订单号(如快手/拼多多单号 `260830-085784...`,出库自动回填) | +| | `shop_name` | `TEXT` | `""` | 售出店铺名称(如 `拼多多向日葵`、`稚嫩游戏`,出库自动回填) | +| | `consumed_at` | `TIMESTAMPTZ`| `NULL` | 出库履约结单时间 | + +--- + +## 三、 三大核心业务场景与操作流 + +### 1. 定向收集链接生成与用户填报(自动化收货渠道) + +``` +[客服后台] ──► 点击【新建收集链接】 + ├── 勾选目标:分类=苏尔南手枪 / 系统=安卓 / 区服=微信 + ├── 勾选品质:系列=暗潮 / 钻级=1钻 / 璀璨=0 + ├── 输入协商收货价:如 105 元,输入批次号:如 8.30-1 + └── 点击【生成链接】 ──► 复制专属 H5 链接发给卖家 + │ +[卖家手机] ◄────────────────┘ + - 打开 H5:顶部显示《和平精英 安卓微信区 暗潮 1钻 回收单》(规格锁定只读) + - 填入:游戏账号、密码、角色ID + - 上传:角色主页图、暗潮与1钻属性截图 + - 填入:收款卡号/抬头(如 王大锤商行...)、卖家联系电话 + - 点击提交 + │ +[后台审核工作台] ◄──────────┘ (状态:pending_review) + - 客服打开「寄卖审核」弹窗,核查截图无误 + - 可修正实收价格与字段 + - 点击【审核通过并入库】 ──► 正式存入寄售仓库(in_stock,允许匹配=开) +``` + +--- + +### 2. 线下人工快速录入(直接收货登记) + +对于客服在线下直接谈好的现货: +* 在「寄售仓库」面板点击 **【快速手工录入】**。 +* 录入表单模块化聚合: + * **商品规格**:点选 系统、区服、系列(暗潮/怒嚎/血宴)、钻级(1~6)、璀璨数。 + * **采购成本与批次**:输入收购价、当前收购人(默认填入当前操作员)、批次代码(如 8.30-1)。 + * **卖家与凭据**:输入卖家联系方式、收款方式、上传品质截图、填入账号密码。 +* 保存后直接进入在库列表,即刻就绪,参与工单匹配。 + +--- + +### 3. 订单发布前拦截与一键内部履约(降本核心) + +``` +[买家下单] ──► 拼多多向日葵 店铺进单,工单状态为 unassigned (待发布大厅) + │ + ▼ 匹配引擎实时计算 (仅扫描 allow_matching = true 的在库库存) +[工单列表行] ──► 醒目标记 Tag:🟢 匹配内部库存(2件) + │ + ▼ 客服点击「发布并核销」 +[发布拦截弹窗] ──► 拦截并弹出《检测到可用寄售库存》推荐卡片: + ├── 卡片 A:【全部匹配】安卓微信 暗潮 1钻 0璀璨(成本 ¥105,收购人:周杰雄) + └── 卡片 B:【部分匹配】安卓微信 暗潮 1钻 1璀璨(成本 ¥128,多出【1璀璨】,高配充低配) + │ + ┌───────────┴───────────┐ + ▼ ▼ +【选用内部库存直接履约】 【忽略并继续发布到大厅】 + 1. 选中卡片 A 点击确认 维持原有逻辑,推送到大厅由打手抢单 + 2. 系统原子事务操作: + - 工单直接 completed(不发大厅,0 赏金支出!) + - 自动顺带核销快手电子凭证 + - 库存状态变为 consumed + - 自动回填: + bound_platform_order_id = 买家平台订单号 + shop_name = 拼多多向日葵 + consumed_at = 当前时间 +``` + +--- + +## 四、 智能匹配引擎逻辑(全部匹配 vs 部分匹配) + +### 1. 过滤基准条件(必须满足,否则不匹配) +* 资产状态必须为 `in_stock`(在库可用); +* **【允许匹配开关】必须为 `TRUE`**(若为 `FALSE` 则完全跳过,绝不打扰); +* 系统与区服必须完全一致(苹果不能配安卓,微信不能配QQ); +* 词条系列必须完全一致(暗潮 只能配 暗潮,不能拿 怒嚎 充数)。 + +### 2. 钻级与璀璨的匹配算法 +* **全部匹配(Exact Match,得分 100 分)**: + * 钻级完全相同(如 订单=1钻,库存=1钻); + * 璀璨完全相同(如 订单=0璀璨,库存=0璀璨)。 + * 推荐标为绿色,无降级风险。 +* **部分匹配 / 超集匹配(Partial Match,得分 80 分)**: + * 核心特征全部满足,但库存规格更高(如 订单=1钻无璀璨,库存=1钻1璀璨,或钻级更高)。 + * 推荐标为黄色,并提示 **“⚠️ 该库存品质更高(多出 1 璀璨),属于高配充低配,请确认是否愿意降级使用”**,由客服做最终决断。 + +### 3. 排序策略 +1. 优先全部匹配(100分) $\gt$ 部分匹配(80分)。 +2. 同等匹配度下,收购成本最低优先(毛利最大化)。 +3. 同等成本下,入库时间最早优先(FIFO 快速清库存)。 + +--- + +## 五、 异常撤单与库存回退重选机制(退款 / 选错 / 无法发货) + +在实际运营中,若发生买家退款、客服选错或账号异常,系统提供完整的**逆向撤销与回库重选闭环**: + +``` + 【 已寄售履约工单 (completed) 】 + │ + 点击【撤销寄售履约】 + │ + ┌────────────────────────┼────────────────────────┐ + ▼ ▼ ▼ +【场景 1:选错库存重新选】 【场景 2:买家退款取消工单】 【场景 3:物资账号异常作废】 + - 原库存解绑 ──► in_stock - 原库存解绑 ──► in_stock - 原库存变更为 ──► archived + - 工单回退 ────► unassigned - 工单状态 ────► cancelled - 工单回退 ────► unassigned + - 客服可重新选用新库存 - 终止发货,库存回库继续卖 - 客服选用其他正常库存发货 + 或直接点击发布到大厅 +``` + +### 1. 工单视角:【撤销寄售履约 / 重新选择】 +在工单列表或工单详情页,对已通过内部库存履约的工单(状态为 `completed` 且带有寄售出库标记),提供 **【撤销履约】** 操作: +* **分支 A:【回退为未分配(重新选库存 或 发大厅)】** + * **原库存处理**:状态从 `consumed` 自动恢复为 `in_stock`,清除绑定的订单号与售出店铺,重新进入待匹配池。 + * **工单处理**:状态从 `completed` 安全回退为 `unassigned`(未分配),清理本次发货卡密记录。 + * **操作结果**:客服可以立即再次点击「发布并核销」选择其他匹配库存,或者直接发布到大厅由打手抢单! +* **分支 B:【买家申请退款(取消工单并退库)】** + * **原库存处理**:安全解绑并自动回退为在库 `in_stock`,避免资产报废损失。 + * **工单处理**:状态流转为 `cancelled`(已取消),不再继续履约。 +* **分支 C:【物资账号异常(作废该库存并重新选)】** + * **原库存处理**:该把手枪/账号密码失效或被封,直接变更为 `archived`(已作废),记录作废原因,不再参与匹配。 + * **工单处理**:工单回退为 `unassigned`,供客服挑选另一把正常手枪继续履约。 + +### 2. 仓库视角:【退回复库 / 重新上架】 +在「寄售仓库」管理列表中,对状态为 `consumed`(已出库)的物品行,提供 **【退回复库】** 按钮: +* 客服可手动将某件出库的物品解绑原订单,直接重置为 `in_stock` 在库待匹配状态。 +* 适合线下沟通中买家临时更换需求、跑单或线下对账异常时的人工修正。 + +### 3. 数据安全与事务保障 +* 撤单过程全部置于数据库事务(`withTransaction`)中,确保库存状态与工单状态原子同步,绝不会出现“工单回退了但库存未恢复”的错乱。 +* 每次撤单操作均在工单事件(`work_order_events`)中留下完整审计轨迹(记录撤销人、撤回时间、原绑定单号与原因)。 + +--- + +## 六、 自动化统计报表规划(告别手工 Excel 汇总) + +在后台提供清晰的统计看板,解决运营与财务的核心诉求: + +1. **收购人人效统计(Buyer Stats)**: + * 维度:王斌、周杰雄、周顺 等。 + * 指标:收货总量(件)、收货总花费(元)、已售出件数、库存去化率(%)、平均单价。 +2. **店铺出库贡献统计(Shop Stats)**: + * 维度:拼多多向日葵、稚嫩游戏、佳佳游戏、小鱼儿 等。 + * 指标:各店铺消耗寄售库存单数、**直接节省的打手赏金总额(元)**、平均履约时效。 +3. **批次核算统计(Batch Stats)**: + * 维度:按 `8.30-1`、`8.30-4` 等批次代码汇总。 + * 指标:批次总支出、已回款金额、剩余未售资产成本,便于每天下班对账。 +4. **品类规格周转(Spec Stats)**: + * 统计 暗潮、怒嚎、血宴 各钻级手枪的在库均价、库存留存天数与断货预警。 + +--- + +## 七、 界面交互与原型设计 + +### 1. 接单平台 · 寄售仓库管理页(`ConsignmentInventoryPanel`) +* **顶部数据指标卡**: + * 【在库总数】 / 【在库总成本】 / 【累计节省赏金】 / 【本月出库单数】。 +* **高级筛选工具条**: + * 下拉筛选:收购人(王斌/周杰雄等)、售出店铺(拼多多向日葵等)、批次(如 8.30-1)。 + * 点选切换:系统(全部/苹果/安卓)、区服(全部/微信/QQ)、系列(全部/暗潮/怒嚎/血宴)、钻级(全部/1钻~6钻)。 + * 状态标签切换:【全部】/【在库待匹配】/【已出库】/【已作废】。 +* **表格列表**: + * 支持彩色 Badge:收购人(王斌-紫色、周杰雄-粉色等)、店铺名(拼多多向日葵-橙色等)。 + * **行内 Switch 开关**:【允许匹配】随手可切,切换即刻生效。 + * 凭据与截图:支持点击缩略图查看大图(包含角色主页图、手枪词条图)。 + * 敏感交付信息:点击【查看账号密码】,弹窗显示并带有一键复制。 + * 操作:【编辑】、【作废】、针对已出库物品提供【退回复库】。 + +### 2. 工单列表与撤销重选(`WorkOrdersPanel`) +* **发布前拦截**:点击「发布并核销」时,弹窗推荐匹配库存,支持一键选用内部履约结单。 +* **撤销履约入口**:对已完成内部履约的工单,提供操作按钮【撤销寄售履约】: + * 弹出确认弹窗,选择“选错重选 / 买家退款 / 物资作废”; + * 点击确认后原子回退状态,工单即刻变回未分配状态,支持重新选或发大厅。 + +### 3. 客服创建收集链接弹窗(`CreateCollectLinkModal`) +* 表单包含系统、区服、系列、钻级、璀璨下拉选择,支持输入协商收货价与批次号。 +* 生成后提供链接复制与二维码展示。 + +### 4. 寄卖提报审核面板(`ConsignmentSubmissionsPanel`) +* 展示卖家提交的待审单据。 +* 审核弹窗:双栏布局,左边看截图(支持放大看词条),右边核对资料,一键【审核入库】或【驳回】。 + +--- + +## 八、 技术落地与阶段划分 + +1. **阶段一:数据库与模型层** + * 创建数据表 `consignment_inventories`、`consignment_collect_links`、`consignment_submissions`。 + * 配置对应 RBAC 权限(`consignment.view`, `consignment.manage`, `consignment.review`, `consignment.fulfill`)。 +2. **阶段二:后端核心服务与匹配引擎** + * 实现库存 CRUD、多维筛选及统计报表查询; + * 实现和平精英规格匹配算法(暗潮/怒嚎/血宴 + 1~6钻 + 1~6璀璨 + `allow_matching` 开关判断); + * 实现发布拦截检测接口与一键内部履约原子事务; + * **实现工单撤销履约与库存回退重选服务(`revokeConsignmentFulfillment`)**。 +3. **阶段三:前端管理后台** + * 接单平台新增「寄售仓库」面板(包含多维筛选、彩色标签、Switch 开关、卡密查看、退回复库); + * 新增「寄售审核与收集链接」面板; + * 改造工单发布拦截与履约弹窗; + * 增加工单列表/详情中的「撤销寄售履约」弹窗与重选逻辑。 +4. **阶段四:移动端 H5 填报端** + * 实现移动端极简收集页,规格只读展示,支持截图直传。