项目预算和交付日期变了,为什么不能直接改台账?

项目预算和交付日期变了,为什么不能直接改台账?

一个项目开始执行后,预算需要增加,计划结束时间需要后移,项目经理也可能交接。给台账加一个编辑按钮,就能很快把最新数字填进去。但过一段时间再回头看,团队往往还要回答另一组问题:原来怎么定的,为什么要改,谁批准的,这次到底改了哪些内容?

台账适合表达当前执行口径,变更单适合表达一次调整的依据。把两种职责混在一起,页面上的值虽然更新了,调整过程却很容易消失。

我们在RuoYi Office的项目模块中,把变更申请、结构化前后值、流程审批和台账生效串成一条链。本文先用一个已有的项目经理变更样本看页面,再拆解预算与计划日期如何复用这条链。这里的日期调整落在项目计划开始、计划结束字段,不能据此推导合同交付承诺也会同步修改。

▲ 主线是"提出调整→审批确认→回写台账"。不同变更类型只更新各自负责的字段;附件归档还需要有实际附件和可用的文档服务。

台账看现在,变更单说明这一次为什么改

先看项目台账。它汇集项目经理、计划日期、预算,以及任务、工时、文档、变更等执行信息。如果每次调整都直接覆盖台账,其他业务仍能读到最新值,却难以解释这个值的来历。

▲ 本地系统既有演示项目PJ202606070002。当前项目经理为"hr 小姐姐",备注含变更单号PROJECT602-2026072900001。预算与计划日期只是该项目现值,不能当成本次变更过的字段;手机可点开整屏图查看。

这里没有把预算、合同额、实际成本合并成一个金额,也没有把项目计划结束日当成任务树里每个任务的结束日。它们分别服务于预算控制、履约约定和执行排程。一个字段发生变化,是否需要连带调整其他对象,必须由具体业务规则决定。

变更单保留另一种视角:针对哪个项目,改哪一类内容,为什么改,影响是什么,以及本次希望写入的新值。审批人据此判断调整,而不是面对一张被人改过却不知道差异的台账。

这也解释了为什么产品需要同时保留立项原件和执行台账:立项表达初始申请,台账表达执行现状,后续变更把二者之间的调整过程连接起来。本文核对的是既有数据,不通过重新提交或改动演示记录制造前后效果。

从一张人员变更,看清申请与结果的对应关系

现有样本的变更类型是人员变更,内容为项目经理调整。页面将申请说明和结构化明细分开:说明给人读,字段值供程序处理。前后对比能同时看到经理ID从1到114,以及名称从原负责人变成新负责人。

▲ 同一变更单的完整浏览器视口,自然滚动到对比区。前值"宇擎源码"、后值"hr 小姐姐";这张图说明申请记录里的差异,不是把两个后台页面拼接成前后效果。

再进入同一项目的"项目动态",筛选变更,可以找到带同一单号的"变更生效"记录。台账经理与变更后值一致,备注和动态又保留了变更编号,读者能够把申请、现值和生效记录对应起来。

▲ 变更动态关联PROJECT602-2026072900001,时间为既有记录的2026-07-29 20:04:30。本轮只读核对这些历史状态,没有重新执行审批,也不把当前页面称作变更前台账。

这三张图分别回答:项目现在由谁负责,这次提出了怎样的调整,以及系统记录了哪张变更单生效。它们没有证明预算或日期也在这次调整中变化,后面两类规则需要回到实现中分别看。

把变更类型做成业务边界,而不是给所有字段开编辑口

同一张变更单支持多种类型,但并不意味着把整个台账对象原样提交。我们把"这次允许表达哪些新值"作为第一层边界。

类型 当前处理目标
范围 项目描述/范围
进度 计划开始、计划结束
预算 项目预算金额
人员 项目经理ID与名称
基础信息 项目名称、优先级、分类、项目集、类型、部门等
其他 备注等补充信息

