更新
This commit is contained in:
1 parent
5718a00e31
commit
1850ac640e
226 files changed
+37772
-12071
No files matched your search
@@ -0,0 +1,534 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user