190 lines
5.6 KiB
Go
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
|
|
}
|