497 lines
15 KiB
Go
497 lines
15 KiB
Go
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)
|
||
}
|