Files
2026-10-19 01:13:17 +08:00

301 lines
8.9 KiB
Go

package erp
import (
"fmt"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
)
// applyToBill 向一张往来账核销 take 金额(正数)。
// 正数账(应收/应付)核销使 settled 增加;红字账(负额)反向移动,
// 统一口径:余额 = amount - settled,核销使 |余额| 趋近 0。
func applyToBill(o orm.QueryExecutor, bill *models.ErpPartnerBill, take float64) (float64, error) {
if take <= 0 {
return 0, nil
}
delta := take
if bill.Amount < 0 {
delta = -take
}
newSettled := round2(bill.SettledAmount + delta)
newBalance := absf(round2(bill.Amount - newSettled))
status := int8(1)
if newBalance < 0.005 {
status = 2
}
_, err := o.QueryTable(new(models.ErpPartnerBill)).Filter("id", bill.ID).
Update(orm.Params{"settled_amount": newSettled, "status": status, "update_time": time.Now()})
if err != nil {
return 0, err
}
used := take
if absf(delta) > absf(take) { // 不超过剩余余额
used = absf(bill.Amount - bill.SettledAmount)
}
return used, nil
}
// autoOffsetBill 新账入账时与反向挂账互抵:
// 红字退货账优先抵扣同单位未结的正数往来账;正数账优先吸收红字余额(预收预付)。
// 返回该账本行插入时应携带的已核销额(正数账吸收红字的场景)。
func autoOffsetBill(o orm.QueryExecutor, bill *models.ErpPartnerBill) (float64, error) {
if absf(bill.Amount) < 0.005 {
return 0, nil
}
// 查找同单位、同向相反、未结清的账
var opps []models.ErpPartnerBill
qs := o.QueryTable(new(models.ErpPartnerBill)).
Filter("tenant_id", bill.TenantID).
Filter("bill_type", bill.BillType).
Filter("partner_id", bill.PartnerID).
Filter("partner_type", bill.PartnerType).
Filter("status__lt", 2)
_, _ = qs.OrderBy("bill_date", "id").All(&opps)
if bill.Amount > 0 {
// 正数账:先吸收红字(负额未结账)
consumed := 0.0
for i := range opps {
b := &opps[i]
if b.ID == 0 || b.Amount >= 0 {
continue
}
credit := absf(b.Amount - b.SettledAmount)
if credit < 0.005 {
continue
}
take := credit
if bill.Amount-consumed < take {
take = round2(bill.Amount - consumed)
}
if _, err := applyToBill(o, b, take); err != nil {
return 0, err
}
consumed += take
if bill.Amount-consumed < 0.005 {
break
}
}
return round2(consumed), nil
}
// 红字账(amount<0):插入后抵扣正数账
var pos []models.ErpPartnerBill
_, _ = o.QueryTable(new(models.ErpPartnerBill)).
Filter("tenant_id", bill.TenantID).
Filter("bill_type", bill.BillType).
Filter("partner_id", bill.PartnerID).
Filter("partner_type", bill.PartnerType).
Filter("status__lt", 2).
Filter("amount__gt", 0).
OrderBy("bill_date", "id").All(&pos)
need := absf(bill.Amount)
consumed := 0.0
for i := range pos {
if need < 0.005 {
break
}
b := &pos[i]
balance := b.Amount - b.SettledAmount
if balance < 0.005 {
continue
}
take := balance
if take > need {
take = need
}
used, err := applyToBill(o, b, take)
if err != nil {
return 0, err
}
consumed += used
need -= used
}
if consumed > 0 {
// 红字自身推进 settled(负向)
newSettled := round2(-consumed)
status := int8(1)
if absf(bill.Amount-newSettled) < 0.005 {
status = 2
}
if _, err := o.QueryTable(new(models.ErpPartnerBill)).Filter("id", bill.ID).
Update(orm.Params{"settled_amount": newSettled, "status": status}); err != nil {
return 0, err
}
}
return 0, nil
}
// OffsetAfterBillInsert 往来账插入后的互抵处理(正数账已在插入时携带 settled 的不再调用)。
func OffsetAfterBillInsert(o orm.QueryExecutor, billID uint64) {
var bill models.ErpPartnerBill
if err := o.QueryTable(new(models.ErpPartnerBill)).Filter("id", billID).One(&bill); err != nil {
return
}
if bill.Amount < 0 {
_, _ = autoOffsetBill(o, &bill)
}
}
// SettleItem 核销明细入参
type SettleItem struct {
PartnerBillID uint64 `json:"partner_bill_id"`
Amount float64 `json:"amount"`
}
// ConfirmFundBill 收付款单确认生效:
// - fund_type=1/2(收款/付款):按 settles 逐笔核销,未指定则自动 FIFO
// (到期日→账期日升序,仅正数余额账);核销余量作为预收/预付留在单上。
// - fund_type=3/4(其他收入/费用):仅登记资金流水。
// - 同时校验:草稿态、金额>0、核销对象为同租户同往来单位且类型匹配。
func ConfirmFundBill(fundID uint64, tenantID string, settles []SettleItem, operatorName string) error {
o := models.Orm
tx, err := o.Begin()
if err != nil {
return err
}
rollback := true
defer func() {
if rollback {
_ = tx.Rollback()
}
}()
var fund models.ErpFundBill
if err := tx.QueryTable(new(models.ErpFundBill)).
Filter("id", fundID).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).ForUpdate().One(&fund); err != nil {
return fmt.Errorf("收付款单不存在")
}
if fund.Status != 0 {
return fmt.Errorf("当前状态不可确认")
}
if fund.Amount <= 0 {
return fmt.Errorf("金额必须大于0")
}
remaining := round2(fund.Amount)
if fund.FundType == models.ErpFundReceive || fund.FundType == models.ErpFundPay {
billType := int8(models.ErpBillReceivable)
if fund.FundType == models.ErpFundPay {
billType = models.ErpBillPayable
}
if len(settles) == 0 {
settles, err = autoFifoSettle(tx, &fund, billType, remaining)
if err != nil {
return err
}
}
for _, s := range settles {
if s.Amount <= 0.004 {
continue
}
if s.Amount-remaining > 0.005 {
return fmt.Errorf("核销总额超出收付款金额")
}
var bill models.ErpPartnerBill
if err := tx.QueryTable(new(models.ErpPartnerBill)).
Filter("id", s.PartnerBillID).Filter("tenant_id", tenantID).ForUpdate().One(&bill); err != nil {
return fmt.Errorf("往来账不存在(id=%d)", s.PartnerBillID)
}
if bill.BillType != billType {
return fmt.Errorf("往来账「%s」类型与收付款方式不符", bill.SourceDocNo)
}
if fund.PartnerID != 0 && bill.PartnerID != fund.PartnerID {
return fmt.Errorf("往来账「%s」不属于 %s", bill.SourceDocNo, fund.PartnerName)
}
balance := bill.Amount - bill.SettledAmount
if balance < 0.005 {
return fmt.Errorf("往来账「%s」已结清", bill.SourceDocNo)
}
take := round2(s.Amount)
if take > balance {
take = round2(balance)
}
if _, err := applyToBill(tx, &bill, take); err != nil {
return err
}
if _, err := tx.Insert(&models.ErpFundSettle{
TenantID: tenantID, FundBillID: fund.ID, PartnerBillID: bill.ID,
PartnerBillNo: bill.SourceDocNo, Amount: take, CreateTime: time.Now(),
}); err != nil {
return fmt.Errorf("写入核销明细失败: %v", err)
}
remaining = round2(remaining - take)
}
}
now := time.Now()
_, err = tx.QueryTable(new(models.ErpFundBill)).Filter("id", fund.ID).
Update(orm.Params{
"status": 1,
"settled_amount": round2(fund.Amount - remaining),
"update_time": now,
})
if err != nil {
return fmt.Errorf("确认失败: %v", err)
}
if err := tx.Commit(); err != nil {
return err
}
rollback = false
return nil
}
// autoFifoSettle 未指定核销明细时,按到期日/账期日升序自动分摊。
func autoFifoSettle(tx orm.QueryExecutor, fund *models.ErpFundBill, billType int8, amount float64) ([]SettleItem, error) {
var bills []models.ErpPartnerBill
qs := tx.QueryTable(new(models.ErpPartnerBill)).
Filter("tenant_id", fund.TenantID).
Filter("bill_type", billType).
Filter("status__lt", 2).
Filter("amount__gt", 0)
if fund.PartnerID > 0 {
qs = qs.Filter("partner_id", fund.PartnerID)
}
_, err := qs.OrderBy("due_date", "bill_date", "id").Limit(200).All(&bills)
if err != nil && err != orm.ErrNoRows {
return nil, err
}
items := make([]SettleItem, 0, len(bills))
remaining := amount
for _, b := range bills {
if remaining < 0.005 {
break
}
balance := round2(b.Amount - b.SettledAmount)
if balance < 0.005 {
continue
}
take := balance
if take > remaining {
take = round2(remaining)
}
items = append(items, SettleItem{PartnerBillID: b.ID, Amount: take})
remaining = round2(remaining - take)
}
return items, nil
}
// VoidFundBill 作废收付款单:仅草稿或确认但未核销任何往来账的单据。
func VoidFundBill(fundID uint64, tenantID string) error {
var fund models.ErpFundBill
if err := models.Orm.QueryTable(new(models.ErpFundBill)).
Filter("id", fundID).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&fund); err != nil {
return fmt.Errorf("收付款单不存在")
}
if fund.Status == 4 {
return nil
}
if fund.Status == 1 && fund.SettledAmount > 0.005 {
return fmt.Errorf("已发生核销的收付款不可作废,请做反向收付款冲销")
}
_, err := models.Orm.QueryTable(new(models.ErpFundBill)).Filter("id", fundID).
Update(orm.Params{"status": 4, "update_time": time.Now()})
return err
}