修正 ERP 边界与资产页面一致性(05-30-fix-erp-boundary-asset-consistency)

这个提交包含在:
2026-05-30 23:06:02 +08:00
父节点 16fd9bdf52
当前提交 f299ceeb74
共修改 47 个文件,包含 476 行新增和 1633 行删除
@@ -361,8 +361,9 @@ point and prevents each route from inventing a different title/action layout.
- `个人设置` stays in the topbar account menu. Notifications stay in the topbar - `个人设置` stays in the topbar account menu. Notifications stay in the topbar
bell and notification center. bell and notification center.
- Finance labels should use work names such as `付款`, `应付账款` and `票据`; - Finance labels should use work names such as `付款`, `应付账款` and `票据`;
internal platform words like `AP 运营`, `ERP 集成` and `功能开关` should be internal platform words like `AP 运营` and `功能开关` should be hidden behind
hidden behind management wording or renamed for users. management wording or renamed for users. `ERP 集成` is not a hyb-oa product
entry.
- `更多` is a lightweight collapsed row for low-frequency, frozen, staged or - `更多` is a lightweight collapsed row for low-frequency, frozen, staged or
occasional entries; do not add a sidebar explanation card, subtitle or occasional entries; do not add a sidebar explanation card, subtitle or
onboarding copy around it. onboarding copy around it.
@@ -652,7 +653,7 @@ screen.
- Procurement ledgers in V1 are request, approval, attachment, light - Procurement ledgers in V1 are request, approval, attachment, light
acceptance/archive and relation surfaces for project, contract and payment/AP acceptance/archive and relation surfaces for project, contract and payment/AP
context. Do not design `/purchase-requests` as purchase-order lifecycle, context. Do not design `/purchase-requests` as purchase-order lifecycle,
inventory, three-way matching, supplier-lifecycle, tendering or ERP software. inventory, three-way matching, supplier-lifecycle or tendering software.
Approved procurement means the request was approved; it does not imply paid, Approved procurement means the request was approved; it does not imply paid,
received into inventory or converted into a fixed asset. received into inventory or converted into a fixed asset.
- Project pages in V1 are light project ledgers for status, owner/member, - Project pages in V1 are light project ledgers for status, owner/member,
@@ -665,10 +666,11 @@ screen.
- Finance ledgers in V1 are payment-preparation, payment-batch, receipt-audit, - Finance ledgers in V1 are payment-preparation, payment-batch, receipt-audit,
AP-status, invoice/OCR-status and agency-accounting handover summaries. Do AP-status, invoice/OCR-status and agency-accounting handover summaries. Do
not design payment, AP or invoice pages as online banking, bank-statement not design payment, AP or invoice pages as online banking, bank-statement
ingestion, ERP/general-ledger, voucher-generation, auto-reconciliation, tax ingestion, general-ledger, voucher-generation, auto-reconciliation, tax filing
filing or full accounting software. Real payment stays in offline banking or or full accounting software. Real payment stays in offline banking or existing
existing finance/accounting workflows; OA records status, evidence and finance/accounting workflows; OA records status, evidence and handover
handover summaries. summaries. hyb-oa does not connect to ERP now and does not plan ERP
integration later.
- Payment, AP and invoice pages default to task/exception visibility: `付款` - Payment, AP and invoice pages default to task/exception visibility: `付款`
shows review, payment-marking, receipt and exception attention before batch shows review, payment-marking, receipt and exception attention before batch
creation; `应付账款` shows pending payment, exceptions, risks and differences creation; `应付账款` shows pending payment, exceptions, risks and differences
@@ -710,7 +712,8 @@ subsystem.
- The default `/assets` view answers where the asset is, who uses it, who is - The default `/assets` view answers where the asset is, who uses it, who is
responsible for it, what state it is in and whether it needs inventory or responsible for it, what state it is in and whether it needs inventory or
handover. Do not lead with depreciation, accounting subjects or ERP sync. handover. Do not lead with depreciation, accounting subjects or external
finance sync.
- Register physical items that must be returned, transferred, inventoried or - Register physical items that must be returned, transferred, inventoried or
protected, such as computers, monitors, disks, equipment and security media. protected, such as computers, monitors, disks, equipment and security media.
Do not create `FA` cards for ordinary consumables. Do not create `FA` cards for ordinary consumables.
@@ -738,9 +741,10 @@ subsystem.
- Move depreciation, residual value, accounting subject and monthly - Move depreciation, residual value, accounting subject and monthly
depreciation voucher concepts out of the OA primary UI unless a later Trellis depreciation voucher concepts out of the OA primary UI unless a later Trellis
task explicitly scopes accounting-ledger work. task explicitly scopes accounting-ledger work.
- Keep classification maintenance, ERP sync, raw metadata and advanced filters - Keep classification maintenance, raw metadata and advanced filters behind
behind management, collapsed or advanced surfaces so they do not dominate the management, collapsed or advanced surfaces so they do not dominate the asset
asset list/detail first screen. list/detail first screen. Do not show ERP sync state or ERP asset numbers in
the asset UI.
- Mobile asset lookup should prefer QR scan and fall back to asset number or - Mobile asset lookup should prefer QR scan and fall back to asset number or
serial number input. Do not require heavy photo inventory unless a later task serial number input. Do not require heavy photo inventory unless a later task
explicitly scopes it. explicitly scopes it.
@@ -1141,9 +1145,10 @@ request, frontend click or message attempt as final business completion.
- Enterprise WeChat owns daily HR/attendance actions: check-in, leave, missed - Enterprise WeChat owns daily HR/attendance actions: check-in, leave, missed
punch, outing and overtime. OA shows mirror, history, exception, audit and punch, outing and overtime. OA shows mirror, history, exception, audit and
emergency-takeover records for those domains. emergency-takeover records for those domains.
- ERP, agency accounting, bank, OCR and Enterprise WeChat messages are external - Agency accounting, bank, OCR and Enterprise WeChat messages are external
capabilities or delivery channels. They do not replace OA server-side capabilities or delivery channels. They do not replace OA server-side
permissions or state machines. permissions or state machines. ERP is outside hyb-oa's product boundary and
must not appear as a current or future user-facing integration.
- Show final labels such as `已同步`, `已发送`, `已付款` and `识别完成` only when the - Show final labels such as `已同步`, `已发送`, `已付款` and `识别完成` only when the
relevant external system has returned a clear success acknowledgement. relevant external system has returned a clear success acknowledgement.
- Use in-progress labels such as `同步中`, `等待回执`, `银行处理中`, `识别中` and - Use in-progress labels such as `同步中`, `等待回执`, `银行处理中`, `识别中` and
@@ -1152,8 +1157,8 @@ request, frontend click or message attempt as final business completion.
such as `银行回执未返回` or `OCR 识别失败,需重新上传 PDF`. such as `银行回执未返回` or `OCR 识别失败,需重新上传 PDF`.
- Do not expose raw external error codes, payloads, full URLs, secrets, tokens - Do not expose raw external error codes, payloads, full URLs, secrets, tokens
or debug traces to ordinary users. or debug traces to ordinary users.
- External writes, payment execution, ERP sync, Enterprise WeChat resend and OCR - External writes, payment execution, Enterprise WeChat resend and OCR retry
retry must be controlled by permissions and state-driven actions. must be controlled by permissions and state-driven actions.
- Retry confirmation should explain which object will be retried, whether it may - Retry confirmation should explain which object will be retried, whether it may
send duplicate external messages, external-system impact and audit effect. send duplicate external messages, external-system impact and audit effect.
- External sync state should not block unrelated primary flows. If it blocks a - External sync state should not block unrelated primary flows. If it blocks a
@@ -1165,8 +1170,9 @@ request, frontend click or message attempt as final business completion.
unless OA emergency takeover is explicitly enabled. unless OA emergency takeover is explicitly enabled.
- Enterprise WeChat messages carry desensitized summary and OA deeplink only; - Enterprise WeChat messages carry desensitized summary and OA deeplink only;
they do not carry sensitive attachment text or final payment/approval actions. they do not carry sensitive attachment text or final payment/approval actions.
- If bank, ERP, agency accounting or another integration is not connected or not - If bank, agency accounting or another enabled integration is not connected or
confirmed, UI copy must not imply the capability already completed. not confirmed, UI copy must not imply the capability already completed. Do not
use ERP as a placeholder for future integration copy.
**Why**: External integrations are asynchronous and failure-prone. Conservative **Why**: External integrations are asynchronous and failure-prone. Conservative
copy prevents users from trusting a state the system has not actually verified. copy prevents users from trusting a state the system has not actually verified.
@@ -1176,7 +1182,7 @@ copy prevents users from trusting a state the system has not actually verified.
- `cd client && npm run typecheck` - `cd client && npm run typecheck`
- `cd client && npm run build` - `cd client && npm run build`
- Focused Playwright coverage when changing Enterprise WeChat boundary copy, - Focused Playwright coverage when changing Enterprise WeChat boundary copy,
OCR status, bank/payment callbacks, ERP/accounting sync status, retry OCR status, bank/payment callbacks, accounting handover status, retry
controls, external failure messages or integration diagnostics. controls, external failure messages or integration diagnostics.
## Convention: Verification And User-Testable Claims ## Convention: Verification And User-Testable Claims
@@ -0,0 +1,3 @@
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "检查列表/详情/动作区是否符合统一成熟页面规则"}
{"file": "docs/产品设计规则.md", "reason": "检查无 ERP、员工姓名编号、资产业务边界是否沉淀"}
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "检查敏感信息和外部系统文案没有误暴露"}
@@ -0,0 +1,3 @@
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "统一前端页面基线、资产台账和详情页动作区规则"}
{"file": "docs/产品设计规则.md", "reason": "产品边界、无 ERP 口径、资产管理业务事实"}
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "资产敏感字段与外部系统文案清理的隐私边界"}
@@ -0,0 +1,13 @@
# 实施说明
## 设计决策
- ERP 是产品边界外能力:当前 UI、导航、设置、功能开关和默认业务详情不再暴露 ERP 入口、同步状态或未来接入口径。
- 资产首屏按业务台账处理:列表只负责查找、状态和进入详情,详情采用主内容区加 PC 右侧操作面板。
- 资产操作区集中承接保存、领用、归还和调拨,按钮横向排列,普通解释文案不默认展示。
- 账号默认展示中文姓名和员工编号;已有本地账号的 `employee_no` 不强制更新,因为数据库约束把员工号视为不可变字段。
## 验证限制
- 本地真实资产 E2E 受数据库迁移漂移阻断,当前库缺少 `asset_cards.asset_type` 和 `purchase_requests.project_id` 等列;本任务不擅自执行数据库迁移。
- 资产页面已用 mock API 做浏览器检查,验证无 ERP 文案、右侧操作区、中文姓名和员工号展示。
@@ -0,0 +1,67 @@
# 修正 ERP 边界与资产页面一致性
## Goal
把用户反馈的产品边界和资产页面一致性问题收口到同一批改造:OA 不再出现 ERP 作为当前或未来接入对象;账号展示使用中文姓名和员工编号;资产列表、资产详情和资产操作区回到全系统统一的成熟企业台账/详情页规则。
## What I already know
- 目标用户是国内、小于 10 人的科技初创公司,页面应优先采用成熟 OA / Feishu / Ant Design Pro 式常规交互,不做创新型操作命名或解释性堆砌。
- 用户明确确认:系统中没有 ERP,以后也不会有 ERP。
- 员工账号应有中文姓名和员工编号,不能在真实可见路径里默认展示 `Admin`、`Employee`、`Finance Manager` 这类英文角色名。
- `client/src/pages/AssetRegistryPage.tsx` 的资产列表仍使用页面本地 `Table` 组件结构,视觉密度和报销等列表不一致。
- 资产详情页仍是单列大卡堆叠,存在基础信息、责任归档、流转、ERP 同步、AI 建议、保存动作等多处操作与说明,和统一详情工作区的右侧状态/动作面板不一致。
- `server/src/scripts/seed-dev.js` 默认开发账号姓名为英文角色名;`client/tests/e2e/helpers/auth.ts` 和多条 E2E fixture 也有英文名。
- `docs/产品设计规则.md` 和 `.trellis/spec/hyb-oa/frontend-react-vite-design.md` 已有部分 no-ERP 事实,但仍把 ERP 写成“外部能力/外部系统”或资产高级轨道。
## Requirements
- 用户可见 UI、导航、设置、功能开关和资产/采购/报销详情的默认路径不得出现 ERP 文案、ERP 入口或 ERP 未来接入口径。
- 后端 API、数据库表、历史迁移或内部代码中的 `erp_*` 技术遗留不在本任务大拆;但不能通过前端入口、菜单或说明暴露为产品能力。
- 开发和自动化默认账号改为中文姓名,并保留员工编号。测试代码中需要登录识别的用户对象也应同步中文显示名。
- 资产列表采用与报销列表一致的业务台账样式:单列列表首屏、统一 `PrimaryTable` + `table-row/table-td` 密度、右侧操作列、移动端卡片尾部动作。
- 资产台账工具只承接低频管理能力,例如分类维护、高级筛选和新建资产;首屏不把工具详情铺在左侧或常驻在列表旁。
- 资产详情采用统一详情工作区:顶部摘要、主内容区信息/证据、PC 右侧状态与操作面板,移动端保持单列和底部/就近主动作。
- 资产流转动作集中到详情操作区,按钮横向排列;基础信息保存、领用、归还、调拨等动作位置稳定,不分散在多个大卡下方。
- 资产详情默认减少解释性句子,仅保留安全/权限/高敏字段等必要边界提示。
## Scope
- 前端页面:资产台账/详情、报销详情财务导出暴露面、顶部账号展示、导航/设置/功能开关中的 ERP 入口隐藏。
- 开发与测试账号:开发种子和常用 E2E fixture 的默认显示名改为中文,并保留员工编号展示。
- 事实源:更新 `docs/产品设计规则.md` 和 `.trellis/spec/hyb-oa/frontend-react-vite-design.md` 中的 no-ERP 和资产详情操作区规则。
- 验证:前端类型检查、构建、聚焦 Playwright/mock 视觉检查、Trellis task 校验、governance 和 diff 检查。
## Acceptance Criteria
- [x] `/assets` PC 首屏资产列表和报销等台账使用一致的表格密度、右侧操作列和单列首屏结构。
- [x] 资产详情 PC 端出现统一右侧状态/动作面板;流转、保存、返回等动作不再分散在页面底部或独立大块中。
- [x] 资产详情默认不显示 ERP 同步状态、ERP 资产编号或 ERP 重试操作。
- [x] 管理入口、功能开关和普通业务页面不再向用户展示 ERP 作为系统能力。
- [x] 开发/E2E 默认账号中文显示名与员工编号同步,顶部账号、人员选择和测试 fixture 不再默认依赖英文角色名。
- [x] 文档和 Trellis spec 固化:OA 无 ERP,未来也不规划接入;资产页不以 ERP/会计固定资产账为产品口径。
- [x] 前端类型检查、构建、相关 Playwright 或截图检查通过;Trellis governance 和 `git diff --check` 通过。真实资产 E2E 因本地数据库 schema 漂移阻断,已记录在 `implement.md`。
## Out of Scope
- 不删除历史数据库迁移、已有后端 ERP 命名服务、历史 API 路径或老数据表,以免扩大破坏面;本任务只清理产品暴露面和当前默认 UI。
- 不引入新的外部系统、财务软件、总账、银行直连、自动凭证或折旧模块。
- 不重做资产数据模型、盘点任务引擎或二维码贴标全流程。
- 不修改公网真实数据,除非后续部署/验证步骤明确需要并获得批准。
## Test Plan
- `cd client && npm run typecheck`
- `cd client && npm run build`
- 聚焦 Playwright:资产列表/详情、跨页面 UI 一致性、登录/顶部账号显示相关用例。
- `python3 ./.trellis/scripts/task.py validate 05-30-fix-erp-boundary-asset-consistency`
- `node scripts/qa/check-trellis-governance.mjs`
- `git diff --check`
- 若需要告知线上可测,部署后再跑公网 `/ready`、bundle copy 扫描和可用凭据范围内的 live smoke。
## Technical Notes
- 主要前端文件:`client/src/pages/AssetRegistryPage.tsx`、`client/src/pages/ErpIntegrationPage.tsx`、`client/src/pages/SystemSettingsPage.tsx`、`client/src/pages/FeatureFlagsPage.tsx`、`client/src/features/navigation/schema.ts`。
- 账号/fixture:`server/src/scripts/seed-dev.js`、`server/src/scripts/seed-trialrun.js`、`client/tests/e2e/helpers/auth.ts` 和涉及 mock `me.name` 的 E2E。
- 事实源:`docs/产品设计规则.md`、`.trellis/spec/hyb-oa/frontend-react-vite-design.md`。
- 设计裁决按 `frontend-design` skill 和现有成熟企业页面基线执行。
@@ -0,0 +1,33 @@
{
"id": "fix-erp-boundary-asset-consistency",
"name": "fix-erp-boundary-asset-consistency",
"title": "修正 ERP 边界与资产页面一致性",
"description": "清理用户可见 ERP 产品暴露,统一资产台账/详情交互,并同步中文姓名与员工编号展示。",
"status": "in_progress",
"dev_type": null,
"scope": "client,server,docs,trellis-spec",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-05-30",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
"client/src/pages/AssetRegistryPage.tsx",
"client/src/pages/ReimbursementDetailPage.tsx",
"client/src/components/navigation/Topbar.tsx",
"server/src/scripts/seed-dev.js",
"docs/产品设计规则.md",
".trellis/spec/hyb-oa/frontend-react-vite-design.md"
],
"notes": "",
"meta": {}
}
+1 -1
查看文件
@@ -358,7 +358,7 @@ export function mapApiErrorMessage(
} }
if (details?.integration === "erp_sync_disabled") { if (details?.integration === "erp_sync_disabled") {
return "ERP 同步功能未开启。"; return "该同步功能未开启。";
} }
if (details?.integration === "travel_sync_disabled") { if (details?.integration === "travel_sync_disabled") {
@@ -56,7 +56,6 @@ const MANAGEMENT_ACTIVE_PATHS = [
"/system/reimbursement-templates", "/system/reimbursement-templates",
"/system/master-data", "/system/master-data",
"/system/notification-channels", "/system/notification-channels",
"/system/integrations/erp",
"/system/users", "/system/users",
"/system/departments", "/system/departments",
"/system/cutover", "/system/cutover",
+5 -3
查看文件
@@ -178,7 +178,9 @@ export function Topbar() {
</span> </span>
) : null} ) : null}
</div> </div>
<div className="mt-0.5 text-xs text-[var(--color-text-secondary)]">{me ? `${me.name} · 当前工作区` : "海音达 OA"}</div> <div className="mt-0.5 text-xs text-[var(--color-text-secondary)]">
{me ? `${me.name} · ${me.employeeNo}` : "海音达 OA"}
</div>
</div> </div>
</div> </div>
@@ -293,8 +295,8 @@ export function Topbar() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button data-testid="topbar-profile-trigger" variant="secondary" type="button" className="max-w-[160px] truncate"> <Button data-testid="topbar-profile-trigger" variant="secondary" type="button" className="max-w-[190px] truncate">
{me?.name ?? "账号"} {me ? `${me.name} · ${me.employeeNo}` : "账号"}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -58,7 +58,7 @@ export function TravelOrderMarkPaidDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>标记差旅订单已支付</DialogTitle> <DialogTitle>标记差旅订单已支付</DialogTitle>
<DialogDescription> <DialogDescription>
确认订单 `{order?.externalOrderNo ?? "-"}` 已完成对公支付;系统会按幂等键复用现有 ERP journal 出站记录。 确认订单 `{order?.externalOrderNo ?? "-"}` 已完成对公支付;系统会按幂等键复用现有财务交接记录。
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -66,7 +66,7 @@ function erpStageLabel(order: TravelPaymentOrder) {
if (order.erpStage.binding?.externalId) { if (order.erpStage.binding?.externalId) {
return "已同步"; return "已同步";
} }
return "ERP 出站未开启"; return "摘要导出未开启";
} }
type TravelPaymentTrackCardProps = { type TravelPaymentTrackCardProps = {
@@ -178,7 +178,7 @@ export function TravelPaymentTrackCard({ travelRequestId }: TravelPaymentTrackCa
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<h3 className="text-base font-semibold text-zinc-900">对公支付轨道</h3> <h3 className="text-base font-semibold text-zinc-900">对公支付轨道</h3>
<p className="mt-1 text-sm text-zinc-500">授权、交易、发票绑定与 ERP 支付出站在此统一查看。</p> <p className="mt-1 text-sm text-zinc-500">授权、交易、发票绑定与财务交接导出在此统一查看。</p>
</div> </div>
{canManagePayments ? <span className="text-xs text-zinc-500">具备支付管理权限</span> : null} {canManagePayments ? <span className="text-xs text-zinc-500">具备支付管理权限</span> : null}
</div> </div>
@@ -257,7 +257,7 @@ export function TravelPaymentTrackCard({ travelRequestId }: TravelPaymentTrackCa
<div>金额:{order.amount} {order.currency}</div> <div>金额:{order.amount} {order.currency}</div>
<div>付款凭证:{order.paymentVoucherNo ?? "-"}</div> <div>付款凭证:{order.paymentVoucherNo ?? "-"}</div>
<div>付款日期:{order.paymentDate ?? "-"} · 渠道:{paymentChannelLabel(order.paymentChannel)}</div> <div>付款日期:{order.paymentDate ?? "-"} · 渠道:{paymentChannelLabel(order.paymentChannel)}</div>
<div>ERP 状态:{erpStageLabel(order)}</div> <div>导出状态:{erpStageLabel(order)}</div>
</div> </div>
</div> </div>
{canManagePayments ? ( {canManagePayments ? (
@@ -244,7 +244,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
}, },
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] }); await queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] });
toast.success('ERP 摘要桥已重新入队'); toast.success('交接摘要已重新入队');
}, },
onError: (error) => { onError: (error) => {
toast.error(resolveError(error)); toast.error(resolveError(error));
@@ -418,7 +418,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
) : null} ) : null}
{!selectedBatchId ? ( {!selectedBatchId ? (
<EmptyStateCard title="请选择对账批次" description="从左侧选择一个批次查看 KPI、异常项与 ERP 摘要桥轨道。" /> <EmptyStateCard title="请选择对账批次" description="从左侧选择一个批次查看 KPI、异常项与交接摘要。" />
) : null} ) : null}
{detail ? ( {detail ? (
@@ -445,7 +445,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
) : null} ) : null}
{track?.permissions.canRetry ? ( {track?.permissions.canRetry ? (
<Button type="button" variant="secondary" onClick={() => requeueMutation.mutate()} disabled={requeueMutation.isPending}> <Button type="button" variant="secondary" onClick={() => requeueMutation.mutate()} disabled={requeueMutation.isPending}>
{requeueMutation.isPending ? '重试中...' : 'Requeue ERP'} {requeueMutation.isPending ? '重试中...' : '重试导出'}
</Button> </Button>
) : null} ) : null}
</div> </div>
@@ -473,14 +473,14 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
<div className="card-std space-y-4 p-6"> <div className="card-std space-y-4 p-6">
<div> <div>
<h3 className="text-base font-semibold text-zinc-900">ERP 摘要桥</h3> <h3 className="text-base font-semibold text-zinc-900">交接摘要</h3>
<p className="mt-1 text-sm text-zinc-500">批次级 `erp.travel.export` 轨道,不会回改订单、申请或预算原值。</p> <p className="mt-1 text-sm text-zinc-500">批次级摘要导出轨道,不会回改订单、申请或预算原值。</p>
</div> </div>
{trackQuery.isLoading ? <CardSkeleton lines={4} /> : null} {trackQuery.isLoading ? <CardSkeleton lines={4} /> : null}
{trackQuery.isError ? ( {trackQuery.isError ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700"> <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
ERP 轨道加载失败:{resolveError(trackQuery.error)} 摘要轨道加载失败:{resolveError(trackQuery.error)}
</p> </p>
) : null} ) : null}
-1
查看文件
@@ -46,7 +46,6 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/system/settings", title: "管理", group: "管理" }, { path: "/system/settings", title: "管理", group: "管理" },
{ path: "/system/feature-flags", title: "功能管理", group: "管理" }, { path: "/system/feature-flags", title: "功能管理", group: "管理" },
{ path: "/system/cutover", title: "切换控制台", group: "更多" }, { path: "/system/cutover", title: "切换控制台", group: "更多" },
{ path: "/system/integrations/erp", title: "外部系统", group: "更多" },
{ path: "/dashboard", title: "数据看板", group: "更多" }, { path: "/dashboard", title: "数据看板", group: "更多" },
{ path: "/audit-logs", title: "审计日志", group: "管理" }, { path: "/audit-logs", title: "审计日志", group: "管理" },
]; ];
+192 -353
查看文件
@@ -10,16 +10,12 @@ import {
fetchAssetAssignees, fetchAssetAssignees,
fetchAssetCategories, fetchAssetCategories,
fetchAssetDetail, fetchAssetDetail,
fetchAssetErpTrack,
fetchAssets, fetchAssets,
patchAsset, patchAsset,
patchAssetCategory, patchAssetCategory,
requeueAssetErpSync,
returnAsset, returnAsset,
transferAsset, transferAsset,
type AssetCategoryItem, type AssetCategoryItem,
type AssetErpStage,
type AssetErpTrack,
type AssetItem, type AssetItem,
type AssetSensitivityLevel, type AssetSensitivityLevel,
type AssetStatus, type AssetStatus,
@@ -30,11 +26,12 @@ import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities } from ".
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions"; import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { DateTextInput } from "../components/common/DateTextInput"; import { DateTextInput } from "../components/common/DateTextInput";
import { StatusBadge } from "../components/common/StatusBadge"; import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton"; import { TableSkeleton } from "../components/common/TableSkeleton";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace"; import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
@@ -117,16 +114,6 @@ function assetStatusLabel(value: AssetStatus | string | null | undefined) {
return assetStatusOptions().find((item) => item.value === value)?.label ?? "未知状态"; return assetStatusOptions().find((item) => item.value === value)?.label ?? "未知状态";
} }
function assetErpChangeTypeLabel(value: string | null | undefined) {
const labels: Record<string, string> = {
created: "新建资产卡",
updated: "资产信息更新",
retired: "资产退役",
archived: "资产归档",
};
return value ? labels[value] ?? "资产变更" : "-";
}
function sensitivityOptions(): Array<{ value: AssetSensitivityLevel | ""; label: string }> { function sensitivityOptions(): Array<{ value: AssetSensitivityLevel | ""; label: string }> {
return [ return [
{ value: "", label: "全部敏感级别" }, { value: "", label: "全部敏感级别" },
@@ -137,38 +124,12 @@ function sensitivityOptions(): Array<{ value: AssetSensitivityLevel | ""; label:
]; ];
} }
function typeHint(assetType: AssetType) {
switch (assetType) {
case "electronic_document":
return "电子资料只登记摘要、hash、敏感级别和归档位置;S3/S4 原文和完整路径不上传公网 OA。";
case "electronic_system":
return "电子系统登记负责人、入口、到期和备份策略;账号密码、token、Secret 不进 OA。";
case "code_repository":
return "代码仓库继续由 Git 管理,OA 只登记仓库元数据、负责人和备份策略。";
case "backup_media":
return "备份介质记录保管人、加密状态、备份范围和最近恢复验证时间。";
default:
return "固定资产支持领用、归还、调拨、盘点和移动扫码。";
}
}
function isElectronicAssetType(assetType: AssetType) { function isElectronicAssetType(assetType: AssetType) {
return assetType !== "fixed_asset"; return assetType !== "fixed_asset";
} }
function assetNextStep(item: Pick<AssetItem, "assetType" | "assignee" | "owner" | "status" | "lastVerifiedAt" | "expiresAt">) { function formatEmployeeSummary(employee: { name: string; employeeNo: string } | null | undefined) {
if (item.status === "archived") return "已归档留存"; return employee ? `${employee.name} · ${employee.employeeNo}` : "-";
if (item.status === "retired") return "核对处置记录";
if (item.status === "under_repair") return "跟进维修结果";
if (item.assetType === "fixed_asset") {
if (!item.assignee) return "待领用或确认保管";
if (!item.lastVerifiedAt) return "待首次盘点";
return "按季度盘点";
}
if (!item.owner) return "补负责人";
if (!item.lastVerifiedAt) return "待验证责任/备份";
if (item.expiresAt && item.expiresAt < new Date().toISOString().slice(0, 10)) return "检查到期续费";
return "按期验证权限";
} }
function CompactAssetNo({ value, copyable = true }: { value: string; copyable?: boolean }) { function CompactAssetNo({ value, copyable = true }: { value: string; copyable?: boolean }) {
@@ -231,37 +192,6 @@ function unsafeAssetTextReason(value: string) {
return ""; return "";
} }
function formatAssetErpReason(reason: string | null) {
switch (reason) {
case "erp_sync_disabled":
case "env_hard_off":
return "ERP 总闸当前关闭";
case "flag_force_off":
return "ERP 功能开关当前强制关闭";
case "missing_flag_record":
return "ERP 功能开关记录缺失";
default:
return reason ?? null;
}
}
function resolveAssetErpTimestamp(stage: AssetErpStage) {
return stage.outbox?.updatedAt ?? stage.outbox?.processedAt ?? stage.binding?.lastSyncedAt ?? null;
}
function resolveAssetErpActionLabel(actionHint: AssetErpTrack["stage"]["actionHint"]) {
switch (actionHint) {
case "backfill":
return "补发";
case "retry":
return "重试同步";
case "resync":
return "重新同步摘要";
default:
return null;
}
}
export function AssetRegistryPage() { export function AssetRegistryPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@@ -270,7 +200,6 @@ export function AssetRegistryPage() {
const canManage = hasPermission(permissions, "asset.manage"); const canManage = hasPermission(permissions, "asset.manage");
const canTransfer = hasPermission(permissions, "asset.transfer"); const canTransfer = hasPermission(permissions, "asset.transfer");
const canIntegrationManage = hasPermission(permissions, "integration.manage");
const canViewAll = hasAnyPermission(permissions, ["asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]); const canViewAll = hasAnyPermission(permissions, ["asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]); const canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]);
@@ -324,12 +253,6 @@ export function AssetRegistryPage() {
queryFn: () => fetchAssetDetail(selectedAssetId), queryFn: () => fetchAssetDetail(selectedAssetId),
}); });
const assetErpTrackQuery = useQuery({
enabled: canIntegrationManage && Boolean(selectedAssetId) && !isCreatingAsset,
queryKey: ["assets", "erp-track", selectedAssetId],
queryFn: () => fetchAssetErpTrack(selectedAssetId),
});
const legalEntitiesQuery = useQuery({ const legalEntitiesQuery = useQuery({
queryKey: ["assets", "legal-entities"], queryKey: ["assets", "legal-entities"],
queryFn: () => fetchLegalEntities({ enabled: true }), queryFn: () => fetchLegalEntities({ enabled: true }),
@@ -442,9 +365,6 @@ export function AssetRegistryPage() {
}, [costCentersQuery.data?.items, legalEntityFilter]); }, [costCentersQuery.data?.items, legalEntityFilter]);
const currentAsset = assetDetailQuery.data?.asset ?? null; const currentAsset = assetDetailQuery.data?.asset ?? null;
const assetErpTrack = assetErpTrackQuery.data ?? null;
const assetErpStage = assetErpTrack?.stage ?? null;
const assetErpActionLabel = assetErpStage ? resolveAssetErpActionLabel(assetErpStage.actionHint) : null;
const canEditCurrent = canManage && (isCreatingAsset || currentAsset?.permissions.canEdit); const canEditCurrent = canManage && (isCreatingAsset || currentAsset?.permissions.canEdit);
function resetCategoryEditor(category?: AssetCategoryItem) { function resetCategoryEditor(category?: AssetCategoryItem) {
@@ -612,7 +532,6 @@ export function AssetRegistryPage() {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }), queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }), queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
queryClient.invalidateQueries({ queryKey: ["assets", "erp-track"] }),
]); ]);
setAssetFormError(""); setAssetFormError("");
setSelectedAssetId(data.asset.id); setSelectedAssetId(data.asset.id);
@@ -627,30 +546,6 @@ export function AssetRegistryPage() {
}); });
const requeueAssetErpMutation = useMutation({
mutationFn: async () => {
if (!currentAsset) {
throw new Error("请先选择资产卡片");
}
return requeueAssetErpSync(currentAsset.id, true);
},
onSuccess: async (data) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assets", "erp-track"] }),
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
]);
const message = data.duplicated
? "ERP 摘要已是最新状态"
: data.requeued
? "资产 ERP 同步已重新入队"
: "资产 ERP 同步已入队";
toast.success(message);
},
onError: (error) => {
toast.error(resolveError(error));
},
});
const assignAssetMutation = useMutation({ const assignAssetMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!currentAsset) { if (!currentAsset) {
@@ -819,6 +714,11 @@ export function AssetRegistryPage() {
</Button> </Button>
) : ( ) : (
<> <>
{canManage ? (
<Button type="button" data-testid="asset-create" onClick={resetAssetEditor}>
新增资产
</Button>
) : null}
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }> <Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
移动扫码查找 移动扫码查找
</Button> </Button>
@@ -835,15 +735,6 @@ export function AssetRegistryPage() {
<div className="grid gap-6"> <div className="grid gap-6">
{showLedgerTools ? ( {showLedgerTools ? (
<div className="card-std space-y-4 p-4 md:p-5" data-testid="asset-ledger-tools"> <div className="card-std space-y-4 p-4 md:p-5" data-testid="asset-ledger-tools">
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
<div className="font-medium text-slate-900">台账工具</div>
{canManage ? (
<Button type="button" className="mt-3" data-testid="asset-create" onClick={resetAssetEditor}>
新建资产卡片
</Button>
) : null}
</div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">资产分类</h3> <h3 className="text-base font-semibold text-zinc-900">资产分类</h3>
{canManage ? ( {canManage ? (
@@ -1087,58 +978,61 @@ export function AssetRegistryPage() {
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">暂无资产卡片</div> <div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">暂无资产卡片</div>
)} )}
</div> </div>
<PrimaryTable className="hidden md:block"> <PrimaryTable className="hidden md:block ops-table-focus">
<Table> <table className="w-full border-collapse text-left" data-testid="assets-table">
<TableHeader> <thead className="table-header">
<tr> <tr>
<TableHead>资产</TableHead> <th className="table-th">资产</th>
<TableHead>类型</TableHead> <th className="table-th">状态</th>
<TableHead>责任链</TableHead> <th className="table-th">责任人</th>
<TableHead>位置</TableHead> <th className="table-th">保管/位置</th>
<TableHead>状态</TableHead> <th className="table-th">盘点</th>
<TableHead className="w-24 text-right">操作</TableHead> <th className="table-th w-24">操作</th>
</tr> </tr>
</TableHeader> </thead>
<TableBody> <tbody className="divide-y divide-zinc-50">
{orderedAssetItems.length ? ( {orderedAssetItems.length ? (
orderedAssetItems.map((item) => ( orderedAssetItems.map((item) => (
<TableRow data-testid="asset-row" key={item.id} onClick={() => { <tr className="table-row" data-testid="asset-row" key={item.id}>
selectAsset(item.id); <td className="table-td">
}} className="cursor-pointer"> <div className="font-semibold text-zinc-900">
<TableCell> <CompactAssetNo value={item.assetNo} />
<div className="font-medium text-zinc-900"> </div>
<CompactAssetNo value={item.assetNo} /> <div className="text-xs text-zinc-500" title={item.name}>
</div> {formatTextWithCompactLongNumbers(item.name)}
<div className="text-xs text-zinc-500" title={item.name}> </div>
{formatTextWithCompactLongNumbers(item.name)} <div className="text-xs text-zinc-500">{assetTypeLabel(item.assetType)}</div>
</div> </td>
</TableCell> <td className="table-td">
<TableCell>{assetTypeLabel(item.assetType)}</TableCell> <StatusBadge status={item.status} />
<TableCell> </td>
<div>{item.owner?.name ?? item.assignee?.name ?? "-"}</div> <td className="table-td">
<div className="text-xs text-zinc-500">保管 {item.keeper?.name ?? "-"}</div> <div className="font-medium text-zinc-900">{formatEmployeeSummary(item.owner ?? item.assignee)}</div>
</TableCell> <div className="text-xs text-zinc-500">归属 {formatEmployeeSummary(item.assignee)}</div>
<TableCell title={item.location ?? undefined}> </td>
<div>{formatTextWithCompactLongNumbers(item.location ?? "-")}</div> <td className="table-td" title={item.location ?? undefined}>
<div className="text-xs text-zinc-500">盘点 {item.lastVerifiedAt ? formatDisplayDateTime(item.lastVerifiedAt) : "未登记"}</div> <div className="font-medium text-zinc-900">{formatEmployeeSummary(item.keeper)}</div>
</TableCell> <div className="text-xs text-zinc-500">{formatTextWithCompactLongNumbers(item.location ?? "-")}</div>
<TableCell><StatusBadge status={item.status} /></TableCell> </td>
<TableCell className="text-right"> <td className="table-td">
<Button type="button" size="sm" variant="secondary" onClick={() => selectAsset(item.id)}> {item.lastVerifiedAt ? formatDisplayDateTime(item.lastVerifiedAt) : "未登记"}
查看详情 </td>
</Button> <td className="table-td">
</TableCell> <Button type="button" size="sm" variant="secondary" onClick={() => selectAsset(item.id)}>
</TableRow> 查看详情
)) </Button>
) : ( </td>
<TableRow> </tr>
<TableCell colSpan={6} className="py-4 text-zinc-500"> ))
暂无资产卡片 ) : (
</TableCell> <tr>
</TableRow> <td colSpan={6} className="py-8 text-center text-sm text-zinc-500">
)} 暂无资产卡片
</TableBody> </td>
</Table> </tr>
)}
</tbody>
</table>
</PrimaryTable> </PrimaryTable>
</> </>
) : null} ) : null}
@@ -1146,7 +1040,8 @@ export function AssetRegistryPage() {
</div> </div>
</div> </div>
) : ( ) : (
<div className="card-std space-y-4 p-4 md:p-5"> <div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="card-std space-y-4 p-4 md:p-5">
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isLoading ? <TableSkeleton columns={2} rows={8} /> : null} {selectedAssetId && !isCreatingAsset && assetDetailQuery.isLoading ? <TableSkeleton columns={2} rows={8} /> : null}
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isError ? ( {selectedAssetId && !isCreatingAsset && assetDetailQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetDetailQuery.error)}</p> <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetDetailQuery.error)}</p>
@@ -1182,19 +1077,19 @@ export function AssetRegistryPage() {
<div className="grid gap-3 md:grid-cols-4" data-testid="asset-detail-summary"> <div className="grid gap-3 md:grid-cols-4" data-testid="asset-detail-summary">
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm"> <div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
<div className="text-xs text-zinc-500">责任人</div> <div className="text-xs text-zinc-500">责任人</div>
<div className="mt-1 font-medium text-zinc-900">{currentAsset.owner?.name ?? currentAsset.assignee?.name ?? "未指定"}</div> <div className="mt-1 font-medium text-zinc-900">{formatEmployeeSummary(currentAsset.owner ?? currentAsset.assignee)}</div>
</div> </div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm"> <div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
<div className="text-xs text-zinc-500">保管/位置</div> <div className="text-xs text-zinc-500">保管/位置</div>
<div className="mt-1 font-medium text-zinc-900">{currentAsset.keeper?.name ?? currentAsset.location ?? "待确认"}</div> <div className="mt-1 font-medium text-zinc-900">{currentAsset.keeper ? formatEmployeeSummary(currentAsset.keeper) : currentAsset.location ?? "待确认"}</div>
</div> </div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm"> <div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
<div className="text-xs text-zinc-500">{isElectronicAssetType(currentAsset.assetType) ? "最近验证" : "最近盘点"}</div> <div className="text-xs text-zinc-500">{isElectronicAssetType(currentAsset.assetType) ? "最近验证" : "最近盘点"}</div>
<div className="mt-1 font-medium text-zinc-900">{currentAsset.lastVerifiedAt ? formatDisplayDateTime(currentAsset.lastVerifiedAt) : "未登记"}</div> <div className="mt-1 font-medium text-zinc-900">{currentAsset.lastVerifiedAt ? formatDisplayDateTime(currentAsset.lastVerifiedAt) : "未登记"}</div>
</div> </div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm"> <div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
<div className="text-xs text-zinc-500">下一步</div> <div className="text-xs text-zinc-500">状态</div>
<div className="mt-1 font-medium text-zinc-900">{assetNextStep(currentAsset)}</div> <div className="mt-1"><StatusBadge status={currentAsset.status} /></div>
</div> </div>
</div> </div>
) : null} ) : null}
@@ -1203,7 +1098,6 @@ export function AssetRegistryPage() {
<section className="space-y-4"> <section className="space-y-4">
<div> <div>
<h4 className="text-sm font-semibold text-zinc-900">基础信息</h4> <h4 className="text-sm font-semibold text-zinc-900">基础信息</h4>
<p className="text-xs text-zinc-500">维护资产主标识、类型、分类和组织归属。</p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
@@ -1219,7 +1113,6 @@ export function AssetRegistryPage() {
</option> </option>
))} ))}
</select> </select>
<p className="text-xs text-zinc-500">{typeHint(assetForm.assetType)}</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">资产状态</label> <label className="text-sm font-medium text-zinc-700">资产状态</label>
@@ -1300,7 +1193,6 @@ export function AssetRegistryPage() {
<section className="space-y-4 border-t border-zinc-200 pt-5"> <section className="space-y-4 border-t border-zinc-200 pt-5">
<div> <div>
<h4 className="text-sm font-semibold text-zinc-900">责任与归档</h4> <h4 className="text-sm font-semibold text-zinc-900">责任与归档</h4>
<p className="text-xs text-zinc-500">明确领用、负责、保管、位置和生命周期日期。</p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
@@ -1371,7 +1263,7 @@ export function AssetRegistryPage() {
<section className="space-y-4 border-t border-zinc-200 pt-5"> <section className="space-y-4 border-t border-zinc-200 pt-5">
<div> <div>
<h4 className="text-sm font-semibold text-zinc-900">电子资产安全字段</h4> <h4 className="text-sm font-semibold text-zinc-900">电子资产安全字段</h4>
<p className="text-xs text-zinc-500">只登记必要摘要,密码、密钥、源码包、原始正文和完整路径不得进入 OA。</p> <p className="text-xs text-zinc-500">不填写密码、密钥、源码包、原始正文或完整路径。</p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
@@ -1403,183 +1295,130 @@ export function AssetRegistryPage() {
</section> </section>
) : null} ) : null}
</div> </div>
{currentAsset && canTransfer && !isCreatingAsset && currentAsset.assetType === "fixed_asset" ? (
<div className="rounded-xl border border-zinc-200 bg-zinc-50 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-zinc-900">资产流转</h4>
<p className="text-xs text-zinc-500">通过受控动作执行领用、归还和调拨,并写入审计记录。</p>
</div>
<div className="text-xs text-zinc-500">
当前归属人:{currentAsset.assignee?.name ?? "未分配"} · 位置:{currentAsset.location ?? "未设置"}
</div>
</div>
{movementError ? (
<p className="mb-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{movementError}</p>
) : null}
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">目标归属人</label>
<select
data-testid="asset-movement-assignee"
className="select-std"
value={movementEmployeeId}
onChange={(event) => setMovementEmployeeId(event.target.value)}
>
<option value="">保持当前归属人</option>
{(assigneesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.employeeNo})
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">目标位置</label>
<Input
data-testid="asset-movement-location"
value={movementLocation}
onChange={(event) => setMovementLocation(event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">备注</label>
<Input
data-testid="asset-movement-note"
value={movementNote}
onChange={(event) => setMovementNote(event.target.value)}
/>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Button
type="button"
data-testid="asset-assign-submit"
disabled={assignAssetMutation.isPending || Boolean(currentAsset.assigneeEmployeeId) || !movementEmployeeId}
onClick={() => assignAssetMutation.mutate()}
>
{assignAssetMutation.isPending ? "领用中..." : "资产领用"}
</Button>
<Button
type="button"
variant="secondary"
data-testid="asset-return-submit"
disabled={returnAssetMutation.isPending || !currentAsset.assigneeEmployeeId}
onClick={() => returnAssetMutation.mutate()}
>
{returnAssetMutation.isPending ? "归还中..." : "资产归还"}
</Button>
<Button
type="button"
variant="secondary"
data-testid="asset-transfer-submit"
disabled={
transferAssetMutation.isPending ||
(!movementEmployeeId && movementLocation.trim() === (currentAsset.location ?? ""))
}
onClick={() => transferAssetMutation.mutate()}
>
{transferAssetMutation.isPending ? "调拨中..." : "资产调拨"}
</Button>
</div>
</div>
) : null}
{selectedAssetId && !isCreatingAsset && canIntegrationManage ? (
<details className="card-std p-4 md:p-5">
<summary className="flex cursor-pointer list-none items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-zinc-900">ERP 同步状态</div>
<div className="mt-1 text-sm text-zinc-500">高级轨道,默认收起。</div>
</div>
<span className="text-sm font-medium text-primary">展开</span>
</summary>
<div className="mt-4">
{assetErpTrackQuery.isLoading ? (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">ERP 同步轨道加载中...</p>
) : assetErpTrackQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetErpTrackQuery.error)}</p>
) : assetErpStage ? (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1 text-xs text-zinc-500">
<div>
最近更新时间:{resolveAssetErpTimestamp(assetErpStage) ? formatDisplayDateTime(resolveAssetErpTimestamp(assetErpStage)) : "尚无同步记录"}
</div>
<div>变更来源:{assetErpStage.preview.sourceEventName ?? "-"}</div>
</div>
<StatusBadge status={assetErpStage.deliveryStatus} />
</div>
<div className="grid gap-2 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600 md:grid-cols-2">
<div>资产编号:{formatTextWithCompactLongNumbers(assetErpTrack?.asset.assetNo ?? currentAsset?.assetNo ?? "-")}</div>
<div>最近变更:{assetErpChangeTypeLabel(assetErpStage.preview.changeType)}</div>
<div>分类:{formatTextWithCompactLongNumbers(assetErpStage.preview.categoryCode ?? "-")}</div>
<div>资产状态:{assetStatusLabel(assetErpStage.preview.status ?? assetErpTrack?.asset.status)}</div>
<div>法体:{assetErpStage.preview.legalEntityCode ?? "-"}</div>
<div>成本中心:{assetErpStage.preview.costCenterCode ?? "-"}</div>
</div>
{formatAssetErpReason(assetErpStage.reason) ? (
<div className="rounded bg-zinc-50 px-3 py-2 text-xs text-zinc-500">{formatAssetErpReason(assetErpStage.reason)}</div>
) : null}
{assetErpStage.outbox?.lastError ? (
<div className="rounded bg-red-50 px-3 py-2 text-xs text-red-600">{assetErpStage.outbox.lastError}</div>
) : null}
{assetErpTrack?.permissions.canSeeExternalRefs && (assetErpStage.binding?.externalCode || assetErpStage.binding?.externalId) ? (
<div className="rounded bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
ERP 资产编号:{assetErpStage.binding?.externalCode ?? "-"}
{assetErpStage.binding?.externalId ? ` · ${assetErpStage.binding.externalId}` : ""}
</div>
) : null}
{assetErpTrack?.permissions.canRetry && assetErpActionLabel ? (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="secondary"
disabled={requeueAssetErpMutation.isPending}
onClick={() => requeueAssetErpMutation.mutate()}
>
{requeueAssetErpMutation.isPending ? "处理中..." : assetErpActionLabel}
</Button>
</div>
) : null}
</div>
) : (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">暂无 ERP 轨道信息</p>
)}
</div>
</details>
) : null}
{selectedAssetId && !isCreatingAsset && currentAsset ? (
<AiSuggestionCard targetType="asset_card" targetId={currentAsset.id} />
) : null}
<div className="flex items-center gap-2">
{canManage ? (
<Button type="button" data-testid="asset-save" disabled={saveAssetMutation.isPending || !canEditCurrent} onClick={() => saveAssetMutation.mutate()}>
{saveAssetMutation.isPending ? "保存中..." : isCreatingAsset ? "创建资产卡片" : "保存资产卡片"}
</Button>
) : null}
{currentAsset?.purchaseRequest ? (
<span className="text-sm text-zinc-500">
采购来源:{formatTextWithCompactLongNumbers(currentAsset.purchaseRequest.requestNo ?? currentAsset.purchaseRequest.id)}
</span>
) : null}
</div>
</> </>
) : null} ) : null}
</div> </div>
{(isCreatingAsset || currentAsset) && !assetDetailQuery.isLoading ? (
<div className="space-y-5">
<SectionPanel title="操作" className="xl:sticky xl:top-24">
<div className="space-y-4">
{canManage ? (
<Button
type="button"
data-testid="asset-save"
disabled={saveAssetMutation.isPending || !canEditCurrent}
onClick={() => saveAssetMutation.mutate()}
>
{saveAssetMutation.isPending ? "保存中..." : isCreatingAsset ? "创建资产卡片" : "保存资产卡片"}
</Button>
) : null}
{currentAsset && canTransfer && !isCreatingAsset && currentAsset.assetType === "fixed_asset" ? (
<div className="space-y-3 border-t border-zinc-200 pt-4">
<div className="text-sm font-semibold text-zinc-900">资产流转</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
归属人:{formatEmployeeSummary(currentAsset.assignee)} · 位置:{currentAsset.location ?? "未设置"}
</div>
{movementError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{movementError}</p>
) : null}
<div className="space-y-3">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">目标归属人</label>
<select
data-testid="asset-movement-assignee"
className="select-std"
value={movementEmployeeId}
onChange={(event) => setMovementEmployeeId(event.target.value)}
>
<option value="">保持当前归属人</option>
{(assigneesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.employeeNo})
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">目标位置</label>
<Input
data-testid="asset-movement-location"
value={movementLocation}
onChange={(event) => setMovementLocation(event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">备注</label>
<Input
data-testid="asset-movement-note"
value={movementNote}
onChange={(event) => setMovementNote(event.target.value)}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
data-testid="asset-assign-submit"
disabled={assignAssetMutation.isPending || Boolean(currentAsset.assigneeEmployeeId) || !movementEmployeeId}
onClick={() => assignAssetMutation.mutate()}
>
{assignAssetMutation.isPending ? "领用中..." : "领用"}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
data-testid="asset-return-submit"
disabled={returnAssetMutation.isPending || !currentAsset.assigneeEmployeeId}
onClick={() => returnAssetMutation.mutate()}
>
{returnAssetMutation.isPending ? "归还中..." : "归还"}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
data-testid="asset-transfer-submit"
disabled={
transferAssetMutation.isPending ||
(!movementEmployeeId && movementLocation.trim() === (currentAsset.location ?? ""))
}
onClick={() => transferAssetMutation.mutate()}
>
{transferAssetMutation.isPending ? "调拨中..." : "调拨"}
</Button>
</div>
</div>
) : null}
</div>
</SectionPanel>
<DetailSideMeta title="资产摘要">
<div>状态:{isCreatingAsset ? "新建中" : assetStatusLabel(currentAsset?.status)}</div>
<div>类型:{assetTypeLabel(assetForm.assetType)}</div>
<div>责任人:{formatEmployeeSummary(currentAsset?.owner ?? currentAsset?.assignee)}</div>
<div>归属人:{formatEmployeeSummary(currentAsset?.assignee)}</div>
<div>保管人:{formatEmployeeSummary(currentAsset?.keeper)}</div>
<div>位置:{(currentAsset?.location ?? assetForm.location) || "-"}</div>
<div>
{isElectronicAssetType(assetForm.assetType) ? "最近验证" : "最近盘点"}:
{currentAsset?.lastVerifiedAt ? formatDisplayDateTime(currentAsset.lastVerifiedAt) : assetForm.lastVerifiedAt || "-"}
</div>
<div>
采购来源:
{currentAsset?.purchaseRequest
? formatTextWithCompactLongNumbers(currentAsset.purchaseRequest.requestNo ?? currentAsset.purchaseRequest.id)
: "-"}
</div>
</DetailSideMeta>
</div>
) : null}
</div>
)} )}
</ListWorkspace> </ListWorkspace>
); );
-686
查看文件
@@ -1,686 +0,0 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
fetchErpMasterDataStatus,
fetchIntegrationBindings,
fetchIntegrationOutbox,
fetchIntegrationOutboxDetail,
fetchIntegrationOutboxStats,
retryIntegrationOutbox,
syncErpMasterData,
type ErpMasterDataResourceType,
type ErpMasterDataSyncItem,
type IntegrationBindingItem,
type IntegrationOutboxItem,
type IntegrationOutboxStatus,
} from "../api/integrations";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
const RESOURCE_LABELS: Record<ErpMasterDataResourceType, string> = {
legal_entity: "法体",
cost_center: "成本中心",
supplier: "供应商",
};
const OUTBOX_STATUS_OPTIONS: Array<{ value: IntegrationOutboxStatus | ""; label: string }> = [
{ value: "", label: "全部状态" },
{ value: "pending", label: "待处理" },
{ value: "processing", label: "处理中" },
{ value: "succeeded", label: "已成功" },
{ value: "failed", label: "失败" },
{ value: "dead_letter", label: "死信" },
];
const BINDING_ENTITY_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "", label: "全部类型" },
{ value: "reimbursement_journal", label: "报销凭证" },
{ value: "purchase_request_commitment_journal", label: "采购 Commitment" },
{ value: "purchase_order_accrual_journal", label: "采购 Accrual" },
{ value: "purchase_order_po", label: "采购 PO 映射" },
{ value: "purchase_order_contract", label: "采购合同映射" },
{ value: "asset_erp_card", label: "资产台账摘要" },
{ value: "travel_payable_summary", label: "差旅应付摘要" },
{ value: "travel_settlement_batch", label: "差旅结算批次" },
];
type ErpTab = "master-data" | "outbox" | "bindings";
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
}
function formatDateTime(value: string | null) {
if (!value) return "-";
return value.slice(0, 19).replace("T", " ");
}
function formatRelativeTime(value: string | null) {
if (!value) return "-";
const target = new Date(value).getTime();
if (!Number.isFinite(target)) return "-";
const diffSeconds = Math.round((target - Date.now()) / 1000);
const abs = Math.abs(diffSeconds);
if (abs < 60) {
return diffSeconds >= 0 ? `${abs} 秒后` : `${abs} 秒前`;
}
const diffMinutes = Math.round(diffSeconds / 60);
if (Math.abs(diffMinutes) < 60) {
return diffMinutes >= 0 ? `${diffMinutes} 分钟后` : `${Math.abs(diffMinutes)} 分钟前`;
}
const diffHours = Math.round(diffMinutes / 60);
if (Math.abs(diffHours) < 24) {
return diffHours >= 0 ? `${diffHours} 小时后` : `${Math.abs(diffHours)} 小时前`;
}
const diffDays = Math.round(diffHours / 24);
return diffDays >= 0 ? `${diffDays} 天后` : `${Math.abs(diffDays)} 天前`;
}
function formatGateReason(reason: string) {
if (reason === "env_hard_off") return "Env 总闸关闭";
if (reason === "flag_force_off") return "功能开关强制关闭";
if (reason === "department_deferred") return "部门灰度在管理端按全局集成动作放行";
if (reason === "env_inherit_global_on") return "Env 继承开启";
if (reason === "flag_force_on_global") return "功能开关强制开启";
return reason;
}
function outboxStatusLabel(status: string) {
switch (status) {
case "pending":
return "待处理";
case "processing":
return "处理中";
case "succeeded":
return "已成功";
case "failed":
return "失败";
case "dead_letter":
return "死信";
default:
return status;
}
}
function outboxStatusClassName(status: string) {
if (status === "failed" || status === "dead_letter") {
return "border-red-200 bg-red-50 text-red-700";
}
if (status === "succeeded") {
return "border-emerald-200 bg-emerald-50 text-emerald-700";
}
if (status === "processing") {
return "border-amber-200 bg-amber-50 text-amber-700";
}
return "border-zinc-200 bg-zinc-50 text-zinc-700";
}
function renderOutboxSummary(item: IntegrationOutboxItem) {
const summary = item.payloadSummary;
return summary.identityCode ?? summary.entityId ?? "-";
}
function bindingEntityLabel(value: string) {
return BINDING_ENTITY_OPTIONS.find((item) => item.value === value)?.label ?? value;
}
function BindingLocalIdCell({ item }: { item: IntegrationBindingItem }) {
if (!item.routePath) {
return <span className="break-all text-zinc-600">{item.localId}</span>;
}
return (
<Link className="break-all text-primary hover:underline" to={item.routePath}>
{item.localId}
</Link>
);
}
export function ErpIntegrationPage() {
const queryClient = useQueryClient();
const [tab, setTab] = useState<ErpTab>("master-data");
const [error, setError] = useState("");
const [outboxPage, setOutboxPage] = useState(1);
const [outboxStatus, setOutboxStatus] = useState<IntegrationOutboxStatus | "">("");
const [outboxEventType, setOutboxEventType] = useState("");
const [selectedOutboxId, setSelectedOutboxId] = useState("");
const [bindingsPage, setBindingsPage] = useState(1);
const [bindingProvider, setBindingProvider] = useState("");
const [bindingEntityType, setBindingEntityType] = useState("");
const statusQuery = useQuery({
queryKey: ["erp-master-data-status"],
queryFn: () => fetchErpMasterDataStatus(),
});
const outboxStatsQuery = useQuery({
enabled: tab === "outbox",
queryKey: ["integration-outbox-stats"],
queryFn: () => fetchIntegrationOutboxStats(),
});
const outboxQuery = useQuery({
enabled: tab === "outbox",
queryKey: ["integration-outbox", outboxPage, outboxStatus, outboxEventType],
queryFn: () =>
fetchIntegrationOutbox({
page: outboxPage,
pageSize: 20,
status: outboxStatus || undefined,
eventType: outboxEventType.trim() || undefined,
}),
});
const outboxDetailQuery = useQuery({
enabled: Boolean(selectedOutboxId),
queryKey: ["integration-outbox-detail", selectedOutboxId],
queryFn: () => fetchIntegrationOutboxDetail(selectedOutboxId),
});
const bindingsQuery = useQuery({
enabled: tab === "bindings",
queryKey: ["integration-bindings", bindingsPage, bindingProvider, bindingEntityType],
queryFn: () =>
fetchIntegrationBindings({
page: bindingsPage,
pageSize: 20,
provider: bindingProvider || undefined,
entityType: bindingEntityType || undefined,
}),
});
const syncMutation = useMutation({
mutationFn: async (resources?: ErpMasterDataResourceType[]) => syncErpMasterData({ resources }),
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: ["erp-master-data-status"] });
setError("");
const finished = result.items
.map((item) => `${RESOURCE_LABELS[item.resourceType]}:${item.status}`)
.join(" / ");
toast.success(`主数据同步完成:${finished}`);
},
onError: (mutationError) => {
const message = resolveError(mutationError);
setError(message);
toast.error(message);
},
});
const retryMutation = useMutation({
mutationFn: retryIntegrationOutbox,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["integration-outbox"] }),
queryClient.invalidateQueries({ queryKey: ["integration-outbox-stats"] }),
queryClient.invalidateQueries({ queryKey: ["integration-outbox-detail"] }),
]);
toast.success("同步任务已重置为待处理");
},
onError: (mutationError) => {
const message = resolveError(mutationError);
toast.error(message);
},
});
const itemsByType = useMemo(() => {
const map = new Map<ErpMasterDataResourceType, ErpMasterDataSyncItem>();
for (const item of statusQuery.data?.items ?? []) {
map.set(item.resourceType, item);
}
return map;
}, [statusQuery.data?.items]);
const gate = statusQuery.data?.gate ?? null;
const outboxTotalPages = Math.max(1, Math.ceil((outboxQuery.data?.total ?? 0) / 20));
const bindingsTotalPages = Math.max(1, Math.ceil((bindingsQuery.data?.total ?? 0) / 20));
return (
<div className="space-y-6">
<PageHeader
title="外部系统"
subtitle="主数据同步、业务队列和 ID 映射。"
actions={
tab === "master-data" ? (
<Button onClick={() => syncMutation.mutate(undefined)} disabled={syncMutation.isPending}>
{syncMutation.isPending ? "同步中..." : "同步全部"}
</Button>
) : null
}
/>
<WorkspaceNotice title="ERP 写入后置" tone="warning">
试运行期间不把 ERP 写入作为主路径;本页用于管理员查看同步状态和队列排障。
</WorkspaceNotice>
{error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
) : null}
<Tabs value={tab} onValueChange={(value) => setTab(value as ErpTab)}>
<TabsList className="flex w-full justify-start gap-1">
<TabsTrigger value="master-data">主数据同步</TabsTrigger>
<TabsTrigger value="outbox">同步队列</TabsTrigger>
<TabsTrigger value="bindings">ID 映射</TabsTrigger>
</TabsList>
<TabsContent value="master-data" className="space-y-6">
<div className="grid gap-4 lg:grid-cols-4">
<div className="card-std p-5 space-y-2 lg:col-span-4">
<h3 className="text-base font-semibold text-zinc-900">ERP 状态</h3>
{statusQuery.isLoading ? <p className="text-sm text-zinc-500">加载中...</p> : null}
{statusQuery.isError ? (
<p className="text-sm text-red-600">{resolveError(statusQuery.error)}</p>
) : null}
{gate ? (
<div className="grid gap-3 text-sm text-zinc-700 md:grid-cols-2 xl:grid-cols-4">
<div>
<div className="text-zinc-500">Env 总闸</div>
<div className="font-medium">{gate.envEnabled ? "已开启" : "已关闭"}</div>
</div>
<div>
<div className="text-zinc-500">功能开关</div>
<div className="font-medium">{gate.effectiveEnabled ? "允许" : "阻断"}</div>
<div className="text-xs text-zinc-500">{formatGateReason(gate.effectiveReason)}</div>
</div>
<div>
<div className="text-zinc-500">同步服务 / 主数据端点</div>
<div className="font-medium">{gate.provider}</div>
<div className="text-xs text-zinc-500">
{gate.masterDataEndpointConfigured ? "主数据端点已配置" : "主数据端点未配置"}
</div>
</div>
<div>
<div className="text-zinc-500">凭证端点 / 自动同步</div>
<div className="font-medium">
{gate.journalEndpointConfigured ? "凭证端点已配置" : "凭证端点未配置"}
</div>
<div className="text-xs text-zinc-500">{gate.masterDataSyncCron || "未启用自动同步"}</div>
</div>
</div>
) : null}
</div>
{(["legal_entity", "cost_center", "supplier"] as const).map((resourceType) => {
const item = itemsByType.get(resourceType) ?? null;
return (
<div key={resourceType} className="card-std space-y-3 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">{RESOURCE_LABELS[resourceType]}</h3>
<p className="text-sm text-zinc-500">状态:{item?.status ?? "idle"}</p>
</div>
<Button
variant="secondary"
onClick={() => syncMutation.mutate([resourceType])}
disabled={syncMutation.isPending}
>
仅同步{RESOURCE_LABELS[resourceType]}
</Button>
</div>
<div className="space-y-1 text-sm text-zinc-700">
<div>最近开始:{formatDateTime(item?.lastStartedAt ?? null)}</div>
<div>最近完成:{formatDateTime(item?.lastFinishedAt ?? null)}</div>
<div>最近成功:{formatDateTime(item?.lastSuccessAt ?? null)}</div>
<div className="break-all">最近错误:{item?.lastError ?? "-"}</div>
</div>
{item?.lastResult ? (
<div className="space-y-1 rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm text-zinc-700">
<div>
请求 {item.lastResult.requested} / 新增 {item.lastResult.created} / 更新 {item.lastResult.updated}
</div>
<div>
归档 {item.lastResult.archived} / 跳过 {item.lastResult.skipped}
</div>
{item.lastResult.sampleSkipped.length ? (
<div className="break-all text-xs text-zinc-500">
示例跳过:
{item.lastResult.sampleSkipped
.map((sample) => `${sample.key}:${sample.reason}`)
.join(";")}
</div>
) : null}
</div>
) : (
<p className="text-sm text-zinc-500">暂无同步记录。</p>
)}
</div>
);
})}
</div>
<div className="card-std space-y-2 p-5 text-sm text-zinc-600">
<h3 className="text-base font-semibold text-zinc-900">说明</h3>
<p>- 本页只负责主数据同步、业务同步队列和 ID 映射。</p>
<p>- 报销、采购和资产的同步动作在对应业务详情区执行。</p>
<p>- 当前仍使用演示同步服务。</p>
</div>
</TabsContent>
<TabsContent value="outbox" className="space-y-6">
<div className="grid gap-4 md:grid-cols-5">
{OUTBOX_STATUS_OPTIONS.filter((item) => item.value).map((item) => {
const status = item.value as IntegrationOutboxStatus;
const count =
status === "pending"
? outboxStatsQuery.data?.pending
: status === "processing"
? outboxStatsQuery.data?.processing
: status === "succeeded"
? outboxStatsQuery.data?.succeeded
: status === "failed"
? outboxStatsQuery.data?.failed
: outboxStatsQuery.data?.deadLetter;
return (
<div
key={status}
className={`card-std space-y-2 p-4 ${status === "failed" || status === "dead_letter" ? "border-red-200 bg-red-50/60" : ""}`}
>
<div className="text-sm text-zinc-500">{outboxStatusLabel(status)}</div>
<div className="text-2xl font-semibold text-zinc-900">
{outboxStatsQuery.isLoading ? "-" : count ?? 0}
</div>
</div>
);
})}
</div>
<div className="card-std space-y-4 p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h3 className="text-base font-semibold text-zinc-900">同步任务列表</h3>
<p className="text-sm text-zinc-500">失败任务可重试。</p>
</div>
<div className="flex flex-col gap-3 md:flex-row">
<label className="space-y-1 text-sm text-zinc-600">
<span>状态</span>
<select
className="select-std min-w-40"
value={outboxStatus}
onChange={(event) => {
setOutboxStatus(event.target.value as IntegrationOutboxStatus | "");
setOutboxPage(1);
}}
>
{OUTBOX_STATUS_OPTIONS.map((item) => (
<option key={item.label} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="space-y-1 text-sm text-zinc-600">
<span>事件类型</span>
<Input
className="min-w-56"
placeholder="例如 erp.journal.export"
value={outboxEventType}
onChange={(event) => {
setOutboxEventType(event.target.value);
setOutboxPage(1);
}}
/>
</label>
</div>
</div>
{outboxQuery.isLoading ? <TableSkeleton columns={7} rows={6} /> : null}
{outboxQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(outboxQuery.error)}
</p>
) : null}
{outboxQuery.data ? (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>事件类型</TableHead>
<TableHead>摘要标识</TableHead>
<TableHead>状态</TableHead>
<TableHead>尝试</TableHead>
<TableHead>下次重试</TableHead>
<TableHead>创建时间</TableHead>
<TableHead>处理时间</TableHead>
<TableHead className="w-40">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{outboxQuery.data.items.length ? (
outboxQuery.data.items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium text-zinc-900">{item.eventType}</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="font-medium text-zinc-900">{renderOutboxSummary(item)}</div>
<div className="text-xs text-zinc-500">
{item.payloadSummary.entityType ?? "-"}
{item.payloadSummary.sourceEventName ? ` · ${item.payloadSummary.sourceEventName}` : ""}
</div>
</div>
</TableCell>
<TableCell>
<span className={`inline-flex rounded-full border px-2 py-1 text-xs font-medium ${outboxStatusClassName(item.status)}`}>
{outboxStatusLabel(item.status)}
</span>
</TableCell>
<TableCell>
{item.attempts} / {item.maxAttempts}
</TableCell>
<TableCell>{formatDateTime(item.nextRetryAt)}</TableCell>
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
<TableCell>{formatDateTime(item.processedAt)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => setSelectedOutboxId(item.id)}
>
查看详情
</Button>
{item.status === "failed" || item.status === "dead_letter" ? (
<Button
type="button"
size="sm"
disabled={retryMutation.isPending}
onClick={() => retryMutation.mutate(item.id)}
>
重试
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={8} className="py-6 text-center text-zinc-500">
当前筛选下暂无同步任务
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pagination page={outboxPage} totalPages={outboxTotalPages} onPageChange={setOutboxPage} />
</>
) : null}
</div>
</TabsContent>
<TabsContent value="bindings" className="space-y-6">
<div className="card-std space-y-4 p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h3 className="text-base font-semibold text-zinc-900">IntegrationBinding 列表</h3>
<p className="text-sm text-zinc-500">查看本地单据与 ERP 外部 ID/编号的映射关系,并支持深链回到业务页。</p>
</div>
<div className="flex flex-col gap-3 md:flex-row">
<label className="space-y-1 text-sm text-zinc-600">
<span>同步服务</span>
<select
className="select-std min-w-40"
value={bindingProvider}
onChange={(event) => {
setBindingProvider(event.target.value);
setBindingsPage(1);
}}
>
<option value="">全部同步服务</option>
<option value="erp">erp</option>
</select>
</label>
<label className="space-y-1 text-sm text-zinc-600">
<span>实体类型</span>
<select
className="select-std min-w-56"
value={bindingEntityType}
onChange={(event) => {
setBindingEntityType(event.target.value);
setBindingsPage(1);
}}
>
{BINDING_ENTITY_OPTIONS.map((item) => (
<option key={item.label} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
</div>
</div>
{bindingsQuery.isLoading ? <TableSkeleton columns={6} rows={6} /> : null}
{bindingsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(bindingsQuery.error)}
</p>
) : null}
{bindingsQuery.data ? (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>同步服务</TableHead>
<TableHead>实体类型</TableHead>
<TableHead>本地 ID</TableHead>
<TableHead>外部 ID</TableHead>
<TableHead>外部编号</TableHead>
<TableHead>最后同步</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bindingsQuery.data.items.length ? (
bindingsQuery.data.items.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.provider}</TableCell>
<TableCell>{bindingEntityLabel(item.entityType)}</TableCell>
<TableCell>
<BindingLocalIdCell item={item} />
</TableCell>
<TableCell className="break-all">{item.externalId ?? "-"}</TableCell>
<TableCell className="break-all">{item.externalCode ?? "-"}</TableCell>
<TableCell title={formatDateTime(item.lastSyncedAt)}>{formatRelativeTime(item.lastSyncedAt)}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="py-6 text-center text-zinc-500">
当前筛选下暂无 ID 映射记录
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pagination page={bindingsPage} totalPages={bindingsTotalPages} onPageChange={setBindingsPage} />
</>
) : null}
</div>
</TabsContent>
</Tabs>
<Dialog
open={Boolean(selectedOutboxId)}
onOpenChange={(open) => {
if (!open) {
setSelectedOutboxId("");
}
}}
>
<DialogContent className="max-h-[85vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>同步任务详情</DialogTitle>
<DialogDescription>查看数据内容、错误摘要和当前队列状态。</DialogDescription>
</DialogHeader>
{outboxDetailQuery.isLoading ? <p className="text-sm text-zinc-500">详情加载中...</p> : null}
{outboxDetailQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(outboxDetailQuery.error)}
</p>
) : null}
{outboxDetailQuery.data ? (
<div className="space-y-4">
<div className="grid gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-700 md:grid-cols-2">
<div>队列 ID:{outboxDetailQuery.data.id}</div>
<div>事件类型:{outboxDetailQuery.data.eventType}</div>
<div>状态:{outboxStatusLabel(outboxDetailQuery.data.status)}</div>
<div>尝试次数:{outboxDetailQuery.data.attempts} / {outboxDetailQuery.data.maxAttempts}</div>
<div>创建时间:{formatDateTime(outboxDetailQuery.data.createdAt)}</div>
<div>处理时间:{formatDateTime(outboxDetailQuery.data.processedAt)}</div>
<div>下次重试:{formatDateTime(outboxDetailQuery.data.nextRetryAt)}</div>
<div>死信时间:{formatDateTime(outboxDetailQuery.data.deadLetterAt)}</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-900">最近错误</div>
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-600">
{outboxDetailQuery.data.lastError ?? "-"}
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-900">数据内容</div>
<pre className="max-h-[45vh] overflow-auto rounded-lg border border-zinc-200 bg-zinc-950 p-4 text-xs text-zinc-100">
{JSON.stringify(outboxDetailQuery.data.payload ?? null, null, 2)}
</pre>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
</div>
);
}
+2 -3
查看文件
@@ -46,7 +46,6 @@ const ROLLOUT_TYPE_OPTIONS: Array<{ value: FeatureFlagRolloutType; label: string
function toDisplayName(flagKey: string) { function toDisplayName(flagKey: string) {
if (flagKey === "ai_assist") return "AI 辅助"; if (flagKey === "ai_assist") return "AI 辅助";
if (flagKey === "erp_sync") return "ERP 同步";
if (flagKey === "invoice_verification") return "发票验真"; if (flagKey === "invoice_verification") return "发票验真";
if (flagKey === "leave_holiday_calendar") return "请假日历增强"; if (flagKey === "leave_holiday_calendar") return "请假日历增强";
if (flagKey === "budget_precheck_v1") return "预算预校验"; if (flagKey === "budget_precheck_v1") return "预算预校验";
@@ -259,7 +258,7 @@ export function FeatureFlagsPage() {
return map; return map;
}, [departmentsQuery.data?.items]); }, [departmentsQuery.data?.items]);
const items = flagsQuery.data?.items ?? []; const items = (flagsQuery.data?.items ?? []).filter((item) => item.flagKey !== "erp_sync");
const flagStats = { const flagStats = {
total: items.length, total: items.length,
effectiveOn: items.filter((item) => item.effectiveEnabled).length, effectiveOn: items.filter((item) => item.effectiveEnabled).length,
@@ -325,7 +324,7 @@ export function FeatureFlagsPage() {
<ListWorkspace className={batchMode ? "pb-32" : undefined}> <ListWorkspace className={batchMode ? "pb-32" : undefined}>
<WorkspaceHeader <WorkspaceHeader
title="功能管理" title="功能管理"
subtitle="外部桥接、验真、灰度和试运行边界。" subtitle="验真、灰度和试运行边界。"
/> />
<WorkspaceNotice title="安全摘要" tone="warning"> <WorkspaceNotice title="安全摘要" tone="warning">
Env 仍是硬闸;批量开启、关闭或灰度变更会影响试运行边界,执行前必须保留变更说明。 Env 仍是硬闸;批量开启、关闭或灰度变更会影响试运行边界,执行前必须保留变更说明。
-251
查看文件
@@ -11,19 +11,14 @@ import {
deletePurchaseAttachment, deletePurchaseAttachment,
fetchPurchaseOrderArchive, fetchPurchaseOrderArchive,
fetchPurchaseRequestDetail, fetchPurchaseRequestDetail,
fetchPurchaseRequestErpTrack,
fetchPurchaseRequests, fetchPurchaseRequests,
issuePurchaseAttachmentSignedUrl, issuePurchaseAttachmentSignedUrl,
precheckPurchaseRequest, precheckPurchaseRequest,
requeuePurchaseRequestErpSync,
submitPurchaseRequest, submitPurchaseRequest,
updatePurchaseRequest, updatePurchaseRequest,
uploadPurchaseAttachment, uploadPurchaseAttachment,
withdrawPurchaseRequest, withdrawPurchaseRequest,
type PurchaseErpBinding,
type PurchaseRequestDetail, type PurchaseRequestDetail,
type PurchaseRequestErpRequeueTarget,
type PurchaseRequestErpTrack,
type PurchaseRequestItemPayload, type PurchaseRequestItemPayload,
type PurchaseRequestStatus, type PurchaseRequestStatus,
} from "../api/purchaseRequests"; } from "../api/purchaseRequests";
@@ -265,106 +260,6 @@ function CompactBusinessCode({ value, copyable = true }: { value: string; copyab
); );
} }
type PurchaseErpStageKey = keyof PurchaseRequestErpTrack["stages"];
const PURCHASE_ERP_STAGE_META: Record<
PurchaseErpStageKey,
{ title: string; target: PurchaseRequestErpRequeueTarget }
> = {
commitmentJournal: { title: "Commitment", target: "commitment_journal" },
accrualJournal: { title: "Accrual", target: "accrual_journal" },
archiveSummary: { title: "PO/合同同步", target: "archive_summary" },
};
function formatStageReason(reason: string | null) {
switch (reason) {
case "purchase_request_not_approved":
return "当前采购单尚未处于已通过状态";
case "purchase_order_archive_missing":
return "尚未完成 PO/合同归档";
default:
return reason ?? null;
}
}
function resolveStageTimestamp(stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const binding = stage.binding as PurchaseErpBinding | null | undefined;
const poBinding = stage.bindings?.po ?? null;
const contractBinding = stage.bindings?.contract ?? null;
return (
stage.outbox?.updatedAt ??
stage.outbox?.processedAt ??
binding?.lastSyncedAt ??
poBinding?.lastSyncedAt ??
contractBinding?.lastSyncedAt ??
null
);
}
function resolveStageExternalCode(stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const binding = stage.binding as PurchaseErpBinding | null | undefined;
return binding?.externalCode ?? stage.bindings?.po?.externalCode ?? stage.bindings?.contract?.externalCode ?? null;
}
function resolveStageExternalId(stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const binding = stage.binding as PurchaseErpBinding | null | undefined;
return binding?.externalId ?? stage.bindings?.po?.externalId ?? stage.bindings?.contract?.externalId ?? null;
}
function resolveStageSummary(stageKey: PurchaseErpStageKey, stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const preview = stage.preview as {
documentNo?: string | null;
requestNo?: string | null;
poNo?: string | null;
contractNo?: string | null;
totalAmount?: string | null;
supplier?: {
code?: string | null;
};
dimensions?: {
legalEntityCode?: string | null;
costCenterCode?: string | null;
supplierCode?: string | null;
};
};
return {
documentNo:
preview.documentNo ??
preview.requestNo ??
(stageKey === "archiveSummary" ? preview.poNo ?? null : null),
totalAmount: preview.totalAmount ?? null,
legalEntityCode: preview.dimensions?.legalEntityCode ?? null,
costCenterCode: preview.dimensions?.costCenterCode ?? null,
supplierCode: preview.dimensions?.supplierCode ?? preview.supplier?.code ?? null,
poNo: preview.poNo ?? null,
contractNo: preview.contractNo ?? null,
};
}
function resolveStageAction(stageKey: PurchaseErpStageKey, stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
if (!stage.eligible) {
return null;
}
if (stage.status === "failed" || stage.status === "dead_letter") {
return {
label: stageKey === "archiveSummary" ? "重试同步摘要" : "重试同步",
target: PURCHASE_ERP_STAGE_META[stageKey].target,
};
}
if (stage.status === "not_triggered" || stage.status === "skipped") {
return {
label: stageKey === "archiveSummary" ? "补发摘要" : "补发",
target: PURCHASE_ERP_STAGE_META[stageKey].target,
};
}
return null;
}
export function PurchaseRequestsPage() { export function PurchaseRequestsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { me, permissions } = useAuth(); const { me, permissions } = useAuth();
@@ -372,7 +267,6 @@ export function PurchaseRequestsPage() {
const canCreate = hasPermission(permissions, "procurement.create"); const canCreate = hasPermission(permissions, "procurement.create");
const canManage = hasPermission(permissions, "procurement.manage"); const canManage = hasPermission(permissions, "procurement.manage");
const canIntegrationManage = hasPermission(permissions, "integration.manage");
const canViewAll = const canViewAll =
hasPermission(permissions, "procurement.view_all") || hasPermission(permissions, "procurement.view_all") ||
canManage; canManage;
@@ -402,7 +296,6 @@ export function PurchaseRequestsPage() {
const [archiveContractNo, setArchiveContractNo] = useState(""); const [archiveContractNo, setArchiveContractNo] = useState("");
const [selectedAttachmentName, setSelectedAttachmentName] = useState(""); const [selectedAttachmentName, setSelectedAttachmentName] = useState("");
const [submitPrecheckConfirm, setSubmitPrecheckConfirm] = useState<string | null>(null); const [submitPrecheckConfirm, setSubmitPrecheckConfirm] = useState<string | null>(null);
const [erpRequeueConfirm, setErpRequeueConfirm] = useState<{ target: PurchaseRequestErpRequeueTarget; label: string } | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null); const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const suppliersQuery = useQuery({ const suppliersQuery = useQuery({
@@ -456,15 +349,6 @@ export function PurchaseRequestsPage() {
queryFn: () => fetchPurchaseOrderArchive(selectedId), queryFn: () => fetchPurchaseOrderArchive(selectedId),
}); });
const erpTrackQuery = useQuery({
enabled:
canIntegrationManage &&
Boolean(selectedId) &&
(detailQuery.data?.status === "approved" || Boolean(detailQuery.data?.archiveOrder)),
queryKey: ["purchase-requests", "erp-track", selectedId],
queryFn: () => fetchPurchaseRequestErpTrack(selectedId),
});
useEffect(() => { useEffect(() => {
const searchSelectedId = searchParams.get("id") ?? ""; const searchSelectedId = searchParams.get("id") ?? "";
if (searchSelectedId && searchSelectedId !== selectedId) { if (searchSelectedId && searchSelectedId !== selectedId) {
@@ -687,7 +571,6 @@ export function PurchaseRequestsPage() {
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }), queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }), queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]); ]);
setFormError(""); setFormError("");
toast.success( toast.success(
@@ -715,7 +598,6 @@ export function PurchaseRequestsPage() {
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }), queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }), queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]); ]);
setFormError(""); setFormError("");
toast.success("采购申请已撤回到草稿"); toast.success("采购申请已撤回到草稿");
@@ -780,7 +662,6 @@ export function PurchaseRequestsPage() {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }), queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }), queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]); ]);
toast.success("PO/合同归档已保存"); toast.success("PO/合同归档已保存");
}, },
@@ -790,39 +671,6 @@ export function PurchaseRequestsPage() {
}, },
}); });
const requeueErpMutation = useMutation({
mutationFn: async (payload: { target: PurchaseRequestErpRequeueTarget; label: string }) => {
if (!selectedId) {
throw new Error("请先选择采购单");
}
return {
label: payload.label,
response: await requeuePurchaseRequestErpSync(selectedId, { targets: [payload.target] }),
};
},
onSuccess: async ({ label, response }) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]);
const firstItem = response.items[0];
if (firstItem?.duplicated) {
toast.success(`${label} 已存在待处理记录`);
return;
}
if (firstItem?.requeued) {
toast.success(`${label} 已重新入队`);
return;
}
toast.success(`${label} 已加入同步队列`);
},
onError: (error) => {
const message = resolveError(error);
toast.error(message);
},
});
const uploadMutation = useMutation({ const uploadMutation = useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) => {
if (!selectedId) { if (!selectedId) {
@@ -916,8 +764,6 @@ export function PurchaseRequestsPage() {
} }
}; };
const erpTrack = erpTrackQuery.data ?? null;
const canRetryErp = Boolean(canIntegrationManage && erpTrack?.permissions.canRetry);
const showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder)); const showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder));
const canEditDraft = Boolean(detail?.permissions.canEditDraft); const canEditDraft = Boolean(detail?.permissions.canEditDraft);
const canSubmit = Boolean(detail?.permissions.canSubmit); const canSubmit = Boolean(detail?.permissions.canSubmit);
@@ -1963,89 +1809,6 @@ export function PurchaseRequestsPage() {
</div> </div>
) : null} ) : null}
{showArchiveSection && canIntegrationManage ? (
<SectionPanel
title="ERP 同步状态"
description="展示 Commitment / Accrual / PO/合同摘要三段同步状态,失败或未触发时可在此补发或重试。"
>
{erpTrackQuery.isLoading ? (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">ERP 同步轨道加载中...</p>
) : erpTrackQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(erpTrackQuery.error)}</p>
) : erpTrack ? (
<div className="grid gap-3 xl:grid-cols-3">
{(Object.entries(PURCHASE_ERP_STAGE_META) as Array<
[PurchaseErpStageKey, (typeof PURCHASE_ERP_STAGE_META)[PurchaseErpStageKey]]
>).map(([stageKey, meta]) => {
const stage = erpTrack.stages[stageKey];
const summary = resolveStageSummary(stageKey, stage);
const action = canRetryErp ? resolveStageAction(stageKey, stage) : null;
const stageTime = resolveStageTimestamp(stage);
const externalCode = resolveStageExternalCode(stage);
const externalId = resolveStageExternalId(stage);
const reasonText = formatStageReason(stage.reason);
const isSubmittingAction = requeueErpMutation.isPending && requeueErpMutation.variables?.target === action?.target;
return (
<div key={stageKey} className="space-y-3 rounded-lg border border-zinc-200 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">{meta.title}</div>
<div className="text-xs text-zinc-500">
{stageTime ? `最近更新时间:${formatDisplayDateTime(stageTime)}` : "尚无同步记录"}
</div>
</div>
<StatusBadge status={stage.status} />
</div>
<div className="space-y-1 text-xs text-zinc-600">
<div>单号:{summary.documentNo ?? "-"}</div>
<div>金额:{summary.totalAmount ?? "-"}</div>
<div>法体 / 成本中心:{summary.legalEntityCode ?? "-"} / {summary.costCenterCode ?? "-"}</div>
<div>供应商:{summary.supplierCode ?? "-"}</div>
{stageKey !== "commitmentJournal" ? (
<div>PO / 合同:{summary.poNo ?? "-"} / {summary.contractNo ?? "-"}</div>
) : null}
</div>
{reasonText ? (
<div className="rounded bg-zinc-50 px-2 py-1 text-xs text-zinc-500">{reasonText}</div>
) : null}
{stage.outbox?.lastError ? (
<div className="rounded bg-red-50 px-2 py-1 text-xs text-red-600">{stage.outbox.lastError}</div>
) : null}
{externalCode || externalId ? (
<div className="rounded bg-zinc-50 px-2 py-1 text-xs text-zinc-600">
ERP 编号:{externalCode ?? "-"}
{externalId ? ` · ${externalId}` : ""}
</div>
) : null}
{action ? (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="secondary"
disabled={requeueErpMutation.isPending}
onClick={() => setErpRequeueConfirm({ target: action.target, label: meta.title })}
>
{isSubmittingAction ? "处理中..." : action.label}
</Button>
</div>
) : null}
</div>
);
})}
</div>
) : (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">暂无 ERP 轨道信息</p>
)}
</SectionPanel>
) : null}
<SectionPanel title="审批记录" description="展示最近审批动作、退回或驳回说明,便于申请人按意见修改。"> <SectionPanel title="审批记录" description="展示最近审批动作、退回或驳回说明,便于申请人按意见修改。">
{detailApprovalHistory.length ? ( {detailApprovalHistory.length ? (
<div className="space-y-2" data-testid="purchase-approval-history"> <div className="space-y-2" data-testid="purchase-approval-history">
@@ -2083,20 +1846,6 @@ export function PurchaseRequestsPage() {
submitMutation.mutate(); submitMutation.mutate();
}} }}
/> />
<ActionConfirmDialog
open={Boolean(erpRequeueConfirm)}
onOpenChange={(open) => {
if (!open) setErpRequeueConfirm(null);
}}
kind="retry"
targetName={erpRequeueConfirm?.label}
description="确认后会重新补发或重试 ERP 同步消息,并追加新的同步处理记录。"
onConfirm={async () => {
if (erpRequeueConfirm) {
requeueErpMutation.mutate(erpRequeueConfirm);
}
}}
/>
</ListWorkspace> </ListWorkspace>
); );
} }
@@ -5,7 +5,6 @@ import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages"; import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes"; import { fetchExpenseTypes } from "../api/expenseTypes";
import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl"; import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl";
import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations";
import { fetchProjects } from "../api/projects"; import { fetchProjects } from "../api/projects";
import { import {
createReimbursementItem, createReimbursementItem,
@@ -108,32 +107,6 @@ function workflowActionLabel(action: string) {
return labels[action] ?? action; return labels[action] ?? action;
} }
function resolveErpSyncStatus(
exportState:
| {
exportedAt: string | null;
lastOutboxStatus: string | null;
}
| null,
) {
if (!exportState) {
return "未导出";
}
if (exportState.lastOutboxStatus === "pending") {
return "待导出";
}
if (exportState.lastOutboxStatus === "processing") {
return "导出中";
}
if (exportState.lastOutboxStatus === "failed" || exportState.lastOutboxStatus === "dead_letter") {
return "导出失败";
}
if (exportState.lastOutboxStatus === "succeeded" || exportState.exportedAt) {
return "已导出";
}
return "未导出";
}
function budgetControlModeLabel(controlMode: string | null | undefined) { function budgetControlModeLabel(controlMode: string | null | undefined) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
block: "超额阻断", block: "超额阻断",
@@ -180,7 +153,6 @@ export function ReimbursementDetailPage() {
const [itemError, setItemError] = useState(""); const [itemError, setItemError] = useState("");
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState(""); const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false); const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
const [showSecondaryDetail, setShowSecondaryDetail] = useState(false); const [showSecondaryDetail, setShowSecondaryDetail] = useState(false);
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null); const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false); const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
@@ -215,8 +187,6 @@ export function ReimbursementDetailPage() {
queryFn: () => fetchProjects({ mine: true }), queryFn: () => fetchProjects({ mine: true }),
}); });
const showExternalSyncUi = import.meta.env.VITE_SHOW_ERP_UI === "true";
const canManageIntegration = hasPermission(permissions, "integration.manage") && showExternalSyncUi;
const canSubmitExpense = hasPermission(permissions, "expense.submit"); const canSubmitExpense = hasPermission(permissions, "expense.submit");
const canUploadInvoice = hasPermission(permissions, "invoice.upload"); const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own"); const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
@@ -229,12 +199,6 @@ export function ReimbursementDetailPage() {
queryFn: () => precheckReimbursement(reimbursementId), queryFn: () => precheckReimbursement(reimbursementId),
}); });
const erpPreviewQuery = useQuery({
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"],
queryFn: () => previewReimbursementJournal(reimbursementId),
});
const bindableInvoicesQuery = useQuery({ const bindableInvoicesQuery = useQuery({
enabled: enabled:
Boolean(reimbursementId) && Boolean(reimbursementId) &&
@@ -461,41 +425,6 @@ export function ReimbursementDetailPage() {
}, },
}); });
const erpExportMutation = useMutation({
mutationFn: async () => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
return exportReimbursementJournal(reimbursementId, true);
},
onSuccess: async (result) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
]);
setActionError("");
const suffix = result.requeued ? "已重入队" : result.duplicated ? "已复用现有队列" : "已入队";
toast.success(`财务桥接出队${suffix}`);
},
onError: (error) => {
if (isApiError(error)) {
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setActionError(msg);
toast.error(msg);
return;
}
if (error instanceof Error) {
setActionError(error.message);
toast.error(error.message);
return;
}
setActionError("财务桥接出队失败,请稍后重试");
toast.error("财务桥接出队失败,请稍后重试");
},
});
const bindInvoicesMutation = useMutation({ const bindInvoicesMutation = useMutation({
mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => { mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => {
if (!reimbursementId) { if (!reimbursementId) {
@@ -809,8 +738,6 @@ export function ReimbursementDetailPage() {
paidBy: null, paidBy: null,
}; };
const displayCode = resolveReimbursementDisplayCode(detail); const displayCode = resolveReimbursementDisplayCode(detail);
const isPaidReimbursement = detail.status === "paid";
const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null);
const budgetHint = detail.budgetHint; const budgetHint = detail.budgetHint;
const canPay = hasPermission(permissions, "expense.finance.pay"); const canPay = hasPermission(permissions, "expense.finance.pay");
const canEditItems = ["draft", "rejected"].includes(detail.status); const canEditItems = ["draft", "rejected"].includes(detail.status);
@@ -1732,72 +1659,6 @@ export function ReimbursementDetailPage() {
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} /> <AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
{canManageIntegration ? (
<SectionPanel
title="财务导出状态"
description="付款后可生成财务导出记录。"
actions={
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
disabled={!isPaidReimbursement || erpPreviewQuery.isLoading || Boolean(erpPreviewQuery.isError) || !erpPreviewQuery.data}
onClick={() => setIsErpPreviewOpen(true)}
>
预览凭证
</Button>
<Button type="button" disabled={!isPaidReimbursement || erpExportMutation.isPending} onClick={() => erpExportMutation.mutate()}>
{erpExportMutation.isPending ? "导出中..." : "生成导出"}
</Button>
</div>
}
>
{!isPaidReimbursement ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
未满足导出条件,仅已付款报销可生成财务导出记录。
</p>
) : null}
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载财务凭证预览...</p> : null}
{erpPreviewQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(erpPreviewQuery.error)
? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage(
erpPreviewQuery.error.code,
erpPreviewQuery.error.details,
erpPreviewQuery.error.message,
)}`
: "加载财务凭证预览失败"}
</p>
) : null}
<div className="detail-fact-grid text-sm xl:grid-cols-4" data-testid="reimbursement-erp-export">
<div className="detail-fact-card">
<div className="detail-fact-card__label">导出状态</div>
<div className="detail-fact-card__value">{erpSyncStatus}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">最近导出</div>
<div className="detail-fact-card__value">{formatDateTime(erpPreviewQuery.data?.exportState.exportedAt ?? null)}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">导出任务</div>
<div className="detail-fact-card__value break-all">{erpPreviewQuery.data?.exportState.lastOutboxId ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">外部凭证号</div>
<div className="detail-fact-card__value break-all">{erpPreviewQuery.data?.exportState.externalId ?? "-"}</div>
</div>
</div>
{erpPreviewQuery.data?.exportState.lastError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
最近错误:{erpPreviewQuery.data.exportState.lastError}
</div>
) : null}
</SectionPanel>
) : null}
</div> </div>
</SectionPanel> </SectionPanel>
@@ -2058,41 +1919,6 @@ export function ReimbursementDetailPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={isErpPreviewOpen} onOpenChange={setIsErpPreviewOpen}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>财务凭证预览</DialogTitle>
<DialogDescription>查看凭证内容和最近导出状态。</DialogDescription>
</DialogHeader>
{erpPreviewQuery.data ? (
<div className="grid gap-4 text-sm lg:grid-cols-2">
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
<h4 className="font-medium text-zinc-900">凭证切片</h4>
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
</div>
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
<h4 className="font-medium text-zinc-900">导出状态</h4>
<div>导出状态:{erpSyncStatus}</div>
<div>外部凭证号:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
<div>任务编号:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>任务状态:{erpSyncStatus}</div>
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
</div>
</div>
) : (
<p className="text-sm text-zinc-500">暂无可预览的财务凭证数据。</p>
)}
</DialogContent>
</Dialog>
<Dialog <Dialog
open={Boolean(submitConfirmPrecheck)} open={Boolean(submitConfirmPrecheck)}
onOpenChange={(open) => { onOpenChange={(open) => {
+1 -2
查看文件
@@ -26,7 +26,6 @@ const ITEMS: ManagementItem[] = [
{ to: "/system/master-data", icon: "dataset", title: "主数据", description: "法体、成本中心、供应商等基础数据。", group: "功能管理", anyOf: ["masterdata.view", "masterdata.manage"] }, { to: "/system/master-data", icon: "dataset", title: "主数据", description: "法体、成本中心、供应商等基础数据。", group: "功能管理", anyOf: ["masterdata.view", "masterdata.manage"] },
{ to: "/system/notification-channels", icon: "notifications_active", title: "通知渠道", description: "企业微信、邮件等补充通知。", group: "功能管理", anyOf: ["notification.channel.manage"] }, { to: "/system/notification-channels", icon: "notifications_active", title: "通知渠道", description: "企业微信、邮件等补充通知。", group: "功能管理", anyOf: ["notification.channel.manage"] },
{ to: "/system/feature-flags", icon: "toggle_on", title: "功能管理", description: "功能开关、灰度和试运行边界。", group: "功能管理", anyOf: ["system.settings"] }, { to: "/system/feature-flags", icon: "toggle_on", title: "功能管理", description: "功能开关、灰度和试运行边界。", group: "功能管理", anyOf: ["system.settings"] },
{ to: "/system/integrations/erp", icon: "hub", title: "外部系统", description: "财务、ERP 和外部系统连接。", group: "功能管理", anyOf: ["integration.manage"] },
]; ];
const GROUPS: ManagementItem["group"][] = ["人员与组织", "流程与规则", "系统与审计", "功能管理"]; const GROUPS: ManagementItem["group"][] = ["人员与组织", "流程与规则", "系统与审计", "功能管理"];
@@ -43,7 +42,7 @@ export function SystemSettingsPage() {
<ListWorkspace> <ListWorkspace>
<WorkspaceHeader <WorkspaceHeader
title="管理" title="管理"
subtitle="人员、组织、角色、审批规则、主数据、通知渠道、外部系统、功能开关和审计统一在这里处理。" subtitle="人员、组织、角色、审批规则、主数据、通知渠道、功能开关和审计统一在这里处理。"
/> />
<div className="finance-status-strip" data-testid="system-settings-summary"> <div className="finance-status-strip" data-testid="system-settings-summary">
<div className="finance-metric-card"> <div className="finance-metric-card">
+5 -3
查看文件
@@ -42,7 +42,6 @@ const MODULE_LABELS: Record<string, string> = {
invoicePool: "票据", invoicePool: "票据",
inventoryChecks: "资产盘点", inventoryChecks: "资产盘点",
cutover: "切换控制台", cutover: "切换控制台",
erpIntegrations: "外部系统",
profile: "个人设置", profile: "个人设置",
}; };
@@ -83,9 +82,12 @@ export function WorkspaceSetupPage() {
const workspace = workspaceQuery.data; const workspace = workspaceQuery.data;
const rolePresets = navigationQuery.data?.recommendedRolePresets ?? []; const rolePresets = navigationQuery.data?.recommendedRolePresets ?? [];
const moduleEntries = useMemo(() => Object.entries(workspace?.moduleVisibility ?? {}), [workspace?.moduleVisibility]); const moduleEntries = useMemo(
() => Object.entries(workspace?.moduleVisibility ?? {}).filter(([key]) => key !== "erpIntegrations"),
[workspace?.moduleVisibility],
);
const workspaceStats = useMemo(() => { const workspaceStats = useMemo(() => {
const entries = Object.entries(workspace?.moduleVisibility ?? {}); const entries = Object.entries(workspace?.moduleVisibility ?? {}).filter(([key]) => key !== "erpIntegrations");
return { return {
total: entries.length, total: entries.length,
main: entries.filter(([, mode]) => mode === "main").length, main: entries.filter(([, mode]) => mode === "main").length,
-10
查看文件
@@ -22,7 +22,6 @@ import {
EmployeeProfilePage, EmployeeProfilePage,
DelegationsPage, DelegationsPage,
DepartmentsPage, DepartmentsPage,
ErpIntegrationPage,
ExpenseStandardsPage, ExpenseStandardsPage,
FeatureFlagsPage, FeatureFlagsPage,
InventoryChecksPage, InventoryChecksPage,
@@ -563,15 +562,6 @@ export function AppRouter() {
} }
/> />
<Route
path="system/integrations/erp"
element={
<RequirePermission anyOf={["integration.manage"]}>
{withRouteFallback(<ErpIntegrationPage />)}
</RequirePermission>
}
/>
<Route <Route
path="system/workspace" path="system/workspace"
element={ element={
@@ -1 +0,0 @@
export { ErpIntegrationPage as default } from "../../pages/ErpIntegrationPage";
-1
查看文件
@@ -8,7 +8,6 @@ export const AssetRegistryPage = lazy(() => import("./AssetRegistryPage"));
export const CutoverConsolePage = lazy(() => import("./CutoverConsolePage")); export const CutoverConsolePage = lazy(() => import("./CutoverConsolePage"));
export const DelegationsPage = lazy(() => import("./DelegationsPage")); export const DelegationsPage = lazy(() => import("./DelegationsPage"));
export const DepartmentsPage = lazy(() => import("./DepartmentsPage")); export const DepartmentsPage = lazy(() => import("./DepartmentsPage"));
export const ErpIntegrationPage = lazy(() => import("./ErpIntegrationPage"));
export const ExpenseStandardsPage = lazy(() => import("./ExpenseStandardsPage")); export const ExpenseStandardsPage = lazy(() => import("./ExpenseStandardsPage"));
export const FeatureFlagsPage = lazy(() => import("./FeatureFlagsPage")); export const FeatureFlagsPage = lazy(() => import("./FeatureFlagsPage"));
export const InventoryChecksPage = lazy(() => import("./InventoryChecksPage")); export const InventoryChecksPage = lazy(() => import("./InventoryChecksPage"));
@@ -39,7 +39,7 @@ function buildMe(user: MockUserKey) {
return { return {
id: "admin-policy-ack", id: "admin-policy-ack",
employeeNo: "A001", employeeNo: "A001",
name: "Admin", name: "王顺利",
phone: "mock-phone-admin", phone: "mock-phone-admin",
email: "[email protected]", email: "[email protected]",
departmentId: "dept-admin", departmentId: "dept-admin",
@@ -55,7 +55,7 @@ function buildMe(user: MockUserKey) {
return { return {
id: "employee-policy-ack", id: "employee-policy-ack",
employeeNo: "E001", employeeNo: "E001",
name: "Employee", name: "陈工程",
phone: "mock-phone-employee", phone: "mock-phone-employee",
email: "[email protected]", email: "[email protected]",
departmentId: "dept-employee", departmentId: "dept-employee",
@@ -85,7 +85,7 @@ function mapAnnouncementForUser(item: MockAnnouncement, currentUser: MockUserKey
function mapAnnouncementForAdmin(item: MockAnnouncement) { function mapAnnouncementForAdmin(item: MockAnnouncement) {
const employees = [ const employees = [
{ id: "employee-policy-ack", employeeNo: "E001", name: "Employee", status: "pending" }, { id: "employee-policy-ack", employeeNo: "E001", name: "陈工程", status: "pending" },
{ id: "employee-policy-ack-2", employeeNo: "E002", name: "员工二", status: "pending" }, { id: "employee-policy-ack-2", employeeNo: "E002", name: "员工二", status: "pending" },
]; ];
const pendingEmployees = item.requiresAcknowledgement const pendingEmployees = item.requiresAcknowledgement
@@ -6,7 +6,7 @@ import { TEST_IDS } from "./helpers/testIds";
const ADMIN = { const ADMIN = {
identifier: "[email protected]", identifier: "[email protected]",
password: "AdminPassword1234", password: "AdminPassword1234",
name: "Admin", name: "王顺利",
}; };
test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇总", async ({ page }) => { test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇总", async ({ page }) => {
+10 -10
查看文件
@@ -2,8 +2,8 @@ import { expect, test, type Locator, type Page } from "@playwright/test";
import { openNavLink } from "./helpers/navigation"; import { openNavLink } from "./helpers/navigation";
const FINANCE_MGR = { identifier: "[email protected]", password: "FinanceMgrPassword1234", name: "Finance Manager" }; const FINANCE_MGR = { identifier: "[email protected]", password: "FinanceMgrPassword1234", name: "钱主管", employeeNo: "HYD000005" };
const ADMINISTRATION = { identifier: "[email protected]", password: "AdministrationPassword1234", name: "Administration" }; const ADMINISTRATION = { identifier: "[email protected]", password: "AdministrationPassword1234", name: "周行政", employeeNo: "HYD000008" };
async function login(page: Page, user: { identifier: string; password: string }) { async function login(page: Page, user: { identifier: string; password: string }) {
await page.goto("/login"); await page.goto("/login");
@@ -130,26 +130,26 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
await page.goto(`/assets?id=${assetId}`); await page.goto(`/assets?id=${assetId}`);
await expect(page.getByTestId("asset-name")).toHaveValue(assetName, { timeout: 30_000 }); await expect(page.getByTestId("asset-name")).toHaveValue(assetName, { timeout: 30_000 });
await selectOptionByText(page.getByTestId("asset-movement-assignee"), /Employee/); await selectOptionByText(page.getByTestId("asset-movement-assignee"), /陈工程/);
await page.getByTestId("asset-movement-note").fill("Playwright 首次领用"); await page.getByTestId("asset-movement-note").fill("Playwright 首次领用");
if (await page.getByText(/当前归属人:未分配/).isVisible().catch(() => false)) { if (await page.getByText(/归属人:-/).isVisible().catch(() => false)) {
await page.getByTestId("asset-assign-submit").click(); await page.getByTestId("asset-assign-submit").click();
} else { } else {
await page.getByTestId("asset-transfer-submit").click(); await page.getByTestId("asset-transfer-submit").click();
} }
await expect(page.getByText(/当前归属人:Employee/)).toBeVisible({ timeout: 30_000 }); await expect(page.getByText(/归属人:陈工程/)).toBeVisible({ timeout: 30_000 });
await page.getByTestId("asset-return-submit").click(); await page.getByTestId("asset-return-submit").click();
await expect(page.getByText(/当前归属人:未分配/)).toBeVisible({ timeout: 30_000 }); await expect(page.getByText(/归属人:-/)).toBeVisible({ timeout: 30_000 });
await selectOptionByText(page.getByTestId("asset-movement-assignee"), /Employee/); await selectOptionByText(page.getByTestId("asset-movement-assignee"), /陈工程/);
await page.getByTestId("asset-assign-submit").click(); await page.getByTestId("asset-assign-submit").click();
await expect(page.getByText(/当前归属人:Employee/)).toBeVisible({ timeout: 30_000 }); await expect(page.getByText(/归属人:陈工程/)).toBeVisible({ timeout: 30_000 });
await selectOptionByText(page.getByTestId("asset-movement-assignee"), /Finance Manager/); await selectOptionByText(page.getByTestId("asset-movement-assignee"), /钱主管/);
await page.getByTestId("asset-movement-location").fill("深圳办公室 B-02"); await page.getByTestId("asset-movement-location").fill("深圳办公室 B-02");
await page.getByTestId("asset-transfer-submit").click(); await page.getByTestId("asset-transfer-submit").click();
await expect(page.getByText(/当前归属人:Finance Manager/)).toBeVisible({ timeout: 30_000 }); await expect(page.getByText(/归属人:钱主管/)).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(/位置:深圳办公室 B-02/)).toBeVisible({ timeout: 30_000 }); await expect(page.getByText(/位置:深圳办公室 B-02/)).toBeVisible({ timeout: 30_000 });
await logout(page, ADMINISTRATION.name); await logout(page, ADMINISTRATION.name);
@@ -5,7 +5,7 @@ import { apiGetData, apiPostData, createAuthenticatedApiClient, login } from "./
const ADMIN = { const ADMIN = {
identifier: "[email protected]", identifier: "[email protected]",
password: "AdminPassword1234", password: "AdminPassword1234",
name: "Admin", name: "王顺利",
}; };
type FeatureFlagItem = { type FeatureFlagItem = {
+11 -5
查看文件
@@ -8,36 +8,42 @@ export type E2EUser = {
identifier: string; identifier: string;
password: string; password: string;
name: string; name: string;
employeeNo?: string;
}; };
export const EMPLOYEE: E2EUser = { export const EMPLOYEE: E2EUser = {
identifier: "[email protected]", identifier: "[email protected]",
password: "EmployeePassword1234", password: "EmployeePassword1234",
name: "Employee", name: "陈工程",
employeeNo: "HYD000003",
}; };
export const MANAGER: E2EUser = { export const MANAGER: E2EUser = {
identifier: "[email protected]", identifier: "[email protected]",
password: "ManagerPassword1234", password: "ManagerPassword1234",
name: "Manager", name: "李研发",
employeeNo: "HYD000002",
}; };
export const FINANCE: E2EUser = { export const FINANCE: E2EUser = {
identifier: "[email protected]", identifier: "[email protected]",
password: "FinancePassword1234", password: "FinancePassword1234",
name: "Finance", name: "赵财务",
employeeNo: "HYD000004",
}; };
export const FINANCE_MGR: E2EUser = { export const FINANCE_MGR: E2EUser = {
identifier: "[email protected]", identifier: "[email protected]",
password: "FinanceMgrPassword1234", password: "FinanceMgrPassword1234",
name: "Finance Manager", name: "钱主管",
employeeNo: "HYD000005",
}; };
export const ADMIN: E2EUser = { export const ADMIN: E2EUser = {
identifier: "[email protected]", identifier: "[email protected]",
password: "AdminPassword1234", password: "AdminPassword1234",
name: "Admin", name: "王顺利",
employeeNo: "HYD000006",
}; };
export type MeResponse = { export type MeResponse = {
+1 -1
查看文件
@@ -5,7 +5,7 @@ import { EMPLOYEE, MANAGER, login, type E2EUser } from "./helpers/auth";
const LEAVE_CALENDAR_HR: E2EUser = { const LEAVE_CALENDAR_HR: E2EUser = {
identifier: "[email protected]", identifier: "[email protected]",
password: "HrPassword1234", password: "HrPassword1234",
name: "HR", name: "孙人事",
}; };
function envelope<T>(data: T) { function envelope<T>(data: T) {
+25 -18
查看文件
@@ -5,8 +5,8 @@ import { TEST_IDS } from "./helpers/testIds";
test.use({ serviceWorkers: "block" }); test.use({ serviceWorkers: "block" });
const ADMIN = { identifier: "[email protected]", password: "AdminPassword1234", name: "Admin" }; const ADMIN = { identifier: "[email protected]", password: "AdminPassword1234", name: "王顺利", employeeNo: "HYD000006" };
const MANAGER = { identifier: "[email protected]", password: "ManagerPassword1234", name: "Manager" }; const MANAGER = { identifier: "[email protected]", password: "ManagerPassword1234", name: "李研发", employeeNo: "HYD000002" };
function envelope(data: unknown) { function envelope(data: unknown) {
return { return {
@@ -148,16 +148,23 @@ function mockNotificationList(page: import("@playwright/test").Page) {
} }
function mockMe(page: import("@playwright/test").Page, roleCodes: string[], name: string) { function mockMe(page: import("@playwright/test").Page, roleCodes: string[], name: string) {
const employeeNoByName: Record<string, string> = {
王顺利: "HYD000006",
李研发: "HYD000002",
陈工程: "HYD000003",
周行政: "HYD000008",
};
const employeeNo = employeeNoByName[name] ?? "HYD000003";
return page.route("**/api/me", async (route) => { return page.route("**/api/me", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(envelope({ body: JSON.stringify(envelope({
id: "employee-mock-1", id: "employee-mock-1",
employeeNo: "E001", employeeNo,
name, name,
phone: "13800000000", phone: "13800000000",
email: `${name.toLowerCase()}@hyinda.com`, email: `mock-${employeeNo.toLowerCase()}@hyinda.com`,
departmentId: "dept-1", departmentId: "dept-1",
directManagerId: null, directManagerId: null,
status: "active", status: "active",
@@ -260,7 +267,7 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
const data = mode === "manager" const data = mode === "manager"
? { ? {
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" }, workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
greeting: { name: "Manager" }, greeting: { name: "李研发" },
cards: [ cards: [
{ key: "pending_approvals", title: "待我处理", value: 3, href: "/workflow/tasks", severity: "warning" }, { key: "pending_approvals", title: "待我处理", value: 3, href: "/workflow/tasks", severity: "warning" },
{ key: "team_presence", title: "今日团队状态", value: "6/8 在岗", href: "/team", severity: "warning" }, { key: "team_presence", title: "今日团队状态", value: "6/8 在岗", href: "/team", severity: "warning" },
@@ -298,7 +305,7 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
} }
: { : {
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" }, workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
greeting: { name: "Employee" }, greeting: { name: "陈工程" },
cards: [ cards: [
{ key: "my_todos", title: "我的待办", value: 4, href: "/todos", severity: "warning" }, { key: "my_todos", title: "我的待办", value: 4, href: "/todos", severity: "warning" },
{ key: "today_presence", title: "今日状态", value: "在岗", href: "/team", severity: "normal" }, { key: "today_presence", title: "今日状态", value: "在岗", href: "/team", severity: "normal" },
@@ -325,10 +332,10 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
peopleLifecycle: null, peopleLifecycle: null,
adoptionSummary: null, adoptionSummary: null,
recentActivities: [ recentActivities: [
{ id: "activity-1", type: "leave.submit", actorName: "Employee", summary: "Employee 提交了请假单", createdAt: "2026-03-22T09:00:00.000Z", href: "/leave-requests" }, { id: "activity-1", type: "leave.submit", actorName: "陈工程", summary: "陈工程 提交了请假单", createdAt: "2026-03-22T09:00:00.000Z", href: "/leave-requests" },
], ],
teamStatusBoard: [ teamStatusBoard: [
{ employeeId: "employee-1", name: "Employee", avatarUrl: null, presenceStatus: "office", attendanceClockIn: "2026-03-22T00:55:00.000Z", attendanceClockOut: null, onLeave: false, leaveType: null }, { employeeId: "employee-1", name: "陈工程", avatarUrl: null, presenceStatus: "office", attendanceClockIn: "2026-03-22T00:55:00.000Z", attendanceClockOut: null, onLeave: false, leaveType: null },
{ employeeId: "employee-2", name: "同事", avatarUrl: null, presenceStatus: "remote", attendanceClockIn: null, attendanceClockOut: null, onLeave: false, leaveType: null }, { employeeId: "employee-2", name: "同事", avatarUrl: null, presenceStatus: "remote", attendanceClockIn: null, attendanceClockOut: null, onLeave: false, leaveType: null },
], ],
}; };
@@ -386,7 +393,7 @@ test("邮箱验证码登录可发送验证码并进入工作台", async ({ page
const verifyPayloads: unknown[] = []; const verifyPayloads: unknown[] = [];
await mockGuestAuthChrome(page); await mockGuestAuthChrome(page);
await mockMe(page, ["employee"], "Employee"); await mockMe(page, ["employee"], "陈工程");
await page.route("**/api/auth/email-login/code", async (route) => { await page.route("**/api/auth/email-login/code", async (route) => {
codeRequests.push(route.request().postDataJSON()); codeRequests.push(route.request().postDataJSON());
@@ -507,11 +514,11 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
await mockPortalHome(page, "employee"); await mockPortalHome(page, "employee");
await mockAttendanceToday(page); await mockAttendanceToday(page);
await mockNotificationList(page); await mockNotificationList(page);
await mockMe(page, ["employee"], "Employee"); await mockMe(page, ["employee"], "陈工程");
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
await expect(page.getByTestId("portal-work-summary")).toContainText("今天有 7 件待处理"); await expect(page.getByTestId("portal-work-summary")).toContainText("待处理 7");
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByRole("link", { name: /待办事项 4 条.*查看待办/ })).toBeVisible(); await expect(page.getByRole("link", { name: /待办事项 4 条.*查看待办/ })).toBeVisible();
await expect(page.getByRole("link", { name: /本周周报待填写.*填写周报/ })).toBeVisible(); await expect(page.getByRole("link", { name: /本周周报待填写.*填写周报/ })).toBeVisible();
@@ -527,7 +534,7 @@ test("Portal 管理视角按角色分发待处理和团队卡点", async ({ page
await mockPortalHome(page, "manager"); await mockPortalHome(page, "manager");
await mockAttendanceToday(page); await mockAttendanceToday(page);
await mockNotificationList(page); await mockNotificationList(page);
await mockMe(page, ["manager"], "Manager"); await mockMe(page, ["manager"], "李研发");
await login(page, MANAGER); await login(page, MANAGER);
@@ -552,7 +559,7 @@ test("Portal 新员工只显示轻量欢迎入口,不再显示快速上手清
await mockPortalHome(page, "employee"); await mockPortalHome(page, "employee");
await mockAttendanceToday(page); await mockAttendanceToday(page);
await mockNotificationList(page); await mockNotificationList(page);
await mockMe(page, ["employee"], "Employee"); await mockMe(page, ["employee"], "陈工程");
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
@@ -584,7 +591,7 @@ test("个人设置默认只展示账号通知和企业微信,不展示权限 c
await mockPortalHome(page, "employee"); await mockPortalHome(page, "employee");
await mockAttendanceToday(page); await mockAttendanceToday(page);
await mockNotificationList(page); await mockNotificationList(page);
await mockMe(page, ["employee"], "Employee"); await mockMe(page, ["employee"], "陈工程");
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
await page.goto("/settings/profile"); await page.goto("/settings/profile");
@@ -646,7 +653,7 @@ test("Portal bootstrap refresh 首次瞬时失败后重试成功仍保留登录
await mockPortalHome(page, "employee"); await mockPortalHome(page, "employee");
await mockAttendanceToday(page); await mockAttendanceToday(page);
await mockNotificationList(page); await mockNotificationList(page);
await mockMe(page, ["employee"], "Employee"); await mockMe(page, ["employee"], "陈工程");
await page.goto("/portal"); await page.goto("/portal");
@@ -661,7 +668,7 @@ test("Portal 域管理角色也能看到团队卡点", async ({ page }) => {
await mockPortalHome(page, "manager"); await mockPortalHome(page, "manager");
await mockAttendanceToday(page); await mockAttendanceToday(page);
await mockNotificationList(page); await mockNotificationList(page);
await mockMe(page, ["administration"], "Administration"); await mockMe(page, ["administration"], "周行政");
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
@@ -676,7 +683,7 @@ test("Portal 管理提醒正常时不显示空壳", async ({ page }) => {
await mockPortalHome(page, "manager"); await mockPortalHome(page, "manager");
await mockAttendanceToday(page); await mockAttendanceToday(page);
await mockNotificationList(page); await mockNotificationList(page);
await mockMe(page, ["admin"], "Admin"); await mockMe(page, ["admin"], "王顺利");
await login(page, ADMIN); await login(page, ADMIN);
@@ -697,7 +704,7 @@ test("企微 WebView 下默认进入移动工作台", async ({ page }) => {
await mockPortalHome(page, "employee"); await mockPortalHome(page, "employee");
await mockAttendanceToday(page); await mockAttendanceToday(page);
await mockNotificationList(page); await mockNotificationList(page);
await mockMe(page, ["employee"], "Employee"); await mockMe(page, ["employee"], "陈工程");
await page.goto("/login"); await page.goto("/login");
await page.getByTestId(TEST_IDS.login.identifier).fill(EMPLOYEE.identifier); await page.getByTestId(TEST_IDS.login.identifier).fill(EMPLOYEE.identifier);
+1 -1
查看文件
@@ -5,7 +5,7 @@ import { apiGetData, apiPostData, createAuthenticatedApiClient, login } from "./
const ADMIN = { const ADMIN = {
identifier: "[email protected]", identifier: "[email protected]",
password: "AdminPassword1234", password: "AdminPassword1234",
name: "Admin", name: "王顺利",
}; };
type FeatureFlagItem = { type FeatureFlagItem = {
@@ -15,7 +15,7 @@ async function fulfillJson(route: Route, data: unknown, status = 200) {
const me = { const me = {
id: "employee-r100", id: "employee-r100",
employeeNo: "E100", employeeNo: "E100",
name: "Employee", name: "陈工程",
phone: "13800000000", phone: "13800000000",
email: "[email protected]", email: "[email protected]",
departmentId: "dept-r100", departmentId: "dept-r100",
@@ -15,7 +15,7 @@ async function fulfillJson(route: Route, data: unknown, status = 200) {
const me = { const me = {
id: "employee-r101", id: "employee-r101",
employeeNo: "E101", employeeNo: "E101",
name: "Employee", name: "陈工程",
phone: "13800000000", phone: "13800000000",
email: "[email protected]", email: "[email protected]",
departmentId: "dept-r101", departmentId: "dept-r101",
@@ -15,7 +15,7 @@ async function fulfillJson(route: Route, data: unknown, status = 200) {
const me = { const me = {
id: "employee-r102", id: "employee-r102",
employeeNo: "E102", employeeNo: "E102",
name: "Employee", name: "陈工程",
phone: "13800000000", phone: "13800000000",
email: "[email protected]", email: "[email protected]",
departmentId: "dept-r102", departmentId: "dept-r102",
@@ -23,8 +23,8 @@ function screenshotPath(fileName: string) {
const me = { const me = {
id: "employee-r103", id: "employee-r103",
employeeNo: "E103", employeeNo: "HYD000103",
name: "Employee", name: "陈工程",
phone: "13800000000", phone: "13800000000",
email: "[email protected]", email: "[email protected]",
departmentId: "dept-r103", departmentId: "dept-r103",
@@ -37,8 +37,8 @@ async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) {
const mockMe = { const mockMe = {
id: "employee-mock-1", id: "employee-mock-1",
employeeNo: "M001", employeeNo: "HYD000002",
name: "Manager", name: "李研发",
phone: "13800000000", phone: "13800000000",
email: "[email protected]", email: "[email protected]",
departmentId: "dept-1", departmentId: "dept-1",
@@ -105,8 +105,8 @@ const pendingTaskDetail = {
reimbursementDate: "2026-05-05", reimbursementDate: "2026-05-05",
totalAmount: "1683.00", totalAmount: "1683.00",
description: "R89 移动审批验证", description: "R89 移动审批验证",
owner: { id: "employee-mock-2", employeeNo: "E002", name: "Employee" }, owner: { id: "employee-mock-2", employeeNo: "HYD000003", name: "陈工程" },
submitter: { id: "employee-mock-2", employeeNo: "E002", name: "Employee" }, submitter: { id: "employee-mock-2", employeeNo: "HYD000003", name: "陈工程" },
}, },
leaveRequest: null, leaveRequest: null,
purchaseRequest: null, purchaseRequest: null,
@@ -60,8 +60,8 @@ async function mockAuthenticatedShell(page: Page) {
await page.route((url) => url.pathname === "/api/me", (route) => await page.route((url) => url.pathname === "/api/me", (route) =>
fulfillJson(route, { fulfillJson(route, {
id: "employee-mock-1", id: "employee-mock-1",
employeeNo: "E001", employeeNo: "HYD000003",
name: "Smoke Employee", name: "陈工程",
phone: "13800000000", phone: "13800000000",
email: "[email protected]", email: "[email protected]",
departmentId: "dept-1", departmentId: "dept-1",
@@ -113,7 +113,7 @@ async function mockAuthenticatedShell(page: Page) {
await page.route((url) => url.pathname === "/api/portal/home", (route) => await page.route((url) => url.pathname === "/api/portal/home", (route) =>
fulfillJson(route, { fulfillJson(route, {
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" }, workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
greeting: { name: "Smoke Employee" }, greeting: { name: "陈工程" },
cards: [], cards: [],
pendingTasks: { count: 0, href: "/todos" }, pendingTasks: { count: 0, href: "/todos" },
myApplications: [], myApplications: [],
@@ -149,8 +149,8 @@ function reimbursementListItem() {
paidAt: null, paidAt: null,
paidBy: null, paidBy: null,
}, },
owner: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" }, owner: { id: "employee-mock-1", employeeNo: "HYD000003", name: "陈工程" },
submitter: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" }, submitter: { id: "employee-mock-1", employeeNo: "HYD000003", name: "陈工程" },
project: null, project: null,
pendingWorkflow: null, pendingWorkflow: null,
createdAt: null, createdAt: null,
@@ -197,8 +197,8 @@ const invoiceListItem = {
mimeType: "application/pdf", mimeType: "application/pdf",
fileSize: 128, fileSize: 128,
verification: null, verification: null,
owner: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" }, owner: { id: "employee-mock-1", employeeNo: "HYD000003", name: "陈工程" },
uploadedBy: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" }, uploadedBy: { id: "employee-mock-1", employeeNo: "HYD000003", name: "陈工程" },
createdAt: null, createdAt: null,
updatedAt: null, updatedAt: null,
}; };
+8 -4
查看文件
@@ -7,7 +7,8 @@ import { EMPLOYEE, FINANCE, login, type E2EUser } from "./helpers/auth";
const ADMIN: E2EUser = { const ADMIN: E2EUser = {
identifier: "[email protected]", identifier: "[email protected]",
password: "AdminPassword1234", password: "AdminPassword1234",
name: "Admin", name: "王顺利",
employeeNo: "HYD000006",
}; };
const SCREENSHOT_DIR = path.resolve(process.cwd(), "../output/playwright/portal-task-first-redesign"); const SCREENSHOT_DIR = path.resolve(process.cwd(), "../output/playwright/portal-task-first-redesign");
@@ -25,9 +26,10 @@ function envelope<T>(data: T) {
} }
function portalPayload(mode: "employee" | "admin" | "finance") { function portalPayload(mode: "employee" | "admin" | "finance") {
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : "王顺利";
const base = { const base = {
workspace: { id: "workspace-visual", persisted: true, profile: "small_team_internal" }, workspace: { id: "workspace-visual", persisted: true, profile: "small_team_internal" },
greeting: { name: mode === "employee" ? "Employee" : mode === "finance" ? "Finance" : "Admin" }, greeting: { name: displayName },
myApplications: [{ domain: "reimbursement", count: 1, href: "/reimbursements" }], myApplications: [{ domain: "reimbursement", count: 1, href: "/reimbursements" }],
riskSummary: null, riskSummary: null,
announcements: [], announcements: [],
@@ -134,13 +136,15 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
return; return;
} }
if (pathName === "/api/me") { if (pathName === "/api/me") {
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : "王顺利";
const employeeNo = mode === "employee" ? "HYD000003" : mode === "finance" ? "HYD000004" : "HYD000006";
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(envelope({ body: JSON.stringify(envelope({
id: `visual-${mode}`, id: `visual-${mode}`,
employeeNo: "VISUAL", employeeNo,
name: mode === "employee" ? "Employee" : mode === "finance" ? "Finance" : "Admin", name: displayName,
email: `${mode}@hyinda.com`, email: `${mode}@hyinda.com`,
roleCodes: mode === "employee" ? ["employee"] : mode === "finance" ? ["finance"] : ["admin"], roleCodes: mode === "employee" ? ["employee"] : mode === "finance" ? ["finance"] : ["admin"],
notificationPushEnabled: true, notificationPushEnabled: true,
+5 -5
查看文件
@@ -19,7 +19,7 @@ async function fulfillJson(route: Route, data: unknown, status = 200) {
const visualMe = { const visualMe = {
id: "employee-r96", id: "employee-r96",
employeeNo: "R96001", employeeNo: "R96001",
name: "Employee", name: "陈工程",
phone: "13800000000", phone: "13800000000",
email: "[email protected]", email: "[email protected]",
departmentId: "dept-r96", departmentId: "dept-r96",
@@ -261,12 +261,12 @@ async function mockVisualApis(page: Page) {
owner: { owner: {
id: "employee-r96", id: "employee-r96",
employeeNo: "R96001", employeeNo: "R96001",
name: "Employee", name: "陈工程",
}, },
submitter: { submitter: {
id: "employee-r96", id: "employee-r96",
employeeNo: "R96001", employeeNo: "R96001",
name: "Employee", name: "陈工程",
}, },
project: null, project: null,
pendingWorkflow: { pendingWorkflow: {
@@ -300,12 +300,12 @@ async function mockVisualApis(page: Page) {
owner: { owner: {
id: "employee-r96", id: "employee-r96",
employeeNo: "R96001", employeeNo: "R96001",
name: "Employee", name: "陈工程",
}, },
submitter: { submitter: {
id: "employee-r96", id: "employee-r96",
employeeNo: "R96001", employeeNo: "R96001",
name: "Employee", name: "陈工程",
}, },
project: null, project: null,
pendingWorkflow: null, pendingWorkflow: null,
@@ -21,7 +21,7 @@ async function fulfillJson<T>(route: Route, data: T) {
const mockMe = { const mockMe = {
id: "employee-confirm-manager", id: "employee-confirm-manager",
employeeNo: "M-confirm", employeeNo: "M-confirm",
name: "Manager", name: "李研发",
email: "[email protected]", email: "[email protected]",
phone: null, phone: null,
roleCodes: ["manager"], roleCodes: ["manager"],
@@ -98,7 +98,7 @@ const pendingTaskDetail = {
reason: "R7E 审批动作确认", reason: "R7E 审批动作确认",
attachment: null, attachment: null,
leaveType: { id: "annual", code: "annual", name: "年假" }, leaveType: { id: "annual", code: "annual", name: "年假" },
owner: { id: "employee-confirm-applicant", employeeNo: "E-confirm", name: "Employee" }, owner: { id: "employee-confirm-applicant", employeeNo: "E-confirm", name: "陈工程" },
}, },
purchaseRequest: null, purchaseRequest: null,
travelRequest: null, travelRequest: null,
@@ -21,7 +21,7 @@ async function fulfillJson<T>(route: Route, data: T) {
const manager = { const manager = {
id: "manager-wecom-boundary", id: "manager-wecom-boundary",
employeeNo: "M-WECOM", employeeNo: "M-WECOM",
name: "Manager", name: "李研发",
email: "[email protected]", email: "[email protected]",
phone: null, phone: null,
roleCodes: ["manager"], roleCodes: ["manager"],
+5 -5
查看文件
@@ -54,11 +54,11 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
合同台账 V1 只做登记、归档、到期/续签提醒,以及关联项目、采购和付款。合同页面不做完整合同生命周期管理,不自建电子签、复杂法务审查、条款库、版本协同或 CLM;如需合同审批,走 OA 统一审批中心或另建后续 Trellis task,合同台账页只展示状态、摘要和关联入口。 合同台账 V1 只做登记、归档、到期/续签提醒,以及关联项目、采购和付款。合同页面不做完整合同生命周期管理,不自建电子签、复杂法务审查、条款库、版本协同或 CLM;如需合同审批,走 OA 统一审批中心或另建后续 Trellis task,合同台账页只展示状态、摘要和关联入口。
采购台账 V1 只做申请、审批、附件、轻量验收/归档,以及关联合同、项目和付款/AP。采购页面不做完整采购 ERP,不承接采购订单全生命周期、库存、三单匹配、供应商生命周期管理、招采评标或 ERP 入账;审批通过不代表已付款、已入库或已形成固定资产。 采购台账 V1 只做申请、审批、附件、轻量验收/归档,以及关联合同、项目和付款/AP。采购页面不承接采购订单全生命周期、库存、三单匹配、供应商生命周期管理、招采评标或自动入账;审批通过不代表已付款、已入库或已形成固定资产。
项目模块 V1 只做轻项目台账:项目状态、负责人/成员、里程碑/任务、风险、交付记录,以及关联报销、采购、合同和付款。项目页面不做完整项目管理软件,不承接甘特图、复杂任务依赖、工时、资源排期、OKR、项目组合管理或大型协同平台;项目页主要负责业务归集、风险提醒和跳转到对应业务详情。 项目模块 V1 只做轻项目台账:项目状态、负责人/成员、里程碑/任务、风险、交付记录,以及关联报销、采购、合同和付款。项目页面不做完整项目管理软件,不承接甘特图、复杂任务依赖、工时、资源排期、OKR、项目组合管理或大型协同平台;项目页主要负责业务归集、风险提醒和跳转到对应业务详情。
财务台账 V1 只做付款准备、付款批次、回单留痕、AP 状态、票据/OCR 状态和代账交接摘要。付款、应付账款和票据页面不做完整财务软件,不承接银行直连真实支付、银行流水自动导入、ERP/总账、凭证生成、自动对账或税务申报;真实付款继续在线下银行或既有财务/代账流程完成,OA 只记录状态、证据和交接摘要。 财务台账 V1 只做付款准备、付款批次、回单留痕、AP 状态、票据/OCR 状态和代账交接摘要。付款、应付账款和票据页面不做完整财务软件,不承接银行直连真实支付、银行流水自动导入、总账、凭证生成、自动对账或税务申报;真实付款继续在线下银行或既有财务/代账流程完成,OA 只记录状态、证据和交接摘要。OA 不接 ERP,未来也不规划 ERP 接入。
付款相关动作集中在 `付款` / 财务处理详情执行。报销、采购、合同、项目等列表和普通详情只显示付款状态、回单状态、`查看付款`、`补回单` 或进入付款记录入口,不直接放 `标记已付款`、`取消付款`、`批量付款`、`执行付款`;付款动作前必须核对金额、对象、线下付款说明、证据和回单。 付款相关动作集中在 `付款` / 财务处理详情执行。报销、采购、合同、项目等列表和普通详情只显示付款状态、回单状态、`查看付款`、`补回单` 或进入付款记录入口,不直接放 `标记已付款`、`取消付款`、`批量付款`、`执行付款`;付款动作前必须核对金额、对象、线下付款说明、证据和回单。
@@ -80,7 +80,7 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
危险动作必须二次确认且展示对象和后果。删除、付款、发布、停用、作废、驳回、退回、切换配置、批量处理等不可逆或高风险动作,确认面板必须包含对象摘要、后果、影响范围、必要理由输入或确认输入,并写入审计;不允许只弹一句 `确定吗?`。 危险动作必须二次确认且展示对象和后果。删除、付款、发布、停用、作废、驳回、退回、切换配置、批量处理等不可逆或高风险动作,确认面板必须包含对象摘要、后果、影响范围、必要理由输入或确认输入,并写入审计;不允许只弹一句 `确定吗?`。
集成和外部系统状态必须保守表达。OA 主办报销、采购、合同、项目、付款、AP、票据、资产和高敏附件;企业微信主办打卡、请假、补卡、外出、加班等 HR/考勤日常动作;ERP、银行、代账、OCR、企业微信消息等外部系统只展示真实同步状态、失败原因和下一步,不在界面上承诺无法确认的 `已同步`、`已付款` 或 `已审批`。 集成和外部系统状态必须保守表达。OA 主办报销、采购、合同、项目、付款、AP、票据、资产和高敏附件;企业微信主办打卡、请假、补卡、外出、加班等 HR/考勤日常动作;银行、代账、OCR、企业微信消息等外部能力只展示真实同步状态、失败原因和下一步,不在界面上承诺无法确认的 `已同步`、`已付款` 或 `已审批`。ERP 不作为当前或未来外部能力。
试运行和验收口径必须准确。本地构建、Playwright 和截图通过只代表本地验证通过;只有完成部署、公网访问检查、关键路径冒烟和必要截图证据后,才可告知用户 `可以线上测试`。未部署时只能说明 `本地验证通过,尚未线上可测`。 试运行和验收口径必须准确。本地构建、Playwright 和截图通过只代表本地验证通过;只有完成部署、公网访问检查、关键路径冒烟和必要截图证据后,才可告知用户 `可以线上测试`。未部署时只能说明 `本地验证通过,尚未线上可测`。
@@ -129,8 +129,8 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
- PC 请假入口默认表达为 `请假记录`;提交请假、补卡、外出、加班仍优先走企业微信或移动入口,只有 OA 应急接管或指定管理场景才突出提交入口。 - PC 请假入口默认表达为 `请假记录`;提交请假、补卡、外出、加班仍优先走企业微信或移动入口,只有 OA 应急接管或指定管理场景才突出提交入口。
- 移动请假默认是记录查看和状态确认;普通员工不显示 OA 新建请假表单、编辑和提交动作。HR/管理员应急接管时才显示 `OA 应急` 表单和 `提交应急审批`。 - 移动请假默认是记录查看和状态确认;普通员工不显示 OA 新建请假表单、编辑和提交动作。HR/管理员应急接管时才显示 `OA 应急` 表单和 `提交应急审批`。
- 考勤页普通员工只看月度摘要、异常解释、同步/留痕状态和企微处理说明;`OA 应急修正` 只给 HR/管理员处理企微不可用时的应急接管。 - 考勤页普通员工只看月度摘要、异常解释、同步/留痕状态和企微处理说明;`OA 应急修正` 只给 HR/管理员处理企微不可用时的应急接管。
- 管理配置类能力在侧边栏合并为 `管理`,具体人员、组织、流程、规则、审计、功能开关和外部系统入口进入管理页后分组承接。 - 管理配置类能力在侧边栏合并为 `管理`,具体人员、组织、流程、规则、审计、功能开关和真实已启用外部能力进入管理页后分组承接。
- 财务入口应使用真实工作名,例如 `付款`、`应付账款`、`票据`;`AP 运营`、`ERP 集成`、`功能开关` 等内部平台词不作为默认一级菜单文案。 - 财务入口应使用真实工作名,例如 `付款`、`应付账款`、`票据`;`AP 运营`、`功能开关` 等内部平台词不作为默认一级菜单文案,`ERP 集成` 不作为产品入口。
- `更多` 只承载低频、冻结、阶段性或偶尔查看的入口,不使用说明型副标题、教学卡片或历史模块解释文案;不得把常用业务台账作为默认二级入口。 - `更多` 只承载低频、冻结、阶段性或偶尔查看的入口,不使用说明型副标题、教学卡片或历史模块解释文案;不得把常用业务台账作为默认二级入口。
## 业务体验事实 ## 业务体验事实
@@ -158,7 +158,7 @@ function renderMarkdown(report) {
lines.push("- 阻塞 3:"); lines.push("- 阻塞 3:");
lines.push(""); lines.push("");
lines.push("## 下周调整"); lines.push("## 下周调整");
lines.push("- 是否继续隐藏考勤、采购、差旅、预算、AP、ERP、AI 等备用模块:"); lines.push("- 是否继续隐藏考勤、采购、差旅、预算、AP、AI 等备用模块:");
lines.push("- 是否需要补充培训、公告提醒或管理员代配置:"); lines.push("- 是否需要补充培训、公告提醒或管理员代配置:");
lines.push("- 是否达到试点预期:是 / 否"); lines.push("- 是否达到试点预期:是 / 否");
return `${lines.join("\n")}\n`; return `${lines.join("\n")}\n`;
-7
查看文件
@@ -255,13 +255,6 @@ const DEFAULT_FEATURE_FLAGS = [
department_ids: [], department_ids: [],
note: "bootstrap default: force_off", note: "bootstrap default: force_off",
}, },
{
flag_key: "erp_sync",
override_mode: "force_off",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_off",
},
{ {
flag_key: "invoice_verification", flag_key: "invoice_verification",
override_mode: "force_off", override_mode: "force_off",
+24 -26
查看文件
@@ -317,7 +317,6 @@ async function ensureWorkspaceSetting(prisma) {
paymentBatches: "admin", paymentBatches: "admin",
apOperations: "admin", apOperations: "admin",
cutover: "admin", cutover: "admin",
erpIntegrations: "admin",
}, },
}; };
@@ -344,7 +343,6 @@ async function ensureEmployee(prisma, payload) {
const employee = await prisma.employee.update({ const employee = await prisma.employee.update({
where: { id: existing.id }, where: { id: existing.id },
data: { data: {
employee_no: payload.employeeNo,
name: payload.name, name: payload.name,
phone: payload.phone, phone: payload.phone,
department_id: payload.departmentId, department_id: payload.departmentId,
@@ -480,8 +478,8 @@ async function main() {
} }
const ceo = await ensureEmployee(prisma, { const ceo = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_CEO_EMPLOYEE_NO ?? "E0001", employeeNo: process.env.SEED_CEO_EMPLOYEE_NO ?? "HYD000001",
name: "CEO", name: "张总",
phone: process.env.SEED_CEO_PHONE ?? "13900000000", phone: process.env.SEED_CEO_PHONE ?? "13900000000",
email: process.env.SEED_CEO_EMAIL ?? "[email protected]", email: process.env.SEED_CEO_EMAIL ?? "[email protected]",
password: process.env.SEED_CEO_PASSWORD ?? "CeoPassword1234", password: process.env.SEED_CEO_PASSWORD ?? "CeoPassword1234",
@@ -494,8 +492,8 @@ async function main() {
}); });
const admin = await ensureEmployee(prisma, { const admin = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_ADMIN_EMPLOYEE_NO ?? "E0000", employeeNo: process.env.SEED_ADMIN_EMPLOYEE_NO ?? "HYD000006",
name: "Admin", name: "王顺利",
phone: process.env.SEED_ADMIN_PHONE ?? "13800000000", phone: process.env.SEED_ADMIN_PHONE ?? "13800000000",
email: process.env.SEED_ADMIN_EMAIL ?? "[email protected]", email: process.env.SEED_ADMIN_EMAIL ?? "[email protected]",
password: process.env.SEED_ADMIN_PASSWORD ?? "AdminPassword1234", password: process.env.SEED_ADMIN_PASSWORD ?? "AdminPassword1234",
@@ -508,8 +506,8 @@ async function main() {
}); });
const manager = await ensureEmployee(prisma, { const manager = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_MANAGER_EMPLOYEE_NO ?? "E0100", employeeNo: process.env.SEED_MANAGER_EMPLOYEE_NO ?? "HYD000002",
name: "Manager", name: "李研发",
phone: process.env.SEED_MANAGER_PHONE ?? "13700000000", phone: process.env.SEED_MANAGER_PHONE ?? "13700000000",
email: process.env.SEED_MANAGER_EMAIL ?? "[email protected]", email: process.env.SEED_MANAGER_EMAIL ?? "[email protected]",
password: process.env.SEED_MANAGER_PASSWORD ?? "ManagerPassword1234", password: process.env.SEED_MANAGER_PASSWORD ?? "ManagerPassword1234",
@@ -522,8 +520,8 @@ async function main() {
}); });
const employee = await ensureEmployee(prisma, { const employee = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_EMPLOYEE_EMPLOYEE_NO ?? "E0101", employeeNo: process.env.SEED_EMPLOYEE_EMPLOYEE_NO ?? "HYD000003",
name: "Employee", name: "陈工程",
phone: process.env.SEED_EMPLOYEE_PHONE ?? "13600000000", phone: process.env.SEED_EMPLOYEE_PHONE ?? "13600000000",
email: process.env.SEED_EMPLOYEE_EMAIL ?? "[email protected]", email: process.env.SEED_EMPLOYEE_EMAIL ?? "[email protected]",
password: process.env.SEED_EMPLOYEE_PASSWORD ?? "EmployeePassword1234", password: process.env.SEED_EMPLOYEE_PASSWORD ?? "EmployeePassword1234",
@@ -536,8 +534,8 @@ async function main() {
}); });
const finance = await ensureEmployee(prisma, { const finance = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_FINANCE_EMPLOYEE_NO ?? "E0200", employeeNo: process.env.SEED_FINANCE_EMPLOYEE_NO ?? "HYD000004",
name: "Finance", name: "赵财务",
phone: process.env.SEED_FINANCE_PHONE ?? "13500000000", phone: process.env.SEED_FINANCE_PHONE ?? "13500000000",
email: process.env.SEED_FINANCE_EMAIL ?? "[email protected]", email: process.env.SEED_FINANCE_EMAIL ?? "[email protected]",
password: process.env.SEED_FINANCE_PASSWORD ?? "FinancePassword1234", password: process.env.SEED_FINANCE_PASSWORD ?? "FinancePassword1234",
@@ -550,8 +548,8 @@ async function main() {
}); });
const financeMgr = await ensureEmployee(prisma, { const financeMgr = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_FINANCE_MGR_EMPLOYEE_NO ?? "E0201", employeeNo: process.env.SEED_FINANCE_MGR_EMPLOYEE_NO ?? "HYD000005",
name: "Finance Manager", name: "钱主管",
phone: process.env.SEED_FINANCE_MGR_PHONE ?? "13400000000", phone: process.env.SEED_FINANCE_MGR_PHONE ?? "13400000000",
email: process.env.SEED_FINANCE_MGR_EMAIL ?? "[email protected]", email: process.env.SEED_FINANCE_MGR_EMAIL ?? "[email protected]",
password: process.env.SEED_FINANCE_MGR_PASSWORD ?? "FinanceMgrPassword1234", password: process.env.SEED_FINANCE_MGR_PASSWORD ?? "FinanceMgrPassword1234",
@@ -564,8 +562,8 @@ async function main() {
}); });
const hr = await ensureEmployee(prisma, { const hr = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_HR_EMPLOYEE_NO ?? "E0300", employeeNo: process.env.SEED_HR_EMPLOYEE_NO ?? "HYD000007",
name: "HR", name: "孙人事",
phone: process.env.SEED_HR_PHONE ?? "13300000000", phone: process.env.SEED_HR_PHONE ?? "13300000000",
email: process.env.SEED_HR_EMAIL ?? "[email protected]", email: process.env.SEED_HR_EMAIL ?? "[email protected]",
password: process.env.SEED_HR_PASSWORD ?? "HrPassword1234", password: process.env.SEED_HR_PASSWORD ?? "HrPassword1234",
@@ -578,8 +576,8 @@ async function main() {
}); });
const administration = await ensureEmployee(prisma, { const administration = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_ADMINISTRATION_EMPLOYEE_NO ?? "E0400", employeeNo: process.env.SEED_ADMINISTRATION_EMPLOYEE_NO ?? "HYD000008",
name: "Administration", name: "周行政",
phone: process.env.SEED_ADMINISTRATION_PHONE ?? "13200000000", phone: process.env.SEED_ADMINISTRATION_PHONE ?? "13200000000",
email: process.env.SEED_ADMINISTRATION_EMAIL ?? "[email protected]", email: process.env.SEED_ADMINISTRATION_EMAIL ?? "[email protected]",
password: process.env.SEED_ADMINISTRATION_PASSWORD ?? "AdministrationPassword1234", password: process.env.SEED_ADMINISTRATION_PASSWORD ?? "AdministrationPassword1234",
@@ -664,21 +662,21 @@ async function main() {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log("Seeded dev users:"); console.log("Seeded dev users:");
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`- CEO: ${ceo.email} / ${process.env.SEED_CEO_PASSWORD ?? "CeoPassword1234"}`); console.log(`- 负责人: ${ceo.email} / ${process.env.SEED_CEO_PASSWORD ?? "CeoPassword1234"}`);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`- Admin: ${admin.email} / ${process.env.SEED_ADMIN_PASSWORD ?? "AdminPassword1234"}`); console.log(`- 管理员: ${admin.email} / ${process.env.SEED_ADMIN_PASSWORD ?? "AdminPassword1234"}`);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`- Manager: ${manager.email} / ${process.env.SEED_MANAGER_PASSWORD ?? "ManagerPassword1234"}`); console.log(`- 研发负责人: ${manager.email} / ${process.env.SEED_MANAGER_PASSWORD ?? "ManagerPassword1234"}`);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`- Employee: ${employee.email} / ${process.env.SEED_EMPLOYEE_PASSWORD ?? "EmployeePassword1234"}`); console.log(`- 员工: ${employee.email} / ${process.env.SEED_EMPLOYEE_PASSWORD ?? "EmployeePassword1234"}`);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`- Finance: ${finance.email} / ${process.env.SEED_FINANCE_PASSWORD ?? "FinancePassword1234"}`); console.log(`- 财务: ${finance.email} / ${process.env.SEED_FINANCE_PASSWORD ?? "FinancePassword1234"}`);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`- Finance Manager: ${financeMgr.email} / ${process.env.SEED_FINANCE_MGR_PASSWORD ?? "FinanceMgrPassword1234"}`); console.log(`- 财务主管: ${financeMgr.email} / ${process.env.SEED_FINANCE_MGR_PASSWORD ?? "FinanceMgrPassword1234"}`);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`- HR: ${hr.email} / ${process.env.SEED_HR_PASSWORD ?? "HrPassword1234"}`); console.log(`- 人事: ${hr.email} / ${process.env.SEED_HR_PASSWORD ?? "HrPassword1234"}`);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`- Administration: ${administration.email} / ${process.env.SEED_ADMINISTRATION_PASSWORD ?? "AdministrationPassword1234"}`); console.log(`- 行政: ${administration.email} / ${process.env.SEED_ADMINISTRATION_PASSWORD ?? "AdministrationPassword1234"}`);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`- Legal Entity: ${legalEntity.code} / ${legalEntity.name}`); console.log(`- Legal Entity: ${legalEntity.code} / ${legalEntity.name}`);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
+2 -5
查看文件
@@ -24,7 +24,6 @@ const DEFAULT_FORCE_ON_FLAGS = Object.freeze([
]); ]);
const OPTIONAL_FORCE_ON_FLAGS = new Set([ const OPTIONAL_FORCE_ON_FLAGS = new Set([
"erp_sync",
"budget_precheck_v1", "budget_precheck_v1",
"payment_batch_v1", "payment_batch_v1",
"workflow_definition_admin_v1", "workflow_definition_admin_v1",
@@ -164,7 +163,6 @@ async function ensureWorkspaceSetting(prisma) {
paymentBatches: "admin", paymentBatches: "admin",
apOperations: "admin", apOperations: "admin",
cutover: "admin", cutover: "admin",
erpIntegrations: "admin",
}, },
}, },
}); });
@@ -199,7 +197,6 @@ async function ensureWorkspaceSetting(prisma) {
paymentBatches: "admin", paymentBatches: "admin",
apOperations: "admin", apOperations: "admin",
cutover: "admin", cutover: "admin",
erpIntegrations: "admin",
}, },
}, },
}); });
@@ -326,8 +323,8 @@ async function main() {
email: adminEmail, email: adminEmail,
phone: adminPhone, phone: adminPhone,
password: adminPassword || null, password: adminPassword || null,
employeeNo: process.env.TRIALRUN_ADMIN_EMPLOYEE_NO ?? "T0000", employeeNo: process.env.TRIALRUN_ADMIN_EMPLOYEE_NO ?? "HYD000006",
name: process.env.TRIALRUN_ADMIN_NAME ?? "Trialrun Admin", name: process.env.TRIALRUN_ADMIN_NAME ?? "王顺利",
title: process.env.TRIALRUN_ADMIN_TITLE ?? "系统管理员", title: process.env.TRIALRUN_ADMIN_TITLE ?? "系统管理员",
departmentId: management.id, departmentId: management.id,
hireDate: new Date(process.env.TRIALRUN_ADMIN_HIRE_DATE ?? "2020-01-01"), hireDate: new Date(process.env.TRIALRUN_ADMIN_HIRE_DATE ?? "2020-01-01"),
-3
查看文件
@@ -31,7 +31,6 @@ const MANAGEMENT_CHILD_MODULES = new Set([
"masterData", "masterData",
"notificationChannels", "notificationChannels",
"cutover", "cutover",
"erpIntegrations",
]); ]);
const BACKUP_NAVIGATION_MODULES = new Set([ const BACKUP_NAVIGATION_MODULES = new Set([
"weeklyReports", "weeklyReports",
@@ -104,7 +103,6 @@ const NAVIGATION_REGISTRY = Object.freeze({
invoicePool: { key: "invoicePool", label: "票据", icon: "receipt_long", href: "/invoice-pool", anyOf: ["invoice.manage"] }, invoicePool: { key: "invoicePool", label: "票据", icon: "receipt_long", href: "/invoice-pool", anyOf: ["invoice.manage"] },
inventoryChecks: { key: "inventoryChecks", label: "资产盘点", icon: "inventory", href: "/inventory-checks", anyOf: ["asset.inventory"] }, inventoryChecks: { key: "inventoryChecks", label: "资产盘点", icon: "inventory", href: "/inventory-checks", anyOf: ["asset.inventory"] },
cutover: { key: "cutover", label: "切换控制台", icon: "swap_calls", href: "/system/cutover", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.cutoverConsole }, cutover: { key: "cutover", label: "切换控制台", icon: "swap_calls", href: "/system/cutover", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.cutoverConsole },
erpIntegrations: { key: "erpIntegrations", label: "外部系统", icon: "hub", href: "/system/integrations/erp", anyOf: ["integration.manage"] },
profile: { key: "profile", label: "个人设置", icon: "manage_accounts", href: "/settings/profile", anyOf: [] }, profile: { key: "profile", label: "个人设置", icon: "manage_accounts", href: "/settings/profile", anyOf: [] },
}); });
@@ -144,7 +142,6 @@ const DEFAULT_MODULE_VISIBILITY = Object.freeze({
invoicePool: "admin", invoicePool: "admin",
inventoryChecks: "admin", inventoryChecks: "admin",
cutover: "admin", cutover: "admin",
erpIntegrations: "admin",
profile: "hidden", profile: "hidden",
}); });