package controllers import ( "strconv" "strings" "time" "server/models" ) // BackendErpStockController 库存查询、流水追溯与预警 // // 结存只能由流水驱动,因此这里全部是只读接口:任何"改库存"的需求都必须 // 通过其他出入库/调拨/盘点单据生效来完成。 // 查询与算法在 erp_stock_core.go,与移动端 /api/erp/stock* 共用同一份实现。 type BackendErpStockController struct { BackendErpBaseController } // List GET /backend/erp/stock/list 库存结存(可按商品聚合或按批次展开) func (c *BackendErpStockController) List() { cl, err := c.claims() if c.denied(err) { return } page, pageSize := c.pageArgs() f := erpStockFilter{ WarehouseID: erpUintParam(c.GetString("warehouse_id")), ProductID: erpUintParam(c.GetString("product_id")), BatchNo: strings.TrimSpace(c.GetString("batch_no")), OnlyStock: strings.TrimSpace(c.GetString("only_stock")) == "1", GroupByProduct: strings.TrimSpace(c.GetString("group_by")) == "product", Keyword: strings.TrimSpace(c.GetString("keyword")), CategoryID: erpUintParam(c.GetString("category_id")), SafetyState: strings.TrimSpace(c.GetString("safety_state")), } list, total, sumAmount, code, msg := erpStockListCore(erpNewActor(cl), f, page, pageSize) if msg != "" { c.fail(code, code, msg) return } c.ok(map[string]interface{}{ "list": list, "total": total, "page": page, "pageSize": pageSize, "sum_amount": sumAmount, }) } // Ledger GET /backend/erp/stock/ledger 库存流水(收发存明细追溯) func (c *BackendErpStockController) Ledger() { cl, err := c.claims() if c.denied(err) { return } page, pageSize := c.pageArgs() f := erpLedgerFilter{ ProductID: erpUintParam(c.GetString("product_id")), WarehouseID: erpUintParam(c.GetString("warehouse_id")), DocType: strings.TrimSpace(c.GetString("doc_type")), BatchNo: strings.TrimSpace(c.GetString("batch_no")), DocNo: strings.TrimSpace(c.GetString("doc_no")), DateFrom: strings.TrimSpace(c.GetString("date_from")), DateTo: strings.TrimSpace(c.GetString("date_to")), } rows, total, code, msg := erpLedgerCore(erpNewActor(cl), f, page, pageSize) if msg != "" { c.fail(code, code, msg) return } c.listOK(rows, total, page, pageSize) } // Warning GET /backend/erp/stock/warning 库存预警:低于安全库存 / 超储 / 零库存 / 效期临近 func (c *BackendErpStockController) Warning() { cl, err := c.claims() if c.denied(err) { return } data, code, msg := erpWarningCore(erpNewActor(cl)) if msg != "" { c.fail(code, code, msg) return } c.ok(data) } // Replenish GET /backend/erp/stock/replenish 智能补货建议(按供应商分组,可一键生成采购订单) func (c *BackendErpStockController) Replenish() { cl, err := c.claims() if c.denied(err) { return } rows, code, msg := erpReplenishCore(erpNewActor(cl)) if msg != "" { c.fail(code, code, msg) return } c.ok(map[string]interface{}{"list": rows, "total": len(rows)}) } // Batch GET /backend/erp/stock/batch 批次与效期台账 // 支持 warehouse_id / product_id / keyword / expire_state(expired|expiring|normal) 过滤。 func (c *BackendErpStockController) Batch() { cl, err := c.claims() if c.denied(err) { return } page, pageSize := c.pageArgs() f := erpBatchFilter{ WarehouseID: erpUintParam(c.GetString("warehouse_id")), ProductID: erpUintParam(c.GetString("product_id")), OnlyPositive: strings.TrimSpace(c.GetString("only_positive")) == "1", Keyword: strings.TrimSpace(c.GetString("keyword")), ExpireState: strings.TrimSpace(c.GetString("expire_state")), } list, total, code, msg := erpBatchCore(erpNewActor(cl), f, page, pageSize) if msg != "" { c.fail(code, code, msg) return } c.listOK(list, total, page, pageSize) } // erpBatchRow 批次台账行;未登记效期时 expire_in_days 为 null type erpBatchRow struct { ProductID uint64 `json:"product_id"` ProductCode string `json:"product_code"` ProductName string `json:"product_name"` Spec string `json:"spec"` Unit string `json:"unit"` WarehouseID uint64 `json:"warehouse_id"` WarehouseName string `json:"warehouse_name"` BatchNo string `json:"batch_no"` Quantity float64 `json:"quantity"` CostPrice float64 `json:"cost_price"` Amount float64 `json:"amount"` ExpireDate string `json:"expire_date"` ExpireInDays *int `json:"expire_in_days"` Expired bool `json:"expired"` } // erpStockRow 库存结存行(列表按仓库+批次展开,或按商品聚合) type erpStockRow struct { ProductID uint64 `json:"product_id"` ProductCode string `json:"product_code"` ProductName string `json:"product_name"` Spec string `json:"spec"` Unit string `json:"unit"` CategoryName string `json:"category_name"` WarehouseID uint64 `json:"warehouse_id"` WarehouseName string `json:"warehouse_name"` BatchNo string `json:"batch_no"` Quantity float64 `json:"quantity"` CostPrice float64 `json:"cost_price"` Amount float64 `json:"amount"` SafetyQty float64 `json:"safety_qty"` MaxQty float64 `json:"max_qty"` SafetyState string `json:"safety_state"` } // erpWarnRow 预警行:低库存/积压/缺货/效期共用一套字段 type erpWarnRow struct { ProductID uint64 `json:"product_id"` ProductCode string `json:"product_code"` ProductName string `json:"product_name"` Spec string `json:"spec"` Unit string `json:"unit"` CategoryName string `json:"category_name"` WarehouseName string `json:"warehouse_name"` BatchNo string `json:"batch_no"` Quantity float64 `json:"quantity"` SafetyQty float64 `json:"safety_qty"` MaxQty float64 `json:"max_qty"` InTransit float64 `json:"in_transit"` Type string `json:"type"` TypeLabel string `json:"type_label"` SuggestQty float64 `json:"suggest_qty"` ExpireDate string `json:"expire_date"` ExpireInDays int `json:"expire_in_days"` } // erpReplenishRow 补货建议行 type erpReplenishRow struct { ProductID uint64 `json:"product_id"` ProductCode string `json:"product_code"` ProductName string `json:"product_name"` Spec string `json:"spec"` Unit string `json:"unit"` Quantity float64 `json:"quantity"` SafetyQty float64 `json:"safety_qty"` InTransit float64 `json:"in_transit"` Sold30 float64 `json:"sold_30"` SuggestQty float64 `json:"suggest_qty"` UnitPrice float64 `json:"unit_price"` Amount float64 `json:"amount"` } // ---- 内部 ---- func erpStockProductIDs(rows []models.ErpStock) []uint64 { seen := map[uint64]bool{} out := make([]uint64, 0, len(rows)) for _, r := range rows { if !seen[r.ProductID] { seen[r.ProductID] = true out = append(out, r.ProductID) } } return out } func erpLedgerProductIDs(rows []models.ErpStockLedger) []uint64 { seen := map[uint64]bool{} out := make([]uint64, 0, len(rows)) for _, r := range rows { if !seen[r.ProductID] { seen[r.ProductID] = true out = append(out, r.ProductID) } } return out } // erpPurchaseInTransit 已生效采购订单的未执行数量(按商品汇总) func erpPurchaseInTransit(tid string) map[uint64]float64 { out := map[uint64]float64{} var docs []models.ErpDocument _, _ = models.Orm.QueryTable(new(models.ErpDocument)). Filter("tenant_id", tid).Filter("doc_type", models.ErpDocPurchaseOrder). Filter("status", models.ErpDocStatusApproved). Filter("delete_time__isnull", true).All(&docs, "id") if len(docs) == 0 { return out } ids := make([]uint64, 0, len(docs)) for _, d := range docs { ids = append(ids, d.ID) } var items []models.ErpDocumentItem _, _ = models.Orm.QueryTable(new(models.ErpDocumentItem)). Filter("tenant_id", tid).Filter("doc_id__in", ids). All(&items, "product_id", "quantity", "executed_qty") for _, it := range items { r := it.Quantity - it.ExecutedQty if r > 0 { out[it.ProductID] += r } } return out } // erpSoldQty 近 n 天销售出库数量(按商品汇总) func erpSoldQty(tid string, days int) map[uint64]float64 { out := map[uint64]float64{} var rows []models.ErpStockLedger _, _ = models.Orm.QueryTable(new(models.ErpStockLedger)). Filter("tenant_id", tid). Filter("doc_type", models.ErpDocSalesOut). Filter("direction", models.ErpStockOut). Filter("create_time__gte", time.Now().AddDate(0, 0, -days)). All(&rows, "product_id", "quantity") for _, r := range rows { out[r.ProductID] += r.Quantity } return out } // erpBatchKey 批次主键:效期属于批次,不随所在仓库变化 func erpBatchKey(productID uint64, batch string) string { return strconv.FormatUint(productID, 10) + "|" + batch } // erpToday 本地零点,用于按日历日计算剩余天数 func erpToday() time.Time { n := time.Now() return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, time.Local) } // erpBatchExpiry 批次到期日表(key = erpBatchKey)。 // // 优先取该批次入库流水登记的最早到期日;没登记但商品配了保质期的, // 按该批次最早入库日推算,这样老数据与手工开单也能纳入效期管控。 func erpBatchExpiry(tid string, productIDs []uint64, pmap map[uint64]models.ErpProduct) map[string]time.Time { out := map[string]time.Time{} if len(productIDs) == 0 { return out } var ledgers []models.ErpStockLedger _, _ = models.Orm.QueryTable(new(models.ErpStockLedger)). Filter("tenant_id", tid). Filter("product_id__in", productIDs). Filter("direction", models.ErpStockIn). Exclude("batch_no", ""). All(&ledgers, "product_id", "batch_no", "expire_date", "create_time") firstIn := map[string]time.Time{} for _, l := range ledgers { if l.BatchNo == "" { continue } k := erpBatchKey(l.ProductID, l.BatchNo) if l.ExpireDate != nil { if cur, hit := out[k]; !hit || l.ExpireDate.Before(cur) { out[k] = *l.ExpireDate } } if cur, hit := firstIn[k]; !hit || l.CreateTime.Before(cur) { firstIn[k] = l.CreateTime } } for k, in := range firstIn { if _, hit := out[k]; hit { continue } head, _, _ := strings.Cut(k, "|") pid, _ := strconv.ParseUint(head, 10, 64) days := pmap[pid].ShelfLifeDays if days <= 0 { continue } out[k] = time.Date(in.Year(), in.Month(), in.Day()+days, 0, 0, 0, 0, time.Local) } return out } // erpExpiringBatches 近 withinDays 天内到期(或已过期)且仍有库存的批次 func erpExpiringBatches(tid string, withinDays int) []erpWarnRow { var stocks []models.ErpStock _, _ = models.Orm.QueryTable(new(models.ErpStock)). Filter("tenant_id", tid).Exclude("batch_no", ""). Filter("quantity__gt", 0).All(&stocks) if len(stocks) == 0 { return nil } ids := erpStockProductIDs(stocks) pmap := erpProductMap(tid, ids) expireMap := erpBatchExpiry(tid, ids, pmap) wmap := erpWarehouseNameMap(tid) today := erpToday() out := make([]erpWarnRow, 0) for _, s := range stocks { exp, hit := expireMap[erpBatchKey(s.ProductID, s.BatchNo)] if !hit { continue } days := int(exp.Sub(today).Hours() / 24) if days > withinDays { continue } p := pmap[s.ProductID] label := "效期临近" typ := "expiring" if days < 0 { label, typ = "已过期", "expired" } out = append(out, erpWarnRow{ ProductID: s.ProductID, ProductCode: p.ProductCode, ProductName: p.ProductName, Spec: p.Spec, Unit: p.BaseUnit, WarehouseName: wmap[s.WarehouseID], BatchNo: s.BatchNo, Quantity: round4f(s.Quantity), Type: typ, TypeLabel: label, ExpireDate: exp.Format("2006-01-02"), ExpireInDays: days, SuggestQty: round4f(s.Quantity), }) } return out } func erpFilterRows[T any](rows []T, keep func(T) bool) []T { out := make([]T, 0, len(rows)) for _, r := range rows { if keep(r) { out = append(out, r) } } return out }