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

345 lines
9.7 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 (
"sort"
"strings"
"time"
"server/models"
)
// BackendErpReportController 进销存报表:购销汇总、收发存、毛利、客户RFM、经营看板
//
// 统计口径全部来自「已生效」单据与库存流水,草稿/审批中/作废不参与,
// 这样报表数字与账务(往来账、库存结存)永远一致。
type BackendErpReportController struct {
BackendErpBaseController
}
// reportRange 解析日期区间,默认当月
func (c *BackendErpReportController) reportRange() (time.Time, time.Time) {
from, to := erpMonthRange(0)
if d := strings.TrimSpace(c.GetString("date_from")); d != "" {
if t, e := time.ParseInLocation("2006-01-02", d, time.Local); e == nil {
from = t
}
}
if d := strings.TrimSpace(c.GetString("date_to")); d != "" {
if t, e := time.ParseInLocation("2006-01-02", d, time.Local); e == nil {
to = t.AddDate(0, 0, 1)
}
}
return from, to
}
func erpMonthRange(offsetMonths int) (time.Time, time.Time) {
now := time.Now()
first := time.Date(now.Year(), now.Month()+time.Month(offsetMonths), 1, 0, 0, 0, 0, time.Local)
return first, first.AddDate(0, 1, 0)
}
// approvedDocIDs 区间内已生效单据ID(按类型)
func erpApprovedDocIDs(tid string, docTypes []string, from, to time.Time, partnerID uint64) []uint64 {
qs := models.Orm.QueryTable(new(models.ErpDocument)).
Filter("tenant_id", tid).
Filter("doc_type__in", docTypes).
Filter("status", models.ErpDocStatusApproved).
Filter("delete_time__isnull", true).
Filter("biz_date__gte", from).Filter("biz_date__lt", to)
if partnerID > 0 {
qs = qs.Filter("partner_id", partnerID)
}
var rows []models.ErpDocument
_, _ = qs.All(&rows, "id")
ids := make([]uint64, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ID)
}
return ids
}
func erpItemsByDocs(tid string, docIDs []uint64) []models.ErpDocumentItem {
if len(docIDs) == 0 {
return nil
}
var out []models.ErpDocumentItem
_, _ = models.Orm.QueryTable(new(models.ErpDocumentItem)).
Filter("tenant_id", tid).Filter("doc_id__in", docIDs).All(&out)
return out
}
// PurchaseSummary GET /backend/erp/report/purchase 采购汇总(dimension=product|supplier|month)
func (c *BackendErpReportController) PurchaseSummary() {
cl, err := c.claims()
if c.denied(err) {
return
}
tid := c.tid(cl)
from, to := c.reportRange()
group := erpReportDimension(c.GetString("dimension"))
items, docPartner, returnItems := erpSummaryDocs(tid, models.ErpDocPurchaseIn,
models.ErpDocPurchaseReturn, from, to, erpUintParam(c.GetString("partner_id")))
c.ok(erpSummaryReportData(group, items, docPartner, returnItems))
}
// SalesSummary GET /backend/erp/report/sales 销售汇总(dimension=product|customer|month)
func (c *BackendErpReportController) SalesSummary() {
cl, err := c.claims()
if c.denied(err) {
return
}
tid := c.tid(cl)
from, to := c.reportRange()
group := erpReportDimension(c.GetString("dimension"))
items, docPartner, returnItems := erpSummaryDocs(tid, models.ErpDocSalesOut,
models.ErpDocSalesReturn, from, to, erpUintParam(c.GetString("partner_id")))
c.ok(erpSummaryReportData(group, items, docPartner, returnItems))
}
// Rank GET /backend/erp/report/rank 热销/滞销排行
func (c *BackendErpReportController) Rank() {
cl, err := c.claims()
if c.denied(err) {
return
}
from, to := c.reportRange()
top, _ := c.GetInt("top", 10)
asc := strings.TrimSpace(c.GetString("order")) == "asc"
data, code, msg := erpSalesRankCore(erpNewActor(cl), from, to, top, asc)
if msg != "" {
c.fail(code, code, msg)
return
}
c.ok(data)
}
// StockMove GET /backend/erp/report/stockmove 收发存汇总表
// 期末取自库存结存,本期收发取自库存流水,期初=期末-(入-出),保证与账实一致。
func (c *BackendErpReportController) StockMove() {
cl, err := c.claims()
if c.denied(err) {
return
}
from, to := c.reportRange()
page, pageSize := c.pageArgs()
list, total, summary, code, msg := erpStockMoveCore(
erpNewActor(cl), from, to,
erpUintParam(c.GetString("warehouse_id")),
c.GetString("keyword"),
page, pageSize,
)
if msg != "" {
c.fail(code, code, msg)
return
}
c.Data["json"] = map[string]interface{}{
"code": 200, "msg": "success",
"data": map[string]interface{}{
"list": list, "total": total, "page": page, "pageSize": pageSize,
"summary": summary,
},
}
_ = c.ServeJSON()
}
// Profit GET /backend/erp/report/profit 毛利分析:收入=销售出库金额-销售退货金额,
// 成本取库存流水的实际出库成本(移动加权),不受手工录入影响。
func (c *BackendErpReportController) Profit() {
cl, err := c.claims()
if c.denied(err) {
return
}
from, to := c.reportRange()
data, code, msg := erpProfitCore(erpNewActor(cl), from, to)
if msg != "" {
c.fail(code, code, msg)
return
}
c.ok(data)
}
// RFM GET /backend/erp/report/rfm 客户价值分层(R最近购买/F频次/M金额)
func (c *BackendErpReportController) RFM() {
cl, err := c.claims()
if c.denied(err) {
return
}
tid := c.tid(cl)
var docs []models.ErpDocument
_, _ = models.Orm.QueryTable(new(models.ErpDocument)).
Filter("tenant_id", tid).
Filter("doc_type__in", []string{models.ErpDocSalesOut, models.ErpDocSalesReturn}).
Filter("status", models.ErpDocStatusApproved).
Filter("delete_time__isnull", true).All(&docs)
now := time.Now()
type row struct {
CustomerID uint64 `json:"customer_id"`
CustomerName string `json:"customer_name"`
RDays int `json:"r_days"`
FCount int `json:"f_count"`
MAmount float64 `json:"m_amount"`
RScore int `json:"r_score"`
FScore int `json:"f_score"`
MScore int `json:"m_score"`
Segment string `json:"segment"`
LastDate string `json:"last_date"`
}
agg := map[uint64]*row{}
for _, d := range docs {
if d.PartnerID == 0 {
continue
}
r := agg[d.PartnerID]
if r == nil {
r = &row{CustomerID: d.PartnerID, CustomerName: d.PartnerName, RDays: 99999}
agg[d.PartnerID] = r
}
amount := d.ActualAmount
if d.DocType == models.ErpDocSalesReturn {
// 退货只冲减金额,不计购买频次、不影响最近购买时间
r.MAmount -= amount
continue
}
r.FCount++
r.MAmount += amount
if d.BizDate.After(now.AddDate(0, 0, -r.RDays)) {
days := int(now.Sub(d.BizDate).Hours() / 24)
if days < r.RDays {
r.RDays = days
r.LastDate = d.BizDate.Format("2006-01-02")
}
}
}
list := make([]*row, 0, len(agg))
rs, fs, ms := make([]float64, 0, len(agg)), make([]float64, 0, len(agg)), make([]float64, 0, len(agg))
for _, r := range agg {
list = append(list, r)
rs = append(rs, float64(r.RDays))
fs = append(fs, float64(r.FCount))
ms = append(ms, r.MAmount)
}
rMed := erpMedian(rs)
fMed := erpMedian(fs)
mMed := erpMedian(ms)
for _, r := range list {
r.MAmount = round2f(r.MAmount)
r.RScore = erpHighScore(float64(r.RDays), rMed, true) // R 越小越好
r.FScore = erpHighScore(float64(r.FCount), fMed, false)
r.MScore = erpHighScore(r.MAmount, mMed, false)
r.Segment = erpRFMSegment(r.RScore, r.FScore, r.MScore)
}
sort.Slice(list, func(i, j int) bool {
if list[i].MAmount != list[j].MAmount {
return list[i].MAmount > list[j].MAmount
}
return list[i].CustomerID < list[j].CustomerID
})
segCount := map[string]int{}
for _, r := range list {
segCount[r.Segment]++
}
c.ok(map[string]interface{}{
"list": list, "median": map[string]float64{"r": rMed, "f": fMed, "m": mMed},
"segment_counts": segCount,
})
}
// Dashboard GET /backend/erp/report/dashboard 经营看板
func (c *BackendErpReportController) Dashboard() {
cl, err := c.claims()
if c.denied(err) {
return
}
c.ok(erpDashboardCore(erpNewActor(cl), c.GetString("period")))
}
// ---- 汇总报表共用:聚合实现见 erp_report_core.go ----
func absFloat(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func erpDocPartnerMap(ids []uint64) map[uint64]models.ErpDocument {
out := map[uint64]models.ErpDocument{}
if len(ids) == 0 {
return out
}
var docs []models.ErpDocument
_, _ = models.Orm.QueryTable(new(models.ErpDocument)).
Filter("id__in", ids).All(&docs, "id", "partner_id", "partner_name", "biz_date")
for _, d := range docs {
out[d.ID] = d
}
return out
}
func keysOfU64[V any](m map[uint64]V) []uint64 {
out := make([]uint64, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func erpMedian(v []float64) float64 {
if len(v) == 0 {
return 0
}
c := append([]float64{}, v...)
sort.Float64s(c)
n := len(c)
if n%2 == 1 {
return c[n/2]
}
return (c[n/2-1] + c[n/2]) / 2
}
// erpHighScore 1=高于中位数(好),0=低于中位数;reverse=true 表示值越小越好
func erpHighScore(v, med float64, reverse bool) int {
if reverse {
if v <= med {
return 1
}
return 0
}
if v >= med {
return 1
}
return 0
}
func erpRFMSegment(r, f, m int) string {
switch {
case r == 1 && f == 1 && m == 1:
return "重要价值客户"
case r == 1 && f == 0 && m == 1:
return "重要发展客户"
case r == 0 && f == 1 && m == 1:
return "重要保持客户"
case r == 0 && f == 0 && m == 1:
return "重要挽留客户"
case r == 1 && f == 1 && m == 0:
return "一般价值客户"
case r == 1 && f == 0 && m == 0:
return "一般发展客户"
case r == 0 && f == 1 && m == 0:
return "一般保持客户"
default:
return "一般挽留客户"
}
}
// erpErpPendingTaskCount 待当前用户审批的进销存单据数
func erpErpPendingTaskCount(tid, uid uint64) int64 {
var rows []models.ApprovalTask
_, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("tid", tid).Filter("approver_id", uid).
Filter("status", models.ApprovalTaskPending).
Filter("biz_type__startswith", "erp_").
Filter("is_deleted", 0).All(&rows, "id")
return int64(len(rows))
}