合同是非常重要的一个环节,一边承接预算的落实清单,一边会承接具体的明细记录。
这里相比原来的一个重大改变,就是我把合同和预算改成多对多的关系,虽然目前可能只会用到一对一,但还是先预留这种可能性并且写好程序再说。
合同因为只和预算发生关系,而且不会将合同跨项目调整,因此将URL
设计如下:
pms/contract/project/{pid}/list
,某个项目下的所有合同pms/contract/budget/{bid}/list
,某个预算项下的所有合同pms/contract/budget/{bid}/add
,添加合同,这个链接的入口在预算的详情页面,为某个预算条目添加对应合同,这个实际上就是一对一的添加。pms/contract/project/{pid}/add
,这个添加合同的功能实际上是可以选择多对多关系的添加,这样就无需到预算中去添加合同,需要手工选择所有的预算条目,预算条目我设置了多对多关系。pms/contract/detail/{id}
,合同详情页pms/contract/{pid}/edit/{id}
,修改合同,可以修改合同所属的预算条目,但不允许修改到其他项目去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
可以自动匹配上对应的数据对象,可以直接选择选中的对象,可见Thymeleaf
和Spring
的配合之棒啊。
页面效果如下:
控制器
控制器就好写多了,主要是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();
这样就初步做好了,之后就是来写合同的展示和修改页面,也是比较简单的。