feat platform dashboard and organization management
This commit is contained in:
@@ -2,7 +2,6 @@ package dashboard
|
||||
|
||||
import (
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -13,7 +12,7 @@ func PingHello(ctx *gin.Context) {
|
||||
|
||||
// DashboardOverview 返回平台总后台的运营概览数据。
|
||||
func DashboardOverview(ctx *gin.Context) {
|
||||
overview, err := models.GetDashboardOverview()
|
||||
overview, err := GetDashboardOverview()
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDashboardOverviewReturnsZeroValuesForAnEmptyDatabase(t *testing.T) {
|
||||
_, mock := setupDashboardDatabase(t)
|
||||
for _, query := range []string{
|
||||
`SELECT count\(\*\) FROM "gas_basic" WHERE status = \$1`,
|
||||
`SELECT count\(\*\) FROM "delivery_basic" WHERE status = \$1`,
|
||||
`SELECT count\(\*\) FROM "staff_account" WHERE work_status = \$1`,
|
||||
`SELECT count\(\*\) FROM "user_account" WHERE status = \$1`,
|
||||
} {
|
||||
mock.ExpectQuery(regexp.MustCompile(query).String()).WithArgs(sqlmock.AnyArg()).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
func TestNamedStatusesUsesFallbackForUnknownStatus(t *testing.T) {
|
||||
got := namedStatuses([]statusAggregate{{Status: 16, Count: 3}, {Status: 999, Count: 1}}, map[int]string{16: "已创建"})
|
||||
if len(got) != 2 || got[0].Name != "已创建" || got[0].Value != 3 || got[1].Name != "其他" {
|
||||
t.Fatalf("named statuses = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillRecentDaysIncludesMissingDays(t *testing.T) {
|
||||
now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.Local)
|
||||
got := fillRecentDays([]dailyAggregate{{
|
||||
Date: time.Date(2026, 7, 28, 0, 0, 0, 0, time.Local), OrderCount: 2, OrderAmount: 3600,
|
||||
}}, now)
|
||||
if len(got) != 7 {
|
||||
t.Fatalf("day count = %d, want 7", len(got))
|
||||
}
|
||||
if got[5].Date != "2026-07-28" || got[5].OrderCount != 2 || got[5].OrderAmount != 3600 {
|
||||
t.Fatalf("filled metric = %#v", got[5])
|
||||
}
|
||||
if got[0].OrderCount != 0 || got[6].OrderAmount != 0 {
|
||||
t.Fatalf("missing days were not zero-filled: %#v", got)
|
||||
}
|
||||
|
||||
overview, err := models.GetDashboardOverview()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if overview != (models.DashboardOverview{}) {
|
||||
t.Fatalf("empty dashboard = %#v, want zero values", overview)
|
||||
}
|
||||
assertDashboardMockExpectations(t, mock)
|
||||
}
|
||||
|
||||
166
backend/api/internal/logic/platform/dashboard/statistics.go
Normal file
166
backend/api/internal/logic/platform/dashboard/statistics.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
)
|
||||
|
||||
// MetricSlice 是仪表盘图表使用的名称/数值序列。
|
||||
type MetricSlice struct {
|
||||
Name string `json:"name"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
|
||||
// DailyMetric 是按自然日聚合的运营指标。
|
||||
type DailyMetric struct {
|
||||
Date string `json:"date"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
OrderAmount int64 `json:"order_amount"`
|
||||
}
|
||||
|
||||
// DashboardStatistics 是平台运营总览。该接口专用结构只属于 dashboard 逻辑层。
|
||||
type DashboardStatistics struct {
|
||||
GasBasicCount int64 `json:"gas_basic_count"`
|
||||
DeliveryBasicCount int64 `json:"delivery_basic_count"`
|
||||
StaffCount int64 `json:"staff_count"`
|
||||
UserCount int64 `json:"user_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
ActiveContractCount int64 `json:"active_contract_count"`
|
||||
TodayOrderCount int64 `json:"today_order_count"`
|
||||
TodayOrderAmount int64 `json:"today_order_amount"`
|
||||
PendingTicketCount int64 `json:"pending_ticket_count"`
|
||||
PaidAmount int64 `json:"paid_amount"`
|
||||
OrderStatuses []MetricSlice `json:"order_statuses"`
|
||||
ProductStatuses []MetricSlice `json:"product_statuses"`
|
||||
PaymentChannels []MetricSlice `json:"payment_channels"`
|
||||
RecentOrders []DailyMetric `json:"recent_orders"`
|
||||
}
|
||||
|
||||
type statusAggregate struct {
|
||||
Status int
|
||||
Count int64
|
||||
}
|
||||
|
||||
type channelAggregate struct {
|
||||
Name string
|
||||
Value int64
|
||||
}
|
||||
|
||||
type dailyAggregate struct {
|
||||
Date time.Time
|
||||
OrderCount int64
|
||||
OrderAmount int64
|
||||
}
|
||||
|
||||
var orderStatusNames = map[int]string{
|
||||
common.StatusCreated: "已创建", common.StatusOrdered: "已下单",
|
||||
common.StatusAssigned: "已分配", common.StatusFilling: "充装中",
|
||||
common.StatusReady: "已就绪", common.StatusDelivering: "配送中",
|
||||
common.StatusAwaitingConfirmation: "待确认", common.StatusCompleted: "已完成",
|
||||
common.StatusCancelled: "已取消", common.StatusException: "异常",
|
||||
}
|
||||
|
||||
var productStatusNames = map[int]string{
|
||||
common.StatusPending: "待处理", common.StatusInStock: "在库",
|
||||
common.StatusInTransit: "运输中", common.StatusInUse: "使用中",
|
||||
common.StatusRepairing: "维修中", common.StatusScrapped: "已报废",
|
||||
}
|
||||
|
||||
// GetDashboardOverview 汇总组织、人员、资产、订单、支付及客服维度的数据。
|
||||
func GetDashboardOverview() (DashboardStatistics, error) {
|
||||
var result DashboardStatistics
|
||||
counts := []struct {
|
||||
model any
|
||||
where string
|
||||
args []any
|
||||
out *int64
|
||||
}{
|
||||
{&models.GasBasic{}, "status = ?", []any{common.StatusEnable}, &result.GasBasicCount},
|
||||
{&models.DeliveryBasic{}, "status = ?", []any{common.StatusEnable}, &result.DeliveryBasicCount},
|
||||
{&models.StaffAccount{}, "status = ? AND work_status = ?", []any{common.StatusEnable, "on_duty"}, &result.StaffCount},
|
||||
{&models.UserAccount{}, "status = ?", []any{common.StatusEnable}, &result.UserCount},
|
||||
{&models.ProductInfo{}, "status = ?", []any{common.StatusEnable}, &result.ProductCount},
|
||||
{&models.GasorderContract{}, "status = ? AND contract_status = ?", []any{common.StatusEnable, common.StatusActive}, &result.ActiveContractCount},
|
||||
{&models.GasorderBasic{}, "status = ? AND created_at >= CURRENT_DATE", []any{common.StatusEnable}, &result.TodayOrderCount},
|
||||
{&models.CsTicket{}, "status = ? AND ticket_status = ?", []any{common.StatusEnable, common.StatusOpen}, &result.PendingTicketCount},
|
||||
}
|
||||
for _, item := range counts {
|
||||
if err := impl.DBService.Model(item.model).Where(item.where, item.args...).Count(item.out).Error; err != nil {
|
||||
return DashboardStatistics{}, err
|
||||
}
|
||||
}
|
||||
if err := impl.DBService.Model(&models.GasorderBasic{}).
|
||||
Select("COALESCE(SUM(payable_amount), 0)").
|
||||
Where("status = ? AND created_at >= CURRENT_DATE", common.StatusEnable).
|
||||
Scan(&result.TodayOrderAmount).Error; err != nil {
|
||||
return DashboardStatistics{}, err
|
||||
}
|
||||
if err := impl.DBService.Model(&models.WalletPayment{}).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Where("status = ? AND payment_status = ?", common.StatusEnable, common.StatusPaid).
|
||||
Scan(&result.PaidAmount).Error; err != nil {
|
||||
return DashboardStatistics{}, err
|
||||
}
|
||||
|
||||
var orderStatuses []statusAggregate
|
||||
if err := impl.DBService.Model(&models.GasorderBasic{}).
|
||||
Select("order_status AS status, COUNT(*) AS count").
|
||||
Where("status = ?", common.StatusEnable).Group("order_status").Scan(&orderStatuses).Error; err != nil {
|
||||
return DashboardStatistics{}, err
|
||||
}
|
||||
result.OrderStatuses = namedStatuses(orderStatuses, orderStatusNames)
|
||||
|
||||
var productStatuses []statusAggregate
|
||||
if err := impl.DBService.Model(&models.ProductInfo{}).
|
||||
Select("product_status AS status, COUNT(*) AS count").
|
||||
Where("status = ?", common.StatusEnable).Group("product_status").Scan(&productStatuses).Error; err != nil {
|
||||
return DashboardStatistics{}, err
|
||||
}
|
||||
result.ProductStatuses = namedStatuses(productStatuses, productStatusNames)
|
||||
|
||||
if err := impl.DBService.Model(&models.WalletPayment{}).
|
||||
Select("pay_channel AS name, COALESCE(SUM(amount), 0) AS value").
|
||||
Where("status = ? AND payment_status = ?", common.StatusEnable, common.StatusPaid).
|
||||
Group("pay_channel").Order("value DESC").Scan(&result.PaymentChannels).Error; err != nil {
|
||||
return DashboardStatistics{}, err
|
||||
}
|
||||
|
||||
var daily []dailyAggregate
|
||||
if err := impl.DBService.Model(&models.GasorderBasic{}).
|
||||
Select("DATE_TRUNC('day', created_at) AS date, COUNT(*) AS order_count, COALESCE(SUM(payable_amount), 0) AS order_amount").
|
||||
Where("status = ? AND created_at >= CURRENT_DATE - INTERVAL '6 days'", common.StatusEnable).
|
||||
Group("DATE_TRUNC('day', created_at)").Order("date").Scan(&daily).Error; err != nil {
|
||||
return DashboardStatistics{}, err
|
||||
}
|
||||
result.RecentOrders = fillRecentDays(daily, time.Now())
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func namedStatuses(rows []statusAggregate, names map[int]string) []MetricSlice {
|
||||
result := make([]MetricSlice, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
name, ok := names[row.Status]
|
||||
if !ok {
|
||||
name = "其他"
|
||||
}
|
||||
result = append(result, MetricSlice{Name: name, Value: row.Count})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fillRecentDays(rows []dailyAggregate, now time.Time) []DailyMetric {
|
||||
values := make(map[string]dailyAggregate, len(rows))
|
||||
for _, row := range rows {
|
||||
values[row.Date.Format("2006-01-02")] = row
|
||||
}
|
||||
result := make([]DailyMetric, 0, 7)
|
||||
for offset := -6; offset <= 0; offset++ {
|
||||
date := now.AddDate(0, 0, offset).Format("2006-01-02")
|
||||
row := values[date]
|
||||
result = append(result, DailyMetric{Date: date, OrderCount: row.OrderCount, OrderAmount: row.OrderAmount})
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -26,14 +26,9 @@ var PlatformMenus = [][]Menu{
|
||||
{Identity: "dashboard_reports", ParentIdentity: "dashboard", GroupCode: "dashboard", Name: "统计报表", Path: "/dashboard/reports", SortNo: 2, Status: common.StatusEnable},
|
||||
},
|
||||
{
|
||||
{Identity: "gas", GroupCode: "gas", Name: "气站管理", Icon: "icon-storage", Path: "/gas", SortNo: 20, Status: common.StatusEnable},
|
||||
{Identity: "gas_basic", ParentIdentity: "gas", GroupCode: "gas", Name: "气站", Path: "/gas/gas-basic", SortNo: 1, Status: common.StatusEnable},
|
||||
{Identity: "gas_account", ParentIdentity: "gas", GroupCode: "gas", Name: "气站账户", Path: "/gas/gas-account", SortNo: 2, Status: common.StatusEnable},
|
||||
},
|
||||
{
|
||||
{Identity: "delivery", GroupCode: "delivery", Name: "配送站管理", Icon: "icon-send", Path: "/delivery", SortNo: 30, Status: common.StatusEnable},
|
||||
{Identity: "delivery_basic", ParentIdentity: "delivery", GroupCode: "delivery", Name: "配送站", Path: "/delivery/delivery-basic", SortNo: 1, Status: common.StatusEnable},
|
||||
{Identity: "delivery_account", ParentIdentity: "delivery", GroupCode: "delivery", Name: "配送站账户", Path: "/delivery/delivery-account", SortNo: 2, Status: common.StatusEnable},
|
||||
{Identity: "organization", GroupCode: "organization", Name: "机构管理", Icon: "icon-storage", Path: "/organization", SortNo: 20, Status: common.StatusEnable},
|
||||
{Identity: "gas_basic", ParentIdentity: "organization", GroupCode: "organization", Name: "气站管理", Path: "/gas/gas-basic", SortNo: 1, Status: common.StatusEnable},
|
||||
{Identity: "delivery_basic", ParentIdentity: "organization", GroupCode: "organization", Name: "配送点管理", Path: "/delivery/delivery-basic", SortNo: 2, Status: common.StatusEnable},
|
||||
},
|
||||
{
|
||||
{Identity: "staff", GroupCode: "staff", Name: "工作人员管理", Icon: "icon-user-group", Path: "/staff", SortNo: 40, Status: common.StatusEnable},
|
||||
|
||||
@@ -32,6 +32,10 @@ func platformRouteMenuIdentity(resource string) string {
|
||||
switch {
|
||||
case resource == "dashboard":
|
||||
return "dashboard_overview"
|
||||
case resource == "gas_account":
|
||||
return "gas_basic"
|
||||
case resource == "delivery_account":
|
||||
return "delivery_basic"
|
||||
case strings.HasPrefix(resource, "gasorder_contract"):
|
||||
return "gasorder_contract"
|
||||
case resource == "gasorder_track" || resource == "gasorder_track_point":
|
||||
|
||||
@@ -11,8 +11,11 @@ func TestSecondLevelMenuPermissionDoesNotGrantSibling(t *testing.T) {
|
||||
if !platformMenuAllowsPath(menus, "/heqi/platform/v1/delivery_basic") {
|
||||
t.Fatal("selected second-level menu did not grant its resource")
|
||||
}
|
||||
if platformMenuAllowsPath(menus, "/heqi/platform/v1/delivery_account") {
|
||||
t.Fatal("selected second-level menu granted a sibling resource")
|
||||
if !platformMenuAllowsPath(menus, "/heqi/platform/v1/delivery_account") {
|
||||
t.Fatal("delivery management did not grant its embedded account management")
|
||||
}
|
||||
if platformMenuAllowsPath(menus, "/heqi/platform/v1/gas_account") {
|
||||
t.Fatal("delivery management granted gas account management")
|
||||
}
|
||||
if platformMenuAllowsPath(menus, "/heqi/platform/v1/gasorder_basic") {
|
||||
t.Fatal("delivery permission leaked into gasorder")
|
||||
|
||||
@@ -2,32 +2,6 @@ package models
|
||||
|
||||
import "git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
|
||||
// DashboardOverview 是平台总后台的跨组织运营概览指标。
|
||||
type DashboardOverview struct {
|
||||
GasBasicCount int64 `json:"gas_basic_count"` // 启用可燃气体站数量
|
||||
DeliveryBasicCount int64 `json:"delivery_basic_count"` // 启用配送点数量
|
||||
StaffCount int64 `json:"staff_count"` // 在岗服务人员数量
|
||||
UserCount int64 `json:"user_count"` // 启用业主客户数量
|
||||
}
|
||||
|
||||
// GetDashboardOverview 通过独立查询返回首页概览指标。
|
||||
func GetDashboardOverview() (DashboardOverview, error) {
|
||||
var overview DashboardOverview
|
||||
if err := impl.DBService.Model(&GasBasic{}).Where("status = ?", 1).Count(&overview.GasBasicCount).Error; err != nil {
|
||||
return DashboardOverview{}, err
|
||||
}
|
||||
if err := impl.DBService.Model(&DeliveryBasic{}).Where("status = ?", 1).Count(&overview.DeliveryBasicCount).Error; err != nil {
|
||||
return DashboardOverview{}, err
|
||||
}
|
||||
if err := impl.DBService.Model(&StaffAccount{}).Where("work_status = ?", "on_duty").Count(&overview.StaffCount).Error; err != nil {
|
||||
return DashboardOverview{}, err
|
||||
}
|
||||
if err := impl.DBService.Model(&UserAccount{}).Where("status = ?", 1).Count(&overview.UserCount).Error; err != nil {
|
||||
return DashboardOverview{}, err
|
||||
}
|
||||
return overview, nil
|
||||
}
|
||||
|
||||
// ListPlatformAccount 返回平台账号分页列表,敏感字段由接口展示层脱敏。
|
||||
func ListPlatformAccount(page, size int) ([]PlatformAccount, int64, error) {
|
||||
var list []PlatformAccount
|
||||
|
||||
Reference in New Issue
Block a user