Files
yunzerwebsiteallinone/go/controllers/erp_report_core.go
T
2026-10-22 17:55:50 +08:00

695 lines
23 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 看板趋势点。Month 保留旧字段(兼容旧页面),Label 为图表 x 轴文案
type erpDashboardTrend struct {
Month string `json:"month"`
Label string `json:"label"`
Buy float64 `json:"buy"`
Sale float64 `json:"sale"`
}
// erpDashboardPeriod 解析看板统计周期:week|month|quarter|year,缺省或非法值回落 month
func erpDashboardPeriod(raw string) string {
switch p := strings.ToLower(strings.TrimSpace(raw)); p {
case "week", "quarter", "year":
return p
default:
return "month"
}
}
// erpPeriodSpan 趋势分桶:Key 用于把单据日期映射到桶,Label 是图表 x 轴文案
type erpPeriodSpan struct {
Key string
Label string
}
// erpPeriodRange 返回周期区间 [from, to)、中文标签与趋势分桶。
// 周/月按天分桶,季度按 3 个月分桶,年度按 12 个月分桶。
func erpPeriodRange(period string, now time.Time) (time.Time, time.Time, string, []erpPeriodSpan) {
switch period {
case "week":
// 周一为一周起点;Go 的 Sunday=0,需要换算成「距本周一的天数」
offset := (int(now.Weekday()) + 6) % 7
from := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, -offset)
names := []string{"周一", "周二", "周三", "周四", "周五", "周六", "周日"}
spans := make([]erpPeriodSpan, 0, 7)
for i := 0; i < 7; i++ {
d := from.AddDate(0, 0, i)
spans = append(spans, erpPeriodSpan{Key: d.Format("2006-01-02"), Label: names[i]})
}
return from, from.AddDate(0, 0, 7), "本周", spans
case "quarter":
q := (int(now.Month()) - 1) / 3
from := time.Date(now.Year(), time.Month(q*3+1), 1, 0, 0, 0, 0, time.Local)
spans := make([]erpPeriodSpan, 0, 3)
for i := 0; i < 3; i++ {
m := from.AddDate(0, i, 0)
spans = append(spans, erpPeriodSpan{Key: m.Format("2006-01"), Label: fmt.Sprintf("%d月", int(m.Month()))})
}
return from, from.AddDate(0, 3, 0), "本季度", spans
case "year":
from := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.Local)
spans := make([]erpPeriodSpan, 0, 12)
for i := 1; i <= 12; i++ {
spans = append(spans, erpPeriodSpan{Key: fmt.Sprintf("%04d-%02d", now.Year(), i), Label: fmt.Sprintf("%d月", i)})
}
return from, from.AddDate(1, 0, 0), "本年", spans
default: // month
from := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local)
to := from.AddDate(0, 1, 0)
spans := make([]erpPeriodSpan, 0, 31)
for d := from; d.Before(to); d = d.AddDate(0, 0, 1) {
spans = append(spans, erpPeriodSpan{Key: d.Format("2006-01-02"), Label: fmt.Sprintf("%d日", d.Day())})
}
return from, to, "本月", spans
}
}
// erpDashboardStats 区间内购销/毛利统计(只认已生效单据与库存流水)
func erpDashboardStats(tid string, from, to time.Time) map[string]interface{} {
buyAmt, buyCnt := erpDocAmount(tid, []string{models.ErpDocPurchaseIn}, from, to)
saleAmt, saleCnt := erpDocAmount(tid, []string{models.ErpDocSalesOut}, from, to)
backAmt, _ := erpDocAmount(tid, []string{models.ErpDocSalesReturn}, from, to)
// 毛利按出库流水成本口径
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, "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
rate := 0.0
if saleAmt-backAmt > 0.005 {
rate = round2f(profit / (saleAmt - backAmt) * 100)
}
return 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": rate,
}
}
// erpDashboardPeriodTrend 当前周期内的购销分布:一次查出单据后按分桶汇总,
// 避免「每个点都打一次库」(年度 12 点、月度 31 点)。
func erpDashboardPeriodTrend(tid string, from, to time.Time, period string, spans []erpPeriodSpan) []erpDashboardTrend {
var docs []models.ErpDocument
_, _ = models.Orm.QueryTable(new(models.ErpDocument)).
Filter("tenant_id", tid).
Filter("doc_type__in", []string{models.ErpDocPurchaseIn, models.ErpDocSalesOut}).
Filter("status", models.ErpDocStatusApproved).
Filter("delete_time__isnull", true).
Filter("biz_date__gte", from).Filter("biz_date__lt", to).
All(&docs, "doc_type", "biz_date", "actual_amount")
index := make(map[string]int, len(spans))
for i, s := range spans {
index[s.Key] = i
}
buy := make([]float64, len(spans))
sale := make([]float64, len(spans))
for _, d := range docs {
key := d.BizDate.Format("2006-01-02")
if period == "quarter" || period == "year" {
key = d.BizDate.Format("2006-01")
}
i, ok := index[key]
if !ok {
continue
}
if d.DocType == models.ErpDocPurchaseIn {
buy[i] += d.ActualAmount
} else {
sale[i] += d.ActualAmount
}
}
list := make([]erpDashboardTrend, 0, len(spans))
for i, s := range spans {
list = append(list, erpDashboardTrend{
Month: s.Key, Label: s.Label,
Buy: round2f(buy[i]), Sale: round2f(sale[i]),
})
}
return list
}
// erpDashboardCore 经营看板:所选周期的购销/毛利、今日销售、库存价值、往来余额、
// 待办、近 6 个月趋势、当前周期趋势与预警。period 取 week|month|quarter|year,缺省 month。
// month 字段保持「本月」口径不变,供旧页面与小程序继续使用;current 为所选周期。
func erpDashboardCore(a erpActor, periodRaw string) map[string]interface{} {
tid := a.Tid
period := erpDashboardPeriod(periodRaw)
now := time.Now()
monthFrom, monthTo := erpMonthRange(0)
monthStats := erpDashboardStats(tid, monthFrom, monthTo)
periodFrom, periodTo, periodLabel, spans := erpPeriodRange(period, now)
current := monthStats
if period != "month" {
current = erpDashboardStats(tid, periodFrom, periodTo)
}
// 不能用 time.Now().Truncate(24*time.Hour):它按「自零时起算的绝对时长」取整,
// 在东八区得到的是 UTC 零点(本地 08:00),会把当天 00:00–08:00 的单据漏掉。
todayFrom := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
todaySale, todayCnt := erpDocAmount(tid, []string{models.ErpDocSalesOut}, todayFrom, todayFrom.AddDate(0, 0, 1))
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)
month := f.Format("2006-01")
trendList = append(trendList, erpDashboardTrend{Month: month, Label: month, Buy: b, Sale: s})
}
return map[string]interface{}{
"period": period,
"range": map[string]interface{}{
"from": periodFrom.Format("2006-01-02"),
"to": periodTo.AddDate(0, 0, -1).Format("2006-01-02"),
"label": periodLabel,
},
"current": current,
// 兼容旧调用方:month 始终是本月口径
"month": monthStats,
"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,
"period_trend": erpDashboardPeriodTrend(tid, periodFrom, periodTo, period, spans),
"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, ""
}