package controllers import ( "fmt" "math" "strings" "server/models" approvalEngine "server/services/approval" erp "server/services/erp" ) // BackendErpDocumentController 进销存单据中心(采购/销售/库存十类单据共用一套接口) // // 单据生命周期:草稿(0) →[提交审批]→ 审批中(1) →[引擎回调]→ 已生效(2)/已驳回(3), // 或草稿/驳回/审批中 →[直接审核/作废]→ 已生效(2)/已作废(4)。 // 只有「已生效」才由 services/erp.ApplyDocEffect 产生库存流水与往来账, // 库存结存一律由流水驱动,接口层不提供任何直接改库存的入口。 // // 本文件只做「解令牌 + 组装参数 + 输出响应」,校验与落库统一在 erp_document_core.go, // 移动端 /api/erp/document* 走同一套核心,避免两端各写一遍业务规则。 type BackendErpDocumentController struct { BackendErpBaseController } type erpDocItemPayload struct { ID uint64 `json:"id"` ProductID uint64 `json:"product_id"` ProductName string `json:"product_name"` Spec string `json:"spec"` Unit string `json:"unit"` WarehouseID uint64 `json:"warehouse_id"` BatchNo string `json:"batch_no"` Quantity float64 `json:"quantity"` BookQty float64 `json:"book_qty"` RefItemID uint64 `json:"ref_item_id"` UnitPrice float64 `json:"unit_price"` Amount float64 `json:"amount"` ProductionDate string `json:"production_date"` ExpireDate string `json:"expire_date"` Remark string `json:"remark"` SortOrder int `json:"sort_order"` } type erpDocPayload struct { ID uint64 `json:"id"` DocNo string `json:"doc_no"` DocType string `json:"doc_type"` BizDate string `json:"biz_date"` WarehouseID uint64 `json:"warehouse_id"` ToWarehouseID uint64 `json:"to_warehouse_id"` PartnerType string `json:"partner_type"` PartnerID uint64 `json:"partner_id"` SourceDocID uint64 `json:"source_doc_id"` DiscountAmount float64 `json:"discount_amount"` Remark string `json:"remark"` Items []erpDocItemPayload `json:"items"` } // docTypePartner 单据类型对应的往来单位类型(空=不需要往来单位) func docTypePartner(docType string) string { switch docType { case models.ErpDocPurchaseOrder, models.ErpDocPurchaseIn, models.ErpDocPurchaseReturn: return "supplier" case models.ErpDocSalesOrder, models.ErpDocSalesOut, models.ErpDocSalesReturn: return "customer" } return "" } // docTypeSources 单据允许的"来源单据"类型集合(空=不限来源)。 // 退货既可追溯到订单,也可追溯到已生效的入库/出库单,故两者都放行。 func docTypeSources(docType string) []string { switch docType { case models.ErpDocPurchaseIn: return []string{models.ErpDocPurchaseOrder} case models.ErpDocPurchaseReturn: return []string{models.ErpDocPurchaseOrder, models.ErpDocPurchaseIn} case models.ErpDocSalesOut: return []string{models.ErpDocSalesOrder} case models.ErpDocSalesReturn: return []string{models.ErpDocSalesOrder, models.ErpDocSalesOut} } return nil } // List GET /backend/erp/document/list func (c *BackendErpDocumentController) List() { cl, err := c.claims() if c.denied(err) { return } page, pageSize := c.pageArgs() f := erpDocFilter{ DocType: strings.TrimSpace(c.GetString("doc_type")), DocTypes: strings.TrimSpace(c.GetString("doc_types")), Status: strings.TrimSpace(c.GetString("status")), Keyword: strings.TrimSpace(c.GetString("keyword")), PartnerID: erpUintParam(c.GetString("partner_id")), WarehouseID: erpUintParam(c.GetString("warehouse_id")), SourceDocID: erpUintParam(c.GetString("source_doc_id")), DateFrom: strings.TrimSpace(c.GetString("date_from")), DateTo: strings.TrimSpace(c.GetString("date_to")), } list, total, code, msg := erpDocListCore(erpNewActor(cl), f, page, pageSize) if msg != "" { c.fail(code, code, msg) return } c.listOK(list, total, page, pageSize) } // Detail GET /backend/erp/document/:id func (c *BackendErpDocumentController) Detail() { cl, err := c.claims() if c.denied(err) { return } doc, code, msg := erpDocDetailCore(erpNewActor(cl), c.pathID()) if msg != "" { c.fail(code, code, msg) return } c.ok(doc) } // Save POST /backend/erp/document // 新建或覆盖保存草稿(payload 带 id 即为编辑)。已生效/审批中的单据不允许改动。 func (c *BackendErpDocumentController) Save() { cl, err := c.claims() if c.denied(err) { return } var p erpDocPayload if !c.bindJSON(&p) { return } id, docNo, code, msg := erpDocSaveCore(erpNewActor(cl), p) if msg != "" { c.fail(code, code, msg) return } c.ok(map[string]interface{}{"id": id, "doc_no": docNo}) } // Delete DELETE /backend/erp/document/:id 仅草稿/驳回可删除(软删) func (c *BackendErpDocumentController) Delete() { cl, err := c.claims() if c.denied(err) { return } code, msg := erpDocDeleteCore(erpNewActor(cl), c.pathID()) if msg != "" { c.fail(code, code, msg) return } c.ok(nil) } // Cancel POST /backend/erp/document/:id/cancel 作废单据 func (c *BackendErpDocumentController) Cancel() { cl, err := c.claims() if c.denied(err) { return } code, msg := erpDocCancelCore(erpNewActor(cl), c.pathID()) if msg != "" { c.fail(code, code, msg) return } c.ok(nil) } // Approve POST /backend/erp/document/:id/approve // 直接审核生效(未走审批流时使用)。库存/往来账效果在同一事务内落地。 func (c *BackendErpDocumentController) Approve() { cl, err := c.claims() if c.denied(err) { return } code, msg := erpDocApproveCore(erpNewActor(cl), c.pathID()) if msg != "" { c.fail(code, code, msg) return } c.ok(nil) } // Submit POST /backend/erp/document/:id/submit func (c *BackendErpDocumentController) Submit() { cl, err := c.claims() if c.denied(err) { return } instID, code, msg := erpDocSubmitCore(erpNewActor(cl), c.pathID(), erpUintParam(c.GetString("flow_id"))) if msg != "" { c.fail(code, code, msg) return } c.ok(map[string]interface{}{"instance_id": instID}) } // Flows GET /backend/erp/document/flows?doc_type= // 返回该单据类型可用的审批流,前端据此决定是否显示「提交审批」。 func (c *BackendErpDocumentController) Flows() { cl, err := c.claims() if c.denied(err) { return } bizType, flows, code, msg := erpDocFlowsCore(erpNewActor(cl), strings.TrimSpace(c.GetString("doc_type"))) if msg != "" { c.fail(code, code, msg) return } c.ok(map[string]interface{}{"list": flows, "biz_type": bizType}) } // Executable GET /backend/erp/document/:id/executable // 取来源订单中尚可执行的明细行(入库/出库/退货按行带出,避免超收超发)。 func (c *BackendErpDocumentController) Executable() { cl, err := c.claims() if c.denied(err) { return } data, code, msg := erpDocExecutableCore(erpNewActor(cl), c.pathID()) if msg != "" { c.fail(code, code, msg) return } c.ok(data) } // TakeSheet GET /backend/erp/document/takeSheet?warehouse_id= // 盘点表初始化:带出当前账面数量与加权成本,盘点员只改实盘数。 func (c *BackendErpDocumentController) TakeSheet() { cl, err := c.claims() if c.denied(err) { return } wid := erpUintParam(c.GetString("warehouse_id")) if wid == 0 { c.fail(400, 400, "请选择仓库") return } list, code, msg := erpTakeSheetCore(erpNewActor(cl), wid) if msg != "" { c.fail(code, code, msg) return } c.ok(map[string]interface{}{"list": list, "warehouse_id": wid}) } // Meta GET /backend/erp/document/meta 前端开单页所需的字典与默认值 func (c *BackendErpDocumentController) Meta() { cl, err := c.claims() if c.denied(err) { return } c.ok(erpDocMetaDict(erpNewActor(cl))) } // ---- 内部 ---- // erpLinePreCheck 开单录入时的行前校验:超订单可执行量、超原单可退量、库存不足。 // 审核生效引擎会再校验一次,这里只是让开单人当场看到问题,不用等审批完才发现。 func erpLinePreCheck(tid string, doc *models.ErpDocument, s *erp.Settings, it *models.ErpDocumentItem) string { switch doc.DocType { case models.ErpDocPurchaseIn, models.ErpDocSalesOut: if it.RefItemID == 0 { break } var ref models.ErpDocumentItem if models.Orm.QueryTable(new(models.ErpDocumentItem)).Filter("id", it.RefItemID). One(&ref, "quantity", "executed_qty") == nil { if remain := ref.Quantity - ref.ExecutedQty; it.Quantity-remain > 0.000001 { return fmt.Sprintf("商品「%s」超出订单可执行数量(剩余 %.4f,本次 %.4f)", it.ProductName, remain, it.Quantity) } } case models.ErpDocPurchaseReturn, models.ErpDocSalesReturn: if it.RefItemID == 0 { break } var ref models.ErpDocumentItem if models.Orm.QueryTable(new(models.ErpDocumentItem)).Filter("id", it.RefItemID). One(&ref, "quantity") == nil { left := ref.Quantity - erpReturnedQty(tid, it.RefItemID, doc.ID) if it.Quantity-left > 0.000001 { return fmt.Sprintf("商品「%s」退货数量超出原单可退数量(可退 %.4f,本次 %.4f)", it.ProductName, left, it.Quantity) } } } var outQty float64 switch doc.DocType { case models.ErpDocSalesOut, models.ErpDocPurchaseReturn, models.ErpDocOtherOut: outQty = it.Quantity case models.ErpDocTransfer: outQty = it.Quantity } if outQty > 0 && !s.NegativeAllowed { wh := it.WarehouseID if wh == 0 { wh = doc.WarehouseID } if avail := erpStockQty(tid, it.ProductID, wh, it.BatchNo); avail-outQty < -0.000001 { return fmt.Sprintf("商品「%s」库存不足:可用 %.4f,本次需要 %.4f", it.ProductName, avail, outQty) } } return "" } // erpPickPrice 价格体系取价:采购单取采购价,销售类按基础设置的售价类型 func erpPickPrice(p models.ErpProduct, docType, salesPriceType string) float64 { switch docType { case models.ErpDocPurchaseOrder, models.ErpDocPurchaseIn, models.ErpDocOtherIn: return p.PurchasePrice case models.ErpDocPurchaseReturn: return p.PurchasePrice case models.ErpDocSalesReturn: return p.SalePrice case models.ErpDocSalesOrder, models.ErpDocSalesOut, models.ErpDocOtherOut: switch salesPriceType { case "wholesale": if p.WholesalePrice > 0 { return p.WholesalePrice } case "member": if p.MemberPrice > 0 { return p.MemberPrice } } return p.SalePrice } return 0 } // erpLoadEditable 取当前租户单据并校验状态在允许集合内 func erpLoadEditable(id uint64, tid string, allow []int8) (*models.ErpDocument, string) { if id == 0 { return nil, "无效的ID" } doc, err := erpLoadDoc(id, tid) if err != nil { return nil, "单据未找到" } for _, s := range allow { if doc.Status == s { return doc, "" } } return nil, "当前状态不可执行该操作" } func erpWarehouseOwned(tid string, id uint64) bool { var w models.ErpWarehouse return models.Orm.QueryTable(new(models.ErpWarehouse)). Filter("id", id).Filter("tenant_id", tid). Filter("delete_time__isnull", true).One(&w) == nil } func erpPartnerName(partnerType string, id uint64) string { if id == 0 { return "" } if partnerType == "supplier" { var s models.TenantCrmSupplier if err := models.Orm.QueryTable(new(models.TenantCrmSupplier)). Filter("id", id).Filter("delete_time__isnull", true).One(&s); err == nil { return s.SupplierName } return "" } var cu models.TenantCrmCustomer if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)). Filter("id", id).Filter("delete_time__isnull", true).One(&cu); err == nil { return cu.CustomerName } return "" } func erpProductMap(tid string, ids []uint64) map[uint64]models.ErpProduct { out := map[uint64]models.ErpProduct{} if len(ids) == 0 { return out } var rows []models.ErpProduct _, _ = models.Orm.QueryTable(new(models.ErpProduct)). Filter("tenant_id", tid).Filter("id__in", ids).All(&rows) for _, r := range rows { out[r.ID] = r } return out } func erpStockQty(tid string, productID, warehouseID uint64, batch string) float64 { var s models.ErpStock if err := models.Orm.QueryTable(new(models.ErpStock)). Filter("tenant_id", tid).Filter("product_id", productID). Filter("warehouse_id", warehouseID).Filter("batch_no", batch). One(&s, "quantity"); err == nil { return s.Quantity } return 0 } func erpStockCost(tid string, productID, warehouseID uint64, batch string) float64 { var s models.ErpStock if err := models.Orm.QueryTable(new(models.ErpStock)). Filter("tenant_id", tid).Filter("product_id", productID). Filter("warehouse_id", warehouseID).Filter("batch_no", batch). One(&s, "cost_price"); err == nil { return s.CostPrice } return 0 } // erpReturnedQty 某原单行已退数量(仅统计已生效退货单,排除 excludeDocID) func erpReturnedQty(tid string, refItemID, excludeDocID uint64) float64 { qs := models.Orm.QueryTable(new(models.ErpDocumentItem)). Filter("tenant_id", tid).Filter("ref_item_id", refItemID) if excludeDocID > 0 { qs = qs.Exclude("doc_id", excludeDocID) } var sibs []models.ErpDocumentItem _, _ = qs.All(&sibs, "doc_id", "quantity") if len(sibs) == 0 { return 0 } ids := make([]uint64, 0, len(sibs)) for _, s := range sibs { ids = append(ids, s.DocID) } var docs []models.ErpDocument _, _ = models.Orm.QueryTable(new(models.ErpDocument)). Filter("id__in", ids).Filter("tenant_id", tid). Filter("status", models.ErpDocStatusApproved). Filter("delete_time__isnull", true).All(&docs, "id") alive := map[uint64]bool{} for _, d := range docs { alive[d.ID] = true } var sum float64 for _, s := range sibs { if alive[s.DocID] { sum += s.Quantity } } return sum } // erpPickFlowID 单据类型可用审批流:先精确匹配 erp_xxx,再回落到模块级 erp。 // 部门适用范围、启用状态等匹配规则统一交给引擎的 ListAvailableFlows,避免两处口径不一致。 func erpPickFlowID(tid, orgID uint64, bizType string) uint64 { for _, bt := range []string{bizType, "erp"} { flows, err := approvalEngine.ListAvailableFlows(models.ApprovalScopeTenant, tid, bt, orgID) if err == nil && len(flows) > 0 { return flows[0].FlowID } } return 0 } func docTypeLabelOf(t string) string { return models.ErpDocTypeLabel[t] } func round4f(v float64) float64 { return math.Round(v*10000) / 10000 }