"人员变更"在这里具体落实到项目经理,不是任意项目成员的增删,也不意味着所有存量任务负责人同时改掉。"预算变更"更新台账预算金额,不自动生成付款单;"进度变更"更新项目计划日期,不自动重排WBS和里程碑。

Vue3表单先维护可编辑的新值,提交时再依据类型组装newValue。下面摘取进度、预算和人员三个分支,其他分支省略;返回对象中的其他单据元数据也省略了:

typescript 复制代码
function syncPayload(): ProjectChangeApi.Change {
  const after: ProjectChangeApi.ChangeValueSnapshot = {};
  const type = formData.value.changeType;
  // 其他变更类型分支省略
  if (type === 2) {
    after.planStartDate = changeAfter.planStartDate;
    after.planEndDate = changeAfter.planEndDate;
  } else if (type === 3) {
    after.budgetAmount = changeAfter.budgetAmount;
  } else if (type === 4) {
    after.managerUserId = changeAfter.managerUserId;
    after.managerUserName = changeAfter.managerUserName;
  }
  return {
    ...(formData.value as ProjectChangeApi.Change),
    newValue: stringifyChangeValue(after),
    // 附件与originalValue组装省略
  };
}

这一步减少了无关字段进入变更载荷的机会,后端仍要按类型解释字段,不能把前端组装当成安全校验。页面的对比组件也使用同一套类型字段映射:进度只比较计划日期,人员只比较经理字段。否则完整快照中无关的空值、名称差异,可能淹没审批人真正需要判断的变化。

对比本身还有一条小规则:当前组件将空值显示为"-",把值转换成展示文本后比较。这适合呈现字段变化,不等于已经完成金额精度归一、对象深层比较或语义相等判定。面向更复杂的变更对象时,需要补充对应的比较方式。

快照留下的是参照,生效更新的是当前项目

▲ 台账与变更单通过projectId关联;流程用businessKey关联变更单ID;活动和文档保留来源身份。这是逻辑关联图,不宣称数据库已建立物理外键。

变更记录中最关键的几组字段职责不同:

字段 保存的业务含义
projectId、billCode 被调整的项目与本次变更编号
changeType、changeContent 类型与给人阅读的调整说明
originalValue、newValue 参照快照与本次希望生效的字段值
processInstanceId、processStatus 对应审批实例及其结果

originalValue与newValue都以字符串承载结构化内容,但不能简单把它们理解成数据库自动管理的版本。新建时,前端会读取所选项目形成参照;后端保存或提交时,如果原值为空,也会用项目现值补齐。

已有变更重新打开时,页面从已保存的originalValue和newValue恢复对比,另读当前台账作为上下文。这样查看旧单时,不必把"今天的台账"当成"当时的原值"。不过,服务端当前只在原值为空时补快照,并未在此处把客户端提交的原值验证成不可篡改基线。

通过钩子还有一条历史数据兜底:如果旧单一直没有原值,会在批准时补一份项目快照。因此不能承诺所有记录的originalValue都严格冻结于申请时点,更不能用它直接证明申请到批准期间无人调整过项目。

以下代码保留了批准后的快照补齐与生效顺序,省略日志:

java 复制代码
@Override
@Transactional(rollbackFor = Exception.class)
public void onProcessApproved(String businessKey) {
    Long id = Long.parseLong(businessKey);
    ProjectChangeDO change = validateChangeExists(id);

    ProjectChangeDO updateObj = new ProjectChangeDO();
    updateObj.setId(id);
    updateObj.setProcessStatus(BpmTaskStatusEnum.APPROVE.getStatus());
    if (StrUtil.isBlank(change.getOriginalValue())) {
        ProjectLedgerDO project = validateProjectExists(change.getProjectId());
        updateObj.setOriginalValue(buildProjectSnapshot(project));
        change.setOriginalValue(updateObj.getOriginalValue());
    }
    changeMapper.updateById(updateObj);
    applyChangeToProject(change);
}

