Files
jiu/backend/internal/service/stock.go
T
wangjia 3625240839 feat(backend): 入库「确认进价」前向补偿(暂估价→真实价)
调货 0 价入库并已审核/出库后,价格确定时补填真实进价:一事务内
回填入库明细/库存批次/已出库成本快照,重算入库单总额,按差额补
应付流水,同步参考进价。按 product_id 精确命中(序列号 1:1),
不动售价/应收。仅 approved 单、管理员/超管,差额幂等可重复确认。

新增 POST /stock-in/orders/:id/confirm-cost 及 4 个测试用例。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:07:49 +08:00

674 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.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
})
}
// CostConfirmItem 是「确认进价」的单行入参:把某入库明细的进价补成真实值。
type CostConfirmItem struct {
ItemID uint64 `json:"item_id"`
UnitPrice float64 `json:"unit_price"`
}
// ConfirmStockInCost 「确认进价」:调货等场景以 0 价(暂估)入库并已审核/已出库后,
// 价格确定时补填真实进价。不反审核、不撤销任何已发生的动作,而是在一个事务内
// 前向写补偿:
// 1. 入库明细 UnitPrice / TotalPrice 改为真实值
// 2. 该明细对应的剩余库存批次 Inventory.UnitPrice 由 NULL→真实值
// 3. 该 product 已出库行 StockOutItem.UnitPrice/TotalPrice(成本快照)回填真实值
// product↔入库明细 1:1,按 product_id 精确命中;不动 SalePrice/应收)
// 4. 入库单 TotalAmount 按差额重算
// 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
for i := range order.Items {
it := &order.Items[i]
newPrice, ok := want[it.ID]
if !ok || newPrice == it.UnitPrice {
continue
}
oldPrice := it.UnitPrice
diff := (newPrice - oldPrice) * it.Quantity
totalDiff += diff
// 1. 入库明细
if err := tx.Model(it).Updates(map[string]interface{}{
"unit_price": newPrice,
"total_price": 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{}{
"unit_price": newPrice,
"total_price": 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 totalDiff == 0 {
return nil
}
// 4. 入库单总额按差额重算
if err := tx.Model(&order).
Update("total_amount", gorm.Expr("total_amount + ?", 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
})
}
// 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
}
// 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.TotalPrice
}
// 冲减应付(负向调整记录,滚动余额)
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.UnitPrice != 0 {
up := it.UnitPrice
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
// 冲应收按售价口径(与审核建应收 TotalAmount=Σ售价×数量 一致)
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
}