package erp import ( "fmt" "strings" "time" "server/models" "github.com/beego/beego/v2/client/orm" ) // ApplyDocEffect 单据「审核生效」的统一效果引擎。调用方必须传入开启的事务 Ormer, // 并保证单据当前状态为 草稿/驳回/审批中。生效后: // // purchase_in → 入库流水 + 累计采购订单执行量 + 生成应付 // sales_out → 出库流水(加权成本) + 累计销售订单执行量 + 信用校验 + 生成应收 // *_return → 反向流水 + 红字往来账冲减 // other_in/out / transfer / stock_take → 仅库存变动 // *_order → 仅状态推进,不动库存 func ApplyDocEffect(o orm.QueryExecutor, doc *models.ErpDocument, items []models.ErpDocumentItem, operatorID, operatorName string) error { if models.ErpDocAffectsStock(doc.DocType) && doc.WarehouseID == 0 && doc.DocType != models.ErpDocStockTake { return fmt.Errorf("单据缺少仓库") } mv := func(it *models.ErpDocumentItem, dir int8, unitCost float64, whID uint64) error { _, err := ApplyMovement(o, &Movement{ TenantID: doc.TenantID, ProductID: it.ProductID, WarehouseID: whID, BatchNo: it.BatchNo, Direction: dir, Qty: it.Quantity, UnitCost: unitCost, DocType: doc.DocType, DocID: doc.ID, DocNo: doc.DocNo, DocItemID: it.ID, OperatorID: operatorID, OperatorName: operatorName, ProductionDate: it.ProductionDate, ExpireDate: it.ExpireDate, }) return err } switch doc.DocType { case models.ErpDocPurchaseOrder, models.ErpDocSalesOrder: // 订单不动库存、不产生往来账 case models.ErpDocPurchaseIn: for i := range items { it := &items[i] if err := checkOrderExec(o, doc.TenantID, it, models.ErpDocPurchaseOrder); err != nil { return err } wh := pickWh(doc, it) if err := mv(it, models.ErpStockIn, it.UnitPrice, wh); err != nil { return err } if err := bumpExecutedQty(o, it); err != nil { return err } } if err := createPartnerBill(o, doc, models.ErpBillPayable); err != nil { return err } case models.ErpDocSalesOut: if err := checkCustomerCredit(o, doc); err != nil { return err } for i := range items { it := &items[i] if err := checkOrderExec(o, doc.TenantID, it, models.ErpDocSalesOrder); err != nil { return err } wh := pickWh(doc, it) if err := mv(it, models.ErpStockOut, 0, wh); err != nil { return err } if err := bumpExecutedQty(o, it); err != nil { return err } } if err := createPartnerBill(o, doc, models.ErpBillReceivable); err != nil { return err } case models.ErpDocPurchaseReturn: if err := checkReturnLimit(o, doc, items); err != nil { return err } for i := range items { it := &items[i] wh := pickWh(doc, it) // 退货出库成本:优先取原入库流水成本,取不到则按当前加权成本 cost := 0.0 if c, ok := refLedgerCost(o, doc.TenantID, it.RefItemID, models.ErpStockIn); ok { cost = c } if err := mv(it, models.ErpStockOut, cost, wh); err != nil { return err } } if err := createPartnerBill(o, doc, models.ErpBillPayable); err != nil { return err } case models.ErpDocSalesReturn: if err := checkReturnLimit(o, doc, items); err != nil { return err } for i := range items { it := &items[i] wh := pickWh(doc, it) cost := 0.0 if c, ok := refLedgerCost(o, doc.TenantID, it.RefItemID, models.ErpStockOut); ok { cost = c // 按原出库成本回冲入库 } if err := mv(it, models.ErpStockIn, cost, wh); err != nil { return err } } if err := createPartnerBill(o, doc, models.ErpBillReceivable); err != nil { return err } case models.ErpDocOtherIn: for i := range items { it := &items[i] if err := mv(it, models.ErpStockIn, it.UnitPrice, pickWh(doc, it)); err != nil { return err } } case models.ErpDocOtherOut: for i := range items { it := &items[i] if err := mv(it, models.ErpStockOut, 0, pickWh(doc, it)); err != nil { return err } } case models.ErpDocTransfer: if doc.ToWarehouseID == 0 || doc.ToWarehouseID == doc.WarehouseID { return fmt.Errorf("调拨调入仓库无效") } for i := range items { it := &items[i] cost, err := ApplyMovement(o, &Movement{ TenantID: doc.TenantID, ProductID: it.ProductID, WarehouseID: doc.WarehouseID, BatchNo: it.BatchNo, Direction: models.ErpStockOut, Qty: it.Quantity, DocType: doc.DocType, DocID: doc.ID, DocNo: doc.DocNo, DocItemID: it.ID, OperatorID: operatorID, OperatorName: operatorName, ProductionDate: it.ProductionDate, ExpireDate: it.ExpireDate, }) if err != nil { return err } if _, err := ApplyMovement(o, &Movement{ TenantID: doc.TenantID, ProductID: it.ProductID, WarehouseID: doc.ToWarehouseID, BatchNo: it.BatchNo, Direction: models.ErpStockIn, Qty: it.Quantity, UnitCost: cost, DocType: doc.DocType, DocID: doc.ID, DocNo: doc.DocNo, DocItemID: it.ID, OperatorID: operatorID, OperatorName: operatorName, ProductionDate: it.ProductionDate, ExpireDate: it.ExpireDate, }); err != nil { return err } } case models.ErpDocStockTake: for i := range items { it := &items[i] wh := it.WarehouseID if wh == 0 { wh = doc.WarehouseID } diff := round4(it.Quantity - it.BookQty) if absf(diff) < 0.000001 { continue } if diff > 0 { if err := mv(it, models.ErpStockIn, it.UnitPrice, wh); err != nil { return err } } else { q := it.Quantity it.Quantity = -diff if err := mv(it, models.ErpStockOut, 0, wh); err != nil { it.Quantity = q return err } it.Quantity = q } } default: return fmt.Errorf("未知单据类型: %s", doc.DocType) } return nil } func pickWh(doc *models.ErpDocument, it *models.ErpDocumentItem) uint64 { if it.WarehouseID > 0 { return it.WarehouseID } return doc.WarehouseID } // checkOrderExec 入库/出库数量不得超过来源订单的未执行数量。 func checkOrderExec(o orm.QueryExecutor, tenantID string, it *models.ErpDocumentItem, orderType string) error { if it.RefItemID == 0 { return nil } var ref models.ErpDocumentItem if err := o.QueryTable(new(models.ErpDocumentItem)).Filter("id", it.RefItemID).One(&ref); err != nil { return fmt.Errorf("关联订单明细不存在(id=%d)", it.RefItemID) } var refDoc models.ErpDocument if err := o.QueryTable(new(models.ErpDocument)).Filter("id", ref.DocID).One(&refDoc); err != nil { return fmt.Errorf("关联订单不存在") } if refDoc.TenantID != tenantID || refDoc.DocType != orderType || refDoc.Status != models.ErpDocStatusApproved { return fmt.Errorf("关联订单「%s」状态不可执行", refDoc.DocNo) } remain := ref.Quantity - ref.ExecutedQty if it.Quantity-remain > 0.000001 { return fmt.Errorf("商品「%s」超出订单可执行数量(剩余 %.4f,本次 %.4f)", it.ProductName, remain, it.Quantity) } return nil } func bumpExecutedQty(o orm.QueryExecutor, it *models.ErpDocumentItem) error { if it.RefItemID == 0 { return nil } var ref models.ErpDocumentItem if err := o.QueryTable(new(models.ErpDocumentItem)).Filter("id", it.RefItemID).ForUpdate().One(&ref); err != nil { return nil } newQty := round4(ref.ExecutedQty + it.Quantity) _, err := o.QueryTable(new(models.ErpDocumentItem)).Filter("id", ref.ID). Update(orm.Params{"executed_qty": newQty}) return err } // checkReturnLimit 退货数量不得超过原单行数量 - 已退数量。 func checkReturnLimit(o orm.QueryExecutor, doc *models.ErpDocument, items []models.ErpDocumentItem) error { ids := make([]uint64, 0, len(items)) for _, it := range items { if it.RefItemID > 0 { ids = append(ids, it.RefItemID) } } if len(ids) == 0 { return nil } var refItems []models.ErpDocumentItem _, _ = o.QueryTable(new(models.ErpDocumentItem)).Filter("id__in", ids).All(&refItems) refMap := map[uint64]*models.ErpDocumentItem{} for i := range refItems { refMap[refItems[i].ID] = &refItems[i] } // 已退数量 = 其他已生效退货单中引用同一原行的数量 already := map[uint64]float64{} var sibs []models.ErpDocumentItem _, _ = o.QueryTable(new(models.ErpDocumentItem)). Filter("tenant_id", doc.TenantID). Filter("ref_item_id__in", ids). Exclude("doc_id", doc.ID).All(&sibs) if len(sibs) > 0 { docIDs := make([]uint64, 0, len(sibs)) for _, s := range sibs { docIDs = append(docIDs, s.DocID) } var docs []models.ErpDocument _, _ = o.QueryTable(new(models.ErpDocument)). Filter("id__in", docIDs). Filter("status", models.ErpDocStatusApproved). Filter("delete_time__isnull", true).All(&docs, "id") alive := map[uint64]bool{} for _, d := range docs { alive[d.ID] = true } for _, s := range sibs { if alive[s.DocID] { already[s.RefItemID] += s.Quantity } } } for _, it := range items { if it.RefItemID == 0 { continue } ref, ok := refMap[it.RefItemID] if !ok { return fmt.Errorf("退货关联的原单明细不存在(id=%d)", it.RefItemID) } remain := ref.Quantity - already[it.RefItemID] if it.Quantity-remain > 0.000001 { return fmt.Errorf("商品「%s」退货数量超出原单可退数量(剩余 %.4f,本次 %.4f)", it.ProductName, remain, it.Quantity) } } return nil } // refLedgerCost 取原单明细对应流水的单位成本(退货回冲口径)。 func refLedgerCost(o orm.QueryExecutor, tenantID string, refItemID uint64, direction int8) (float64, bool) { if refItemID == 0 { return 0, false } var led models.ErpStockLedger err := o.QueryTable(new(models.ErpStockLedger)). Filter("tenant_id", tenantID). Filter("doc_item_id", refItemID). Filter("direction", direction). OrderBy("-id").One(&led) if err != nil { return 0, false } return led.UnitCost, true } // checkCustomerCredit 销售出库信用额度校验:客户未收往来余额 + 本单金额 ≤ 信用额度。 func checkCustomerCredit(o orm.QueryExecutor, doc *models.ErpDocument) error { if doc.PartnerType != "customer" || doc.PartnerID == 0 { return nil } limit := GetCustomerCreditLimit(doc.PartnerID) if limit <= 0 { return nil } var openSum float64 var bills []models.ErpPartnerBill _, _ = o.QueryTable(new(models.ErpPartnerBill)). Filter("tenant_id", doc.TenantID). Filter("bill_type", models.ErpBillReceivable). Filter("partner_id", doc.PartnerID). Filter("status__lt", 2).All(&bills) for _, b := range bills { openSum += b.Amount - b.SettledAmount } if openSum+doc.ActualAmount-limit > 0.005 { return &CreditError{ PartnerName: doc.PartnerName, Limit: limit, Open: round2(openSum), This: doc.ActualAmount, } } return nil } type CreditError struct { PartnerName string Limit float64 Open float64 This float64 } func (e *CreditError) Error() string { return fmt.Sprintf("客户「%s」超出信用额度:额度 %.2f,未收 %.2f,本单 %.2f", e.PartnerName, e.Limit, e.Open, e.This) } // createPartnerBill 出库/入库/退货生效时生成往来账;退货生成红字(负数)账。 func createPartnerBill(o orm.QueryExecutor, doc *models.ErpDocument, billType int8) error { if doc.PartnerID == 0 || doc.ActualAmount == 0 { return nil } amount := round2(doc.ActualAmount) switch doc.DocType { case models.ErpDocPurchaseReturn, models.ErpDocSalesReturn: amount = -amount } var due *time.Time if days := GetPartnerPaymentDays(doc.PartnerType, doc.PartnerID); days > 0 { d := doc.BizDate.AddDate(0, 0, days) due = &d } bill := models.ErpPartnerBill{ TenantID: doc.TenantID, BillType: billType, PartnerType: doc.PartnerType, PartnerID: doc.PartnerID, PartnerName: doc.PartnerName, SourceDocID: doc.ID, SourceDocNo: doc.DocNo, BillDate: doc.BizDate, DueDate: due, Amount: amount, SettledAmount: 0, Status: 0, CreateTime: time.Now(), UpdateTime: time.Now(), } // 红字互抵:正数账先吸收同单位红字余额;红字账入账后抵扣未结正数账 if amount > 0 { consumed, err := autoOffsetBill(o, &bill) if err != nil { return err } bill.SettledAmount = consumed if consumed > 0.005 { bill.Status = 1 if absf(bill.Amount-consumed) < 0.005 { bill.Status = 2 } } } if _, err := o.Insert(&bill); err != nil { return fmt.Errorf("生成往来账失败: %v", err) } if amount < 0 { if _, err := autoOffsetBill(o, &bill); err != nil { return err } } return nil } // SyncErpDocResult 审批引擎回调:审批终态回写业务单据。 // bizType 形如 erp_sales_out;status 为实例终态。幂等:仅推进处于「审批中」的单据。 func SyncErpDocResult(bizType string, bizID uint64, tid uint64, status int8) { if !strings.HasPrefix(bizType, "erp_") || bizID == 0 || tid == 0 { return } docType := strings.TrimPrefix(bizType, "erp_") var doc models.ErpDocument if err := models.Orm.QueryTable(new(models.ErpDocument)). Filter("id", bizID).Filter("tenant_id", itoa(tid)). Filter("doc_type", docType). Filter("delete_time__isnull", true).One(&doc); err != nil { return } if doc.Status != models.ErpDocStatusPending { return // 已被处理,幂等跳过 } switch status { case models.ApprovalInstanceApproved: _ = approveWithTx(bizID, itoa(tid), "approval-engine", "审批引擎") case models.ApprovalInstanceRejected: models.Orm.QueryTable(new(models.ErpDocument)).Filter("id", bizID). Update(orm.Params{"status": models.ErpDocStatusRejected}) case models.ApprovalInstanceWithdrawn: models.Orm.QueryTable(new(models.ErpDocument)).Filter("id", bizID). Update(orm.Params{"status": models.ErpDocStatusDraft, "instance_id": 0}) } } // approveWithTx 独立开启事务并生效单据(审批回调 / 直接审核共用)。 func approveWithTx(docID uint64, tenantID, auditorID, auditorName string) error { o := models.Orm tx, err := o.Begin() if err != nil { return err } defer func() { _ = tx.Rollback() }() if err := ApproveDocumentTx(tx, docID, tenantID, auditorID, auditorName); err != nil { return err } return tx.Commit() } // ApproveDocumentTx 事务内:状态锁 + 效果执行 + 状态推进,供控制器与审批回调复用。 func ApproveDocumentTx(tx orm.QueryExecutor, docID uint64, tenantID, auditorID, auditorName string) error { var doc models.ErpDocument if err := tx.QueryTable(new(models.ErpDocument)). Filter("id", docID).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).ForUpdate().One(&doc); err != nil { return fmt.Errorf("单据不存在") } if doc.Status == models.ErpDocStatusApproved { return nil // 幂等 } if doc.Status != models.ErpDocStatusDraft && doc.Status != models.ErpDocStatusRejected && doc.Status != models.ErpDocStatusPending { return fmt.Errorf("当前状态不可审核") } var items []models.ErpDocumentItem if _, err := tx.QueryTable(new(models.ErpDocumentItem)). Filter("doc_id", docID).OrderBy("sort_order", "id").All(&items); err != nil { return err } if len(items) == 0 { return fmt.Errorf("单据没有明细行") } if err := ApplyDocEffect(tx, &doc, items, auditorID, auditorName); err != nil { return err } now := time.Now() _, err := tx.QueryTable(new(models.ErpDocument)).Filter("id", docID). Update(orm.Params{ "status": models.ErpDocStatusApproved, "auditor_id": auditorID, "auditor_name": auditorName, "audit_time": now, "update_time": now, }) return err } // ApproveDocument 非审批流路径的直接审核入口。 func ApproveDocument(docID uint64, tenantID, auditorID, auditorName string) error { return approveWithTx(docID, tenantID, auditorID, auditorName) }