Files
yunzerwebsiteallinone/go/controllers/backend_erp_stock.go
T
2026-10-19 01:13:17 +08:00

378 lines
12 KiB
Go

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
}