Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/InvoiceController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:19:15 +08:00

196 lines
9.2 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.Money;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.Invoice;
import com.kaidi.oa.repository.ContractRepository;
import com.kaidi.oa.repository.InvoiceRepository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.List;
/**
* Invoice master data. type is one of 进项 / 销项;
* status is one of 已开 / 已认证 / 作废.
*/
@RestController
@RequestMapping("/api/oa/invoices")
public class InvoiceController {
/** Invoice statuses that count as issued, i.e. eligible to write back to the contract. */
private static final java.util.Set<String> ISSUED_STATUSES = java.util.Set.of("已开", "已开票", "已开具", "已认证");
private final InvoiceRepository invoiceRepo;
private final ContractRepository contractRepo;
public InvoiceController(InvoiceRepository invoiceRepo, ContractRepository contractRepo) {
this.invoiceRepo = invoiceRepo;
this.contractRepo = contractRepo;
}
/**
* Only 销项 (output/sales) invoices write back to the contract's invoicedAmount
* (receivable side). 进项 (input/purchase) invoices are payables and must NOT
* inflate the contract's invoiced amount (H12: 应付被当应收混入资金池).
* Unspecified type defaults to output for backward compatibility.
*/
private static boolean isOutput(String type) {
return !"进项".equals(type);
}
/** Amount that writes back to the contract: pre-tax amount, falling back to total. */
private static BigDecimal writeBackAmount(BigDecimal amount, BigDecimal total) {
return Money.nz(amount).signum() != 0 ? Money.nz(amount) : Money.nz(total);
}
/**
* Apply a signed delta to the contract's invoicedAmount only when the invoice
* is output (销项) and issued (counts toward the receivable). sign=+1 books the
* amount, sign=-1 reverses it. No-op when contractId is null or the invoice does
* not qualify. Shared by create (book new), update (reverse old + book new) and
* delete (reverse old) so the contract's invoicedAmount never drifts.
*/
private void applyContractDelta(Long contractId, String type, String status,
BigDecimal amount, BigDecimal total, int sign) {
if (contractId == null || !isOutput(type) || !ISSUED_STATUSES.contains(status)) {
return;
}
BigDecimal base = writeBackAmount(amount, total);
BigDecimal delta = sign < 0 ? base.negate() : base;
if (delta.signum() == 0) {
return;
}
contractRepo.findById(contractId).ifPresent(contract -> {
contract.setInvoicedAmount(Money.add(contract.getInvoicedAmount(), delta));
contractRepo.save(contract);
});
}
@GetMapping
public ApiResp<List<Invoice>> list(@RequestParam(required = false) Long projectId) {
List<Invoice> list = (projectId == null)
? invoiceRepo.findAll()
: invoiceRepo.findByProjectId(projectId);
return ApiResp.ok(list);
}
@GetMapping("/{id}")
public ApiResp<Invoice> get(@PathVariable Long id) {
return ApiResp.ok(invoiceRepo.findById(id)
.orElseThrow(() -> new NotFoundException("invoice not found: " + id)));
}
// total (price-and-tax sum) is derived server-side as amount + taxAmount;
// the front-end no longer passes total raw.
public record CreateInvoiceRequest(
String code, String number, String type, Double amount, Double taxAmount,
String partyName, Long projectId, Long contractId, String status,
String issueDate) {
}
@Transactional
@PostMapping
public ApiResp<Invoice> create(@RequestBody CreateInvoiceRequest req) {
if (req.number() == null || req.number().isBlank()) {
throw new ApiException(400, "发票号码不能为空");
}
Invoice v = new Invoice();
v.setCode(req.code());
v.setNumber(req.number());
v.setType(req.type());
BigDecimal amount = Money.of(req.amount());
BigDecimal taxAmount = Money.of(req.taxAmount());
v.setAmount(amount);
v.setTaxAmount(taxAmount);
v.setTotal(Money.add(amount, taxAmount)); // -> price-and-tax total = pre-tax amount + tax
v.setPartyName(req.partyName());
v.setProjectId(req.projectId());
v.setContractId(req.contractId());
// 建单一律以"待开"起始,不接受客户端直接置"已开"——否则建单即把金额回写进合同应收(invoicedAmount)、
// 绕过开具这一受控动作。开票回写只在后续显式 update(待开→已开) 时发生,使写回有据可查。
v.setStatus("待开");
v.setIssueDate(req.issueDate());
Invoice saved = invoiceRepo.save(v);
// 新建恒为"待开"applyContractDelta 对未开具发票为 no-op;此调用保留以兼容(实际不回写)。
applyContractDelta(saved.getContractId(), saved.getType(), saved.getStatus(),
saved.getAmount(), saved.getTotal(), +1);
return ApiResp.ok(saved);
}
// total is derived server-side as amount + taxAmount; not accepted from the client.
public record UpdateInvoiceRequest(
String code, String number, String type, Double amount, Double taxAmount,
String partyName, Long projectId, Long contractId, String status,
String issueDate) {
}
/** PUT /{id} -> update an invoice; recomputes price-and-tax total from amount + taxAmount. */
@Transactional
@PutMapping("/{id}")
public ApiResp<Invoice> update(@PathVariable Long id, @RequestBody UpdateInvoiceRequest req) {
Invoice v = invoiceRepo.findById(id)
.orElseThrow(() -> new NotFoundException("invoice not found: " + id));
// Snapshot the pre-update contract booking so we can reverse exactly what was
// booked before applying the new values (handles 改额/作废/换合同/转进项).
Long oldContractId = v.getContractId();
String oldType = v.getType();
String oldStatus = v.getStatus();
BigDecimal oldAmount = v.getAmount();
BigDecimal oldTotal = v.getTotal();
if (req.number() != null) {
if (req.number().isBlank()) {
throw new ApiException(400, "发票号码不能为空");
}
v.setNumber(req.number());
}
if (req.code() != null) v.setCode(req.code());
if (req.type() != null) v.setType(req.type());
if (req.partyName() != null) v.setPartyName(req.partyName());
if (req.projectId() != null) v.setProjectId(req.projectId());
if (req.contractId() != null) v.setContractId(req.contractId());
if (req.status() != null) v.setStatus(req.status());
if (req.issueDate() != null) v.setIssueDate(req.issueDate());
// When either money field is supplied, take the new value (else keep current),
// then re-derive total = amount + taxAmount so total never drifts from its parts.
if (req.amount() != null || req.taxAmount() != null) {
BigDecimal amount = req.amount() == null ? v.getAmount() : Money.of(req.amount());
BigDecimal taxAmount = req.taxAmount() == null ? v.getTaxAmount() : Money.of(req.taxAmount());
v.setAmount(amount);
v.setTaxAmount(taxAmount);
v.setTotal(Money.add(amount, taxAmount)); // -> price-and-tax total = pre-tax amount + tax
}
Invoice saved = invoiceRepo.save(v);
// Reverse the old booking, then book the new one. Either side is a no-op when
// it does not qualify, so 作废(已开->作废) reverses only, and 待开->已开 books only.
applyContractDelta(oldContractId, oldType, oldStatus, oldAmount, oldTotal, -1);
applyContractDelta(saved.getContractId(), saved.getType(), saved.getStatus(),
saved.getAmount(), saved.getTotal(), +1);
return ApiResp.ok(saved);
}
/** DELETE /{id} -> remove an invoice and reverse its contract booking. */
@Transactional
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
Invoice v = invoiceRepo.findById(id)
.orElseThrow(() -> new NotFoundException("invoice not found: " + id));
// Reverse whatever this invoice booked onto the contract before removing it.
applyContractDelta(v.getContractId(), v.getType(), v.getStatus(),
v.getAmount(), v.getTotal(), -1);
invoiceRepo.deleteById(id);
return ApiResp.ok(null);
}
}