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

497 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}