feat: 财务结清、酒行信息、库存备注编辑、标签溯源、入库必填校验
后端 - 新增 shop handler:GET/PUT /shop/info(管理员权限) - 新增 finance CloseByRef:按单据 ref_type+ref_id 结清账款 - 新增 inventory UpdateRemark:PUT /inventory/:id/remark - 入库/出库审批自动生成财务应付/应收记录(去除金额>0限制) - 种子数据 S001-S003 补充真实门店信息 前端 - 设置页新增「酒行信息」Tab,管理员可编辑门店名称/地址/电话/负责人 - 入库单列表新增结清按钮(含确认弹窗),出库单同步 - 入库表单:规格、系列、生产日期、供应商、商品名称改为提交必填 - 入库/出库列表新增入库时间、出库时间、创建时间列 - 商品标签标题改为读取 shop 表门店名,扫码文案改为「扫码溯源 · TRACE」 - 标签页脚显示门店地址和电话(从 API 读取,不再依赖编译时 dart-define) - 库存备注支持点击编辑,超4字截断显示+Hover展示全文 - ApiClient 新增 patch() 方法(已改用 PUT 规避 CORS) 文档 - 新增 docs/user-manual.md 完整用户操作手册(12章) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -20,11 +21,11 @@ func NewStockService(db *gorm.DB) *StockService {
|
||||
return &StockService{db: db}
|
||||
}
|
||||
|
||||
// ApproveStockIn 审核入库单,审核通过后更新库存(事务)
|
||||
// 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").
|
||||
if err := tx.Preload("Items.Product").Preload("Warehouse").Preload("Partner").
|
||||
Where("id = ? AND shop_id = ?", orderID, shopID).
|
||||
First(&order).Error; err != nil {
|
||||
return err
|
||||
@@ -33,10 +34,102 @@ func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error
|
||||
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 {
|
||||
if err := s.updateInventory(tx, shopID, order.WarehouseID, item.ProductID,
|
||||
"in", item.Quantity, orderID, "stock_in", reviewerID); err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -49,7 +142,7 @@ func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error
|
||||
})
|
||||
}
|
||||
|
||||
// ApproveStockOut 审核出库单
|
||||
// ApproveStockOut 审核出库单,FIFO 扣减批次库存
|
||||
func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.StockOutOrder
|
||||
@@ -62,24 +155,84 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error
|
||||
return errors.New("order is not in pending status")
|
||||
}
|
||||
|
||||
// 预检库存(FOR UPDATE 加锁,防止并发审核超卖)
|
||||
now := time.Now()
|
||||
warehouseID := order.WarehouseID
|
||||
|
||||
for _, item := range order.Items {
|
||||
var inv model.Inventory
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shopID, order.WarehouseID, item.ProductID).First(&inv).Error; err != nil {
|
||||
return fmt.Errorf("product %d not in inventory", item.ProductID)
|
||||
}
|
||||
if inv.Quantity < item.Quantity {
|
||||
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, item.ProductID, inv.Quantity, item.Quantity)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, item := range order.Items {
|
||||
if err := s.updateInventory(tx, shopID, order.WarehouseID, item.ProductID,
|
||||
"out", item.Quantity, orderID, "stock_out", reviewerID); err != nil {
|
||||
// 自动创建应收账款财务记录
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -92,51 +245,17 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error
|
||||
})
|
||||
}
|
||||
|
||||
// updateInventory 更新库存并写流水(在事务中调用)
|
||||
func (s *StockService) updateInventory(tx *gorm.DB, shopID, warehouseID, productID uint64,
|
||||
direction string, qty float64, refID uint64, refType string, operatorID uint64) error {
|
||||
|
||||
var inv model.Inventory
|
||||
result := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shopID, warehouseID, productID).First(&inv)
|
||||
|
||||
qtyBefore := inv.Quantity
|
||||
var qtyAfter float64
|
||||
|
||||
if direction == "in" {
|
||||
qtyAfter = qtyBefore + qty
|
||||
if result.Error != nil {
|
||||
// 不存在则创建
|
||||
inv = model.Inventory{ShopID: shopID, WarehouseID: warehouseID, ProductID: productID, Quantity: qtyAfter}
|
||||
if err := tx.Create(&inv).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Model(&inv).Update("quantity", qtyAfter).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
qtyAfter = qtyBefore - qty
|
||||
if err := tx.Model(&inv).Update("quantity", qtyAfter).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
q = q.Where("partner_id IS NULL")
|
||||
}
|
||||
|
||||
log := model.InventoryLog{
|
||||
ShopID: shopID,
|
||||
WarehouseID: warehouseID,
|
||||
ProductID: productID,
|
||||
Direction: direction,
|
||||
Quantity: qty,
|
||||
QtyBefore: qtyBefore,
|
||||
QtyAfter: qtyAfter,
|
||||
RefType: refType,
|
||||
RefID: refID,
|
||||
OperatorID: &operatorID,
|
||||
}
|
||||
return tx.Create(&log).Error
|
||||
q.Order("id DESC").First(&last)
|
||||
return last.Balance
|
||||
}
|
||||
|
||||
// GenerateOrderNo 生成单号(事务安全,FOR UPDATE 防止并发重复单号)
|
||||
@@ -147,8 +266,16 @@ func (s *StockService) GenerateOrderNo(shopID uint64, orderType string) (string,
|
||||
result := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Where("shop_id = ? AND type = ?", shopID, orderType).First(&rule)
|
||||
if result.Error != nil {
|
||||
// 初始化规则
|
||||
rule = model.NumberRule{ShopID: shopID, Type: orderType, Prefix: orderType[:2], CurrentNo: 0}
|
||||
// 初始化规则(使用中文惯用前缀)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user