更新
This commit is contained in:
1 parent
5718a00e31
commit
1850ac640e
226 files changed
+37772
-12071
No files matched your search
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/services/erp"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
@@ -1023,7 +1024,16 @@ func SyncBizResult(instID uint64, status int8) {
|
||||
return
|
||||
}
|
||||
// 无业务单据ID的实例(纯手工审批)不回写任何业务表
|
||||
if inst.BizID == 0 || !IsReimburseBizType(inst.BizType) {
|
||||
if inst.BizID == 0 {
|
||||
return
|
||||
}
|
||||
// 进销存单据:biz_type 形如 erp_purchase_in / erp_sales_out,终态回写单据状态,
|
||||
// 「已生效」单据的效果(库存流水/往来账)在回写时同事务执行。
|
||||
if strings.HasPrefix(inst.BizType, "erp_") {
|
||||
erp.SyncErpDocResult(inst.BizType, inst.BizID, bizTenantID(&inst), status)
|
||||
return
|
||||
}
|
||||
if !IsReimburseBizType(inst.BizType) {
|
||||
return
|
||||
}
|
||||
syncReimburseResult(&inst, status)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// InstanceStatusOf 读取实例当前状态(单据提交后用于判断流程是否已直接结束)。
|
||||
func InstanceStatusOf(instID uint64) int8 {
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", instID).One(&inst, "status"); err != nil {
|
||||
return models.ApprovalInstanceRunning
|
||||
}
|
||||
return inst.Status
|
||||
}
|
||||
|
||||
// InstanceBrief 单据详情页展示的审批进度摘要:当前节点、待办人、时间线。
|
||||
func InstanceBrief(instID uint64) map[string]interface{} {
|
||||
if instID == 0 {
|
||||
return nil
|
||||
}
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", instID).One(&inst); err != nil {
|
||||
return nil
|
||||
}
|
||||
tasks := make([]map[string]interface{}, 0)
|
||||
var rows []models.ApprovalTask
|
||||
_, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("instance_id", instID).OrderBy("sort", "id").All(&rows)
|
||||
for _, t := range rows {
|
||||
tasks = append(tasks, map[string]interface{}{
|
||||
"task_id": t.ID, "node_key": t.NodeKey, "node_name": t.NodeName,
|
||||
"approver_id": t.ApproverID, "approver_name": t.ApproverName,
|
||||
"status": t.Status, "status_label": approvalTaskLabel(t.Status),
|
||||
"comment": t.Comment, "action": t.Action,
|
||||
"create_time": t.CreateTime, "finish_time": t.FinishTime,
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"instance_id": inst.ID,
|
||||
"flow_id": inst.FlowID,
|
||||
"flow_name": flowNameOf(inst.FlowID),
|
||||
"biz_type": inst.BizType,
|
||||
"status": inst.Status,
|
||||
"status_label": approvalInstanceLabel(inst.Status),
|
||||
"current_node": inst.CurrentNode,
|
||||
"submit_name": inst.SubmitUserName,
|
||||
"submit_time": inst.CreateTime,
|
||||
"finish_time": inst.FinishTime,
|
||||
"tasks": tasks,
|
||||
}
|
||||
}
|
||||
|
||||
// flowNameOf 流程名称(流程被删除时回落为占位文案,不影响单据查看)
|
||||
func flowNameOf(flowID uint64) string {
|
||||
if flowID == 0 {
|
||||
return ""
|
||||
}
|
||||
var f models.TenantApprovalFlow
|
||||
if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
|
||||
Filter("id", flowID).One(&f, "flow_name"); err == nil {
|
||||
return f.FlowName
|
||||
}
|
||||
return "已删除的审批流"
|
||||
}
|
||||
|
||||
func approvalTaskLabel(status int8) string {
|
||||
switch status {
|
||||
case models.ApprovalTaskApproved:
|
||||
return "已通过"
|
||||
case models.ApprovalTaskRejected:
|
||||
return "已驳回"
|
||||
case models.ApprovalTaskSkipped:
|
||||
return "已跳过"
|
||||
case models.ApprovalTaskNotified:
|
||||
return "已通知"
|
||||
default:
|
||||
return "待审批"
|
||||
}
|
||||
}
|
||||
|
||||
func approvalInstanceLabel(status int8) string {
|
||||
switch status {
|
||||
case models.ApprovalInstanceApproved:
|
||||
return "已通过"
|
||||
case models.ApprovalInstanceRejected:
|
||||
return "已驳回"
|
||||
case models.ApprovalInstanceWithdrawn:
|
||||
return "已撤回"
|
||||
default:
|
||||
return "审批中"
|
||||
}
|
||||
}
|
||||
|
||||
// CancelInstance 业务单据作废时同步终结在途实例,避免审批中心留下无效待办。
|
||||
// 不做成「驳回」是因为驳回会让单据回到已驳回态;这里直接按撤回处理,
|
||||
// 调用方已负责把业务单据置为终态。
|
||||
func CancelInstance(instID uint64, reason string) {
|
||||
if instID == 0 {
|
||||
return
|
||||
}
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", instID).One(&inst, "id", "status"); err != nil {
|
||||
return
|
||||
}
|
||||
if inst.Status != models.ApprovalInstanceRunning {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
_, _ = models.Orm.QueryTable(new(models.ApprovalInstance)).Filter("id", instID).
|
||||
Update(orm.Params{"status": models.ApprovalInstanceWithdrawn, "finish_time": now, "update_time": now})
|
||||
_, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("instance_id", instID).Filter("status", models.ApprovalTaskPending).
|
||||
Update(orm.Params{"status": models.ApprovalTaskSkipped, "comment": reason, "update_time": now})
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package erp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// Settings 从租户「ERP基础设置」读取的运行时参数。
|
||||
type Settings struct {
|
||||
NegativeAllowed bool // negative_stock_allowed
|
||||
AutoDocNo bool // auto_generate_doc_no
|
||||
DefaultWarehouseID uint64 // default_warehouse(存的是名称,按名称匹配)
|
||||
SalesPriceType string // sales_price_type:sale/wholesale/member
|
||||
}
|
||||
|
||||
func GetSettings(tenantID string) *Settings {
|
||||
s := &Settings{AutoDocNo: true, SalesPriceType: "sale"}
|
||||
if v := models.GetErpNormalSettingByKey(tenantID, "negative_stock_allowed"); v != nil {
|
||||
s.NegativeAllowed = strings.TrimSpace(v.ConfigValue) == "1"
|
||||
}
|
||||
if v := models.GetErpNormalSettingByKey(tenantID, "auto_generate_doc_no"); v != nil {
|
||||
s.AutoDocNo = strings.TrimSpace(v.ConfigValue) != "0"
|
||||
}
|
||||
if v := models.GetErpNormalSettingByKey(tenantID, "sales_price_type"); v != nil {
|
||||
if t := strings.TrimSpace(v.ConfigValue); t != "" {
|
||||
s.SalesPriceType = t
|
||||
}
|
||||
}
|
||||
if v := models.GetErpNormalSettingByKey(tenantID, "default_warehouse"); v != nil {
|
||||
name := strings.TrimSpace(v.ConfigValue)
|
||||
if name != "" {
|
||||
var wh models.ErpWarehouse
|
||||
if err := models.Orm.QueryTable(new(models.ErpWarehouse)).
|
||||
Filter("tenant_id", tenantID).
|
||||
Filter("warehouse_name", name).
|
||||
Filter("delete_time__isnull", true).One(&wh); err == nil {
|
||||
s.DefaultWarehouseID = wh.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.DefaultWarehouseID == 0 {
|
||||
var wh models.ErpWarehouse
|
||||
if err := models.Orm.QueryTable(new(models.ErpWarehouse)).
|
||||
Filter("tenant_id", tenantID).
|
||||
Filter("is_default", 1).
|
||||
Filter("status", 1).
|
||||
Filter("delete_time__isnull", true).One(&wh); err == nil {
|
||||
s.DefaultWarehouseID = wh.ID
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// erpDocPrefixDefaults 各单据类型的取号前缀默认值(也是基础设置里可改的初值)。
|
||||
// 必须一型一前缀:共用前缀会让采购入库/其他入库、采购退货/销售退货同号段,
|
||||
// 单据号无法反推类型,日次序号也会互相穿插。
|
||||
var erpDocPrefixDefaults = map[string]string{
|
||||
models.ErpDocPurchaseOrder: "CGDD",
|
||||
models.ErpDocPurchaseIn: "CGRK",
|
||||
models.ErpDocPurchaseReturn: "CGTH",
|
||||
models.ErpDocSalesOrder: "XSDD",
|
||||
models.ErpDocSalesOut: "XSCK",
|
||||
models.ErpDocSalesReturn: "XSTH",
|
||||
models.ErpDocOtherIn: "QTRK",
|
||||
models.ErpDocOtherOut: "QTCK",
|
||||
models.ErpDocTransfer: "DB",
|
||||
models.ErpDocStockTake: "PD",
|
||||
}
|
||||
|
||||
// ErpDocPrefixKey 单据类型 → 基础设置键(document 分组,每型独立)
|
||||
func ErpDocPrefixKey(docType string) string {
|
||||
if _, ok := erpDocPrefixDefaults[docType]; !ok {
|
||||
return ""
|
||||
}
|
||||
return "doc_prefix_" + docType
|
||||
}
|
||||
|
||||
// ErpDocPrefixDefaults 供基础设置接口登记默认前缀
|
||||
func ErpDocPrefixDefaults() map[string]string {
|
||||
return erpDocPrefixDefaults
|
||||
}
|
||||
|
||||
// ErpDocPrefixField 基础设置「单据设置」里一条可编辑的单据前缀
|
||||
type ErpDocPrefixField struct {
|
||||
Key string
|
||||
Name string
|
||||
Default string
|
||||
}
|
||||
|
||||
// 表单展示顺序:按采购→销售→其他→调拨/盘点
|
||||
var erpDocPrefixLabels = []struct {
|
||||
docType string
|
||||
name string
|
||||
}{
|
||||
{models.ErpDocPurchaseOrder, "采购订单前缀"},
|
||||
{models.ErpDocPurchaseIn, "采购入库前缀"},
|
||||
{models.ErpDocPurchaseReturn, "采购退货前缀"},
|
||||
{models.ErpDocSalesOrder, "销售订单前缀"},
|
||||
{models.ErpDocSalesOut, "销售出库前缀"},
|
||||
{models.ErpDocSalesReturn, "销售退货前缀"},
|
||||
{models.ErpDocOtherIn, "其他入库前缀"},
|
||||
{models.ErpDocOtherOut, "其他出库前缀"},
|
||||
{models.ErpDocTransfer, "调拨单前缀"},
|
||||
{models.ErpDocStockTake, "盘点单前缀"},
|
||||
}
|
||||
|
||||
// ErpDocPrefixFields 返回全部单据前缀配置项,供基础设置接口注册默认值
|
||||
func ErpDocPrefixFields() []ErpDocPrefixField {
|
||||
out := make([]ErpDocPrefixField, 0, len(erpDocPrefixLabels))
|
||||
for _, l := range erpDocPrefixLabels {
|
||||
out = append(out, ErpDocPrefixField{
|
||||
Key: ErpDocPrefixKey(l.docType),
|
||||
Name: l.name,
|
||||
Default: erpDocPrefixDefaults[l.docType],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// docNoPrefix 按单据类型取号前缀(基础设置 document 分组),缺省用类型缩写。
|
||||
func docNoPrefix(tenantID, docType string) string {
|
||||
if key := ErpDocPrefixKey(docType); key != "" {
|
||||
if v := models.GetErpNormalSettingByKey(tenantID, key); v != nil {
|
||||
if p := strings.ToUpper(strings.TrimSpace(v.ConfigValue)); p != "" {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
if p, ok := erpDocPrefixDefaults[docType]; ok {
|
||||
return p
|
||||
}
|
||||
return "ERP"
|
||||
}
|
||||
|
||||
// GenerateDocNo 生成单据号:前缀+YYYYMMDD+当日3位序号(按租户+类型+日期计数)。
|
||||
// 依赖 yz_backend_erp_document 的 uk_tenant_docno 唯一索引兜底,调用方需处理重号重试。
|
||||
func GenerateDocNo(o orm.QueryExecutor, tenantID, docType string) (string, error) {
|
||||
prefix := docNoPrefix(tenantID, docType)
|
||||
day := time.Now().Format("20060102")
|
||||
var count int64
|
||||
var err error
|
||||
if o != nil {
|
||||
count, err = o.QueryTable(new(models.ErpDocument)).
|
||||
Filter("tenant_id", tenantID).Filter("doc_no__startswith", prefix+day).Count()
|
||||
} else {
|
||||
count, err = models.Orm.QueryTable(new(models.ErpDocument)).
|
||||
Filter("tenant_id", tenantID).Filter("doc_no__startswith", prefix+day).Count()
|
||||
}
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return "", err
|
||||
}
|
||||
seq := count + 1
|
||||
if seq > 999 {
|
||||
return "", fmt.Errorf("当日单据编号已超过999,请先归档")
|
||||
}
|
||||
return fmt.Sprintf("%s%s%03d", prefix, day, seq), nil
|
||||
}
|
||||
|
||||
// GenerateFundNo 收付款单号:前缀(SK/FK/QR/ZC)+日期+序号。
|
||||
func GenerateFundNo(tenantID string, fundType int8) (string, error) {
|
||||
prefixMap := map[int8]string{
|
||||
models.ErpFundReceive: "SK", models.ErpFundPay: "FK",
|
||||
models.ErpFundOtherIn: "QR", models.ErpFundExpense: "FY",
|
||||
}
|
||||
p, ok := prefixMap[fundType]
|
||||
if !ok {
|
||||
p = "ZJ"
|
||||
}
|
||||
day := time.Now().Format("20060102")
|
||||
count, err := models.Orm.QueryTable(new(models.ErpFundBill)).
|
||||
Filter("tenant_id", tenantID).Filter("fund_no__startswith", p+day).Count()
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s%s%03d", p, day, count+1), nil
|
||||
}
|
||||
|
||||
// GenerateProductCode 商品编号:SP+日期+当日序号(租户内唯一由 uk_tenant_code 兜底)。
|
||||
func GenerateProductCode(tenantID string) string {
|
||||
day := time.Now().Format("20060102")
|
||||
count, _ := models.Orm.QueryTable(new(models.ErpProduct)).
|
||||
Filter("tenant_id", tenantID).Filter("product_code__startswith", "SP"+day).Count()
|
||||
return fmt.Sprintf("SP%s%03d", day, count+1)
|
||||
}
|
||||
|
||||
// GetPartnerPaymentDays 取往来单位账期天数(应收到期日计算)。
|
||||
func GetPartnerPaymentDays(partnerType string, partnerID uint64) int {
|
||||
if partnerID == 0 {
|
||||
return 0
|
||||
}
|
||||
switch partnerType {
|
||||
case "customer":
|
||||
var row models.TenantCrmCustomer
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
|
||||
Filter("id", partnerID).One(&row); err == nil {
|
||||
return row.PaymentDays
|
||||
}
|
||||
case "supplier":
|
||||
var row models.TenantCrmSupplier
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmSupplier)).
|
||||
Filter("id", partnerID).One(&row); err == nil {
|
||||
return row.PaymentDays
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetCustomerCreditLimit 客户信用额度,0=不限。
|
||||
func GetCustomerCreditLimit(customerID uint64) float64 {
|
||||
var row models.TenantCrmCustomer
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
|
||||
Filter("id", customerID).One(&row); err != nil {
|
||||
return 0
|
||||
}
|
||||
return row.CreditLimit
|
||||
}
|
||||
|
||||
// 内部工具
|
||||
func itoa(v uint64) string { return strconv.FormatUint(v, 10) }
|
||||
@@ -0,0 +1,189 @@
|
||||
package erp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// Movement 一次库存变动的入参。库存结存只能由本引擎经流水驱动。
|
||||
type Movement struct {
|
||||
TenantID string
|
||||
ProductID uint64
|
||||
WarehouseID uint64
|
||||
BatchNo string
|
||||
Direction int8 // models.ErpStockIn / ErpStockOut
|
||||
Qty float64
|
||||
UnitCost float64 // 入库=进价/成本;出库传 0 表示按当前加权成本
|
||||
DocType string
|
||||
DocID uint64
|
||||
DocNo string
|
||||
DocItemID uint64
|
||||
OperatorID string
|
||||
OperatorName string
|
||||
Remark string
|
||||
// 批次效期随入库流水落地,出库/调拨沿用同批次快照
|
||||
ProductionDate *time.Time
|
||||
ExpireDate *time.Time
|
||||
}
|
||||
|
||||
// StockError 库存不足等业务错误(调用方可据 ErrInsufficientStock 区分文案)。
|
||||
type StockError struct {
|
||||
ProductName string
|
||||
Requested float64
|
||||
Available float64
|
||||
Insufficient bool
|
||||
}
|
||||
|
||||
func (e *StockError) Error() string {
|
||||
if e.Insufficient {
|
||||
return fmt.Sprintf("商品「%s」库存不足:可用 %.4f,本次需要 %.4f", e.ProductName, e.Available, e.Requested)
|
||||
}
|
||||
return fmt.Sprintf("商品「%s」库存异常", e.ProductName)
|
||||
}
|
||||
|
||||
// ApplyMovement 在事务内执行一条库存变动:
|
||||
// 1. 定位/创建结存行(tenant+product+warehouse+batch 唯一);
|
||||
// 2. 出库时校验可用量(负库存开关取自基础设置);
|
||||
// 3. 入库按移动加权平均重算单位成本;出库成本=出库时加权成本(记入流水,毛利口径);
|
||||
// 4. 写库存流水 + 更新结存。
|
||||
//
|
||||
// 返回出库时点的单位成本(销售成本/退货回冲依据)。
|
||||
func ApplyMovement(o orm.QueryExecutor, mv *Movement) (float64, error) {
|
||||
if mv.Qty <= 0 {
|
||||
return 0, fmt.Errorf("库存变动数量必须大于0")
|
||||
}
|
||||
if mv.WarehouseID == 0 || mv.ProductID == 0 {
|
||||
return 0, fmt.Errorf("库存变动缺少商品或仓库")
|
||||
}
|
||||
|
||||
settings := GetSettings(mv.TenantID)
|
||||
|
||||
var product models.ErpProduct
|
||||
if err := o.QueryTable(new(models.ErpProduct)).
|
||||
Filter("id", mv.ProductID).Filter("tenant_id", mv.TenantID).One(&product); err != nil {
|
||||
return 0, fmt.Errorf("商品不存在(id=%d)", mv.ProductID)
|
||||
}
|
||||
|
||||
var stock models.ErpStock
|
||||
err := o.QueryTable(new(models.ErpStock)).
|
||||
Filter("tenant_id", mv.TenantID).
|
||||
Filter("product_id", mv.ProductID).
|
||||
Filter("warehouse_id", mv.WarehouseID).
|
||||
Filter("batch_no", mv.BatchNo).
|
||||
ForUpdate().One(&stock)
|
||||
if err == orm.ErrNoRows {
|
||||
stock = models.ErpStock{
|
||||
TenantID: mv.TenantID, ProductID: mv.ProductID,
|
||||
WarehouseID: mv.WarehouseID, BatchNo: mv.BatchNo,
|
||||
Quantity: 0, CostPrice: product.PurchasePrice,
|
||||
UpdateTime: time.Now(),
|
||||
}
|
||||
id, ierr := o.Insert(&stock)
|
||||
if ierr != nil {
|
||||
return 0, fmt.Errorf("初始化库存结存失败: %v", ierr)
|
||||
}
|
||||
stock.ID = uint64(id)
|
||||
} else if err != nil {
|
||||
return 0, fmt.Errorf("查询库存失败: %v", err)
|
||||
}
|
||||
|
||||
before := stock.Quantity
|
||||
dir := mv.Direction
|
||||
unitCost := mv.UnitCost
|
||||
outCost := stock.CostPrice // 出库成本口径:当前加权成本
|
||||
|
||||
if dir == models.ErpStockOut {
|
||||
if !settings.NegativeAllowed && before-mv.Qty < -0.000001 {
|
||||
return 0, &StockError{
|
||||
ProductName: product.ProductName,
|
||||
Requested: mv.Qty,
|
||||
Available: before,
|
||||
Insufficient: true,
|
||||
}
|
||||
}
|
||||
after := before - mv.Qty
|
||||
if unitCost <= 0 {
|
||||
unitCost = outCost
|
||||
}
|
||||
if err := writeLedger(o, mv, dir, before, after, unitCost); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := o.QueryTable(new(models.ErpStock)).Filter("id", stock.ID).
|
||||
Update(orm.Params{"quantity": after, "update_time": time.Now()}); err != nil {
|
||||
return 0, fmt.Errorf("更新库存结存失败: %v", err)
|
||||
}
|
||||
return unitCost, nil
|
||||
}
|
||||
|
||||
// 入库:移动加权平均
|
||||
if unitCost <= 0 {
|
||||
unitCost = product.PurchasePrice
|
||||
}
|
||||
after := before + mv.Qty
|
||||
newCost := unitCost
|
||||
if after > 0.000001 {
|
||||
newCost = (before*stock.CostPrice + mv.Qty*unitCost) / after
|
||||
}
|
||||
if err := writeLedger(o, mv, dir, before, after, unitCost); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := o.QueryTable(new(models.ErpStock)).Filter("id", stock.ID).
|
||||
Update(orm.Params{"quantity": after, "cost_price": newCost, "update_time": time.Now()}); err != nil {
|
||||
return 0, fmt.Errorf("更新库存结存失败: %v", err)
|
||||
}
|
||||
return unitCost, nil
|
||||
}
|
||||
|
||||
func writeLedger(o orm.QueryExecutor, mv *Movement, dir int8, before, after, unitCost float64) error {
|
||||
now := time.Now()
|
||||
ledger := models.ErpStockLedger{
|
||||
TenantID: mv.TenantID, ProductID: mv.ProductID, WarehouseID: mv.WarehouseID,
|
||||
BatchNo: mv.BatchNo, Direction: dir, Quantity: mv.Qty,
|
||||
BeforeQty: before, AfterQty: after,
|
||||
UnitCost: unitCost, TotalCost: round2(unitCost * mv.Qty),
|
||||
DocType: mv.DocType, DocID: mv.DocID, DocNo: mv.DocNo, DocItemID: mv.DocItemID,
|
||||
ProductionDate: mv.ProductionDate, ExpireDate: mv.ExpireDate,
|
||||
OperatorID: mv.OperatorID, OperatorName: mv.OperatorName,
|
||||
Remark: mv.Remark, CreateTime: now,
|
||||
}
|
||||
if _, err := o.Insert(&ledger); err != nil {
|
||||
return fmt.Errorf("写入库存流水失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefOutboundCost 取某条出库流水记录的出库成本(销售退货回冲入库成本用)。
|
||||
func RefOutboundCost(o orm.QueryExecutor, tenantID string, docItemID uint64) float64 {
|
||||
if docItemID == 0 {
|
||||
return 0
|
||||
}
|
||||
var led models.ErpStockLedger
|
||||
if err := o.QueryTable(new(models.ErpStockLedger)).
|
||||
Filter("tenant_id", tenantID).
|
||||
Filter("doc_item_id", docItemID).
|
||||
Filter("direction", models.ErpStockOut).
|
||||
OrderBy("-id").One(&led); err != nil {
|
||||
return 0
|
||||
}
|
||||
return led.UnitCost
|
||||
}
|
||||
|
||||
func round2(v float64) float64 {
|
||||
return math.Round(v*100) / 100
|
||||
}
|
||||
|
||||
func round4(v float64) float64 {
|
||||
return math.Round(v*10000) / 10000
|
||||
}
|
||||
|
||||
func absf(v float64) float64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
+59
-17
@@ -2,13 +2,55 @@ package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// ParseMenuRights 将角色 rights 解析为菜单 ID 集合(兼容 JSON 数组 / 逗号分隔)。
|
||||
// 数字数组是角色保存接口 json.Marshal([]uint64) 写回的规范格式,必须支持;
|
||||
// 若只按 []string 反序列化会整体失败并退化成逗号切分,
|
||||
// 首尾元素会带上 [ / ] 而解析不出数字,导致第一个和最后一个菜单被静默丢弃。
|
||||
func ParseMenuRights(raw string) map[uint64]bool {
|
||||
set := map[uint64]bool{}
|
||||
var arr []interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &arr); err == nil {
|
||||
for _, v := range arr {
|
||||
if n := menuIDOf(v); n > 0 {
|
||||
set[n] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
for _, s := range strings.Split(raw, ",") {
|
||||
if n, e := strconv.ParseUint(strings.TrimSpace(s), 10, 64); e == nil {
|
||||
set[n] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// menuIDOf 把 JSON 元素(数字或字符串)转成菜单 ID,无效时返回 0
|
||||
func menuIDOf(v interface{}) uint64 {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
if t > 0 {
|
||||
return uint64(t)
|
||||
}
|
||||
case string:
|
||||
if n, e := strconv.ParseUint(strings.TrimSpace(t), 10, 64); e == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// CheckUserPermission 校验用户是否拥有指定权限标识。
|
||||
// 兼容 rights 为 JSON 数组 / 逗号分隔字符串;解析失败时默认放行,避免历史数据阻断请求。
|
||||
// rights 里存的是菜单 ID 而不是权限串,必须经 yz_system_menu.permission 换算后再比对;
|
||||
// 直接拿 rights 元素和权限串比永远不相等,会把本来有权限的角色全部判成无权访问。
|
||||
// rights 为空(nil / 空串)视为全权限,与菜单过滤 filterMenusByRights 口径一致;
|
||||
// rights 有内容却解析不出任何菜单 ID(历史脏数据)按无权限处理,不放行。
|
||||
func CheckUserPermission(userID int, permission string) (bool, error) {
|
||||
if permission == "" || userID <= 0 {
|
||||
return true, nil
|
||||
@@ -26,25 +68,25 @@ func CheckUserPermission(userID int, permission string) (bool, error) {
|
||||
if role.Rights == nil || strings.TrimSpace(*role.Rights) == "" {
|
||||
return true, nil
|
||||
}
|
||||
rightsRaw := strings.TrimSpace(*role.Rights)
|
||||
|
||||
// 1) JSON 数组格式
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(rightsRaw), &arr); err == nil {
|
||||
for _, p := range arr {
|
||||
if strings.TrimSpace(p) == permission {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
menuIDs := ParseMenuRights(*role.Rights)
|
||||
if len(menuIDs) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 2) 逗号分隔字符串
|
||||
for _, p := range strings.Split(rightsRaw, ",") {
|
||||
if strings.TrimSpace(p) == permission {
|
||||
return true, nil
|
||||
}
|
||||
idList := make([]uint64, 0, len(menuIDs))
|
||||
for id := range menuIDs {
|
||||
idList = append(idList, id)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
// 只有启用且未删除的菜单才能授予权限,禁用的菜单不应继续放行其权限标识
|
||||
count, err := models.Orm.QueryTable(new(models.SystemMenu)).
|
||||
Filter("id__in", idList).
|
||||
Filter("permission", permission).
|
||||
Filter("status", 1).
|
||||
Filter("delete_time__isnull", true).
|
||||
Count()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
// 这些用例只覆盖权限模块中不依赖数据库的纯函数:角色 rights 解析。
|
||||
// CheckUserPermission 需要查用户/角色/菜单表,属于集成测试范围,此处不覆盖。
|
||||
|
||||
func TestParseMenuRights(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want []uint64
|
||||
}{
|
||||
// 规范格式:角色保存接口 json.Marshal([]uint64) 写回的数字数组。
|
||||
// 曾经只按 []string 反序列化,整体失败后退化成逗号切分,
|
||||
// 首尾元素带上 [ / ] 解析不出数字,第一个和最后一个菜单被静默丢弃。
|
||||
{"数字数组", "[15,36,37,263]", []uint64{15, 36, 37, 263}},
|
||||
{"单元素数组", "[7]", []uint64{7}},
|
||||
{"字符串数组", `["15","36"]`, []uint64{15, 36}},
|
||||
{"逗号分隔", "15, 36 ,263", []uint64{15, 36, 263}},
|
||||
{"空数组", "[]", nil},
|
||||
{"空串", "", nil},
|
||||
{"脏数据", "abc", nil},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := ParseMenuRights(c.raw)
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("rights=%q 期望解析出 %d 个菜单 ID,实际 %d 个:%v", c.raw, len(c.want), len(got), got)
|
||||
}
|
||||
for _, id := range c.want {
|
||||
if !got[id] {
|
||||
t.Fatalf("rights=%q 期望包含菜单 ID %d,实际结果 %v", c.raw, id, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMenuRightsKeepsBoundaryIDs(t *testing.T) {
|
||||
// 单独锁住边界元素:丢首尾是线上真实出现过的故障表现(菜单页渲染成 404)
|
||||
got := ParseMenuRights("[15,36,37,38,263]")
|
||||
for _, id := range []uint64{15, 263} {
|
||||
if !got[id] {
|
||||
t.Fatalf("首尾菜单 ID %d 丢失,解析结果 %v", id, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user