378 lines
12 KiB
Go
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
|
|
}
|