Fms-Java版开发实录:23 合同 - 设计与新增合同功能初步实现

Fms-Java版开发实录:23 合同 - 设计与新增合同功能初步实现

继续慢慢写项目管理系统。。。

合同是非常重要的一个环节,一边承接预算的落实清单,一边会承接具体的明细记录。

这里相比原来的一个重大改变,就是我把合同和预算改成多对多的关系,虽然目前可能只会用到一对一,但还是先预留这种可能性并且写好程序再说。

合同因为只和预算发生关系,而且不会将合同跨项目调整,因此将URL设计如下:

  1. pms/contract/project/{pid}/list,某个项目下的所有合同
  2. pms/contract/budget/{bid}/list,某个预算项下的所有合同
  3. pms/contract/budget/{bid}/add,添加合同,这个链接的入口在预算的详情页面,为某个预算条目添加对应合同,这个实际上就是一对一的添加。
  4. pms/contract/project/{pid}/add,这个添加合同的功能实际上是可以选择多对多关系的添加,这样就无需到预算中去添加合同,需要手工选择所有的预算条目,预算条目我设置了多对多关系。
  5. pms/contract/detail/{id},合同详情页
  6. pms/contract/{pid}/edit/{id},修改合同,可以修改合同所属的预算条目,但不允许修改到其他项目去
  7. pms/contract/delete,删除合同

在未来针对一个合同添加明细记录的时候,就会从这个合同所对应的预算条目中,选择一个让每个明细记录对应其中的一个,这样就不会乱套,同时也能方便的根据预算条目来进行统计,这样一个明细记录实际上就有两个外键,一个关联到合同,一个关联到预算项目,以后统计预算项目的时候直接根据明细记录进行统计。

现在初步设计好了,后边就来编写。

合同PO类设计

原来设计的合同信息有点太多,我精简一下,不过不打算分表存储了,因为大部分数据其实也不多,这里主要就是关联到预算条目的多对多,以及关联到印花税类型的外键。

@Entity
@Table(name = "contract")
public class Contract implements Serializable {

    private static final long serialVersionUID = 9L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Integer id;

    // 合同编号
    @NotEmpty(message = "合同编号不能为空")
    @Length(max = 255, message = "合同编号最长255个字符")
    @Column(name = "number", nullable = false, unique = true)
    private String number;

    // 多对多关联到预算条目的外键
    @ManyToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinTable(name = "budget_contract", joinColumns = @JoinColumn(name = "contract_id", nullable = false),
            inverseJoinColumns = @JoinColumn(name = "budget_id", nullable = false))
    private List<Budget> budgets;

    //关联到印花税条目的外键,每个合同只对应一种印花税
    @ManyToOne(optional = false, fetch = FetchType.LAZY)
    @JoinColumn(name = "stamp_id")
    private Stamp stamp;

    // 合同名称
    @NotEmpty(message = "合同名称不能为空")
    @Length(max = 255, message = "合同名称最长255个字符")
    @Column(name = "name", nullable = false)
    private String name;

    // 合同主要条款
    @NotEmpty(message = "主要条款不能为空")
    @Length(max = 1000, message = "长度不能超过1000个字符")
    @Column(name = "terms", nullable = false, length = 1000)
    private String terms;

    // 主要合同方
    @NotEmpty(message = "主要合同方不能为空")
    @Length(max = 255, message = "长度不能超过255个字符")
    @Column(name = "supplier", nullable = false, length = 255)
    private String supplier;

    //以上为必填项目,以下为可留空项目

    // 签订时间
    @Temporal(TemporalType.DATE)
    @Column(name = "sign_date")
    @DateTimeFormat(pattern = "yyyy-MM-dd", iso = DateTimeFormat.ISO.DATE)
    private Date signDate;

    // 终止时间
    @Temporal(TemporalType.DATE)
    @Column(name = "end_date")
    @DateTimeFormat(pattern = "yyyy-MM-dd", iso = DateTimeFormat.ISO.DATE)
    private Date endDate;

