package service import ( "errors" "fmt" "strings" "time" "gorm.io/gorm" "github.com/wangjia/jiu/backend/internal/model" ) var ErrInsufficientStock = errors.New("insufficient stock") // ErrForbidden 表示当前用户无权执行该操作(handler 据此返回 403)。 var ErrForbidden = errors.New("forbidden") 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.CostPrice != 0 { unitPricePtr = &itemCopy.CostPrice } // 优先使用明细自带快照列(历史导入单的真实商品信息在此), // 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.CostTotal orderID := order.ID rec := model.FinanceRecord{ ShopID: shopID, PartnerID: order.PartnerID, Type: "payable", Amount: order.CostTotal, 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 }) } // CostConfirmItem 是「确认进价」的单行入参:把某入库明细的进价补成真实值。 type CostConfirmItem struct { ItemID uint64 `json:"item_id"` UnitPrice float64 `json:"unit_price"` } // SaleConfirmItem 出库「确认售价」的一条明细(先出后定价)。 type SaleConfirmItem struct { ItemID uint64 `json:"item_id"` SalePrice float64 `json:"sale_price"` } // ConfirmStockInCost 「确认进价」:调货等场景以 0 价(暂估)入库并已审核/已出库后, // 价格确定时补填真实进价。不反审核、不撤销任何已发生的动作,而是在一个事务内 // 前向写补偿: // 1. 入库明细 CostPrice / CostAmount 改为真实值 // 2. 该明细对应的剩余库存批次 Inventory.UnitPrice 由 NULL→真实值 // 3. 该 product 已出库行 StockOutItem.CostPrice/CostAmount(成本快照)回填真实值 // (product↔入库明细 1:1,按 product_id 精确命中;不动 SalePrice/应收) // 4. 入库单 CostTotal 按差额重算 // 5. 对供应商应付按差额补一条调整流水(滚动余额) // // 仅 approved 单可确认(draft 直接编辑即可);权限:管理员/超管。 // 以「旧价→新价」差额计算补偿,支持多次修正(再次确认按新旧差额补)。 func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role string, items []CostConfirmItem) error { if role != "admin" && role != "superadmin" { return ErrForbidden } return s.db.Transaction(func(tx *gorm.DB) error { var order model.StockInOrder if err := tx.Preload("Items").Where("id = ? AND shop_id = ?", orderID, shopID). First(&order).Error; err != nil { return err } if order.Status != "approved" { return errors.New("只有已审核单据可确认进价") } want := make(map[uint64]float64, len(items)) for _, it := range items { want[it.ItemID] = it.UnitPrice } now := time.Now() var totalDiff float64 var changedProducts []uint64 // 成本被改的商品:其已出库单的利润需联动重算 for i := range order.Items { it := &order.Items[i] newPrice, ok := want[it.ID] if !ok || newPrice == it.CostPrice { continue } oldPrice := it.CostPrice diff := (newPrice - oldPrice) * it.Quantity totalDiff += diff // 1. 入库明细 if err := tx.Model(it).Updates(map[string]interface{}{ "cost_price": newPrice, "cost_amount": newPrice * it.Quantity, }).Error; err != nil { return err } // 2. 该明细的剩余库存批次(已全部出库则无行,跳过) if err := tx.Model(&model.Inventory{}). Where("shop_id = ? AND stock_in_item_id = ? AND deleted_at IS NULL", shopID, it.ID). Update("unit_price", newPrice).Error; err != nil { return err } // 3. 已出库行成本快照回填(按 product_id 精确命中;product_id=0 的历史导入单不传播) if it.ProductID != 0 { if err := tx.Model(&model.StockOutItem{}). Where("shop_id = ? AND product_id = ?", shopID, it.ProductID). Updates(map[string]interface{}{ "cost_price": newPrice, "cost_amount": gorm.Expr("? * quantity", newPrice), }).Error; err != nil { return err } } // 同步 product 参考进价 if it.ProductID != 0 { tx.Model(&model.Product{}).Where("id = ? AND shop_id = ?", it.ProductID, shopID). Update("purchase_price", newPrice) } if it.ProductID != 0 { changedProducts = append(changedProducts, it.ProductID) } } // 3.5 成本快照变了 → 受影响出库单的总利润联动重算 if len(changedProducts) > 0 { var outOrderIDs []uint64 if err := tx.Model(&model.StockOutItem{}). Where("shop_id = ? AND product_id IN ?", shopID, changedProducts). Distinct().Pluck("order_id", &outOrderIDs).Error; err != nil { return err } if err := recalcStockOutProfit(tx, shopID, outOrderIDs); err != nil { return err } } if totalDiff == 0 { return nil } // 4. 入库单应付合计按差额重算 if err := tx.Model(&order). Update("cost_total", gorm.Expr("cost_total + ?", totalDiff)).Error; err != nil { return err } // 5. 应付差额调整流水(滚动余额) bal := partnerLastBalance(tx, shopID, order.PartnerID) + totalDiff oid := order.ID return tx.Create(&model.FinanceRecord{ ShopID: shopID, PartnerID: order.PartnerID, Type: "payable", Amount: totalDiff, Balance: bal, Status: "open", RefType: "stock_in_cost_adjust", RefID: &oid, OperatorID: userID, RecordDate: now, }).Error }) } // recalcStockOutProfit 按行利润口径整单重算总利润: // profit_total = Σ(sale_price>0 ? (sale_price-cost_price)×quantity : 0)。 // 确认售价 / 确认进价(成本回填)后调用,保证利润与两侧价格始终一致。 func recalcStockOutProfit(tx *gorm.DB, shopID uint64, orderIDs []uint64) error { if len(orderIDs) == 0 { return nil } return tx.Exec(`UPDATE stock_out_orders SET profit_total = ( SELECT COALESCE(SUM(CASE WHEN i.sale_price > 0 THEN (i.sale_price - i.cost_price) * i.quantity ELSE 0 END), 0) FROM stock_out_items i WHERE i.order_id = stock_out_orders.id ) WHERE shop_id = ? AND id IN ?`, shopID, orderIDs).Error } // ConfirmStockOutSale 出库「先出后定价」:对 sale_price<=0 的明细写真实售价, // 重算应收(sale_total=Σ售价小计)与总利润,并补应收差额流水。仅 admin/superadmin、已审核单可确认。 func (s *StockService) ConfirmStockOutSale(shopID, orderID, userID uint64, role string, items []SaleConfirmItem) error { if role != "admin" && role != "superadmin" { return ErrForbidden } 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 != "approved" { return errors.New("只有已审核单据可确认售价") } want := make(map[uint64]float64, len(items)) for _, it := range items { want[it.ItemID] = it.SalePrice } now := time.Now() var totalDiff float64 for i := range order.Items { it := &order.Items[i] newPrice, ok := want[it.ID] if !ok || newPrice == it.SalePrice { continue } diff := (newPrice - it.SalePrice) * it.Quantity totalDiff += diff // 售价与售价小计同步改,避免「单价×数量 ≠ 金额」的口径漂移 if err := tx.Model(it).Updates(map[string]interface{}{ "sale_price": newPrice, "sale_amount": newPrice * it.Quantity, }).Error; err != nil { return err } } if totalDiff == 0 { return nil } // 应收合计按差额重算;总利润整单重算 if err := tx.Model(&order). Update("sale_total", gorm.Expr("sale_total + ?", totalDiff)).Error; err != nil { return err } if err := recalcStockOutProfit(tx, shopID, []uint64{order.ID}); err != nil { return err } // 应收差额调整流水(滚动余额,与出库审核建应收同口径 last+amount) bal := partnerLastBalance(tx, shopID, order.PartnerID) + totalDiff oid := order.ID return tx.Create(&model.FinanceRecord{ ShopID: shopID, PartnerID: order.PartnerID, Type: "receivable", Amount: totalDiff, Balance: bal, Status: "open", RefType: "stock_out_sale_adjust", RefID: &oid, OperatorID: userID, RecordDate: 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.SaleTotal oid := order.ID rec := model.FinanceRecord{ ShopID: shopID, PartnerID: order.PartnerID, Type: "receivable", Amount: order.SaleTotal, 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 } // ReturnStockIn 入库单退单:把选中明细退掉,对应库存从库存中删除,并冲减应付。 // 仅 approved 单可退;权限:管理员/超管。已被出库消耗的明细禁止退(库存不足)。 func (s *StockService) ReturnStockIn(shopID, orderID, userID uint64, role string, itemIDs []uint64) error { if role != "admin" && role != "superadmin" { return ErrForbidden } return s.db.Transaction(func(tx *gorm.DB) error { var order model.StockInOrder if err := tx.Preload("Items").Where("id = ? AND shop_id = ?", orderID, shopID). First(&order).Error; err != nil { return err } if order.Status != "approved" { return errors.New("只有已审核单据可退单") } want := idSet(itemIDs) now := time.Now() var returnedAmount float64 for i := range order.Items { it := &order.Items[i] if !want[it.ID] || it.ReturnedQuantity >= it.Quantity { continue } // 找该明细审核时建的库存行(FOR UPDATE) var inv model.Inventory if err := tx.Set("gorm:query_option", "FOR UPDATE"). Where("shop_id = ? AND stock_in_item_id = ? AND deleted_at IS NULL", shopID, it.ID). First(&inv).Error; err != nil { return fmt.Errorf("明细「%s」库存已出库,无法退单", itemDesc(it.ProductName, it.ProductCode)) } // 已被部分出库(库存数量 < 入库数量)→ 禁止退 if inv.Quantity < it.Quantity { return fmt.Errorf("明细「%s」库存已部分出库,无法退单", itemDesc(it.ProductName, it.ProductCode)) } wid, pid := uint64(0), uint64(0) if inv.WarehouseID != nil { wid = *inv.WarehouseID } if inv.ProductID != nil { pid = *inv.ProductID } // 删库存行 + 写反向流水 if err := tx.Model(&inv).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now}).Error; err != nil { return err } if err := tx.Create(&model.InventoryLog{ ShopID: shopID, WarehouseID: wid, ProductID: pid, Direction: "out", Quantity: it.Quantity, QtyBefore: inv.Quantity, QtyAfter: 0, RefType: "stock_in_return", RefID: orderID, OperatorID: &userID, }).Error; err != nil { return err } if err := tx.Model(it).Update("returned_quantity", it.Quantity).Error; err != nil { return err } it.ReturnedQuantity = it.Quantity returnedAmount += it.CostAmount } // 冲减应付(负向调整记录,滚动余额) if returnedAmount > 0 { bal := partnerLastBalance(tx, shopID, order.PartnerID) - returnedAmount oid := order.ID if err := tx.Create(&model.FinanceRecord{ ShopID: shopID, PartnerID: order.PartnerID, Type: "payable", Amount: -returnedAmount, Balance: bal, Status: "closed", RefType: "stock_in_return", RefID: &oid, OperatorID: userID, RecordDate: now, }).Error; err != nil { return err } } return tx.Model(&order).Update("return_state", returnStateOf(order.Items)).Error }) } // ReturnStockOut 出库单退单:把选中明细退掉,数量加回库存(新建库存行),并冲减应收。 // 仅 approved 单可退;权限:管理员/超管 或 本人(operator)。 func (s *StockService) ReturnStockOut(shopID, orderID, userID uint64, role string, itemIDs []uint64) error { return s.db.Transaction(func(tx *gorm.DB) error { var order model.StockOutOrder if err := tx.Preload("Items.Product").Where("id = ? AND shop_id = ?", orderID, shopID). First(&order).Error; err != nil { return err } if role != "admin" && role != "superadmin" && order.OperatorID != userID { return ErrForbidden } if order.Status != "approved" { return errors.New("只有已审核单据可退单") } want := idSet(itemIDs) now := time.Now() warehouseID := order.WarehouseID var returnedAmount float64 for i := range order.Items { it := &order.Items[i] if !want[it.ID] || it.ReturnedQuantity >= it.Quantity { continue } productID := it.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 it.CostPrice != 0 { up := it.CostPrice unitPricePtr = &up } unit := "" if it.Product != nil { unit = it.Product.Unit } // 加回库存:新建一条库存行 inv := model.Inventory{ ShopID: shopID, WarehouseID: &warehouseID, ProductID: &productID, Quantity: it.Quantity, ProductCode: it.ProductCode, ProductName: it.ProductName, Series: it.Series, Spec: it.Spec, Unit: unit, UnitPrice: unitPricePtr, ProductionDate: it.ProductionDate, BatchNo: it.BatchNo, } if err := tx.Create(&inv).Error; err != nil { return err } if err := tx.Create(&model.InventoryLog{ ShopID: shopID, WarehouseID: warehouseID, ProductID: productID, Direction: "in", Quantity: it.Quantity, QtyBefore: qtyBefore, QtyAfter: qtyBefore + it.Quantity, RefType: "stock_out_return", RefID: orderID, OperatorID: &userID, }).Error; err != nil { return err } if err := tx.Model(it).Update("returned_quantity", it.Quantity).Error; err != nil { return err } it.ReturnedQuantity = it.Quantity // 冲应收按售价口径(与审核建应收 SaleTotal=Σ售价×数量 一致) returnedAmount += it.Quantity * it.SalePrice } // 冲减应收(负向调整记录) if returnedAmount > 0 { bal := partnerLastBalance(tx, shopID, order.PartnerID) - returnedAmount oid := order.ID if err := tx.Create(&model.FinanceRecord{ ShopID: shopID, PartnerID: order.PartnerID, Type: "receivable", Amount: -returnedAmount, Balance: bal, Status: "closed", RefType: "stock_out_return", RefID: &oid, OperatorID: userID, RecordDate: now, }).Error; err != nil { return err } } return tx.Model(&order).Update("return_state", returnStateOfOut(order.Items)).Error }) } func idSet(ids []uint64) map[uint64]bool { m := make(map[uint64]bool, len(ids)) for _, id := range ids { m[id] = true } return m } func itemDesc(name, code string) string { if name == "" { return code } if code == "" { return name } return name + " " + code } // returnStateOf 据明细已退情况推导入库单退单状态。 func returnStateOf(items []model.StockInItem) string { total, returned := 0, 0 for _, it := range items { total++ if it.ReturnedQuantity >= it.Quantity { returned++ } } switch { case returned == 0: return "none" case returned >= total: return "full" default: return "partial" } } func returnStateOfOut(items []model.StockOutItem) string { total, returned := 0, 0 for _, it := range items { total++ if it.ReturnedQuantity >= it.Quantity { returned++ } } switch { case returned == 0: return "none" case returned >= total: return "full" default: return "partial" } } // 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 }