feat(consignment): 补齐寄卖前端权限门控,收口调拨状态与运营体验
权限门控(P1-8 剩余): - 新增 resolveAdminPermission(sessionPermissions, code):会话接口的最新权限列表优先, 接口异常才回退登录时的本地快照;空数组也判无权限,避免"刚被收回权限仍能点"。 - 寄售仓库面板:consignment.manage 控制手动入库/编辑/作废/退单回库/匹配开关(无权限时开关禁用)。 - 寄卖提报面板:consignment.review 控制审核入库/驳回。 - 接单平台页:无 consignment.view 不渲染两个寄卖 tab,并把 URL/localStorage 的旧选择拉回工单页。 数据约束(P2-19 剩余): - 迁移 080:allocation_status 历史非法值归位「待安排」(备注留痕)后加 CHECK(待安排/已安排), 并顺手修正 077 遗留的"收集链接/玩家提交"权限描述文案。 - 编辑接口拒绝直接改 allocationStatus(400 consignment_allocation_status_update_forbidden)。 运营体验: - 两个面板补 isError 错误态与重试;审核通过/驳回加 confirmLoading 与提交中守卫(防双提交)。 - 待审徽标改用后端真实总数(原来只统计当前页);编辑弹窗押金不再兜底 ¥100(会误写押金)。 - 履约拦截弹窗冷静期文案不再硬编码"3 天",改为按商品规则设定。 - 核实并撤回"倒计时时区偏移"误报:deadlineAt 为带 Z 的 ISO,前端无偏移问题。 验证:后端 412 例(409 通过)、前端 21 例(新增 5 例权限解析)全绿, typecheck / lint / format / check:sql / build 通过;迁移 001→080 在全新空库跑通并在 dev 库应用。
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
-- 080_consignment_allocation_status_and_copy.sql —— 寄卖收口:调拨状态加约束 + 修正下线渠道遗留文案
|
||||||
|
--
|
||||||
|
-- 背景:
|
||||||
|
-- 1) consignment_inventories.allocation_status 自 077 起是自由文本,系统只会写「待安排 / 已安排」,
|
||||||
|
-- 但没有任何约束挡住别的值进来;列表角标与筛选口径依赖这两个值,必须锁死。
|
||||||
|
-- 2) 077 写入的权限描述还写着「收集链接」(该渠道已在 079 整体下线)与「玩家提交」,
|
||||||
|
-- 需要在权限说明里改成实际口径(打手通过接单平台提报)。
|
||||||
|
-- 历史数据不做兼容处理(测试阶段),仅把非法值归位到「待安排」并在备注留痕。
|
||||||
|
|
||||||
|
-- 1) 历史非法值归位(先归位再加约束,否则 ADD CONSTRAINT 会因为存量脏数据直接失败)
|
||||||
|
UPDATE consignment_inventories
|
||||||
|
SET
|
||||||
|
remark = remark || ' [allocation_status 非法值归位: ' || allocation_status || ']',
|
||||||
|
allocation_status = '待安排',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE allocation_status NOT IN ('待安排', '已安排');
|
||||||
|
|
||||||
|
-- 2) 调拨状态约束:与系统写入口径一致,重复执行安全
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'consignment_inventories_allocation_status_check'
|
||||||
|
AND conrelid = 'consignment_inventories'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE consignment_inventories
|
||||||
|
DROP CONSTRAINT consignment_inventories_allocation_status_check;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
ALTER TABLE consignment_inventories
|
||||||
|
ADD CONSTRAINT consignment_inventories_allocation_status_check
|
||||||
|
CHECK (allocation_status IN ('待安排', '已安排'));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN consignment_inventories.allocation_status IS
|
||||||
|
'调拨状态:待安排 在库待匹配 / 已安排 已被工单占用;由系统在占库、回库时写入,编辑接口不可改';
|
||||||
|
|
||||||
|
-- 3) 权限描述修正(收集链接渠道已下线,提报来源改为接单平台打手端)
|
||||||
|
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();
|
||||||
@@ -261,6 +261,14 @@ export async function updateAdminConsignmentInventory(
|
|||||||
errorCode: 'consignment_status_update_forbidden',
|
errorCode: 'consignment_status_update_forbidden',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 调拨状态由系统在占库/回库时写入,前端也从不展示该字段的编辑入口;
|
||||||
|
// 开放自由文本会让"待安排/已安排"之外的值进库,导致列表角标与筛选口径失真。
|
||||||
|
if (payload.allocationStatus !== undefined) {
|
||||||
|
throw createHttpError('编辑接口不支持直接修改调拨状态', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'consignment_allocation_status_update_forbidden',
|
||||||
|
})
|
||||||
|
}
|
||||||
const updated = await updateConsignmentInventory(id, {
|
const updated = await updateConsignmentInventory(id, {
|
||||||
itemName: payload.itemName !== undefined ? String(payload.itemName).trim() : undefined,
|
itemName: payload.itemName !== undefined ? String(payload.itemName).trim() : undefined,
|
||||||
system: payload.system !== undefined ? String(payload.system).trim() : undefined,
|
system: payload.system !== undefined ? String(payload.system).trim() : undefined,
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
returnConsignmentInventoryToStockWithClient,
|
returnConsignmentInventoryToStockWithClient,
|
||||||
} from '../../repositories/worker-platform/consignment-repo.js'
|
} from '../../repositories/worker-platform/consignment-repo.js'
|
||||||
import { ADMIN_PERMISSION_KEYS } from '../admin/admin-rbac-service.js'
|
import { ADMIN_PERMISSION_KEYS } from '../admin/admin-rbac-service.js'
|
||||||
|
import { updateAdminConsignmentInventory } from './consignment-admin-service.js'
|
||||||
import {
|
import {
|
||||||
assertConsignmentRevocableWorkOrderStatus,
|
assertConsignmentRevocableWorkOrderStatus,
|
||||||
resolveRevocableConsignmentInventoryId,
|
resolveRevocableConsignmentInventoryId,
|
||||||
@@ -2027,3 +2028,23 @@ test('内部履约的电子凭证核销失败要写事件留痕(不能静默
|
|||||||
assert.ok(src.includes('consignment_fulfill_voucher_consume_failed'))
|
assert.ok(src.includes('consignment_fulfill_voucher_consume_failed'))
|
||||||
assert.ok(src.includes('if (!voucherConsume.ok)'))
|
assert.ok(src.includes('if (!voucherConsume.ok)'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('编辑接口拒绝直接改调拨状态,避免自由文本进库破坏角标口径', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
updateAdminConsignmentInventory(1, {
|
||||||
|
allocationStatus: '随便写的状态',
|
||||||
|
}),
|
||||||
|
(error: unknown) =>
|
||||||
|
(error as { errorCode?: string }).errorCode ===
|
||||||
|
'consignment_allocation_status_update_forbidden',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('编辑接口仍然拒绝直接改物品状态(回归)', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() => updateAdminConsignmentInventory(1, { status: 'archived' }),
|
||||||
|
(error: unknown) =>
|
||||||
|
(error as { errorCode?: string }).errorCode === 'consignment_status_update_forbidden',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useLocation, useSearchParams } from 'react-router'
|
|||||||
import PageHeader from '@/components/admin/PageHeader'
|
import PageHeader from '@/components/admin/PageHeader'
|
||||||
import type { WorkOrder } from '@/types/worker-platform'
|
import type { WorkOrder } from '@/types/worker-platform'
|
||||||
import { fetchAdminWorkerPlatformSummary } from '@/services/admin'
|
import { fetchAdminWorkerPlatformSummary } from '@/services/admin'
|
||||||
import { getAdminRole } from '@/utils/admin-auth'
|
import { getAdminRole, hasAdminPermission } from '@/utils/admin-auth'
|
||||||
import AfterSalesPanel, { type AfterSalesPanelHandle } from './panels/AfterSalesPanel'
|
import AfterSalesPanel, { type AfterSalesPanelHandle } from './panels/AfterSalesPanel'
|
||||||
import AnnouncementPanel from './panels/AnnouncementPanel'
|
import AnnouncementPanel from './panels/AnnouncementPanel'
|
||||||
import CancelRequestsPanel from './panels/CancelRequestsPanel'
|
import CancelRequestsPanel from './panels/CancelRequestsPanel'
|
||||||
@@ -36,11 +36,18 @@ export default function AdminWorkerPlatformPage() {
|
|||||||
const isAdmin = role === 'admin'
|
const isAdmin = role === 'admin'
|
||||||
const isSupport = role === 'support'
|
const isSupport = role === 'support'
|
||||||
const isFinance = role === 'finance'
|
const isFinance = role === 'finance'
|
||||||
|
const canViewConsignment = hasAdminPermission('consignment.view')
|
||||||
|
const isConsignmentTab =
|
||||||
|
activeTab === 'consignment-inventory' || activeTab === 'consignment-submissions'
|
||||||
|
// 没有 consignment.view 时这两个 tab 不渲染,把 URL/localStorage 里的旧选择也拉回工单页,
|
||||||
|
// 否则会落在一个空的 tab 内容上。
|
||||||
const visibleActiveTab = isFinance
|
const visibleActiveTab = isFinance
|
||||||
? 'finance'
|
? 'finance'
|
||||||
: !isAdmin && activeTab === 'notifications'
|
: !isAdmin && activeTab === 'notifications'
|
||||||
? 'orders'
|
? 'orders'
|
||||||
: activeTab
|
: !canViewConsignment && isConsignmentTab
|
||||||
|
? 'orders'
|
||||||
|
: activeTab
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tab = searchParams.get('tab')
|
const tab = searchParams.get('tab')
|
||||||
@@ -79,6 +86,23 @@ export default function AdminWorkerPlatformPage() {
|
|||||||
children: <AfterSalesPanel ref={afterSalesPanelRef} />,
|
children: <AfterSalesPanel ref={afterSalesPanelRef} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 寄卖相关 tab 按权限渲染:后端所有寄卖接口都挂了 requireAdminPermission,
|
||||||
|
// 没有 consignment.view 的人看到 tab 只会一路 403。
|
||||||
|
const consignmentTabs = canViewConsignment
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'consignment-inventory',
|
||||||
|
label: '寄售仓库',
|
||||||
|
children: <ConsignmentInventoryPanel />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'consignment-submissions',
|
||||||
|
label: '寄卖提报审核',
|
||||||
|
children: <ConsignmentSubmissionsPanel />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack">
|
<section className="page-stack">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -96,16 +120,7 @@ export default function AdminWorkerPlatformPage() {
|
|||||||
: isSupport
|
: isSupport
|
||||||
? [
|
? [
|
||||||
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
||||||
{
|
...consignmentTabs,
|
||||||
key: 'consignment-inventory',
|
|
||||||
label: '寄售仓库',
|
|
||||||
children: <ConsignmentInventoryPanel />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'consignment-submissions',
|
|
||||||
label: '寄卖提报审核',
|
|
||||||
children: <ConsignmentSubmissionsPanel />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'cancel-requests',
|
key: 'cancel-requests',
|
||||||
label: '打手退单审核',
|
label: '打手退单审核',
|
||||||
@@ -116,16 +131,7 @@ export default function AdminWorkerPlatformPage() {
|
|||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
||||||
{
|
...consignmentTabs,
|
||||||
key: 'consignment-inventory',
|
|
||||||
label: '寄售仓库',
|
|
||||||
children: <ConsignmentInventoryPanel />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'consignment-submissions',
|
|
||||||
label: '寄卖提报审核',
|
|
||||||
children: <ConsignmentSubmissionsPanel />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'cancel-requests',
|
key: 'cancel-requests',
|
||||||
label: '打手退单审核',
|
label: '打手退单审核',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
App,
|
App,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -46,6 +47,8 @@ import {
|
|||||||
} from '@/services/admin/consignment'
|
} from '@/services/admin/consignment'
|
||||||
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
|
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
|
||||||
import type { ConsignmentInventory } from '@/types/consignment'
|
import type { ConsignmentInventory } from '@/types/consignment'
|
||||||
|
import { fetchAdminSession } from '@/services/admin/auth'
|
||||||
|
import { resolveAdminPermission } from '@/utils/admin-auth'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
@@ -113,6 +116,16 @@ export default function ConsignmentInventoryPanel() {
|
|||||||
const [inboundForm] = Form.useForm()
|
const [inboundForm] = Form.useForm()
|
||||||
const [editForm] = Form.useForm()
|
const [editForm] = Form.useForm()
|
||||||
|
|
||||||
|
// 写操作按钮统一按 consignment.manage 门控:后端全部挂了 requireAdminPermission,
|
||||||
|
// 前端不隐藏会让无权限的人看到按钮却每次点击都 403。
|
||||||
|
const adminSessionQuery = useQuery({
|
||||||
|
queryKey: ['admin-session'],
|
||||||
|
queryFn: fetchAdminSession,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
const sessionPermissions = adminSessionQuery.data?.data.user.permissions
|
||||||
|
const canManageConsignment = resolveAdminPermission(sessionPermissions, 'consignment.manage')
|
||||||
|
|
||||||
// 查询活跃打手列表用于指派与关联
|
// 查询活跃打手列表用于指派与关联
|
||||||
const workersQuery = useQuery({
|
const workersQuery = useQuery({
|
||||||
queryKey: ['admin-worker-users-consignment-options'],
|
queryKey: ['admin-worker-users-consignment-options'],
|
||||||
@@ -444,11 +457,17 @@ export default function ConsignmentInventoryPanel() {
|
|||||||
width: 110,
|
width: 110,
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={record.allowMatching ? '当前允许系统与工单匹配' : '已禁止匹配,不进入候选池'}
|
title={
|
||||||
|
!canManageConsignment
|
||||||
|
? '无寄卖管理权限,无法修改匹配开关'
|
||||||
|
: record.allowMatching
|
||||||
|
? '当前允许系统与工单匹配'
|
||||||
|
: '已禁止匹配,不进入候选池'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
checked={record.allowMatching}
|
checked={record.allowMatching}
|
||||||
disabled={record.status !== 'in_stock'}
|
disabled={record.status !== 'in_stock' || !canManageConsignment}
|
||||||
checkedChildren="开"
|
checkedChildren="开"
|
||||||
unCheckedChildren="关"
|
unCheckedChildren="关"
|
||||||
onChange={(checked) => handleToggleMatching(record, checked)}
|
onChange={(checked) => handleToggleMatching(record, checked)}
|
||||||
@@ -503,7 +522,7 @@ export default function ConsignmentInventoryPanel() {
|
|||||||
>
|
>
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
{record.status === 'in_stock' ? (
|
{record.status === 'in_stock' && canManageConsignment ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -514,7 +533,10 @@ export default function ConsignmentInventoryPanel() {
|
|||||||
...record,
|
...record,
|
||||||
costPrice: record.costPriceFen / 100,
|
costPrice: record.costPriceFen / 100,
|
||||||
workerId: record.workerId || undefined,
|
workerId: record.workerId || undefined,
|
||||||
depositAmount: record.depositAmountFen ? record.depositAmountFen / 100 : 100,
|
// 没有押金就留空,不能兜底成 100:保存会把"无押金"悄悄写成 ¥100
|
||||||
|
depositAmount: record.depositAmountFen
|
||||||
|
? record.depositAmountFen / 100
|
||||||
|
: undefined,
|
||||||
loginMethod: (record.credentialsJson as any)?.loginMethod,
|
loginMethod: (record.credentialsJson as any)?.loginMethod,
|
||||||
account: (record.credentialsJson as any)?.account,
|
account: (record.credentialsJson as any)?.account,
|
||||||
password: (record.credentialsJson as any)?.password,
|
password: (record.credentialsJson as any)?.password,
|
||||||
@@ -535,7 +557,7 @@ export default function ConsignmentInventoryPanel() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{record.status === 'consumed' ? (
|
{record.status === 'consumed' && canManageConsignment ? (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -691,32 +713,51 @@ export default function ConsignmentInventoryPanel() {
|
|||||||
<Button icon={<ReloadOutlined />} onClick={refresh}>
|
<Button icon={<ReloadOutlined />} onClick={refresh}>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{canManageConsignment ? (
|
||||||
type="primary"
|
<Button
|
||||||
icon={<PlusOutlined />}
|
type="primary"
|
||||||
onClick={() => {
|
icon={<PlusOutlined />}
|
||||||
inboundForm.resetFields()
|
onClick={() => {
|
||||||
inboundForm.setFieldsValue({
|
inboundForm.resetFields()
|
||||||
system: '苹果',
|
inboundForm.setFieldsValue({
|
||||||
serverZone: '微信区',
|
system: '苹果',
|
||||||
series: '暗潮',
|
serverZone: '微信区',
|
||||||
diamondLevel: 1,
|
series: '暗潮',
|
||||||
brillianceCount: 0,
|
diamondLevel: 1,
|
||||||
count: 1,
|
brillianceCount: 0,
|
||||||
allowMatching: true,
|
count: 1,
|
||||||
itemName: '苹果微信暗潮1钻',
|
allowMatching: true,
|
||||||
})
|
itemName: '苹果微信暗潮1钻',
|
||||||
setInboundModalOpen(true)
|
})
|
||||||
}}
|
setInboundModalOpen(true)
|
||||||
>
|
}}
|
||||||
手动入库
|
>
|
||||||
</Button>
|
手动入库
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 库存表格 */}
|
{/* 库存表格 */}
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
|
{inventoriesQuery.isError ? (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
message="读取寄卖库存失败"
|
||||||
|
description={
|
||||||
|
(inventoriesQuery.error as any)?.message ||
|
||||||
|
'请检查网络或稍后重试;也可能是当前账号缺少 consignment.view 权限。'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Button size="small" onClick={() => void inventoriesQuery.refetch()}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -33,8 +34,10 @@ import {
|
|||||||
fetchAdminConsignmentSubmissions,
|
fetchAdminConsignmentSubmissions,
|
||||||
rejectAdminConsignmentSubmission,
|
rejectAdminConsignmentSubmission,
|
||||||
} from '@/services/admin/consignment'
|
} from '@/services/admin/consignment'
|
||||||
|
import { fetchAdminSession } from '@/services/admin/auth'
|
||||||
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
|
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
|
||||||
import type { ConsignmentSubmission } from '@/types/consignment'
|
import type { ConsignmentSubmission } from '@/types/consignment'
|
||||||
|
import { resolveAdminPermission } from '@/utils/admin-auth'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
@@ -51,6 +54,17 @@ export default function ConsignmentSubmissionsPanel() {
|
|||||||
const [viewingSubmission, setViewingSubmission] = useState<ConsignmentSubmission | null>(null)
|
const [viewingSubmission, setViewingSubmission] = useState<ConsignmentSubmission | null>(null)
|
||||||
const [rejectNote, setRejectNote] = useState('')
|
const [rejectNote, setRejectNote] = useState('')
|
||||||
const [approveForm] = Form.useForm()
|
const [approveForm] = Form.useForm()
|
||||||
|
const [approving, setApproving] = useState(false)
|
||||||
|
const [rejecting, setRejecting] = useState(false)
|
||||||
|
|
||||||
|
// 通过/驳回要 consignment.review:后端接口已挂权限,前端不隐藏会让人点了必 403
|
||||||
|
const adminSessionQuery = useQuery({
|
||||||
|
queryKey: ['admin-session'],
|
||||||
|
queryFn: fetchAdminSession,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
const sessionPermissions = adminSessionQuery.data?.data.user.permissions
|
||||||
|
const canReviewConsignment = resolveAdminPermission(sessionPermissions, 'consignment.review')
|
||||||
|
|
||||||
const workersQuery = useQuery({
|
const workersQuery = useQuery({
|
||||||
queryKey: ['admin-worker-users-consignment-options'],
|
queryKey: ['admin-worker-users-consignment-options'],
|
||||||
@@ -82,10 +96,23 @@ export default function ConsignmentSubmissionsPanel() {
|
|||||||
|
|
||||||
const submissions = submissionsQuery.data?.data.items || []
|
const submissions = submissionsQuery.data?.data.items || []
|
||||||
const submissionTotal = submissionsQuery.data?.data.total || 0
|
const submissionTotal = submissionsQuery.data?.data.total || 0
|
||||||
const pendingCount = submissions.filter((s) => s.status === 'pending_review').length
|
|
||||||
|
// 待审徽标必须用后端总数:原来只统计当前页(pageSize=20 时最多显示 20,且翻页就变)。
|
||||||
|
const pendingTotalQuery = useQuery({
|
||||||
|
queryKey: ['admin-consignment-submissions-pending-total'],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchAdminConsignmentSubmissions({
|
||||||
|
status: 'pending_review',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
}),
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
})
|
||||||
|
const pendingCount = pendingTotalQuery.data?.data.total || 0
|
||||||
|
|
||||||
async function handleApproveSubmit() {
|
async function handleApproveSubmit() {
|
||||||
if (!approveModalItem) return
|
if (!approveModalItem || approving) return
|
||||||
|
setApproving(true)
|
||||||
try {
|
try {
|
||||||
const values = await approveForm.validateFields()
|
const values = await approveForm.validateFields()
|
||||||
const costPriceFen = Math.round(Number(values.costPrice || 0) * 100)
|
const costPriceFen = Math.round(Number(values.costPrice || 0) * 100)
|
||||||
@@ -118,23 +145,28 @@ export default function ConsignmentSubmissionsPanel() {
|
|||||||
|
|
||||||
message.success('审核通过!已正式存入寄售库存待匹配!')
|
message.success('审核通过!已正式存入寄售库存待匹配!')
|
||||||
setApproveModalItem(null)
|
setApproveModalItem(null)
|
||||||
await submissionsQuery.refetch()
|
await Promise.all([submissionsQuery.refetch(), pendingTotalQuery.refetch()])
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.errorFields) return
|
if (err.errorFields) return
|
||||||
message.error(err.message || '审核入库失败')
|
message.error(err.message || '审核入库失败')
|
||||||
|
} finally {
|
||||||
|
setApproving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRejectConfirm() {
|
async function handleRejectConfirm() {
|
||||||
if (!rejectModalItem) return
|
if (!rejectModalItem || rejecting) return
|
||||||
|
setRejecting(true)
|
||||||
try {
|
try {
|
||||||
await rejectAdminConsignmentSubmission(rejectModalItem.id, { reviewNote: rejectNote })
|
await rejectAdminConsignmentSubmission(rejectModalItem.id, { reviewNote: rejectNote })
|
||||||
message.success('已驳回该提报申请')
|
message.success('已驳回该提报申请')
|
||||||
setRejectModalItem(null)
|
setRejectModalItem(null)
|
||||||
setRejectNote('')
|
setRejectNote('')
|
||||||
await submissionsQuery.refetch()
|
await Promise.all([submissionsQuery.refetch(), pendingTotalQuery.refetch()])
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error(err.message || '驳回失败')
|
message.error(err.message || '驳回失败')
|
||||||
|
} finally {
|
||||||
|
setRejecting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +288,7 @@ export default function ConsignmentSubmissionsPanel() {
|
|||||||
>
|
>
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
{record.status === 'pending_review' ? (
|
{record.status === 'pending_review' && canReviewConsignment ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -338,6 +370,23 @@ export default function ConsignmentSubmissionsPanel() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
|
{submissionsQuery.isError ? (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
message="读取寄卖提报单失败"
|
||||||
|
description={
|
||||||
|
(submissionsQuery.error as any)?.message ||
|
||||||
|
'请检查网络或稍后重试;也可能是当前账号缺少 consignment.view 权限。'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Button size="small" onClick={() => void submissionsQuery.refetch()}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={submissionColumns}
|
columns={submissionColumns}
|
||||||
@@ -363,6 +412,7 @@ export default function ConsignmentSubmissionsPanel() {
|
|||||||
width={680}
|
width={680}
|
||||||
onCancel={() => setApproveModalItem(null)}
|
onCancel={() => setApproveModalItem(null)}
|
||||||
onOk={handleApproveSubmit}
|
onOk={handleApproveSubmit}
|
||||||
|
confirmLoading={approving}
|
||||||
okText="审核通过并正式入库"
|
okText="审核通过并正式入库"
|
||||||
okType="primary"
|
okType="primary"
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
@@ -519,6 +569,7 @@ export default function ConsignmentSubmissionsPanel() {
|
|||||||
open={Boolean(rejectModalItem)}
|
open={Boolean(rejectModalItem)}
|
||||||
onCancel={() => setRejectModalItem(null)}
|
onCancel={() => setRejectModalItem(null)}
|
||||||
onOk={handleRejectConfirm}
|
onOk={handleRejectConfirm}
|
||||||
|
confirmLoading={rejecting}
|
||||||
okText="确认驳回"
|
okText="确认驳回"
|
||||||
okType="danger"
|
okType="danger"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2913,8 +2913,8 @@ export default function WorkOrdersPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#389e0d', marginTop: 4 }}>
|
<div style={{ color: '#389e0d', marginTop: 4 }}>
|
||||||
若使用内部寄售库存履约,<strong>无需向外部打手支付佣金(打手支出 ¥0.00)</strong>
|
若使用内部寄售库存履约,<strong>无需向外部打手支付佣金(打手支出 ¥0.00)</strong>
|
||||||
。系统会立刻占用库存,并跳过「待打手提交资料」,直接进入默认 3
|
。系统会立刻占用库存,并跳过「待打手提交资料」,直接进入该商品规则设定的赠送冷静期(未单独配置时为
|
||||||
天赠送冷静期;冷静期结束后再用库存账号赠送并验收。
|
72 小时);冷静期结束后再用库存账号赠送并验收。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,25 @@ export function hasAdminPermission(permission: string) {
|
|||||||
return getAdminPermissions().includes(String(permission || '').trim())
|
return getAdminPermissions().includes(String(permission || '').trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析管理端权限:会话接口返回的权限列表优先,缺失时回退到登录时写入本地的权限快照。
|
||||||
|
*
|
||||||
|
* 为什么需要两层:
|
||||||
|
* - 本地快照可能过期(管理员改权限后该账号没重新登录),页面拿到 `/admin/auth/session`
|
||||||
|
* 的最新列表后必须以它为准;
|
||||||
|
* - 会话接口失败时不能让整个面板的按钮全消失,所以保留本地回退。
|
||||||
|
*/
|
||||||
|
export function resolveAdminPermission(
|
||||||
|
sessionPermissions: string[] | null | undefined,
|
||||||
|
permission: string,
|
||||||
|
): boolean {
|
||||||
|
const code = String(permission || '').trim()
|
||||||
|
if (!code) return false
|
||||||
|
// 空数组也算"拿到了最新列表",此时判定为无权限,不回退本地快照。
|
||||||
|
if (Array.isArray(sessionPermissions)) return sessionPermissions.includes(code)
|
||||||
|
return hasAdminPermission(code)
|
||||||
|
}
|
||||||
|
|
||||||
export function hasAdminRole(role: 'admin' | 'operator' | 'support' | 'finance') {
|
export function hasAdminRole(role: 'admin' | 'operator' | 'support' | 'finance') {
|
||||||
if (!hasAdminSession()) {
|
if (!hasAdminSession()) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { resolveAdminPermission } from '../src/utils/admin-auth.ts'
|
||||||
|
|
||||||
|
type StorageLike = {
|
||||||
|
getItem: (key: string) => string | null
|
||||||
|
setItem: (key: string, value: string) => void
|
||||||
|
removeItem: (key: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function installLocalStorage(entries: Record<string, string>) {
|
||||||
|
const store = new Map(Object.entries(entries))
|
||||||
|
const storage: StorageLike = {
|
||||||
|
getItem: (key) => (store.has(key) ? (store.get(key) as string) : null),
|
||||||
|
setItem: (key, value) => {
|
||||||
|
store.set(key, value)
|
||||||
|
},
|
||||||
|
removeItem: (key) => {
|
||||||
|
store.delete(key)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
;(globalThis as { localStorage?: StorageLike }).localStorage = storage
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
function installAdminSessionSnapshot(permissions: string[]) {
|
||||||
|
return installLocalStorage({
|
||||||
|
'order-site-admin-token': 'token',
|
||||||
|
'order-site-admin-token-expires-at': new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
'order-site-admin-permissions': JSON.stringify(permissions),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('会话接口返回的权限列表优先于本地快照(管理员刚收回权限的场景)', () => {
|
||||||
|
installAdminSessionSnapshot(['consignment.manage'])
|
||||||
|
|
||||||
|
// 本地快照里有 manage,但会话接口的最新列表没有 → 以会话为准
|
||||||
|
assert.equal(resolveAdminPermission(['consignment.view'], 'consignment.manage'), false)
|
||||||
|
assert.equal(resolveAdminPermission(['consignment.view'], 'consignment.view'), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('会话返回空权限数组时判定为无权限,不回退本地快照', () => {
|
||||||
|
installAdminSessionSnapshot(['consignment.manage', 'consignment.review'])
|
||||||
|
|
||||||
|
assert.equal(resolveAdminPermission([], 'consignment.manage'), false)
|
||||||
|
assert.equal(resolveAdminPermission([], 'consignment.review'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('会话权限缺失(接口失败/未返回)时回退本地快照', () => {
|
||||||
|
installAdminSessionSnapshot(['consignment.review'])
|
||||||
|
|
||||||
|
assert.equal(resolveAdminPermission(undefined, 'consignment.review'), true)
|
||||||
|
assert.equal(resolveAdminPermission(null, 'consignment.manage'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('没有登录会话时即使快照里有权限也不放行', () => {
|
||||||
|
installLocalStorage({ 'order-site-admin-permissions': JSON.stringify(['consignment.manage']) })
|
||||||
|
|
||||||
|
assert.equal(resolveAdminPermission(undefined, 'consignment.manage'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('空权限名一律拒绝(避免调用方漏传参数时误放行)', () => {
|
||||||
|
installAdminSessionSnapshot(['consignment.manage'])
|
||||||
|
|
||||||
|
assert.equal(resolveAdminPermission(['consignment.manage'], ''), false)
|
||||||
|
assert.equal(resolveAdminPermission([''], 'consignment.manage'), false)
|
||||||
|
})
|
||||||
+26
-6
@@ -19,7 +19,8 @@
|
|||||||
| `6b940796` | 样式修复(cooldownMinutes prefer-const) | — |
|
| `6b940796` | 样式修复(cooldownMinutes prefer-const) | — |
|
||||||
| `e828e677` | 权限打通(19 接口改 `requireAdminPermission`)、指派占库改条件更新、撤销守卫 | 新增 4 个回归测试 |
|
| `e828e677` | 权限打通(19 接口改 `requireAdminPermission`)、指派占库改条件更新、撤销守卫 | 新增 4 个回归测试 |
|
||||||
| `632dddc4` | 上一轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 |
|
| `632dddc4` | 上一轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 |
|
||||||
| *(工作区未提交)* | 本轮 · 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` |
|
| `5d11bf20` | 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` |
|
||||||
|
| 本批提交(批次 E,同一提交内含本文档,故不写自身 hash) | 前端四权限门控收口、`allocation_status` 约束与权限文案修正、面板错误态/防双提交/真实待审计数 | `admin-auth.ts`、两个寄卖面板、`AdminWorkerPlatformPage.tsx`、迁移 `080` |
|
||||||
|
|
||||||
**结论**:功能主干 3 天连成闭环(库存 → 提报审核 → 匹配 → 内部现货 / 打手押金两条履约 → 赠送冷静期 → 验收结算 → 撤销回库),但最近三次提交全是**上线后回填正确性**(权限被静默清除、并发占库、撤销资金挂账)。
|
**结论**:功能主干 3 天连成闭环(库存 → 提报审核 → 匹配 → 内部现货 / 打手押金两条履约 → 赠送冷静期 → 验收结算 → 撤销回库),但最近三次提交全是**上线后回填正确性**(权限被静默清除、并发占库、撤销资金挂账)。
|
||||||
当前主要矛盾不是"缺功能",而是:**状态机不封闭 + 库存绑定无唯一性约束 + 工单终态对寄卖零感知**(本轮已处理);入口侧"H5 不可提交 / 手工录入丢凭据"两条断链已通过"下线 H5 + 修字段名"解决。
|
当前主要矛盾不是"缺功能",而是:**状态机不封闭 + 库存绑定无唯一性约束 + 工单终态对寄卖零感知**(本轮已处理);入口侧"H5 不可提交 / 手工录入丢凭据"两条断链已通过"下线 H5 + 修字段名"解决。
|
||||||
@@ -273,10 +274,29 @@
|
|||||||
前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。
|
前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。
|
||||||
3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。
|
3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。
|
||||||
|
|
||||||
### 批次 E · 权限门控与运营体验(P1/P2)
|
### 批次 E · 权限门控与运营体验(P1/P2)—— 🔶 部分完成(本轮)
|
||||||
1. 前端四权限门控(P1-8 剩余部分:库存/提报面板尚无 `consignment.view/manage/review` 门控);打手端菜单收口(P1-9 剩余)。
|
|
||||||
2. 报表按文档 §六补齐节省赏金等指标(P2-7);`allocation_status` CHECK 约束(P2-19 剩余)。
|
1. ✅ **前端四权限门控(P1-8 剩余部分)**:
|
||||||
3. 面板错误态、双提交、徽标、搜索、押金回显、文案/时区等体验项(P2-1~P2-18)。
|
- 新增 `resolveAdminPermission(sessionPermissions, code)`(`utils/admin-auth.ts`):会话接口的最新权限列表优先,
|
||||||
|
接口异常时才回退登录时的本地快照;**空数组也判定为无权限**,不回退(管理员刚收回权限的场景)。
|
||||||
|
- 寄售仓库面板:`consignment.manage` 控制"手动入库 / 编辑 / 作废 / 退单回库 / 匹配开关"(无权限时开关禁用并给 tooltip 说明)。
|
||||||
|
- 寄卖提报面板:`consignment.review` 控制"审核入库 / 驳回"。
|
||||||
|
- `AdminWorkerPlatformPage`:无 `consignment.view` 时不渲染"寄售仓库 / 寄卖提报审核"两个 tab,
|
||||||
|
并把 URL 或 localStorage 里的旧选择拉回工单页(否则会落在空白 tab 上)。
|
||||||
|
2. ✅ **`allocation_status` 收口(P2-19 剩余)**:迁移 `080` 先把历史非法值归位到「待安排」(备注留痕)再加
|
||||||
|
`CHECK (allocation_status IN ('待安排','已安排'))`;编辑接口新增拒绝 `allocationStatus`(400
|
||||||
|
`consignment_allocation_status_update_forbidden`),与"编辑接口不可改 status"同口径。顺带修正 077 遗留的
|
||||||
|
"收集链接/玩家提交"权限描述文案(该渠道已在 079 下线)。
|
||||||
|
3. ✅ **体验项(部分)**:两个面板加 `isError` 错误态(含重试按钮);审核通过/驳回弹窗加 `confirmLoading` 与
|
||||||
|
提交中守卫(防双提交);待审徽标改用后端真实总数(原来只统计当前页,翻页就变);编辑弹窗押金不再兜底 `¥100`
|
||||||
|
(会把"无押金"悄悄写成 100 元);履约拦截弹窗的冷静期文案不再硬编码"3 天",改为"按商品规则设定(未配置时 72 小时)"。
|
||||||
|
4. ✅ **核实并撤回一条误报**:原审计提到"倒计时时区偏移"——`deadlineAt` 由 node-pg 解析 `timestamptz` 后
|
||||||
|
以带 `Z` 的 ISO 字符串下发,前端 `new Date(deadlineAt)` 无偏移问题,已用真实库数据确认,不需要改。
|
||||||
|
5. ⏳ 未做:
|
||||||
|
- 报表补齐(P2-7:节省赏金 / 出库单数 / 批次回款);
|
||||||
|
- 搜索字段后端不支持(面板承诺按联系方式/游戏 ID 搜索,后端只搜部分字段);
|
||||||
|
- 打手端"按账号状态隐藏寄卖入口":后端 `POST /consignment/publish` 已强制 `requireActiveWorker`,
|
||||||
|
前端要隐藏入口需要 `/worker/me` 暴露账号状态字段,本次未动接口。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -285,4 +305,4 @@
|
|||||||
1. **撤销边界**:✅ 定案 —— **已验收一律禁止撤销**,历史数据不做兼容(用户:"这是测试阶段,不用管历史记录")。买家退款/纠纷走售后流程;若日后确有"验收后退款"场景,另开"售后回收库存"流程(回库或作废 + 押金重算)。
|
1. **撤销边界**:✅ 定案 —— **已验收一律禁止撤销**,历史数据不做兼容(用户:"这是测试阶段,不用管历史记录")。买家退款/纠纷走售后流程;若日后确有"验收后退款"场景,另开"售后回收库存"流程(回库或作废 + 押金重算)。
|
||||||
2. **`worker_id` 拆分**:✅ 定案 —— 直接新增 `assigned_worker_id` 并让 `worker_id` 固定为卖家(迁移 `079`,无兼容期,不回填历史)。
|
2. **`worker_id` 拆分**:✅ 定案 —— 直接新增 `assigned_worker_id` 并让 `worker_id` 固定为卖家(迁移 `079`,无兼容期,不回填历史)。
|
||||||
3. **押金策略**:⏳ 未定 —— `pending_review` 超期自动驳回退押金的时限(如 7 天)、以及打手自助撤回是否允许,涉及运营对账口径,需业务确认。
|
3. **押金策略**:⏳ 未定 —— `pending_review` 超期自动驳回退押金的时限(如 7 天)、以及打手自助撤回是否允许,涉及运营对账口径,需业务确认。
|
||||||
4. **批次顺序**:✅ 定案 —— A(入口可用)→ B(资金安全)→ C/D(资金与货物一致性、防错发)均已落地;E(权限门控与运营体验)为下一批。
|
4. **批次顺序**:✅ 定案 —— A(入口可用)→ B(资金安全)→ C/D(资金与货物一致性、防错发)已完成;E(权限门控与运营体验)本轮完成权限与约束部分,报表/搜索/打手端入口为剩余项。
|
||||||
|
|||||||
Reference in New Issue
Block a user