ASSET1 统一电子资产与固定资产台账

这个提交包含在:
2026-05-22 22:04:41 +08:00
父节点 c2a3c7a84b
当前提交 9090046a97
共修改 18 个文件,包含 1457 行新增和 199 行删除
+1
查看文件
@@ -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`.
+33
查看文件
@@ -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<string, unknown> | 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<string, unknown> | null;
lastVerifiedAt?: string | null;
expiresAt?: string | null;
activatedAt?: string | null;
retiredAt?: string | null;
};
+270 -4
查看文件
@@ -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<AssetStatus | "">("");
const [assetTypeFilter, setAssetTypeFilter] = useState<AssetType | "">("");
const [sensitivityFilter, setSensitivityFilter] = useState<AssetSensitivityLevel | "">("");
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<string, unknown>;
}
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() {
<div className="space-y-6">
<FilterBar className="space-y-4">
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-6">
<Input
placeholder="搜索编号/名称/hash/VER"
value={assetSearch}
onChange={(event) => setAssetSearch(event.target.value)}
/>
<select className="select-std" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as AssetStatus | "") }>
{assetStatusOptions().map((item) => (
<option key={item.value || "all"} value={item.value}>
@@ -706,6 +869,20 @@ export function AssetRegistryPage() {
</option>
))}
</select>
<select className="select-std" value={assetTypeFilter} onChange={(event) => setAssetTypeFilter(event.target.value as AssetType | "") }>
{assetTypeOptions().map((item) => (
<option key={item.value || "all"} value={item.value}>
{item.label}
</option>
))}
</select>
<select className="select-std" value={sensitivityFilter} onChange={(event) => setSensitivityFilter(event.target.value as AssetSensitivityLevel | "") }>
{sensitivityOptions().map((item) => (
<option key={item.value || "all"} value={item.value}>
{item.label}
</option>
))}
</select>
<select className="select-std" value={categoryFilter} onChange={(event) => setCategoryFilter(event.target.value)}>
<option value="">全部分类</option>
{(categoriesQuery.data?.items ?? []).map((item) => (
@@ -785,6 +962,10 @@ export function AssetRegistryPage() {
<div className="text-zinc-400">分类</div>
<div className="mt-0.5 truncate text-zinc-700">{item.category?.name ?? "-"}</div>
</div>
<div>
<div className="text-zinc-400">类型</div>
<div className="mt-0.5 truncate text-zinc-700">{assetTypeLabel(item.assetType)}</div>
</div>
<div>
<div className="text-zinc-400">归属人</div>
<div className="mt-0.5 truncate text-zinc-700">{item.assignee?.name ?? "-"}</div>
@@ -801,9 +982,11 @@ export function AssetRegistryPage() {
<TableHeader>
<tr>
<TableHead>资产</TableHead>
<TableHead>类型</TableHead>
<TableHead>分类</TableHead>
<TableHead>敏感级别</TableHead>
<TableHead>状态</TableHead>
<TableHead>归属人</TableHead>
<TableHead>负责人</TableHead>
</tr>
</TableHeader>
<TableBody>
@@ -816,14 +999,16 @@ export function AssetRegistryPage() {
<div className="font-medium text-zinc-900">{item.assetNo}</div>
<div className="text-xs text-zinc-500">{item.name}</div>
</TableCell>
<TableCell>{assetTypeLabel(item.assetType)}</TableCell>
<TableCell>{item.category?.name ?? "-"}</TableCell>
<TableCell>{sensitivityLabel(item.sensitivityLevel)}</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell>{item.assignee?.name ?? "-"}</TableCell>
<TableCell>{item.owner?.name ?? item.assignee?.name ?? "-"}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="py-4 text-zinc-500">
<TableCell colSpan={6} className="py-4 text-zinc-500">
暂无资产卡片
</TableCell>
</TableRow>
@@ -857,6 +1042,12 @@ export function AssetRegistryPage() {
{!isCreatingAsset && currentAsset ? <StatusBadge status={currentAsset.status} /> : null}
</div>
{!isCreatingAsset && currentAsset?.sensitiveFieldsRedacted ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
当前资产包含敏感字段,你可查看摘要;归档位置、外部引用、hash 和扩展元数据仅管理员或相关责任人可见。
</p>
) : null}
{assetFormError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{assetFormError}</p>
) : null}
@@ -866,6 +1057,17 @@ export function AssetRegistryPage() {
<label className="text-sm font-medium text-zinc-700">资产名称</label>
<Input data-testid="asset-name" value={assetForm.name} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, name: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">资产类型</label>
<select data-testid="asset-type" className="select-std" value={assetForm.assetType} disabled={!canEditCurrent || !isCreatingAsset} onChange={(event) => setAssetForm((current) => ({ ...current, assetType: event.target.value as AssetType }))}>
{assetTypeOptions().filter((item) => item.value).map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</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>
<select data-testid="asset-status" className="select-std" value={assetForm.status} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, status: event.target.value as AssetStatus }))}>
@@ -876,6 +1078,16 @@ export function AssetRegistryPage() {
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">敏感级别</label>
<select data-testid="asset-sensitivity" className="select-std" value={assetForm.sensitivityLevel} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, sensitivityLevel: event.target.value as AssetSensitivityLevel }))}>
{sensitivityOptions().filter((item) => item.value).map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">资产分类</label>
<select data-testid="asset-category" className="select-std" value={assetForm.categoryId} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, categoryId: event.target.value }))}>
@@ -929,6 +1141,28 @@ export function AssetRegistryPage() {
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">负责人</label>
<select data-testid="asset-owner" className="select-std" value={assetForm.ownerEmployeeId} disabled={!canManage} onChange={(event) => setAssetForm((current) => ({ ...current, ownerEmployeeId: 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>
<select data-testid="asset-keeper" className="select-std" value={assetForm.keeperEmployeeId} disabled={!canManage} onChange={(event) => setAssetForm((current) => ({ ...current, keeperEmployeeId: 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>
<select className="select-std" value={assetForm.purchaseRequestId} disabled={!canEditCurrent || !canReadProcurementSources || !isCreatingAsset} onChange={(event) => setAssetForm((current) => ({ ...current, purchaseRequestId: event.target.value }))}>
@@ -948,6 +1182,30 @@ export function AssetRegistryPage() {
<label className="text-sm font-medium text-zinc-700">存放位置</label>
<Input data-testid="asset-location" value={assetForm.location} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, location: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">归档位置/备份位置</label>
<Input data-testid="asset-archive-location" placeholder="填归档位置摘要或离线档案号,不填 /home 或盘符完整路径" value={assetForm.archiveLocation} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, archiveLocation: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">外部引用/仓库地址</label>
<Input data-testid="asset-external-ref" placeholder="填系统入口或仓库主页,不填带 token 的链接或完整配置" value={assetForm.externalRef} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, externalRef: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">文件 hash / 校验摘要</label>
<Input data-testid="asset-source-hash" value={assetForm.sourceHash} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, sourceHash: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">历史验证编号</label>
<Input data-testid="asset-verification-no" placeholder="VER-00001" value={assetForm.verificationNo} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, verificationNo: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">最近盘点/恢复验证日期</label>
<Input type="date" value={assetForm.lastVerifiedAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, lastVerifiedAt: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">到期/续费日期</label>
<Input type="date" value={assetForm.expiresAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, expiresAt: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">启用日期</label>
<Input type="date" value={assetForm.activatedAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, activatedAt: event.target.value }))} />
@@ -956,6 +1214,14 @@ export function AssetRegistryPage() {
<label className="text-sm font-medium text-zinc-700">退役日期</label>
<Input type="date" value={assetForm.retiredAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, retiredAt: event.target.value }))} />
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-sm font-medium text-zinc-700">保密/回收要求</label>
<textarea className="textarea-std min-h-20" placeholder="写回收要求、保管要求或脱敏说明,不写密码、密钥、原文片段" value={assetForm.confidentialityNote} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, confidentialityNote: event.target.value }))} />
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-sm font-medium text-zinc-700">扩展元数据 JSON</label>
<textarea className="textarea-std min-h-24 font-mono text-xs" placeholder='例如 {"backupPolicy":"monthly"};不要粘贴 .env、源码配置、原始 payload 或密钥' value={assetForm.metadataText} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, metadataText: event.target.value }))} />
</div>
</div>
+12
查看文件
@@ -594,6 +594,18 @@ Go 结论边界:
| 写入边界 | 已固定 | 不设置 `WECOM_APPROVAL_FIXTURE_WRITE=1`,不调用 create_template/applyevent,不创建 OA-POC 外出/加班样件,不启用真实员工;报告不得泄露完整 `sp_no`、template_id、userid、手机号、邮箱、token、Secret、EncodingAESKey、表单值、附件、评论或原始 payload。 |
| 后续准入 | 暂不自动放行 WREAL1 | 外出和加班正式模板测试号证据均已形成 approved 终态;WREAL1 仍需用户单独问答确认,不能自动启用真实员工。 |
## ASSET1 电子资产 + 固定资产统一台账
执行时间:2026-05-22。本轮承接 OAOPS1 后的 OA 主办域开发,目标是把资产模块从固定资产卡片升级为统一资产台账 V1。
| 项目 | 状态 | 证据与边界 |
| --- | --- | --- |
| 执行合同 | 已建档 | 新增 `issues/2026-05-22_21-18-33-oa-asset-governance-v1.csv`,ID 为 `ASSET1-0..ASSET1-8`。 |
| 资产范围 | 已固化 | 统一管理 `FA` 固定资产、`EA-DOC` 电子资料、`EA-SYS` 电子系统、`EA-REPO` 代码仓库元数据和 `EA-BKP` 备份资产;`EA-ACC` 账号资产暂缓。 |
| 安全边界 | 已固化 | OA 不保存密码、token、Secret、密钥、源码包、完整配置、涉密正文、真实完整路径、敏感报价、银行流水原件或未脱敏票面。代码仓库继续由 Git 管理源码,OA 只登记仓库元数据和责任人。 |
| 历史资料导入 | 默认 dry-run | 只允许读取 `/home/shunli/Projects/faculty-daily-assistant/公司/00_保密索引` 的脱敏索引和元数据;公网写入需单独确认和写入开关,不自动导入全部历史资料。 |
| 后续人工验收 | 待公网试录 | 管理员后续可试录 1 条 `FA`、3 条脱敏 `EA-DOC`、1 条 `EA-SYS`、1 条 `EA-REPO` 和 1 条 `EA-BKP`,并验证权限、盘点和离职交接口径。 |
## 历史恢复
需要追溯 R70-R91 的详细流水时,使用:
+11 -3
查看文件
@@ -11,6 +11,7 @@
- `周报`:每周写一次三段式周报。
- `待办`:记录和跟进个人事项。
- `项目`:查看自己的任务、里程碑、风险和交付记录。
- `我的资产`:查看自己领用、负责或保管的固定资产和电子资产索引。
## 2. 第一次登录后建议先做三件事
1. 检查个人资料是否正确。
@@ -47,7 +48,7 @@
## 7. 入职 / 离职时你会看到什么
- 入职时 HR 会优先在企业微信/人事助手维护人员资料,OA 后续同步生成账号、角色和绑定。
- 离职前需要归还资产并完成交接;企业微信和 OA 的账号状态会按流程同步停用或禁用。
- 离职前需要归还固定资产,并交接自己负责或保管的电子资料、系统、代码仓库和备份资产;企业微信和 OA 的账号状态会按流程同步停用或禁用。
## 8. 使用建议
- 每天登录先看 `Portal`
@@ -80,13 +81,20 @@
- 涉密资料正文继续按公司要求放在内网文件盘、NAS 或离线档案中。
- 普通合同摘要、合同编号、负责人、项目、金额、状态和线下归档位置可以录入 OA;非涉密盖章页、验收单或归档证明可按管理员要求上传。
## 12. 客户沟通回填规则(R23 试点期)
## 12. 资产怎么配合
- 固定资产编号以 `FA-YYYY-NNNNNN` 为主;电脑、设备和办公物品按管理员要求完成领用、归还、调拨或盘点。
- 电子资料资产编号以 `EA-DOC-YYYY-NNNNNN` 为主;只录入资料摘要、hash、敏感级别、负责人和归档位置摘要,不上传涉密正文或完整真实路径。
- 电子系统、代码仓库和备份资产分别使用 `EA-SYS`、`EA-REPO`、`EA-BKP`;OA 只记录责任和审计线索,源码继续在 Git,密码、token、Secret 和密钥不进 OA。
- 如果你不是负责人、保管人或管理员,涉密资产的归档位置、外部引用、hash 和扩展元数据会被隐藏,这是正常权限控制。
- 发现自己名下资产不准确时,联系管理员调整,不要用聊天记录替代 OA 台账。
## 13. 客户沟通回填规则(R23 试点期)
- 如果你通过私人微信、电话、会议、企业微信或现场沟通拿到了会影响项目推进的结果,请在 `24h` 内回填到海音达工作台。
- 回填内容只写:沟通日期、渠道、结果摘要、下一步动作、下次跟进日期。
- 不要把完整聊天记录、截图、敏感报价或涉密正文复制到海音达工作台。
- 项目是否推进、谁来跟进、下次何时联系,不能只留在聊天里。
## 13. 项目收口怎么配合
## 14. 项目收口怎么配合
- 如果你负责交付、报销、差旅、风险或交接,请关注项目里的“项目收口”面板。
- 面板里看到待处理项并不代表不能继续推进,而是提醒你尽快补齐留痕。
- 如果管理员要求你补交付记录、合同归档位置或交接说明,请优先在海音达工作台里补齐,不要只在群里回复“已处理”。
+9 -6
查看文件
@@ -109,6 +109,7 @@
| `WREMOTE1` | `issues/2026-05-22_12-34-41-wecom-remote-closeout-and-full-smoke-v1.csv` | `CURRENT` | 远端可信 IP 收口、WFULLSMOKE2 全域 smoke 和 WEXT1-FORMAL 正式模板外出/加班测试合同:先远端 WTCLOSE2,再远端全域 smoke;外出/加班不自动建模板,改为 `SMOKE002` 正式模板人工提交、`SMOKE101` 审批后系统只读补偿/归档/WRULE1 |
| `WEXT1F` | `issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv` | `CURRENT` | 正式模板外出/加班证据脚本合同:`SMOKE002` 人工提交、`SMOKE101` 先看详情再审批后,使用 `WEXT1F_MANUAL_CONFIRMATION=SMOKE002_SUBMITTED_SMOKE101_REVIEWED bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote` 从部署服务器只读拉取 outing/overtime;补偿拉取必须按 `creator=SMOKE002` 过滤,只写 OA 留存、离线读取和 WRULE1;不创建模板、不自动提交审批、不写企业微信 |
| `OAOPS1` | `issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv` | `CURRENT` | 当前 OA 主办域真实使用稳定化合同:企微 HR/考勤先冻结在企微主办 + OA 留存/审计完成态;公网 OA 进入 7 天小团队全员真实使用,允许真实业务数据,覆盖报销/PDF OCR、审批、采购/费用、合同台账、项目、付款批次、银行回单、AP 台账、代账交接、移动/企微入口和审计;新增 `scripts/qa/run-oa-real-use-daily.sh` 默认只读巡检,真实业务写入由人工完成;P2 冻结项继续冻结 |
| `ASSET1` | `issues/2026-05-22_21-18-33-oa-asset-governance-v1.csv` | `CURRENT` | 当前统一资产台账 V1 合同:复用现有资产模块统一管理固定资产和电子资产索引;编号族为 `FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP`,`EA-ACC` 暂缓;代码仓库由 Git 管源码,OA 只登记元数据;历史公司目录只允许脱敏索引 dry-run 或受控写入 |
| `WREAL1-PREP` | `issues/2026-05-20_14-35-42-wecom-real-trial-prep-v1.csv` | `CURRENT` | 真实小范围试用准备合同:只固化候选员工、非自审审批人、回滚联系人、允许流程、暂停条件和验收标准;当前不启用真实员工,真实试用必须等请假/补卡/外出/加班四类测试号证据、WAPPR1 审批规则门禁和 WFULLSMOKE1 全域主路径可解释通过后再问 |
旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。
@@ -117,8 +118,8 @@
| Doc_ID | Path | Category | Authority | Status | Owner | Related_Issues | Evidence | Supersedes | Last_Audited | Audit_Method | Risk | Notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE2,WREAL1P,OAOPS1` | `rg` | none | 2026-05-22 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么;OAOPS1 后优先读取 OA 主办域真实使用稳定化、合同台账、阻断即修、每日巡检和 P2 冻结口径 |
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE2,WREAL1P,OAOPS1` | `audit-docs` | none | 2026-05-22 | `audit-docs --check-registry` | medium | 判断文档权威性;登记当前 OAOPS1 小团队全员真实使用稳定化、合同台账、每日巡检、阻断即修和冻结项事实 |
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE2,WREAL1P,OAOPS1,ASSET1` | `rg` | none | 2026-05-22 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么;OAOPS1/ASSET1 后优先读取 OA 主办域真实使用稳定化、统一资产台账、合同台账、阻断即修、每日巡检和 P2 冻结口径 |
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE2,WREAL1P,OAOPS1,ASSET1` | `audit-docs` | none | 2026-05-22 | `audit-docs --check-registry` | medium | 判断文档权威性;登记当前 OAOPS1 小团队全员真实使用稳定化、ASSET1 统一资产台账、合同台账、每日巡检、阻断即修和冻结项事实 |
| `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WAPC1,WAPC2,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1` | Issue CSV | `docs/requirements-specification.md` | 2026-05-20 | `rg` | high | WARCH3/WCHECK1/WEXT1/WOPS1 后定义 HR/考勤企微主办、OA 自建留存、受控测试域、只读历史查询、自动巡检、打卡摘要、外出/加班 dry-run、WEXT1-FORMAL、WREAL1 前置和深度镜像边界;ERP 未来不接入 OA |
| `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WAPC1,WAPC2FLIVE,WCBK1,WEGOV1` | `rg` | none | 2026-05-19 | `rg` | high | 记录已拍板产品边界、企微/OA 设计门禁、开发路线图、WCBK1 真实回调事件证据、WEGOV1 混合主办边界和 OA 自建留存策略 |
| `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8,DG9,WFDG` | Playwright/视觉审查/DG8/DG9/WFDG | none | 2026-05-18 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考;DG9 后按页面分层和样板选型;WFDG 后区分 OA 主办、企微镜像和 OA 应急接管 |
@@ -128,17 +129,18 @@
| `QA-GATE` | `docs/测试规约与回归门禁.md` | 测试验收 | 测试和回归门禁事实源 | `CURRENT` | qa | `QAF8,GOV8,WPROC1,WPROC2,WAPC2FLIVE,WCBK1,WEGOV1` | 回归脚本/Playwright | none | 2026-05-19 | `rg` | high | 测试计划、DONE 门禁、企微三层门禁、自动化优先、非自审、无完整 sandbox 假设、WREAD1 真机可读性和 WARCH1 留存验收门禁 |
| `AGENT-PRACTICE` | `docs/Agent开发交付实践.md` | Agent 治理 | Agent 协作事实源 | `CURRENT` | dev | `GOV8,DG8` | Skill 校验 | none | 2026-05-13 | `rg` | medium | Agent 协作开发实践和设计主参考层级 |
| `AGENTS` | `AGENTS.md` | 执行硬约束 | 项目硬规则 | `CURRENT` | lead | `GOV8` | 人工核对 | none | 2026-05-13 | `rg` | high | 不可违反约束 |
| `SKILL-HYB` | `.agents/skills/hyb-oa-delivery/SKILL.md` | Skill | 项目交付 Skill | `CURRENT` | dev | `GOV8,DG8,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WROUTE2,WTCLOSE1,WTCLOSE2,WREAL1P` | quick_validate | none | 2026-05-22 | `quick_validate.py` | medium | 每轮交付操作入口;企微/OA 后续先读 API 能力目录、Secret 矩阵、路线图、WEGOV1/WADM1/WARCH1/WARCH2/WROAD2/WARCH3/WCHECK1/WEXT1/WOPS1/WROAD2B/WAPPR1/WRULE1/WAUTO1/WEXT1LD/WFULLSMOKE1/WTCLOSE1/WTCLOSE2 自建留存、受控巡检、业务冒烟、审批规则、审批链校验、自动证据汇总、全域收口 dry-run、最终测试号收口和告警降噪边界;不得绕过 WROAD2C/WROAD2B/WAPPR1/WRULE1/WAUTO1/WEXT1LD/WFULLSMOKE1/WTCLOSE2/WEXT1-FORMAL/WREAL1 或后续独立合同直接做完整考勤审批镜像 |
| `SKILL-HYB` | `.agents/skills/hyb-oa-delivery/SKILL.md` | Skill | 项目交付 Skill | `CURRENT` | dev | `GOV8,DG8,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WROUTE2,WTCLOSE1,WTCLOSE2,WREAL1P,OAOPS1,ASSET1` | quick_validate | none | 2026-05-22 | `quick_validate.py` | medium | 每轮交付操作入口;企微/OA 后续先读 API 能力目录、Secret 矩阵、路线图和 ASSET1 资产边界;不得绕过 WROAD2C/WROAD2B/WAPPR1/WRULE1/WAUTO1/WEXT1LD/WFULLSMOKE1/WTCLOSE2/WEXT1-FORMAL/WREAL1 或后续独立合同直接做完整考勤审批镜像;统一资产台账不保存密码、Secret、源码包、完整配置或涉密原文 |
| `REQ-WF` | `docs/需求-统一审批中心规约.md` | 需求事实源 | 审批模块规约 | `CURRENT` | product/backend | `AP6,WF8,WFOC,DBWFOC,WFDG,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WRULE1,WTCLOSE2` | workflow 回归 | none | 2026-05-22 | `rg` | high | OA 主办审批、企微主办 HR/考勤审批、OA 自建留存、真实回调事件证据、WACP3R 补偿拉取 findings、WREAD1 请假/补卡真机可读性结论、WARCH1 最小历史留存、WAPPR1 审批规则规范化和 API 不支持读取规则边界、WTCLOSE1 小团队一层审批 + 管理员兜底目标 A、WRULE1 理论审批人与实际审批链校验、WTCLOSE2 测试号最终收口、WAUTO1/WEXT1LD/WFULLSMOKE1 前置和 OA 应急接管边界 |
| `REQ-OCR` | `docs/需求-报销发票OCR规约.md` | 需求事实源 | OCR 模块规约 | `CURRENT` | product/backend | `RI6,OCR8,BIZ8` | OCR 回归 | none | 2026-05-13 | `rg` | high | OCR 和发票边界 |
| `RULE-REIMB` | `docs/报销发票附件规则.md` | 需求事实源 | 报销发票附件规则 | `CURRENT` | product/finance | `RI6,BIZ8` | 报销回归 | none | 2026-05-13 | `rg` | high | 报销、发票、附件细则 |
| `REQ-WECOM` | `docs/需求-企业微信基础设施规约.md` | 需求事实源 | 企业微信边界规约 | `CURRENT` | product/backend | `R7E,ORG8,WF8,ATTV1,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WROAD2B,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WTCLOSE2` | live smoke/真机反馈/Issue CSV | none | 2026-05-22 | `rg` | high | 企微优先承担 HR/考勤员工日常审批和人事/打卡能力;WARCH2 已提供请假/补卡正式只读历史查询,WARCH3 固定受控上线自动巡检,WCHECK1 固定打卡摘要留存,WEXT1 固定外出/加班 dry-run,WROAD2B 固定三天人工业务冒烟,WAPPR1/WTCLOSE1 固定审批规则规范化和小团队目标 A,WFULLSMOKE1 固定全域主路径测试边界,WTCLOSE2 固定最终测试号自动收口,WEXT1-FORMAL 需收口后另建合同;采购、合同、项目、报销和财务继续 OA 主办 |
| `REQ-ASSET` | `docs/需求-资产管理规约.md` | 需求事实源 | 统一资产台账规约 | `CURRENT` | product/backend | `ASSET1` | Issue CSV / 本地门禁 | none | 2026-05-22 | `rg` | high | 固定资产和电子资产统一台账;编号族 `FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP`;`EA-ACC` 暂缓;Git 管源码,OA 只登记仓库元数据;历史资料只导脱敏索引;不保存密码、token、Secret、源码包、完整配置、涉密正文、真实完整路径、敏感报价或银行流水原件 |
| `RUNBOOK-TRIAL` | `docs/试运行与自动化验证手册.md` | 操作手册 | 试运行验证手册 | `CURRENT` | qa/ops | `R7E,QAOPS8` | live readiness | none | 2026-05-13 | `rg` | medium | 本地、公网、企业微信和真实 OCR 验证 |
| `MANUAL-ADMIN` | `docs/管理员手册.md` | 操作手册 | 管理员手册 | `CURRENT` | ops/admin | `ORG8,R7E,WFOC,DBWFOC,WFDG,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WTCLOSE2,WREAL1P` | 人工验收 | none | 2026-05-22 | `rg` | medium | 管理员上线、企微优先边界、Secret 矩阵、WARCH3 自动巡检与脱敏告警、WOPS1 告警降噪、WCHECK1 打卡摘要留存、WROAD2B 三天业务冒烟、WAPPR1/WTCLOSE1 审批规则规范化和 API 不支持读取规则后的人工后台/真机门禁、小团队目标 A、WAUTO1 自动证据汇总、WFULLSMOKE1 全域最终目标测试、WTCLOSE2 测试号最终收口、WEXT1 外出/加班 dry-run、WEXT1-FORMAL 前置、WREAL1 准备和深度镜像前置关注点 |
| `MANUAL-EMPLOYEE` | `docs/员工快速上手.md` | 操作手册 | 员工手册 | `CURRENT` | product | `R7E,WFOC,DBWFOC,WFDG` | 人工验收 | none | 2026-05-18 | `rg` | low | 员工侧使用说明,HR/考勤优先企业微信,采购/合同/财务仍在 OA;区分企微镜像和 OA 应急接管 |
| `MANUAL-ADMIN` | `docs/管理员手册.md` | 操作手册 | 管理员手册 | `CURRENT` | ops/admin | `ORG8,R7E,WFOC,DBWFOC,WFDG,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WTCLOSE2,WREAL1P,ASSET1` | 人工验收 | none | 2026-05-22 | `rg` | medium | 管理员上线、企微优先边界、Secret 矩阵、巡检和 HR/考勤门禁;补充 ASSET1 统一资产台账、编号族、电子资产索引、EA-ACC 暂缓和敏感资料不进公网 OA 的管理口径 |
| `MANUAL-EMPLOYEE` | `docs/员工快速上手.md` | 操作手册 | 员工手册 | `CURRENT` | product | `R7E,WFOC,DBWFOC,WFDG,ASSET1` | 人工验收 | none | 2026-05-22 | `rg` | low | 员工侧使用说明,HR/考勤优先企业微信,采购/合同/财务仍在 OA;补充统一资产台账、电子资产和涉密资产可见边界 |
| `R7-DRAFT` | `docs/trialrun/R7扩大试运行草案.md` | 草案 | R7 准备材料 | `DRAFT` | product/qa | `R7E` | R7EP 报告 | none | 2026-05-13 | `rg` | medium | 不代表当前 R7E 执行状态 |
| `OPS-RUNBOOK` | `docs/trialrun/OPS7运维交接Runbook.md` | 运维草案 | OPS 准备材料 | `DRAFT` | ops | `OPS8,QAOPS8` | 只读检查 | none | 2026-05-13 | `rg` | medium | 不代表恢复演练已执行 |
| `TRIAL-REPORT` | `docs/trialrun/人工试运行报告.md` | 证据材料 | 试运行证据入口 | `EVIDENCE` | qa | `R7EP,OPS8,QAF8,WOUA,WAPI,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WRULE1,WTCLOSE2,WREAL1P` | artifacts/qa-flow | none | 2026-05-22 | `rg` | medium | 只证明发生过;记录 WAPC2FLIVE live 样件、WCBK1 真实回调事件、WEGOV1 路线治理、WROAD2 Day 0、WARCH3 自动巡检、WOPS1 告警降噪、WCHECK1 打卡摘要、WEXT1 dry-run、WROAD2B 三天业务冒烟、WAPPR1 审批规则门禁、WAUTO1/WEXT1LD、WFULLSMOKE1 全域最终目标测试、WTCLOSE1 小团队收口 dry-run、WRULE1 审批链校验、WTCLOSE2 测试号最终收口和 WREAL1 准备,不直接放行深度镜像 |
| `TRIAL-REPORT` | `docs/trialrun/人工试运行报告.md` | 证据材料 | 试运行证据入口 | `EVIDENCE` | qa | `R7EP,OPS8,QAF8,WOUA,WAPI,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WRULE1,WTCLOSE2,WREAL1P,ASSET1` | artifacts/qa-flow | none | 2026-05-22 | `rg` | medium | 只证明发生过;记录 WAPC2FLIVE live 样件、WCBK1 真实回调事件、企微路线证据、WTCLOSE2 测试号最终收口、WREAL1 准备和 ASSET1 统一资产台账边界,不直接放行深度镜像或公网资产导入 |
| `FBA-AUDIT` | `docs/trialrun/R7E后功能边界审计.md` | 证据材料 | R7E 后功能边界审计 | `EVIDENCE` | product/dev/qa | `FBA,R7E,QAOPS8,P2T` | Issue CSV | none | 2026-05-15 | `rg` | medium | 只作为功能边界、P2 候选和 Issue 草案证据,不直接派工 |
| `HISTORY-MATRIX` | `docs/历史功能状态矩阵.md` | 历史材料 | 历史功能索引 | `HISTORICAL` | product | `DCL7` | git 历史 | none | 2026-05-13 | `rg` | low | 只追溯历史 |
| `OLD-IMPL-PLAN` | `docs/implementation_plan.md` | 已移除历史 | 早期实施计划 | `REMOVED` | product | `DCL7` | git history | none | 2026-05-13 | manual | low | 从 git 历史恢复 |
@@ -162,6 +164,7 @@
| `docs/需求-采购费用审批规约.md` | 当前事实源 | 采购和费用审批 |
| `docs/需求-合同附件归档规约.md` | 当前事实源 | 合同台账和附件归档 |
| `docs/需求-项目轻管理规约.md` | 当前事实源 | 项目轻管理 |
| `docs/需求-资产管理规约.md` | 当前事实源 | 统一资产台账、电子资产安全边界和资产编号族 |
| `docs/测试规约与回归门禁.md` | 当前事实源 | 测试和回归门禁 |
| `docs/开发交付与变更管理规约.md` | 当前事实源 | Issue CSV、review、提交和交付 |
| `docs/Agent开发交付实践.md` | 当前事实源 | Agent 协作开发实践 |
+3 -1
查看文件
@@ -20,7 +20,7 @@
## 最近事实源链
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT -> HR-WECOM-DIRECTORY-V1 -> HRWD-CONTACT-LIVE-TEST -> WCFB -> WFOC -> DBWFOC -> WFDG -> WDRG -> WCP1 -> WCPC -> WHMS -> WHMSLIVE -> WOUA -> WAPI -> WRRA -> WOUA-FIX -> WRV1 -> WRA1 -> WMSG1 -> WAPC1 -> WAPC2 -> WPROC1 -> WPROC2 -> WAPC2F dry-run -> WAPC2FLIVE -> WCBK1 -> WEGOV1 -> WACP3/WACP3R -> WREAD1 自动汇总 -> WREAD1F 新样例 -> WREAD1 真机确认 -> WADM1 自建留存模型设计 -> WARCH1 最小审批历史留存 -> WARCH2 正式只读历史查询 -> WROAD2+OPS 受控上线与每日巡检 -> WARCH3 归档稳定化自动巡检 -> WCHECK1 打卡摘要留存 -> WEXT1 外出/加班 dry-run -> WOPS1 巡检告警降噪 -> WROAD2-CLOSEOUT 三天验收 -> WROAD2-BIZ1 三天业务冒烟 -> WAPPR1 审批规则规范化 -> WRULE1 审批链校验器 POC -> WAUTO1 自动证据汇总 -> WEXT1LD live 前 dry-run -> WFULLSMOKE1 三天全域最终目标测试 -> WROUTE2 测试号收口到最小镜像路线锁定 -> WTCLOSE1 小团队自动审批规则与测试号全域收口 dry-run -> WTCLOSE2 测试号最终收口与证据复核 -> WTCLOSE2F 远端可信 IP 执行口径修正 -> WREMOTE1 远端可信 IP 收口 -> WFULLSMOKE2 全域 smoke -> WEXT1-FORMAL/WEXT1F 正式模板外出/加班测试 -> OAOPS1 小团队全员真实使用稳定化(当前主线)-> 后续再按需问答 WREAL1/最小镜像。
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT -> HR-WECOM-DIRECTORY-V1 -> HRWD-CONTACT-LIVE-TEST -> WCFB -> WFOC -> DBWFOC -> WFDG -> WDRG -> WCP1 -> WCPC -> WHMS -> WHMSLIVE -> WOUA -> WAPI -> WRRA -> WOUA-FIX -> WRV1 -> WRA1 -> WMSG1 -> WAPC1 -> WAPC2 -> WPROC1 -> WPROC2 -> WAPC2F dry-run -> WAPC2FLIVE -> WCBK1 -> WEGOV1 -> WACP3/WACP3R -> WREAD1 自动汇总 -> WREAD1F 新样例 -> WREAD1 真机确认 -> WADM1 自建留存模型设计 -> WARCH1 最小审批历史留存 -> WARCH2 正式只读历史查询 -> WROAD2+OPS 受控上线与每日巡检 -> WARCH3 归档稳定化自动巡检 -> WCHECK1 打卡摘要留存 -> WEXT1 外出/加班 dry-run -> WOPS1 巡检告警降噪 -> WROAD2-CLOSEOUT 三天验收 -> WROAD2-BIZ1 三天业务冒烟 -> WAPPR1 审批规则规范化 -> WRULE1 审批链校验器 POC -> WAUTO1 自动证据汇总 -> WEXT1LD live 前 dry-run -> WFULLSMOKE1 三天全域最终目标测试 -> WROUTE2 测试号收口到最小镜像路线锁定 -> WTCLOSE1 小团队自动审批规则与测试号全域收口 dry-run -> WTCLOSE2 测试号最终收口与证据复核 -> WTCLOSE2F 远端可信 IP 执行口径修正 -> WREMOTE1 远端可信 IP 收口 -> WFULLSMOKE2 全域 smoke -> WEXT1-FORMAL/WEXT1F 正式模板外出/加班测试 -> OAOPS1 小团队全员真实使用稳定化 -> ASSET1 电子资产与固定资产统一台账(当前实现)-> 后续再按需问答 WREAL1/最小镜像。
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
@@ -170,6 +170,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
| `WREMOTE1` | `issues/2026-05-22_12-34-41-wecom-remote-closeout-and-full-smoke-v1.csv` | 当前远端收口、全域 smoke 和正式模板外出/加班测试路线 | WFULLSMOKE2 必须使用 `--remote` 在部署服务器执行;外出/加班使用正式模板由 `SMOKE002` 人工提交、`SMOKE101` 审批,系统只读补偿、归档和 WRULE1 | 允许 OA `TEST-SMOKE-WFULL` 测试数据;不写企业微信、不自动创建外出/加班模板或审批单;真实员工信息只通过 `/tmp/hyb-oa-wreal1.env` 临时文件 |
| `WEXT1F` | `issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv` | 当前正式模板外出/加班证据脚本:人工提交后从部署服务器只读收证据 | 新增 `WEXT1F_MANUAL_CONFIRMATION=SMOKE002_SUBMITTED_SMOKE101_REVIEWED bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote`;显式拉取 `outing/overtime` 且按 `creator=SMOKE002` 过滤,写 OA 留存仅限 `WECOM_ARCHIVE_WRITE=1` + `--persist-to-db`;随后做离线归档读取和 WRULE1 | 不创建模板、不自动提交审批、不写企业微信、不启用真实员工;未有人工作业或缺确认标记时只可得到人工缺口结论 |
| `OAOPS1` | `issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv` | 当前 OA 主办域真实使用稳定化:7 天小团队全员边用边修 | 新增 `scripts/qa/run-oa-real-use-daily.sh`,每日只读核验公网 ready、live smoke、feature flag、报销/PDF OCR、审批、采购/费用、合同台账、项目、付款批次、AP、移动/企微入口和隐私边界 | 允许真实业务数据进入公网 OA 并按审计保留;真实写入由人工完成;涉密合同正文、敏感报价、成本细节、银行流水原件、未脱敏票面不进仓库/聊天/报告;图片 OCR、复杂 BPM、预算、ERP、银行流水导入、真实支付直连、企微 HR 深镜像、应急接管、恢复回写、RuleOps 继续冻结 |
| `ASSET1` | `issues/2026-05-22_21-18-33-oa-asset-governance-v1.csv` | 当前统一资产台账 V1:电子资产 + 固定资产共用现有资产模块 | 扩展 `AssetCard` 为 `FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP`,新增资产大类、敏感级别、负责人/保管人、归档位置、外部引用、hash、历史验证编号、最近验证时间和脱敏索引 dry-run;前端资产页支持类型/敏感级别/编号搜索 | 代码仓库仍由 Git 管理;`EA-ACC` 暂缓;不保存密码、token、Secret、源码包、完整配置、涉密正文、真实完整路径、敏感报价、银行流水原件或未脱敏票面 |
| `WREAL1-PREP` | `issues/2026-05-20_14-35-42-wecom-real-trial-prep-v1.csv` | 当前真实试用准备:不启用真实员工 | 固化候选员工、非自审审批人、回滚联系人、允许流程、暂停条件和验收标准;真实试用必须等请假、补卡、外出、加班四类测试号证据、WAPPR1 和 WFULLSMOKE1 通过后再问 | 当前不创建外出/加班 live 样件;真实员工名单不写入 Issue 或文档,后续通过问答确认 |
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
@@ -204,6 +205,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
| [需求 - 采购费用审批规约](./需求-采购费用审批规约.md) | 采购、费用、附件、审批规则 |
| [需求 - 合同附件归档规约](./需求-合同附件归档规约.md) | 合同台账和附件归档边界 |
| [需求 - 项目轻管理规约](./需求-项目轻管理规约.md) | 项目轻管理边界 |
| [需求 - 资产管理规约](./需求-资产管理规约.md) | 统一资产台账、编号族、电子资产安全边界和离职交接口径 |
| [测试规约与回归门禁](./测试规约与回归门禁.md) | 自动化、视觉、真机、状态回填 |
| [开发交付与变更管理规约](./开发交付与变更管理规约.md) | Issue CSV、review、提交、交付和企微阶段门禁顺序 |
| [Agent开发交付实践](./Agent开发交付实践.md) | Agent 协作和交付实践 |
+28 -3
查看文件
@@ -403,6 +403,31 @@ bash scripts/qa/run-oa-real-use-daily.sh
合同台账纳入首批真实使用:合同负责人可录入合同编号、合同名称、主体、金额、负责人、项目、状态和线下归档位置;非涉密盖章页、验收单、归档证明等可按权限上传并留下下载审计。涉密合同正文、敏感报价、成本细节、客户敏感资料不进入公网 OA;只记录线下归档位置或外部归档编号。
### 3.6 ASSET1 统一资产台账
ASSET1 后,资产模块统一承载固定资产和电子资产索引,不再另建两套资产系统。编号族如下:
| 编号 | 用途 |
| --- | --- |
| `FA-YYYY-NNNNNN` | 公司固定资产,例如电脑、设备、办公物品、备份硬盘实物 |
| `EA-DOC-YYYY-NNNNNN` | 电子资料资产,例如合同、发票、回单、证照、软著、项目资料索引 |
| `EA-SYS-YYYY-NNNNNN` | 电子系统资产,例如 OA、企业微信后台、服务器、数据库、域名、证书、云服务 |
| `EA-REPO-YYYY-NNNNNN` | 代码仓库资产,只登记仓库元数据,不托管源码 |
| `EA-BKP-YYYY-NNNNNN` | 备份介质或备份集合,例如移动硬盘、NAS、快照、数据库备份目录 |
| `VER-NNNNN` | 历史验证编号,只用于旧资料映射和查找 |
管理员登记资产时按以下规则处理:
1. 固定资产继续走领用、归还、调拨、盘点和移动扫码。
2. 电子资料只登记名称、类型、hash、敏感级别、负责人、归档位置摘要和关联项目/合同/付款摘要;涉密正文、真实完整路径、敏感报价、银行账户、身份证件、账号凭据不上传公网 OA。
3. 电子系统记录负责人、入口、到期日期、备份策略和权限回收要求;密码、token、Secret、证书私钥和完整配置不进 OA。
4. 代码仓库继续由 Git 管理源码和提交历史;OA 只登记仓库名称、用途、负责人、托管平台、可见级别、关联项目/系统、归档状态和备份策略。
5. 备份资产记录保管人、备份范围、加密状态和最近恢复验证日期;真实恢复演练仍需另建批准合同。
账号资产 `EA-ACC` V1 暂缓。离职交接时检查“此人是否持有关键系统权限”,不在 OA 维护每个普通账号资产卡,也不保存任何密码或密钥。
历史资料导入只允许从 `/home/shunli/Projects/faculty-daily-assistant/公司/00_保密索引` 读取脱敏索引,默认 dry-run。公网写入需要 `ASSET_INDEX_IMPORT_WRITE=1` 和单独确认;报告不得输出 S3/S4 原文、完整路径、Secret、token、银行流水原件或未脱敏票面。
阻断判定:
- 无法登录、无法提交或审批报销/采购/合同相关事项。
@@ -461,7 +486,7 @@ bash scripts/qa/run-oa-real-use-daily.sh
## 8. 备用模块治理
- 采购/费用、合同/附件和项目轻管理属于 P1 轻量能力,可在 P0 报销/OCR、财务闭环、企微 HR/考勤镜像和企业微信入口稳定后逐步开放。
- 支付批次和 AP 运营已作为正式财务闭环开放给出纳、财务和管理员;普通员工仍不显示财务运营入口。
- 预算、AI、完整差旅、资产深度管理等保留代码和历史数据,但不作为当前 MVP 首批主路径;ERP 保持关闭,不再规划接入。
- 预算、AI、完整差旅等保留代码和历史数据,但不作为当前 MVP 首批主路径;资产管理按 ASSET1 统一台账 V1 小范围启用;ERP 保持关闭,不再规划接入。
- 若短期完全不用,保持管理员可见即可,不必删除。
- 只有出现真实场景时,再恢复到主导航或单独扩展。
@@ -471,7 +496,7 @@ bash scripts/qa/run-oa-real-use-daily.sh
3. 同步启用付款批次、AP 台账和银行回单附件,付款动作仍由出纳在 OA 外通过银行渠道完成。
4. 先以企业微信作为请假、补卡、外出、加班和打卡主操作端;OA 侧按后续合同逐步启用 HR 主数据镜像、企微审批/考勤镜像、人工接管和恢复回写。
5. 在 P0 稳定后开放 P1:`采购/费用审批`、`合同/附件归档`、`项目轻管理`。
6. 最后按真实需要评估周报、团队状态、公告、资产、预算、AI 和完整差旅;ERP 不进入评估队列。
6. 最后按真实需要评估周报、团队状态、公告、资产台账、预算、AI 和完整差旅;ERP 不进入评估队列。
## 10. 试点验收口径
- 全员都能账号密码登录;已绑定员工能从企业微信 `海音达工作台` 进入移动工作台。
@@ -546,7 +571,7 @@ bash scripts/qa/run-oa-real-use-daily.sh
- 部门
- 员工
- 角色
- 在用资产
- 在用固定资产和必要电子资产索引
- 活跃项目
- 合同元数据
4. 不导入这些内容:
+113
查看文件
@@ -0,0 +1,113 @@
# 需求 - 资产管理规约
**文档状态**:当前资产管理事实源。
**分类**:需求事实源 / 资产台账。
**关联 Issue**:`ASSET1`。
**最近更新**:2026-05-22。
**上级事实源**:[海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md)、[产品口径](./产品口径.md)、[管理员手册](./管理员手册.md)。
**安全边界**:本文不授权在 OA、仓库、聊天或测试报告中保存密码、token、Secret、密钥、源码包、完整配置、涉密正文、敏感报价、成本细节、银行流水原件或未脱敏票面。
## 1. 目标
ASSET1 后,OA 资产模块从“固定资产卡片”升级为“统一资产台账 V1”。目标不是替代 Git、密码管理器、档案库或银行系统,而是在 OA 中建立公司级资产责任、归档、盘点、审计和离职交接索引。
国内小团队资产治理按三条线收敛:
| 管理线 | OA 中的落点 | 重点 |
| --- | --- | --- |
| 财务/内控资产 | 固定资产 `FA` | 编号、采购来源、责任人、领用、调拨、盘点、处置 |
| 电子档案/资料资产 | 电子资料 `EA-DOC` | 元数据、hash、敏感级别、归档位置、负责人、利用审计 |
| 信息/数据安全资产 | 系统、仓库、备份 `EA-SYS/EA-REPO/EA-BKP` | 负责人、访问入口、权限回收清单、备份策略、恢复验证 |
## 2. 编号族
| 编号 | 资产类型 | 示例 |
| --- | --- | --- |
| `FA-YYYY-NNNNNN` | 公司固定资产 | 电脑、设备、办公物品、备份硬盘实物 |
| `EA-DOC-YYYY-NNNNNN` | 电子资料资产 | 合同、发票、回单、证照、软著、项目资料索引 |
| `EA-SYS-YYYY-NNNNNN` | 电子系统资产 | OA、企业微信后台、服务器、数据库、域名、证书、云服务 |
| `EA-REPO-YYYY-NNNNNN` | 代码仓库资产 | Git 仓库元数据、负责人、可见级别、备份策略 |
| `EA-BKP-YYYY-NNNNNN` | 备份介质或备份集合 | 移动硬盘、NAS、快照、数据库备份目录 |
| `VER-NNNNN` | 历史验证编号 | 旧资料校验、导入映射和查找,不作为新资产主编号 |
`EA-ACC` 账号资产 V1 暂缓。OA 不保存密码、token、Secret 或密钥;账号权限只作为 `EA-SYS` 的管理员、负责人和离职权限回收清单字段。
## 3. 资产类型口径
### 固定资产 `FA`
固定资产继续承接已有卡片、分类、领用、归还、调拨、盘点和移动扫码能力。适合电脑、显示器、仪器设备、办公家具、备份硬盘等可实物盘点资产。
### 电子资料资产 `EA-DOC`
电子资料只登记可控摘要和索引:资料名称、类型、hash、敏感级别、归档建议、负责人、关联项目/合同/付款摘要和脱敏外部引用。公网 OA 不上传 S3/S4 原文、真实完整路径、合同报价、银行账户、身份证件、账号凭据或未脱敏附件。
历史目录第一轮只允许从 `/home/shunli/Projects/faculty-daily-assistant/公司/00_保密索引` 读取脱敏索引和元数据,默认 dry-run。公网写入真实索引必须另行确认并带写入开关。
### 电子系统资产 `EA-SYS`
电子系统登记系统名称、用途、负责人、管理员角色、入口、到期时间、备份策略和权限回收要求。OA 可以记录“谁负责回收权限”,不能保存登录密码、API token、证书私钥或完整配置。
### 代码仓库资产 `EA-REPO`
代码仓库继续由 Git 承担源码、提交历史、分支和 tag 主事实源。OA 只登记仓库名称、用途、负责人、托管平台、可见级别、关联项目/系统、归档状态和备份策略,不上传源码包、`.env`、密钥或完整配置。
### 备份介质资产 `EA-BKP`
备份介质或备份集合记录保管人、备份范围、加密状态、存放位置摘要、最近恢复验证日期和下一次验证日期。备份硬盘同时可作为 `FA` 实物资产;若它承载关键备份,应补建或关联 `EA-BKP`。
## 4. 权限边界
- 普通员工只能看到自己领用、负责或保管的资产。
- 行政/管理员维护固定资产、分类、领用、归还、调拨和盘点。
- 财务可查看与付款、发票、合同和固定资产账相关的必要摘要,但不默认查看涉密正文、密钥、源码或完整配置。
- 系统管理员维护系统、仓库和备份资产元数据;密钥和凭据仍在专用工具或离线渠道保管。
- 对 `confidential` 和 `strictly_confidential` 资产,非管理员且非相关责任人的用户只能看摘要;归档位置、外部引用、hash、保密要求和扩展元数据需要脱敏或隐藏。
## 5. 离职与盘点
离职交接至少检查:
- 名下 `FA` 是否归还、调拨或报废。
- 作为负责人/保管人的 `EA-DOC/EA-SYS/EA-REPO/EA-BKP` 是否完成移交。
- `EA-SYS` 中标记的关键系统权限是否完成人工回收。
- `EA-BKP` 是否完成保管人变更和最近恢复验证记录。
盘点不只面向实物。电子资料、系统、仓库和备份也要按周期记录“最近验证时间”,确认负责人、归档位置、备份策略和权限回收要求仍有效。
## 6. 当前实现边界
ASSET1 只做统一资产台账 V1:
- 复用现有 `AssetCard`,扩展资产大类、敏感级别、负责人/保管人、归档位置、外部引用、hash、历史验证编号、保密要求、扩展元数据和最近验证日期。
- 编号服务支持 `FA`、`EA-DOC`、`EA-SYS`、`EA-REPO`、`EA-BKP`。
- 前端资产页支持类型、敏感级别和编号/hash/VER 搜索。
- 移动端继续优先固定资产扫码和盘点,不把复杂电子资料编辑搬到移动端。
- QA 脚本只输出脱敏索引摘要;默认不写公网 OA。
不做:
- 不做密码库。
- 不做源码托管。
- 不做涉密文件外发系统。
- 不自动导入全部历史资料。
- 不把银行流水原件、合同正文、敏感报价或成本细节上传到公网 OA。
## 7. 验收样例
ASSET1 后,管理员可按以下最小样例试录:
| 样例 | 目标 |
| --- | --- |
| 新建 1 条 `FA` 固定资产 | 完成领用、归还、盘点 |
| 导入 3 条脱敏 `EA-DOC` 索引 | 不上传原文,不输出完整路径 |
| 登记 1 条 `EA-SYS` OA 系统资产 | 记录负责人、入口、备份策略和到期项 |
| 登记 1 条 `EA-REPO` 仓库资产 | 只保存 Git 元数据和责任人 |
| 登记 1 条 `EA-BKP` 备份资产 | 记录保管人和最近恢复验证日期 |
## 8. 参考依据
- 财政部《企业内部控制应用指引第8号——资产管理》:强调资产安全、使用效能和全流程梳理。
- 国家档案局《企业档案管理规定》:强调企业档案归档、保管、利用和处置。
- 国家档案局《电子档案管理办法》与 `GB/T 18894-2016` 摘要:强调电子文件归档、真实性、完整性、可用性和安全。
- 《中华人民共和国数据安全法》《中华人民共和国个人信息保护法》:强调分类分级、最小必要、访问控制和审计。
@@ -0,0 +1,10 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
ASSET1-0,建立电子资产与固定资产统一台账合同,"基于国内资产内控、电子档案和数据安全实践,建立统一资产台账 V1 执行合同。","Issue CSV 通过校验;范围固定为复用现有资产模块,统一管理 FA、EA-DOC、EA-SYS、EA-REPO、EA-BKP;EA-ACC 暂缓。","受影响流程:管理员确认资产治理范围和编号族;验证步骤:执行 validate_issues_csv.py 本文件和 check-process-contract general 门禁。","manual",DONE,DONE,DONE,"issues/2026-05-22_21-18-33-oa-asset-governance-v1.csv","[]","不上传敏感原文;不在 OA 保存密码、token、Secret、源码包或完整配置。"
ASSET1-1,扩展资产模型与编号族,"在现有 AssetCard 上增加资产大类、敏感级别、归档/责任/外部引用/校验字段,并将默认固定资产编号从 ZC 调整为 FA。","Prisma schema 和迁移可验证;新资产可生成 FA、EA-DOC、EA-SYS、EA-REPO、EA-BKP 编号;VER 仅保留历史映射字段。","受影响流程:管理员新建统一资产卡 -> 系统按资产大类生成编号;验证步骤:cd server && npm run prisma:validate && node --check src/routes/assets.js。","prisma,node",DONE,DONE,DONE,"server/prisma/schema.prisma;server/prisma/migrations/*;server/src/services/documentSequence.js;server/src/scripts/seed-config.js;server/src/routes/assets.js","[""ASSET1-0""]","涉及 Prisma migration;回滚路径为回滚本次迁移和代码,已生成编号数据不得重用。"
ASSET1-2,资产 API 读写与脱敏索引导入,"扩展 /api/assets 查询、创建和更新字段,并新增 QA-only/管理导入接口读取脱敏索引元数据创建 EA-DOC 草稿资产。","API 支持 assetType、sensitivityLevel、owner/keeper、archiveLocation、externalRef、hash、verifiedAt;导入只接收脱敏元数据,不接收文件正文。","受影响流程:管理员导入公司资料脱敏索引 -> OA 生成 EA-DOC 台账 -> 不上传原文;验证步骤:node --check src/routes/assets.js,并用 focused 单测或脚本校验 payload 隐私边界。","node",DONE,DONE,DONE,"server/src/routes/assets.js;server/tests","[""ASSET1-1""]","导入接口不得读取真实路径原文进入报告;S3/S4 只保留摘要、hash、敏感级别和本地引用。"
ASSET1-3,前端资产台账升级,"升级现有资产页,增加资产大类、敏感级别、负责人/保管人、编号搜索和按类型显示的字段区域。","资产列表可按类型和敏感级别筛选;详情页能录入 FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP 关键元数据;固定资产扫码入口保持可用。","受影响流程:管理员登记固定资产、电子资料、系统、仓库和备份资产;验证步骤:cd client && npm run typecheck && npm run build。","typescript,react",DONE,DONE,DONE,"client/src/api/assets.ts;client/src/pages/AssetRegistryPage.tsx","[""ASSET1-2""]","不在移动端实现复杂电子资料编辑;移动端继续优先固定资产扫码和盘点。"
ASSET1-4,权限边界与离职交接口径,"保持普通员工仅看本人/授权资产,管理员维护台账,财务只看必要摘要;电子系统资产记录权限回收清单但不保存账号密码。","服务端权限不放宽;离职交接仍检查名下资产,新增电子系统/仓库/备份资产可用于人工权限回收。","受影响流程:员工查看自己资产 -> 管理员维护电子资产 -> 离职检查资产/系统权限回收;验证步骤:执行资产回归脚本或现有权限回归。","manual,node",DONE,DONE,DONE,"server/src/routes/assets.js;server/src/routes/employeeLifecycle.js;docs/管理员手册.md","[""ASSET1-2""]","EA-ACC 不进入 V1;账号权限作为 EA-SYS 元数据和离职清单处理。"
ASSET1-5,历史资料脱敏索引试导入,"提供只读/受控导入脚本,从 /home/shunli/Projects/faculty-daily-assistant/公司/00_保密索引 的脱敏索引创建少量 EA-DOC 台账样例。","可导入 3 条脱敏 EA-DOC 元数据;不上传真实文件、不输出完整路径、不输出 S3/S4 原文。","受影响流程:管理员从历史资料目录建立电子资料资产索引;验证步骤:dry-run 报告脱敏,live 写入需明确开关。","bash,node",DONE,DONE,DONE,"scripts/qa;server/src/routes/assets.js","[""ASSET1-2""]","默认 dry-run;公网真实写入历史索引需另行确认,不自动导入 627 条;回滚路径为按导入批次删除未被引用的 EA-DOC 台账。"
ASSET1-6,文档事实源回写,"更新资产管理规约、管理员手册、员工快速上手、文档索引、事实源矩阵和 hyb-oa-delivery skill。","事实源明确统一资产台账、编号族、代码仓库只登记元数据、EA-ACC 暂缓、敏感资料不上传公网 OA。","受影响流程:后来者从文档能判断资产管理范围和安全边界;验证步骤:audit-docs、quick_validate、rg 关键字。","node,python",DONE,DONE,DONE,"docs/需求-资产管理规约.md;docs/管理员手册.md;docs/员工快速上手.md;docs/文档索引.md;docs/文档事实源状态矩阵.md;.agents/skills/hyb-oa-delivery/SKILL.md","[""ASSET1-0""]","优先复用现有事实源;如新增资产规约,同步登记索引和矩阵。"
ASSET1-7,ASSET1 回归门禁,"完成合同、schema、后端静态、前端构建、文档和最小回归。","所有列出的本地门禁通过;如存在非阻断既有 warning 需说明。","受影响流程:资产台账可进入 OAOPS1 后续真实使用候选;验证步骤:validate_issues_csv、check-process-contract、prisma validate、node --check、client typecheck/build、audit-docs、quick_validate、git diff --check。","bash,node,python",DONE,DONE,DONE,"issues/2026-05-22_21-18-33-oa-asset-governance-v1.csv;server;client;docs","[""ASSET1-1"",""ASSET1-3"",""ASSET1-6""]","本轮不部署公网;部署需另行按 OAOPS1/OPS 合同执行。"
ASSET1-8,提交与后续真实使用准备,"中文提交 ASSET1 变更,并记录后续人工试用清单。","git commit 引用 ASSET1;保留后续人工创建 1 条 FA、3 条 EA-DOC、1 条 EA-SYS、1 条 EA-REPO、1 条 EA-BKP 的验收清单。","受影响流程:管理员按清单开始资产台账真实试录;验证步骤:git status 干净,提交信息中文并含 ASSET1。","git",DONE,DONE,DONE,"issues/2026-05-22_21-18-33-oa-asset-governance-v1.csv","[""ASSET1-7""]","真实导入历史目录和公网部署均不在本轮自动执行;若后续公网试录发现阻断,按独立修复合同处理或清理试录资产。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 ASSET1-0 建立电子资产与固定资产统一台账合同 基于国内资产内控、电子档案和数据安全实践,建立统一资产台账 V1 执行合同。 Issue CSV 通过校验;范围固定为复用现有资产模块,统一管理 FA、EA-DOC、EA-SYS、EA-REPO、EA-BKP;EA-ACC 暂缓。 受影响流程:管理员确认资产治理范围和编号族;验证步骤:执行 validate_issues_csv.py 本文件和 check-process-contract general 门禁。 manual DONE DONE DONE issues/2026-05-22_21-18-33-oa-asset-governance-v1.csv [] 不上传敏感原文;不在 OA 保存密码、token、Secret、源码包或完整配置。
3 ASSET1-1 扩展资产模型与编号族 在现有 AssetCard 上增加资产大类、敏感级别、归档/责任/外部引用/校验字段,并将默认固定资产编号从 ZC 调整为 FA。 Prisma schema 和迁移可验证;新资产可生成 FA、EA-DOC、EA-SYS、EA-REPO、EA-BKP 编号;VER 仅保留历史映射字段。 受影响流程:管理员新建统一资产卡 -> 系统按资产大类生成编号;验证步骤:cd server && npm run prisma:validate && node --check src/routes/assets.js。 prisma,node DONE DONE DONE server/prisma/schema.prisma;server/prisma/migrations/*;server/src/services/documentSequence.js;server/src/scripts/seed-config.js;server/src/routes/assets.js ["ASSET1-0"] 涉及 Prisma migration;回滚路径为回滚本次迁移和代码,已生成编号数据不得重用。
4 ASSET1-2 资产 API 读写与脱敏索引导入 扩展 /api/assets 查询、创建和更新字段,并新增 QA-only/管理导入接口读取脱敏索引元数据创建 EA-DOC 草稿资产。 API 支持 assetType、sensitivityLevel、owner/keeper、archiveLocation、externalRef、hash、verifiedAt;导入只接收脱敏元数据,不接收文件正文。 受影响流程:管理员导入公司资料脱敏索引 -> OA 生成 EA-DOC 台账 -> 不上传原文;验证步骤:node --check src/routes/assets.js,并用 focused 单测或脚本校验 payload 隐私边界。 node DONE DONE DONE server/src/routes/assets.js;server/tests ["ASSET1-1"] 导入接口不得读取真实路径原文进入报告;S3/S4 只保留摘要、hash、敏感级别和本地引用。
5 ASSET1-3 前端资产台账升级 升级现有资产页,增加资产大类、敏感级别、负责人/保管人、编号搜索和按类型显示的字段区域。 资产列表可按类型和敏感级别筛选;详情页能录入 FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP 关键元数据;固定资产扫码入口保持可用。 受影响流程:管理员登记固定资产、电子资料、系统、仓库和备份资产;验证步骤:cd client && npm run typecheck && npm run build。 typescript,react DONE DONE DONE client/src/api/assets.ts;client/src/pages/AssetRegistryPage.tsx ["ASSET1-2"] 不在移动端实现复杂电子资料编辑;移动端继续优先固定资产扫码和盘点。
6 ASSET1-4 权限边界与离职交接口径 保持普通员工仅看本人/授权资产,管理员维护台账,财务只看必要摘要;电子系统资产记录权限回收清单但不保存账号密码。 服务端权限不放宽;离职交接仍检查名下资产,新增电子系统/仓库/备份资产可用于人工权限回收。 受影响流程:员工查看自己资产 -> 管理员维护电子资产 -> 离职检查资产/系统权限回收;验证步骤:执行资产回归脚本或现有权限回归。 manual,node DONE DONE DONE server/src/routes/assets.js;server/src/routes/employeeLifecycle.js;docs/管理员手册.md ["ASSET1-2"] EA-ACC 不进入 V1;账号权限作为 EA-SYS 元数据和离职清单处理。
7 ASSET1-5 历史资料脱敏索引试导入 提供只读/受控导入脚本,从 /home/shunli/Projects/faculty-daily-assistant/公司/00_保密索引 的脱敏索引创建少量 EA-DOC 台账样例。 可导入 3 条脱敏 EA-DOC 元数据;不上传真实文件、不输出完整路径、不输出 S3/S4 原文。 受影响流程:管理员从历史资料目录建立电子资料资产索引;验证步骤:dry-run 报告脱敏,live 写入需明确开关。 bash,node DONE DONE DONE scripts/qa;server/src/routes/assets.js ["ASSET1-2"] 默认 dry-run;公网真实写入历史索引需另行确认,不自动导入 627 条;回滚路径为按导入批次删除未被引用的 EA-DOC 台账。
8 ASSET1-6 文档事实源回写 更新资产管理规约、管理员手册、员工快速上手、文档索引、事实源矩阵和 hyb-oa-delivery skill。 事实源明确统一资产台账、编号族、代码仓库只登记元数据、EA-ACC 暂缓、敏感资料不上传公网 OA。 受影响流程:后来者从文档能判断资产管理范围和安全边界;验证步骤:audit-docs、quick_validate、rg 关键字。 node,python DONE DONE DONE docs/需求-资产管理规约.md;docs/管理员手册.md;docs/员工快速上手.md;docs/文档索引.md;docs/文档事实源状态矩阵.md;.agents/skills/hyb-oa-delivery/SKILL.md ["ASSET1-0"] 优先复用现有事实源;如新增资产规约,同步登记索引和矩阵。
9 ASSET1-7 ASSET1 回归门禁 完成合同、schema、后端静态、前端构建、文档和最小回归。 所有列出的本地门禁通过;如存在非阻断既有 warning 需说明。 受影响流程:资产台账可进入 OAOPS1 后续真实使用候选;验证步骤:validate_issues_csv、check-process-contract、prisma validate、node --check、client typecheck/build、audit-docs、quick_validate、git diff --check。 bash,node,python DONE DONE DONE issues/2026-05-22_21-18-33-oa-asset-governance-v1.csv;server;client;docs ["ASSET1-1","ASSET1-3","ASSET1-6"] 本轮不部署公网;部署需另行按 OAOPS1/OPS 合同执行。
10 ASSET1-8 提交与后续真实使用准备 中文提交 ASSET1 变更,并记录后续人工试用清单。 git commit 引用 ASSET1;保留后续人工创建 1 条 FA、3 条 EA-DOC、1 条 EA-SYS、1 条 EA-REPO、1 条 EA-BKP 的验收清单。 受影响流程:管理员按清单开始资产台账真实试录;验证步骤:git status 干净,提交信息中文并含 ASSET1。 git DONE DONE DONE issues/2026-05-22_21-18-33-oa-asset-governance-v1.csv ["ASSET1-7"] 真实导入历史目录和公网部署均不在本轮自动执行;若后续公网试录发现阻断,按独立修复合同处理或清理试录资产。
+108
查看文件
@@ -0,0 +1,108 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const DEFAULT_INDEX =
"/home/shunli/Projects/faculty-daily-assistant/公司/00_保密索引/内部资料保密索引_含真实路径_不得外发.csv";
function argValue(name, fallback = null) {
const prefix = `${name}=`;
const found = process.argv.slice(2).find((item) => item === name || item.startsWith(prefix));
if (!found) return fallback;
if (found === name) return "1";
return found.slice(prefix.length);
}
function parseCsvLine(line) {
const cells = [];
let current = "";
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
const next = line[index + 1];
if (quoted && char === '"' && next === '"') {
current += '"';
index += 1;
continue;
}
if (char === '"') {
quoted = !quoted;
continue;
}
if (!quoted && char === ",") {
cells.push(current);
current = "";
continue;
}
current += char;
}
cells.push(current);
return cells;
}
function parseCsv(content) {
const lines = content.replace(/^\uFEFF/, "").split(/\r?\n/).filter(Boolean);
if (!lines.length) return [];
const headers = parseCsvLine(lines[0]).map((item) => item.trim());
return lines.slice(1).map((line) => {
const values = parseCsvLine(line);
return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ""]));
});
}
function mapSensitivity(level) {
if (level === "S4") return "strictly_confidential";
if (level === "S3") return "confidential";
if (level === "S1") return "public_candidate";
return "internal";
}
const input = argValue("--input", DEFAULT_INDEX);
const limit = Number(argValue("--limit", "3"));
const reportDir =
argValue("--report-dir") ??
path.resolve(process.cwd(), `artifacts/qa-flow/asset-redacted-index-${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}`);
fs.mkdirSync(reportDir, { recursive: true });
const rows = parseCsv(fs.readFileSync(input, "utf8"));
const selected = rows.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 3);
const items = selected.map((row, index) => ({
index,
code: row.code || null,
name: row.doc_type ? `${row.doc_type}-${row.code || index + 1}` : `电子资料-${row.code || index + 1}`,
extension: row.extension || null,
docType: row.doc_type || null,
sourceHash: row.sha256_12 || null,
sensitivityLevel: mapSensitivity(row.level),
detectedCategories: row.detected_categories || null,
requiredRedaction: row.required_redaction || null,
rawFileImported: false,
}));
const bySensitivity = items.reduce((acc, item) => {
acc[item.sensitivityLevel] = (acc[item.sensitivityLevel] ?? 0) + 1;
return acc;
}, {});
const summary = {
issueId: "ASSET1",
status: "PASS_REDACTED_INDEX_DRY_RUN_READY",
mode: "asset-redacted-index-dry-run",
input: "local_confidential_index",
checkedAt: new Date().toISOString(),
totalRows: rows.length,
selectedRows: items.length,
bySensitivity,
items,
boundaries: {
dryRunOnly: true,
noFileUpload: true,
noRawContent: true,
noFullPathOutput: true,
noSecretOutput: true,
},
};
fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
console.log(JSON.stringify(summary, null, 2));
@@ -0,0 +1,28 @@
CREATE TYPE "AssetType" AS ENUM ('fixed_asset', 'electronic_document', 'electronic_system', 'code_repository', 'backup_media');
CREATE TYPE "AssetSensitivityLevel" AS ENUM ('public_candidate', 'internal', 'confidential', 'strictly_confidential');
ALTER TABLE "asset_cards"
ADD COLUMN "asset_type" "AssetType" NOT NULL DEFAULT 'fixed_asset',
ADD COLUMN "sensitivity_level" "AssetSensitivityLevel" NOT NULL DEFAULT 'internal',
ADD COLUMN "owner_employee_id" UUID,
ADD COLUMN "keeper_employee_id" UUID,
ADD COLUMN "archive_location" TEXT,
ADD COLUMN "external_ref" TEXT,
ADD COLUMN "source_hash" TEXT,
ADD COLUMN "verification_no" TEXT,
ADD COLUMN "confidentiality_note" TEXT,
ADD COLUMN "metadata" JSONB,
ADD COLUMN "last_verified_at" DATE,
ADD COLUMN "expires_at" DATE;
CREATE UNIQUE INDEX "asset_cards_verification_no_key" ON "asset_cards"("verification_no");
CREATE INDEX "idx_asset_cards_asset_type" ON "asset_cards"("asset_type");
CREATE INDEX "idx_asset_cards_sensitivity" ON "asset_cards"("sensitivity_level");
CREATE INDEX "idx_asset_cards_owner" ON "asset_cards"("owner_employee_id");
CREATE INDEX "idx_asset_cards_keeper" ON "asset_cards"("keeper_employee_id");
CREATE INDEX "idx_asset_cards_source_hash" ON "asset_cards"("source_hash");
CREATE INDEX "idx_asset_cards_last_verified" ON "asset_cards"("last_verified_at");
ALTER TABLE "asset_cards" ADD CONSTRAINT "asset_cards_owner_employee_id_fkey" FOREIGN KEY ("owner_employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "asset_cards" ADD CONSTRAINT "asset_cards_keeper_employee_id_fkey" FOREIGN KEY ("keeper_employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+37
查看文件
@@ -111,6 +111,21 @@ enum AssetCardStatus {
archived
}
enum AssetType {
fixed_asset
electronic_document
electronic_system
code_repository
backup_media
}
enum AssetSensitivityLevel {
public_candidate
internal
confidential
strictly_confidential
}
enum InventoryCheckScopeType {
all
legal_entity
@@ -639,6 +654,8 @@ model Employee {
purchase_attachments_uploaded PurchaseAttachment[] @relation("PurchaseAttachmentUploader")
purchase_order_archives PurchaseOrderArchive[] @relation("PurchaseOrderArchiveArchivedBy")
assigned_asset_cards AssetCard[] @relation("AssetCardAssignee")
owned_asset_cards AssetCard[] @relation("AssetCardOwner")
kept_asset_cards AssetCard[] @relation("AssetCardKeeper")
asset_assignments AssetAssignment[]
asset_transfers_from AssetTransfer[] @relation("AssetTransferFromEmployee")
asset_transfers_to AssetTransfer[] @relation("AssetTransferToEmployee")
@@ -1198,6 +1215,8 @@ model AssetCard {
id String @id @default(uuid()) @db.Uuid
asset_no String @unique
name String
asset_type AssetType @default(fixed_asset)
sensitivity_level AssetSensitivityLevel @default(internal)
category_id String @db.Uuid
legal_entity_id String @db.Uuid
cost_center_id String @db.Uuid
@@ -1206,6 +1225,16 @@ model AssetCard {
location String?
status AssetCardStatus @default(in_stock)
assignee_employee_id String? @db.Uuid
owner_employee_id String? @db.Uuid
keeper_employee_id String? @db.Uuid
archive_location String?
external_ref String?
source_hash String?
verification_no String? @unique
confidentiality_note String?
metadata Json?
last_verified_at DateTime? @db.Date
expires_at DateTime? @db.Date
activated_at DateTime? @db.Date
retired_at DateTime? @db.Date
@@ -1219,15 +1248,23 @@ model AssetCard {
cost_center CostCenter @relation(fields: [cost_center_id], references: [id], onDelete: Restrict)
purchase_request PurchaseRequest? @relation(fields: [purchase_request_id], references: [id], onDelete: SetNull)
assignee Employee? @relation("AssetCardAssignee", fields: [assignee_employee_id], references: [id], onDelete: SetNull)
owner Employee? @relation("AssetCardOwner", fields: [owner_employee_id], references: [id], onDelete: SetNull)
keeper Employee? @relation("AssetCardKeeper", fields: [keeper_employee_id], references: [id], onDelete: SetNull)
assignments AssetAssignment[]
transfers AssetTransfer[]
inventory_items InventoryCheckItem[]
@@index([asset_type], map: "idx_asset_cards_asset_type")
@@index([sensitivity_level], map: "idx_asset_cards_sensitivity")
@@index([category_id], map: "idx_asset_cards_category")
@@index([legal_entity_id], map: "idx_asset_cards_legal_entity")
@@index([cost_center_id], map: "idx_asset_cards_cost_center")
@@index([purchase_request_id], map: "idx_asset_cards_purchase_request")
@@index([assignee_employee_id], map: "idx_asset_cards_assignee")
@@index([owner_employee_id], map: "idx_asset_cards_owner")
@@index([keeper_employee_id], map: "idx_asset_cards_keeper")
@@index([source_hash], map: "idx_asset_cards_source_hash")
@@index([last_verified_at], map: "idx_asset_cards_last_verified")
@@index([status], map: "idx_asset_cards_status")
@@map("asset_cards")
}
+553 -49
查看文件
@@ -16,6 +16,16 @@ const UUID_SCHEMA = z.string().uuid();
const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "true", "false"]).optional();
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const ASSET_STATUS_VALUES = ["in_stock", "in_use", "under_repair", "retired", "archived"];
const ASSET_TYPE_VALUES = ["fixed_asset", "electronic_document", "electronic_system", "code_repository", "backup_media"];
const ASSET_SENSITIVITY_VALUES = ["public_candidate", "internal", "confidential", "strictly_confidential"];
const ASSET_DOCUMENT_CATEGORY_BY_TYPE = {
fixed_asset: "ASSET_CARD_DEFAULT",
electronic_document: "ASSET_EA_DOC",
electronic_system: "ASSET_EA_SYS",
code_repository: "ASSET_EA_REPO",
backup_media: "ASSET_EA_BKP",
};
const CATEGORY_LIST_QUERY_SCHEMA = z.object({
includeArchived: BOOLEAN_QUERY_SCHEMA,
@@ -24,8 +34,12 @@ const CATEGORY_LIST_QUERY_SCHEMA = z.object({
const ASSET_LIST_QUERY_SCHEMA = z.object({
status: z.enum(ASSET_STATUS_VALUES).optional(),
assetType: z.enum(ASSET_TYPE_VALUES).optional(),
sensitivityLevel: z.enum(ASSET_SENSITIVITY_VALUES).optional(),
categoryId: UUID_SCHEMA.optional(),
assigneeId: UUID_SCHEMA.optional(),
ownerId: UUID_SCHEMA.optional(),
keeperId: UUID_SCHEMA.optional(),
legalEntityId: UUID_SCHEMA.optional(),
costCenterId: UUID_SCHEMA.optional(),
q: z.string().trim().max(120).optional(),
@@ -50,6 +64,8 @@ const ASSET_CATEGORY_PATCH_SCHEMA = ASSET_CATEGORY_CREATE_SCHEMA.partial();
const ASSET_CREATE_SCHEMA = z
.object({
name: z.string().trim().min(1).max(200),
assetType: z.enum(ASSET_TYPE_VALUES).optional(),
sensitivityLevel: z.enum(ASSET_SENSITIVITY_VALUES).optional(),
categoryId: UUID_SCHEMA,
legalEntityId: UUID_SCHEMA,
costCenterId: UUID_SCHEMA,
@@ -58,12 +74,54 @@ const ASSET_CREATE_SCHEMA = z
location: z.string().trim().max(200).nullable().optional(),
status: z.enum(ASSET_STATUS_VALUES).optional(),
assigneeEmployeeId: UUID_SCHEMA.nullable().optional(),
ownerEmployeeId: UUID_SCHEMA.nullable().optional(),
keeperEmployeeId: UUID_SCHEMA.nullable().optional(),
archiveLocation: z.string().trim().max(500).nullable().optional(),
externalRef: z.string().trim().max(500).nullable().optional(),
sourceHash: z.string().trim().max(128).nullable().optional(),
verificationNo: z.string().trim().regex(/^VER-\d{5,}$/).max(32).nullable().optional(),
confidentialityNote: z.string().trim().max(1000).nullable().optional(),
metadata: z.record(z.unknown()).nullable().optional(),
lastVerifiedAt: DATE_ONLY_SCHEMA.nullable().optional(),
expiresAt: DATE_ONLY_SCHEMA.nullable().optional(),
activatedAt: DATE_ONLY_SCHEMA.nullable().optional(),
retiredAt: DATE_ONLY_SCHEMA.nullable().optional(),
})
.strict();
const ASSET_PATCH_SCHEMA = ASSET_CREATE_SCHEMA.omit({ purchaseRequestId: true, assigneeEmployeeId: true }).partial();
const ASSET_PATCH_SCHEMA = ASSET_CREATE_SCHEMA.omit({ purchaseRequestId: true, assigneeEmployeeId: true, assetType: true }).partial();
const REDACTED_DOC_IMPORT_SCHEMA = z
.object({
dryRun: z.boolean().optional(),
write: z.boolean().optional(),
categoryId: UUID_SCHEMA.optional(),
legalEntityId: UUID_SCHEMA.optional(),
costCenterId: UUID_SCHEMA.optional(),
ownerEmployeeId: UUID_SCHEMA.nullable().optional(),
keeperEmployeeId: UUID_SCHEMA.nullable().optional(),
items: z
.array(
z
.object({
code: z.string().trim().max(64).optional(),
name: z.string().trim().min(1).max(200),
sourceHash: z.string().trim().max(128).nullable().optional(),
sensitivityLevel: z.enum(ASSET_SENSITIVITY_VALUES).optional(),
externalRef: z.string().trim().max(500).nullable().optional(),
archiveLocation: z.string().trim().max(500).nullable().optional(),
docType: z.string().trim().max(80).nullable().optional(),
extension: z.string().trim().max(32).nullable().optional(),
detectedCategories: z.string().trim().max(500).nullable().optional(),
requiredRedaction: z.string().trim().max(1000).nullable().optional(),
verificationNo: z.string().trim().regex(/^VER-\d{5,}$/).max(32).nullable().optional(),
})
.strict(),
)
.min(1)
.max(20),
})
.strict();
const ASSET_CATEGORY_SELECT = {
id: true,
@@ -88,6 +146,8 @@ const ASSET_CARD_SELECT = {
id: true,
asset_no: true,
name: true,
asset_type: true,
sensitivity_level: true,
category_id: true,
legal_entity_id: true,
cost_center_id: true,
@@ -96,6 +156,16 @@ const ASSET_CARD_SELECT = {
location: true,
status: true,
assignee_employee_id: true,
owner_employee_id: true,
keeper_employee_id: true,
archive_location: true,
external_ref: true,
source_hash: true,
verification_no: true,
confidentiality_note: true,
metadata: true,
last_verified_at: true,
expires_at: true,
activated_at: true,
retired_at: true,
created_at: true,
@@ -153,6 +223,32 @@ const ASSET_CARD_SELECT = {
},
},
},
owner: {
select: {
id: true,
employee_no: true,
name: true,
department: {
select: {
id: true,
name: true,
},
},
},
},
keeper: {
select: {
id: true,
employee_no: true,
name: true,
department: {
select: {
id: true,
name: true,
},
},
},
},
};
function buildValidationError(details, statusCode = 400) {
@@ -197,8 +293,12 @@ function parseAssetListQuery(query) {
}
return {
status: parsed.data.status,
assetType: parsed.data.assetType,
sensitivityLevel: parsed.data.sensitivityLevel,
categoryId: parsed.data.categoryId,
assigneeId: parsed.data.assigneeId,
ownerId: parsed.data.ownerId,
keeperId: parsed.data.keeperId,
legalEntityId: parsed.data.legalEntityId,
costCenterId: parsed.data.costCenterId,
q: parsed.data.q?.trim() ?? "",
@@ -247,6 +347,8 @@ function normalizeAssetCategoryPayload(body) {
function normalizeAssetPayload(body) {
return {
name: typeof body.name === "string" ? body.name.trim() : undefined,
assetType: body.assetType,
sensitivityLevel: body.sensitivityLevel,
categoryId: body.categoryId,
legalEntityId: body.legalEntityId,
costCenterId: body.costCenterId,
@@ -267,11 +369,120 @@ function normalizeAssetPayload(body) {
assigneeEmployeeId: Object.prototype.hasOwnProperty.call(body, "assigneeEmployeeId")
? body.assigneeEmployeeId ?? null
: undefined,
ownerEmployeeId: Object.prototype.hasOwnProperty.call(body, "ownerEmployeeId")
? body.ownerEmployeeId ?? null
: undefined,
keeperEmployeeId: Object.prototype.hasOwnProperty.call(body, "keeperEmployeeId")
? body.keeperEmployeeId ?? null
: undefined,
archiveLocation: Object.prototype.hasOwnProperty.call(body, "archiveLocation")
? body.archiveLocation?.trim()
? body.archiveLocation.trim()
: null
: undefined,
externalRef: Object.prototype.hasOwnProperty.call(body, "externalRef")
? body.externalRef?.trim()
? body.externalRef.trim()
: null
: undefined,
sourceHash: Object.prototype.hasOwnProperty.call(body, "sourceHash")
? body.sourceHash?.trim()
? body.sourceHash.trim()
: null
: undefined,
verificationNo: Object.prototype.hasOwnProperty.call(body, "verificationNo")
? body.verificationNo?.trim()
? body.verificationNo.trim().toUpperCase()
: null
: undefined,
confidentialityNote: Object.prototype.hasOwnProperty.call(body, "confidentialityNote")
? body.confidentialityNote?.trim()
? body.confidentialityNote.trim()
: null
: undefined,
metadata: Object.prototype.hasOwnProperty.call(body, "metadata") ? body.metadata ?? null : undefined,
lastVerifiedAt: Object.prototype.hasOwnProperty.call(body, "lastVerifiedAt") ? body.lastVerifiedAt ?? null : undefined,
expiresAt: Object.prototype.hasOwnProperty.call(body, "expiresAt") ? body.expiresAt ?? null : undefined,
activatedAt: Object.prototype.hasOwnProperty.call(body, "activatedAt") ? body.activatedAt ?? null : undefined,
retiredAt: Object.prototype.hasOwnProperty.call(body, "retiredAt") ? body.retiredAt ?? null : undefined,
};
}
const SAFE_HASH_PATTERN = /^[A-Za-z0-9:_-]+$/;
const FORBIDDEN_ASSET_TEXT_PATTERNS = [
{ code: "secret_assignment", pattern: /(?:token|secret|password|passwd|pwd|api[_-]?key|access[_-]?key|private[_-]?key|encodingaeskey)\s*[:=]/i },
{ code: "secret_env_name", pattern: /\b[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PRIVATE_KEY|ENCODINGAESKEY)[A-Z0-9_]*\b/ },
{ code: "secret_cn_assignment", pattern: /(?:密码|密钥|私钥|令牌)\s*[::=]/ },
{ code: "bearer_token", pattern: /\bBearer\s+[A-Za-z0-9._-]{10,}/i },
{ code: "private_key_material", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/i },
{ code: "local_absolute_path", pattern: /(^|[\s"'((,,;;])(?:\/(?:home|root|Users|var|etc|mnt|Volumes|app)\/|[A-Za-z]:[\\/]|\\\\)/i },
{ code: "raw_payload", pattern: /(原始\s*(?:payload|xml|报文|正文|表单)|raw\s*(?:payload|xml|body|form))/i },
];
function findUnsafeAssetText(value) {
if (typeof value !== "string" || !value.trim()) {
return null;
}
if (value.length > 1500) {
return "too_long";
}
const matched = FORBIDDEN_ASSET_TEXT_PATTERNS.find((rule) => rule.pattern.test(value));
return matched?.code ?? null;
}
function validateSourceHash(fieldName, value, details) {
if (typeof value === "string" && value && !SAFE_HASH_PATTERN.test(value)) {
details[fieldName] = "unsafe_hash_format";
}
}
function validateSafeTextField(fieldName, value, details) {
const unsafeReason = findUnsafeAssetText(value);
if (unsafeReason) {
details[fieldName] = unsafeReason;
}
}
function validateMetadataField(fieldName, value, details) {
if (value == null) {
return;
}
const serialized = JSON.stringify(value);
if (serialized.length > 2000) {
details[fieldName] = "too_large";
return;
}
validateSafeTextField(fieldName, serialized, details);
}
function validateAssetTextBoundaries(payload) {
const details = {};
validateSafeTextField("name", payload.name, details);
validateSafeTextField("archiveLocation", payload.archiveLocation, details);
validateSafeTextField("externalRef", payload.externalRef, details);
validateSafeTextField("confidentialityNote", payload.confidentialityNote, details);
validateSourceHash("sourceHash", payload.sourceHash, details);
validateMetadataField("metadata", payload.metadata, details);
if (Object.keys(details).length > 0) {
throw buildValidationError(details);
}
}
function validateRedactedImportBoundaries(payload) {
const details = {};
for (const [index, item] of payload.items.entries()) {
validateSafeTextField(`items.${index}.name`, item.name, details);
validateSafeTextField(`items.${index}.archiveLocation`, item.archiveLocation, details);
validateSafeTextField(`items.${index}.externalRef`, item.externalRef, details);
validateSafeTextField(`items.${index}.detectedCategories`, item.detectedCategories, details);
validateSafeTextField(`items.${index}.requiredRedaction`, item.requiredRedaction, details);
validateSourceHash(`items.${index}.sourceHash`, item.sourceHash, details);
}
if (Object.keys(details).length > 0) {
throw buildValidationError(details);
}
}
function canReadAssets(fastify, employee) {
return ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"].some((permission) =>
fastify.hasPermission(employee, permission),
@@ -303,7 +514,12 @@ function ensureAssetViewerOwnsRecord(fastify, request, assetRecord) {
if (canViewAllAssets(fastify, request.employee)) {
return;
}
if (assetRecord.assignee_employee_id !== request.employee.id) {
const employeeId = request.employee.id;
if (
assetRecord.assignee_employee_id !== employeeId &&
assetRecord.owner_employee_id !== employeeId &&
assetRecord.keeper_employee_id !== employeeId
) {
throw new AppError({
code: "PERM_001",
message: "无权限",
@@ -313,6 +529,26 @@ function ensureAssetViewerOwnsRecord(fastify, request, assetRecord) {
}
}
function isAssetRelatedEmployee(request, record) {
const employeeId = request.employee?.id;
return Boolean(
employeeId &&
(record.assignee_employee_id === employeeId ||
record.owner_employee_id === employeeId ||
record.keeper_employee_id === employeeId),
);
}
function canViewAssetSensitiveFields(fastify, request, record) {
if (canManageAssets(fastify, request.employee) || isAssetRelatedEmployee(request, record)) {
return true;
}
if (record.asset_type && record.asset_type !== "fixed_asset") {
return false;
}
return !["confidential", "strictly_confidential"].includes(record.sensitivity_level);
}
function mapAssetCategory(record) {
return {
id: record.id,
@@ -334,11 +570,31 @@ function mapAssetCategory(record) {
};
}
function mapEmployeeRef(record) {
if (!record) {
return null;
}
return {
id: record.id,
employeeNo: record.employee_no,
name: record.name,
department: record.department
? {
id: record.department.id,
name: record.department.name,
}
: null,
};
}
function mapAsset(record, request, fastify) {
const canViewSensitiveFields = canViewAssetSensitiveFields(fastify, request, record);
return {
id: record.id,
assetNo: record.asset_no,
name: record.name,
assetType: record.asset_type,
sensitivityLevel: record.sensitivity_level,
categoryId: record.category_id,
legalEntityId: record.legal_entity_id,
costCenterId: record.cost_center_id,
@@ -347,6 +603,17 @@ function mapAsset(record, request, fastify) {
location: record.location ?? null,
status: record.status,
assigneeEmployeeId: record.assignee_employee_id ?? null,
ownerEmployeeId: record.owner_employee_id ?? null,
keeperEmployeeId: record.keeper_employee_id ?? null,
archiveLocation: canViewSensitiveFields ? record.archive_location ?? null : null,
externalRef: canViewSensitiveFields ? record.external_ref ?? null : null,
sourceHash: canViewSensitiveFields ? record.source_hash ?? null : null,
verificationNo: record.verification_no ?? null,
confidentialityNote: canViewSensitiveFields ? record.confidentiality_note ?? null : null,
metadata: canViewSensitiveFields ? record.metadata ?? null : null,
sensitiveFieldsRedacted: !canViewSensitiveFields,
lastVerifiedAt: record.last_verified_at?.toISOString?.().slice(0, 10) ?? null,
expiresAt: record.expires_at?.toISOString?.().slice(0, 10) ?? null,
activatedAt: record.activated_at?.toISOString?.().slice(0, 10) ?? null,
retiredAt: record.retired_at?.toISOString?.().slice(0, 10) ?? null,
createdAt: record.created_at?.toISOString?.() ?? null,
@@ -389,19 +656,9 @@ function mapAsset(record, request, fastify) {
status: record.purchase_request.status,
}
: null,
assignee: record.assignee
? {
id: record.assignee.id,
employeeNo: record.assignee.employee_no,
name: record.assignee.name,
department: record.assignee.department
? {
id: record.assignee.department.id,
name: record.assignee.department.name,
}
: null,
}
: null,
assignee: mapEmployeeRef(record.assignee),
owner: mapEmployeeRef(record.owner),
keeper: mapEmployeeRef(record.keeper),
permissions: {
canEdit: canManageAssets(fastify, request.employee),
},
@@ -521,12 +778,12 @@ async function ensureCostCenterAvailable(tx, costCenterId, legalEntityId) {
return record;
}
async function ensureAssigneeEmployee(tx, assigneeEmployeeId) {
if (!assigneeEmployeeId) {
async function ensureActiveEmployee(tx, fieldName, employeeId) {
if (!employeeId) {
return null;
}
const record = await tx.employee.findUnique({
where: { id: assigneeEmployeeId },
where: { id: employeeId },
select: {
id: true,
employee_no: true,
@@ -535,14 +792,29 @@ async function ensureAssigneeEmployee(tx, assigneeEmployeeId) {
},
});
if (!record) {
throw buildValidationError({ assigneeEmployeeId: "not_found" }, 404);
throw buildValidationError({ [fieldName]: "not_found" }, 404);
}
if (record.status !== "active") {
throw buildValidationError({ assigneeEmployeeId: "inactive" }, 409);
throw buildValidationError({ [fieldName]: "inactive" }, 409);
}
return record;
}
async function ensureAssigneeEmployee(tx, assigneeEmployeeId) {
return ensureActiveEmployee(tx, "assigneeEmployeeId", assigneeEmployeeId);
}
function assetDocumentCategoryCode(assetType, category = null) {
if (assetType === "fixed_asset" && category?.document_category?.code) {
return category.document_category.code;
}
return ASSET_DOCUMENT_CATEGORY_BY_TYPE[assetType] ?? ASSET_DOCUMENT_CATEGORY_BY_TYPE.fixed_asset;
}
function assetSequenceKey(assetType, documentCategoryCode) {
return `asset_card:${documentCategoryCode || assetDocumentCategoryCode(assetType)}`;
}
async function ensureApprovedPurchaseRequest(tx, purchaseRequestId) {
if (!purchaseRequestId) {
return null;
@@ -878,6 +1150,12 @@ async function assetsRoutes(fastify) {
if (query.status) {
baseWhere.status = query.status;
}
if (query.assetType) {
baseWhere.asset_type = query.assetType;
}
if (query.sensitivityLevel) {
baseWhere.sensitivity_level = query.sensitivityLevel;
}
if (query.categoryId) {
baseWhere.category_id = query.categoryId;
}
@@ -888,10 +1166,20 @@ async function assetsRoutes(fastify) {
baseWhere.cost_center_id = query.costCenterId;
}
if (mine) {
baseWhere.assignee_employee_id = request.employee.id;
baseWhere.OR = [
{ assignee_employee_id: request.employee.id },
{ owner_employee_id: request.employee.id },
{ keeper_employee_id: request.employee.id },
];
} else if (query.assigneeId) {
baseWhere.assignee_employee_id = query.assigneeId;
}
if (!mine && query.ownerId) {
baseWhere.owner_employee_id = query.ownerId;
}
if (!mine && query.keeperId) {
baseWhere.keeper_employee_id = query.keeperId;
}
const listOptions = {
orderBy: [{ updated_at: "desc" }, { created_at: "desc" }],
@@ -900,36 +1188,24 @@ async function assetsRoutes(fastify) {
let items;
if (query.q) {
const searchConditions = [
{ asset_no: query.q },
{ serial_no: query.q },
{ verification_no: query.q.toUpperCase() },
{ name: { contains: query.q, mode: "insensitive" } },
];
if (canManageAssets(fastify, request.employee) || mine) {
searchConditions.splice(3, 0, { source_hash: query.q });
searchConditions.splice(4, 0, { external_ref: { contains: query.q, mode: "insensitive" } });
searchConditions.splice(5, 0, { archive_location: { contains: query.q, mode: "insensitive" } });
}
const searchWhere = {
OR: searchConditions,
};
items = await fastify.prisma.assetCard.findMany({
where: {
...baseWhere,
asset_no: query.q,
},
where: { AND: [baseWhere, searchWhere] },
...listOptions,
});
if (!items.length) {
items = await fastify.prisma.assetCard.findMany({
where: {
...baseWhere,
serial_no: query.q,
},
...listOptions,
});
}
if (!items.length) {
items = await fastify.prisma.assetCard.findMany({
where: {
...baseWhere,
name: {
contains: query.q,
mode: "insensitive",
},
},
...listOptions,
});
}
} else {
items = await fastify.prisma.assetCard.findMany({
where: baseWhere,
@@ -952,16 +1228,22 @@ async function assetsRoutes(fastify) {
},
async (request, reply) => {
const payload = normalizeAssetPayload(parseCreateBody(ASSET_CREATE_SCHEMA, request.body));
validateAssetTextBoundaries(payload);
const asset = await fastify.prisma.$transaction(async (tx) => {
const category = await ensureAssetCategoryExists(tx, payload.categoryId, { requireActive: true });
await ensureLegalEntityAvailable(tx, payload.legalEntityId);
await ensureCostCenterAvailable(tx, payload.costCenterId, payload.legalEntityId);
await ensureAssigneeEmployee(tx, payload.assigneeEmployeeId ?? null);
await ensureActiveEmployee(tx, "ownerEmployeeId", payload.ownerEmployeeId ?? null);
await ensureActiveEmployee(tx, "keeperEmployeeId", payload.keeperEmployeeId ?? null);
await ensureApprovedPurchaseRequest(tx, payload.purchaseRequestId ?? null);
const assetType = payload.assetType ?? "fixed_asset";
const documentCategoryCode = assetDocumentCategoryCode(assetType, category);
const issued = await issueDocumentNumber(tx, {
documentType: "asset_card",
documentCategoryCode: category.document_category?.code ?? null,
documentCategoryCode,
sequenceKey: assetSequenceKey(assetType, documentCategoryCode),
actorId: request.employee.id,
});
@@ -971,6 +1253,8 @@ async function assetsRoutes(fastify) {
data: {
asset_no: issued.documentNumber,
name: payload.name,
asset_type: assetType,
sensitivity_level: payload.sensitivityLevel ?? "internal",
category_id: payload.categoryId,
legal_entity_id: payload.legalEntityId,
cost_center_id: payload.costCenterId,
@@ -979,6 +1263,16 @@ async function assetsRoutes(fastify) {
location: payload.location ?? null,
status: resolvedStatus,
assignee_employee_id: payload.assigneeEmployeeId ?? null,
owner_employee_id: payload.ownerEmployeeId ?? null,
keeper_employee_id: payload.keeperEmployeeId ?? null,
archive_location: payload.archiveLocation ?? null,
external_ref: payload.externalRef ?? null,
source_hash: payload.sourceHash ?? null,
verification_no: payload.verificationNo ?? null,
confidentiality_note: payload.confidentialityNote ?? null,
metadata: payload.metadata ?? null,
last_verified_at: payload.lastVerifiedAt ? parseDateOnlyString(payload.lastVerifiedAt) : null,
expires_at: payload.expiresAt ? parseDateOnlyString(payload.expiresAt) : null,
activated_at: payload.activatedAt ? parseDateOnlyString(payload.activatedAt) : null,
retired_at: payload.retiredAt ? parseDateOnlyString(payload.retiredAt) : null,
created_by: request.employee.id,
@@ -1009,8 +1303,12 @@ async function assetsRoutes(fastify) {
targetId: asset.id,
extra: {
assetNo: asset.asset_no,
assetType: asset.asset_type,
sensitivityLevel: asset.sensitivity_level,
purchaseRequestId: asset.purchase_request_id ?? null,
assigneeEmployeeId: asset.assignee_employee_id ?? null,
ownerEmployeeId: asset.owner_employee_id ?? null,
keeperEmployeeId: asset.keeper_employee_id ?? null,
},
}),
}).catch((error) => {
@@ -1032,6 +1330,177 @@ async function assetsRoutes(fastify) {
},
);
fastify.post(
"/assets/import/redacted-doc-index",
{
schema: { tags: ["Assets"], summary: "Import Redacted Electronic Document Asset Index" },
preHandler: fastify.requirePermission("asset.manage"),
},
async (request, reply) => {
const payload = parseCreateBody(REDACTED_DOC_IMPORT_SCHEMA, request.body);
validateRedactedImportBoundaries(payload);
const shouldWrite = payload.write === true && payload.dryRun !== true;
if (shouldWrite && process.env.ASSET_INDEX_IMPORT_WRITE !== "1") {
throw new AppError({
code: "CONFIG_001",
message: "资产索引写入开关未开启",
statusCode: 409,
details: { requiredEnv: "ASSET_INDEX_IMPORT_WRITE=1" },
});
}
if (shouldWrite && (!payload.categoryId || !payload.legalEntityId || !payload.costCenterId)) {
throw buildValidationError({
categoryId: payload.categoryId ? undefined : "required_for_write",
legalEntityId: payload.legalEntityId ? undefined : "required_for_write",
costCenterId: payload.costCenterId ? undefined : "required_for_write",
});
}
const preview = payload.items.map((item, index) => ({
index,
code: item.code ?? null,
name: item.name,
sourceHash: item.sourceHash ?? null,
sensitivityLevel: item.sensitivityLevel ?? "confidential",
action: shouldWrite ? "create_or_skip" : "dry_run",
}));
if (!shouldWrite) {
return sendData(reply, request, {
mode: "dry_run",
readOnly: true,
imported: 0,
items: preview,
boundaries: {
noFileUpload: true,
noRawContent: true,
noFullPathOutput: true,
},
});
}
const result = await fastify.prisma.$transaction(async (tx) => {
const category = await ensureAssetCategoryExists(tx, payload.categoryId, { requireActive: true });
await ensureLegalEntityAvailable(tx, payload.legalEntityId);
await ensureCostCenterAvailable(tx, payload.costCenterId, payload.legalEntityId);
await ensureActiveEmployee(tx, "ownerEmployeeId", payload.ownerEmployeeId ?? null);
await ensureActiveEmployee(tx, "keeperEmployeeId", payload.keeperEmployeeId ?? null);
const items = [];
for (const [index, item] of payload.items.entries()) {
const existing = item.sourceHash
? await tx.assetCard.findFirst({
where: { source_hash: item.sourceHash, asset_type: "electronic_document" },
select: { id: true, asset_no: true },
})
: null;
if (existing) {
items.push({
index,
code: item.code ?? null,
status: "skipped_existing_hash",
assetId: existing.id,
assetNo: existing.asset_no,
sourceHash: item.sourceHash ?? null,
});
continue;
}
const issued = await issueDocumentNumber(tx, {
documentType: "asset_card",
documentCategoryCode: assetDocumentCategoryCode("electronic_document"),
sequenceKey: assetSequenceKey("electronic_document", assetDocumentCategoryCode("electronic_document")),
actorId: request.employee.id,
});
const created = await tx.assetCard.create({
data: {
asset_no: issued.documentNumber,
name: item.name.trim(),
asset_type: "electronic_document",
sensitivity_level: item.sensitivityLevel ?? "confidential",
category_id: payload.categoryId,
legal_entity_id: payload.legalEntityId,
cost_center_id: payload.costCenterId,
status: "archived",
owner_employee_id: payload.ownerEmployeeId ?? null,
keeper_employee_id: payload.keeperEmployeeId ?? null,
archive_location: item.archiveLocation ?? null,
external_ref: item.code ?? item.externalRef ?? null,
source_hash: item.sourceHash ?? null,
verification_no: item.verificationNo ?? null,
confidentiality_note: item.requiredRedaction ?? null,
metadata: {
source: "redacted_company_index",
docType: item.docType ?? null,
extension: item.extension ?? null,
detectedCategories: item.detectedCategories ?? null,
rawFileImported: false,
},
last_verified_at: new Date(),
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: { id: true, asset_no: true },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ASSET_REDACTED_DOC_INDEX_IMPORTED",
targetType: "asset_card",
targetId: created.id,
extra: {
assetNo: created.asset_no,
code: item.code ?? null,
sourceHash: item.sourceHash ?? null,
rawFileImported: false,
},
}),
});
items.push({
index,
code: item.code ?? null,
status: "created",
assetId: created.id,
assetNo: created.asset_no,
sourceHash: item.sourceHash ?? null,
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ASSET_REDACTED_DOC_INDEX_IMPORT_BATCH",
targetType: "asset_card",
targetId: null,
extra: {
itemCount: payload.items.length,
createdCount: items.filter((item) => item.status === "created").length,
categoryId: category.id,
rawFileImported: false,
},
}),
});
return items;
});
return sendData(reply, request, {
mode: "write",
readOnly: false,
imported: result.filter((item) => item.status === "created").length,
items: result,
boundaries: {
noFileUpload: true,
noRawContent: true,
noFullPathOutput: true,
},
});
},
);
fastify.get(
"/assets/:id",
{
@@ -1062,6 +1531,7 @@ async function assetsRoutes(fastify) {
async (request, reply) => {
const assetId = parseUuid("id", request.params?.id);
const payload = normalizeAssetPayload(parsePatchBody(ASSET_PATCH_SCHEMA, request.body));
validateAssetTextBoundaries(payload);
const { asset, assetLedgerSummaryChange } = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.assetCard.findUnique({
@@ -1070,6 +1540,8 @@ async function assetsRoutes(fastify) {
id: true,
asset_no: true,
name: true,
asset_type: true,
sensitivity_level: true,
category_id: true,
legal_entity_id: true,
cost_center_id: true,
@@ -1078,6 +1550,16 @@ async function assetsRoutes(fastify) {
location: true,
status: true,
assignee_employee_id: true,
owner_employee_id: true,
keeper_employee_id: true,
archive_location: true,
external_ref: true,
source_hash: true,
verification_no: true,
confidentiality_note: true,
metadata: true,
last_verified_at: true,
expires_at: true,
activated_at: true,
retired_at: true,
},
@@ -1093,6 +1575,8 @@ async function assetsRoutes(fastify) {
if (payload.categoryId !== undefined) {
await ensureAssetCategoryExists(tx, nextCategoryId, { requireActive: true });
}
await ensureActiveEmployee(tx, "ownerEmployeeId", payload.ownerEmployeeId ?? null);
await ensureActiveEmployee(tx, "keeperEmployeeId", payload.keeperEmployeeId ?? null);
if (payload.legalEntityId !== undefined) {
await ensureLegalEntityAvailable(tx, nextLegalEntityId);
}
@@ -1104,12 +1588,28 @@ async function assetsRoutes(fastify) {
where: { id: assetId },
data: {
...(payload.name !== undefined ? { name: payload.name } : {}),
...(payload.assetType !== undefined ? { asset_type: payload.assetType } : {}),
...(payload.sensitivityLevel !== undefined ? { sensitivity_level: payload.sensitivityLevel } : {}),
...(payload.categoryId !== undefined ? { category_id: payload.categoryId } : {}),
...(payload.legalEntityId !== undefined ? { legal_entity_id: payload.legalEntityId } : {}),
...(payload.costCenterId !== undefined ? { cost_center_id: payload.costCenterId } : {}),
...(payload.serialNo !== undefined ? { serial_no: payload.serialNo } : {}),
...(payload.location !== undefined ? { location: payload.location } : {}),
...(payload.status !== undefined ? { status: payload.status } : {}),
...(payload.ownerEmployeeId !== undefined ? { owner_employee_id: payload.ownerEmployeeId } : {}),
...(payload.keeperEmployeeId !== undefined ? { keeper_employee_id: payload.keeperEmployeeId } : {}),
...(payload.archiveLocation !== undefined ? { archive_location: payload.archiveLocation } : {}),
...(payload.externalRef !== undefined ? { external_ref: payload.externalRef } : {}),
...(payload.sourceHash !== undefined ? { source_hash: payload.sourceHash } : {}),
...(payload.verificationNo !== undefined ? { verification_no: payload.verificationNo } : {}),
...(payload.confidentialityNote !== undefined ? { confidentiality_note: payload.confidentialityNote } : {}),
...(payload.metadata !== undefined ? { metadata: payload.metadata } : {}),
...(payload.lastVerifiedAt !== undefined
? { last_verified_at: payload.lastVerifiedAt ? parseDateOnlyString(payload.lastVerifiedAt) : null }
: {}),
...(payload.expiresAt !== undefined
? { expires_at: payload.expiresAt ? parseDateOnlyString(payload.expiresAt) : null }
: {}),
...(payload.activatedAt !== undefined
? { activated_at: payload.activatedAt ? parseDateOnlyString(payload.activatedAt) : null }
: {}),
@@ -1129,8 +1629,12 @@ async function assetsRoutes(fastify) {
targetId: updated.id,
extra: {
assetNo: current.asset_no,
assetType: updated.asset_type,
sensitivityLevel: updated.sensitivity_level,
status: updated.status,
assigneeEmployeeId: updated.assignee_employee_id ?? null,
ownerEmployeeId: updated.owner_employee_id ?? null,
keeperEmployeeId: updated.keeper_employee_id ?? null,
},
}),
});
+42 -2
查看文件
@@ -186,8 +186,48 @@ const DEFAULT_DOCUMENT_CATEGORIES = [
code: "ASSET_CARD_DEFAULT",
name: "固定资产卡片",
entity_type: "asset_card",
sequence_prefix: "ZC",
number_length: 4,
sequence_prefix: "FA",
number_length: 6,
reset_period: "yearly",
default_workflow_key: null,
enabled: true,
},
{
code: "ASSET_EA_DOC",
name: "电子资料资产",
entity_type: "asset_card",
sequence_prefix: "EA-DOC",
number_length: 6,
reset_period: "yearly",
default_workflow_key: null,
enabled: true,
},
{
code: "ASSET_EA_SYS",
name: "电子系统资产",
entity_type: "asset_card",
sequence_prefix: "EA-SYS",
number_length: 6,
reset_period: "yearly",
default_workflow_key: null,
enabled: true,
},
{
code: "ASSET_EA_REPO",
name: "代码仓库资产",
entity_type: "asset_card",
sequence_prefix: "EA-REPO",
number_length: 6,
reset_period: "yearly",
default_workflow_key: null,
enabled: true,
},
{
code: "ASSET_EA_BKP",
name: "备份介质资产",
entity_type: "asset_card",
sequence_prefix: "EA-BKP",
number_length: 6,
reset_period: "yearly",
default_workflow_key: null,
enabled: true,
+26 -10
查看文件
@@ -65,10 +65,10 @@ const DOCUMENT_TYPE_CONFIG = {
defaultCategoryCode: "ASSET_CARD_DEFAULT",
preferLegacySequence: false,
fallback: {
prefix: "ZC",
dateFormat: "YYYYMMDD",
prefix: "FA",
dateFormat: "YYYY",
separator: "-",
numberLength: 4,
numberLength: 6,
resetPeriod: "yearly",
},
},
@@ -151,6 +151,7 @@ function buildConfigFromFallback(documentType) {
function buildConfigFromCategory(documentType, category, sequenceRecord) {
const fallback = DOCUMENT_TYPE_CONFIG[documentType].fallback;
const mappedSequence = mapSequenceRecord(sequenceRecord);
const shouldPreferFallbackDateFormat = documentType === "asset_card";
return {
exists: Boolean(sequenceRecord),
@@ -158,7 +159,7 @@ function buildConfigFromCategory(documentType, category, sequenceRecord) {
documentType,
documentCategoryCode: category.code,
prefix: category.sequence_prefix,
dateFormat: mappedSequence?.dateFormat ?? fallback.dateFormat,
dateFormat: shouldPreferFallbackDateFormat ? fallback.dateFormat : mappedSequence?.dateFormat ?? fallback.dateFormat,
separator: mappedSequence?.separator ?? fallback.separator,
numberLength: category.number_length,
resetPeriod: category.reset_period,
@@ -249,12 +250,13 @@ async function findDocumentCategory(prisma, { documentType, documentCategoryCode
});
}
async function resolveDocumentSequenceConfig(prisma, { documentType, documentCategoryCode = null } = {}) {
async function resolveDocumentSequenceConfig(prisma, { documentType, documentCategoryCode = null, sequenceKey = null } = {}) {
const normalizedDocumentType = normalizeDocumentType(documentType);
const documentTypeConfig = DOCUMENT_TYPE_CONFIG[normalizedDocumentType];
const resolvedSequenceKey = sequenceKey || normalizedDocumentType;
const sequenceRecord = await prisma.documentSequence.findUnique({
where: { document_type: normalizedDocumentType },
where: { document_type: resolvedSequenceKey },
select: {
document_type: true,
prefix: true,
@@ -299,24 +301,37 @@ async function resolveDocumentSequenceConfig(prisma, { documentType, documentCat
return buildConfigFromFallback(normalizedDocumentType);
}
function formatDatePart(dateFormat, timeZone) {
const compact = compactDateString(timeZone);
if (dateFormat === "YYYY") {
return compact.slice(0, 4);
}
if (dateFormat === "YYYYMM") {
return compact.slice(0, 6);
}
return compact;
}
function formatDocumentNumber(sequenceConfig, timeZone) {
const datePart = compactDateString(timeZone);
const datePart = formatDatePart(sequenceConfig.dateFormat, timeZone);
const serial = String(sequenceConfig.current_number).padStart(sequenceConfig.number_length, "0");
return `${sequenceConfig.prefix}${sequenceConfig.separator}${datePart}${sequenceConfig.separator}${serial}`;
}
async function issueDocumentNumber(
tx,
{ documentType, documentCategoryCode = null, actorId = null, timeZone = "Asia/Shanghai" } = {},
{ documentType, documentCategoryCode = null, sequenceKey = null, actorId = null, timeZone = "Asia/Shanghai" } = {},
) {
const normalizedDocumentType = normalizeDocumentType(documentType);
const resolvedSequenceKey = sequenceKey || normalizedDocumentType;
const config = await resolveDocumentSequenceConfig(tx, {
documentType: normalizedDocumentType,
documentCategoryCode,
sequenceKey: resolvedSequenceKey,
});
const sequenceRecord = await tx.documentSequence.upsert({
where: { document_type: normalizedDocumentType },
where: { document_type: resolvedSequenceKey },
update: {
prefix: config.prefix,
date_format: config.dateFormat,
@@ -327,7 +342,7 @@ async function issueDocumentNumber(
updated_by: actorId,
},
create: {
document_type: normalizedDocumentType,
document_type: resolvedSequenceKey,
prefix: config.prefix,
date_format: config.dateFormat,
separator: config.separator,
@@ -356,6 +371,7 @@ async function issueDocumentNumber(
{
prefix: mappedSequence.prefix,
separator: mappedSequence.separator,
dateFormat: mappedSequence.dateFormat,
number_length: mappedSequence.numberLength,
current_number: mappedSequence.currentNumber,
},
@@ -207,4 +207,45 @@ describe("documentSequence service", () => {
expect(config.documentCategoryCode).toBe("ASSET_CARD_DEFAULT");
expect(config.prefix).toBe("ZC");
});
it("can issue asset numbers with independent sequence keys per asset number family", async () => {
const prisma = buildPrisma({
sequenceMap: {
"asset_card:ASSET_EA_DOC": {
document_type: "asset_card:ASSET_EA_DOC",
prefix: "EA-DOC",
date_format: "YYYY",
separator: "-",
number_length: 6,
reset_period: "yearly",
current_number: 8,
last_reset_date: null,
updated_at: new Date("2026-03-07T00:00:00.000Z"),
updated_by: "tester",
},
},
categoryMap: {
ASSET_EA_DOC: {
code: "ASSET_EA_DOC",
entity_type: "asset_card",
sequence_prefix: "EA-DOC",
number_length: 6,
reset_period: "yearly",
enabled: true,
archived_at: null,
},
},
});
const issued = await issueDocumentNumber(prisma, {
documentType: "asset_card",
documentCategoryCode: "ASSET_EA_DOC",
sequenceKey: "asset_card:ASSET_EA_DOC",
actorId: "tester",
timeZone: "Asia/Shanghai",
});
expect(issued.sequence.documentType).toBe("asset_card:ASSET_EA_DOC");
expect(issued.documentNumber).toMatch(/^EA-DOC-\d{4}-000009$/);
});
});
+132 -121
查看文件
@@ -5,9 +5,18 @@ const assetsRoutes = require('../../../src/routes/assets');
function buildAssetRecord({
id = 'asset-1',
assetNo = 'ZC-001',
assetNo = 'FA-2026-000001',
serialNo = 'SN-001',
assetType = 'fixed_asset',
sensitivityLevel = 'internal',
assigneeEmployeeId = 'employee-1',
ownerEmployeeId = null,
keeperEmployeeId = null,
archiveLocation = null,
externalRef = null,
sourceHash = null,
confidentialityNote = null,
metadata = null,
name = '测试笔记本',
} = {}) {
const now = new Date('2026-03-11T00:00:00.000Z');
@@ -15,6 +24,8 @@ function buildAssetRecord({
id,
asset_no: assetNo,
name,
asset_type: assetType,
sensitivity_level: sensitivityLevel,
category_id: 'cat-1',
legal_entity_id: 'legal-1',
cost_center_id: 'cc-1',
@@ -23,6 +34,16 @@ function buildAssetRecord({
location: '深圳办公室',
status: 'in_use',
assignee_employee_id: assigneeEmployeeId,
owner_employee_id: ownerEmployeeId,
keeper_employee_id: keeperEmployeeId,
archive_location: archiveLocation,
external_ref: externalRef,
source_hash: sourceHash,
verification_no: null,
confidentiality_note: confidentialityNote,
metadata,
last_verified_at: null,
expires_at: null,
activated_at: now,
retired_at: null,
created_at: now,
@@ -46,6 +67,8 @@ function buildAssetRecord({
name: '张三',
department: null,
},
owner: null,
keeper: null,
};
}
@@ -109,25 +132,25 @@ async function buildApp({ employee, prisma }) {
}
describe('assets search route', () => {
it('prefers exact assetNo matching before serial or fuzzy lookup', async () => {
it('uses one scoped OR query for assetNo, serialNo, VER/hash/ref/location and name lookup', async () => {
const prisma = buildPrisma({
items: [buildAssetRecord()],
});
const app = await buildApp({
employee: { id: 'manager-1', permissionCodes: ['asset.view_all'] },
employee: { id: 'admin-1', permissionCodes: ['asset.manage'] },
prisma,
});
const response = await app.inject({
method: 'GET',
url: '/api/assets?q=ZC-001',
url: '/api/assets?q=FA-2026-000001',
headers: { authorization: 'Bearer token' },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.items[0]).toEqual(
expect.objectContaining({
assetNo: 'ZC-001',
assetNo: 'FA-2026-000001',
serialNo: 'SN-001',
}),
);
@@ -135,100 +158,18 @@ describe('assets search route', () => {
expect(prisma.assetCard.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
asset_no: 'ZC-001',
}),
}),
);
await app.close();
});
it('falls back to serialNo exact lookup when assetNo misses', async () => {
const prisma = {
assetCard: {
findMany: vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([buildAssetRecord({ id: 'asset-2', assetNo: 'ZC-003', serialNo: 'SN-003' })]),
},
};
const app = await buildApp({
employee: { id: 'manager-1', permissionCodes: ['asset.view_all'] },
prisma,
});
const response = await app.inject({
method: 'GET',
url: '/api/assets?q=SN-003',
headers: { authorization: 'Bearer token' },
});
expect(response.statusCode).toBe(200);
expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(2);
expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: expect.objectContaining({
asset_no: 'SN-003',
}),
}),
);
expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
where: expect.objectContaining({
serial_no: 'SN-003',
}),
}),
);
await app.close();
});
it('falls back to fuzzy name search only after both exact lookups miss', async () => {
const prisma = {
assetCard: {
findMany: vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([buildAssetRecord({ id: 'asset-3', assetNo: 'ZC-004', serialNo: 'SN-004', name: '研发测试笔记本' })]),
},
};
const app = await buildApp({
employee: { id: 'manager-1', permissionCodes: ['asset.view_all'] },
prisma,
});
const response = await app.inject({
method: 'GET',
url: '/api/assets?q=测试笔记本',
headers: { authorization: 'Bearer token' },
});
expect(response.statusCode).toBe(200);
expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(3);
expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: expect.objectContaining({
asset_no: '测试笔记本',
}),
}),
);
expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
where: expect.objectContaining({
serial_no: '测试笔记本',
}),
}),
);
expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
where: expect.objectContaining({
name: { contains: '测试笔记本', mode: 'insensitive' },
AND: expect.arrayContaining([
expect.objectContaining({}),
expect.objectContaining({
OR: expect.arrayContaining([
{ asset_no: 'FA-2026-000001' },
{ serial_no: 'FA-2026-000001' },
{ verification_no: 'FA-2026-000001' },
{ source_hash: 'FA-2026-000001' },
{ name: { contains: 'FA-2026-000001', mode: 'insensitive' } },
]),
}),
]),
}),
}),
);
@@ -237,14 +178,9 @@ describe('assets search route', () => {
});
it('forces own-scope lookup even when caller passes mine=0', async () => {
const prisma = {
assetCard: {
findMany: vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([buildAssetRecord({ assigneeEmployeeId: 'employee-1', assetNo: 'ZC-002', serialNo: 'SN-002' })]),
},
};
const prisma = buildPrisma({
items: [buildAssetRecord({ assigneeEmployeeId: 'employee-1', assetNo: 'FA-2026-000002', serialNo: 'SN-002' })],
});
const app = await buildApp({
employee: { id: 'employee-1', permissionCodes: ['asset.view_own'] },
prisma,
@@ -258,26 +194,101 @@ describe('assets search route', () => {
expect(response.statusCode).toBe(200);
expect(response.json().data.mine).toBe(true);
expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(2);
expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith(
1,
expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(1);
expect(prisma.assetCard.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
assignee_employee_id: 'employee-1',
asset_no: 'SN-002',
}),
}),
);
expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
where: expect.objectContaining({
assignee_employee_id: 'employee-1',
serial_no: 'SN-002',
AND: expect.arrayContaining([
expect.objectContaining({
OR: [
{ assignee_employee_id: 'employee-1' },
{ owner_employee_id: 'employee-1' },
{ keeper_employee_id: 'employee-1' },
],
}),
expect.objectContaining({
OR: expect.arrayContaining([{ serial_no: 'SN-002' }]),
}),
]),
}),
}),
);
await app.close();
});
it('redacts sensitive electronic asset fields for asset.view_all callers that are not related employees', async () => {
const prisma = buildPrisma({
items: [
buildAssetRecord({
id: 'asset-3',
assetNo: 'EA-DOC-2026-000001',
assetType: 'electronic_document',
sensitivityLevel: 'confidential',
archiveLocation: '离线档案柜-A',
externalRef: 'DOC-001',
sourceHash: 'sha256:abcdef',
confidentialityNote: '仅负责人可查看',
metadata: { source: 'redacted_index' },
}),
],
});
const app = await buildApp({
employee: { id: 'manager-1', permissionCodes: ['asset.view_all'] },
prisma,
});
const response = await app.inject({
method: 'GET',
url: '/api/assets?q=EA-DOC-2026-000001',
headers: { authorization: 'Bearer token' },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.items[0]).toEqual(
expect.objectContaining({
assetNo: 'EA-DOC-2026-000001',
archiveLocation: null,
externalRef: null,
sourceHash: null,
confidentialityNote: null,
metadata: null,
sensitiveFieldsRedacted: true,
}),
);
await app.close();
});
it('rejects redacted index import items that still contain full local paths', async () => {
const prisma = buildPrisma();
const app = await buildApp({
employee: { id: 'admin-1', permissionCodes: ['asset.manage'] },
prisma,
});
const response = await app.inject({
method: 'POST',
url: '/api/assets/import/redacted-doc-index',
headers: { authorization: 'Bearer token' },
payload: {
dryRun: true,
items: [
{
name: '合同索引',
archiveLocation: '/home/shunli/Projects/faculty-daily-assistant/公司/合同.pdf',
},
],
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual(
expect.objectContaining({
'items.0.archiveLocation': 'local_absolute_path',
}),
);
await app.close();
});
});