345 lines
9.7 KiB
Go
345 lines
9.7 KiB
Go
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))
|
||
}
|