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 }