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

+344
View File
@@ -0,0 +1,344 @@
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)))
}
// ---- 汇总报表共用:聚合实现见 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))
}