更新
This commit is contained in:
1 parent
5718a00e31
commit
1850ac640e
226 files changed
+37772
-12071
No files matched your search
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user