package controllers import ( "fmt" "math" "strings" "time" "server/models" erp "server/services/erp" "github.com/beego/beego/v2/client/orm" ) // BackendErpProductController 商品档案 + 商品分类(进销存基础数据) type BackendErpProductController struct { BackendErpBaseController } type erpProductPayload struct { ProductCode string `json:"product_code"` ProductName string `json:"product_name"` CategoryID uint64 `json:"category_id"` Brand string `json:"brand"` Spec string `json:"spec"` Model string `json:"model"` BaseUnit string `json:"base_unit"` AuxUnit string `json:"aux_unit"` AuxRatio float64 `json:"aux_ratio"` Barcode string `json:"barcode"` ExtraBarcodes string `json:"extra_barcodes"` PurchasePrice float64 `json:"purchase_price"` SalePrice float64 `json:"sale_price"` WholesalePrice float64 `json:"wholesale_price"` MemberPrice float64 `json:"member_price"` MinPrice float64 `json:"min_price"` TaxRate float64 `json:"tax_rate"` SafetyQty float64 `json:"safety_qty"` MaxQty float64 `json:"max_qty"` ShelfLifeDays int `json:"shelf_life_days"` EnableBatch int8 `json:"enable_batch"` ImageURL string `json:"image_url"` Status *int8 `json:"status"` IsDraft int8 `json:"is_draft"` Remark string `json:"remark"` } // List GET /backend/erp/product/list func (c *BackendErpProductController) List() { cl, err := c.claims() if c.denied(err) { return } page, pageSize := c.pageArgs() f := erpProductFilter{ Keyword: c.GetString("keyword"), CategoryID: erpUintParam(c.GetString("category_id")), Brand: c.GetString("brand"), Status: c.GetString("status"), IsDraft: c.GetString("is_draft"), OnlyEnabled: strings.TrimSpace(c.GetString("only_enabled")) == "1", } list, total, code, msg := erpProductListCore(erpNewActor(cl), f, page, pageSize) if msg != "" { c.fail(code, code, msg) return } c.listOK(list, total, page, pageSize) } // Detail GET /backend/erp/product/:id func (c *BackendErpProductController) Detail() { cl, err := c.claims() if c.denied(err) { return } p, code, msg := erpProductDetailCore(erpNewActor(cl), c.pathID()) if msg != "" { c.fail(code, code, msg) return } c.ok(p) } // Create POST /backend/erp/product func (c *BackendErpProductController) Create() { cl, err := c.claims() if c.denied(err) { return } var p erpProductPayload if !c.bindJSON(&p) { return } if strings.TrimSpace(p.ProductName) == "" { c.fail(400, 400, "商品名称不能为空") return } if strings.TrimSpace(p.BaseUnit) == "" { c.fail(400, 400, "基本单位不能为空") return } tid := c.tid(cl) if p.Status == nil { enabled := int8(1) p.Status = &enabled } code := strings.TrimSpace(p.ProductCode) if code == "" { code = erp.GenerateProductCode(tid) } if erpProductCodeExists(tid, code, 0) { c.fail(400, 400, "商品编码已存在: "+code) return } p.ProductCode = code row := erpApplyProduct(&models.ErpProduct{TenantID: tid, CreateUserID: fmt.Sprintf("%d", cl.UserID)}, &p) row.CreateTime = time.Now() row.UpdateTime = time.Now() id, err := models.Orm.Insert(&row) if err != nil { c.fail(500, 500, "创建失败: "+err.Error()) return } c.ok(map[string]interface{}{"id": id, "product_code": row.ProductCode}) } // Update PUT /backend/erp/product/:id func (c *BackendErpProductController) Update() { cl, err := c.claims() if c.denied(err) { return } id := c.pathID() if id == 0 { c.fail(400, 400, "无效的ID") return } var p erpProductPayload if !c.bindJSON(&p) { return } tid := c.tid(cl) var row models.ErpProduct if err := models.Orm.QueryTable(new(models.ErpProduct)). Filter("id", id).Filter("tenant_id", tid). Filter("delete_time__isnull", true).One(&row); err != nil { c.fail(404, 404, "商品未找到") return } code := strings.TrimSpace(p.ProductCode) if code == "" { code = row.ProductCode } if erpProductCodeExists(tid, code, id) { c.fail(400, 400, "商品编码已存在: "+code) return } updated := erpApplyProduct(&row, &p) updated.ID = row.ID updated.TenantID = tid updated.CreateTime = row.CreateTime updated.UpdateTime = time.Now() if _, err := models.Orm.Update(&updated); err != nil { c.fail(500, 500, "更新失败: "+err.Error()) return } c.ok(nil) } // Delete DELETE /backend/erp/product/:id // 软删除;已有库存或已开过单的商品不允许删除,避免历史数据失去指向。 func (c *BackendErpProductController) Delete() { cl, err := c.claims() if c.denied(err) { return } id := c.pathID() if id == 0 { c.fail(400, 400, "无效的ID") return } tid := c.tid(cl) var row models.ErpProduct if err := models.Orm.QueryTable(new(models.ErpProduct)). Filter("id", id).Filter("tenant_id", tid). Filter("delete_time__isnull", true).One(&row); err != nil { c.fail(404, 404, "商品未找到") return } if qty := erpProductTotalQty(tid, id); qty > 0.000001 { c.fail(400, 400, "该商品仍有库存,不能删除") return } var used int64 if n, err := models.Orm.QueryTable(new(models.ErpDocumentItem)). Filter("tenant_id", tid).Filter("product_id", id).Count(); err == nil { used = n } if used > 0 { c.fail(400, 400, "该商品已存在业务单据中,请停用而不是删除") return } now := time.Now() _, err = models.Orm.QueryTable(new(models.ErpProduct)).Filter("id", id). Update(orm.Params{"delete_time": now, "update_time": now}) if err != nil { c.fail(500, 500, "删除失败: "+err.Error()) return } c.ok(nil) } // ToggleStatus POST /backend/erp/product/:id/status func (c *BackendErpProductController) ToggleStatus() { cl, err := c.claims() if c.denied(err) { return } id := c.pathID() if id == 0 { c.fail(400, 400, "无效的ID") return } var p struct { Status int8 `json:"status"` } if !c.bindJSON(&p) { return } _, err = models.Orm.QueryTable(new(models.ErpProduct)). Filter("id", id).Filter("tenant_id", c.tid(cl)). Update(orm.Params{"status": p.Status, "is_draft": 0, "update_time": time.Now()}) if err != nil { c.fail(500, 500, "操作失败: "+err.Error()) return } c.ok(nil) } // ---- 分类 ---- // CategoryList GET /backend/erp/product/category/list func (c *BackendErpProductController) CategoryList() { cl, err := c.claims() if c.denied(err) { return } rows, tree, code, msg := erpCategoryListCore(erpNewActor(cl)) if msg != "" { c.fail(code, code, msg) return } c.ok(map[string]interface{}{"list": rows, "tree": tree}) } // CategorySave POST /backend/erp/product/category 新建传 id=0,修改传原 id func (c *BackendErpProductController) CategorySave() { cl, err := c.claims() if c.denied(err) { return } tid := c.tid(cl) var p struct { ID uint64 `json:"id"` CategoryName string `json:"category_name"` ParentID uint64 `json:"parent_id"` SortOrder int `json:"sort_order"` Status int8 `json:"status"` Remark string `json:"remark"` } if !c.bindJSON(&p) { return } if strings.TrimSpace(p.CategoryName) == "" { c.fail(400, 400, "分类名称不能为空") return } if p.ID == 0 { p.Status = 1 } now := time.Now() id := p.ID if p.ID > 0 { if p.ParentID == p.ID { c.fail(400, 400, "上级分类不能是自己") return } if erpCategoryIsDescendant(tid, p.ParentID, p.ID) { c.fail(400, 400, "上级分类不能是自己的子分类") return } _, err = models.Orm.QueryTable(new(models.ErpProductCategory)). Filter("id", p.ID).Filter("tenant_id", tid). Update(orm.Params{ "category_name": strings.TrimSpace(p.CategoryName), "parent_id": p.ParentID, "sort_order": p.SortOrder, "status": p.Status, "remark": p.Remark, "update_time": now, }) } else { row := models.ErpProductCategory{ TenantID: tid, CategoryName: strings.TrimSpace(p.CategoryName), ParentID: p.ParentID, SortOrder: p.SortOrder, Status: p.Status, Remark: p.Remark, CreateTime: now, UpdateTime: now, } newID, e := models.Orm.Insert(&row) if e != nil { c.fail(500, 500, "保存失败: "+e.Error()) return } id = uint64(newID) } if err != nil { c.fail(500, 500, "保存失败: "+err.Error()) return } c.ok(map[string]interface{}{"id": id}) } // CategoryDelete DELETE /backend/erp/product/category/:id func (c *BackendErpProductController) CategoryDelete() { cl, err := c.claims() if c.denied(err) { return } id := c.pathID() if id == 0 { c.fail(400, 400, "无效的ID") return } tid := c.tid(cl) var kids int64 if n, err := models.Orm.QueryTable(new(models.ErpProductCategory)). Filter("tenant_id", tid).Filter("parent_id", id). Filter("delete_time__isnull", true).Count(); err == nil { kids = n } if kids > 0 { c.fail(400, 400, "请先删除子分类") return } var used int64 if n, err := models.Orm.QueryTable(new(models.ErpProduct)). Filter("tenant_id", tid).Filter("category_id", id). Filter("delete_time__isnull", true).Count(); err == nil { used = n } if used > 0 { c.fail(400, 400, "该分类下还有商品,不能删除") return } now := time.Now() if _, err := models.Orm.QueryTable(new(models.ErpProductCategory)).Filter("id", id). Update(orm.Params{"delete_time": now, "update_time": now}); err != nil { c.fail(500, 500, "删除失败: "+err.Error()) return } c.ok(nil) } // ---- 内部工具 ---- func erpApplyProduct(row *models.ErpProduct, p *erpProductPayload) models.ErpProduct { out := *row out.ProductCode = strings.TrimSpace(p.ProductCode) out.ProductName = strings.TrimSpace(p.ProductName) out.CategoryID = p.CategoryID out.Brand = strings.TrimSpace(p.Brand) out.Spec = strings.TrimSpace(p.Spec) out.Model = strings.TrimSpace(p.Model) out.BaseUnit = strings.TrimSpace(p.BaseUnit) out.AuxUnit = strings.TrimSpace(p.AuxUnit) out.AuxRatio = p.AuxRatio out.Barcode = strings.TrimSpace(p.Barcode) out.ExtraBarcodes = strings.TrimSpace(p.ExtraBarcodes) out.PurchasePrice = p.PurchasePrice out.SalePrice = p.SalePrice out.WholesalePrice = p.WholesalePrice out.MemberPrice = p.MemberPrice out.MinPrice = p.MinPrice out.TaxRate = p.TaxRate out.SafetyQty = p.SafetyQty out.MaxQty = p.MaxQty out.ShelfLifeDays = p.ShelfLifeDays out.EnableBatch = p.EnableBatch out.ImageURL = strings.TrimSpace(p.ImageURL) if p.Status != nil { out.Status = *p.Status } out.IsDraft = p.IsDraft out.Remark = p.Remark if out.AuxRatio <= 0 { out.AuxRatio = 1 } return out } func erpProductCodeExists(tid, code string, excludeID uint64) bool { qs := models.Orm.QueryTable(new(models.ErpProduct)). Filter("tenant_id", tid).Filter("product_code", code). Filter("delete_time__isnull", true) if excludeID > 0 { qs = qs.Exclude("id", excludeID) } n, _ := qs.Count() return n > 0 } func erpProductTotalQty(tid string, productID uint64) float64 { var rows []models.ErpStock _, _ = models.Orm.QueryTable(new(models.ErpStock)). Filter("tenant_id", tid).Filter("product_id", productID). All(&rows, "quantity") var sum float64 for _, r := range rows { sum += r.Quantity } return sum } type erpStockAggRow struct { qty float64 cost float64 value float64 } func erpStockAggregate(tid string, productIDs []uint64) map[uint64]erpStockAggRow { out := map[uint64]erpStockAggRow{} if len(productIDs) == 0 { return out } var rows []models.ErpStock _, _ = models.Orm.QueryTable(new(models.ErpStock)). Filter("tenant_id", tid).Filter("product_id__in", productIDs). All(&rows, "product_id", "quantity", "cost_price") for _, r := range rows { v := out[r.ProductID] v.qty += r.Quantity v.value += r.Quantity * r.CostPrice out[r.ProductID] = v } return out } func erpProductStocks(tid string, productID uint64) []map[string]interface{} { var rows []models.ErpStock _, _ = models.Orm.QueryTable(new(models.ErpStock)). Filter("tenant_id", tid).Filter("product_id", productID). OrderBy("warehouse_id", "batch_no").All(&rows) names := erpWarehouseNameMap(tid) out := make([]map[string]interface{}, 0, len(rows)) for _, r := range rows { out = append(out, map[string]interface{}{ "warehouse_id": r.WarehouseID, "warehouse_name": names[r.WarehouseID], "batch_no": r.BatchNo, "quantity": r.Quantity, "cost_price": r.CostPrice, "amount": round2f(r.Quantity * r.CostPrice), }) } return out } // erpSafetyState low-低于安全库存 / over-超过最高库存 / normal func erpSafetyState(safety, qty, max float64) string { if safety > 0 && qty < safety { return "low" } if max > 0 && qty > max { return "over" } return "normal" } func erpCategoryMap(tid string) map[uint64]string { var rows []models.ErpProductCategory _, _ = models.Orm.QueryTable(new(models.ErpProductCategory)). Filter("tenant_id", tid).All(&rows, "id", "category_name") m := make(map[uint64]string, len(rows)) for _, r := range rows { m[r.ID] = r.CategoryName } return m } func erpCategoryTree(rows []map[string]interface{}, parent uint64) []map[string]interface{} { out := make([]map[string]interface{}, 0) for _, r := range rows { pv, _ := r["parent_id"].(uint64) if pv != parent { continue } node := map[string]interface{}{} for k, v := range r { node[k] = v } node["children"] = erpCategoryTree(rows, node["id"].(uint64)) out = append(out, node) } return out } func erpCategoryIsDescendant(tid string, nodeID, ancestor uint64) bool { if nodeID == 0 { return false } seen := map[uint64]bool{} cur := nodeID for cur != 0 && !seen[cur] { seen[cur] = true if cur == ancestor { return true } var row models.ErpProductCategory if err := models.Orm.QueryTable(new(models.ErpProductCategory)). Filter("tenant_id", tid).Filter("id", cur).One(&row, "parent_id"); err != nil { return false } cur = row.ParentID } return false } func erpWarehouseNameMap(tid string) map[uint64]string { var rows []models.ErpWarehouse _, _ = models.Orm.QueryTable(new(models.ErpWarehouse)). Filter("tenant_id", tid).All(&rows, "id", "warehouse_name") m := make(map[uint64]string, len(rows)) for _, r := range rows { m[r.ID] = r.WarehouseName } return m } func round2f(v float64) float64 { return math.Round(v*100) / 100 }