From c1febfffecee2689b40738aa3804ca461eae3f55 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 21 Jun 2026 16:30:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E5=85=A5=E5=BA=93=E6=AF=8F?= =?UTF-8?q?=E8=A1=8C=E6=96=B0=E5=BB=BA=E7=8B=AC=E7=AB=8B=E4=BA=A7=E5=93=81?= =?UTF-8?q?=E5=8F=91=E7=8B=AC=E7=AB=8B=E5=BA=8F=E5=88=97=E5=8F=B7(product?= =?UTF-8?q?=20=E5=8A=A0=E7=94=9F=E4=BA=A7=E6=97=A5=E6=9C=9F/=E6=89=B9?= =?UTF-8?q?=E6=AC=A1=E5=88=97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 入库 Create/Update 改为对每条明细新建独立 product(createIndependentProduct, nextProductCode 自增 + uk_shop_code 唯一约束兜底),回填 product_id;不再按名称复用。 product 表加 production_date/batch_no。向后兼容:保留明细/库存快照列。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx --- CHANGELOG-server.md | 5 +++ backend/internal/handler/product.go | 36 ++++++++++++++++ backend/internal/handler/stock_in.go | 52 +++++++++++++++-------- backend/internal/handler/stock_in_test.go | 18 +++++--- backend/internal/model/product.go | 3 ++ backend/testutil/setup.go | 2 + 6 files changed, 91 insertions(+), 25 deletions(-) diff --git a/CHANGELOG-server.md b/CHANGELOG-server.md index 300788f..c25cfd5 100644 --- a/CHANGELOG-server.md +++ b/CHANGELOG-server.md @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.69] - 2026-06-21 + +### 改进 +- 入库录入改为「每行一个独立产品」:每条明细新建独立商品、发独立序列号(同名同规格录多次也各自独立),不再复用已有商品编号,符合"每个货品一个唯一编号"的管理方式 + ## [1.0.68] - 2026-06-21 ### 修复 diff --git a/backend/internal/handler/product.go b/backend/internal/handler/product.go index 9f18ecc..dc9f82f 100644 --- a/backend/internal/handler/product.go +++ b/backend/internal/handler/product.go @@ -85,6 +85,42 @@ func nextProductCode(tx *gorm.DB, shopID uint64) (string, error) { return fmt.Sprintf("P%03d", maxN+1), nil } +// createIndependentProduct 为入库明细新建一个独立产品(特有产品/序列号),返回新 product。 +// "入库每行 = 一个特有产品"模型:每条明细建一个独立 product、发新序列号,不按名称复用。 +// 含 nextProductCode 自增 + 撞 uk_shop_code 唯一约束时重试。必须在事务内调用。 +func createIndependentProduct(tx *gorm.DB, shopID uint64, name, series, spec, batchNo string, prodDate *model.Date, price float64) (model.Product, error) { + namePinyin, nameInitials := util.ToPinyin(name) + var prod model.Product + var err error + for attempt := 0; attempt < 5; attempt++ { + code, e := nextProductCode(tx, shopID) + if e != nil { + return model.Product{}, e + } + prod = model.Product{ + TenantBase: model.TenantBase{ShopID: shopID}, + PublicID: uuid.New().String(), + Code: code, + Name: name, + Series: series, + Spec: spec, + BatchNo: batchNo, + ProductionDate: prodDate, + PurchasePrice: price, + NamePinyin: namePinyin, + NameInitials: nameInitials, + } + prod.ID = 0 + if err = tx.Create(&prod).Error; err == nil || !errors.Is(err, gorm.ErrDuplicatedKey) { + break + } + } + if err != nil { + return model.Product{}, err + } + return prod, nil +} + // Create POST /api/v1/products func (h *ProductHandler) Create(c *gin.Context) { shopID := middleware.GetShopID(c) diff --git a/backend/internal/handler/stock_in.go b/backend/internal/handler/stock_in.go index 84ee0bb..9a725ba 100644 --- a/backend/internal/handler/stock_in.go +++ b/backend/internal/handler/stock_in.go @@ -106,19 +106,28 @@ func (h *StockInHandler) Create(c *gin.Context) { } req.OrderNo = orderNo - // 计算总金额;自动生成批次号 - var total float64 - for i := range req.Items { - req.Items[i].ShopID = shopID - req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice - total += req.Items[i].TotalPrice - if req.Items[i].BatchNo == "" { - req.Items[i].BatchNo = fmt.Sprintf("%s-%02d", req.OrderNo, i+1) + // 事务内:每条明细新建一个独立产品(特有产品/序列号,不按名称复用),回填 product_id,再建单。 + err = h.db.Transaction(func(tx *gorm.DB) error { + var total float64 + for i := range req.Items { + it := &req.Items[i] + it.ShopID = shopID + if it.BatchNo == "" { + it.BatchNo = fmt.Sprintf("%s-%02d", req.OrderNo, i+1) + } + prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.UnitPrice) + if e != nil { + return e + } + it.ProductID = prod.ID + it.ProductCode = prod.Code // 保留快照,兼容现有查询(瘦身阶段再去) + it.TotalPrice = it.Quantity * it.UnitPrice + total += it.TotalPrice } - } - req.TotalAmount = total - - if err := h.db.Create(&req).Error; err != nil { + req.TotalAmount = total + return tx.Create(&req).Error + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -148,13 +157,20 @@ func (h *StockInHandler) Update(c *gin.Context) { } var total float64 for i := range req.Items { - req.Items[i].ShopID = shopID - req.Items[i].OrderID = order.ID - req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice - total += req.Items[i].TotalPrice - if req.Items[i].BatchNo == "" { - req.Items[i].BatchNo = fmt.Sprintf("%s-%02d", order.OrderNo, i+1) + it := &req.Items[i] + it.ShopID = shopID + it.OrderID = order.ID + if it.BatchNo == "" { + it.BatchNo = fmt.Sprintf("%s-%02d", order.OrderNo, i+1) } + // 编辑草稿:旧明细已删,每条按新模型重建独立产品(旧草稿 product 无库存,暂留待后续清理) + prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.UnitPrice) + if e != nil { + return e + } + it.ProductID = prod.ID + it.ProductCode = prod.Code + it.TotalPrice = it.Quantity * it.UnitPrice } updates := map[string]interface{}{ "warehouse_id": req.WarehouseID, diff --git a/backend/internal/handler/stock_in_test.go b/backend/internal/handler/stock_in_test.go index 476c49d..aaf4e9f 100644 --- a/backend/internal/handler/stock_in_test.go +++ b/backend/internal/handler/stock_in_test.go @@ -28,9 +28,11 @@ func TestStockInHandler_FullFlow(t *testing.T) { "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ { - "product_id": product.ID, - "quantity": 10.0, - "unit_price": 5.0, + "product_name": "Test Beer", + "series": "普通", + "spec": "500ml", + "quantity": 10.0, + "unit_price": 5.0, }, }, }) @@ -57,15 +59,17 @@ func TestStockInHandler_FullFlow(t *testing.T) { w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", orderID), token, nil) require.Equal(t, http.StatusOK, w.Code) - // 5. 验证库存变化 + // 5. 验证库存变化:入库为明细新建了独立产品,库存指向它(不是预设的 product) var inv model.Inventory - db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?", - shop.ID, warehouse.ID, product.ID).First(&inv) + db.Where("shop_id = ? AND warehouse_id = ?", + shop.ID, warehouse.ID).First(&inv) assert.Equal(t, float64(10), inv.Quantity) + assert.NotZero(t, inv.ProductID) + assert.NotEqual(t, product.ID, inv.ProductID) // 6. 验证库存流水 var logs []model.InventoryLog - db.Where("shop_id = ? AND product_id = ?", shop.ID, product.ID).Find(&logs) + db.Where("shop_id = ? AND product_id = ?", shop.ID, inv.ProductID).Find(&logs) require.Len(t, logs, 1) assert.Equal(t, "in", logs[0].Direction) assert.Equal(t, float64(10), logs[0].Quantity) diff --git a/backend/internal/model/product.go b/backend/internal/model/product.go index cbf4a26..2d7c5e5 100644 --- a/backend/internal/model/product.go +++ b/backend/internal/model/product.go @@ -22,6 +22,9 @@ type Product struct { Brand string `gorm:"size:100" json:"brand"` PurchasePrice float64 `gorm:"type:decimal(12,2)" json:"purchase_price"` SalePrice float64 `gorm:"type:decimal(12,2)" json:"sale_price"` + // 特有产品的批次属性:每个 product = 一个特有产品/序列号,生产日期/批次归此(单一来源) + ProductionDate *Date `gorm:"type:date" json:"production_date"` + BatchNo string `gorm:"size:50" json:"batch_no"` MinStock int `gorm:"default:0" json:"min_stock"` Description string `gorm:"type:text" json:"description"` CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index abd562e..e960fb2 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -188,6 +188,8 @@ func SetupTestDB() *gorm.DB { brand TEXT, purchase_price REAL, sale_price REAL, + production_date DATETIME, + batch_no TEXT, min_stock INTEGER DEFAULT 0, description TEXT, custom_fields TEXT,