更新日报相关功能
This commit is contained in:
1 parent
51fd90fc51
commit
68cdaf38fa
23 files changed
+2313
-1056
No files matched your search
@@ -144,7 +144,7 @@ func (c *AppErpController) Dashboard() {
|
||||
if c.erpDenied(err) {
|
||||
return
|
||||
}
|
||||
c.erpOk(erpDashboardCore(c.actor(cl)))
|
||||
c.erpOk(erpDashboardCore(c.actor(cl), c.erpQuery("period")))
|
||||
}
|
||||
|
||||
// Meta GET /api/erp/meta 单据类型、状态、收付款方式等下拉字典
|
||||
|
||||
@@ -72,6 +72,23 @@ func (c *AuthLoginController) LoginPage() {
|
||||
// 自助账户流程提示:registered=注册成功 / registered_warn=注册成功但加入企业失败 / reset=密码已重置
|
||||
c.Data["Notice"] = c.GetString("notice")
|
||||
c.Data["WarnMsg"] = c.GetString("warn")
|
||||
|
||||
// 登录页品牌:platform「平台设置 → 登录页设置」里的配置,未配置时用默认文案。
|
||||
// 读取失败不阻断登录页,GetPlatformSiteTagMap 会返回空值。
|
||||
brand := models.GetPlatformSiteTagMap()
|
||||
loginTitle := strings.TrimSpace(brand["login_title"])
|
||||
if loginTitle == "" {
|
||||
loginTitle = "一次登录,全平台通行"
|
||||
}
|
||||
loginSubtitle := strings.TrimSpace(brand["login_subtitle"])
|
||||
if loginSubtitle == "" {
|
||||
loginSubtitle = "基于 OIDC / OAuth 2.0 的统一身份底座,为所有接入应用提供安全、一致的登录体验。"
|
||||
}
|
||||
c.Data["LoginTitle"] = loginTitle
|
||||
c.Data["LoginSubtitle"] = loginSubtitle
|
||||
c.Data["LoginLogo"] = strings.TrimSpace(brand["login_logo"])
|
||||
c.Data["LoginBg"] = strings.TrimSpace(brand["login_bg"])
|
||||
|
||||
c.TplName = "auth/login.tpl"
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ func (c *BackendErpReportController) Dashboard() {
|
||||
if c.denied(err) {
|
||||
return
|
||||
}
|
||||
c.ok(erpDashboardCore(erpNewActor(cl)))
|
||||
c.ok(erpDashboardCore(erpNewActor(cl), c.GetString("period")))
|
||||
}
|
||||
|
||||
// ---- 汇总报表共用:聚合实现见 erp_report_core.go ----
|
||||
|
||||
@@ -187,6 +187,19 @@ func remapReceiptAmounts(oldIDs []uint64, oldAmounts []string, newIDs []uint64)
|
||||
return joinReceiptAmounts(parts)
|
||||
}
|
||||
|
||||
// normalizeReceiptURLs 规范化逗号分隔的票据地址(历史七牛 HTTP 域名改写为存储配置入口),
|
||||
// 保证 HTTPS 页面不会因 Mixed Content 拦截发票预览。
|
||||
func normalizeReceiptURLs(scheme, host, raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
for i, part := range parts {
|
||||
parts[i] = services.ResolvePublicURL(scheme, host, strings.TrimSpace(part))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func resolveReceiptFiles(scheme, host string, item models.BackendReimbursementItem) []map[string]interface{} {
|
||||
ids := parseReceiptFileIDs(item.ReceiptFileIDs)
|
||||
if len(ids) == 0 {
|
||||
@@ -282,7 +295,7 @@ func applyReceiptToItem(item *models.BackendReimbursementItem, payload reimburse
|
||||
|
||||
func reimbursementItemDTO(item models.BackendReimbursementItem, scheme, host string) map[string]interface{} {
|
||||
receiptFiles := resolveReceiptFiles(scheme, host, item)
|
||||
receiptURLs := strings.TrimSpace(item.ReceiptURLs)
|
||||
receiptURLs := normalizeReceiptURLs(scheme, host, item.ReceiptURLs)
|
||||
receiptFlag := item.ReceiptFlag
|
||||
if len(receiptFiles) > 0 {
|
||||
receiptFlag = 1
|
||||
|
||||
@@ -68,33 +68,84 @@ func erpDocAmount(tid string, docTypes []string, from, to time.Time) (float64, i
|
||||
return round2f(sum), int64(len(docs))
|
||||
}
|
||||
|
||||
// erpDashboardTrend 近 6 个月采购/销售趋势
|
||||
// erpDashboardTrend 看板趋势点。Month 保留旧字段(兼容旧页面),Label 为图表 x 轴文案
|
||||
type erpDashboardTrend struct {
|
||||
Month string `json:"month"`
|
||||
Label string `json:"label"`
|
||||
Buy float64 `json:"buy"`
|
||||
Sale float64 `json:"sale"`
|
||||
}
|
||||
|
||||
// erpDashboardCore 经营看板:本月购销/毛利、今日销售、库存价值、往来余额、待办、趋势、预警
|
||||
func erpDashboardCore(a erpActor) map[string]interface{} {
|
||||
tid := a.Tid
|
||||
monthFrom, monthTo := erpMonthRange(0)
|
||||
// 不能用 time.Now().Truncate(24*time.Hour):它按「自零时起算的绝对时长」取整,
|
||||
// 在东八区得到的是 UTC 零点(本地 08:00),会把当天 00:00–08:00 的单据漏掉。
|
||||
now := time.Now()
|
||||
todayFrom := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||
// erpDashboardPeriod 解析看板统计周期:week|month|quarter|year,缺省或非法值回落 month
|
||||
func erpDashboardPeriod(raw string) string {
|
||||
switch p := strings.ToLower(strings.TrimSpace(raw)); p {
|
||||
case "week", "quarter", "year":
|
||||
return p
|
||||
default:
|
||||
return "month"
|
||||
}
|
||||
}
|
||||
|
||||
buyAmt, buyCnt := erpDocAmount(tid, []string{models.ErpDocPurchaseIn}, monthFrom, monthTo)
|
||||
saleAmt, saleCnt := erpDocAmount(tid, []string{models.ErpDocSalesOut}, monthFrom, monthTo)
|
||||
backAmt, _ := erpDocAmount(tid, []string{models.ErpDocSalesReturn}, monthFrom, monthTo)
|
||||
todaySale, todayCnt := erpDocAmount(tid, []string{models.ErpDocSalesOut}, todayFrom, todayFrom.AddDate(0, 0, 1))
|
||||
// erpPeriodSpan 趋势分桶:Key 用于把单据日期映射到桶,Label 是图表 x 轴文案
|
||||
type erpPeriodSpan struct {
|
||||
Key string
|
||||
Label string
|
||||
}
|
||||
|
||||
// 本月毛利(出库流水成本口径)
|
||||
// erpPeriodRange 返回周期区间 [from, to)、中文标签与趋势分桶。
|
||||
// 周/月按天分桶,季度按 3 个月分桶,年度按 12 个月分桶。
|
||||
func erpPeriodRange(period string, now time.Time) (time.Time, time.Time, string, []erpPeriodSpan) {
|
||||
switch period {
|
||||
case "week":
|
||||
// 周一为一周起点;Go 的 Sunday=0,需要换算成「距本周一的天数」
|
||||
offset := (int(now.Weekday()) + 6) % 7
|
||||
from := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, -offset)
|
||||
names := []string{"周一", "周二", "周三", "周四", "周五", "周六", "周日"}
|
||||
spans := make([]erpPeriodSpan, 0, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
d := from.AddDate(0, 0, i)
|
||||
spans = append(spans, erpPeriodSpan{Key: d.Format("2006-01-02"), Label: names[i]})
|
||||
}
|
||||
return from, from.AddDate(0, 0, 7), "本周", spans
|
||||
case "quarter":
|
||||
q := (int(now.Month()) - 1) / 3
|
||||
from := time.Date(now.Year(), time.Month(q*3+1), 1, 0, 0, 0, 0, time.Local)
|
||||
spans := make([]erpPeriodSpan, 0, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
m := from.AddDate(0, i, 0)
|
||||
spans = append(spans, erpPeriodSpan{Key: m.Format("2006-01"), Label: fmt.Sprintf("%d月", int(m.Month()))})
|
||||
}
|
||||
return from, from.AddDate(0, 3, 0), "本季度", spans
|
||||
case "year":
|
||||
from := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.Local)
|
||||
spans := make([]erpPeriodSpan, 0, 12)
|
||||
for i := 1; i <= 12; i++ {
|
||||
spans = append(spans, erpPeriodSpan{Key: fmt.Sprintf("%04d-%02d", now.Year(), i), Label: fmt.Sprintf("%d月", i)})
|
||||
}
|
||||
return from, from.AddDate(1, 0, 0), "本年", spans
|
||||
default: // month
|
||||
from := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local)
|
||||
to := from.AddDate(0, 1, 0)
|
||||
spans := make([]erpPeriodSpan, 0, 31)
|
||||
for d := from; d.Before(to); d = d.AddDate(0, 0, 1) {
|
||||
spans = append(spans, erpPeriodSpan{Key: d.Format("2006-01-02"), Label: fmt.Sprintf("%d日", d.Day())})
|
||||
}
|
||||
return from, to, "本月", spans
|
||||
}
|
||||
}
|
||||
|
||||
// erpDashboardStats 区间内购销/毛利统计(只认已生效单据与库存流水)
|
||||
func erpDashboardStats(tid string, from, to time.Time) map[string]interface{} {
|
||||
buyAmt, buyCnt := erpDocAmount(tid, []string{models.ErpDocPurchaseIn}, from, to)
|
||||
saleAmt, saleCnt := erpDocAmount(tid, []string{models.ErpDocSalesOut}, from, to)
|
||||
backAmt, _ := erpDocAmount(tid, []string{models.ErpDocSalesReturn}, from, to)
|
||||
|
||||
// 毛利按出库流水成本口径
|
||||
var ledgers []models.ErpStockLedger
|
||||
_, _ = models.Orm.QueryTable(new(models.ErpStockLedger)).
|
||||
Filter("tenant_id", tid).
|
||||
Filter("doc_type__in", []string{models.ErpDocSalesOut, models.ErpDocSalesReturn}).
|
||||
Filter("create_time__gte", monthFrom).Filter("create_time__lt", monthTo).
|
||||
Filter("create_time__gte", from).Filter("create_time__lt", to).
|
||||
All(&ledgers, "direction", "total_cost")
|
||||
var cost float64
|
||||
for _, l := range ledgers {
|
||||
@@ -105,6 +156,82 @@ func erpDashboardCore(a erpActor) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
profit := saleAmt - backAmt - cost
|
||||
rate := 0.0
|
||||
if saleAmt-backAmt > 0.005 {
|
||||
rate = round2f(profit / (saleAmt - backAmt) * 100)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"purchase_amount": buyAmt, "purchase_count": buyCnt,
|
||||
"sales_amount": saleAmt, "sales_count": saleCnt,
|
||||
"return_amount": backAmt, "cost": round2f(cost),
|
||||
"profit": round2f(profit), "profit_rate": rate,
|
||||
}
|
||||
}
|
||||
|
||||
// erpDashboardPeriodTrend 当前周期内的购销分布:一次查出单据后按分桶汇总,
|
||||
// 避免「每个点都打一次库」(年度 12 点、月度 31 点)。
|
||||
func erpDashboardPeriodTrend(tid string, from, to time.Time, period string, spans []erpPeriodSpan) []erpDashboardTrend {
|
||||
var docs []models.ErpDocument
|
||||
_, _ = models.Orm.QueryTable(new(models.ErpDocument)).
|
||||
Filter("tenant_id", tid).
|
||||
Filter("doc_type__in", []string{models.ErpDocPurchaseIn, models.ErpDocSalesOut}).
|
||||
Filter("status", models.ErpDocStatusApproved).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("biz_date__gte", from).Filter("biz_date__lt", to).
|
||||
All(&docs, "doc_type", "biz_date", "actual_amount")
|
||||
|
||||
index := make(map[string]int, len(spans))
|
||||
for i, s := range spans {
|
||||
index[s.Key] = i
|
||||
}
|
||||
buy := make([]float64, len(spans))
|
||||
sale := make([]float64, len(spans))
|
||||
for _, d := range docs {
|
||||
key := d.BizDate.Format("2006-01-02")
|
||||
if period == "quarter" || period == "year" {
|
||||
key = d.BizDate.Format("2006-01")
|
||||
}
|
||||
i, ok := index[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if d.DocType == models.ErpDocPurchaseIn {
|
||||
buy[i] += d.ActualAmount
|
||||
} else {
|
||||
sale[i] += d.ActualAmount
|
||||
}
|
||||
}
|
||||
list := make([]erpDashboardTrend, 0, len(spans))
|
||||
for i, s := range spans {
|
||||
list = append(list, erpDashboardTrend{
|
||||
Month: s.Key, Label: s.Label,
|
||||
Buy: round2f(buy[i]), Sale: round2f(sale[i]),
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// erpDashboardCore 经营看板:所选周期的购销/毛利、今日销售、库存价值、往来余额、
|
||||
// 待办、近 6 个月趋势、当前周期趋势与预警。period 取 week|month|quarter|year,缺省 month。
|
||||
// month 字段保持「本月」口径不变,供旧页面与小程序继续使用;current 为所选周期。
|
||||
func erpDashboardCore(a erpActor, periodRaw string) map[string]interface{} {
|
||||
tid := a.Tid
|
||||
period := erpDashboardPeriod(periodRaw)
|
||||
now := time.Now()
|
||||
|
||||
monthFrom, monthTo := erpMonthRange(0)
|
||||
monthStats := erpDashboardStats(tid, monthFrom, monthTo)
|
||||
|
||||
periodFrom, periodTo, periodLabel, spans := erpPeriodRange(period, now)
|
||||
current := monthStats
|
||||
if period != "month" {
|
||||
current = erpDashboardStats(tid, periodFrom, periodTo)
|
||||
}
|
||||
|
||||
// 不能用 time.Now().Truncate(24*time.Hour):它按「自零时起算的绝对时长」取整,
|
||||
// 在东八区得到的是 UTC 零点(本地 08:00),会把当天 00:00–08:00 的单据漏掉。
|
||||
todayFrom := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||
todaySale, todayCnt := erpDocAmount(tid, []string{models.ErpDocSalesOut}, todayFrom, todayFrom.AddDate(0, 0, 1))
|
||||
|
||||
var stocks []models.ErpStock
|
||||
_, _ = models.Orm.QueryTable(new(models.ErpStock)).Filter("tenant_id", tid).
|
||||
@@ -142,30 +269,29 @@ func erpDashboardCore(a erpActor) map[string]interface{} {
|
||||
f, t := erpMonthRange(-i)
|
||||
b, _ := erpDocAmount(tid, []string{models.ErpDocPurchaseIn}, f, t)
|
||||
s, _ := erpDocAmount(tid, []string{models.ErpDocSalesOut}, f, t)
|
||||
trendList = append(trendList, erpDashboardTrend{Month: f.Format("2006-01"), Buy: b, Sale: s})
|
||||
month := f.Format("2006-01")
|
||||
trendList = append(trendList, erpDashboardTrend{Month: month, Label: month, Buy: b, Sale: s})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"month": map[string]interface{}{
|
||||
"purchase_amount": buyAmt, "purchase_count": buyCnt,
|
||||
"sales_amount": saleAmt, "sales_count": saleCnt,
|
||||
"return_amount": backAmt, "cost": round2f(cost),
|
||||
"profit": round2f(profit),
|
||||
"profit_rate": func() float64 {
|
||||
if saleAmt-backAmt > 0.005 {
|
||||
return round2f(profit / (saleAmt - backAmt) * 100)
|
||||
}
|
||||
return 0
|
||||
}(),
|
||||
"period": period,
|
||||
"range": map[string]interface{}{
|
||||
"from": periodFrom.Format("2006-01-02"),
|
||||
"to": periodTo.AddDate(0, 0, -1).Format("2006-01-02"),
|
||||
"label": periodLabel,
|
||||
},
|
||||
"current": current,
|
||||
// 兼容旧调用方:month 始终是本月口径
|
||||
"month": monthStats,
|
||||
"today": map[string]interface{}{"sales_amount": todaySale, "sales_count": todayCnt},
|
||||
"stock": map[string]interface{}{"value": round2f(stockValue), "qty": round4f(stockQty)},
|
||||
"partner_bill": map[string]interface{}{
|
||||
"receivable": round2f(recv), "payable": round2f(pay), "net": round2f(recv - pay),
|
||||
},
|
||||
"todo": map[string]interface{}{"approval_pending": pendingTasks, "draft_docs": draftCnt},
|
||||
"trend": trendList,
|
||||
"warning": erpWarningCounts(tid),
|
||||
"todo": map[string]interface{}{"approval_pending": pendingTasks, "draft_docs": draftCnt},
|
||||
"trend": trendList,
|
||||
"period_trend": erpDashboardPeriodTrend(tid, periodFrom, periodTo, period, spans),
|
||||
"warning": erpWarningCounts(tid),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"server/models"
|
||||
"server/pkg/identity"
|
||||
"server/services"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
@@ -108,3 +109,110 @@ func (c *PlatformWebsiteSettingController) Save() {
|
||||
c.Data["json"] = map[string]any{"code": 200, "msg": "保存成功"}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// loginBrandTags 登录页品牌配置项,存放于 yz_platform_site_setting,
|
||||
// 统一认证中心 /auth/login 渲染时读取。
|
||||
var loginBrandTags = []string{"login_title", "login_subtitle", "login_logo", "login_bg"}
|
||||
|
||||
// LoginBrandInfo GET /platform/loginBrand
|
||||
// 返回登录页品牌配置(主标题 / 副标题 / Logo / 背景图)。
|
||||
func (c *PlatformWebsiteSettingController) LoginBrandInfo() {
|
||||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||||
code := identity.Status(err)
|
||||
c.jsonErr(code, code, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
all := models.GetPlatformSiteTagMap()
|
||||
data := make(map[string]string, len(loginBrandTags))
|
||||
for _, tag := range loginBrandTags {
|
||||
data[tag] = all[tag]
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]any{"code": 200, "msg": "success", "data": data}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// SaveLoginBrand POST /platform/saveLoginBrand
|
||||
// 入参 { login_title, login_subtitle, login_logo, login_bg }。
|
||||
// 只接受这四个标签,避免与「站点设置」共用的 yz_platform_site_setting 被写入无关项。
|
||||
func (c *PlatformWebsiteSettingController) SaveLoginBrand() {
|
||||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||||
code := identity.Status(err)
|
||||
c.jsonErr(code, code, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
var payload map[string]string
|
||||
if uerr := json.Unmarshal(raw, &payload); uerr != nil {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
values := make(map[string]string, len(loginBrandTags))
|
||||
for _, tag := range loginBrandTags {
|
||||
if v, ok := payload[tag]; ok {
|
||||
values[tag] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if len([]rune(values["login_title"])) > 60 {
|
||||
c.jsonErr(400, 400, "主标题不能超过 60 个字符")
|
||||
return
|
||||
}
|
||||
if len([]rune(values["login_subtitle"])) > 120 {
|
||||
c.jsonErr(400, 400, "副标题不能超过 120 个字符")
|
||||
return
|
||||
}
|
||||
for _, tag := range []string{"login_logo", "login_bg"} {
|
||||
img, ok := normalizeLoginBrandImage(values[tag])
|
||||
if !ok {
|
||||
c.jsonErr(400, 400, "图片地址必须是 HTTPS 或站内相对路径")
|
||||
return
|
||||
}
|
||||
values[tag] = img
|
||||
}
|
||||
|
||||
if err = models.SavePlatformSiteSettings(values); err != nil {
|
||||
c.jsonErr(500, 500, "保存失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]any{"code": 200, "msg": "保存成功"}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// normalizeLoginBrandImage 校验登录页图片地址:
|
||||
// - 站内相对路径(/uploads/...)直接放行;
|
||||
// - HTTPS 完整地址放行;
|
||||
// - HTTP 历史七牛域名尝试改写为存储配置的 HTTPS 基址,改不动则拒绝,
|
||||
// 否则登录页会被浏览器按 Mixed Content 拦截。
|
||||
func normalizeLoginBrandImage(raw string) (string, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", true
|
||||
}
|
||||
if strings.HasPrefix(raw, "/") {
|
||||
return raw, true
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.HasPrefix(lower, "https://") {
|
||||
return raw, true
|
||||
}
|
||||
if strings.HasPrefix(lower, "http://") {
|
||||
rewritten := services.ResolvePublicURL("", "", raw)
|
||||
if strings.HasPrefix(strings.ToLower(rewritten), "https://") {
|
||||
return rewritten, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
Reference in new issue
Block a user