175 lines
6.5 KiB
TypeScript
175 lines
6.5 KiB
TypeScript
import type { FileInfo } from '@/types/api'
|
||
import type {
|
||
AfterSales,
|
||
Milestone,
|
||
Project,
|
||
ProjectBill,
|
||
ProjectStatus,
|
||
RequirementAfterSale,
|
||
RequirementAttachment,
|
||
RequirementBillPlan,
|
||
RequirementContract,
|
||
RequirementDetailVo,
|
||
RequirementInvoice,
|
||
TeamMember,
|
||
} from '@/types/business/project'
|
||
import { toBusinessType } from '@/utils/format'
|
||
import { PROJECT_STATUS_LABEL } from '@/utils/projectMeta'
|
||
|
||
/**
|
||
* 需求详情(后端 `ProjectRequirementUserVo`)→ 详情页 `Project`。
|
||
*
|
||
* <p>为什么不直接改组件:详情页 6 个子组件(InfoCard/Team/Milestones/Files/Finance/
|
||
* AfterSales)都按 `Project` 这套字段写的,改数据来源不动组件契约,改动面最小。</p>
|
||
*
|
||
* <p><b>未立项是常态</b>:需求在 `vm_rd_project` 里没有对应行时,后端返回的
|
||
* `projectStage` / `projectMilestones` / `members` / `billPlans` / `invoices` / `contracts`
|
||
* 全为空 —— 这里一律兜底成空数组 / 0 / undefined,详情页显示为空态,不报错。</p>
|
||
*/
|
||
|
||
/**
|
||
* 状态码值 → 详情页中文。
|
||
*
|
||
* <p>码值域是两张表的并集(见 {@link ProjectStatus}):需求侧 `INITIATING`/`CANCELLED`,
|
||
* 项目侧 `IN_PROGRESS`/`PENDING_ACCEPTANCE`/… 未知码值兜底「待立项」(未立项是最常见态)。</p>
|
||
*/
|
||
function toProjectStatus(raw: string | null | undefined): ProjectStatus {
|
||
const label = PROJECT_STATUS_LABEL[raw ?? '']
|
||
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>⚠️ 后端 `BillPlan.status` 的码值语义**未确认**(`vm_bill_plan` 当前 0 行,无样本),
|
||
* 只认字面量,其余一律「未到期」——不臆造成「已支付」。</p>
|
||
*/
|
||
function toBillStatus(raw: string | null | undefined): ProjectBill['status'] {
|
||
const value = (raw ?? '').toUpperCase()
|
||
if (value.includes('PAID') || value.includes('支付')) return '已支付'
|
||
if (value.includes('WAIT') || value.includes('PENDING') || value.includes('待支付')) return '待支付'
|
||
return '未到期'
|
||
}
|
||
|
||
/** 需求附件 → 详情页文件项(文件 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 ?? '',
|
||
uploadTime: item.signDate ?? item.createTime ?? '',
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 付款计划。
|
||
*
|
||
* <p>`BillPlan` 没有「单据号」也没有「期次名」,所以:`planName` 用 `periodRule`(付不出
|
||
* 就用「第 N 期/共 M 期」拼),`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: item.periodRule || period || '付款计划',
|
||
billNo: item.projectNo ?? '',
|
||
amount: item.totalAmount ?? 0,
|
||
status: toBillStatus(item.status),
|
||
projectName: item.projectName ?? '',
|
||
}
|
||
}
|
||
|
||
/** 项目成员 → 详情页团队成员卡 */
|
||
function toTeamMember(item: RequirementDetailVo['members'][number]): TeamMember {
|
||
const name = item.userName ?? ''
|
||
return {
|
||
role: item.roleName ?? (item.isOwner ? '项目负责人' : '项目成员'),
|
||
name,
|
||
avatarChar: name.slice(0, 1),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 售后工单。
|
||
*
|
||
* <p>详情页 `AfterSales` 只有「无 / 处理中」两态,且 `type`(问题类型)后端没有对应字段
|
||
* ⇒ 留空,`description` 用售后原因、`remark` 用处理说明。</p>
|
||
*/
|
||
function toAfterSales(item: RequirementAfterSale): AfterSales {
|
||
const value = (item.status ?? '').toUpperCase()
|
||
const closed = value.includes('CLOSE') || value.includes('CANCEL') || value.includes('WITHDRAW')
|
||
return {
|
||
status: closed ? '无' : '处理中',
|
||
type: '',
|
||
description: item.reason ?? '',
|
||
remark: item.processNote ?? '',
|
||
attachments: [],
|
||
createTime: item.requestTime ?? '',
|
||
}
|
||
}
|
||
|
||
/** 详情接口返回体 → 详情页数据模型(未立项时项目侧字段自动为空) */
|
||
export function toProjectDetail(vo: RequirementDetailVo): Project {
|
||
const stage = vo.projectStage
|
||
const contract = vo.contracts?.[0]
|
||
return {
|
||
id: String(vo.id),
|
||
projectNo: vo.projectNo ?? '',
|
||
projectName: vo.projectName ?? '',
|
||
// 后端下发的是中文类型名(如「硬件电路」),归一化成前端 code;'0' 之类脏值归 other
|
||
businessType: toBusinessType(vo.businessType),
|
||
// 列表页的 progress 就是详情侧 developmentProgress;未立项无进度 ⇒ 0
|
||
progress: stage?.developmentProgress ?? 0,
|
||
status: toProjectStatus(vo.status),
|
||
submitTime: vo.createTime ?? '',
|
||
startTime: stage?.startTime ?? undefined,
|
||
endTime: stage?.finishTime ?? stage?.planTime ?? undefined,
|
||
milestones: (vo.projectMilestones ?? []).map((item) => ({
|
||
name: item.milestoneName ?? '',
|
||
status: toMilestoneStatus(item.status),
|
||
time: item.startTime ?? undefined,
|
||
})),
|
||
team: (vo.members ?? []).map(toTeamMember),
|
||
files: (vo.requirementAttachments ?? []).map(attachmentToFile),
|
||
paymentPlans: (vo.billPlans ?? []).map(toProjectBill),
|
||
invoiceRecords: (vo.invoices ?? []).map(invoiceToFile),
|
||
contractFile: contract ? contractToFile(contract) : undefined,
|
||
afterSales: vo.afterSale ? toAfterSales(vo.afterSale) : undefined,
|
||
}
|
||
}
|