diff --git a/.agents/skills/hyb-oa-delivery/SKILL.md b/.agents/skills/hyb-oa-delivery/SKILL.md index ba9ae09f..b7ed3a29 100644 --- a/.agents/skills/hyb-oa-delivery/SKILL.md +++ b/.agents/skills/hyb-oa-delivery/SKILL.md @@ -26,6 +26,7 @@ Read only what is relevant: - WREMOTE1/WFULLSMOKE2/WEXT1-FORMAL supersedes the earlier fixture-creation route for the next step: first deploy/sync and run WTCLOSE2 from the deployment server trusted IP, then run WFULLSMOKE2 from the deployment server with `WFULLSMOKE_ALLOW_PUBLIC_WRITES=1` when OA `TEST-SMOKE-WFULL` public test data is approved, then use existing formal WeCom outing/overtime templates by having `SMOKE002` manually submit and `SMOKE101` review details before approving. WEXT1F adds `WEXT1F_MANUAL_CONFIRMATION=SMOKE002_SUBMITTED_SMOKE101_REVIEWED bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote` for the post-manual evidence run: it explicitly pulls `--target=outing --target=overtime` with `--filter-creator-employee-no=SMOKE002`, may persist only to OA archive via `WECOM_ARCHIVE_WRITE=1` + `--persist-to-db`, and then runs archive offline read + WRULE1. Missing manual confirmation must produce `PAUSED_MANUAL_CONFIRMATION` and no WeCom API calls. Public WEXT1F rerun `/app/artifacts/qa-flow/wecom-formal-outing-overtime-evidence-20260522-104521/summary.json` is `PASS_WITH_FINDINGS`: outing/overtime read/archive/rule validation work and privacy smoke pass, and both outing and overtime are archived as approved; remaining callback duplicate/history-window observations do not block the WREAL1 question gate, but do not auto-start real users without explicit approval. Do not auto-create outing/overtime OA-POC templates, auto-submit approvals, write WeCom, broadly pull formal-template records from other employees, or run WeCom API evidence locally in this route. WREAL1 uses `/tmp/hyb-oa-wreal1.env` for real employee/approver values and must not write real employee identifiers to repo docs, Issue CSV, or chat. - OAOPS1 is the current post-WeCom lane for OA-primary maturation: WeCom check-in, missed punch, outing, leave, and overtime are frozen for now as WeCom-owned HR/attendance with OA archive/audit. The active route returns to OA-primary real use for a 7-day small-team stabilization window. Public OA may hold real business data for reimbursement/PDF OCR, approval, procurement/expense, contract registry, projects, payment batches, bank receipt attachments, AP/accounting handoff, mobile/WeCom entry, and audit. Use `scripts/qa/run-oa-real-use-daily.sh` for daily read-only checks; real business writes are manual in public OA. Do not upload confidential contract bodies, sensitive quotes, cost detail, bank statement originals, raw invoice images, or secrets to repo docs, chat, or reports. Keep image OCR, complex BPM, budgets, ERP, bank-statement import, real payment-provider integration, WeCom HR deep mirror, emergency takeover, recovery writeback, and RuleOps frozen unless a new Issue CSV explicitly approves them. - WAPC2FLIVE supersedes the WAPC2F dry-run gap for OA-POC samples: after user approval, the runner was corrected to the official `template_name` + `property` controls create-template payload, deployed with `SYNC_ENV=0`, and the public live report `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` reached `PASS_FIXTURE_CREATED`; leave/missed-punch OA-POC templates and low-risk test approvals were created and retained for audit, with WAPC2 auto-rerun `PASS_APPROVAL_POC`. WCBK1 has added and deployed read-only `GET/POST /api/wecom/approval/callback`, `wecomCallbackCrypto`, `npm run check:wecom-approval-callback-poc`, and the `wecom-callback` process gate. After remote callback config was set, public report `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T083422Z/summary.json` reached `PASS_CALLBACK_URL_VERIFIED`; after `SMOKE101` approved an existing OA-POC missed-punch test approval, `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` reached `PASS_CALLBACK_EVENT_CONFIRMED` with one redacted event. WACP3/WACP3R has added and deployed `npm run check:wecom-approval-compensation-poc`, `POST /cgi-bin/oa/getapprovalinfo` read support, and automatic template discovery by `SMOKE002` creator; WREAD1 deploy reran WACP3R at `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T125515Z/summary.json` and then generated `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T125532Z/summary.json` as `PAUSED_MANUAL_CONFIRMATION`. WREAD1F then fixed duplicate OA-POC template names by adding timestamp uniqueness and created fresh leave/missed-punch approvals at `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T131958Z/summary.json`; final WREAD1 manual report `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` is `PASS_READABLE`. This clears leave/missed-punch readability only; mirror data model, minimum archive, emergency mode, writeback, RuleOps, and outing/overtime sample decisions remain later gates. +- ASSET1 is the current unified asset registry lane after OAOPS1: reuse the existing asset module for fixed assets plus electronic document/system/repository/backup asset metadata. Number families are `FA-YYYY-NNNNNN`, `EA-DOC-YYYY-NNNNNN`, `EA-SYS-YYYY-NNNNNN`, `EA-REPO-YYYY-NNNNNN`, and `EA-BKP-YYYY-NNNNNN`; `VER-NNNNN` is historical lookup only. `EA-ACC` is deferred: never store passwords, token, Secret, private keys, source bundles, full config, confidential document bodies, complete local paths, sensitive quotes, bank statement originals, or raw invoice images in OA, repo docs, chat, or reports. Git remains the source of truth for code; OA only stores repository metadata and responsibility. Historical company directory imports may only use redacted metadata from `00_保密索引`, default dry-run, with sensitive text/path guards. - WEGOV1 changes the current WeCom route to "Enterprise WeChat owns mature HR/attendance flows; OA stores self-owned history, audit, entry, messages, and emergency fallback." Treat four domains explicitly: WeCom-owned domain is check-in, leave, missed punch, outing, overtime, leave balance, and HR assistant; OA-owned domain remains reimbursement, procurement, contracts, projects, payments, invoices, bank receipts, sensitive attachments, and operating audit; OA archive domain stores WeCom business history, full external keys, and necessary snapshots; OA emergency domain is manually enabled only when WeCom is unavailable. Do not assume a full isolated WeCom sandbox; use the controlled test domain `SMOKE002 -> SMOKE101`, `OA-POC` templates, low-risk approvals, synthetic tests, public live runners, and limited real-device confirmation. WARCH1 now supplies the minimum leave/missed-punch archive implementation, WARCH2 supplies the formal read-only history query surface, and WROAD2 supplies controlled ops daily checks. Do not start deep mirror until WROAD2-CLOSEOUT, WROAD2-BIZ1, WAPPR1, WRULE1, WAUTO1/WEXT1LD, WFULLSMOKE1, WTCLOSE2, WEXT1-FORMAL, WREAL1 question gates or explicit approval, and the mirror stage's own contract are complete. - For reimbursement, invoice, OCR, duplicate invoice, or attachment work, read `docs/报销发票附件规则.md` and `references/reimbursement.md`. - For local/WeCom/public trial-run, OCR runtime, Caddy, or DB drift work, read `docs/试运行与自动化验证手册.md` and `references/trialrun.md`. diff --git a/client/src/api/assets.ts b/client/src/api/assets.ts index ff735a33..bc167f64 100644 --- a/client/src/api/assets.ts +++ b/client/src/api/assets.ts @@ -1,6 +1,8 @@ import { apiRequestData } from "./http"; export type AssetStatus = "in_stock" | "in_use" | "under_repair" | "retired" | "archived"; +export type AssetType = "fixed_asset" | "electronic_document" | "electronic_system" | "code_repository" | "backup_media"; +export type AssetSensitivityLevel = "public_candidate" | "internal" | "confidential" | "strictly_confidential"; export type AssetCategoryItem = { id: string; @@ -33,6 +35,8 @@ export type AssetItem = { id: string; assetNo: string; name: string; + assetType: AssetType; + sensitivityLevel: AssetSensitivityLevel; categoryId: string; legalEntityId: string; costCenterId: string; @@ -41,6 +45,17 @@ export type AssetItem = { location: string | null; status: AssetStatus; assigneeEmployeeId: string | null; + ownerEmployeeId: string | null; + keeperEmployeeId: string | null; + archiveLocation: string | null; + externalRef: string | null; + sourceHash: string | null; + verificationNo: string | null; + confidentialityNote: string | null; + metadata: Record | null; + sensitiveFieldsRedacted?: boolean; + lastVerifiedAt: string | null; + expiresAt: string | null; activatedAt: string | null; retiredAt: string | null; createdAt: string | null; @@ -82,6 +97,8 @@ export type AssetItem = { name: string; } | null; } | null; + owner: AssetAssigneeOption | null; + keeper: AssetAssigneeOption | null; permissions: { canEdit: boolean; }; @@ -99,8 +116,12 @@ export type AssetListResponse = { export type AssetListQuery = { status?: AssetStatus; + assetType?: AssetType; + sensitivityLevel?: AssetSensitivityLevel; categoryId?: string; assigneeId?: string; + ownerId?: string; + keeperId?: string; legalEntityId?: string; costCenterId?: string; q?: string; @@ -109,6 +130,8 @@ export type AssetListQuery = { export type CreateAssetPayload = { name: string; + assetType?: AssetType; + sensitivityLevel?: AssetSensitivityLevel; categoryId: string; legalEntityId: string; costCenterId: string; @@ -117,6 +140,16 @@ export type CreateAssetPayload = { location?: string | null; status?: AssetStatus; assigneeEmployeeId?: string | null; + ownerEmployeeId?: string | null; + keeperEmployeeId?: string | null; + archiveLocation?: string | null; + externalRef?: string | null; + sourceHash?: string | null; + verificationNo?: string | null; + confidentialityNote?: string | null; + metadata?: Record | null; + lastVerifiedAt?: string | null; + expiresAt?: string | null; activatedAt?: string | null; retiredAt?: string | null; }; diff --git a/client/src/pages/AssetRegistryPage.tsx b/client/src/pages/AssetRegistryPage.tsx index 9da76f46..2440e77b 100644 --- a/client/src/pages/AssetRegistryPage.tsx +++ b/client/src/pages/AssetRegistryPage.tsx @@ -20,7 +20,9 @@ import { type AssetCategoryItem, type AssetErpStage, type AssetErpTrack, + type AssetSensitivityLevel, type AssetStatus, + type AssetType, } from "../api/assets"; import { fetchPurchaseRequests } from "../api/purchaseRequests"; import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities } from "../api/masterData"; @@ -58,6 +60,8 @@ function initialCategoryForm() { function initialAssetForm() { return { name: "", + assetType: "fixed_asset" as AssetType, + sensitivityLevel: "internal" as AssetSensitivityLevel, categoryId: "", legalEntityId: "", costCenterId: "", @@ -66,6 +70,16 @@ function initialAssetForm() { location: "", status: "in_stock" as AssetStatus, assigneeEmployeeId: "", + ownerEmployeeId: "", + keeperEmployeeId: "", + archiveLocation: "", + externalRef: "", + sourceHash: "", + verificationNo: "", + confidentialityNote: "", + metadataText: "", + lastVerifiedAt: "", + expiresAt: "", activatedAt: "", retiredAt: "", }; @@ -82,6 +96,75 @@ function assetStatusOptions(): Array<{ value: AssetStatus | ""; label: string }> ]; } +function assetTypeOptions(): Array<{ value: AssetType | ""; label: string }> { + return [ + { value: "", label: "全部资产类型" }, + { value: "fixed_asset", label: "固定资产 FA" }, + { value: "electronic_document", label: "电子资料 EA-DOC" }, + { value: "electronic_system", label: "电子系统 EA-SYS" }, + { value: "code_repository", label: "代码仓库 EA-REPO" }, + { value: "backup_media", label: "备份介质 EA-BKP" }, + ]; +} + +function assetTypeLabel(value: AssetType | string | null | undefined) { + return assetTypeOptions().find((item) => item.value === value)?.label ?? "未知类型"; +} + +function sensitivityOptions(): Array<{ value: AssetSensitivityLevel | ""; label: string }> { + return [ + { value: "", label: "全部敏感级别" }, + { value: "public_candidate", label: "可公开候选" }, + { value: "internal", label: "内部资料" }, + { value: "confidential", label: "商业秘密/机密" }, + { value: "strictly_confidential", label: "严格保密" }, + ]; +} + +function sensitivityLabel(value: AssetSensitivityLevel | string | null | undefined) { + return sensitivityOptions().find((item) => item.value === value)?.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 unsafeAssetTextReason(value: string) { + if (!value.trim()) { + return ""; + } + if (/(?:token|secret|password|passwd|pwd|api[_-]?key|access[_-]?key|private[_-]?key|encodingaeskey)\s*[:=]/i.test(value)) { + return "不要填写 token、Secret、密码或密钥"; + } + if (/\b[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PRIVATE_KEY|ENCODINGAESKEY)[A-Z0-9_]*\b/.test(value)) { + return "不要填写密钥类环境变量名或配置片段"; + } + if (/(?:密码|密钥|私钥|令牌)\s*[::=]/.test(value) || /\bBearer\s+[A-Za-z0-9._-]{10,}/i.test(value)) { + return "不要填写密码、密钥、令牌或授权头"; + } + if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/i.test(value)) { + return "不要填写证书私钥或密钥正文"; + } + if (/(^|[\s"'((,,;;])(?:\/(?:home|root|Users|var|etc|mnt|Volumes|app)\/|[A-Za-z]:[\\/]|\\\\)/i.test(value)) { + return "不要填写本机或服务器完整路径"; + } + if (/(原始\s*(?:payload|xml|报文|正文|表单)|raw\s*(?:payload|xml|body|form))/i.test(value)) { + return "不要填写原始 payload、XML、表单或正文"; + } + return ""; +} + function formatAssetErpReason(reason: string | null) { switch (reason) { case "erp_sync_disabled": @@ -129,10 +212,13 @@ export function AssetRegistryPage() { const [editingCategoryId, setEditingCategoryId] = useState(""); const [statusFilter, setStatusFilter] = useState(""); + const [assetTypeFilter, setAssetTypeFilter] = useState(""); + const [sensitivityFilter, setSensitivityFilter] = useState(""); const [categoryFilter, setCategoryFilter] = useState(""); const [legalEntityFilter, setLegalEntityFilter] = useState(""); const [costCenterFilter, setCostCenterFilter] = useState(""); const [assigneeFilter, setAssigneeFilter] = useState(""); + const [assetSearch, setAssetSearch] = useState(""); const [mine, setMine] = useState(!canViewAll); const [selectedAssetId, setSelectedAssetId] = useState(searchParams.get("id") ?? ""); @@ -150,14 +236,17 @@ export function AssetRegistryPage() { }); const assetsQuery = useQuery({ - queryKey: ["assets", "list", statusFilter, categoryFilter, legalEntityFilter, costCenterFilter, assigneeFilter, mine, canViewAll], + queryKey: ["assets", "list", statusFilter, assetTypeFilter, sensitivityFilter, categoryFilter, legalEntityFilter, costCenterFilter, assigneeFilter, assetSearch, mine, canViewAll], queryFn: () => fetchAssets({ status: statusFilter || undefined, + assetType: assetTypeFilter || undefined, + sensitivityLevel: sensitivityFilter || undefined, categoryId: categoryFilter || undefined, legalEntityId: legalEntityFilter || undefined, costCenterId: costCenterFilter || undefined, assigneeId: assigneeFilter || undefined, + q: assetSearch.trim() || undefined, mine: canViewAll ? mine : true, }), }); @@ -259,6 +348,8 @@ export function AssetRegistryPage() { const asset = assetDetailQuery.data.asset; setAssetForm({ name: asset.name, + assetType: asset.assetType, + sensitivityLevel: asset.sensitivityLevel, categoryId: asset.categoryId, legalEntityId: asset.legalEntityId, costCenterId: asset.costCenterId, @@ -267,6 +358,16 @@ export function AssetRegistryPage() { location: asset.location ?? "", status: asset.status, assigneeEmployeeId: asset.assigneeEmployeeId ?? "", + ownerEmployeeId: asset.ownerEmployeeId ?? "", + keeperEmployeeId: asset.keeperEmployeeId ?? "", + archiveLocation: asset.archiveLocation ?? "", + externalRef: asset.externalRef ?? "", + sourceHash: asset.sourceHash ?? "", + verificationNo: asset.verificationNo ?? "", + confidentialityNote: asset.confidentialityNote ?? "", + metadataText: asset.metadata ? JSON.stringify(asset.metadata, null, 2) : "", + lastVerifiedAt: asset.lastVerifiedAt ?? "", + expiresAt: asset.expiresAt ?? "", activatedAt: asset.activatedAt ?? "", retiredAt: asset.retiredAt ?? "", }); @@ -319,9 +420,23 @@ export function AssetRegistryPage() { setAssetFormError(""); } + function parseAssetMetadata() { + const text = assetForm.metadataText.trim(); + if (!text) { + return null; + } + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("扩展元数据必须是 JSON 对象"); + } + return parsed as Record; + } + function buildCreateAssetPayload() { return { name: assetForm.name.trim(), + assetType: assetForm.assetType, + sensitivityLevel: assetForm.sensitivityLevel, categoryId: assetForm.categoryId, legalEntityId: assetForm.legalEntityId, costCenterId: assetForm.costCenterId, @@ -330,6 +445,16 @@ export function AssetRegistryPage() { location: assetForm.location.trim() || null, status: assetForm.status, assigneeEmployeeId: assetForm.assigneeEmployeeId || null, + ownerEmployeeId: assetForm.ownerEmployeeId || null, + keeperEmployeeId: assetForm.keeperEmployeeId || null, + archiveLocation: assetForm.archiveLocation.trim() || null, + externalRef: assetForm.externalRef.trim() || null, + sourceHash: assetForm.sourceHash.trim() || null, + verificationNo: assetForm.verificationNo.trim() || null, + confidentialityNote: assetForm.confidentialityNote.trim() || null, + metadata: parseAssetMetadata(), + lastVerifiedAt: assetForm.lastVerifiedAt || null, + expiresAt: assetForm.expiresAt || null, activatedAt: assetForm.activatedAt || null, retiredAt: assetForm.retiredAt || null, }; @@ -338,12 +463,23 @@ export function AssetRegistryPage() { function buildPatchAssetPayload() { return { name: assetForm.name.trim(), + sensitivityLevel: assetForm.sensitivityLevel, categoryId: assetForm.categoryId, legalEntityId: assetForm.legalEntityId, costCenterId: assetForm.costCenterId, serialNo: assetForm.serialNo.trim() || null, location: assetForm.location.trim() || null, status: assetForm.status, + ownerEmployeeId: assetForm.ownerEmployeeId || null, + keeperEmployeeId: assetForm.keeperEmployeeId || null, + archiveLocation: assetForm.archiveLocation.trim() || null, + externalRef: assetForm.externalRef.trim() || null, + sourceHash: assetForm.sourceHash.trim() || null, + verificationNo: assetForm.verificationNo.trim() || null, + confidentialityNote: assetForm.confidentialityNote.trim() || null, + metadata: parseAssetMetadata(), + lastVerifiedAt: assetForm.lastVerifiedAt || null, + expiresAt: assetForm.expiresAt || null, activatedAt: assetForm.activatedAt || null, retiredAt: assetForm.retiredAt || null, }; @@ -362,6 +498,28 @@ export function AssetRegistryPage() { if (!assetForm.costCenterId) { throw new Error("请选择成本中心"); } + if (assetForm.verificationNo.trim() && !/^VER-\d{5,}$/.test(assetForm.verificationNo.trim())) { + throw new Error("历史验证编号格式应为 VER-NNNNN"); + } + if (assetForm.sourceHash.trim() && !/^[A-Za-z0-9:_-]+$/.test(assetForm.sourceHash.trim())) { + throw new Error("文件 hash / 校验摘要只能包含字母、数字、冒号、下划线和短横线"); + } + if (["electronic_document", "electronic_system", "code_repository", "backup_media"].includes(assetForm.assetType) && !assetForm.ownerEmployeeId) { + throw new Error("电子资产请指定负责人"); + } + const textFields = [ + ["资产名称", assetForm.name], + ["归档位置/备份位置", assetForm.archiveLocation], + ["外部引用/仓库地址", assetForm.externalRef], + ["保密/回收要求", assetForm.confidentialityNote], + ["扩展元数据 JSON", assetForm.metadataText], + ] as const; + for (const [label, value] of textFields) { + const reason = unsafeAssetTextReason(value); + if (reason) { + throw new Error(`${label}:${reason}`); + } + } } const saveCategoryMutation = useMutation({ @@ -699,6 +857,11 @@ export function AssetRegistryPage() {
+ setAssetSearch(event.target.value)} + /> + + setAssetForm((current) => ({ ...current, name: event.target.value }))} />
+
+ + +

{typeHint(assetForm.assetType)}

+
+
+ + +
+
+ + +
+
+ + +
setAssetForm((current) => ({ ...current, location: event.target.value }))} />
+
+ + setAssetForm((current) => ({ ...current, archiveLocation: event.target.value }))} /> +
+
+ + setAssetForm((current) => ({ ...current, externalRef: event.target.value }))} /> +
+
+ + setAssetForm((current) => ({ ...current, sourceHash: event.target.value }))} /> +
+
+ + setAssetForm((current) => ({ ...current, verificationNo: event.target.value }))} /> +
+
+ + setAssetForm((current) => ({ ...current, lastVerifiedAt: event.target.value }))} /> +
+
+ + setAssetForm((current) => ({ ...current, expiresAt: event.target.value }))} /> +
setAssetForm((current) => ({ ...current, activatedAt: event.target.value }))} /> @@ -956,6 +1214,14 @@ export function AssetRegistryPage() { setAssetForm((current) => ({ ...current, retiredAt: event.target.value }))} />
+
+ +