535 lines
14 KiB
Go
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
|
|
}
|