diff --git a/client/src/api/projects.ts b/client/src/api/projects.ts index 0403122b..b157f958 100644 --- a/client/src/api/projects.ts +++ b/client/src/api/projects.ts @@ -145,6 +145,7 @@ export type ProjectDetail = { tasks: number; risks: number; reimbursements: number; + purchaseRequests: number; travelRequests: number; todoItems: number; weeklyReports: number; @@ -209,6 +210,18 @@ export type ProjectTravelSummary = { }>; }; +export type ProjectPurchaseSummary = { + totalAmount: string; + items: Array<{ + id: string; + requestNo: string; + title: string; + status: string; + totalAmount: string; + updatedAt: string | null; + }>; +}; + export type ProjectWeeklyReportLink = { id: string; weeklyReportId: string; @@ -458,6 +471,10 @@ export async function fetchProjectTravelSummary(projectId: string) { return apiRequestData(`/api/projects/${projectId}/travel/summary`); } +export async function fetchProjectPurchaseSummary(projectId: string) { + return apiRequestData(`/api/projects/${projectId}/purchase-requests/summary`); +} + export async function fetchProjectWeeklyReports(projectId: string) { return apiRequestData<{ items: ProjectWeeklyReportLink[] }>(`/api/projects/${projectId}/weekly-reports`); } diff --git a/client/src/api/purchaseRequests.ts b/client/src/api/purchaseRequests.ts index 03b96e15..5c95cd6d 100644 --- a/client/src/api/purchaseRequests.ts +++ b/client/src/api/purchaseRequests.ts @@ -14,6 +14,7 @@ export type PurchaseRequestSummary = { supplierId: string | null; documentCategoryId: string | null; budgetPoolId: string | null; + projectId: string | null; title: string; reason: string | null; currencyCode: string; @@ -63,6 +64,12 @@ export type PurchaseRequestSummary = { code: string; name: string; } | null; + project: { + id: string; + projectNo: string; + name: string; + status: string; + } | null; }; export type PurchaseRequestItem = { @@ -235,6 +242,7 @@ export type PurchaseRequestListQuery = { supplierId?: string; legalEntityId?: string; costCenterId?: string; + projectId?: string; }; export type PurchaseRequestItemPayload = { @@ -285,6 +293,7 @@ export async function updatePurchaseRequest( supplierId: string | null; documentCategoryId: string | null; budgetPoolId: string | null; + projectId: string | null; expectedDeliveryDate: string | null; items: PurchaseRequestItemPayload[]; }>, diff --git a/client/src/pages/ProjectDetailPage.tsx b/client/src/pages/ProjectDetailPage.tsx index b8ada761..d9002abf 100644 --- a/client/src/pages/ProjectDetailPage.tsx +++ b/client/src/pages/ProjectDetailPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchEmployees } from "../api/employees"; -import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectClosureChecklistItem, patchProjectCommunicationLog, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, type ConfidentialityLevel, type DocumentStorageType, type ProjectCommunicationChannel, type ProjectClosureChecklistStatus, type ProjectDeliveryRecordType, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects"; +import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectPurchaseSummary, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectClosureChecklistItem, patchProjectCommunicationLog, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, type ConfidentialityLevel, type DocumentStorageType, type ProjectCommunicationChannel, type ProjectClosureChecklistStatus, type ProjectDeliveryRecordType, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects"; import { fetchContractsRegistry } from "../api/contractsRegistry"; import { mapApiErrorMessage } from "../api/errorMessages"; import { DetailSideMeta } from "../components/patterns/DetailSideMeta"; @@ -98,6 +98,7 @@ export function ProjectDetailPage() { }); const expensesQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "expenses"], queryFn: () => fetchProjectExpensesSummary(projectId) }); const travelQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "travel"], queryFn: () => fetchProjectTravelSummary(projectId) }); + const purchaseQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "purchase-requests"], queryFn: () => fetchProjectPurchaseSummary(projectId) }); const weeklyQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "weekly"], queryFn: () => fetchProjectWeeklyReports(projectId) }); const deliveriesQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "deliveries"], queryFn: () => fetchProjectDeliveries(projectId) }); const docsQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "document-refs"], queryFn: () => fetchProjectDocumentRefs(projectId) }); @@ -524,6 +525,7 @@ export function ProjectDetailPage() { 0 ? "warning" : "neutral"} /> + @@ -1010,6 +1012,28 @@ export function ProjectDetailPage() { ))} +
+
采购
+
{purchaseQuery.data?.totalAmount ?? "0.00"}
+
+ {purchaseQuery.isLoading ?
采购加载中...
: null} + {purchaseQuery.isError ?
{resolveError(purchaseQuery.error, "加载采购摘要失败")}
: null} + {!purchaseQuery.isLoading && !purchaseQuery.isError && !(purchaseQuery.data?.items.length ?? 0) ? ( +
暂无关联采购
+ ) : null} + {(purchaseQuery.data?.items ?? []).slice(0, 5).map((item) => ( + +
+ {item.requestNo || item.title} + +
+
+ {item.totalAmount} · {item.updatedAt ? formatDateTime(item.updatedAt) : "-"} +
+ + ))} +
+
项目周报
{weeklyQuery.data?.items.length ?? 0}
diff --git a/client/src/pages/PurchaseRequestsPage.tsx b/client/src/pages/PurchaseRequestsPage.tsx index cdcc72fe..94431885 100644 --- a/client/src/pages/PurchaseRequestsPage.tsx +++ b/client/src/pages/PurchaseRequestsPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useSearchParams } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; @@ -32,6 +32,7 @@ import { fetchLegalEntities, fetchSuppliers, } from "../api/masterData"; +import { fetchProjects } from "../api/projects"; import { remindWorkflowTask } from "../api/workflow"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; @@ -237,6 +238,7 @@ export function PurchaseRequestsPage() { const [costCenterId, setCostCenterId] = useState(""); const [supplierId, setSupplierId] = useState(""); const [documentCategoryId, setDocumentCategoryId] = useState(""); + const [projectId, setProjectId] = useState(""); const [expectedDeliveryDate, setExpectedDeliveryDate] = useState(""); const [items, setItems] = useState([]); const [archivePoNo, setArchivePoNo] = useState(""); @@ -264,6 +266,11 @@ export function PurchaseRequestsPage() { queryFn: () => fetchDocumentCategories({ entityType: "purchase_request", enabled: true }), }); + const projectsQuery = useQuery({ + queryKey: ["projects", "purchase-request-link"], + queryFn: () => fetchProjects({ mine: true }), + }); + const listQuery = useQuery({ queryKey: ["purchase-requests", "list", status, mine, supplierFilter, legalEntityFilter, costCenterFilter, page], queryFn: () => @@ -350,6 +357,7 @@ export function PurchaseRequestsPage() { setCostCenterId(detailQuery.data.costCenterId || ""); setSupplierId(detailQuery.data.supplierId || ""); setDocumentCategoryId(detailQuery.data.documentCategoryId || ""); + setProjectId(detailQuery.data.projectId || ""); setExpectedDeliveryDate(detailQuery.data.expectedDeliveryDate || ""); setItems( detailQuery.data.items.map((item) => ({ @@ -390,6 +398,30 @@ export function PurchaseRequestsPage() { return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === legalEntityFilter); }, [costCentersQuery.data?.items, legalEntityFilter]); + const projectOptions = useMemo(() => { + const activeItems = (projectsQuery.data?.items ?? []) + .filter((item) => item.status !== "archived") + .map((item) => ({ + id: item.id, + projectNo: item.projectNo, + name: item.name, + status: item.status, + })); + const currentProject = detailQuery.data?.project; + if (currentProject && currentProject.id === projectId && !activeItems.some((item) => item.id === currentProject.id)) { + return [ + { + id: currentProject.id, + projectNo: currentProject.projectNo, + name: currentProject.name, + status: currentProject.status || "archived", + }, + ...activeItems, + ]; + } + return activeItems; + }, [detailQuery.data?.project, projectId, projectsQuery.data?.items]); + const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); const purchaseItems = listQuery.data?.items ?? []; const totalAmountPreview = useMemo( @@ -421,6 +453,7 @@ export function PurchaseRequestsPage() { costCenterId: string | null; supplierId: string | null; documentCategoryId: string | null; + projectId: string | null; expectedDeliveryDate: string | null; items: PurchaseRequestItemPayload[]; } { @@ -432,6 +465,7 @@ export function PurchaseRequestsPage() { costCenterId: costCenterId || null, supplierId: supplierId || null, documentCategoryId: documentCategoryId || null, + projectId: projectId || null, expectedDeliveryDate: expectedDeliveryDate || null, items: items.map((item) => ({ description: item.description.trim(), @@ -905,6 +939,12 @@ export function PurchaseRequestsPage() {
金额
{item.totalAmount}
+
+
关联项目
+
+ {item.project ? `${item.project.projectNo} · ${item.project.name}` : "未关联项目"} +
+
)) @@ -918,6 +958,7 @@ export function PurchaseRequestsPage() { 单据 申请人 + 项目 状态 金额 @@ -938,13 +979,23 @@ export function PurchaseRequestsPage() {
{item.title || "未命名采购申请"}
{item.applicant?.name ?? "-"} + + {item.project ? ( +
+
{item.project.projectNo}
+
{item.project.name}
+
+ ) : ( + 未关联项目 + )} +
{item.totalAmount} )) ) : ( - + 暂无采购申请 @@ -1049,6 +1100,22 @@ export function PurchaseRequestsPage() { +
+
+
+
关联项目
+
+ {detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联项目"} +
+
+ {detail.project ? ( + + 查看项目 + + ) : null} +
+
+ @@ -1136,6 +1203,23 @@ export function PurchaseRequestsPage() { ))} +
+ + +
setExpectedDeliveryDate(event.target.value)} /> @@ -1149,6 +1233,7 @@ export function PurchaseRequestsPage() {
预算池:{detail.budgetPool?.name ?? "未匹配"}
+
关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联项目"}
待办节点:{detail.pendingWorkflow?.task?.stepName ?? "-"}
总金额:{totalAmountPreview}
筛选或扫读待付款报销 -> 检查状态、金额、申请人和票据提示。",playwright|npm|manual,DONE,DONE,DONE,client/src|server/src|docs/产品设计规则.md,"[""BIZ8-1""]",完成:财务台账读取链路补 project select/map;关键字搜索支持项目编号和名称;AP 台账表格展示项目名称和项目编号;AP 导出财务台账段增加项目列;不改付款状态机 不改权限 不引入 ERP 不改移动入口;验证 prisma validate 通过;node --check financeLedger apOperations exports 通过;server unit 2 files 3 tests passed;client typecheck/build 通过;Playwright biz8-ap-ledger-project 1 passed。 BIZ8-9,付款和导出增强,"增强或验证财务付款标记和导出路径,保证权限、状态和审计一致。","1) 只有财务可执行付款或导出;2) 状态转换合法;3) 导出不泄露无权限数据;4) 操作可追溯。","流程级验证:财务付款或导出 -> 普通员工和管理员直连复核 -> 检查状态和权限。",node|bash|manual,DONE,DONE,DONE,server/src|client/src|server/scripts,"[""BIZ8-8""]",完成:仅补强 AP export 权限单测和测试错误契约 不改业务代码;覆盖财务导出成功并写 EXPORT_AP_OPERATIONS 审计 普通员工和 admin-only 无财务权限导出 403 且不写审计;付款批次报销流保持 reviewed 到 paid invoice archived ERP outbox notification audit followup 一致;验证 prisma validate 通过;node --check exports paymentBatches financeLedger 通过;npm test exports.apOperations paymentBatches.reimbursement financeLedger 3 files 8 tests passed;继续冻结直接付款按钮 ERP 实时集成和新付款状态机。 BIZ8-10,费用提示轻量规则,"设计轻量费用提示规则,作为填报辅助而非重预算控制。","1) 提示不阻断合法报销;2) 规则来源清楚;3) 不承诺预算控制;4) 用户可理解。","流程级验证:填写不同金额或类型报销 -> 检查费用提示 -> 提交和审批不被误阻断。",manual|playwright,DONE,DONE,DONE,docs/报销发票附件规则.md|client/src|server/src,"[""BIZ8-1""]",已在 docs/需求-报销发票OCR规约.md 增加 BIZ8 费用提示轻量规则,明确材料提示、金额提示、费用标准 warn、费用标准 block 和预算提示边界;npm test -- tests/unit/services/expenseStandards.check.test.js tests/unit/routes/reimbursements.expense-standards.test.js 通过 2 files 9 tests,覆盖 warn 不阻断、block 阻断和 manual_review 优先级;未改业务代码。 -BIZ8-11,项目关联增强,"增强报销、采购或合同与项目轻管理的关联,保持轻量不扩展成重型项目管理。","1) 项目选择和展示清楚;2) 无项目时可解释;3) 项目权限不泄露;4) 不扩展复杂 WBS。","流程级验证:创建或选择项目 -> 提交报销/采购 -> 财务或负责人查看关联 -> 检查权限。",node|playwright|manual,TODO,TODO,TODO,docs/需求-项目轻管理规约.md|server/src|client/src,"[""BIZ8-1""]",待批准方案:报销和合同已有项目关联;采购模型缺 project_id 且项目详情页无采购摘要;外部复核显示飞书项目采用字段和角色权限 钉钉宜搭支持审批表单间数据引用 因此本方案只做轻量项目引用和摘要 不让项目权限突破采购权限;批准后需 Prisma migration 给 purchase_requests 增 nullable project_id 和索引 relation;后端采购创建/更新/详情/列表补项目字段并校验项目可读和未归档;前端采购表单/详情/列表补项目选择与展示;项目详情补采购摘要;必须 reviewer 和权限回归;继续冻结复杂 WBS 预算控制和 ERP。 +BIZ8-11,项目关联增强,"增强报销、采购或合同与项目轻管理的关联,保持轻量不扩展成重型项目管理。","1) 项目选择和展示清楚;2) 无项目时可解释;3) 项目权限不泄露;4) 不扩展复杂 WBS。","流程级验证:创建或选择项目 -> 保存采购草稿 -> 项目负责人查看项目采购摘要 -> 采购详情权限直连复核;自动化覆盖 projectId 列表过滤 归档项目新关联拦截 已有关联不重复阻断 项目成员不能越过采购详情权限。",node|npm|git|manual,DONE,DONE,DONE,server/prisma|server/src|server/tests|client/src,"[""BIZ8-1""]",完成:采购新增 nullable project_id 外键 ON DELETE SET NULL 和索引;采购列表详情草稿更新补项目字段;新关联校验项目可读且未归档;已有项目无关编辑不重复阻断;项目详情新增轻量采购摘要;前端采购表单列表详情和项目详情补展示;验证 npm run prisma:validate 通过;node --check purchaseRequests projects 通过;npm test purchase.project-link projects purchase.submit-budget-precheck 3 files 12 tests passed;client typecheck 和 build 通过;git diff --check 通过;reviewer 已复核并修复阻断问题;回滚:代码回滚可保留 nullable 列,物理回滚需先 drop constraint purchase_requests_project_id_fkey 再 drop index idx_purchase_requests_project 再 drop column purchase_requests.project_id 且关联数据会丢失;继续冻结复杂 WBS 重预算 ERP 实时集成。 BIZ8-12,移动端业务入口评估,"评估报销、OCR、财务和项目轻量能力在移动端和企业微信入口的可用性。","1) 移动入口不重复;2) 企业微信入口回 OA;3) 财务敏感能力不误暴露;4) 新增入口进入移动矩阵。","流程级验证:员工和财务角色在移动端进入业务入口 -> 检查报销/OCR/项目状态和权限。",playwright|manual,TODO,TODO,TODO,client/src|docs/产品设计规则.md|docs/trialrun/真机公网测试清单.md,"[""BIZ8-8"",""BIZ8-11""]",真机验证不能被自动化替代。 BIZ8-13,财务和项目权限回归,"回归财务台账、付款导出、项目关联和报销详情的权限边界。","1) 普通员工只能看自己的数据;2) 财务只看授权财务数据;3) 管理员不自动获得财务处理;4) 项目关联不越权。","流程级验证:不同角色直连财务、项目、报销详情和导出 API -> 检查允许和拒绝结果。",bash|node|manual,TODO,TODO,TODO,scripts/qa|server/src|client/src,"[""BIZ8-9"",""BIZ8-11"",""BIZ8-12""]",高风险权限变更必须 reviewer。 BIZ8-14,BIZ8 收口准备,"整理 BIZ8 验证摘要、OCR 边界、财务和项目权限结论、未完成项。","1) DONE 行均有证据;2) OCR 真实与 mock 结论分离;3) 权限回归通过或有阻断;4) 未完成项不伪装完成。","流程级验证:人工核对 BIZ8 CSV 状态、验证摘要、OCR 证据和权限回归结果。",manual|git,TODO,TODO,TODO,issues/2026-05-13_18-23-53-biz8-finance-ocr-project.csv|docs/文档索引.md,"[""BIZ8-13""]",对应原规划 BIZ8-14。 diff --git a/server/prisma/migrations/20260513214500_biz8_purchase_project_link/migration.sql b/server/prisma/migrations/20260513214500_biz8_purchase_project_link/migration.sql new file mode 100644 index 00000000..7fa1db79 --- /dev/null +++ b/server/prisma/migrations/20260513214500_biz8_purchase_project_link/migration.sql @@ -0,0 +1,9 @@ +-- BIZ8-11: align purchase requests with the existing lightweight project-link pattern. +ALTER TABLE "purchase_requests" ADD COLUMN "project_id" UUID; + +CREATE INDEX "idx_purchase_requests_project" ON "purchase_requests"("project_id"); + +ALTER TABLE "purchase_requests" + ADD CONSTRAINT "purchase_requests_project_id_fkey" + FOREIGN KEY ("project_id") REFERENCES "projects"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 1943c438..8fe959ce 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -1047,6 +1047,7 @@ model PurchaseRequest { supplier_id String? @db.Uuid document_category_id String? @db.Uuid budget_pool_id String? @db.Uuid + project_id String? @db.Uuid title String @default("") reason String? currency_code String @default("CNY") @db.VarChar(8) @@ -1067,6 +1068,7 @@ model PurchaseRequest { supplier Supplier? @relation(fields: [supplier_id], references: [id], onDelete: SetNull) document_category DocumentCategory? @relation(fields: [document_category_id], references: [id], onDelete: SetNull) budget_pool BudgetPool? @relation(fields: [budget_pool_id], references: [id], onDelete: SetNull) + project Project? @relation(fields: [project_id], references: [id], onDelete: SetNull) items PurchaseRequestItem[] attachments PurchaseAttachment[] @@ -1082,6 +1084,7 @@ model PurchaseRequest { @@index([supplier_id], map: "idx_purchase_requests_supplier") @@index([document_category_id], map: "idx_purchase_requests_document_category") @@index([budget_pool_id], map: "idx_purchase_requests_budget_pool") + @@index([project_id], map: "idx_purchase_requests_project") @@index([status], map: "idx_purchase_requests_status") @@map("purchase_requests") } @@ -1991,6 +1994,7 @@ model Project { tasks ProjectTask[] risks ProjectRisk[] reimbursements ReimbursementForm[] + purchase_requests PurchaseRequest[] travel_requests TravelRequest[] todo_items TodoItem[] weekly_reports ProjectLinkOnWeeklyReport[] diff --git a/server/src/routes/projects.js b/server/src/routes/projects.js index 817e1e9e..784ee239 100644 --- a/server/src/routes/projects.js +++ b/server/src/routes/projects.js @@ -187,6 +187,7 @@ function buildProjectDetailInclude() { tasks: true, risks: true, reimbursements: true, + purchase_requests: true, travel_requests: true, todo_items: true, weekly_reports: true, @@ -425,6 +426,7 @@ function mapProjectDetail(project) { tasks: project._count?.tasks ?? 0, risks: project._count?.risks ?? 0, reimbursements: project._count?.reimbursements ?? 0, + purchaseRequests: project._count?.purchase_requests ?? 0, travelRequests: project._count?.travel_requests ?? 0, todoItems: project._count?.todo_items ?? 0, weeklyReports: project._count?.weekly_reports ?? 0, @@ -861,6 +863,29 @@ async function projectsRoutes(fastify) { return sendData(reply, request, { totalAmount: totalAmount.toFixed(2), items: items.map((item) => ({ id: item.id, requestNo: item.request_no ?? item.id, status: item.status, estimatedAmount: String(item.estimated_amount ?? 0), startDate: item.start_date?.toISOString?.().slice(0, 10) ?? null, endDate: item.end_date?.toISOString?.().slice(0, 10) ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null })) }); }); + fastify.get("/projects/:id/purchase-requests/summary", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking); + const projectId = parseId(request.params?.id); + await ensureReadableProject(fastify, request, projectId); + const items = await fastify.prisma.purchaseRequest.findMany({ + where: { project_id: projectId }, + orderBy: { updated_at: "desc" }, + select: { id: true, request_no: true, title: true, status: true, total_amount: true, updated_at: true }, + }); + const totalAmount = items.reduce((sum, item) => sum + Number(item.total_amount ?? 0), 0); + return sendData(reply, request, { + totalAmount: totalAmount.toFixed(2), + items: items.map((item) => ({ + id: item.id, + requestNo: item.request_no ?? item.id, + title: item.title, + status: item.status, + totalAmount: String(item.total_amount ?? 0), + updatedAt: item.updated_at?.toISOString?.() ?? null, + })), + }); + }); + fastify.get("/projects/:id/weekly-reports", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking); const projectId = parseId(request.params?.id); diff --git a/server/src/routes/purchaseRequests.js b/server/src/routes/purchaseRequests.js index d0ee5acd..a4a2a3c4 100644 --- a/server/src/routes/purchaseRequests.js +++ b/server/src/routes/purchaseRequests.js @@ -48,6 +48,7 @@ const PURCHASE_LIST_QUERY_SCHEMA = z.object({ supplierId: UUID_SCHEMA.optional(), legalEntityId: UUID_SCHEMA.optional(), costCenterId: UUID_SCHEMA.optional(), + projectId: UUID_SCHEMA.optional(), }); const PURCHASE_PATCH_SCHEMA = z @@ -60,6 +61,7 @@ const PURCHASE_PATCH_SCHEMA = z supplierId: UUID_SCHEMA.nullable().optional(), documentCategoryId: UUID_SCHEMA.nullable().optional(), budgetPoolId: UUID_SCHEMA.nullable().optional(), + projectId: UUID_SCHEMA.nullable().optional(), expectedDeliveryDate: DATE_ONLY_SCHEMA.nullable().optional(), items: z .array( @@ -106,6 +108,7 @@ const PURCHASE_SUMMARY_SELECT = { supplier_id: true, document_category_id: true, budget_pool_id: true, + project_id: true, title: true, reason: true, currency_code: true, @@ -171,6 +174,14 @@ const PURCHASE_SUMMARY_SELECT = { name: true, }, }, + project: { + select: { + id: true, + project_no: true, + name: true, + status: true, + }, + }, }; const PURCHASE_DETAIL_SELECT = { @@ -334,6 +345,7 @@ function parseListQuery(query) { supplierId: parsed.data.supplierId, legalEntityId: parsed.data.legalEntityId, costCenterId: parsed.data.costCenterId, + projectId: parsed.data.projectId, }; } @@ -472,6 +484,7 @@ function mapPurchaseRequestSummary(item) { supplierId: item.supplier_id ?? null, documentCategoryId: item.document_category_id ?? null, budgetPoolId: item.budget_pool_id ?? null, + projectId: item.project_id ?? null, title: item.title, reason: item.reason ?? null, currencyCode: item.currency_code, @@ -525,6 +538,14 @@ function mapPurchaseRequestSummary(item) { name: item.budget_pool.name, } : null, + project: item.project + ? { + id: item.project.id, + projectNo: item.project.project_no, + name: item.project.name, + status: item.project.status, + } + : null, }; } @@ -769,10 +790,90 @@ async function ensureBudgetPoolAvailable(tx, budgetPoolId) { return item; } +async function ensureProjectLinkingEnabled(fastify, tx, request) { + const gate = await evaluateFeatureFlag({ + prisma: tx, + config: fastify.config, + flagKey: FEATURE_FLAG_KEYS.projectLinking, + departmentId: request.employee.department_id ?? null, + strictDepartment: false, + }); + + if (!gate.enabled) { + throw buildValidationError({ featureFlag: `${FEATURE_FLAG_KEYS.projectLinking}_disabled`, reason: gate.reason }, 409); + } +} + +function canReadProjectForLink(fastify, request, project) { + if (!project) { + return false; + } + if ( + fastify.hasPermission(request.employee, "project.view_all") || + fastify.hasPermission(request.employee, "project.manage") || + fastify.hasPermission(request.employee, "system.settings") + ) { + return true; + } + + return ( + project.owner_employee_id === request.employee.id || + project.manager_employee_id === request.employee.id || + (project.members ?? []).some((item) => item.employee_id === request.employee.id && item.left_at == null) + ); +} + +async function ensureProjectLinkable(fastify, tx, request, projectId) { + if (!projectId) { + return null; + } + + await ensureProjectLinkingEnabled(fastify, tx, request); + + const project = await tx.project.findUnique({ + where: { id: projectId }, + select: { + id: true, + project_no: true, + name: true, + status: true, + owner_employee_id: true, + manager_employee_id: true, + members: { + where: { left_at: null }, + select: { employee_id: true, left_at: true }, + }, + }, + }); + + if (!project) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + if (!canReadProjectForLink(fastify, request, project)) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + details: { permission: "project.view_own" }, + }); + } + + if (project.status === "archived") { + throw buildValidationError({ projectId: "archived" }, 409); + } + + return project; +} + async function loadPurchaseRequestForUpdate(tx, purchaseRequestId) { const rows = await tx.$queryRaw` SELECT id, applicant_id, submitter_id, department_id, legal_entity_id, cost_center_id, supplier_id, - document_category_id, budget_pool_id, request_no, currency_code, status + document_category_id, budget_pool_id, project_id, request_no, currency_code, status FROM "purchase_requests" WHERE id = ${purchaseRequestId}::uuid FOR UPDATE @@ -1249,6 +1350,7 @@ async function purchaseRequestRoutes(fastify) { ...(query.supplierId ? { supplier_id: query.supplierId } : {}), ...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}), ...(query.costCenterId ? { cost_center_id: query.costCenterId } : {}), + ...(query.projectId ? { project_id: query.projectId } : {}), ...(mineOnly ? { OR: [ @@ -1411,6 +1513,9 @@ async function purchaseRequestRoutes(fastify) { if (payload.budgetPoolId) { await ensureBudgetPoolAvailable(tx, payload.budgetPoolId); } + if (payload.projectId && payload.projectId !== (locked.project_id ?? null)) { + await ensureProjectLinkable(fastify, tx, request, payload.projectId); + } const updateData = { ...(payload.title !== undefined ? { title: payload.title.trim() } : {}), @@ -1421,6 +1526,7 @@ async function purchaseRequestRoutes(fastify) { ...(payload.supplierId !== undefined ? { supplier_id: payload.supplierId || null } : {}), ...(payload.documentCategoryId !== undefined ? { document_category_id: payload.documentCategoryId || null } : {}), ...(payload.budgetPoolId !== undefined ? { budget_pool_id: payload.budgetPoolId || null } : {}), + ...(payload.projectId !== undefined ? { project_id: payload.projectId || null } : {}), ...(payload.expectedDeliveryDate !== undefined ? { expected_delivery_date: payload.expectedDeliveryDate diff --git a/server/tests/unit/routes/projects.test.js b/server/tests/unit/routes/projects.test.js index 87081af9..df089540 100644 --- a/server/tests/unit/routes/projects.test.js +++ b/server/tests/unit/routes/projects.test.js @@ -66,11 +66,12 @@ function buildProjectState(overrides = {}) { }; } -function createPrismaState({ communicationLogs = [], closureChecklistItems = [] } = {}) { +function createPrismaState({ communicationLogs = [], closureChecklistItems = [], purchaseRequests = [] } = {}) { const state = { project: buildProjectState(), communicationLogs: communicationLogs.map((item) => ({ ...item })), closureChecklistItems: closureChecklistItems.map((item) => ({ ...item })), + purchaseRequests: purchaseRequests.map((item) => ({ ...item })), openRiskCount: 0, openReimbursementCount: 0, openTravelCount: 0, @@ -89,6 +90,7 @@ function createPrismaState({ communicationLogs = [], closureChecklistItems = [] tasks: (project.tasks ?? []).length, risks: (project.risks ?? []).length, reimbursements: 0, + purchase_requests: state.purchaseRequests.filter((item) => item.project_id === project.id).length, travel_requests: 0, todo_items: 0, weekly_reports: 0, @@ -277,6 +279,13 @@ function createPrismaState({ communicationLogs = [], closureChecklistItems = [] projectRisk: { count: vi.fn().mockImplementation(async () => state.openRiskCount) }, reimbursementForm: { count: vi.fn().mockImplementation(async () => state.openReimbursementCount) }, travelRequest: { count: vi.fn().mockImplementation(async () => state.openTravelCount) }, + purchaseRequest: { + findMany: vi.fn().mockImplementation(async ({ where }) => { + return state.purchaseRequests + .filter((item) => item.project_id === where.project_id) + .sort((left, right) => new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime()); + }), + }, projectDeliveryRecord: { count: vi.fn().mockImplementation(async () => state.deliveryRecordCount) }, contractRegistry: { count: vi.fn().mockImplementation(async () => state.contractCount) }, confidentialDocumentRef: { count: vi.fn().mockImplementation(async () => state.confidentialDocRefCount) }, @@ -329,6 +338,79 @@ async function buildApp({ employee, prisma }) { } describe("project R23 routes", () => { + it("returns purchase request summary only to readable project users", async () => { + const { prisma } = createPrismaState({ + purchaseRequests: [ + { + id: "77777777-7777-4777-8777-777777777777", + project_id: PROJECT_ID, + request_no: "PR-20260513-001", + title: "测试设备采购", + status: "approved", + total_amount: "1200.50", + updated_at: new Date("2026-05-13T08:00:00.000Z"), + }, + { + id: "88888888-8888-4888-8888-888888888888", + project_id: PROJECT_ID, + request_no: null, + title: "未编号采购草稿", + status: "draft", + total_amount: "99.50", + updated_at: new Date("2026-05-13T09:00:00.000Z"), + }, + ], + }); + const memberApp = await buildApp({ + employee: { id: MEMBER_ID, department_id: null, permissionCodes: ["project.view_own"] }, + prisma, + }); + + const response = await memberApp.inject({ + method: "GET", + url: `/api/projects/${PROJECT_ID}/purchase-requests/summary`, + headers: { authorization: "Bearer test-token" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual({ + totalAmount: "1300.00", + items: [ + { + id: "88888888-8888-4888-8888-888888888888", + requestNo: "88888888-8888-4888-8888-888888888888", + title: "未编号采购草稿", + status: "draft", + totalAmount: "99.50", + updatedAt: "2026-05-13T09:00:00.000Z", + }, + { + id: "77777777-7777-4777-8777-777777777777", + requestNo: "PR-20260513-001", + title: "测试设备采购", + status: "approved", + totalAmount: "1200.50", + updatedAt: "2026-05-13T08:00:00.000Z", + }, + ], + }); + + const outsiderApp = await buildApp({ + employee: { id: OUTSIDER_ID, department_id: null, permissionCodes: ["project.view_own"] }, + prisma, + }); + const forbiddenResponse = await outsiderApp.inject({ + method: "GET", + url: `/api/projects/${PROJECT_ID}/purchase-requests/summary`, + headers: { authorization: "Bearer test-token" }, + }); + + expect(forbiddenResponse.statusCode).toBe(403); + expect(forbiddenResponse.json().error.code).toBe("PERM_001"); + + await Promise.all([memberApp.close(), outsiderApp.close()]); + }); + it("allows project member to create communication logs and blocks outsiders", async () => { const { prisma } = createPrismaState(); const memberApp = await buildApp({ diff --git a/server/tests/unit/routes/purchase.project-link.test.js b/server/tests/unit/routes/purchase.project-link.test.js new file mode 100644 index 00000000..ad72653b --- /dev/null +++ b/server/tests/unit/routes/purchase.project-link.test.js @@ -0,0 +1,370 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const purchaseRequestRoutes = require("../../../src/routes/purchaseRequests"); + +const PURCHASE_REQUEST_ID = "11111111-1111-4111-8111-111111111111"; +const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222"; +const OTHER_EMPLOYEE_ID = "33333333-3333-4333-8333-333333333333"; +const DEPARTMENT_ID = "44444444-4444-4444-8444-444444444444"; +const PROJECT_ID = "55555555-5555-4555-8555-555555555555"; +const NOW = new Date("2026-05-13T10:00:00.000Z"); + +function buildFeatureFlagRecord(flagKey) { + return { + id: `flag-${flagKey}`, + flag_key: flagKey, + override_mode: "force_on", + rollout_type: "global", + department_ids: [], + note: "unit-test", + version: 1, + created_at: NOW, + created_by: null, + updated_at: NOW, + updated_by: null, + }; +} + +function buildProject(overrides = {}) { + return { + id: PROJECT_ID, + project_no: "PRJ-BIZ8", + name: "BIZ8 测试项目", + status: "active", + owner_employee_id: EMPLOYEE_ID, + manager_employee_id: null, + members: [], + ...overrides, + }; +} + +function buildPurchaseRequest(overrides = {}) { + return { + id: PURCHASE_REQUEST_ID, + request_no: null, + applicant_id: EMPLOYEE_ID, + submitter_id: null, + department_id: DEPARTMENT_ID, + legal_entity_id: null, + cost_center_id: null, + supplier_id: null, + document_category_id: null, + budget_pool_id: null, + project_id: null, + title: "项目关联采购草稿", + reason: null, + currency_code: "CNY", + total_amount: "0.00", + expected_delivery_date: null, + status: "draft", + created_at: NOW, + updated_at: NOW, + applicant: { id: EMPLOYEE_ID, employee_no: "E001", name: "申请人" }, + submitter: null, + department: { id: DEPARTMENT_ID, name: "测试部门" }, + legal_entity: null, + cost_center: null, + supplier: null, + document_category: null, + budget_pool: null, + project: null, + items: [], + attachments: [], + order_archive: null, + workflow_instances: [], + ...overrides, + }; +} + +async function buildApp({ + project = buildProject(), + actorId = EMPLOYEE_ID, + permissionCodes = ["procurement.create", "procurement.view_own", "project.view_own"], + purchaseRequestOverrides = {}, + purchaseRequests = null, +} = {}) { + const app = Fastify(); + const actor = { + id: actorId, + department_id: DEPARTMENT_ID, + roleCodes: [], + permissionCodes, + }; + const state = { + purchaseRequest: buildPurchaseRequest(purchaseRequestOverrides), + purchaseRequests: null, + auditEntries: [], + }; + state.purchaseRequests = purchaseRequests?.map((item) => buildPurchaseRequest(item)) ?? [state.purchaseRequest]; + + function attachProjectRelation(item) { + return { + ...item, + project: item.project_id === project.id + ? { + id: project.id, + project_no: project.project_no, + name: project.name, + status: project.status, + } + : null, + }; + } + + const tx = { + $queryRaw: vi.fn(async () => [state.purchaseRequest]), + featureFlag: { + findUnique: vi.fn(async ({ where }) => buildFeatureFlagRecord(where.flag_key)), + }, + project: { + findUnique: vi.fn(async ({ where }) => (where.id === project.id ? project : null)), + }, + purchaseRequest: { + update: vi.fn(async ({ data }) => { + state.purchaseRequest = { + ...state.purchaseRequest, + ...(data.project_id !== undefined ? { project_id: data.project_id } : {}), + ...(data.title !== undefined ? { title: data.title } : {}), + ...(data.reason !== undefined ? { reason: data.reason } : {}), + ...(data.currency_code !== undefined ? { currency_code: data.currency_code } : {}), + ...(data.total_amount !== undefined ? { total_amount: data.total_amount } : {}), + updated_by: data.updated_by, + updated_at: NOW, + }; + return attachProjectRelation(state.purchaseRequest); + }), + findUnique: vi.fn(async () => attachProjectRelation(state.purchaseRequest)), + }, + auditLog: { + create: vi.fn(async ({ data }) => { + state.auditEntries.push(data); + return { id: "audit-1" }; + }), + }, + }; + + function listMatchesWhere(item, where = {}) { + if (where.project_id && item.project_id !== where.project_id) { + return false; + } + if (where.status && item.status !== where.status) { + return false; + } + if (where.OR) { + return where.OR.some((entry) => { + if (entry.applicant_id) return item.applicant_id === entry.applicant_id; + if (entry.submitter_id) return item.submitter_id === entry.submitter_id; + return false; + }); + } + return true; + } + + const prisma = { + $transaction: vi.fn(async (callback) => callback(tx)), + purchaseRequest: { + findUnique: vi.fn(async () => attachProjectRelation(state.purchaseRequest)), + findMany: vi.fn(async ({ where }) => state.purchaseRequests.filter((item) => listMatchesWhere(item, where)).map(attachProjectRelation)), + count: vi.fn(async ({ where }) => state.purchaseRequests.filter((item) => listMatchesWhere(item, where)).length), + }, + entityRiskFlag: { + findMany: vi.fn(async () => []), + }, + }; + + app.decorate("prisma", prisma); + app.decorate("config", { + appTimezone: "Asia/Shanghai", + projectLinkingEnabled: true, + cutoverConsoleEnabled: false, + }); + app.decorate("hasPermission", (employee, permissionCode) => { + const permissionCodes = employee?.permissionCodes ?? []; + return permissionCodes.includes("*") || permissionCodes.includes(permissionCode); + }); + app.decorate("requirePermission", (permissionCode) => async (request) => { + request.employee = actor; + if (!app.hasPermission(actor, permissionCode)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } + }); + app.decorate("authenticate", async (request) => { + request.employee = actor; + }); + app.setErrorHandler((error, request, reply) => { + reply.status(error.statusCode || 500).send({ + requestId: request.id, + error: { + code: error.code || "SYS_001", + message: error.message, + details: error.details ?? null, + }, + }); + }); + + await app.register(purchaseRequestRoutes, { prefix: "/api" }); + return { app, tx, state }; +} + +describe("purchase request project link", () => { + it("links draft purchase request to a readable active project", async () => { + const { app, tx, state } = await buildApp(); + + const response = await app.inject({ + method: "PATCH", + url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}`, + payload: { projectId: PROJECT_ID }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.purchaseRequest).toEqual( + expect.objectContaining({ + id: PURCHASE_REQUEST_ID, + projectId: PROJECT_ID, + project: { + id: PROJECT_ID, + projectNo: "PRJ-BIZ8", + name: "BIZ8 测试项目", + status: "active", + }, + }), + ); + expect(state.purchaseRequest.project_id).toBe(PROJECT_ID); + expect(tx.auditLog.create).toHaveBeenCalledTimes(1); + + await app.close(); + }); + + it("blocks archived projects from new purchase links", async () => { + const { app, tx } = await buildApp({ + project: buildProject({ status: "archived" }), + }); + + const response = await app.inject({ + method: "PATCH", + url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}`, + payload: { projectId: PROJECT_ID }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ projectId: "archived" }); + expect(tx.purchaseRequest.update).not.toHaveBeenCalled(); + + await app.close(); + }); + + it("does not revalidate an unchanged existing project link during draft edits", async () => { + const { app, tx } = await buildApp({ + project: buildProject({ status: "archived" }), + purchaseRequestOverrides: { + project_id: PROJECT_ID, + }, + }); + + const response = await app.inject({ + method: "PATCH", + url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}`, + payload: { projectId: PROJECT_ID, title: "只修改标题" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.purchaseRequest).toEqual( + expect.objectContaining({ + projectId: PROJECT_ID, + title: "只修改标题", + project: expect.objectContaining({ status: "archived" }), + }), + ); + expect(tx.project.findUnique).not.toHaveBeenCalled(); + + await app.close(); + }); + + it("does not let unreadable projects be linked to purchase drafts", async () => { + const { app, tx } = await buildApp({ + project: buildProject({ owner_employee_id: OTHER_EMPLOYEE_ID }), + }); + + const response = await app.inject({ + method: "PATCH", + url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}`, + payload: { projectId: PROJECT_ID }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().error.code).toBe("PERM_001"); + expect(tx.purchaseRequest.update).not.toHaveBeenCalled(); + + await app.close(); + }); + + it("filters purchase request list by project id", async () => { + const otherProjectId = "66666666-6666-4666-8666-666666666666"; + const { app } = await buildApp({ + permissionCodes: ["procurement.view_all"], + purchaseRequests: [ + { + id: PURCHASE_REQUEST_ID, + project_id: PROJECT_ID, + request_no: "PR-001", + title: "关联项目采购", + status: "draft", + }, + { + id: "77777777-7777-4777-8777-777777777777", + project_id: otherProjectId, + request_no: "PR-002", + title: "其他项目采购", + status: "draft", + }, + ], + }); + + const response = await app.inject({ + method: "GET", + url: `/api/purchase-requests?projectId=${PROJECT_ID}&mine=0`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual( + expect.objectContaining({ + total: 1, + items: [ + expect.objectContaining({ + id: PURCHASE_REQUEST_ID, + projectId: PROJECT_ID, + project: expect.objectContaining({ + projectNo: "PRJ-BIZ8", + name: "BIZ8 测试项目", + }), + }), + ], + }), + ); + + await app.close(); + }); + + it("does not let project membership bypass purchase detail permission", async () => { + const { app } = await buildApp({ + actorId: OTHER_EMPLOYEE_ID, + project: buildProject({ owner_employee_id: OTHER_EMPLOYEE_ID }), + permissionCodes: ["procurement.view_own", "project.view_own"], + purchaseRequestOverrides: { + applicant_id: EMPLOYEE_ID, + project_id: PROJECT_ID, + }, + }); + + const response = await app.inject({ + method: "GET", + url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}`, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().error.code).toBe("PERM_001"); + + await app.close(); + }); +});