    // 次要供应商1
    @Length(max = 255, message = "长度不能超过255个字符")
    @Column(name = "secondary_supplier1", length = 255)
    private String secondarySupplier1;

    // 次要供应商2
    @Length(max = 255, message = "长度不能超过255个字符")
    @Column(name = "secondary_supplier2", length = 255)
    private String secondarySupplier2;

    // 次要供应商3
    @Length(max = 255, message = "长度不能超过255个字符")
    @Column(name = "secondary_supplier3", length = 255)
    private String secondarySupplier3;

    // 合同标的
    @Length(max = 255, message = "长度不能超过255个字符")
    @Column(name = "target", length = 255)
    private String target;

    // 联系人
    @Length(max = 255, message = "长度不能超过255个字符")
    @Column(name = "contact", length = 255)
    private String contact;

    // 联系电话
    @Length(max = 255, message = "长度不能超过255个字符")
    @Column(name = "phone", length = 255)
    private String phone;
}

Budget类中要做相应修改:

    //关联到合同的多对多映射
    @ManyToMany(mappedBy = "budgets")
    private List<Contract> contracts;

这里的关键是要在哪一方进行级联更新,就要把多对多的主控方设置在哪一边,这里我们希望保存合同对象的时候,就可以更新中间表,那么就要把多对多的主控方放在Contract类中。

这里我还没有加上合同的价格要素,还在考虑如何添加,合同总价,不含税价和税是要有的,此外还应该区分开口合同和闭口合同,以及是否要包含最终结算价格。这个我还要再想一下。

新增合同

其实无论是修改还是新增,都是先把表单排出来。这里先编写通用的添加合同,也即让用户任意选择合同对应预算条目的功能。

合同页面

这里排了一个完整的页面,分为必填项目和选填项目两种,详细代码如下:

