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)) }