Files
cz-web/src/utils/projectVo.ts
2026-09-28 17:47:07 +08:00

243 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`。
*
* <p>为什么不直接改组件:详情页 6 个子组件(InfoCard/Team/Milestones/Files/Finance/
* AfterSales)都按 `Project` 这套字段写的,改数据来源不动组件契约,改动面最小。</p>
*
* <p><b>基础信息取哪一支都行</b>:4 个页签 VO 都继承 `ProjectRequirementBaseVo` 且同源
* (需求表同一行),这里统一取 `bundle.progress` 那一支当基础信息(UI 上它最先渲染)。</p>
*
* <p><b>未立项是常态</b>:需求在 `vm_rd_project` 里没有对应行时,后端返回的
* `projectStage` / `projectMilestones` / `members` / `billPlans` / `invoices` / `contracts`
* 全为空 —— 这里一律兜底成空数组 / 0 / undefined,详情页显示为空态,不报错。</p>
*/
/**
* 状态 → 详情页中文。
*
* <p>⚠️ 后端 `selectUserProjectPage` / 详情侧已用 `sys_dict_data`(`vm_requirement_status`)
* 把状态翻成**中文**下发(如「进行中」),但码值(`INITIATING` 等)也可能出现
* (直接查子表、或字典缺项时)⇒ 两种口径都要认,认不出的一律兜底「待立项」(未立项最常见)。</p>
*/
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) ?? '待立项'
}
/**
* 里程碑码值 → 三态。
*
* <p>详情页 `Milestone.status` 只有「待开始 / 进行中 / 已完成」三态(设计稿如此),
* 而后端 `status` 是自由文本 ⇒ 按关键词归类,认不出的一律「待开始」。</p>
*/
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 '待开始'
}
/**
* 账单/付款计划状态 → 三态。
*
* <p>⚠️ 数据源是 `vm_bill_plan`,实体注释的值域只有 **ACTIVE / DONE / CLOSED**
* (不是 `vm_bill` 的 UNSTARTED/PENDING/PAID)。两者语义对照:
* 计划执行中 = 还有期次要付 ⇒ 「待支付」;计划结束 = 已付清 ⇒ 「已支付」。</p>
*
* <p><b>「未到期」在 `vm_bill_plan` 里没有对应码值</b>(已知契约缺口)——设计稿的第三张卡
* (第三期·尾款 → 未到期)目前靠 `UNSTARTED` 表达,所以这里显式认它,
* 而不是依赖「认不出就兜底未到期」的隐式行为;另兼容 `vm_bill` 的 PAID/PENDING 字面量。</p>
*/
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"`)。
*
* <p>直接当 number 用会崩:`formatMoney('120000.00')` → `value.toFixed is not a function`
* ⇒ 组件渲染失败、整个页签白屏(实测「合同财务」页签)。</p>
*/
function toNumber(value: number | string | null | undefined, fallback = 0): number {
const num = Number(value)
return Number.isFinite(num) ? num : fallback
}
/**
* LocalDateTime 字符串 → 设计稿口径的「日」。
*
* <p>后端下发 `2026-09-03 00:00:00`(或 ISO `…T00:00:00`),两种都能截前 10 位;
* 空值返回 undefined,交给组件显示「—」。</p>
*/
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) ?? '',
}
}
/**
* 付款计划。
*
* <p>`BillPlan` 没有「单据号」也没有「期次名」,所以:`planName` 用「第 N 期/共 M 期」
* (**期次优先**,`periodRule` 的 '30,30,40' 只作兜底——直接把比例当名字显示不可读),
* `billNo` 回落到 `projectNo`。</p>
*/
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),
}
}
/**
* 售后工单。
*
* <p>详情页 `AfterSales` 只有「无 / 处理中」两态:撤回(WITHDRAWN)/ 作废 / 关闭 ⇒ 「无」
* (页面回到可再次申请的状态),其余(REQUESTED / IN_PROGRESS / COMPLETED)都算「处理中」。</p>
*
* <p>字段对应:`type` = 售后类型、`description` = 用户填的问题描述(后端 `reason`)、
* `remark` = 用户补充备注(后端 `remark`,不是处理人写的 `processNote`)、
* `attachments` = 售后工单附件(同表 `phase = AFTER_SALE`,由后端随页签下发)。</p>
*/
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,
}
}