<main class="container">
    <nav style="--bs-breadcrumb-divider: '>';" aria-label="breadcrumb" class="mt-3">
        <ol class="breadcrumb">
            <li class="breadcrumb-item"><a href="/home" th:href="@{/home}" class="link-secondary">首页</a></li>
            <li class="breadcrumb-item" aria-current="page"><a href="/pms/project/list" th:href="@{/pms/project/list}"
                                                               class="link-secondary">项目管理</a></li>
            <li class="breadcrumb-item" aria-current="page"><a href="/pms/project/list" th:href="${project.absoluteUrl()}"
                                                               class="link-secondary">[[${project.projectName}]]</a></li>
            <li class="breadcrumb-item" aria-current="page"><a href="/pms/project/list" th:href="${project.addContractUrl()}"
                                                               class="link-secondary">新增合同</a></li>
        </ol>
    </nav>

    <div class="py-1 text-center">
        <h2>新增合同</h2>
    </div>
    <div class="row py-1" >
        <div class="col-sm-4"></div>
        <div th:if="${numberError}" class="text-center alert alert-danger col-12 col-sm-4" role="alert">
            [[${numberError}]]
        </div>
        <div class="col-sm-4"></div>
    </div>
    <div class="row g-5">
        <div class="col-2"></div>
        <div class="col-md-8 col-lg-8">
            <h4 class="mb-3">必填内容</h4>
            <form class="needs-validation" action="/pms/project/add" th:action="${project.addContractUrl()}" method="post"
                  th:object="${contract}">
                <div class="row g-3 mb-3">
                    <div class="col-sm-12">
                        <label for="contractName" class="form-label">合同名称</label>
                        <input type="text" class="form-control" id="contractName" th:field="*{name}"
                               th:classappend="${#fields.hasErrors('name')}? 'is-invalid'" required>
                        <div id="addressError" th:if="${#fields.hasErrors('name')}"
                             th:errors="*{name}"
                             class="invalid-feedback"></div>
                    </div>

                    <div class="col-sm-6">
                        <label for="contractNumber" class="form-label">合同编号</label>
                        <input type="text" class="form-control" id="contractNumber"
                               th:field="*{number}"
                               th:classappend="${#fields.hasErrors('number')}? 'is-invalid'" required>
                        <div id="numberError" th:if="${#fields.hasErrors('number')}"
                             th:errors="*{number}"
                             class="invalid-feedback"></div>
                    </div>

                    <div class="col-sm-6">
                        <label for="projectManager" class="form-label">主要合同方</label>
                        <input type="text" class="form-control" id="projectManager" th:field="*{supplier}"
                               th:classappend="${#fields.hasErrors('supplier')}? 'is-invalid'" required>
                        <div id="managerError" th:if="${#fields.hasErrors('supplier')}"
                             th:errors="*{supplier}"
                             class="invalid-feedback"></div>
                    </div>

                    <div class="col-12">
                        <label for="projectDescription" class="form-label">合同主要条款</label>
                        <textarea class="form-control" id="projectDescription" th:field="*{terms}" rows="3"
                                  th:classappend="${#fields.hasErrors('terms')}? 'is-invalid'" required>
                        </textarea>
                        <div id="descriptionError" th:if="${#fields.hasErrors('terms')}"
                             th:errors="*{terms}"
                             class="invalid-feedback"></div>
                    </div>

                    <div class="col-sm-6">
                    <p class="mb-0">合同所属预算条目(可按Ctrl键多选)</p>
                    <select class="form-select mt-2" multiple aria-label="budgets" name="budgets" th:field="*{budgets}">
                        <option th:each="budget, control:${budgets}" th:value="${budget.getId()}">[[${budget.budgetName}]]</option>
                    </select>
                    </div>

                    <div class="col-sm-6">
                        <p class="mb-0">合同印花税类型</p>
                        <select class="form-select mt-2" aria-label="stamp" name="stamp" th:field="*{stamp}">
                            <option th:each="stamp, control:${stamps}" th:value="${stamp.getId()}">[[${stamp.type}]] - [[${stamp.rate}]]</option>
                        </select>
                    </div>

                    <hr>
                    <h4 class="mb-3">选填内容</h4>

                    <div class="col-sm-6">
                        <label for="signDate" class="form-label">签订时间</label>
                        <input type="date" class="form-control" id="signDate" th:field="*{signDate}"
                               th:classappend="${#fields.hasErrors('signDate')}? 'is-invalid'">
                        <div id="startTimeError" th:if="${#fields.hasErrors('signDate')}"
                             th:errors="*{signDate}"
                             class="invalid-feedback"></div>
                    </div>

                    <div class="col-sm-6">
                        <label for="endDate" class="form-label">终止时间</label>
                        <input type="date" class="form-control" id="endDate" th:field="*{endDate}"
                               th:classappend="${#fields.hasErrors('endDate')}? 'is-invalid'">
                        <div id="endTimeError" th:if="${#fields.hasErrors('endDate')}"
                             th:errors="*{endDate}"
                             class="invalid-feedback"></div>
                    </div>

                    <div class="col-sm-4">
                        <label for="secondarySupplier1" class="form-label">其他合同方1</label>
                        <input class="form-control" id="secondarySupplier1" th:field="*{secondarySupplier1}"
                                  th:classappend="${#fields.hasErrors('secondarySupplier1')}? 'is-invalid'">
                        </input>
                        <div id="supplier1Error" th:if="${#fields.hasErrors('secondarySupplier1')}"
                             th:errors="*{secondarySupplier1}"
                             class="invalid-feedback"></div>
                    </div>
                    <div class="col-sm-4">
                        <label for="secondarySupplier2" class="form-label">其他合同方2</label>
                        <input class="form-control" id="secondarySupplier2" th:field="*{secondarySupplier2}"
                                  th:classappend="${#fields.hasErrors('secondarySupplier2')}? 'is-invalid'">
                        </input>
                        <div id="supplier2Error" th:if="${#fields.hasErrors('secondarySupplier2')}"
                             th:errors="*{secondarySupplier2}"
                             class="invalid-feedback"></div>
                    </div>
                    <div class="col-sm-4">
                        <label for="secondarySupplier3" class="form-label">其他合同方3</label>
                        <input class="form-control" id="secondarySupplier3" th:field="*{secondarySupplier3}"
                                  th:classappend="${#fields.hasErrors('secondarySupplier3')}? 'is-invalid'">
                        </input>
                        <div id="supplier3Error" th:if="${#fields.hasErrors('secondarySupplier3')}"
                             th:errors="*{secondarySupplier3}"
                             class="invalid-feedback"></div>
                    </div>

                    <div class="col-sm-6">
                        <label for="target" class="form-label">合同标的</label>
                        <input class="form-control" id="target" th:field="*{target}"
                               th:classappend="${#fields.hasErrors('target')}? 'is-invalid'">
                        </input>
                        <div id="targetError" th:if="${#fields.hasErrors('target')}"
                             th:errors="*{target}"
                             class="invalid-feedback"></div>
                    </div>

                    <div class="col-sm-6">
                        <label for="contact" class="form-label">联系人</label>
                        <input class="form-control" id="contact" th:field="*{contact}"
                               th:classappend="${#fields.hasErrors('contact')}? 'is-invalid'">
                        </input>
                        <div id="contactError" th:if="${#fields.hasErrors('contact')}"
                             th:errors="*{contact}"
                             class="invalid-feedback"></div>
                    </div>

                    <div class="col-sm-6">
                        <label for="phone" class="form-label">联系电话</label>
                        <input class="form-control" id="phone" th:field="*{phone}"
                               th:classappend="${#fields.hasErrors('phone')}? 'is-invalid'">
                        </input>
                        <div id="phoneError" th:if="${#fields.hasErrors('phone')}"
                             th:errors="*{phone}"
                             class="invalid-feedback"></div>
                    </div>
                </div>
                <div class="text-center">
                    <button class="btn btn-primary btn-lg" type="submit"><i class="fas fa-check"></i> 提交</button>
                    <a class="btn btn-secondary btn-lg" th:href="${project.absoluteUrl()}" href="/pms/project/list"><i class="fas fa-undo-alt"></i> 返回</a>
                </div>
            </form>
        </div>
        <div class="col-2"></div>

    </div>

