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

569 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controllers
import (
"fmt"
"sort"
"strings"
"time"
"server/models"
)
// ERP 报表/看板核心(两端共用):统计口径只认「已生效」单据与库存流水,
// 草稿/审批中/作废一律不参与,这样 Web 端与手机端看到的金额、毛利、库存预警数完全一致。
// erpReportDimension 归一购销汇总维度:只认 product|supplier|customer|month,
// 其它一律回落到商品,出参的 dimension 字段即后端真实使用的分组。
func erpReportDimension(raw string) string {
switch g := strings.TrimSpace(raw); g {
case "supplier", "customer", "month":
return g
default:
return "product"
}
}
// erpWarningCounts 库存预警各类计数(看板角标用,不含明细)
func erpWarningCounts(tid string) map[string]int {
var products []models.ErpProduct
_, _ = models.Orm.QueryTable(new(models.ErpProduct)).
Filter("tenant_id", tid).Filter("status", 1).Filter("is_draft", 0).
Filter("delete_time__isnull", true).All(&products, "id", "safety_qty", "max_qty")
var stocks []models.ErpStock
_, _ = models.Orm.QueryTable(new(models.ErpStock)).Filter("tenant_id", tid).
All(&stocks, "product_id", "quantity")
qtyMap := map[uint64]float64{}
for _, s := range stocks {
qtyMap[s.ProductID] += s.Quantity
}
low, over, out := 0, 0, 0
for _, p := range products {
q := qtyMap[p.ID]
if p.SafetyQty > 0 && q < p.SafetyQty {
low++
if q <= 0.000001 {
out++
}
}
if p.MaxQty > 0 && q > p.MaxQty {
over++
}
}
return map[string]int{"low": low, "over": over, "out": out, "expiring": len(erpExpiringBatches(tid, 30))}
}
// erpDocAmount 区间内已生效单据的金额合计与单数
func erpDocAmount(tid string, docTypes []string, from, to time.Time) (float64, int64) {
ids := erpApprovedDocIDs(tid, docTypes, from, to, 0)
if len(ids) == 0 {
return 0, 0
}
var docs []models.ErpDocument
_, _ = models.Orm.QueryTable(new(models.ErpDocument)).
Filter("id__in", ids).All(&docs, "id", "actual_amount")
var sum float64
for _, d := range docs {
sum += d.ActualAmount
}
return round2f(sum), int64(len(docs))
}
// erpDashboardTrend 近 6 个月采购/销售趋势
type erpDashboardTrend struct {
Month string `json:"month"`
Buy float64 `json:"buy"`
Sale float64 `json:"sale"`
}
// erpDashboardCore 经营看板:本月购销/毛利、今日销售、库存价值、往来余额、待办、趋势、预警
func erpDashboardCore(a erpActor) map[string]interface{} {
tid := a.Tid
monthFrom, monthTo := erpMonthRange(0)
// 不能用 time.Now().Truncate(24*time.Hour):它按「自零时起算的绝对时长」取整,
// 在东八区得到的是 UTC 零点(本地 08:00),会把当天 00:00–08:00 的单据漏掉。
now := time.Now()
todayFrom := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
buyAmt, buyCnt := erpDocAmount(tid, []string{models.ErpDocPurchaseIn}, monthFrom, monthTo)
saleAmt, saleCnt := erpDocAmount(tid, []string{models.ErpDocSalesOut}, monthFrom, monthTo)
backAmt, _ := erpDocAmount(tid, []string{models.ErpDocSalesReturn}, monthFrom, monthTo)
todaySale, todayCnt := erpDocAmount(tid, []string{models.ErpDocSalesOut}, todayFrom, todayFrom.AddDate(0, 0, 1))
// 本月毛利(出库流水成本口径)
var ledgers []models.ErpStockLedger
_, _ = models.Orm.QueryTable(new(models.ErpStockLedger)).
Filter("tenant_id", tid).
Filter("doc_type__in", []string{models.ErpDocSalesOut, models.ErpDocSalesReturn}).
Filter("create_time__gte", monthFrom).Filter("create_time__lt", monthTo).
All(&ledgers, "direction", "total_cost")
var cost float64
for _, l := range ledgers {
if l.Direction == models.ErpStockOut {
cost += l.TotalCost
} else {
cost -= l.TotalCost
}
}
profit := saleAmt - backAmt - cost
var stocks []models.ErpStock
_, _ = models.Orm.QueryTable(new(models.ErpStock)).Filter("tenant_id", tid).
All(&stocks, "quantity", "cost_price")
var stockValue, stockQty float64
for _, s := range stocks {
stockValue += s.Quantity * s.CostPrice
stockQty += s.Quantity
}
var bills []models.ErpPartnerBill
_, _ = models.Orm.QueryTable(new(models.ErpPartnerBill)).
Filter("tenant_id", tid).Filter("status__lt", 2).
All(&bills, "bill_type", "amount", "settled_amount")
var recv, pay float64
for _, b := range bills {
if b.BillType == models.ErpBillReceivable {
recv += b.Amount - b.SettledAmount
} else {
pay += b.Amount - b.SettledAmount
}
}
// 待办:待我审批的进销存单据 + 草稿单据数
pendingTasks := erpErpPendingTaskCount(a.TidNum, a.UID)
var draftCnt int64
if n, e := models.Orm.QueryTable(new(models.ErpDocument)).
Filter("tenant_id", tid).Filter("status", models.ErpDocStatusDraft).
Filter("delete_time__isnull", true).Count(); e == nil {
draftCnt = n
}
trendList := make([]erpDashboardTrend, 0, 6)
for i := 5; i >= 0; i-- {
f, t := erpMonthRange(-i)
b, _ := erpDocAmount(tid, []string{models.ErpDocPurchaseIn}, f, t)
s, _ := erpDocAmount(tid, []string{models.ErpDocSalesOut}, f, t)
trendList = append(trendList, erpDashboardTrend{Month: f.Format("2006-01"), Buy: b, Sale: s})
}
return map[string]interface{}{
"month": map[string]interface{}{
"purchase_amount": buyAmt, "purchase_count": buyCnt,
"sales_amount": saleAmt, "sales_count": saleCnt,
"return_amount": backAmt, "cost": round2f(cost),
"profit": round2f(profit),
"profit_rate": func() float64 {
if saleAmt-backAmt > 0.005 {
return round2f(profit / (saleAmt - backAmt) * 100)
}
return 0
}(),
},
"today": map[string]interface{}{"sales_amount": todaySale, "sales_count": todayCnt},
"stock": map[string]interface{}{"value": round2f(stockValue), "qty": round4f(stockQty)},
"partner_bill": map[string]interface{}{
"receivable": round2f(recv), "payable": round2f(pay), "net": round2f(recv - pay),
},
"todo": map[string]interface{}{"approval_pending": pendingTasks, "draft_docs": draftCnt},
"trend": trendList,
"warning": erpWarningCounts(tid),
}
}
// erpReportRow 汇总报表行(按商品/往来单位/月份聚合后的结果)
type erpReportRow struct {
Key string `json:"key"`
Label string `json:"label"`
Qty float64 `json:"qty"`
Amount float64 `json:"amount"`
Lines int64 `json:"lines"`
}
// erpSummaryReportData 购销汇总:按商品/往来单位/月份三种维度聚合,退货行做负向冲减
func erpSummaryReportData(group string, items []models.ErpDocumentItem,
docPartner map[uint64]models.ErpDocument, returnItems []models.ErpDocumentItem) map[string]interface{} {
agg := map[string]*erpReportRow{}
add := func(key, label string, qty, amount float64) {
r := agg[key]
if r == nil {
r = &erpReportRow{Key: key, Label: label}
agg[key] = r
}
r.Qty += qty
r.Amount += amount
r.Lines++
}
bucket := func(it models.ErpDocumentItem) (string, string) {
d := docPartner[it.DocID]
switch group {
case "supplier", "customer":
return fmt.Sprintf("%d", d.PartnerID), d.PartnerName
case "month":
return d.BizDate.Format("2006-01"), d.BizDate.Format("2006-01")
default:
return fmt.Sprintf("%d", it.ProductID), it.ProductName + " " + it.Spec
}
}
for _, it := range items {
k, lb := bucket(it)
add(k, lb, it.Quantity, it.Amount)
}
// 退货按同维度负向冲减,净额与往来账口径一致
for _, it := range returnItems {
k, lb := bucket(it)
if group == "supplier" || group == "customer" {
d := docPartner[it.DocID]
if d.PartnerID == 0 {
continue
}
}
add(k, lb, -it.Quantity, -it.Amount)
}
list := make([]*erpReportRow, 0, len(agg))
var tQty, tAmount float64
for _, r := range agg {
r.Qty = round4f(r.Qty)
r.Amount = round2f(r.Amount)
if absFloat(r.Amount) < 0.005 && absFloat(r.Qty) < 0.000001 {
continue
}
list = append(list, r)
tQty += r.Qty
tAmount += r.Amount
}
sort.Slice(list, func(i, j int) bool {
if list[i].Amount != list[j].Amount {
return list[i].Amount > list[j].Amount
}
return list[i].Key < list[j].Key // 金额相同(如对冲后同为 0)时按维度键定序,避免两次请求行序抖动
})
return map[string]interface{}{
"list": list, "dimension": group,
"summary": map[string]interface{}{"qty": round4f(tQty), "amount": round2f(tAmount)},
}
}
// erpSalesRankRow 热销/滞销排行行
type erpSalesRankRow struct {
ProductID uint64 `json:"product_id"`
ProductName string `json:"product_name"`
ProductCode string `json:"product_code"`
Spec string `json:"spec"`
Unit string `json:"unit"`
Qty float64 `json:"qty"`
Amount float64 `json:"amount"`
}
// erpSalesRankCore 热销(desc)/滞销(asc)排行;滞销榜需要把「一件没卖」的在售商品也排进来
func erpSalesRankCore(a erpActor, from, to time.Time, top int, asc bool) (map[string]interface{}, int, string) {
if top < 1 || top > 50 {
top = 10
}
tid := a.Tid
ids := erpApprovedDocIDs(tid, []string{models.ErpDocSalesOut}, from, to, 0)
items := erpItemsByDocs(tid, ids)
agg := map[uint64]*erpSalesRankRow{}
for _, it := range items {
r := agg[it.ProductID]
if r == nil {
r = &erpSalesRankRow{ProductID: it.ProductID, ProductName: it.ProductName, Spec: it.Spec, Unit: it.Unit}
agg[it.ProductID] = r
}
r.Qty += it.Quantity
r.Amount += it.Amount
}
if asc {
var ps []models.ErpProduct
_, _ = models.Orm.QueryTable(new(models.ErpProduct)).
Filter("tenant_id", tid).Filter("status", 1).Filter("is_draft", 0).
Filter("delete_time__isnull", true).All(&ps, "id", "product_name", "spec", "base_unit")
for _, p := range ps {
if agg[p.ID] == nil {
agg[p.ID] = &erpSalesRankRow{ProductID: p.ID, ProductName: p.ProductName, Spec: p.Spec, Unit: p.BaseUnit}
}
}
}
pmap := erpProductMap(tid, keysOfU64(agg))
list := make([]*erpSalesRankRow, 0, len(agg))
for _, r := range agg {
r.ProductCode = pmap[r.ProductID].ProductCode
r.Qty = round4f(r.Qty)
r.Amount = round2f(r.Amount)
list = append(list, r)
}
sort.Slice(list, func(i, j int) bool {
if asc {
if list[i].Amount != list[j].Amount {
return list[i].Amount < list[j].Amount
}
if list[i].Qty != list[j].Qty {
return list[i].Qty < list[j].Qty
}
} else {
if list[i].Amount != list[j].Amount {
return list[i].Amount > list[j].Amount
}
if list[i].Qty != list[j].Qty {
return list[i].Qty > list[j].Qty
}
}
return list[i].ProductID < list[j].ProductID
})
if len(list) > top {
list = list[:top]
}
return map[string]interface{}{
"list": list, "order": map[bool]string{true: "asc", false: "desc"}[asc],
"range": []string{from.Format("2006-01-02"), to.AddDate(0, 0, -1).Format("2006-01-02")},
}, 200, ""
}
// erpSummaryPurchaseCore / erpSummarySalesCore 购销汇总取数:两端按维度调用同一份聚合
func erpSummaryDocs(tid, docType, returnType string, from, to time.Time, partnerID uint64) (
[]models.ErpDocumentItem, map[uint64]models.ErpDocument, []models.ErpDocumentItem) {
ids := erpApprovedDocIDs(tid, []string{docType}, from, to, partnerID)
rids := erpApprovedDocIDs(tid, []string{returnType}, from, to, partnerID)
items := erpItemsByDocs(tid, ids)
returnItems := erpItemsByDocs(tid, rids)
return items, erpDocPartnerMap(append(append([]uint64{}, ids...), rids...)), returnItems
}
// erpStockMoveRow 收发存汇总行:期末取自库存结存,本期收发取自库存流水,
// 期初=期末-(入-出),保证与账实一致。
type erpStockMoveRow 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"`
OpenQty float64 `json:"open_qty"`
OpenAmount float64 `json:"open_amount"`
InQty float64 `json:"in_qty"`
InAmount float64 `json:"in_amount"`
OutQty float64 `json:"out_qty"`
OutAmount float64 `json:"out_amount"`
EndQty float64 `json:"end_qty"`
EndCost float64 `json:"end_cost"`
EndAmount float64 `json:"end_amount"`
}
// erpStockMoveCore 收发存汇总表(分页 + 全量合计)
func erpStockMoveCore(a erpActor, from, to time.Time, warehouseID uint64, keyword string,
page, pageSize int) ([]erpStockMoveRow, int64, map[string]float64, int, string) {
tid := a.Tid
var stocks []models.ErpStock
qs := models.Orm.QueryTable(new(models.ErpStock)).Filter("tenant_id", tid)
if warehouseID > 0 {
qs = qs.Filter("warehouse_id", warehouseID)
}
_, _ = qs.All(&stocks)
var ledgers []models.ErpStockLedger
lqs := models.Orm.QueryTable(new(models.ErpStockLedger)).
Filter("tenant_id", tid).
Filter("create_time__gte", from).Filter("create_time__lt", to)
if warehouseID > 0 {
lqs = lqs.Filter("warehouse_id", warehouseID)
}
_, _ = lqs.All(&ledgers)
type key struct {
productID, warehouseID uint64
}
endQty := map[key]float64{}
endCost := map[key]float64{}
endValue := map[key]float64{}
keys := make([]key, 0, len(stocks))
for _, s := range stocks {
k := key{s.ProductID, s.WarehouseID}
if _, hit := endQty[k]; !hit {
keys = append(keys, k)
}
endQty[k] += s.Quantity
endValue[k] += s.Quantity * s.CostPrice
endCost[k] = s.CostPrice
}
inQty := map[key]float64{}
inAmt := map[key]float64{}
outQty := map[key]float64{}
outAmt := map[key]float64{}
for _, l := range ledgers {
k := key{l.ProductID, l.WarehouseID}
if l.Direction == models.ErpStockIn {
inQty[k] += l.Quantity
inAmt[k] += l.TotalCost
} else {
outQty[k] += l.Quantity
outAmt[k] += l.TotalCost
}
// 区间内有流水但当前无结存的组合也要出现在表里(已全部出库)
found := false
for _, e := range keys {
if e == k {
found = true
break
}
}
if !found {
keys = append(keys, k)
endQty[k] = 0
}
}
ids := make([]uint64, 0, len(keys))
for _, k := range keys {
ids = append(ids, k.productID)
}
pmap := erpProductMap(tid, ids)
wmap := erpWarehouseNameMap(tid)
cats := erpCategoryMap(tid)
list := make([]erpStockMoveRow, 0, len(keys))
for _, k := range keys {
p := pmap[k.productID]
openQty := endQty[k] - (inQty[k] - outQty[k])
openAmt := endValue[k] - (inAmt[k] - outAmt[k])
cost := endCost[k]
if endQty[k] > 0.000001 {
cost = endValue[k] / endQty[k]
}
list = append(list, erpStockMoveRow{
ProductID: k.productID, ProductCode: p.ProductCode, ProductName: p.ProductName,
Spec: p.Spec, Unit: p.BaseUnit, CategoryName: cats[p.CategoryID],
WarehouseID: k.warehouseID, WarehouseName: wmap[k.warehouseID],
OpenQty: round4f(openQty), OpenAmount: round2f(openAmt),
InQty: round4f(inQty[k]), InAmount: round2f(inAmt[k]),
OutQty: round4f(outQty[k]), OutAmount: round2f(outAmt[k]),
EndQty: round4f(endQty[k]), EndCost: round2f(cost), EndAmount: round2f(endValue[k]),
})
}
if kw := strings.TrimSpace(keyword); kw != "" {
list = erpFilterRows(list, func(r erpStockMoveRow) bool {
return strings.Contains(r.ProductName, kw) || strings.Contains(r.ProductCode, kw)
})
}
sort.Slice(list, func(i, j int) bool {
if list[i].ProductName != list[j].ProductName {
return list[i].ProductName < list[j].ProductName
}
if list[i].WarehouseName != list[j].WarehouseName {
return list[i].WarehouseName < list[j].WarehouseName
}
if list[i].ProductID != list[j].ProductID {
return list[i].ProductID < list[j].ProductID
}
return list[i].WarehouseID < list[j].WarehouseID
})
sum := erpStockMoveRow{}
for _, r := range list {
sum.InQty += r.InQty
sum.InAmount += r.InAmount
sum.OutQty += r.OutQty
sum.OutAmount += r.OutAmount
sum.EndAmount += r.EndAmount
}
summary := map[string]float64{
"in_qty": round4f(sum.InQty), "in_amount": round2f(sum.InAmount),
"out_qty": round4f(sum.OutQty), "out_amount": round2f(sum.OutAmount),
"end_amount": round2f(sum.EndAmount),
}
start, end := erpPageWindow(len(list), page, pageSize)
return list[start:end], int64(len(list)), summary, 200, ""
}
// erpProfitRow 毛利分析行:收入=销售出库金额-销售退货金额,
// 成本取库存流水的实际出库成本(移动加权),不受手工录入影响。
type erpProfitRow struct {
ProductID uint64 `json:"product_id"`
ProductCode string `json:"product_code"`
ProductName string `json:"product_name"`
Spec string `json:"spec"`
Unit string `json:"unit"`
Qty float64 `json:"qty"`
Revenue float64 `json:"revenue"`
Cost float64 `json:"cost"`
Profit float64 `json:"profit"`
ProfitRate float64 `json:"profit_rate"`
}
// erpProfitCore 毛利分析(按商品)
func erpProfitCore(a erpActor, from, to time.Time) (map[string]interface{}, int, string) {
tid := a.Tid
outIDs := erpApprovedDocIDs(tid, []string{models.ErpDocSalesOut}, from, to, 0)
backIDs := erpApprovedDocIDs(tid, []string{models.ErpDocSalesReturn}, from, to, 0)
revenue := map[uint64]float64{}
qty := map[uint64]float64{}
names := map[uint64]models.ErpDocumentItem{}
for _, it := range erpItemsByDocs(tid, outIDs) {
revenue[it.ProductID] += it.Amount
qty[it.ProductID] += it.Quantity
names[it.ProductID] = it
}
for _, it := range erpItemsByDocs(tid, backIDs) {
revenue[it.ProductID] -= it.Amount
qty[it.ProductID] -= it.Quantity
if _, hit := names[it.ProductID]; !hit {
names[it.ProductID] = it
}
}
var ledgers []models.ErpStockLedger
_, _ = models.Orm.QueryTable(new(models.ErpStockLedger)).
Filter("tenant_id", tid).
Filter("doc_type__in", []string{models.ErpDocSalesOut, models.ErpDocSalesReturn}).
Filter("create_time__gte", from).Filter("create_time__lt", to).
All(&ledgers)
cost := map[uint64]float64{}
for _, l := range ledgers {
if l.Direction == models.ErpStockOut {
cost[l.ProductID] += l.TotalCost
} else {
cost[l.ProductID] -= l.TotalCost
}
}
pmap := erpProductMap(tid, keysOfU64(revenue))
list := make([]erpProfitRow, 0, len(revenue))
var tRev, tCost, tQty float64
for pid, rv := range revenue {
p := pmap[pid]
cst := cost[pid]
prof := rv - cst
rate := 0.0
if rv > 0.005 {
rate = round2f(prof / rv * 100)
}
list = append(list, erpProfitRow{
ProductID: pid, ProductCode: p.ProductCode, ProductName: p.ProductName,
Spec: p.Spec, Unit: p.BaseUnit, Qty: round4f(qty[pid]),
Revenue: round2f(rv), Cost: round2f(cst), Profit: round2f(prof), ProfitRate: rate,
})
tRev += rv
tCost += cst
tQty += qty[pid]
}
sort.Slice(list, func(i, j int) bool {
if list[i].Profit != list[j].Profit {
return list[i].Profit > list[j].Profit
}
return list[i].ProductID < list[j].ProductID
})
totalProfit := tRev - tCost
rate := 0.0
if tRev > 0.005 {
rate = round2f(totalProfit / tRev * 100)
}
return map[string]interface{}{
"list": list,
"summary": map[string]interface{}{
"revenue": round2f(tRev), "cost": round2f(tCost),
"profit": round2f(totalProfit), "profit_rate": rate, "qty": round4f(tQty),
},
}, 200, ""
}