695 lines
23 KiB
Go
695 lines
23 KiB
Go
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, ""
|
||
}
|