更新日报相关功能

This commit is contained in:
hero920103 committed 2026-10-22 17:55:50 +08:00
1 parent 51fd90fc51
commit 68cdaf38fa
23 files changed
+2313 -1056

No files matched your search

+1 -1
View File
@@ -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 单据类型、状态、收付款方式等下拉字典
+17
View File
@@ -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"
}
+1 -1
View File
@@ -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 ----
+14 -1
View File
@@ -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
+156 -30
View File
@@ -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),
}
}
+108
View File
@@ -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
}