</main>

这里的关键是那两个SELECT元素,使用id和正确的name属性并且搭配th:field的时候,Spring可以自动匹配上对应的数据对象,可以直接选择选中的对象,可见ThymeleafSpring的配合之棒啊。
页面效果如下:
fms - addContract

控制器

控制器就好写多了,主要是POST方法中的几个判断,包括判断合同编号是否重复,是否选择了对应的预算条目等。

@Controller
@RequestMapping("/pms/contract")
public class ContractController {

    private final BudgetService budgetService;
    private final ProjectService projectService;
    private final ContractService contractService;
    private final StampService stampService;

    @Autowired
    public ContractController(BudgetService budgetService, ProjectService projectService,
                              ContractService contractService, StampService stampService) {
        this.budgetService = budgetService;
        this.projectService = projectService;
        this.contractService = contractService;
        this.stampService = stampService;
    }

    //在所有方法执行之前放进判断显示导航条的方法
    @ModelAttribute
    public void getCurrentUser(Model model) {
        User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        model.addAttribute("isAdmin", user.hasRole("ADMIN"));
    }

    @GetMapping("/project/{pid}/add")
    public String addContractPage(@ModelAttribute("contract") Contract contract, @PathVariable("pid") int pid, Model model) {
        Project project = projectService.findById(pid);
        List<Budget> budgets = budgetService.getBudgetByProjectId(pid);

        model.addAttribute("project", project);
        model.addAttribute("budgets", budgets);
        model.addAttribute("stamps", stampService.getAllStamps());

        return "pms/contract/contractAdd";
    }

