Files
yunzerwebsiteallinone/go/services/erp/stock.go
T
2026-10-19 01:13:17 +08:00

190 lines
5.6 KiB
Go

package erp
import (
"fmt"
"math"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
)
// Movement 一次库存变动的入参。库存结存只能由本引擎经流水驱动。
type Movement struct {
TenantID string
ProductID uint64
WarehouseID uint64
BatchNo string
Direction int8 // models.ErpStockIn / ErpStockOut
Qty float64
UnitCost float64 // 入库=进价/成本;出库传 0 表示按当前加权成本
DocType string
DocID uint64
DocNo string
DocItemID uint64
OperatorID string
OperatorName string
Remark string
// 批次效期随入库流水落地,出库/调拨沿用同批次快照
ProductionDate *time.Time
ExpireDate *time.Time
}
// StockError 库存不足等业务错误(调用方可据 ErrInsufficientStock 区分文案)。
type StockError struct {
ProductName string
Requested float64
Available float64
Insufficient bool
}
func (e *StockError) Error() string {
if e.Insufficient {
return fmt.Sprintf("商品「%s」库存不足:可用 %.4f,本次需要 %.4f", e.ProductName, e.Available, e.Requested)
}
return fmt.Sprintf("商品「%s」库存异常", e.ProductName)
}
// ApplyMovement 在事务内执行一条库存变动:
// 1. 定位/创建结存行(tenant+product+warehouse+batch 唯一);
// 2. 出库时校验可用量(负库存开关取自基础设置);
// 3. 入库按移动加权平均重算单位成本;出库成本=出库时加权成本(记入流水,毛利口径);
// 4. 写库存流水 + 更新结存。
//
// 返回出库时点的单位成本(销售成本/退货回冲依据)。
func ApplyMovement(o orm.QueryExecutor, mv *Movement) (float64, error) {
if mv.Qty <= 0 {
return 0, fmt.Errorf("库存变动数量必须大于0")
}
if mv.WarehouseID == 0 || mv.ProductID == 0 {
return 0, fmt.Errorf("库存变动缺少商品或仓库")
}
settings := GetSettings(mv.TenantID)
var product models.ErpProduct
if err := o.QueryTable(new(models.ErpProduct)).
Filter("id", mv.ProductID).Filter("tenant_id", mv.TenantID).One(&product); err != nil {
return 0, fmt.Errorf("商品不存在(id=%d)", mv.ProductID)
}
var stock models.ErpStock
err := o.QueryTable(new(models.ErpStock)).
Filter("tenant_id", mv.TenantID).
Filter("product_id", mv.ProductID).
Filter("warehouse_id", mv.WarehouseID).
Filter("batch_no", mv.BatchNo).
ForUpdate().One(&stock)
if err == orm.ErrNoRows {
stock = models.ErpStock{
TenantID: mv.TenantID, ProductID: mv.ProductID,
WarehouseID: mv.WarehouseID, BatchNo: mv.BatchNo,
Quantity: 0, CostPrice: product.PurchasePrice,
UpdateTime: time.Now(),
}
id, ierr := o.Insert(&stock)
if ierr != nil {
return 0, fmt.Errorf("初始化库存结存失败: %v", ierr)
}
stock.ID = uint64(id)
} else if err != nil {
return 0, fmt.Errorf("查询库存失败: %v", err)
}
before := stock.Quantity
dir := mv.Direction
unitCost := mv.UnitCost
outCost := stock.CostPrice // 出库成本口径:当前加权成本
if dir == models.ErpStockOut {
if !settings.NegativeAllowed && before-mv.Qty < -0.000001 {
return 0, &StockError{
ProductName: product.ProductName,
Requested: mv.Qty,
Available: before,
Insufficient: true,
}
}
after := before - mv.Qty
if unitCost <= 0 {
unitCost = outCost
}
if err := writeLedger(o, mv, dir, before, after, unitCost); err != nil {
return 0, err
}
if _, err := o.QueryTable(new(models.ErpStock)).Filter("id", stock.ID).
Update(orm.Params{"quantity": after, "update_time": time.Now()}); err != nil {
return 0, fmt.Errorf("更新库存结存失败: %v", err)
}
return unitCost, nil
}
// 入库:移动加权平均
if unitCost <= 0 {
unitCost = product.PurchasePrice
}
after := before + mv.Qty
newCost := unitCost
if after > 0.000001 {
newCost = (before*stock.CostPrice + mv.Qty*unitCost) / after
}
if err := writeLedger(o, mv, dir, before, after, unitCost); err != nil {
return 0, err
}
if _, err := o.QueryTable(new(models.ErpStock)).Filter("id", stock.ID).
Update(orm.Params{"quantity": after, "cost_price": newCost, "update_time": time.Now()}); err != nil {
return 0, fmt.Errorf("更新库存结存失败: %v", err)
}
return unitCost, nil
}
func writeLedger(o orm.QueryExecutor, mv *Movement, dir int8, before, after, unitCost float64) error {
now := time.Now()
ledger := models.ErpStockLedger{
TenantID: mv.TenantID, ProductID: mv.ProductID, WarehouseID: mv.WarehouseID,
BatchNo: mv.BatchNo, Direction: dir, Quantity: mv.Qty,
BeforeQty: before, AfterQty: after,
UnitCost: unitCost, TotalCost: round2(unitCost * mv.Qty),
DocType: mv.DocType, DocID: mv.DocID, DocNo: mv.DocNo, DocItemID: mv.DocItemID,
ProductionDate: mv.ProductionDate, ExpireDate: mv.ExpireDate,
OperatorID: mv.OperatorID, OperatorName: mv.OperatorName,
Remark: mv.Remark, CreateTime: now,
}
if _, err := o.Insert(&ledger); err != nil {
return fmt.Errorf("写入库存流水失败: %v", err)
}
return nil
}
// RefOutboundCost 取某条出库流水记录的出库成本(销售退货回冲入库成本用)。
func RefOutboundCost(o orm.QueryExecutor, tenantID string, docItemID uint64) float64 {
if docItemID == 0 {
return 0
}
var led models.ErpStockLedger
if err := o.QueryTable(new(models.ErpStockLedger)).
Filter("tenant_id", tenantID).
Filter("doc_item_id", docItemID).
Filter("direction", models.ErpStockOut).
OrderBy("-id").One(&led); err != nil {
return 0
}
return led.UnitCost
}
func round2(v float64) float64 {
return math.Round(v*100) / 100
}
func round4(v float64) float64 {
return math.Round(v*10000) / 10000
}
func absf(v float64) float64 {
if v < 0 {
return -v
}
return v
}