Files
2026-10-19 01:13:17 +08:00

535 lines
14 KiB
Go

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
}