import type { FileInfo } from '@/types/api' import type { AfterSales, Milestone, Project, ProjectBill, ProjectStatus, RequirementAfterSale, RequirementAttachment, RequirementBillPlan, RequirementContract, RequirementDetailBundle, RequirementInvoice, RequirementMember, TeamMember, } from '@/types/business/project' import { toBusinessType } from '@/utils/format' import { PROJECT_STATUS_LABEL } from '@/utils/projectMeta' /** * 需求详情(后端 **4 个页签接口**的聚合结果 `RequirementDetailBundle`)→ 详情页 `Project`。 * *

为什么不直接改组件:详情页 6 个子组件(InfoCard/Team/Milestones/Files/Finance/ * AfterSales)都按 `Project` 这套字段写的,改数据来源不动组件契约,改动面最小。

* *

基础信息取哪一支都行:4 个页签 VO 都继承 `ProjectRequirementBaseVo` 且同源 * (需求表同一行),这里统一取 `bundle.progress` 那一支当基础信息(UI 上它最先渲染)。

* *

未立项是常态:需求在 `vm_rd_project` 里没有对应行时,后端返回的 * `projectStage` / `projectMilestones` / `members` / `billPlans` / `invoices` / `contracts` * 全为空 —— 这里一律兜底成空数组 / 0 / undefined,详情页显示为空态,不报错。

*/ /** * 状态 → 详情页中文。 * *

⚠️ 后端 `selectUserProjectPage` / 详情侧已用 `sys_dict_data`(`vm_requirement_status`) * 把状态翻成**中文**下发(如「进行中」),但码值(`INITIATING` 等)也可能出现 * (直接查子表、或字典缺项时)⇒ 两种口径都要认,认不出的一律兜底「待立项」(未立项最常见)。

*/ const PROJECT_STATUS_LABELS: string[] = Object.values(PROJECT_STATUS_LABEL) function toProjectStatus(raw: string | null | undefined): ProjectStatus { const value = (raw ?? '').trim() if (!value) return '待立项' // 先按码值查表;后端已是中文名时直接采信 const label = PROJECT_STATUS_LABEL[value] ?? (PROJECT_STATUS_LABELS.includes(value) ? value : undefined) return (label as ProjectStatus | undefined) ?? '待立项' } /** * 里程碑码值 → 三态。 * *

详情页 `Milestone.status` 只有「待开始 / 进行中 / 已完成」三态(设计稿如此), * 而后端 `status` 是自由文本 ⇒ 按关键词归类,认不出的一律「待开始」。

*/ function toMilestoneStatus(raw: string | null | undefined): Milestone['status'] { const value = (raw ?? '').toUpperCase() if (value.includes('COMPLET') || value.includes('DONE') || value.includes('FINISH')) return '已完成' if (value.includes('PROGRESS') || value.includes('DOING')) return '进行中' return '待开始' } /** * 账单/付款计划状态 → 三态。 * *

⚠️ 数据源是 `vm_bill_plan`,实体注释的值域只有 **ACTIVE / DONE / CLOSED** * (不是 `vm_bill` 的 UNSTARTED/PENDING/PAID)。两者语义对照: * 计划执行中 = 还有期次要付 ⇒ 「待支付」;计划结束 = 已付清 ⇒ 「已支付」。

* *

「未到期」在 `vm_bill_plan` 里没有对应码值(已知契约缺口)——设计稿的第三张卡 * (第三期·尾款 → 未到期)目前靠 `UNSTARTED` 表达,所以这里显式认它, * 而不是依赖「认不出就兜底未到期」的隐式行为;另兼容 `vm_bill` 的 PAID/PENDING 字面量。

*/ function toBillStatus(raw: string | null | undefined): ProjectBill['status'] { const value = (raw ?? '').toUpperCase() // ⚠️ 关键词别用宽泛的「支付」:中文「**待**支付」也包含它,会先被这里截走误判成「已支付」。 if (value.includes('PAID') || value.includes('DONE') || value.includes('CLOSE') || value.includes('已支付')) return '已支付' if (value.includes('ACTIVE') || value.includes('WAIT') || value.includes('PENDING') || value.includes('待支付')) return '待支付' if (value.includes('UNSTART') || value.includes('NOT_DUE') || value.includes('未到期') || value.includes('未开始')) return '未到期' // 认不出的一律「未到期」——不臆造成「已支付」(那是更坏的错) return '未到期' } /** * 后端数值字段归一(BigDecimal 会以**字符串**下发,如 `"120000.00"`)。 * *

直接当 number 用会崩:`formatMoney('120000.00')` → `value.toFixed is not a function` * ⇒ 组件渲染失败、整个页签白屏(实测「合同财务」页签)。

*/ function toNumber(value: number | string | null | undefined, fallback = 0): number { const num = Number(value) return Number.isFinite(num) ? num : fallback } /** * LocalDateTime 字符串 → 设计稿口径的「日」。 * *

后端下发 `2026-09-03 00:00:00`(或 ISO `…T00:00:00`),两种都能截前 10 位; * 空值返回 undefined,交给组件显示「—」。

*/ function toDate(value: string | null | undefined): string | undefined { return value ? value.slice(0, 10) : undefined } /** 需求附件 → 详情页文件项(文件 tab 用) */ function attachmentToFile(item: RequirementAttachment): FileInfo { return { name: item.originalName ?? '', size: item.fileSize ?? 0, url: item.url ?? '', uploadTime: item.createTime ?? '', } } /** 发票 → 详情页文件项(财务 tab 的发票记录只是文件列表) */ function invoiceToFile(item: RequirementInvoice): FileInfo { return { name: item.invoiceTitle ?? item.invoiceNo ?? '发票', size: 0, url: item.invoiceFile ?? '', uploadTime: item.invoiceDate ?? item.createTime ?? '', } } /** 合同 → 详情页文件项(财务 tab 只展示一份合同文件,取第一条) */ function contractToFile(item: RequirementContract): FileInfo { return { name: item.title ?? item.contractNo ?? '合同文件', size: 0, url: item.file ?? '', // signDate 是 DATE 列,后端会下发成 '2026-08-20 00:00:00' ⇒ 只保留到日 uploadTime: toDate(item.signDate ?? item.createTime) ?? '', } } /** * 付款计划。 * *

`BillPlan` 没有「单据号」也没有「期次名」,所以:`planName` 用「第 N 期/共 M 期」 * (**期次优先**,`periodRule` 的 '30,30,40' 只作兜底——直接把比例当名字显示不可读), * `billNo` 回落到 `projectNo`。

*/ function toProjectBill(item: RequirementBillPlan): ProjectBill { const period = item.currentPeriod != null && item.periods != null ? `第${item.currentPeriod}期/共${item.periods}期` : '' return { id: String(item.id), planName: period || item.periodRule || '付款计划', billNo: item.projectNo ?? '', amount: toNumber(item.totalAmount), status: toBillStatus(item.status), projectName: item.projectName ?? '', } } /** 项目成员 → 详情页团队成员卡 */ function toTeamMember(item: RequirementMember): TeamMember { const name = item.userName ?? '' return { role: item.roleName ?? (item.isOwner ? '项目负责人' : '项目成员'), name, avatarChar: name.slice(0, 1), } } /** * 售后工单。 * *

详情页 `AfterSales` 只有「无 / 处理中」两态:撤回(WITHDRAWN)/ 作废 / 关闭 ⇒ 「无」 * (页面回到可再次申请的状态),其余(REQUESTED / IN_PROGRESS / COMPLETED)都算「处理中」。

* *

字段对应:`type` = 售后类型、`description` = 用户填的问题描述(后端 `reason`)、 * `remark` = 用户补充备注(后端 `remark`,不是处理人写的 `processNote`)、 * `attachments` = 售后工单附件(同表 `phase = AFTER_SALE`,由后端随页签下发)。

*/ function toAfterSales( item: RequirementAfterSale, attachments: RequirementAttachment[] = [], ): AfterSales { const value = (item.status ?? '').toUpperCase() const closed = value.includes('CLOSE') || value.includes('CANCEL') || value.includes('WITHDRAW') return { status: closed ? '无' : '处理中', type: item.type ?? '', description: item.reason ?? '', remark: item.remark ?? '', attachments: attachments.map(attachmentToFile), createTime: item.requestTime ?? '', } } /** 4 个页签接口的聚合结果 → 详情页数据模型(未立项时项目侧字段自动为空) */ export function toProjectDetail(bundle: RequirementDetailBundle): Project { const { progress, files, finance, afterSales } = bundle // 基础信息 4 支同源(需求表同一行),统一取 progress 那一支 const base = progress const { projectStage: stage } = progress const contract = finance.contracts?.[0] return { id: String(base.id), projectNo: base.projectNo ?? '', projectName: base.projectName ?? '', // 后端下发的是中文类型名(如「硬件电路」),归一化成前端 code;'0' 之类脏值归 other businessType: toBusinessType(base.businessType), // 同时保留原样名称:前端枚举只有 5 项,后台新增类型时会全落成「其他」,展示要用原名 businessTypeName: base.businessType ?? '', // 总体进度百分比 —— 后端 `developmentProgress` 就是列表那个 progress(同源), // 它缺失时才回落到阶段表的 development_progress;未立项两处都没有 ⇒ 0 progress: progress.developmentProgress ?? stage?.developmentProgress ?? 0, status: toProjectStatus(base.status), submitTime: base.createTime ?? '', startTime: toDate(stage?.startTime), endTime: toDate(stage?.finishTime ?? stage?.planTime), // ⚠️ 后端里程碑/团队都是 `selectList(...eq(projectId))`,**没有 orderBy** ⇒ 返回顺序不保证 // (实测同一项目返回 id 5,8,6,7)。里程碑顺序会直接毁掉时间线语义,必须按 sortOrder 归位; // 团队同理,项目负责人排最前。 milestones: [...(progress.projectMilestones ?? [])] .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) .map((item) => ({ name: item.milestoneName ?? '', status: toMilestoneStatus(item.status), // 后端是 LocalDateTime('2026-09-03 00:00:00'),设计稿只到「日」 time: toDate(item.startTime), })), team: [...(progress.members ?? [])] .sort((a, b) => Number(b.isOwner ?? false) - Number(a.isOwner ?? false)) .map(toTeamMember), files: (files.requirementAttachments ?? []).map(attachmentToFile), paymentPlans: (finance.billPlans ?? []).map(toProjectBill), invoiceRecords: (finance.invoices ?? []).map(invoiceToFile), contractFile: contract ? contractToFile(contract) : undefined, afterSales: afterSales.afterSale ? toAfterSales(afterSales.afterSale, afterSales.afterSaleAttachments ?? []) : undefined, } }