package service import ( "errors" "fmt" "strings" "time" "gorm.io/gorm" "github.com/wangjia/jiu/backend/internal/model" ) var ErrInsufficientStock = errors.New("insufficient stock") type StockService struct { db *gorm.DB } func NewStockService(db *gorm.DB) *StockService { return &StockService{db: db} } // ApproveStockIn 审核入库单,每个明细行创建一条独立的批次库存记录 func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error { return s.db.Transaction(func(tx *gorm.DB) error { var order model.StockInOrder if err := tx.Preload("Items.Product").Preload("Warehouse").Preload("Partner"). Where("id = ? AND shop_id = ?", orderID, shopID). First(&order).Error; err != nil { return err } if order.Status != "pending" { return errors.New("order is not in pending status") } supplierName := "" if order.Partner != nil { supplierName = order.Partner.Name } warehouseName := "" if order.Warehouse != nil { warehouseName = order.Warehouse.Name } now := time.Now() for _, item := range order.Items { itemCopy := item itemID := itemCopy.ID warehouseID := order.WarehouseID productID := itemCopy.ProductID // 计算入库前库存总量(用于流水记录) var qtyBefore float64 if err := tx.Model(&model.Inventory{}). Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", shopID, warehouseID, productID). Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore).Error; err != nil { return err } var unitPricePtr *float64 if itemCopy.UnitPrice != 0 { unitPricePtr = &itemCopy.UnitPrice } // 优先使用明细自带快照列(历史导入单的真实商品信息在此), // Product 关联仅作兜底(普通 UI 录入单的明细快照为空)。 productCode := itemCopy.ProductCode productName := itemCopy.ProductName series := itemCopy.Series spec := itemCopy.Spec unit := "" if itemCopy.Product != nil { if productCode == "" { productCode = itemCopy.Product.Code } if productName == "" { productName = itemCopy.Product.Name } if series == "" { series = itemCopy.Product.Series } if spec == "" { spec = itemCopy.Product.Spec } unit = itemCopy.Product.Unit } inv := model.Inventory{ ShopID: shopID, WarehouseID: &warehouseID, ProductID: &productID, StockInItemID: &itemID, Quantity: itemCopy.Quantity, ProductCode: productCode, ProductName: productName, Series: series, Spec: spec, Unit: unit, WarehouseName: warehouseName, UnitPrice: unitPricePtr, ProductionDate: itemCopy.ProductionDate, BatchNo: itemCopy.BatchNo, SupplierName: supplierName, } if err := tx.Create(&inv).Error; err != nil { return err } log := model.InventoryLog{ ShopID: shopID, WarehouseID: warehouseID, ProductID: productID, Direction: "in", Quantity: itemCopy.Quantity, QtyBefore: qtyBefore, QtyAfter: qtyBefore + itemCopy.Quantity, RefType: "stock_in", RefID: orderID, OperatorID: &reviewerID, } if err := tx.Create(&log).Error; err != nil { return err } } // 自动创建应付账款财务记录 { bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.TotalAmount orderID := order.ID rec := model.FinanceRecord{ ShopID: shopID, PartnerID: order.PartnerID, Type: "payable", Amount: order.TotalAmount, Balance: bal, Status: "open", RefType: "stock_in", RefID: &orderID, OperatorID: reviewerID, RecordDate: order.OrderDate.Time, } if err := tx.Create(&rec).Error; err != nil { return err } } return tx.Model(&order).Updates(map[string]interface{}{ "status": "approved", "reviewer_id": reviewerID, "reviewed_at": now, }).Error }) } // ApproveStockOut 审核出库单,FIFO 扣减批次库存 func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error { return s.db.Transaction(func(tx *gorm.DB) error { var order model.StockOutOrder if err := tx.Preload("Items"). Where("id = ? AND shop_id = ?", orderID, shopID). First(&order).Error; err != nil { return err } if order.Status != "pending" { return errors.New("order is not in pending status") } now := time.Now() warehouseID := order.WarehouseID for _, item := range order.Items { itemCopy := item productID := itemCopy.ProductID needed := itemCopy.Quantity // 1. FOR UPDATE 锁定批次后再汇总,消除 TOCTOU 窗口 var batches []model.Inventory tx.Set("gorm:query_option", "FOR UPDATE"). Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL", shopID, warehouseID, productID). Order("created_at ASC").Find(&batches) var totalQty float64 for _, b := range batches { totalQty += b.Quantity } if totalQty < needed { return fmt.Errorf("%w: product_id=%d, available=%.3f, required=%.3f", ErrInsufficientStock, productID, totalQty, needed) } qtyBefore := totalQty // 2. FIFO 扣减 remaining := needed for i := range batches { if remaining <= 0 { break } b := &batches[i] if b.Quantity <= remaining { remaining -= b.Quantity tx.Model(b).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now}) } else { tx.Model(b).Update("quantity", gorm.Expr("quantity - ?", remaining)) remaining = 0 } } // 3. 写流水 log := model.InventoryLog{ ShopID: shopID, WarehouseID: warehouseID, ProductID: productID, Direction: "out", Quantity: needed, QtyBefore: qtyBefore, QtyAfter: qtyBefore - needed, RefType: "stock_out", RefID: orderID, OperatorID: &reviewerID, } if err := tx.Create(&log).Error; err != nil { return err } } // 自动创建应收账款财务记录 { bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.TotalAmount oid := order.ID rec := model.FinanceRecord{ ShopID: shopID, PartnerID: order.PartnerID, Type: "receivable", Amount: order.TotalAmount, Balance: bal, Status: "open", RefType: "stock_out", RefID: &oid, OperatorID: reviewerID, RecordDate: order.OrderDate.Time, } if err := tx.Create(&rec).Error; err != nil { return err } } return tx.Model(&order).Updates(map[string]interface{}{ "status": "approved", "reviewer_id": reviewerID, "reviewed_at": now, }).Error }) } // partnerLastBalance 查询该往来单位最后一条财务记录的余额(用于计算滚动余额) func partnerLastBalance(tx *gorm.DB, shopID uint64, partnerID *uint64) float64 { var last model.FinanceRecord q := tx.Where("shop_id = ? AND deleted_at IS NULL", shopID) if partnerID != nil { q = q.Where("partner_id = ?", *partnerID) } else { q = q.Where("partner_id IS NULL") } q.Order("id DESC").First(&last) return last.Balance } // GenerateOrderNo 生成单号(事务安全,FOR UPDATE 防止并发重复单号) func (s *StockService) GenerateOrderNo(shopID uint64, orderType string) (string, error) { var no string err := s.db.Transaction(func(tx *gorm.DB) error { var rule model.NumberRule result := tx.Set("gorm:query_option", "FOR UPDATE"). Where("shop_id = ? AND type = ?", shopID, orderType).First(&rule) if result.Error != nil { // 初始化规则(使用中文惯用前缀) prefixMap := map[string]string{ "stock_in": "RK", "stock_out": "CK", "inventory_check": "PD", "product": "SP", } prefix := prefixMap[orderType] if prefix == "" { prefix = strings.ToUpper(orderType[:2]) } rule = model.NumberRule{ShopID: shopID, Type: orderType, Prefix: prefix, DateFormat: "YYYYMMDD", CurrentNo: 0} tx.Create(&rule) } rule.CurrentNo++ tx.Model(&rule).Update("current_no", rule.CurrentNo) dateStr := time.Now().Format("20060102") no = fmt.Sprintf("%s%s%06d", rule.Prefix, dateStr, rule.CurrentNo) return nil }) return no, err } // CheckInventoryAvailability validates that the warehouse has sufficient stock for each item. // Returns an error describing the first shortage encountered. func (s *StockService) CheckInventoryAvailability(shopID, warehouseID uint64, items []model.StockOutItem) error { if len(items) == 0 { return nil } productIDs := make([]uint64, 0, len(items)) for _, item := range items { productIDs = append(productIDs, item.ProductID) } type inventorySum struct { ProductID uint64 Total float64 } var sums []inventorySum if err := s.db.Model(&model.Inventory{}). Select("product_id, COALESCE(SUM(quantity), 0) AS total"). Where("shop_id = ? AND warehouse_id = ? AND product_id IN ? AND deleted_at IS NULL", shopID, warehouseID, productIDs). Group("product_id").Scan(&sums).Error; err != nil { return err } sumMap := make(map[uint64]float64, len(sums)) for _, s := range sums { sumMap[s.ProductID] = s.Total } for _, item := range items { have := sumMap[item.ProductID] if have < item.Quantity { var p model.Product s.db.Where("id = ?", item.ProductID).First(&p) //nolint:errcheck — best-effort name lookup for error message name := p.Name if name == "" { name = fmt.Sprintf("商品ID %d", item.ProductID) } return fmt.Errorf("库存不足:%s 当前库存 %.0f,需要 %.0f", name, have, item.Quantity) } } return nil }