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 }