修正 ERP 边界与资产页面一致性(05-30-fix-erp-boundary-asset-consistency)
这个提交包含在:
@@ -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
|
||||
bell and notification center.
|
||||
- Finance labels should use work names such as `付款`, `应付账款` and `票据`;
|
||||
internal platform words like `AP 运营`, `ERP 集成` and `功能开关` should be
|
||||
hidden behind management wording or renamed for users.
|
||||
internal platform words like `AP 运营` and `功能开关` should be hidden behind
|
||||
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
|
||||
occasional entries; do not add a sidebar explanation card, subtitle or
|
||||
onboarding copy around it.
|
||||
@@ -652,7 +653,7 @@ screen.
|
||||
- Procurement ledgers in V1 are request, approval, attachment, light
|
||||
acceptance/archive and relation surfaces for project, contract and payment/AP
|
||||
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,
|
||||
received into inventory or converted into a fixed asset.
|
||||
- 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,
|
||||
AP-status, invoice/OCR-status and agency-accounting handover summaries. Do
|
||||
not design payment, AP or invoice pages as online banking, bank-statement
|
||||
ingestion, ERP/general-ledger, voucher-generation, auto-reconciliation, tax
|
||||
filing or full accounting software. Real payment stays in offline banking or
|
||||
existing finance/accounting workflows; OA records status, evidence and
|
||||
handover summaries.
|
||||
ingestion, general-ledger, voucher-generation, auto-reconciliation, tax filing
|
||||
or full accounting software. Real payment stays in offline banking or existing
|
||||
finance/accounting workflows; OA records status, evidence and handover
|
||||
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: `付款`
|
||||
shows review, payment-marking, receipt and exception attention before batch
|
||||
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
|
||||
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
|
||||
protected, such as computers, monitors, disks, equipment and security media.
|
||||
Do not create `FA` cards for ordinary consumables.
|
||||
@@ -738,9 +741,10 @@ subsystem.
|
||||
- Move depreciation, residual value, accounting subject and monthly
|
||||
depreciation voucher concepts out of the OA primary UI unless a later Trellis
|
||||
task explicitly scopes accounting-ledger work.
|
||||
- Keep classification maintenance, ERP sync, raw metadata and advanced filters
|
||||
behind management, collapsed or advanced surfaces so they do not dominate the
|
||||
asset list/detail first screen.
|
||||
- Keep classification maintenance, raw metadata and advanced filters behind
|
||||
management, collapsed or advanced surfaces so they do not dominate the asset
|
||||
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
|
||||
serial number input. Do not require heavy photo inventory unless a later task
|
||||
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
|
||||
punch, outing and overtime. OA shows mirror, history, exception, audit and
|
||||
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
|
||||
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
|
||||
relevant external system has returned a clear success acknowledgement.
|
||||
- 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`.
|
||||
- Do not expose raw external error codes, payloads, full URLs, secrets, tokens
|
||||
or debug traces to ordinary users.
|
||||
- External writes, payment execution, ERP sync, Enterprise WeChat resend and OCR
|
||||
retry must be controlled by permissions and state-driven actions.
|
||||
- External writes, payment execution, Enterprise WeChat resend and OCR retry
|
||||
must be controlled by permissions and state-driven actions.
|
||||
- Retry confirmation should explain which object will be retried, whether it may
|
||||
send duplicate external messages, external-system impact and audit effect.
|
||||
- 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.
|
||||
- Enterprise WeChat messages carry desensitized summary and OA deeplink only;
|
||||
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
|
||||
confirmed, UI copy must not imply the capability already completed.
|
||||
- If bank, agency accounting or another enabled integration is not connected or
|
||||
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
|
||||
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 build`
|
||||
- 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.
|
||||
|
||||
## 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": {}
|
||||
}
|
||||
@@ -358,7 +358,7 @@ export function mapApiErrorMessage(
|
||||
}
|
||||
|
||||
if (details?.integration === "erp_sync_disabled") {
|
||||
return "ERP 同步功能未开启。";
|
||||
return "该同步功能未开启。";
|
||||
}
|
||||
|
||||
if (details?.integration === "travel_sync_disabled") {
|
||||
|
||||
@@ -56,7 +56,6 @@ const MANAGEMENT_ACTIVE_PATHS = [
|
||||
"/system/reimbursement-templates",
|
||||
"/system/master-data",
|
||||
"/system/notification-channels",
|
||||
"/system/integrations/erp",
|
||||
"/system/users",
|
||||
"/system/departments",
|
||||
"/system/cutover",
|
||||
|
||||
@@ -178,7 +178,9 @@ export function Topbar() {
|
||||
</span>
|
||||
) : null}
|
||||
</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>
|
||||
|
||||
@@ -293,8 +295,8 @@ export function Topbar() {
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button data-testid="topbar-profile-trigger" variant="secondary" type="button" className="max-w-[160px] truncate">
|
||||
{me?.name ?? "账号"}
|
||||
<Button data-testid="topbar-profile-trigger" variant="secondary" type="button" className="max-w-[190px] truncate">
|
||||
{me ? `${me.name} · ${me.employeeNo}` : "账号"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export function TravelOrderMarkPaidDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>标记差旅订单已支付</DialogTitle>
|
||||
<DialogDescription>
|
||||
确认订单 `{order?.externalOrderNo ?? "-"}` 已完成对公支付;系统会按幂等键复用现有 ERP journal 出站记录。
|
||||
确认订单 `{order?.externalOrderNo ?? "-"}` 已完成对公支付;系统会按幂等键复用现有财务交接记录。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ function erpStageLabel(order: TravelPaymentOrder) {
|
||||
if (order.erpStage.binding?.externalId) {
|
||||
return "已同步";
|
||||
}
|
||||
return "ERP 出站未开启";
|
||||
return "摘要导出未开启";
|
||||
}
|
||||
|
||||
type TravelPaymentTrackCardProps = {
|
||||
@@ -178,7 +178,7 @@ export function TravelPaymentTrackCard({ travelRequestId }: TravelPaymentTrackCa
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<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>
|
||||
{canManagePayments ? <span className="text-xs text-zinc-500">具备支付管理权限</span> : null}
|
||||
</div>
|
||||
@@ -257,7 +257,7 @@ export function TravelPaymentTrackCard({ travelRequestId }: TravelPaymentTrackCa
|
||||
<div>金额:{order.amount} {order.currency}</div>
|
||||
<div>付款凭证:{order.paymentVoucherNo ?? "-"}</div>
|
||||
<div>付款日期:{order.paymentDate ?? "-"} · 渠道:{paymentChannelLabel(order.paymentChannel)}</div>
|
||||
<div>ERP 状态:{erpStageLabel(order)}</div>
|
||||
<div>导出状态:{erpStageLabel(order)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{canManagePayments ? (
|
||||
|
||||
@@ -244,7 +244,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] });
|
||||
toast.success('ERP 摘要桥已重新入队');
|
||||
toast.success('交接摘要已重新入队');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveError(error));
|
||||
@@ -418,7 +418,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
|
||||
) : null}
|
||||
|
||||
{!selectedBatchId ? (
|
||||
<EmptyStateCard title="请选择对账批次" description="从左侧选择一个批次查看 KPI、异常项与 ERP 摘要桥轨道。" />
|
||||
<EmptyStateCard title="请选择对账批次" description="从左侧选择一个批次查看 KPI、异常项与交接摘要。" />
|
||||
) : null}
|
||||
|
||||
{detail ? (
|
||||
@@ -445,7 +445,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
|
||||
) : null}
|
||||
{track?.permissions.canRetry ? (
|
||||
<Button type="button" variant="secondary" onClick={() => requeueMutation.mutate()} disabled={requeueMutation.isPending}>
|
||||
{requeueMutation.isPending ? '重试中...' : 'Requeue ERP'}
|
||||
{requeueMutation.isPending ? '重试中...' : '重试导出'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -473,14 +473,14 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">ERP 摘要桥</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">批次级 `erp.travel.export` 轨道,不会回改订单、申请或预算原值。</p>
|
||||
<h3 className="text-base font-semibold text-zinc-900">交接摘要</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">批次级摘要导出轨道,不会回改订单、申请或预算原值。</p>
|
||||
</div>
|
||||
|
||||
{trackQuery.isLoading ? <CardSkeleton lines={4} /> : null}
|
||||
{trackQuery.isError ? (
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/system/settings", title: "管理", group: "管理" },
|
||||
{ path: "/system/feature-flags", title: "功能管理", group: "管理" },
|
||||
{ path: "/system/cutover", title: "切换控制台", group: "更多" },
|
||||
{ path: "/system/integrations/erp", title: "外部系统", group: "更多" },
|
||||
{ path: "/dashboard", title: "数据看板", group: "更多" },
|
||||
{ path: "/audit-logs", title: "审计日志", group: "管理" },
|
||||
];
|
||||
|
||||
+192
-353
@@ -10,16 +10,12 @@ import {
|
||||
fetchAssetAssignees,
|
||||
fetchAssetCategories,
|
||||
fetchAssetDetail,
|
||||
fetchAssetErpTrack,
|
||||
fetchAssets,
|
||||
patchAsset,
|
||||
patchAssetCategory,
|
||||
requeueAssetErpSync,
|
||||
returnAsset,
|
||||
transferAsset,
|
||||
type AssetCategoryItem,
|
||||
type AssetErpStage,
|
||||
type AssetErpTrack,
|
||||
type AssetItem,
|
||||
type AssetSensitivityLevel,
|
||||
type AssetStatus,
|
||||
@@ -30,11 +26,12 @@ import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities } from ".
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
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 ?? "未知状态";
|
||||
}
|
||||
|
||||
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 }> {
|
||||
return [
|
||||
{ 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) {
|
||||
return assetType !== "fixed_asset";
|
||||
}
|
||||
|
||||
function assetNextStep(item: Pick<AssetItem, "assetType" | "assignee" | "owner" | "status" | "lastVerifiedAt" | "expiresAt">) {
|
||||
if (item.status === "archived") return "已归档留存";
|
||||
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 formatEmployeeSummary(employee: { name: string; employeeNo: string } | null | undefined) {
|
||||
return employee ? `${employee.name} · ${employee.employeeNo}` : "-";
|
||||
}
|
||||
|
||||
function CompactAssetNo({ value, copyable = true }: { value: string; copyable?: boolean }) {
|
||||
@@ -231,37 +192,6 @@ function unsafeAssetTextReason(value: string) {
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -270,7 +200,6 @@ export function AssetRegistryPage() {
|
||||
|
||||
const canManage = hasPermission(permissions, "asset.manage");
|
||||
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 canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]);
|
||||
|
||||
@@ -324,12 +253,6 @@ export function AssetRegistryPage() {
|
||||
queryFn: () => fetchAssetDetail(selectedAssetId),
|
||||
});
|
||||
|
||||
const assetErpTrackQuery = useQuery({
|
||||
enabled: canIntegrationManage && Boolean(selectedAssetId) && !isCreatingAsset,
|
||||
queryKey: ["assets", "erp-track", selectedAssetId],
|
||||
queryFn: () => fetchAssetErpTrack(selectedAssetId),
|
||||
});
|
||||
|
||||
const legalEntitiesQuery = useQuery({
|
||||
queryKey: ["assets", "legal-entities"],
|
||||
queryFn: () => fetchLegalEntities({ enabled: true }),
|
||||
@@ -442,9 +365,6 @@ export function AssetRegistryPage() {
|
||||
}, [costCentersQuery.data?.items, legalEntityFilter]);
|
||||
|
||||
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);
|
||||
|
||||
function resetCategoryEditor(category?: AssetCategoryItem) {
|
||||
@@ -612,7 +532,6 @@ export function AssetRegistryPage() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "erp-track"] }),
|
||||
]);
|
||||
setAssetFormError("");
|
||||
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({
|
||||
mutationFn: async () => {
|
||||
if (!currentAsset) {
|
||||
@@ -819,6 +714,11 @@ export function AssetRegistryPage() {
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{canManage ? (
|
||||
<Button type="button" data-testid="asset-create" onClick={resetAssetEditor}>
|
||||
新增资产
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
|
||||
移动扫码查找
|
||||
</Button>
|
||||
@@ -835,15 +735,6 @@ export function AssetRegistryPage() {
|
||||
<div className="grid gap-6">
|
||||
{showLedgerTools ? (
|
||||
<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">
|
||||
<h3 className="text-base font-semibold text-zinc-900">资产分类</h3>
|
||||
{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>
|
||||
<PrimaryTable className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>资产</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>责任链</TableHead>
|
||||
<TableHead>位置</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="w-24 text-right">操作</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orderedAssetItems.length ? (
|
||||
orderedAssetItems.map((item) => (
|
||||
<TableRow data-testid="asset-row" key={item.id} onClick={() => {
|
||||
selectAsset(item.id);
|
||||
}} className="cursor-pointer">
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">
|
||||
<CompactAssetNo value={item.assetNo} />
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500" title={item.name}>
|
||||
{formatTextWithCompactLongNumbers(item.name)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{assetTypeLabel(item.assetType)}</TableCell>
|
||||
<TableCell>
|
||||
<div>{item.owner?.name ?? item.assignee?.name ?? "-"}</div>
|
||||
<div className="text-xs text-zinc-500">保管 {item.keeper?.name ?? "-"}</div>
|
||||
</TableCell>
|
||||
<TableCell title={item.location ?? undefined}>
|
||||
<div>{formatTextWithCompactLongNumbers(item.location ?? "-")}</div>
|
||||
<div className="text-xs text-zinc-500">盘点 {item.lastVerifiedAt ? formatDisplayDateTime(item.lastVerifiedAt) : "未登记"}</div>
|
||||
</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => selectAsset(item.id)}>
|
||||
查看详情
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-4 text-zinc-500">
|
||||
暂无资产卡片
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<PrimaryTable className="hidden md:block ops-table-focus">
|
||||
<table className="w-full border-collapse text-left" data-testid="assets-table">
|
||||
<thead className="table-header">
|
||||
<tr>
|
||||
<th className="table-th">资产</th>
|
||||
<th className="table-th">状态</th>
|
||||
<th className="table-th">责任人</th>
|
||||
<th className="table-th">保管/位置</th>
|
||||
<th className="table-th">盘点</th>
|
||||
<th className="table-th w-24">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-50">
|
||||
{orderedAssetItems.length ? (
|
||||
orderedAssetItems.map((item) => (
|
||||
<tr className="table-row" data-testid="asset-row" key={item.id}>
|
||||
<td className="table-td">
|
||||
<div className="font-semibold text-zinc-900">
|
||||
<CompactAssetNo value={item.assetNo} />
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500" title={item.name}>
|
||||
{formatTextWithCompactLongNumbers(item.name)}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">{assetTypeLabel(item.assetType)}</div>
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<StatusBadge status={item.status} />
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<div className="font-medium text-zinc-900">{formatEmployeeSummary(item.owner ?? item.assignee)}</div>
|
||||
<div className="text-xs text-zinc-500">归属 {formatEmployeeSummary(item.assignee)}</div>
|
||||
</td>
|
||||
<td className="table-td" title={item.location ?? undefined}>
|
||||
<div className="font-medium text-zinc-900">{formatEmployeeSummary(item.keeper)}</div>
|
||||
<div className="text-xs text-zinc-500">{formatTextWithCompactLongNumbers(item.location ?? "-")}</div>
|
||||
</td>
|
||||
<td className="table-td">
|
||||
{item.lastVerifiedAt ? formatDisplayDateTime(item.lastVerifiedAt) : "未登记"}
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => selectAsset(item.id)}>
|
||||
查看详情
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-sm text-zinc-500">
|
||||
暂无资产卡片
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</PrimaryTable>
|
||||
</>
|
||||
) : null}
|
||||
@@ -1146,7 +1040,8 @@ export function AssetRegistryPage() {
|
||||
</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.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>
|
||||
@@ -1182,19 +1077,19 @@ export function AssetRegistryPage() {
|
||||
<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="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 className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
|
||||
<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 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="mt-1 font-medium text-zinc-900">{currentAsset.lastVerifiedAt ? formatDisplayDateTime(currentAsset.lastVerifiedAt) : "未登记"}</div>
|
||||
</div>
|
||||
<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="mt-1 font-medium text-zinc-900">{assetNextStep(currentAsset)}</div>
|
||||
<div className="text-xs text-zinc-500">状态</div>
|
||||
<div className="mt-1"><StatusBadge status={currentAsset.status} /></div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1203,7 +1098,6 @@ export function AssetRegistryPage() {
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900">基础信息</h4>
|
||||
<p className="text-xs text-zinc-500">维护资产主标识、类型、分类和组织归属。</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
@@ -1219,7 +1113,6 @@ export function AssetRegistryPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-zinc-500">{typeHint(assetForm.assetType)}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900">责任与归档</h4>
|
||||
<p className="text-xs text-zinc-500">明确领用、负责、保管、位置和生命周期日期。</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-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">
|
||||
<div>
|
||||
<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 className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
@@ -1403,183 +1295,130 @@ export function AssetRegistryPage() {
|
||||
</section>
|
||||
) : null}
|
||||
</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}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,6 @@ const ROLLOUT_TYPE_OPTIONS: Array<{ value: FeatureFlagRolloutType; label: string
|
||||
|
||||
function toDisplayName(flagKey: string) {
|
||||
if (flagKey === "ai_assist") return "AI 辅助";
|
||||
if (flagKey === "erp_sync") return "ERP 同步";
|
||||
if (flagKey === "invoice_verification") return "发票验真";
|
||||
if (flagKey === "leave_holiday_calendar") return "请假日历增强";
|
||||
if (flagKey === "budget_precheck_v1") return "预算预校验";
|
||||
@@ -259,7 +258,7 @@ export function FeatureFlagsPage() {
|
||||
return map;
|
||||
}, [departmentsQuery.data?.items]);
|
||||
|
||||
const items = flagsQuery.data?.items ?? [];
|
||||
const items = (flagsQuery.data?.items ?? []).filter((item) => item.flagKey !== "erp_sync");
|
||||
const flagStats = {
|
||||
total: items.length,
|
||||
effectiveOn: items.filter((item) => item.effectiveEnabled).length,
|
||||
@@ -325,7 +324,7 @@ export function FeatureFlagsPage() {
|
||||
<ListWorkspace className={batchMode ? "pb-32" : undefined}>
|
||||
<WorkspaceHeader
|
||||
title="功能管理"
|
||||
subtitle="外部桥接、验真、灰度和试运行边界。"
|
||||
subtitle="验真、灰度和试运行边界。"
|
||||
/>
|
||||
<WorkspaceNotice title="安全摘要" tone="warning">
|
||||
Env 仍是硬闸;批量开启、关闭或灰度变更会影响试运行边界,执行前必须保留变更说明。
|
||||
|
||||
@@ -11,19 +11,14 @@ import {
|
||||
deletePurchaseAttachment,
|
||||
fetchPurchaseOrderArchive,
|
||||
fetchPurchaseRequestDetail,
|
||||
fetchPurchaseRequestErpTrack,
|
||||
fetchPurchaseRequests,
|
||||
issuePurchaseAttachmentSignedUrl,
|
||||
precheckPurchaseRequest,
|
||||
requeuePurchaseRequestErpSync,
|
||||
submitPurchaseRequest,
|
||||
updatePurchaseRequest,
|
||||
uploadPurchaseAttachment,
|
||||
withdrawPurchaseRequest,
|
||||
type PurchaseErpBinding,
|
||||
type PurchaseRequestDetail,
|
||||
type PurchaseRequestErpRequeueTarget,
|
||||
type PurchaseRequestErpTrack,
|
||||
type PurchaseRequestItemPayload,
|
||||
type PurchaseRequestStatus,
|
||||
} 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() {
|
||||
const queryClient = useQueryClient();
|
||||
const { me, permissions } = useAuth();
|
||||
@@ -372,7 +267,6 @@ export function PurchaseRequestsPage() {
|
||||
|
||||
const canCreate = hasPermission(permissions, "procurement.create");
|
||||
const canManage = hasPermission(permissions, "procurement.manage");
|
||||
const canIntegrationManage = hasPermission(permissions, "integration.manage");
|
||||
const canViewAll =
|
||||
hasPermission(permissions, "procurement.view_all") ||
|
||||
canManage;
|
||||
@@ -402,7 +296,6 @@ export function PurchaseRequestsPage() {
|
||||
const [archiveContractNo, setArchiveContractNo] = useState("");
|
||||
const [selectedAttachmentName, setSelectedAttachmentName] = useState("");
|
||||
const [submitPrecheckConfirm, setSubmitPrecheckConfirm] = useState<string | null>(null);
|
||||
const [erpRequeueConfirm, setErpRequeueConfirm] = useState<{ target: PurchaseRequestErpRequeueTarget; label: string } | null>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const suppliersQuery = useQuery({
|
||||
@@ -456,15 +349,6 @@ export function PurchaseRequestsPage() {
|
||||
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(() => {
|
||||
const searchSelectedId = searchParams.get("id") ?? "";
|
||||
if (searchSelectedId && searchSelectedId !== selectedId) {
|
||||
@@ -687,7 +571,6 @@ export function PurchaseRequestsPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
|
||||
]);
|
||||
setFormError("");
|
||||
toast.success(
|
||||
@@ -715,7 +598,6 @@ export function PurchaseRequestsPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
|
||||
]);
|
||||
setFormError("");
|
||||
toast.success("采购申请已撤回到草稿");
|
||||
@@ -780,7 +662,6 @@ export function PurchaseRequestsPage() {
|
||||
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] }),
|
||||
]);
|
||||
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({
|
||||
mutationFn: async (file: File) => {
|
||||
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 canEditDraft = Boolean(detail?.permissions.canEditDraft);
|
||||
const canSubmit = Boolean(detail?.permissions.canSubmit);
|
||||
@@ -1963,89 +1809,6 @@ export function PurchaseRequestsPage() {
|
||||
</div>
|
||||
) : 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="展示最近审批动作、退回或驳回说明,便于申请人按意见修改。">
|
||||
{detailApprovalHistory.length ? (
|
||||
<div className="space-y-2" data-testid="purchase-approval-history">
|
||||
@@ -2083,20 +1846,6 @@ export function PurchaseRequestsPage() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||
import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl";
|
||||
import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations";
|
||||
import { fetchProjects } from "../api/projects";
|
||||
import {
|
||||
createReimbursementItem,
|
||||
@@ -108,32 +107,6 @@ function workflowActionLabel(action: string) {
|
||||
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) {
|
||||
const labels: Record<string, string> = {
|
||||
block: "超额阻断",
|
||||
@@ -180,7 +153,6 @@ export function ReimbursementDetailPage() {
|
||||
const [itemError, setItemError] = useState("");
|
||||
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||||
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
|
||||
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
|
||||
const [showSecondaryDetail, setShowSecondaryDetail] = useState(false);
|
||||
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
|
||||
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
|
||||
@@ -215,8 +187,6 @@ export function ReimbursementDetailPage() {
|
||||
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 canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
||||
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
|
||||
@@ -229,12 +199,6 @@ export function ReimbursementDetailPage() {
|
||||
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({
|
||||
enabled:
|
||||
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({
|
||||
mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => {
|
||||
if (!reimbursementId) {
|
||||
@@ -809,8 +738,6 @@ export function ReimbursementDetailPage() {
|
||||
paidBy: null,
|
||||
};
|
||||
const displayCode = resolveReimbursementDisplayCode(detail);
|
||||
const isPaidReimbursement = detail.status === "paid";
|
||||
const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null);
|
||||
const budgetHint = detail.budgetHint;
|
||||
const canPay = hasPermission(permissions, "expense.finance.pay");
|
||||
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
||||
@@ -1732,72 +1659,6 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
|
||||
<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>
|
||||
</SectionPanel>
|
||||
|
||||
@@ -2058,41 +1919,6 @@ export function ReimbursementDetailPage() {
|
||||
</DialogContent>
|
||||
</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
|
||||
open={Boolean(submitConfirmPrecheck)}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -26,7 +26,6 @@ const ITEMS: ManagementItem[] = [
|
||||
{ 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/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"][] = ["人员与组织", "流程与规则", "系统与审计", "功能管理"];
|
||||
@@ -43,7 +42,7 @@ export function SystemSettingsPage() {
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="管理"
|
||||
subtitle="人员、组织、角色、审批规则、主数据、通知渠道、外部系统、功能开关和审计统一在这里处理。"
|
||||
subtitle="人员、组织、角色、审批规则、主数据、通知渠道、功能开关和审计统一在这里处理。"
|
||||
/>
|
||||
<div className="finance-status-strip" data-testid="system-settings-summary">
|
||||
<div className="finance-metric-card">
|
||||
|
||||
@@ -42,7 +42,6 @@ const MODULE_LABELS: Record<string, string> = {
|
||||
invoicePool: "票据",
|
||||
inventoryChecks: "资产盘点",
|
||||
cutover: "切换控制台",
|
||||
erpIntegrations: "外部系统",
|
||||
profile: "个人设置",
|
||||
};
|
||||
|
||||
@@ -83,9 +82,12 @@ export function WorkspaceSetupPage() {
|
||||
|
||||
const workspace = workspaceQuery.data;
|
||||
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 entries = Object.entries(workspace?.moduleVisibility ?? {});
|
||||
const entries = Object.entries(workspace?.moduleVisibility ?? {}).filter(([key]) => key !== "erpIntegrations");
|
||||
return {
|
||||
total: entries.length,
|
||||
main: entries.filter(([, mode]) => mode === "main").length,
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
EmployeeProfilePage,
|
||||
DelegationsPage,
|
||||
DepartmentsPage,
|
||||
ErpIntegrationPage,
|
||||
ExpenseStandardsPage,
|
||||
FeatureFlagsPage,
|
||||
InventoryChecksPage,
|
||||
@@ -563,15 +562,6 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/integrations/erp"
|
||||
element={
|
||||
<RequirePermission anyOf={["integration.manage"]}>
|
||||
{withRouteFallback(<ErpIntegrationPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/workspace"
|
||||
element={
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { ErpIntegrationPage as default } from "../../pages/ErpIntegrationPage";
|
||||
@@ -8,7 +8,6 @@ export const AssetRegistryPage = lazy(() => import("./AssetRegistryPage"));
|
||||
export const CutoverConsolePage = lazy(() => import("./CutoverConsolePage"));
|
||||
export const DelegationsPage = lazy(() => import("./DelegationsPage"));
|
||||
export const DepartmentsPage = lazy(() => import("./DepartmentsPage"));
|
||||
export const ErpIntegrationPage = lazy(() => import("./ErpIntegrationPage"));
|
||||
export const ExpenseStandardsPage = lazy(() => import("./ExpenseStandardsPage"));
|
||||
export const FeatureFlagsPage = lazy(() => import("./FeatureFlagsPage"));
|
||||
export const InventoryChecksPage = lazy(() => import("./InventoryChecksPage"));
|
||||
|
||||
@@ -39,7 +39,7 @@ function buildMe(user: MockUserKey) {
|
||||
return {
|
||||
id: "admin-policy-ack",
|
||||
employeeNo: "A001",
|
||||
name: "Admin",
|
||||
name: "王顺利",
|
||||
phone: "mock-phone-admin",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-admin",
|
||||
@@ -55,7 +55,7 @@ function buildMe(user: MockUserKey) {
|
||||
return {
|
||||
id: "employee-policy-ack",
|
||||
employeeNo: "E001",
|
||||
name: "Employee",
|
||||
name: "陈工程",
|
||||
phone: "mock-phone-employee",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-employee",
|
||||
@@ -85,7 +85,7 @@ function mapAnnouncementForUser(item: MockAnnouncement, currentUser: MockUserKey
|
||||
|
||||
function mapAnnouncementForAdmin(item: MockAnnouncement) {
|
||||
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" },
|
||||
];
|
||||
const pendingEmployees = item.requiresAcknowledgement
|
||||
|
||||
@@ -6,7 +6,7 @@ import { TEST_IDS } from "./helpers/testIds";
|
||||
const ADMIN = {
|
||||
identifier: "[email protected]",
|
||||
password: "AdminPassword1234",
|
||||
name: "Admin",
|
||||
name: "王顺利",
|
||||
};
|
||||
|
||||
test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇总", async ({ page }) => {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { openNavLink } from "./helpers/navigation";
|
||||
|
||||
const FINANCE_MGR = { identifier: "[email protected]", password: "FinanceMgrPassword1234", name: "Finance Manager" };
|
||||
const ADMINISTRATION = { identifier: "[email protected]", password: "AdministrationPassword1234", name: "Administration" };
|
||||
const FINANCE_MGR = { identifier: "[email protected]", password: "FinanceMgrPassword1234", name: "钱主管", employeeNo: "HYD000005" };
|
||||
const ADMINISTRATION = { identifier: "[email protected]", password: "AdministrationPassword1234", name: "周行政", employeeNo: "HYD000008" };
|
||||
|
||||
async function login(page: Page, user: { identifier: string; password: string }) {
|
||||
await page.goto("/login");
|
||||
@@ -130,26 +130,26 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
await page.goto(`/assets?id=${assetId}`);
|
||||
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 首次领用");
|
||||
if (await page.getByText(/当前归属人:未分配/).isVisible().catch(() => false)) {
|
||||
if (await page.getByText(/归属人:-/).isVisible().catch(() => false)) {
|
||||
await page.getByTestId("asset-assign-submit").click();
|
||||
} else {
|
||||
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 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 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-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 logout(page, ADMINISTRATION.name);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { apiGetData, apiPostData, createAuthenticatedApiClient, login } from "./
|
||||
const ADMIN = {
|
||||
identifier: "[email protected]",
|
||||
password: "AdminPassword1234",
|
||||
name: "Admin",
|
||||
name: "王顺利",
|
||||
};
|
||||
|
||||
type FeatureFlagItem = {
|
||||
|
||||
@@ -8,36 +8,42 @@ export type E2EUser = {
|
||||
identifier: string;
|
||||
password: string;
|
||||
name: string;
|
||||
employeeNo?: string;
|
||||
};
|
||||
|
||||
export const EMPLOYEE: E2EUser = {
|
||||
identifier: "[email protected]",
|
||||
password: "EmployeePassword1234",
|
||||
name: "Employee",
|
||||
name: "陈工程",
|
||||
employeeNo: "HYD000003",
|
||||
};
|
||||
|
||||
export const MANAGER: E2EUser = {
|
||||
identifier: "[email protected]",
|
||||
password: "ManagerPassword1234",
|
||||
name: "Manager",
|
||||
name: "李研发",
|
||||
employeeNo: "HYD000002",
|
||||
};
|
||||
|
||||
export const FINANCE: E2EUser = {
|
||||
identifier: "[email protected]",
|
||||
password: "FinancePassword1234",
|
||||
name: "Finance",
|
||||
name: "赵财务",
|
||||
employeeNo: "HYD000004",
|
||||
};
|
||||
|
||||
export const FINANCE_MGR: E2EUser = {
|
||||
identifier: "[email protected]",
|
||||
password: "FinanceMgrPassword1234",
|
||||
name: "Finance Manager",
|
||||
name: "钱主管",
|
||||
employeeNo: "HYD000005",
|
||||
};
|
||||
|
||||
export const ADMIN: E2EUser = {
|
||||
identifier: "[email protected]",
|
||||
password: "AdminPassword1234",
|
||||
name: "Admin",
|
||||
name: "王顺利",
|
||||
employeeNo: "HYD000006",
|
||||
};
|
||||
|
||||
export type MeResponse = {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { EMPLOYEE, MANAGER, login, type E2EUser } from "./helpers/auth";
|
||||
const LEAVE_CALENDAR_HR: E2EUser = {
|
||||
identifier: "[email protected]",
|
||||
password: "HrPassword1234",
|
||||
name: "HR",
|
||||
name: "孙人事",
|
||||
};
|
||||
|
||||
function envelope<T>(data: T) {
|
||||
|
||||
@@ -5,8 +5,8 @@ import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
const ADMIN = { identifier: "[email protected]", password: "AdminPassword1234", name: "Admin" };
|
||||
const MANAGER = { identifier: "[email protected]", password: "ManagerPassword1234", name: "Manager" };
|
||||
const ADMIN = { identifier: "[email protected]", password: "AdminPassword1234", name: "王顺利", employeeNo: "HYD000006" };
|
||||
const MANAGER = { identifier: "[email protected]", password: "ManagerPassword1234", name: "李研发", employeeNo: "HYD000002" };
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return {
|
||||
@@ -148,16 +148,23 @@ function mockNotificationList(page: import("@playwright/test").Page) {
|
||||
}
|
||||
|
||||
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) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
id: "employee-mock-1",
|
||||
employeeNo: "E001",
|
||||
employeeNo,
|
||||
name,
|
||||
phone: "13800000000",
|
||||
email: `${name.toLowerCase()}@hyinda.com`,
|
||||
email: `mock-${employeeNo.toLowerCase()}@hyinda.com`,
|
||||
departmentId: "dept-1",
|
||||
directManagerId: null,
|
||||
status: "active",
|
||||
@@ -260,7 +267,7 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
|
||||
const data = mode === "manager"
|
||||
? {
|
||||
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: "Manager" },
|
||||
greeting: { name: "李研发" },
|
||||
cards: [
|
||||
{ key: "pending_approvals", title: "待我处理", value: 3, href: "/workflow/tasks", 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" },
|
||||
greeting: { name: "Employee" },
|
||||
greeting: { name: "陈工程" },
|
||||
cards: [
|
||||
{ key: "my_todos", title: "我的待办", value: 4, href: "/todos", severity: "warning" },
|
||||
{ key: "today_presence", title: "今日状态", value: "在岗", href: "/team", severity: "normal" },
|
||||
@@ -325,10 +332,10 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
|
||||
peopleLifecycle: null,
|
||||
adoptionSummary: null,
|
||||
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: [
|
||||
{ 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 },
|
||||
],
|
||||
};
|
||||
@@ -386,7 +393,7 @@ test("邮箱验证码登录可发送验证码并进入工作台", async ({ page
|
||||
const verifyPayloads: unknown[] = [];
|
||||
|
||||
await mockGuestAuthChrome(page);
|
||||
await mockMe(page, ["employee"], "Employee");
|
||||
await mockMe(page, ["employee"], "陈工程");
|
||||
|
||||
await page.route("**/api/auth/email-login/code", async (route) => {
|
||||
codeRequests.push(route.request().postDataJSON());
|
||||
@@ -507,11 +514,11 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
||||
await mockPortalHome(page, "employee");
|
||||
await mockAttendanceToday(page);
|
||||
await mockNotificationList(page);
|
||||
await mockMe(page, ["employee"], "Employee");
|
||||
await mockMe(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("link", { name: /待办事项 4 条.*查看待办/ })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /本周周报待填写.*填写周报/ })).toBeVisible();
|
||||
@@ -527,7 +534,7 @@ test("Portal 管理视角按角色分发待处理和团队卡点", async ({ page
|
||||
await mockPortalHome(page, "manager");
|
||||
await mockAttendanceToday(page);
|
||||
await mockNotificationList(page);
|
||||
await mockMe(page, ["manager"], "Manager");
|
||||
await mockMe(page, ["manager"], "李研发");
|
||||
|
||||
await login(page, MANAGER);
|
||||
|
||||
@@ -552,7 +559,7 @@ test("Portal 新员工只显示轻量欢迎入口,不再显示快速上手清
|
||||
await mockPortalHome(page, "employee");
|
||||
await mockAttendanceToday(page);
|
||||
await mockNotificationList(page);
|
||||
await mockMe(page, ["employee"], "Employee");
|
||||
await mockMe(page, ["employee"], "陈工程");
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
@@ -584,7 +591,7 @@ test("个人设置默认只展示账号通知和企业微信,不展示权限 c
|
||||
await mockPortalHome(page, "employee");
|
||||
await mockAttendanceToday(page);
|
||||
await mockNotificationList(page);
|
||||
await mockMe(page, ["employee"], "Employee");
|
||||
await mockMe(page, ["employee"], "陈工程");
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
await page.goto("/settings/profile");
|
||||
@@ -646,7 +653,7 @@ test("Portal bootstrap refresh 首次瞬时失败后重试成功仍保留登录
|
||||
await mockPortalHome(page, "employee");
|
||||
await mockAttendanceToday(page);
|
||||
await mockNotificationList(page);
|
||||
await mockMe(page, ["employee"], "Employee");
|
||||
await mockMe(page, ["employee"], "陈工程");
|
||||
|
||||
await page.goto("/portal");
|
||||
|
||||
@@ -661,7 +668,7 @@ test("Portal 域管理角色也能看到团队卡点", async ({ page }) => {
|
||||
await mockPortalHome(page, "manager");
|
||||
await mockAttendanceToday(page);
|
||||
await mockNotificationList(page);
|
||||
await mockMe(page, ["administration"], "Administration");
|
||||
await mockMe(page, ["administration"], "周行政");
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
@@ -676,7 +683,7 @@ test("Portal 管理提醒正常时不显示空壳", async ({ page }) => {
|
||||
await mockPortalHome(page, "manager");
|
||||
await mockAttendanceToday(page);
|
||||
await mockNotificationList(page);
|
||||
await mockMe(page, ["admin"], "Admin");
|
||||
await mockMe(page, ["admin"], "王顺利");
|
||||
|
||||
await login(page, ADMIN);
|
||||
|
||||
@@ -697,7 +704,7 @@ test("企微 WebView 下默认进入移动工作台", async ({ page }) => {
|
||||
await mockPortalHome(page, "employee");
|
||||
await mockAttendanceToday(page);
|
||||
await mockNotificationList(page);
|
||||
await mockMe(page, ["employee"], "Employee");
|
||||
await mockMe(page, ["employee"], "陈工程");
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByTestId(TEST_IDS.login.identifier).fill(EMPLOYEE.identifier);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { apiGetData, apiPostData, createAuthenticatedApiClient, login } from "./
|
||||
const ADMIN = {
|
||||
identifier: "[email protected]",
|
||||
password: "AdminPassword1234",
|
||||
name: "Admin",
|
||||
name: "王顺利",
|
||||
};
|
||||
|
||||
type FeatureFlagItem = {
|
||||
|
||||
@@ -15,7 +15,7 @@ async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||
const me = {
|
||||
id: "employee-r100",
|
||||
employeeNo: "E100",
|
||||
name: "Employee",
|
||||
name: "陈工程",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-r100",
|
||||
|
||||
@@ -15,7 +15,7 @@ async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||
const me = {
|
||||
id: "employee-r101",
|
||||
employeeNo: "E101",
|
||||
name: "Employee",
|
||||
name: "陈工程",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-r101",
|
||||
|
||||
@@ -15,7 +15,7 @@ async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||
const me = {
|
||||
id: "employee-r102",
|
||||
employeeNo: "E102",
|
||||
name: "Employee",
|
||||
name: "陈工程",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-r102",
|
||||
|
||||
@@ -23,8 +23,8 @@ function screenshotPath(fileName: string) {
|
||||
|
||||
const me = {
|
||||
id: "employee-r103",
|
||||
employeeNo: "E103",
|
||||
name: "Employee",
|
||||
employeeNo: "HYD000103",
|
||||
name: "陈工程",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-r103",
|
||||
|
||||
@@ -37,8 +37,8 @@ async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) {
|
||||
|
||||
const mockMe = {
|
||||
id: "employee-mock-1",
|
||||
employeeNo: "M001",
|
||||
name: "Manager",
|
||||
employeeNo: "HYD000002",
|
||||
name: "李研发",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-1",
|
||||
@@ -105,8 +105,8 @@ const pendingTaskDetail = {
|
||||
reimbursementDate: "2026-05-05",
|
||||
totalAmount: "1683.00",
|
||||
description: "R89 移动审批验证",
|
||||
owner: { id: "employee-mock-2", employeeNo: "E002", name: "Employee" },
|
||||
submitter: { id: "employee-mock-2", employeeNo: "E002", name: "Employee" },
|
||||
owner: { id: "employee-mock-2", employeeNo: "HYD000003", name: "陈工程" },
|
||||
submitter: { id: "employee-mock-2", employeeNo: "HYD000003", name: "陈工程" },
|
||||
},
|
||||
leaveRequest: null,
|
||||
purchaseRequest: null,
|
||||
|
||||
@@ -60,8 +60,8 @@ async function mockAuthenticatedShell(page: Page) {
|
||||
await page.route((url) => url.pathname === "/api/me", (route) =>
|
||||
fulfillJson(route, {
|
||||
id: "employee-mock-1",
|
||||
employeeNo: "E001",
|
||||
name: "Smoke Employee",
|
||||
employeeNo: "HYD000003",
|
||||
name: "陈工程",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-1",
|
||||
@@ -113,7 +113,7 @@ async function mockAuthenticatedShell(page: Page) {
|
||||
await page.route((url) => url.pathname === "/api/portal/home", (route) =>
|
||||
fulfillJson(route, {
|
||||
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: "Smoke Employee" },
|
||||
greeting: { name: "陈工程" },
|
||||
cards: [],
|
||||
pendingTasks: { count: 0, href: "/todos" },
|
||||
myApplications: [],
|
||||
@@ -149,8 +149,8 @@ function reimbursementListItem() {
|
||||
paidAt: null,
|
||||
paidBy: null,
|
||||
},
|
||||
owner: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" },
|
||||
submitter: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" },
|
||||
owner: { id: "employee-mock-1", employeeNo: "HYD000003", name: "陈工程" },
|
||||
submitter: { id: "employee-mock-1", employeeNo: "HYD000003", name: "陈工程" },
|
||||
project: null,
|
||||
pendingWorkflow: null,
|
||||
createdAt: null,
|
||||
@@ -197,8 +197,8 @@ const invoiceListItem = {
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 128,
|
||||
verification: null,
|
||||
owner: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" },
|
||||
uploadedBy: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" },
|
||||
owner: { id: "employee-mock-1", employeeNo: "HYD000003", name: "陈工程" },
|
||||
uploadedBy: { id: "employee-mock-1", employeeNo: "HYD000003", name: "陈工程" },
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,8 @@ import { EMPLOYEE, FINANCE, login, type E2EUser } from "./helpers/auth";
|
||||
const ADMIN: E2EUser = {
|
||||
identifier: "[email protected]",
|
||||
password: "AdminPassword1234",
|
||||
name: "Admin",
|
||||
name: "王顺利",
|
||||
employeeNo: "HYD000006",
|
||||
};
|
||||
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") {
|
||||
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : "王顺利";
|
||||
const base = {
|
||||
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" }],
|
||||
riskSummary: null,
|
||||
announcements: [],
|
||||
@@ -134,13 +136,15 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/me") {
|
||||
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : "王顺利";
|
||||
const employeeNo = mode === "employee" ? "HYD000003" : mode === "finance" ? "HYD000004" : "HYD000006";
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
id: `visual-${mode}`,
|
||||
employeeNo: "VISUAL",
|
||||
name: mode === "employee" ? "Employee" : mode === "finance" ? "Finance" : "Admin",
|
||||
employeeNo,
|
||||
name: displayName,
|
||||
email: `${mode}@hyinda.com`,
|
||||
roleCodes: mode === "employee" ? ["employee"] : mode === "finance" ? ["finance"] : ["admin"],
|
||||
notificationPushEnabled: true,
|
||||
|
||||
@@ -19,7 +19,7 @@ async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||
const visualMe = {
|
||||
id: "employee-r96",
|
||||
employeeNo: "R96001",
|
||||
name: "Employee",
|
||||
name: "陈工程",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-r96",
|
||||
@@ -261,12 +261,12 @@ async function mockVisualApis(page: Page) {
|
||||
owner: {
|
||||
id: "employee-r96",
|
||||
employeeNo: "R96001",
|
||||
name: "Employee",
|
||||
name: "陈工程",
|
||||
},
|
||||
submitter: {
|
||||
id: "employee-r96",
|
||||
employeeNo: "R96001",
|
||||
name: "Employee",
|
||||
name: "陈工程",
|
||||
},
|
||||
project: null,
|
||||
pendingWorkflow: {
|
||||
@@ -300,12 +300,12 @@ async function mockVisualApis(page: Page) {
|
||||
owner: {
|
||||
id: "employee-r96",
|
||||
employeeNo: "R96001",
|
||||
name: "Employee",
|
||||
name: "陈工程",
|
||||
},
|
||||
submitter: {
|
||||
id: "employee-r96",
|
||||
employeeNo: "R96001",
|
||||
name: "Employee",
|
||||
name: "陈工程",
|
||||
},
|
||||
project: null,
|
||||
pendingWorkflow: null,
|
||||
|
||||
@@ -21,7 +21,7 @@ async function fulfillJson<T>(route: Route, data: T) {
|
||||
const mockMe = {
|
||||
id: "employee-confirm-manager",
|
||||
employeeNo: "M-confirm",
|
||||
name: "Manager",
|
||||
name: "李研发",
|
||||
email: "[email protected]",
|
||||
phone: null,
|
||||
roleCodes: ["manager"],
|
||||
@@ -98,7 +98,7 @@ const pendingTaskDetail = {
|
||||
reason: "R7E 审批动作确认",
|
||||
attachment: null,
|
||||
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,
|
||||
travelRequest: null,
|
||||
|
||||
@@ -21,7 +21,7 @@ async function fulfillJson<T>(route: Route, data: T) {
|
||||
const manager = {
|
||||
id: "manager-wecom-boundary",
|
||||
employeeNo: "M-WECOM",
|
||||
name: "Manager",
|
||||
name: "李研发",
|
||||
email: "[email protected]",
|
||||
phone: null,
|
||||
roleCodes: ["manager"],
|
||||
|
||||
+5
-5
@@ -54,11 +54,11 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
|
||||
|
||||
合同台账 V1 只做登记、归档、到期/续签提醒,以及关联项目、采购和付款。合同页面不做完整合同生命周期管理,不自建电子签、复杂法务审查、条款库、版本协同或 CLM;如需合同审批,走 OA 统一审批中心或另建后续 Trellis task,合同台账页只展示状态、摘要和关联入口。
|
||||
|
||||
采购台账 V1 只做申请、审批、附件、轻量验收/归档,以及关联合同、项目和付款/AP。采购页面不做完整采购 ERP,不承接采购订单全生命周期、库存、三单匹配、供应商生命周期管理、招采评标或 ERP 入账;审批通过不代表已付款、已入库或已形成固定资产。
|
||||
采购台账 V1 只做申请、审批、附件、轻量验收/归档,以及关联合同、项目和付款/AP。采购页面不承接采购订单全生命周期、库存、三单匹配、供应商生命周期管理、招采评标或自动入账;审批通过不代表已付款、已入库或已形成固定资产。
|
||||
|
||||
项目模块 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 和截图通过只代表本地验证通过;只有完成部署、公网访问检查、关键路径冒烟和必要截图证据后,才可告知用户 `可以线上测试`。未部署时只能说明 `本地验证通过,尚未线上可测`。
|
||||
|
||||
@@ -129,8 +129,8 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
|
||||
- PC 请假入口默认表达为 `请假记录`;提交请假、补卡、外出、加班仍优先走企业微信或移动入口,只有 OA 应急接管或指定管理场景才突出提交入口。
|
||||
- 移动请假默认是记录查看和状态确认;普通员工不显示 OA 新建请假表单、编辑和提交动作。HR/管理员应急接管时才显示 `OA 应急` 表单和 `提交应急审批`。
|
||||
- 考勤页普通员工只看月度摘要、异常解释、同步/留痕状态和企微处理说明;`OA 应急修正` 只给 HR/管理员处理企微不可用时的应急接管。
|
||||
- 管理配置类能力在侧边栏合并为 `管理`,具体人员、组织、流程、规则、审计、功能开关和外部系统入口进入管理页后分组承接。
|
||||
- 财务入口应使用真实工作名,例如 `付款`、`应付账款`、`票据`;`AP 运营`、`ERP 集成`、`功能开关` 等内部平台词不作为默认一级菜单文案。
|
||||
- 管理配置类能力在侧边栏合并为 `管理`,具体人员、组织、流程、规则、审计、功能开关和真实已启用外部能力进入管理页后分组承接。
|
||||
- 财务入口应使用真实工作名,例如 `付款`、`应付账款`、`票据`;`AP 运营`、`功能开关` 等内部平台词不作为默认一级菜单文案,`ERP 集成` 不作为产品入口。
|
||||
- `更多` 只承载低频、冻结、阶段性或偶尔查看的入口,不使用说明型副标题、教学卡片或历史模块解释文案;不得把常用业务台账作为默认二级入口。
|
||||
|
||||
## 业务体验事实
|
||||
|
||||
@@ -158,7 +158,7 @@ function renderMarkdown(report) {
|
||||
lines.push("- 阻塞 3:");
|
||||
lines.push("");
|
||||
lines.push("## 下周调整");
|
||||
lines.push("- 是否继续隐藏考勤、采购、差旅、预算、AP、ERP、AI 等备用模块:");
|
||||
lines.push("- 是否继续隐藏考勤、采购、差旅、预算、AP、AI 等备用模块:");
|
||||
lines.push("- 是否需要补充培训、公告提醒或管理员代配置:");
|
||||
lines.push("- 是否达到试点预期:是 / 否");
|
||||
return `${lines.join("\n")}\n`;
|
||||
|
||||
@@ -255,13 +255,6 @@ const DEFAULT_FEATURE_FLAGS = [
|
||||
department_ids: [],
|
||||
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",
|
||||
override_mode: "force_off",
|
||||
|
||||
+24
-26
@@ -317,7 +317,6 @@ async function ensureWorkspaceSetting(prisma) {
|
||||
paymentBatches: "admin",
|
||||
apOperations: "admin",
|
||||
cutover: "admin",
|
||||
erpIntegrations: "admin",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -344,7 +343,6 @@ async function ensureEmployee(prisma, payload) {
|
||||
const employee = await prisma.employee.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
employee_no: payload.employeeNo,
|
||||
name: payload.name,
|
||||
phone: payload.phone,
|
||||
department_id: payload.departmentId,
|
||||
@@ -480,8 +478,8 @@ async function main() {
|
||||
}
|
||||
|
||||
const ceo = await ensureEmployee(prisma, {
|
||||
employeeNo: process.env.SEED_CEO_EMPLOYEE_NO ?? "E0001",
|
||||
name: "CEO",
|
||||
employeeNo: process.env.SEED_CEO_EMPLOYEE_NO ?? "HYD000001",
|
||||
name: "张总",
|
||||
phone: process.env.SEED_CEO_PHONE ?? "13900000000",
|
||||
email: process.env.SEED_CEO_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_CEO_PASSWORD ?? "CeoPassword1234",
|
||||
@@ -494,8 +492,8 @@ async function main() {
|
||||
});
|
||||
|
||||
const admin = await ensureEmployee(prisma, {
|
||||
employeeNo: process.env.SEED_ADMIN_EMPLOYEE_NO ?? "E0000",
|
||||
name: "Admin",
|
||||
employeeNo: process.env.SEED_ADMIN_EMPLOYEE_NO ?? "HYD000006",
|
||||
name: "王顺利",
|
||||
phone: process.env.SEED_ADMIN_PHONE ?? "13800000000",
|
||||
email: process.env.SEED_ADMIN_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_ADMIN_PASSWORD ?? "AdminPassword1234",
|
||||
@@ -508,8 +506,8 @@ async function main() {
|
||||
});
|
||||
|
||||
const manager = await ensureEmployee(prisma, {
|
||||
employeeNo: process.env.SEED_MANAGER_EMPLOYEE_NO ?? "E0100",
|
||||
name: "Manager",
|
||||
employeeNo: process.env.SEED_MANAGER_EMPLOYEE_NO ?? "HYD000002",
|
||||
name: "李研发",
|
||||
phone: process.env.SEED_MANAGER_PHONE ?? "13700000000",
|
||||
email: process.env.SEED_MANAGER_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_MANAGER_PASSWORD ?? "ManagerPassword1234",
|
||||
@@ -522,8 +520,8 @@ async function main() {
|
||||
});
|
||||
|
||||
const employee = await ensureEmployee(prisma, {
|
||||
employeeNo: process.env.SEED_EMPLOYEE_EMPLOYEE_NO ?? "E0101",
|
||||
name: "Employee",
|
||||
employeeNo: process.env.SEED_EMPLOYEE_EMPLOYEE_NO ?? "HYD000003",
|
||||
name: "陈工程",
|
||||
phone: process.env.SEED_EMPLOYEE_PHONE ?? "13600000000",
|
||||
email: process.env.SEED_EMPLOYEE_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_EMPLOYEE_PASSWORD ?? "EmployeePassword1234",
|
||||
@@ -536,8 +534,8 @@ async function main() {
|
||||
});
|
||||
|
||||
const finance = await ensureEmployee(prisma, {
|
||||
employeeNo: process.env.SEED_FINANCE_EMPLOYEE_NO ?? "E0200",
|
||||
name: "Finance",
|
||||
employeeNo: process.env.SEED_FINANCE_EMPLOYEE_NO ?? "HYD000004",
|
||||
name: "赵财务",
|
||||
phone: process.env.SEED_FINANCE_PHONE ?? "13500000000",
|
||||
email: process.env.SEED_FINANCE_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_FINANCE_PASSWORD ?? "FinancePassword1234",
|
||||
@@ -550,8 +548,8 @@ async function main() {
|
||||
});
|
||||
|
||||
const financeMgr = await ensureEmployee(prisma, {
|
||||
employeeNo: process.env.SEED_FINANCE_MGR_EMPLOYEE_NO ?? "E0201",
|
||||
name: "Finance Manager",
|
||||
employeeNo: process.env.SEED_FINANCE_MGR_EMPLOYEE_NO ?? "HYD000005",
|
||||
name: "钱主管",
|
||||
phone: process.env.SEED_FINANCE_MGR_PHONE ?? "13400000000",
|
||||
email: process.env.SEED_FINANCE_MGR_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_FINANCE_MGR_PASSWORD ?? "FinanceMgrPassword1234",
|
||||
@@ -564,8 +562,8 @@ async function main() {
|
||||
});
|
||||
|
||||
const hr = await ensureEmployee(prisma, {
|
||||
employeeNo: process.env.SEED_HR_EMPLOYEE_NO ?? "E0300",
|
||||
name: "HR",
|
||||
employeeNo: process.env.SEED_HR_EMPLOYEE_NO ?? "HYD000007",
|
||||
name: "孙人事",
|
||||
phone: process.env.SEED_HR_PHONE ?? "13300000000",
|
||||
email: process.env.SEED_HR_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_HR_PASSWORD ?? "HrPassword1234",
|
||||
@@ -578,8 +576,8 @@ async function main() {
|
||||
});
|
||||
|
||||
const administration = await ensureEmployee(prisma, {
|
||||
employeeNo: process.env.SEED_ADMINISTRATION_EMPLOYEE_NO ?? "E0400",
|
||||
name: "Administration",
|
||||
employeeNo: process.env.SEED_ADMINISTRATION_EMPLOYEE_NO ?? "HYD000008",
|
||||
name: "周行政",
|
||||
phone: process.env.SEED_ADMINISTRATION_PHONE ?? "13200000000",
|
||||
email: process.env.SEED_ADMINISTRATION_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_ADMINISTRATION_PASSWORD ?? "AdministrationPassword1234",
|
||||
@@ -664,21 +662,21 @@ async function main() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Seeded dev users:");
|
||||
// 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
console.log(`- Legal Entity: ${legalEntity.code} / ${legalEntity.name}`);
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
@@ -24,7 +24,6 @@ const DEFAULT_FORCE_ON_FLAGS = Object.freeze([
|
||||
]);
|
||||
|
||||
const OPTIONAL_FORCE_ON_FLAGS = new Set([
|
||||
"erp_sync",
|
||||
"budget_precheck_v1",
|
||||
"payment_batch_v1",
|
||||
"workflow_definition_admin_v1",
|
||||
@@ -164,7 +163,6 @@ async function ensureWorkspaceSetting(prisma) {
|
||||
paymentBatches: "admin",
|
||||
apOperations: "admin",
|
||||
cutover: "admin",
|
||||
erpIntegrations: "admin",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -199,7 +197,6 @@ async function ensureWorkspaceSetting(prisma) {
|
||||
paymentBatches: "admin",
|
||||
apOperations: "admin",
|
||||
cutover: "admin",
|
||||
erpIntegrations: "admin",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -326,8 +323,8 @@ async function main() {
|
||||
email: adminEmail,
|
||||
phone: adminPhone,
|
||||
password: adminPassword || null,
|
||||
employeeNo: process.env.TRIALRUN_ADMIN_EMPLOYEE_NO ?? "T0000",
|
||||
name: process.env.TRIALRUN_ADMIN_NAME ?? "Trialrun Admin",
|
||||
employeeNo: process.env.TRIALRUN_ADMIN_EMPLOYEE_NO ?? "HYD000006",
|
||||
name: process.env.TRIALRUN_ADMIN_NAME ?? "王顺利",
|
||||
title: process.env.TRIALRUN_ADMIN_TITLE ?? "系统管理员",
|
||||
departmentId: management.id,
|
||||
hireDate: new Date(process.env.TRIALRUN_ADMIN_HIRE_DATE ?? "2020-01-01"),
|
||||
|
||||
@@ -31,7 +31,6 @@ const MANAGEMENT_CHILD_MODULES = new Set([
|
||||
"masterData",
|
||||
"notificationChannels",
|
||||
"cutover",
|
||||
"erpIntegrations",
|
||||
]);
|
||||
const BACKUP_NAVIGATION_MODULES = new Set([
|
||||
"weeklyReports",
|
||||
@@ -104,7 +103,6 @@ const NAVIGATION_REGISTRY = Object.freeze({
|
||||
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"] },
|
||||
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: [] },
|
||||
});
|
||||
|
||||
@@ -144,7 +142,6 @@ const DEFAULT_MODULE_VISIBILITY = Object.freeze({
|
||||
invoicePool: "admin",
|
||||
inventoryChecks: "admin",
|
||||
cutover: "admin",
|
||||
erpIntegrations: "admin",
|
||||
profile: "hidden",
|
||||
});
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户