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 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) var unitPricePtr *float64 if itemCopy.UnitPrice != 0 { unitPricePtr = &itemCopy.UnitPrice } productCode := "" productName := "" series := "" spec := "" unit := "" if itemCopy.Product != nil { productCode = itemCopy.Product.Code productName = itemCopy.Product.Name series = itemCopy.Product.Series 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. 预检:SUM 是否充足 var totalQty float64 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(&totalQty) if totalQty < needed { return fmt.Errorf("%w: product_id=%d, available=%.3f, required=%.3f", ErrInsufficientStock, productID, totalQty, needed) } qtyBefore := totalQty // 2. FIFO 扣减批次 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) 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 }