恢复点(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>
196 lines
9.2 KiB
Java
196 lines
9.2 KiB
Java
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);
|
||
}
|
||
}
|