This commit is contained in:
hero920103 committed 2026-10-19 01:13:17 +08:00
1 parent 5718a00e31
commit 1850ac640e
226 files changed
+37772 -12071

No files matched your search

+568
View File
@@ -0,0 +1,568 @@
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, ""
}