这里没有直接把整份快照覆盖回台账。生效方法根据changeType调用各自的字段处理函数,再把局部更新交给台账服务。这样一次经理变更不会顺带把申请时的旧预算、旧日期写回来。

审批不是存一个结果,而是决定哪一次调整可以执行

保存草稿时,服务写变更记录和附件,不执行台账回写。提交时,服务再检查变更类型、说明和对应的新值能否解析,随后创建审批实例,把变更单ID作为businessKey,并回填实例ID。

状态上也有区别:未提交、审批不通过和已撤回可进入再次提交路径;审批中、已通过等状态不能重复按草稿提交。保存方法另外允许审批中的记录进入局部保存路径,但具体审批字段权限不能只靠这一个状态判断保证,仍要结合页面字段权限与后端输入约束。

我们把关键设计取舍归纳为四项:

决策 当前落点与原因
说明与可执行值分开 说明表达背景,newValue承担明确字段赋值,避免解析自然语言猜金额
草稿与生效分开 草稿可以继续准备;通过钩子才进入台账更新
按类型局部回写 每类只触碰负责的字段,减少无关旧值覆盖
当前状态与依据分开 台账读现值,变更单、动态与附件保留来源

▲ 保存和提交不等于台账生效。通过后进入项目业务钩子,再更新台账、记录动态,并在文档服务可用时索引附件。各步骤的失败处理并不完全相同。

本文不展开引擎事件的传输方式,只关注结果到达项目服务之后做什么。接入时仍须确保所选通知通道确实调用项目的通过钩子;如果只有processStatus变成通过,却没有进入onProcessApproved,台账就不会因为一个绿色标签自动变化。

经理、日期和预算,各有一段明确的赋值规则

人员变更与前面页面直接对应。服务读取经理ID和名称,只对存在的新值赋值,最后由统一生效方法写入台账:

java 复制代码
private void applyMemberChange(ProjectLedgerDO projectUpdate,
                               ProjectChangeDO change, JsonNode newValueNode) {
    Long managerUserId = readLong(newValueNode, "managerUserId");
    String managerUserName = readText(newValueNode, "managerUserName");
    if (managerUserId != null) {
        projectUpdate.setManagerUserId(managerUserId);
    }
    if (StrUtil.isNotBlank(managerUserName)) {
        projectUpdate.setManagerUserName(managerUserName);
    }
    // 无有效经理信息时的警告日志省略
}

它解释了为什么页面既要选择人员ID,也要保留用于展示的名称。当前提交校验允许经理ID或经理名称至少一项存在;如果业务要求负责人必须是有效用户,还需要服务端进一步校验身份和二者一致性,不能仅凭有名称就认定授权关系已同步。

进度变更采用类似的局部赋值方式,同时兼容历史startDate、endDate键。下面保留日期取值和更新条件,省略无法解析时的警告日志:

java 复制代码
private void applyScheduleChange(ProjectLedgerDO projectUpdate,
                                 ProjectChangeDO change, JsonNode newValueNode) {
    LocalDate planStartDate = readLocalDate(newValueNode, "planStartDate");
    LocalDate planEndDate = readLocalDate(newValueNode, "planEndDate");
    if (planStartDate == null) {
        planStartDate = readLocalDate(newValueNode, "startDate");
    }
    if (planEndDate == null) {
        planEndDate = readLocalDate(newValueNode, "endDate");
    }
    if (planStartDate != null) {
        projectUpdate.setPlanStartDate(planStartDate);
    }
    if (planEndDate != null) {
        projectUpdate.setPlanEndDate(planEndDate);
    }
}

这段规则允许只调整开始或结束中的一项;null在这里代表不赋值,不能直接当成清空已有日期的指令。日期解析支持日期字符串以及特定长度的时间戳,时间戳按服务端时区转为日期。纯日期场景优先使用明确的日期字符串,有助于减少时区转换带来的理解差异。

