package controllers import ( "fmt" "strings" "time" "server/models" "server/pkg/jwtutil" approvalEngine "server/services/approval" erp "server/services/erp" "github.com/beego/beego/v2/client/orm" ) // ERP 单据核心逻辑(两端共用)。 // // 按项目的接口隔离约定,/backend/erp/document* 与 /api/erp/document* 必须各有独立的 // handler 与路由,但单据的校验、明细装配、落库事务与审批提交口径只能有一份实现: // 这里就是那一份。两端 handler 只负责「解析自己的令牌 → 组装 erpActor → 调核心 → // 按各自的响应信封输出」,业务规则不在两端各写一遍,避免移动端口径慢慢跑偏。 // // 返回约定沿用 reimburse*Core:最后一个 string 为空即成功,非空时给出处置状态码与文案, // 由调用方原样透出,保证抽取前后接口行为完全一致。 // erpActor ERP 操作人:由各自端的 JWT 解析而来,核心层只认这个结构,不认 beego 上下文 type erpActor struct { Tid string // 租户ID字符串口径(ERP 表的 tenant_id 是 VARCHAR) TidNum uint64 UID uint64 Name string } func erpNewActor(claims *jwtutil.Claims) erpActor { return erpActor{ Tid: fmt.Sprintf("%d", claims.TenantId), TidNum: uint64(claims.TenantId), UID: uint64(claims.UserID), Name: resolveUserName(claims), } } // erpDocFilter 单据列表筛选条件(与 Web 端查询参数一一对应) type erpDocFilter struct { DocType string DocTypes string // 逗号分隔的多类型 Status string Keyword string PartnerID uint64 WarehouseID uint64 SourceDocID uint64 DateFrom string DateTo string } // erpDocListCore 单据分页查询 func erpDocListCore(a erpActor, f erpDocFilter, page, pageSize int) ([]models.ErpDocument, int64, int, string) { if f.DocType != "" && !erpDocTypes[f.DocType] { return nil, 0, 400, "未知的单据类型" } qs := models.Orm.QueryTable(new(models.ErpDocument)). Filter("tenant_id", a.Tid).Filter("delete_time__isnull", true) if f.DocType != "" { qs = qs.Filter("doc_type", f.DocType) } if types := strings.TrimSpace(f.DocTypes); types != "" { list := make([]string, 0, 4) for _, t := range strings.Split(types, ",") { if erpDocTypes[strings.TrimSpace(t)] { list = append(list, strings.TrimSpace(t)) } } if len(list) > 0 { qs = qs.Filter("doc_type__in", list) } } if st := strings.TrimSpace(f.Status); st != "" { qs = qs.Filter("status", st) } if kw := strings.TrimSpace(f.Keyword); kw != "" { cond := orm.NewCondition(). Or("doc_no__contains", kw). Or("partner_name__contains", kw). Or("remark__contains", kw) qs = qs.SetCond(qs.GetCond().AndCond(cond)) } if f.PartnerID > 0 { qs = qs.Filter("partner_id", f.PartnerID) } if f.WarehouseID > 0 { qs = qs.Filter("warehouse_id", f.WarehouseID) } if f.SourceDocID > 0 { qs = qs.Filter("source_doc_id", f.SourceDocID) } for _, d := range []struct { raw string cond string }{{f.DateFrom, "biz_date__gte"}, {f.DateTo, "biz_date__lte"}} { if strings.TrimSpace(d.raw) == "" { continue } if t, e := time.ParseInLocation("2006-01-02", strings.TrimSpace(d.raw), time.Local); e == nil { qs = qs.Filter(d.cond, t) } } total, _ := qs.Count() var list []models.ErpDocument if _, err := qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&list); err != nil { return nil, 0, 500, "查询失败: " + err.Error() } erpFillDocNames(list) for i := range list { list[i].StatusLabel = models.ErpDocStatusLabel(list[i].Status) } return list, total, 200, "" } // erpDocDetailCore 单据详情(含明细、库存快照与审批实例摘要) func erpDocDetailCore(a erpActor, id uint64) (*models.ErpDocument, int, string) { if id == 0 { return nil, 400, "无效的ID" } doc, err := erpLoadDoc(id, a.Tid) if err != nil { return nil, 404, "单据未找到" } doc.Items = erpDocItems(models.Orm, id) erpFillItemStock(a.Tid, doc.Items, doc.WarehouseID) single := []models.ErpDocument{*doc} erpFillDocNames(single) single[0].StatusLabel = models.ErpDocStatusLabel(single[0].Status) if doc.InstanceID > 0 { single[0].Instance = approvalEngine.InstanceBrief(doc.InstanceID) } return &single[0], 200, "" } // erpValidateDocHead 校验表头:仓库归属、往来单位匹配、调拨/盘点的仓库约束 func erpValidateDocHead(tid string, p *erpDocPayload) string { needWh := models.ErpDocAffectsStock(p.DocType) if needWh && p.WarehouseID == 0 && p.DocType != models.ErpDocStockTake { return "请选择仓库" } if p.WarehouseID > 0 && !erpWarehouseOwned(tid, p.WarehouseID) { return "仓库不存在或不属于当前租户" } if p.DocType == models.ErpDocTransfer { if p.ToWarehouseID == 0 { return "请选择调入仓库" } if p.ToWarehouseID == p.WarehouseID { return "调入仓库不能与调出仓库相同" } if !erpWarehouseOwned(tid, p.ToWarehouseID) { return "调入仓库不存在或不属于当前租户" } } if pt := docTypePartner(p.DocType); pt != "" { if p.PartnerID == 0 { return "请选择" + erpPartnerLabel(pt) } } if p.SourceDocID > 0 { src, e := erpLoadDoc(p.SourceDocID, tid) if e != nil { return "来源单据不存在" } wants := docTypeSources(p.DocType) if len(wants) > 0 { allowed := false labels := make([]string, 0, len(wants)) for _, w := range wants { labels = append(labels, docTypeLabelOf(w)) if src.DocType == w { allowed = true } } if !allowed { return "来源单据类型应为" + strings.Join(labels, "或") } if src.Status != models.ErpDocStatusApproved { return "来源单据「" + src.DocNo + "」尚未生效,不能引用" } } } if p.DiscountAmount < 0 { return "优惠金额不能为负" } return "" } // refItemIDs 收集明细行引用的来源单据行ID func refItemIDs(raw []erpDocItemPayload) []uint64 { out := make([]uint64, 0, len(raw)) for _, r := range raw { if r.RefItemID > 0 { out = append(out, r.RefItemID) } } return out } // erpBuildItems 明细行规范化:补商品快照/单价(价格体系)、算金额、校验限价与批次 func erpBuildItems(tid string, doc *models.ErpDocument, raw []erpDocItemPayload) ([]models.ErpDocumentItem, string) { ids := make([]uint64, 0, len(raw)) for _, r := range raw { if r.ProductID > 0 { ids = append(ids, r.ProductID) } } pmap := erpProductMap(tid, ids) // 来源订单明细行归属:草稿覆盖保存会重建明细(行 ID 变化),前端若仍拿着旧行 ID, // 不在开单时就拦下,脏引用要拖到审核生效才报错,移动端更难定位。 refOwner := map[uint64]uint64{} if refIDs := refItemIDs(raw); len(refIDs) > 0 { var refs []models.ErpDocumentItem _, _ = models.Orm.QueryTable(new(models.ErpDocumentItem)). Filter("id__in", refIDs).Filter("tenant_id", tid).All(&refs, "id", "doc_id") for _, r := range refs { refOwner[r.ID] = r.DocID } } settings := erp.GetSettings(tid) out := make([]models.ErpDocumentItem, 0, len(raw)) for i, r := range raw { p, okm := pmap[r.ProductID] if !okm { return nil, fmt.Sprintf("第%d行商品不存在", i+1) } if p.IsDraft == 1 || p.Status != 1 { return nil, fmt.Sprintf("商品「%s」未启用,不能开单", p.ProductName) } if r.Quantity <= 0 { return nil, fmt.Sprintf("商品「%s」数量必须大于0", p.ProductName) } unitPrice := r.UnitPrice if unitPrice <= 0 { unitPrice = erpPickPrice(p, doc.DocType, settings.SalesPriceType) } // 限价控制:低于最低售价直接拦截(除管理员放行的场景,这里不做例外) if strings.HasPrefix(doc.DocType, "sales") && p.MinPrice > 0 && unitPrice < p.MinPrice-0.005 { return nil, fmt.Sprintf("商品「%s」售价 %.2f 低于最低售价 %.2f", p.ProductName, unitPrice, p.MinPrice) } if doc.DocType == models.ErpDocPurchaseOrder || doc.DocType == models.ErpDocPurchaseIn { if p.EnableBatch == 1 && strings.TrimSpace(r.BatchNo) == "" && doc.DocType == models.ErpDocPurchaseIn { return nil, fmt.Sprintf("商品「%s」启用批次管理,请填写批次号", p.ProductName) } } unit := strings.TrimSpace(r.Unit) if unit == "" { unit = p.BaseUnit } amount := r.Amount if amount <= 0 { amount = round2f(r.Quantity * unitPrice) } wh := r.WarehouseID if wh > 0 && !erpWarehouseOwned(tid, wh) { return nil, fmt.Sprintf("第%d行仓库无效", i+1) } if r.RefItemID > 0 && refOwner[r.RefItemID] != doc.SourceDocID { return nil, fmt.Sprintf("第%d行关联的来源单据明细已失效,请重新选择来源单据", i+1) } it := models.ErpDocumentItem{ ProductID: p.ID, ProductName: p.ProductName, Spec: p.Spec, Unit: unit, WarehouseID: wh, BatchNo: strings.TrimSpace(r.BatchNo), Quantity: round4f(r.Quantity), BookQty: round4f(r.BookQty), RefItemID: r.RefItemID, UnitPrice: unitPrice, Amount: round2f(amount), Remark: r.Remark, SortOrder: r.SortOrder, } if it.SortOrder == 0 { it.SortOrder = i + 1 } if r.ProductionDate != "" { if t, e := time.ParseInLocation("2006-01-02", r.ProductionDate, time.Local); e == nil { it.ProductionDate = &t if p.ShelfLifeDays > 0 { ed := t.AddDate(0, 0, p.ShelfLifeDays) it.ExpireDate = &ed } } } if r.ExpireDate != "" { if t, e := time.ParseInLocation("2006-01-02", r.ExpireDate, time.Local); e == nil { it.ExpireDate = &t } } // 盘点行未带账面数时按当前结存补齐 if doc.DocType == models.ErpDocStockTake && it.BookQty == 0 && it.WarehouseID == 0 { it.BookQty = erpStockQty(tid, p.ID, doc.WarehouseID, it.BatchNo) if it.UnitPrice == 0 { it.UnitPrice = erpStockCost(tid, p.ID, doc.WarehouseID, it.BatchNo) } } if msg := erpLinePreCheck(tid, doc, settings, &it); msg != "" { return nil, msg } out = append(out, it) } return out, "" } // erpDocSaveCore 新建或覆盖保存草稿(payload 带 id 即为编辑)。已生效/审批中的单据不允许改动。 func erpDocSaveCore(a erpActor, p erpDocPayload) (uint64, string, int, string) { tid := a.Tid if !erpDocTypes[p.DocType] { return 0, "", 400, "未知的单据类型" } if len(p.Items) == 0 { return 0, "", 400, "请至少添加一行明细" } if errMsg := erpValidateDocHead(tid, &p); errMsg != "" { return 0, "", 400, errMsg } doc := &models.ErpDocument{TenantID: tid} if p.ID > 0 { exist, e := erpLoadDoc(p.ID, tid) if e != nil { return 0, "", 404, "单据未找到" } if exist.Status == models.ErpDocStatusApproved || exist.Status == models.ErpDocStatusPending { return 0, "", 400, "审批中或已生效的单据不可编辑,请先撤回或作废" } if exist.Status == models.ErpDocStatusCancelled { return 0, "", 400, "已作废单据不可编辑" } doc = exist } if doc.DocNo == "" { no, e := erp.GenerateDocNo(nil, tid, p.DocType) if e != nil { return 0, "", 500, e.Error() } doc.DocNo = no } bizDate := time.Now() if p.BizDate != "" { if t, e := time.ParseInLocation("2006-01-02", p.BizDate, time.Local); e == nil { bizDate = t } } doc.DocType = p.DocType doc.BizDate = bizDate doc.WarehouseID = p.WarehouseID doc.ToWarehouseID = p.ToWarehouseID doc.SourceDocID = p.SourceDocID doc.DiscountAmount = p.DiscountAmount doc.Remark = p.Remark doc.Status = models.ErpDocStatusDraft if pt := docTypePartner(p.DocType); pt != "" { doc.PartnerType = pt doc.PartnerID = p.PartnerID doc.PartnerName = erpPartnerName(pt, p.PartnerID) if doc.PartnerName == "" { return 0, "", 400, erpPartnerLabel(pt) + "未选择或不存在" } } else { doc.PartnerType, doc.PartnerID, doc.PartnerName = "", 0, "" } if doc.CreateUserID == "" { doc.CreateUserID = fmt.Sprintf("%d", a.UID) doc.CreateUserName = a.Name } items, errMsg := erpBuildItems(tid, doc, p.Items) if errMsg != "" { return 0, "", 400, errMsg } doc.TotalQty = 0 doc.TotalAmount = 0 for _, it := range items { doc.TotalQty += it.Quantity doc.TotalAmount += it.Amount } doc.TotalQty = round4f(doc.TotalQty) doc.TotalAmount = round2f(doc.TotalAmount) doc.ActualAmount = round2f(doc.TotalAmount - doc.DiscountAmount) if doc.ActualAmount < 0 { return 0, "", 400, "优惠金额不能大于单据金额" } o := models.Orm tx, err := o.Begin() if err != nil { return 0, "", 500, "保存失败" } rollback := true defer func() { if rollback { _ = tx.Rollback() } }() now := time.Now() doc.UpdateTime = now if doc.ID == 0 { doc.CreateTime = now id, e := tx.Insert(doc) if e != nil { return 0, "", 500, "保存失败: " + e.Error() } doc.ID = uint64(id) } else { if _, e := tx.QueryTable(new(models.ErpDocument)).Filter("id", doc.ID). Update(orm.Params{ "doc_type": doc.DocType, "biz_date": doc.BizDate, "warehouse_id": doc.WarehouseID, "to_warehouse_id": doc.ToWarehouseID, "partner_type": doc.PartnerType, "partner_id": doc.PartnerID, "partner_name": doc.PartnerName, "source_doc_id": doc.SourceDocID, "total_qty": doc.TotalQty, "total_amount": doc.TotalAmount, "discount_amount": doc.DiscountAmount, "actual_amount": doc.ActualAmount, "status": models.ErpDocStatusDraft, "instance_id": 0, "remark": doc.Remark, "update_time": now, }); e != nil { return 0, "", 500, "保存失败: " + e.Error() } if _, e := tx.QueryTable(new(models.ErpDocumentItem)).Filter("doc_id", doc.ID).Delete(); e != nil { return 0, "", 500, "保存失败: " + e.Error() } } for i := range items { items[i].DocID = doc.ID items[i].TenantID = tid items[i].ID = 0 if _, e := tx.Insert(&items[i]); e != nil { return 0, "", 500, "保存明细失败: " + e.Error() } } if err := tx.Commit(); err != nil { return 0, "", 500, "保存失败: " + err.Error() } rollback = false return doc.ID, doc.DocNo, 200, "" } // erpDocDeleteCore 删除单据(软删主表、硬删明细),仅草稿/驳回可删 func erpDocDeleteCore(a erpActor, id uint64) (int, string) { doc, errMsg := erpLoadEditable(id, a.Tid, []int8{models.ErpDocStatusDraft, models.ErpDocStatusRejected}) if errMsg != "" { return 400, errMsg } now := time.Now() tx, err := models.Orm.Begin() if err != nil { return 500, "删除失败" } rollback := true defer func() { if rollback { _ = tx.Rollback() } }() if _, err = tx.QueryTable(new(models.ErpDocument)).Filter("id", doc.ID). Update(orm.Params{"delete_time": now, "update_time": now}); err != nil { return 500, "删除失败: " + err.Error() } // 明细随主表硬删:主表只软删,历史仍可追溯;明细无独立查询价值 if _, err = tx.QueryTable(new(models.ErpDocumentItem)).Filter("doc_id", doc.ID).Delete(); err != nil { return 500, "删除失败: " + err.Error() } if err = tx.Commit(); err != nil { return 500, "删除失败" } rollback = false return 200, "" } // erpDocCancelCore 作废:草稿/驳回/审批中均可作废;已生效单据不做反向单据,直接拒绝, // 保证「已生效=流水已入账」这一不可篡改前提。 func erpDocCancelCore(a erpActor, id uint64) (int, string) { doc, errMsg := erpLoadEditable(id, a.Tid, []int8{ models.ErpDocStatusDraft, models.ErpDocStatusRejected, models.ErpDocStatusPending, }) if errMsg != "" { return 400, errMsg } now := time.Now() params := orm.Params{"status": models.ErpDocStatusCancelled, "instance_id": 0, "update_time": now} if _, err := models.Orm.QueryTable(new(models.ErpDocument)).Filter("id", doc.ID).Update(params); err != nil { return 500, "作废失败: " + err.Error() } if doc.InstanceID > 0 { approvalEngine.CancelInstance(doc.InstanceID, "单据已作废") } return 200, "" } // erpDocApproveCore 直接审核生效(未走审批流时使用)。库存/往来账效果在同一事务内落地。 func erpDocApproveCore(a erpActor, id uint64) (int, string) { if id == 0 { return 400, "无效的ID" } if err := erp.ApproveDocument(id, a.Tid, fmt.Sprintf("%d", a.UID), a.Name); err != nil { return 400, err.Error() } return 200, "" } // erpDocSubmitCore 提交审批:flowID 为 0 时按单据类型自动选流。 // 流程未配置人工节点时引擎会直接结束实例,此时同步单据终态。 func erpDocSubmitCore(a erpActor, id, flowID uint64) (uint64, int, string) { if id == 0 { return 0, 400, "无效的ID" } doc, errMsg := erpLoadEditable(id, a.Tid, []int8{models.ErpDocStatusDraft, models.ErpDocStatusRejected}) if errMsg != "" { return 0, 400, errMsg } if len(erpDocItems(models.Orm, doc.ID)) == 0 { return 0, 400, "单据没有明细行,无法提交" } bizType := models.ErpDocApprovalBizType(doc.DocType) deptID := erpUserOrgID(a.TidNum, a.UID) if flowID == 0 { flowID = erpPickFlowID(a.TidNum, deptID, bizType) } if flowID == 0 { return 0, 400, "该单据类型尚未配置审批流程,可直接审核或到「审批流配置」中新增" } instID, err := approvalEngine.Submit(approvalEngine.SubmitInput{ Scope: models.ApprovalScopeTenant, Tid: a.TidNum, FlowID: flowID, BizType: bizType, BizID: doc.ID, BizTitle: fmt.Sprintf("[%s] %s %s", docTypeLabelOf(doc.DocType), doc.DocNo, doc.PartnerName), SubmitUID: a.UID, SubmitName: a.Name, DeptID: deptID, FormData: map[string]interface{}{ "doc_type": doc.DocType, "doc_no": doc.DocNo, "total_amount": doc.TotalAmount, "actual_amount": doc.ActualAmount, "total_qty": doc.TotalQty, "warehouse_id": doc.WarehouseID, "partner_id": doc.PartnerID, "partner_type": doc.PartnerType, }, }) if err != nil { return 0, 400, err.Error() } now := time.Now() if _, err := models.Orm.QueryTable(new(models.ErpDocument)).Filter("id", doc.ID). Update(orm.Params{"status": models.ErpDocStatusPending, "instance_id": instID, "update_time": now}); err != nil { return 0, 500, "提交失败: " + err.Error() } approvalEngine.SyncBizResult(instID, approvalEngine.InstanceStatusOf(instID)) return instID, 200, "" } type erpFlowRow struct { FlowID uint64 `json:"flow_id"` FlowCode string `json:"flow_code"` FlowName string `json:"flow_name"` BizType string `json:"biz_type"` } // erpDocFlowsCore 该单据类型可用的审批流(先精确匹配 erp_xxx,再回落到模块级 erp) func erpDocFlowsCore(a erpActor, docType string) (string, []erpFlowRow, int, string) { if !erpDocTypes[docType] { return "", nil, 400, "未知的单据类型" } bizType := models.ErpDocApprovalBizType(docType) deptID := erpUserOrgID(a.TidNum, a.UID) flows, err := approvalEngine.ListAvailableFlows(models.ApprovalScopeTenant, a.TidNum, bizType, deptID) if err != nil { return bizType, nil, 500, err.Error() } if len(flows) == 0 { flows, _ = approvalEngine.ListAvailableFlows(models.ApprovalScopeTenant, a.TidNum, "erp", deptID) } out := make([]erpFlowRow, 0, len(flows)) for _, f := range flows { out = append(out, erpFlowRow{f.FlowID, f.FlowCode, f.FlowName, f.BizType}) } return bizType, out, 200, "" } // erpDocExecutableCore 取来源订单中尚可执行的明细行(入库/出库/退货按行带出,避免超收超发) func erpDocExecutableCore(a erpActor, id uint64) (map[string]interface{}, int, string) { doc, e := erpLoadDoc(id, a.Tid) if e != nil { return nil, 404, "单据未找到" } isReturn := doc.DocType == models.ErpDocPurchaseReturn || doc.DocType == models.ErpDocSalesReturn items := erpDocItems(models.Orm, doc.ID) out := make([]map[string]interface{}, 0, len(items)) for _, it := range items { basis := it.Quantity used := it.ExecutedQty if isReturn { used = erpReturnedQty(a.Tid, it.ID, 0) } remain := basis - used if remain < -0.000001 { remain = 0 } out = append(out, map[string]interface{}{ "item_id": it.ID, "product_id": it.ProductID, "product_name": it.ProductName, "spec": it.Spec, "unit": it.Unit, "batch_no": it.BatchNo, "quantity": it.Quantity, "unit_price": it.UnitPrice, "amount": it.Amount, "executed_qty": round4f(used), "remain_qty": round4f(remain), }) } return map[string]interface{}{"doc": doc, "items": out}, 200, "" } // erpTakeSheetCore 盘点表初始化:带出当前账面数量与加权成本,盘点员只改实盘数 func erpTakeSheetCore(a erpActor, warehouseID uint64) ([]map[string]interface{}, int, string) { var stocks []models.ErpStock _, _ = models.Orm.QueryTable(new(models.ErpStock)). Filter("tenant_id", a.Tid).Filter("warehouse_id", warehouseID). OrderBy("product_id", "batch_no").All(&stocks) ids := make([]uint64, 0, len(stocks)) for _, s := range stocks { ids = append(ids, s.ProductID) } pmap := erpProductMap(a.Tid, ids) out := make([]map[string]interface{}, 0, len(stocks)) for _, s := range stocks { p := pmap[s.ProductID] out = append(out, map[string]interface{}{ "product_id": s.ProductID, "product_name": p.ProductName, "product_code": p.ProductCode, "spec": p.Spec, "unit": p.BaseUnit, "batch_no": s.BatchNo, "book_qty": s.Quantity, "cost_price": s.CostPrice, }) } return out, 200, "" } // erpDocMetaDict 开单页所需字典与默认值(两端共用同一份口径) func erpDocMetaDict(a erpActor) map[string]interface{} { s := erp.GetSettings(a.Tid) types := make([]map[string]interface{}, 0, len(models.ErpDocTypeLabel)) for _, k := range []string{ models.ErpDocPurchaseOrder, models.ErpDocPurchaseIn, models.ErpDocPurchaseReturn, models.ErpDocSalesOrder, models.ErpDocSalesOut, models.ErpDocSalesReturn, models.ErpDocOtherIn, models.ErpDocOtherOut, models.ErpDocTransfer, models.ErpDocStockTake, } { types = append(types, map[string]interface{}{ "value": k, "label": models.ErpDocTypeLabel[k], "affects_stock": models.ErpDocAffectsStock(k), "partner_type": docTypePartner(k), }) } return map[string]interface{}{ "doc_types": types, "statuses": []map[string]interface{}{ {"value": 0, "label": "草稿"}, {"value": 1, "label": "审批中"}, {"value": 2, "label": "已生效"}, {"value": 3, "label": "已驳回"}, {"value": 4, "label": "已作废"}, }, "default_warehouse_id": s.DefaultWarehouseID, "sales_price_type": s.SalesPriceType, "negative_stock": s.NegativeAllowed, } }