    @PostMapping("/project/{pid}/add")
    public String addContract(@Valid @ModelAttribute("contract") Contract contract, BindingResult rs,
                              Model model, RedirectAttributes attributes, @PathVariable("pid") int pid) {
        Project project = projectService.findById(pid);
        List<Budget> budgets = budgetService.getBudgetByProjectId(pid);
        model.addAttribute("project", project);
        model.addAttribute("budgets", budgets);
        model.addAttribute("stamps", stampService.getAllStamps());

        if (rs.hasErrors()) {
            return "pms/contract/contractAdd";
        } else {
            // 合同编号重复
            if (contractService.existByNumber(contract.getNumber())) {
                model.addAttribute("numberError", "该合同编号已经存在");
                return "pms/contract/contractAdd";
            }

            //没有对应的预算条目
            if (contract.getBudgets() == null || contract.getBudgets().size() == 0) {
                model.addAttribute("numberError", "未选择预算条目");
                return "pms/contract/contractAdd";
            }

            contractService.saveContract(contract);
            System.out.println(contract);
            attributes.addFlashAttribute("created", "成功添加合同" + contract.getName());
            return "redirect:" + project.absoluteUrl();

        }
    }

}

最后的return "redirect:" + project.absoluteUrl();是临时先添加的,实际上应该转到该项目的合同列表页面。

这样就做好了添加合同的功能。做到这里实际上我发现,也未必要再做从预算中添加合同的功能了,就用这个通用功能即可。这其中和涉及了创建ContractDao和业务层等内容,就不一一放出了,唯一一个值得写的就是根据某个预算查到其对应的所有合同的功能:

public interface ContractDao extends JpaRepository<Contract, Integer> {

    List<Contract> findContractsByBudgetsContains(Budget budget);

    boolean existsByNumber(String number);

}

不得不说掌握了JPA之后还是很香的,可以快速的写出某些查询方法。至于重型计算,则可以在查出来之后在业务层进行运算和组装BO

添加价格相关的字段

这里的价格字段要特别注意,一般合同会有总价,如果能够分离出含税价和不含税价,则还要加上验证功能。
此外,如果是单价合同,再设置一个布尔值用于勾选,如果勾上了这个布尔值,则所有的计算是否超付的功能就都返回false

// 合同总价
    @NotNull(message = "合同总价不能为空")
    @Digits(integer = 14, fraction = 2)
    private BigDecimal totalPrice;

    //是否单价合同或开口合同
    @Column(name = "is_open")
    @ColumnDefault("false")
    private boolean isOpen;

    //以上为必填项目,以下为可留空项目

    // 不含税价格
    @Digits(integer = 14, fraction = 2)
    private BigDecimal priceWithoutVAT;

    // 增值税额
    @Digits(integer = 14, fraction = 2)
    private BigDecimal VAT;

    // 结算价格
    @Digits(integer = 14, fraction = 2)
    private BigDecimal finalPrice;

然后在保存的过程中,需要检测一下不含税价和含税价是否有问题:

// 如果有一个不为空,另外一个为空,将其设置为0
            if ((contract.getPriceWithoutVAT() == null) && (contract.getVAT() != null)) {
                contract.setPriceWithoutVAT(new BigDecimal("0.00"));
            }
            if ((contract.getPriceWithoutVAT() != null) && (contract.getVAT() == null)) {
                contract.setVAT(new BigDecimal("0.00"));
            }

            // 判断都不为空的情况下,是否相等
            if ((contract.getPriceWithoutVAT() != null) && (contract.getVAT() != null)) {
                if (!contract.getTotalPrice().equals(contract.getPriceWithoutVAT().add(contract.getVAT()))) {
                    model.addAttribute("numberError", "不含税价+增值税 不等于 合同总价");
                    return "pms/contract/contractAdd";
                }
            }

            contractService.saveContract(contract);
            attributes.addFlashAttribute("created", "成功添加合同" + contract.getName());
            return "redirect:" + project.absoluteUrl();

这样就初步做好了,之后就是来写合同的展示和修改页面,也是比较简单的。

LICENSED UNDER CC BY-NC-SA 4.0
Comment