预算变更先读取结构化budgetAmount,解析不到时兼容旧的纯金额文本,最终赋给台账预算字段。它不是增量累加:把预算调到某个数,与"在现有预算上增加某个数"是两种不同指令,申请说明和新值应保持一致。

当前提交校验主要确认对应类型有可解析的新值,不能据此宣传日期前后关系、预算正数、增幅上限、成本占用等业务制度都已完整覆盖。若需要这些控制,应把规则补在业务服务边界,而不是只在日期选择器或数字输入框上限制。

动态与附件,让结果有出处,但不是完整幂等的替代品

字段处理之后,统一生效方法会在台账备注中追加带变更单号的标记,调用台账服务更新字段,再记录"变更生效"动态。这就是前面页面上备注与活动记录的来源。备注追加会检查同一标记是否已存在,避免重复堆同一行文字。

附件有两条路径需要区分:新单据通过AttachmentService按业务类型与单据ID保存附件;历史fileUrls字段则兼容保留。文档服务可用时,生效方法确保项目空间存在,并按项目、来源类型和变更单ID索引附件。索引会跳过空地址,并检查相同来源与URL是否已有记录。

这些规则让项目资料可以保留来源身份,但不能写成"每次变更必有附件"或"审批一过就产生新的文件内容"。本次人员变更样本的附件区为空,所以正文不拿它证明实际归档效果。

还要区分记录策略:项目动态的record方法捕获异常并记录警告,设计上不以动态失败阻断主业务。通过钩子虽有事务注解,也不能仅凭这一行注解,宣称流程引擎、通知、主数据消息和所有附件操作构成跨服务原子事务。

对于同一项目同时审批两张变更单,按类型局部更新能减少无关覆盖,却没有自动解决同字段竞争。两张预算变更先后批准,后执行的赋值仍可能覆盖先前结果。更严格的场景可以增加台账版本、批准时基线校验和冲突处理,并将本次生效身份记录下来。

重复执行也要单独治理。备注标记防重复、文档来源检查,只覆盖各自的小范围;活动记录和其他附带动作仍需检查。可以引入独立生效记录与唯一约束,明确哪些动作已完成、哪些允许重试。这些属于进一步设计建议,不把现有留痕包装成恰好执行一次的保证。

项目变更的价值,是把"最新值"与"为什么变成这个值"接在一起。预算、计划日期、经理都可以沿用同一申请和审批骨架,但各自的字段、校验和联动必须保持具体。台账继续服务执行,变更单承担调整依据,后续讨论才有一个能够追溯的起点。

相关推荐
小蒜学长1 小时前
基于过程性评价的ai助学平台(代码+数据库+LW)
java·spring boot·后端·微信小程序·过程性评价
博风2 小时前
使用Spring官网下载Springboot骨架
java·spring boot·spring
木井巳3 小时前
【JavaEE】SpringBoot 统一功能处理: 拦截器、统一返回与全局异常
java·spring boot·后端·spring·java-ee
Thomas.Sir3 小时前
第6课:SpringBoot4.x 核心注解全解:Component、Bean、Scope、Lazy等IOC相关注解
java·spring boot
paopaokaka_luck3 小时前
美食推荐系统(协同过滤算法推荐、AI助手、HanLP中文分词、 ECharts可视化分析、菜谱学习与话题交流)
java·spring boot·mysql·tomcat·echarts
相隔一个图书馆的距离4 小时前
单测(三)Spring Boot 3.5.6 && @MockitoSpyBean / AOP / 循环依赖:静默吞错与运行期 NPE
java·spring boot·后端
凤山老林4 小时前
Spring Boot 集成 Apache CXF 发布与消费 SOAP WebService:WSDL、WS-Security
spring boot·后端·apache·webservice·soap·apache cxf
软件技术NINI4 小时前
基于JavaWeb 汽车租聘
java·vue.js·spring boot·后端·python·毕业设计·毕设
yz_aiks4 小时前
RestTemplate 废弃后迁移 RestClient 实战:Spring Boot 4.2 升级指南
java·spring boot·后端·微服务·restclient