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 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(@RequestParam(required = false) Long projectId) { List list = (projectId == null) ? invoiceRepo.findAll() : invoiceRepo.findByProjectId(projectId); return ApiResp.ok(list); } @GetMapping("/{id}") public ApiResp 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 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 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 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); } }