项目中心
This commit is contained in:
@@ -9,32 +9,43 @@ import type {
|
||||
RequirementAttachment,
|
||||
RequirementBillPlan,
|
||||
RequirementContract,
|
||||
RequirementDetailVo,
|
||||
RequirementDetailBundle,
|
||||
RequirementInvoice,
|
||||
RequirementMember,
|
||||
TeamMember,
|
||||
} from '@/types/business/project'
|
||||
import { toBusinessType } from '@/utils/format'
|
||||
import { PROJECT_STATUS_LABEL } from '@/utils/projectMeta'
|
||||
|
||||
/**
|
||||
* 需求详情(后端 `ProjectRequirementUserVo`)→ 详情页 `Project`。
|
||||
* 需求详情(后端 **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>码值域是两张表的并集(见 {@link ProjectStatus}):需求侧 `INITIATING`/`CANCELLED`,
|
||||
* 项目侧 `IN_PROGRESS`/`PENDING_ACCEPTANCE`/… 未知码值兜底「待立项」(未立项是最常见态)。</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 label = PROJECT_STATUS_LABEL[raw ?? '']
|
||||
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) ?? '待立项'
|
||||
}
|
||||
|
||||
@@ -54,16 +65,48 @@ function toMilestoneStatus(raw: string | null | undefined): Milestone['status']
|
||||
/**
|
||||
* 账单/付款计划状态 → 三态。
|
||||
*
|
||||
* <p>⚠️ 后端 `BillPlan.status` 的码值语义**未确认**(`vm_bill_plan` 当前 0 行,无样本),
|
||||
* 只认字面量,其余一律「未到期」——不臆造成「已支付」。</p>
|
||||
* <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('支付')) return '已支付'
|
||||
if (value.includes('WAIT') || value.includes('PENDING') || value.includes('待支付')) return '待支付'
|
||||
// ⚠️ 关键词别用宽泛的「支付」:中文「**待**支付」也包含它,会先被这里截走误判成「已支付」。
|
||||
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 {
|
||||
@@ -90,15 +133,17 @@ function contractToFile(item: RequirementContract): FileInfo {
|
||||
name: item.title ?? item.contractNo ?? '合同文件',
|
||||
size: 0,
|
||||
url: item.file ?? '',
|
||||
uploadTime: item.signDate ?? item.createTime ?? '',
|
||||
// signDate 是 DATE 列,后端会下发成 '2026-08-20 00:00:00' ⇒ 只保留到日
|
||||
uploadTime: toDate(item.signDate ?? item.createTime) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 付款计划。
|
||||
*
|
||||
* <p>`BillPlan` 没有「单据号」也没有「期次名」,所以:`planName` 用 `periodRule`(付不出
|
||||
* 就用「第 N 期/共 M 期」拼),`billNo` 回落到 `projectNo`。</p>
|
||||
* <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
|
||||
@@ -106,16 +151,16 @@ function toProjectBill(item: RequirementBillPlan): ProjectBill {
|
||||
: ''
|
||||
return {
|
||||
id: String(item.id),
|
||||
planName: item.periodRule || period || '付款计划',
|
||||
planName: period || item.periodRule || '付款计划',
|
||||
billNo: item.projectNo ?? '',
|
||||
amount: item.totalAmount ?? 0,
|
||||
amount: toNumber(item.totalAmount),
|
||||
status: toBillStatus(item.status),
|
||||
projectName: item.projectName ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 项目成员 → 详情页团队成员卡 */
|
||||
function toTeamMember(item: RequirementDetailVo['members'][number]): TeamMember {
|
||||
function toTeamMember(item: RequirementMember): TeamMember {
|
||||
const name = item.userName ?? ''
|
||||
return {
|
||||
role: item.roleName ?? (item.isOwner ? '项目负责人' : '项目成员'),
|
||||
@@ -127,48 +172,71 @@ function toTeamMember(item: RequirementDetailVo['members'][number]): TeamMember
|
||||
/**
|
||||
* 售后工单。
|
||||
*
|
||||
* <p>详情页 `AfterSales` 只有「无 / 处理中」两态,且 `type`(问题类型)后端没有对应字段
|
||||
* ⇒ 留空,`description` 用售后原因、`remark` 用处理说明。</p>
|
||||
* <p>详情页 `AfterSales` 只有「无 / 处理中」两态:撤回(WITHDRAWN)/ 作废 / 关闭 ⇒ 「无」
|
||||
* (页面回到可再次申请的状态),其余(REQUESTED / IN_PROGRESS / COMPLETED)都算「处理中」。</p>
|
||||
*
|
||||
* <p>字段对应:`type` = 售后类型、`description` = 用户填的问题描述(后端 `reason`)、
|
||||
* `remark` = 用户补充备注(后端 `remark`,不是处理人写的 `processNote`)、
|
||||
* `attachments` = 售后工单附件(同表 `phase = AFTER_SALE`,由后端随页签下发)。</p>
|
||||
*/
|
||||
function toAfterSales(item: RequirementAfterSale): AfterSales {
|
||||
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: '',
|
||||
type: item.type ?? '',
|
||||
description: item.reason ?? '',
|
||||
remark: item.processNote ?? '',
|
||||
attachments: [],
|
||||
remark: item.remark ?? '',
|
||||
attachments: attachments.map(attachmentToFile),
|
||||
createTime: item.requestTime ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 详情接口返回体 → 详情页数据模型(未立项时项目侧字段自动为空) */
|
||||
export function toProjectDetail(vo: RequirementDetailVo): Project {
|
||||
const stage = vo.projectStage
|
||||
const contract = vo.contracts?.[0]
|
||||
/** 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(vo.id),
|
||||
projectNo: vo.projectNo ?? '',
|
||||
projectName: vo.projectName ?? '',
|
||||
id: String(base.id),
|
||||
projectNo: base.projectNo ?? '',
|
||||
projectName: base.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),
|
||||
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: vo.afterSale ? toAfterSales(vo.afterSale) : undefined,
|
||||
afterSales: afterSales.afterSale
|
||||
? toAfterSales(afterSales.afterSale, afterSales.afterSaleAttachments ?? [])
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user