243 lines
11 KiB
TypeScript
243 lines
11 KiB
TypeScript
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,
|
||
}
|
||
}
|