fix: 优化负利润分析报表处理逻辑

This commit is contained in:
renzhiyuan 2026-01-13 17:13:44 +08:00
parent 03652cb588
commit 6c987b15db
16 changed files with 730 additions and 122 deletions

View File

@ -27,6 +27,7 @@ lsxd:
login_url: "https://api.user.1688sup.com/v1/login/phone"
phone: "ORlviZN7N06W2+WKLe76xg=="
password: "V5Uh8C4bamEM6UQZh4TCeQ=="
code: "456789"
check_token_url: "https://api.user.1688sup.com/v1/user/welcome"

View File

@ -26,10 +26,14 @@ coze:
lsxd:
# 统一登录
login_url: "https://api.user.1688sup.com/v1/login/phone"
phone: "ORlviZN7N06W2+WKLe76xg=="
password: "V5Uh8C4bamEM6UQZh4TCeQ=="
check_token_url: "https://api.user.1688sup.com/v1/user/welcome"
login_url: "http://api.test.user.1688sup.com/v1/login/phone"
phone: "OFJ8UpqOlI7+w3Qklf36ZA=="
password: "tEbFegH/DRRh6LutFb7o3g=="
code: "123456"
check_token_url: "http://api.test.user.1688sup.com/v1/user/welcome"
zltx:
req_url: "https://gateway.dev.cdlsxd.cn/zltx_api"
sys:
session_len: 6
@ -41,7 +45,7 @@ redis:
host: 47.97.27.195:6379
type: node
pass: lansexiongdi@666
key: report-api-test
key: ai_scheduler-test
pollSize: 5 #连接池大小不配置或配置为0表示不启用连接池
minIdleConns: 2 #最小空闲连接数
maxIdleTime: 30 #每个连接最大空闲时间,如果超过了这个时间会被关闭

View File

@ -29,21 +29,22 @@ import (
// AiRouterBiz 智能路由服务
type DingTalkBotBiz struct {
do *do.Do
handle *do.Handle
botConfigImpl *impl.BotConfigImpl
replier *chatbot.ChatbotReplier
log log.Logger
dingTalkUser *dingtalk.User
botGroupImpl *impl.BotGroupImpl
botGroupConfigImpl *impl.BotGroupConfigImpl
botGroupQywxImpl *impl.BotGroupQywxImpl
toolManager *tools.Manager
chatHis *impl.BotChatHisImpl
conf *config.Config
cardSend *dingtalk.SendCardClient
qywxGroupHandle *qywx.Group
groupConfigBiz *GroupConfigBiz
do *do.Do
handle *do.Handle
botConfigImpl *impl.BotConfigImpl
replier *chatbot.ChatbotReplier
log log.Logger
dingTalkUser *dingtalk.User
botGroupImpl *impl.BotGroupImpl
botGroupConfigImpl *impl.BotGroupConfigImpl
botGroupQywxImpl *impl.BotGroupQywxImpl
toolManager *tools.Manager
chatHis *impl.BotChatHisImpl
conf *config.Config
cardSend *dingtalk.SendCardClient
qywxGroupHandle *qywx.Group
groupConfigBiz *GroupConfigBiz
reportDailyCacheImpl *impl.ReportDailyCacheImpl
}
// NewDingTalkBotBiz
@ -54,23 +55,25 @@ func NewDingTalkBotBiz(
botGroupImpl *impl.BotGroupImpl,
dingTalkUser *dingtalk.User,
chatHis *impl.BotChatHisImpl,
reportDailyCacheImpl *impl.ReportDailyCacheImpl,
toolManager *tools.Manager,
conf *config.Config,
cardSend *dingtalk.SendCardClient,
groupConfigBiz *GroupConfigBiz,
) *DingTalkBotBiz {
return &DingTalkBotBiz{
do: do,
handle: handle,
botConfigImpl: botConfigImpl,
replier: chatbot.NewChatbotReplier(),
dingTalkUser: dingTalkUser,
groupConfigBiz: groupConfigBiz,
botGroupImpl: botGroupImpl,
toolManager: toolManager,
chatHis: chatHis,
conf: conf,
cardSend: cardSend,
do: do,
handle: handle,
botConfigImpl: botConfigImpl,
replier: chatbot.NewChatbotReplier(),
dingTalkUser: dingTalkUser,
groupConfigBiz: groupConfigBiz,
botGroupImpl: botGroupImpl,
toolManager: toolManager,
chatHis: chatHis,
conf: conf,
cardSend: cardSend,
reportDailyCacheImpl: reportDailyCacheImpl,
}
}
@ -197,6 +200,49 @@ func (d *DingTalkBotBiz) Macro(ctx context.Context, requireData *entitys.Require
return
}
if strings.Contains(content, "[负利润分析]获取") {
var (
data model.AiReportDailyCache
value map[int32]*bbxt.ResellerLossSumProductRelation
)
cond := builder.NewCond()
cond = cond.And(builder.Eq{"`index`": bbxt.IndexLossSumDetail})
cond = cond.And(builder.Eq{"`key`": time.Now().Format(time.DateOnly)})
err = d.reportDailyCacheImpl.GetOneBySearchToStrut(&cond, &data)
if err != nil {
entitys.ResText(requireData.Ch, "", "获取失败")
return
}
err = json.Unmarshal([]byte(data.Value), &value)
if err != nil {
entitys.ResText(requireData.Ch, "", "获取失败,格式解析错误")
return
}
return
}
if strings.Contains(content, "[负利润分析]更新") {
// 提取冒号后的内容
if len(groupConfig.ProductName) == 0 {
entitys.ResText(requireData.Ch, "", "暂未设置")
} else {
entitys.ResText(requireData.Ch, "", groupConfig.ProductName)
isFinish = true
}
return
}
if strings.Contains(content, "[负利润分析]同步") {
// 提取冒号后的内容
if len(groupConfig.ProductName) == 0 {
entitys.ResText(requireData.Ch, "", "暂未设置")
} else {
entitys.ResText(requireData.Ch, "", groupConfig.ProductName)
isFinish = true
}
return
}
return
}

View File

@ -9,11 +9,15 @@ import (
"ai_scheduler/internal/domain/workflow/recharge"
"ai_scheduler/internal/domain/workflow/runtime"
"ai_scheduler/internal/entitys"
"ai_scheduler/internal/pkg"
"ai_scheduler/internal/pkg/l_request"
"ai_scheduler/internal/pkg/lsxd"
"ai_scheduler/internal/pkg/utils_oss"
"ai_scheduler/internal/tools"
"ai_scheduler/internal/tools/bbxt"
"ai_scheduler/utils"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
@ -30,12 +34,14 @@ import (
// AiRouterBiz 智能路由服务
type GroupConfigBiz struct {
botGroupConfigImpl *impl.BotGroupConfigImpl
ossClient *utils_oss.Client
workflowManager *runtime.Registry
botTools []model.AiBotTool
toolManager *tools.Manager
conf *config.Config
botGroupConfigImpl *impl.BotGroupConfigImpl
reportDailyCacheImpl *impl.ReportDailyCacheImpl
ossClient *utils_oss.Client
workflowManager *runtime.Registry
botTools []model.AiBotTool
toolManager *tools.Manager
conf *config.Config
rdb *utils.Rdb
}
// NewDingTalkBotBiz
@ -45,13 +51,17 @@ func NewGroupConfigBiz(
botGroupConfigImpl *impl.BotGroupConfigImpl,
workflowManager *runtime.Registry,
conf *config.Config,
reportDailyCacheImpl *impl.ReportDailyCacheImpl,
rdb *utils.Rdb,
) *GroupConfigBiz {
return &GroupConfigBiz{
botTools: tools.BootTools,
ossClient: ossClient,
botGroupConfigImpl: botGroupConfigImpl,
workflowManager: workflowManager,
conf: conf,
botTools: tools.BootTools,
ossClient: ossClient,
botGroupConfigImpl: botGroupConfigImpl,
workflowManager: workflowManager,
conf: conf,
reportDailyCacheImpl: reportDailyCacheImpl,
rdb: rdb,
}
}
@ -72,12 +82,12 @@ func (g *GroupConfigBiz) GetReportLists(ctx context.Context, groupConfig *model.
product = strings.Split(groupConfig.ProductName, ",")
}
reportList, err := bbxt.NewBbxtTools(g.conf)
reportList, err := bbxt.NewBbxtTools(g.conf, lsxd.NewLogin(g.conf, g.rdb))
if err != nil {
return
}
reports, err = reportList.DailyReport(time.Now(), bbxt.DownWardValue, product, bbxt.SumFilter, g.ossClient)
reports, err = reportList.DailyReport(ctx, time.Now(), bbxt.DownWardValue, product, bbxt.SumFilter, g.ossClient, g.GetReportCache)
if err != nil {
return
}
@ -145,7 +155,8 @@ func (g *GroupConfigBiz) handleReport(ctx context.Context, rec *entitys.Recogniz
}
}
}
rep, err := bbxt.NewBbxtTools(g.conf)
rep, err := bbxt.NewBbxtTools(g.conf, lsxd.NewLogin(g.conf, g.rdb))
uploader := bbxt.NewUploader(g.ossClient, g.conf)
if err != nil {
return err
@ -153,7 +164,7 @@ func (g *GroupConfigBiz) handleReport(ctx context.Context, rec *entitys.Recogniz
var reports []*bbxt.ReportRes
switch rec.Match.Index {
case "report_loss_analysis":
repo, _err := rep.StatisOursProductLossSum(t)
repo, _err := rep.StatisOursProductLossSum(ctx, t, g.GetReportCache)
if _err != nil {
return _err
}
@ -174,7 +185,7 @@ func (g *GroupConfigBiz) handleReport(ctx context.Context, rec *entitys.Recogniz
reports = append(reports, repo)
case "report_daily":
product := strings.Split(groupConfig.ProductName, ",")
repo, _err := rep.DailyReport(t, bbxt.DownWardValue, product, bbxt.SumFilter, nil)
repo, _err := rep.DailyReport(ctx, t, bbxt.DownWardValue, product, bbxt.SumFilter, nil, g.GetReportCache)
if _err != nil {
return _err
}
@ -404,3 +415,47 @@ func (g *GroupConfigBiz) otherTask(ctx context.Context, rec *entitys.Recognize)
entitys.ResText(rec.Ch, "", rec.Match.Reasoning)
return
}
func (g *GroupConfigBiz) GetReportCache(ctx context.Context, day time.Time, totalDetail []*bbxt.ResellerLoss, bbxtObj *bbxt.BbxtTools) error {
var ResellerProductRelation map[int32]*bbxt.ResellerLossSumProductRelation
dayDate := day.Format(time.DateOnly)
cond := builder.NewCond()
cond = cond.And(builder.Eq{"index": bbxt.IndexLossSumDetail})
cond = cond.And(builder.Eq{"key": dayDate})
var cache model.AiReportDailyCache
err := g.reportDailyCacheImpl.GetOneBySearchToStrut(&cond, &cache)
if err != nil {
if errors.Is(sql.ErrNoRows, err) {
ResellerProductRelation, err = bbxtObj.GetResellerLossMannagerAndLossReasonFromApi(ctx, totalDetail)
if err != nil {
return err
}
cache = model.AiReportDailyCache{
Key: dayDate,
Index: bbxt.IndexLossSumDetail,
Value: pkg.JsonStringIgonErr(ResellerProductRelation),
}
_, err = g.reportDailyCacheImpl.Add(&cache)
}
} else {
err = json.Unmarshal([]byte(cache.Value), &ResellerProductRelation)
}
if err != nil {
return err
}
for _, v := range totalDetail {
if _, ex := ResellerProductRelation[v.ResellerId]; !ex {
continue
}
v.Manager = ResellerProductRelation[v.ResellerId].AfterSaleName
for _, vv := range v.ProductLoss {
if _, ex := ResellerProductRelation[v.ResellerId].Products[vv.ProductId]; !ex {
continue
}
vv.LossReason = ResellerProductRelation[v.ResellerId].Products[vv.ProductId].LossReason
}
}
return nil
}

View File

@ -27,6 +27,11 @@ type Config struct {
LLM LLM `mapstructure:"llm"`
Dingtalk DingtalkConfig `mapstructure:"dingtalk"`
Qywx QywxConfig `mapstructure:"qywx"`
ZLTX ZLTX `mapstructure:"zltx"`
}
type ZLTX struct {
ReqUrl string `mapstructure:"req_url"`
}
type SysPrompt struct {
@ -136,6 +141,7 @@ type LSXDConfig struct {
LoginURL string `mapstructure:"login_url"`
Phone string `mapstructure:"phone"`
Password string `mapstructure:"password"`
Code string `mapstructure:"code"`
CheckTokenURL string `mapstructure:"check_token_url"`
}

View File

@ -17,4 +17,5 @@ var ProviderImpl = wire.NewSet(
NewBotGroupImpl,
NewBotGroupConfigImpl,
NewBotGroupQywxImpl,
NewReportDailyCacheImpl,
)

View File

@ -0,0 +1,17 @@
package impl
import (
"ai_scheduler/internal/data/model"
"ai_scheduler/tmpl/dataTemp"
"ai_scheduler/utils"
)
type ReportDailyCacheImpl struct {
dataTemp.DataTemp
}
func NewReportDailyCacheImpl(db *utils.Db) *ReportDailyCacheImpl {
return &ReportDailyCacheImpl{
DataTemp: *dataTemp.NewDataTemp(db, new(model.AiReportDailyCache)),
}
}

View File

@ -0,0 +1,20 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package model
const TableNameAiReportDailyCache = "ai_report_daily_cache"
// AiReportDailyCache mapped from table <ai_report_daily_cache>
type AiReportDailyCache struct {
ID int32 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"`
Key string `gorm:"column:key;not null;default:1;comment:索引方式,可以是任意类型" json:"key"` // 索引方式,可以是任意类型
Value string `gorm:"column:value;comment:类型下所需路由以及参数" json:"value"` // 类型下所需路由以及参数
Index string `gorm:"column:index;not null;comment:类型" json:"index"` // 类型
}
// TableName AiReportDailyCache's table name
func (*AiReportDailyCache) TableName() string {
return TableNameAiReportDailyCache
}

View File

@ -95,30 +95,18 @@ func (l *Login) checkTokenValid(ctx context.Context, token string) bool {
// 调用登录接口获取token
func (l *Login) login(ctx context.Context) (string, error) {
// 1.获取配置
loginURL := l.config.LSXD.LoginURL
phone := l.config.LSXD.Phone
password := l.config.LSXD.Password
// 2.调用登录接口获取token
if loginURL == "" {
return "", errors.New("login url is empty")
}
if phone == "" || password == "" {
return "", errors.New("phone or password is empty")
}
reqBody := map[string]any{
"phone": phone,
"password": password,
"code": "456789",
"phone": l.config.LSXD.Phone,
"password": l.config.LSXD.Password,
"code": l.config.LSXD.Code,
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
status, respBody, err := l.doRequestWithBody(ctx, http.MethodPost, loginURL, "", "application/json", bodyBytes)
status, respBody, err := l.doRequestWithBody(ctx, http.MethodPost, l.config.LSXD.LoginURL, "", "application/json", bodyBytes)
if err != nil {
return "", err
}
@ -158,7 +146,7 @@ func (l *Login) login(ctx context.Context) (string, error) {
}
func (l *Login) getCachedToken(ctx context.Context) (string, error) {
token, err := l.redisCli.Get(ctx, constants.CACHE_KEY_LSXD_TOKEN).Result()
token, err := l.redisCli.Get(ctx, l.getCacheKey()).Result()
if err == nil {
return token, nil
}
@ -168,11 +156,16 @@ func (l *Login) getCachedToken(ctx context.Context) (string, error) {
return "", err
}
func (l *Login) getCacheKey() string {
return l.config.Redis.Key + constants.CACHE_KEY_LSXD_TOKEN + l.config.LSXD.Phone // 1.获取配置
}
func (l *Login) cacheToken(ctx context.Context, token string) error {
if token == "" {
return errors.New("token is empty")
}
return l.redisCli.Set(ctx, constants.CACHE_KEY_LSXD_TOKEN, token, constants.EXPIRE_LSXD_TOKEN).Err()
return l.redisCli.Set(ctx, l.getCacheKey(), token, constants.EXPIRE_LSXD_TOKEN).Err()
}
func (l *Login) doRequest(ctx context.Context, method string, url string, authorization string, body []byte) (int, error) {

View File

@ -1,6 +1,10 @@
package util
import "encoding/json"
import (
"encoding/json"
"reflect"
"strings"
)
// StructToMap 将结构体转换为 map[string]any
func StructToMap(v any) (map[string]any, error) {
@ -12,3 +16,28 @@ func StructToMap(v any) (map[string]any, error) {
err = json.Unmarshal(b, &m)
return m, err
}
func StructToMapWithReflect(obj interface{}) map[string]interface{} {
val := reflect.ValueOf(obj)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
if val.Kind() != reflect.Struct {
return nil
}
data := make(map[string]interface{})
for i := 0; i < val.NumField(); i++ {
valueField := val.Field(i)
typeField := val.Type().Field(i)
jsonTag := typeField.Tag.Get("json")
if idx := strings.Index(jsonTag, ","); idx != -1 {
jsonTag = jsonTag[:idx]
}
if !typeField.IsExported() {
continue
}
data[jsonTag] = valueField.Interface()
}
return data
}

View File

@ -3,6 +3,8 @@ package bbxt
import (
"ai_scheduler/internal/pkg"
"ai_scheduler/internal/pkg/l_request"
"ai_scheduler/internal/pkg/util"
"encoding/json"
"fmt"
"net/http"
@ -29,12 +31,23 @@ type StatisOursProductLossSumResponse struct {
}
const Base = "https://reportapi.1688sup.com/api"
const AuthUrl = "http://test.analysis.com/api"
// StatisOursProductLossSumApi 负利润分析
func StatisOursProductLossSumApi(param *StatisOursProductLossSumReq) (*StatisOursProductLossSumRes, error) {
url := "/dataanalytics/statisOursProductLossSum"
var res StatisOursProductLossSumRes
if err := request(url, param, &res); err != nil {
if err := request(url, param, &res, ""); err != nil {
return nil, err
}
return &res, nil
}
// StatisOursProductLossSumApi 负利润分析
func StatisOursProductLossSumApiWithAuth(param *StatisOursProductLossSumReq, token string) (*StatisOursProductLossSumRes, error) {
url := "/dataanalytics/statisOursProductLossSum"
var res StatisOursProductLossSumRes
if err := request(url, param, &res, token); err != nil {
return nil, err
}
return &res, nil
@ -73,7 +86,7 @@ type ProfitRankingSumResponse struct {
func GetProfitRankingSumApi(param *GetProfitRankingSumRequest) (*GetProfitRankingSumResponse, error) {
url := "/dataanalytics/profitRankingSum"
var res GetProfitRankingSumResponse
if err := request(url, param, &res); err != nil {
if err := request(url, param, &res, ""); err != nil {
return nil, err
}
return &res, nil
@ -106,7 +119,7 @@ type GetStatisOfficialProductSum struct {
func GetStatisOfficialProductSumApi(param *GetStatisOfficialProductSumRequest) (*GetStatisOfficialProductSumResponse, error) {
url := "/dataanalytics/statisOfficialProduct"
var res GetStatisOfficialProductSumResponse
if err := request(url, param, &res); err != nil {
if err := request(url, param, &res, ""); err != nil {
return nil, err
}
return &res, nil
@ -133,7 +146,7 @@ type GetStatisOfficialProductSumDecline struct {
func GetStatisOfficialProductSumDeclineApi(param *GetStatisOfficialProductSumRequest) (*GetStatisOfficialProductSumDeclineResponse, error) {
url := "/dataanalytics/statisOfficialProductDecline"
var res GetStatisOfficialProductSumDeclineResponse
if err := request(url, param, &res); err != nil {
if err := request(url, param, &res, ""); err != nil {
return nil, err
}
return &res, nil
@ -162,23 +175,119 @@ type StatisFilterOfficialProductResponse struct {
func GetStatisFilterOfficialProductApi(param *GetStatisFilterOfficialProductRequest) (*GetStatisFilterOfficialProductResponse, error) {
url := "/dataanalytics/statisFilterOfficialProduct"
var res GetStatisFilterOfficialProductResponse
if err := request(url, param, &res); err != nil {
if err := request(url, param, &res, ""); err != nil {
return nil, err
}
return &res, nil
}
func request(url string, reqData interface{}, resData interface{}) error {
//type GetManagerAndDefaultLossReasonRequest struct {
// ResellerId int32 ` json:"reseller_id"`
// GoodsIds int32 ` json:"reseller_id"`
//}
type GetManagerAndDefaultLossReasonRequest struct {
Param map[int32]map[string][]int32 ` json:"param"`
}
type GetManagerAndDefaultLossReasonResponse struct {
Res []*GetManagerAndDefaultLossReasonResponseList `json:"res,omitempty"`
}
type GetManagerAndDefaultLossReasonResponseList struct {
ResellerInfo *GetManagerAndDefaultLossReasonResponse_ResellerInfo `json:"GetManagerAndDefaultLossReasonResponse_ResellerInfo,omitempty"`
ProductList []*GetManagerAndDefaultLossReasonResponse_ProductList `json:"GetManagerAndDefaultLossReasonResponse_ProductList,omitempty"`
}
type GetManagerAndDefaultLossReasonResponse_ResellerInfo struct {
ResellerId int32 `json:"reseller_id,omitempty"`
AfterSaleName string `json:"after_sale_name,omitempty"`
}
type GetManagerAndDefaultLossReasonResponse_ProductList struct {
ProductId int32 `json:"product_id,omitempty"`
LossReason string `json:"loss_reason,omitempty"`
}
// GetStatisFilterOfficialProductApi 官方商品列表
func GetManagerAndDefaultLossReasonApi(param *GetManagerAndDefaultLossReasonRequest, token string, reqUrl string) ([]*GetManagerAndDefaultLossReasonResponseList, error) {
return []*GetManagerAndDefaultLossReasonResponseList{
{
ResellerInfo: &GetManagerAndDefaultLossReasonResponse_ResellerInfo{
ResellerId: 25009,
AfterSaleName: "张三",
},
ProductList: []*GetManagerAndDefaultLossReasonResponse_ProductList{
{
ProductId: 129,
LossReason: "小米钱包h5-QQ音乐绿钻季卡原因",
},
{
ProductId: 2218,
LossReason: "小米钱包h5-百度网盘新vip会员月卡原因",
},
{
ProductId: 3226,
LossReason: "小米钱包h5-腾讯视频月卡-小米钱包2024原因",
},
{
ProductId: 3364,
LossReason: "小米钱包h5-腾讯视频月卡-0元直充原因",
},
},
},
}, nil
reqParam, err := util.StructToMap(param)
if err != nil {
return nil, err
}
req := &l_request.Request{
Url: reqUrl + "/admin/reseller/resellerAuthProduct/getManagerAndDefaultLossReason",
Method: http.MethodPost,
Json: reqParam,
Headers: map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", token),
},
}
res, err := req.Send()
if err != nil {
return nil, err
}
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("request failed, status code: %d,resion: %s", res.StatusCode, res.Reason)
}
var code resCode
if err = json.Unmarshal(res.Content, &code); err != nil {
return nil, fmt.Errorf("返回结构异常:%s", string(res.Content))
}
var resData []*GetManagerAndDefaultLossReasonResponseList
if err = json.Unmarshal(code.Data, &resData); err != nil {
return nil, fmt.Errorf("返回数据异常:%s", string(res.Content))
}
return resData, nil
}
func request(url string, reqData interface{}, resData interface{}, token string) error {
requestSchema := Base
if len(token) > 0 {
requestSchema = AuthUrl
}
reqParam, err := pkg.StructToURLValues(reqData)
if err != nil {
return err
}
req := &l_request.Request{
Url: FormatPHPURL(Base+url, reqParam),
Url: FormatPHPURL(requestSchema+url, reqParam),
Method: http.MethodGet,
}
if len(token) > 0 {
req.Headers = map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", token),
}
}
res, err := req.Send()
if err != nil {
return err

View File

@ -3,8 +3,10 @@ package bbxt
import (
"ai_scheduler/internal/config"
pkginner "ai_scheduler/internal/pkg"
"ai_scheduler/internal/pkg/lsxd"
"ai_scheduler/internal/pkg/utils_oss"
"ai_scheduler/pkg"
"context"
"fmt"
"math/rand"
"slices"
@ -18,18 +20,17 @@ const (
GreenStyle = "${color: 008000;horizontal:center;vertical:center;borderColor:#000000}"
)
const (
IndexLossSumDetail = "lossSumDetail"
)
type LossSumInitFunc func(ctx context.Context, day time.Time, totalDetail []*ResellerLoss, selfObj *BbxtTools) error
var (
DownWardValue int32 = 1000
SumFilter int32 = -150
)
var resellerBlackList = []string{
"悦跑",
"电商-独立",
"蓝星严选连续包月",
"通钱-2025年12月",
}
var resellerBlackListProduct = []string{
"悦跑",
"电商-独立",
@ -43,9 +44,10 @@ type BbxtTools struct {
excelTempDir string
ossClient *utils_oss.Client
config *config.Config
login *lsxd.Login
}
func NewBbxtTools(config *config.Config) (*BbxtTools, error) {
func NewBbxtTools(config *config.Config, login *lsxd.Login) (*BbxtTools, error) {
cache, err := pkg.GetCacheDir()
if err != nil {
return nil, err
@ -59,12 +61,21 @@ func NewBbxtTools(config *config.Config) (*BbxtTools, error) {
cacheDir: cache,
excelTempDir: fmt.Sprintf("%s/excel_temp", tempDir),
config: config,
login: login,
}, nil
}
func (b *BbxtTools) DailyReport(now time.Time, downWardValue int32, productName []string, sumFilter int32, ossClient *utils_oss.Client) (reports []*ReportRes, err error) {
func (b *BbxtTools) DailyReport(
ctx context.Context,
now time.Time,
downWardValue int32,
productName []string,
sumFilter int32,
ossClient *utils_oss.Client,
initFunc LossSumInitFunc,
) (reports []*ReportRes, err error) {
reports = make([]*ReportRes, 0, 4)
productLossReport, err := b.StatisOursProductLossSum(now)
productLossReport, err := b.StatisOursProductLossSum(ctx, now, initFunc)
if err != nil {
return
}
@ -94,7 +105,7 @@ func (b *BbxtTools) DailyReport(now time.Time, downWardValue int32, productName
}
// StatisOursProductLossSum 负利润分析
func (b *BbxtTools) StatisOursProductLossSum(now time.Time) (report []*ReportRes, err error) {
func (b *BbxtTools) StatisOursProductLossSum(ctx context.Context, now time.Time, initFunc LossSumInitFunc) (report []*ReportRes, err error) {
ct := []string{
time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Format("2006-01-02 15:04:05"),
adjustedTime(now), //adjustedTime(time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())),
@ -110,6 +121,7 @@ func (b *BbxtTools) StatisOursProductLossSum(now time.Time) (report []*ReportRes
resellerMap = make(map[int32]*ResellerLoss)
total [][]string
gt []*ResellerLoss
totalDetail []*ResellerLoss
)
for _, info := range data.List {
@ -119,8 +131,8 @@ func (b *BbxtTools) StatisOursProductLossSum(now time.Time) (report []*ReportRes
resellerMap[info.ResellerId] = &ResellerLoss{
ResellerId: info.ResellerId,
ResellerName: info.ResellerName,
Total: 0, // 初始化为0后续累加
ProductLoss: make(map[int32]ProductLoss), // 初始化map
Total: 0, // 初始化为0后续累加
ProductLoss: make(map[int32]*ProductLoss), // 初始化map
}
}
@ -133,7 +145,7 @@ func (b *BbxtTools) StatisOursProductLossSum(now time.Time) (report []*ReportRes
// 检查产品是否已存在
if _, ok := reseller.ProductLoss[info.OursProductId]; !ok {
// 创建新的产品亏损记录
reseller.ProductLoss[info.OursProductId] = ProductLoss{
reseller.ProductLoss[info.OursProductId] = &ProductLoss{
ProductId: info.OursProductId,
ProductName: info.OursProductName,
Loss: info.Loss, // 初始化为当前产品的亏损
@ -155,7 +167,8 @@ func (b *BbxtTools) StatisOursProductLossSum(now time.Time) (report []*ReportRes
return resellers[i].Total < resellers[j].Total
})
var (
totalSum float64
totalSum float64
totalSum500 float64
)
// 构建分组
@ -166,46 +179,116 @@ func (b *BbxtTools) StatisOursProductLossSum(now time.Time) (report []*ReportRes
fmt.Sprintf("%.2f", v.Total),
})
totalSum += v.Total
totalDetail = append(totalDetail, v)
}
if v.Total <= -500 && !slices.Contains(resellerBlackListProduct, v.ResellerName) {
gt = append(gt, v)
totalSum500 += v.Total
}
}
report = make([]*ReportRes, 2)
report = make([]*ReportRes, 3)
timeCh := now.Format("1月2日15点")
//总量生成excel
if len(total) > 0 {
filePath := b.cacheDir + "/kshj_total" + fmt.Sprintf("%d%d", time.Now().Unix(), rand.Intn(1000)) + ".xlsx"
err = b.SimpleFillExcelWithTitle(b.excelTempDir+"/"+"kshj_total.xlsx", filePath, total, "")
report[0] = &ReportRes{
ReportName: "负利润分析(合计表)",
Title: "截至" + timeCh + "利润累计亏损" + fmt.Sprintf("%.2f", totalSum),
Path: filePath,
Data: total,
}
}
//if len(total) > 0 {
// filePath := b.cacheDir + "/kshj_total" + fmt.Sprintf("%d%d", time.Now().Unix(), rand.Intn(1000)) + ".xlsx"
// err = b.SimpleFillExcelWithTitle(b.excelTempDir+"/"+"kshj_total.xlsx", filePath, total, "")
// if err != nil {
// return
// }
// report[0] = &ReportRes{
// ReportName: "分销商负利润统计",
// Title: "截至" + timeCh + "利润累计亏损" + fmt.Sprintf("%.2f", totalSum),
// Path: filePath,
// Data: total,
// }
//}
if err != nil {
return
}
if len(gt) > 0 {
filePath := b.cacheDir + "/kshj_gt" + fmt.Sprintf("%d%d", time.Now().Unix(), rand.Intn(1000)) + ".xlsx"
title := "截至" + timeCh + "亏损500以上的分销商和产品"
err = b.resellerDetailFillExcelV2(b.excelTempDir+"/"+"kshj_gt.xlsx", filePath, gt, title)
report[1] = &ReportRes{
ReportName: "负利润分析(亏损500以上)",
Title: "截至" + timeCh + "亏损500以上利润累计亏损" + fmt.Sprintf("%.2f", totalSum500),
//if len(gt) > 0 {
// filePath := b.cacheDir + "/kshj_gt" + fmt.Sprintf("%d%d", time.Now().Unix(), rand.Intn(1000)) + ".xlsx"
// title := "截至" + timeCh + "亏损500以上的分销商和产品"
// err = b.resellerDetailFillExcelV2(b.excelTempDir+"/"+"kshj_gt.xlsx", filePath, gt, title)
// if err != nil {
// return
// }
// report[1] = &ReportRes{
// ReportName: "负利润分析(亏损500以上)",
// Title: "截至" + timeCh + "亏损500以上利润累计亏损" + fmt.Sprintf("%.2f", totalSum500),
// Path: filePath,
// Data: total,
// }
//}
if len(totalDetail) > 0 {
err = initFunc(ctx, now, totalDetail, b)
if err != nil {
return
}
filePath := b.cacheDir + "/kshj_total_ana" + fmt.Sprintf("%d%d", time.Now().Unix(), rand.Intn(1000)) + ".xlsx"
title := "截至" + timeCh + "亏损100以上的分销商&产品负利润原因"
err = b.resellerDetailFillExcelAna(b.excelTempDir+"/"+"kshj_total_ana.xlsx", filePath, totalDetail, title)
if err != nil {
return
}
report[2] = &ReportRes{
ReportName: "负利润分析(亏损100以上)",
Title: "截至" + timeCh + "亏损100以上利润原因",
Path: filePath,
Data: total,
}
}
if err != nil {
return
}
return report, nil
}
func (b *BbxtTools) GetResellerLossMannagerAndLossReasonFromApi(ctx context.Context, resellerLoss []*ResellerLoss) (map[int32]*ResellerLossSumProductRelation, error) {
var (
resellerMap = make(map[int32]map[string][]int32)
resellerLossMap = make(map[int32]*ResellerLoss, len(resellerLoss))
relationMap = make(map[int32]*ResellerLossSumProductRelation)
)
for _, v := range resellerLoss {
var productSlice = make([]int32, 0, len(v.ProductLoss))
for _, vv := range v.ProductLoss {
productSlice = append(productSlice, vv.ProductId)
}
if _, ex := resellerMap[v.ResellerId]; !ex {
resellerMap[v.ResellerId] = make(map[string][]int32, 1)
}
resellerMap[v.ResellerId]["values"] = productSlice
resellerLossMap[v.ResellerId] = v
relationMap[v.ResellerId] = &ResellerLossSumProductRelation{
ResellerName: v.ResellerName,
Products: make(map[int32]*LossReason, len(v.ProductLoss)),
}
for _, product := range v.ProductLoss {
relationMap[v.ResellerId].Products[product.ProductId] = &LossReason{
ProductName: product.ProductName,
LossReason: "未填写", // 初始化为未填写
}
}
}
res, err := GetManagerAndDefaultLossReasonApi(&GetManagerAndDefaultLossReasonRequest{
Param: resellerMap,
}, b.login.GetToken(ctx), b.config.ZLTX.ReqUrl)
if err != nil {
return nil, err
}
for _, v := range res {
if v == nil {
continue
}
if _, ok := resellerLossMap[v.ResellerInfo.ResellerId]; !ok {
continue
}
relationMap[v.ResellerInfo.ResellerId].AfterSaleName = v.ResellerInfo.AfterSaleName
for _, vv := range v.ProductList {
relationMap[v.ResellerInfo.ResellerId].Products[vv.ProductId].LossReason = vv.LossReason
}
}
return relationMap, nil
}
// GetProfitRankingSum 利润同比分销商排行榜
func (b *BbxtTools) GetProfitRankingSum(now time.Time) (report *ReportRes, err error) {

View File

@ -2,10 +2,19 @@ package bbxt
import (
"ai_scheduler/internal/config"
"ai_scheduler/internal/data/impl"
"ai_scheduler/internal/data/model"
"ai_scheduler/internal/pkg"
"ai_scheduler/internal/pkg/lsxd"
"ai_scheduler/internal/pkg/utils_oss"
"ai_scheduler/utils"
"context"
"encoding/json"
"strings"
"testing"
"time"
"xorm.io/builder"
)
func Test_StatisOursProductLossSumApiTotal(t *testing.T) {
@ -23,29 +32,30 @@ func Test_StatisOursProductLossSumApiTotal(t *testing.T) {
if err != nil {
panic(err)
}
o, err := NewBbxtTools(nil)
o, err := NewBbxtTools(nil, nil)
if err != nil {
panic(err)
}
reports, err := o.DailyReport(time.Now(), DownWardValue, []string{"官方-爱奇艺-星钻季卡", "官方-爱奇艺-星钻半年卡", "官方--腾讯-年卡", "官方--爱奇艺-月卡"}, SumFilter, ossClient)
reports, err := o.DailyReport(context.Background(), time.Now(), DownWardValue, []string{"官方-爱奇艺-星钻季卡", "官方-爱奇艺-星钻半年卡", "官方--腾讯-年卡", "官方--爱奇艺-月卡"}, SumFilter, ossClient, GetReportCache)
t.Log(reports, err)
}
func Test_StatisOursProductLossSum(t *testing.T) {
o, err := NewBbxtTools(nil)
run()
o, err := NewBbxtTools(configConfig, lsxd.NewLogin(configConfig, utils.NewRdb(configConfig)))
if err != nil {
panic(err)
}
report, err := o.StatisOursProductLossSum(time.Now())
report, err := o.StatisOursProductLossSum(context.Background(), time.Now(), GetReportCache)
t.Log(report, err)
}
func Test_GetProfitRankingSum(t *testing.T) {
o, err := NewBbxtTools(nil)
o, err := NewBbxtTools(nil, nil)
if err != nil {
panic(err)
}
@ -56,7 +66,7 @@ func Test_GetProfitRankingSum(t *testing.T) {
}
func Test_GetStatisOfficialProductSumDecline(t *testing.T) {
o, err := NewBbxtTools(nil)
o, err := NewBbxtTools(nil, nil)
if err != nil {
panic(err)
}
@ -69,7 +79,9 @@ func Test_GetStatisOfficialProductSumDecline(t *testing.T) {
}
func Test_GetStatisOfficialProductSum(t *testing.T) {
o, err := NewBbxtTools(nil)
configs := configConfig
o, err := NewBbxtTools(nil, lsxd.NewLogin(configs, utils.NewRdb(configConfig)))
if err != nil {
panic(err)
}
@ -79,3 +91,62 @@ func Test_GetStatisOfficialProductSum(t *testing.T) {
t.Log(report, err)
}
var (
reportDailyCacheImpl *impl.ReportDailyCacheImpl
configConfig *config.Config
)
func run() {
configConfig, _ = config.LoadConfigWithTest()
// 初始化数据库连接
db, _ := utils.NewGormDb(configConfig)
reportDailyCacheImpl = impl.NewReportDailyCacheImpl(db)
}
func GetReportCache(ctx context.Context, day time.Time, totalDetail []*ResellerLoss, bbxtObj *BbxtTools) error {
run()
var ResellerProductRelation map[int32]*ResellerLossSumProductRelation
dayDate := day.Format(time.DateOnly)
cond := builder.NewCond()
cond = cond.And(builder.Eq{"`index`": IndexLossSumDetail})
cond = cond.And(builder.Eq{"`key`": dayDate})
var cache model.AiReportDailyCache
err := reportDailyCacheImpl.GetOneBySearchToStrut(&cond, &cache)
if err != nil {
return err
}
if cache.Value == "" {
ResellerProductRelation, err = bbxtObj.GetResellerLossMannagerAndLossReasonFromApi(ctx, totalDetail)
if err != nil {
return err
}
cache = model.AiReportDailyCache{
Key: dayDate,
Index: IndexLossSumDetail,
Value: pkg.JsonStringIgonErr(ResellerProductRelation),
}
_, err = reportDailyCacheImpl.Add(&cache)
if err != nil {
return err
}
}
err = json.Unmarshal([]byte(cache.Value), &ResellerProductRelation)
for _, v := range totalDetail {
if _, ex := ResellerProductRelation[v.ResellerId]; !ex {
continue
}
v.Manager = ResellerProductRelation[v.ResellerId].AfterSaleName
for _, vv := range v.ProductLoss {
if _, ex := ResellerProductRelation[v.ResellerId].Products[vv.ProductId]; !ex {
continue
}
vv.LossReason = ResellerProductRelation[v.ResellerId].Products[vv.ProductId].LossReason
}
}
return nil
}

View File

@ -4,13 +4,15 @@ type ResellerLoss struct {
ResellerId int32
ResellerName string
Total float64
ProductLoss map[int32]ProductLoss
ProductLoss map[int32]*ProductLoss
Manager string
}
type ProductLoss struct {
ProductId int32
ProductName string
Loss float64
LossReason string
}
type ReportRes struct {
@ -37,3 +39,14 @@ type ProductSumReseller struct {
HistoryTwoNum int32 //上周成功数量
HistoryTwoDiff int32 //同比上周当前增减量
}
type ResellerLossSumProductRelation struct {
AfterSaleName string `json:"after_sale_name"`
ResellerName string `json:"reseller_name"`
Products map[int32]*LossReason
}
type LossReason struct {
ProductName string
LossReason string
}

View File

@ -242,7 +242,7 @@ func (b *BbxtTools) resellerDetailFillExcelV2(templatePath, outputPath string, d
for _, reseller := range dataSlice {
// 排序 ProductLoss
var products []ProductLoss
var products []*ProductLoss
for _, p := range reseller.ProductLoss {
products = append(products, p)
}
@ -311,4 +311,164 @@ func (b *BbxtTools) resellerDetailFillExcelV2(templatePath, outputPath string, d
return f.SaveAs(outputPath)
}
// OfficialProductSumDeclineExcel
func (b *BbxtTools) resellerDetailFillExcelAna(templatePath, outputPath string, dataSlice []*ResellerLoss, title string) error {
// 1. 读取模板
f, err := excelize.OpenFile(templatePath)
if err != nil {
return err
}
defer f.Close()
sheet := f.GetSheetName(0)
if len(title) > 0 {
// 写入标题
f.SetCellValue(sheet, "A1", title)
}
// ---------------- 样式获取 ----------------
// 模板第2行数据行样式
tplRowData := 3
styleA3, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", tplRowData))
if err != nil {
styleA3 = 0
}
// B2和C2通常样式一致这里取B2作为明细列样式
styleB3, err := f.GetCellStyle(sheet, fmt.Sprintf("B%d", tplRowData))
if err != nil {
styleB3 = 0
}
styleC3, err := f.GetCellStyle(sheet, fmt.Sprintf("C%d", tplRowData))
if err != nil {
styleC3 = 0
}
styleD3, err := f.GetCellStyle(sheet, fmt.Sprintf("D%d", tplRowData))
if err != nil {
styleC3 = 0
}
styleE3, err := f.GetCellStyle(sheet, fmt.Sprintf("E%d", tplRowData))
if err != nil {
styleC3 = 0
}
rowHeightData, err := f.GetRowHeight(sheet, tplRowData)
if err != nil {
rowHeightData = 20
}
// 模板第4行合计行样式
tplRowTotal := 5
styleTotalA, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", tplRowTotal))
if err != nil {
styleTotalA = 0
}
styleTotalB, err := f.GetCellStyle(sheet, fmt.Sprintf("B%d", tplRowTotal))
if err != nil {
styleTotalB = 0
}
styleTotalC, err := f.GetCellStyle(sheet, fmt.Sprintf("C%d", tplRowTotal))
if err != nil {
styleTotalC = 0
}
styleTotalD, err := f.GetCellStyle(sheet, fmt.Sprintf("D%d", tplRowTotal))
if err != nil {
styleTotalC = 0
}
styleTotalE, err := f.GetCellStyle(sheet, fmt.Sprintf("E%d", tplRowTotal))
if err != nil {
styleTotalC = 0
}
rowHeightTotal, err := f.GetRowHeight(sheet, tplRowTotal)
if err != nil {
rowHeightTotal = 30
}
// ----------------------------------------
currentRow := 3
totalLoss := 0.0
for _, reseller := range dataSlice {
// 排序 ProductLoss
var products []*ProductLoss
for _, p := range reseller.ProductLoss {
products = append(products, p)
}
sort.Slice(products, func(i, j int) bool {
return products[i].Loss < products[j].Loss
})
startRow := currentRow
// 填充该经销商的所有产品
for _, p := range products {
// 设置行高
f.SetRowHeight(sheet, currentRow, rowHeightData)
// 设置值
f.SetCellValue(sheet, fmt.Sprintf("A%d", currentRow), reseller.ResellerName)
f.SetCellValue(sheet, fmt.Sprintf("B%d", currentRow), p.ProductName)
f.SetCellValue(sheet, fmt.Sprintf("C%d", currentRow), p.Loss)
f.SetCellValue(sheet, fmt.Sprintf("D%d", currentRow), reseller.Manager)
f.SetCellValue(sheet, fmt.Sprintf("E%d", currentRow), p.LossReason)
// 设置样式
if styleA3 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("A%d", currentRow), styleA3)
}
if styleB3 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("B%d", currentRow), fmt.Sprintf("B%d", currentRow), styleB3)
}
if styleC3 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("C%d", currentRow), fmt.Sprintf("C%d", currentRow), styleC3)
}
if styleD3 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("D%d", currentRow), fmt.Sprintf("D%d", currentRow), styleD3)
}
if styleE3 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("E%d", currentRow), fmt.Sprintf("E%d", currentRow), styleE3)
}
totalLoss += p.Loss
currentRow++
}
endRow := currentRow - 1
// 合并单元格 (如果多于1行)
if endRow > startRow {
f.MergeCell(sheet, fmt.Sprintf("A%d", startRow), fmt.Sprintf("A%d", endRow))
f.MergeCell(sheet, fmt.Sprintf("D%d", startRow), fmt.Sprintf("D%d", endRow))
}
}
// ---------------- 填充合计行 ----------------
// 四舍五入保留四位小数
totalLoss, _ = decimal.NewFromFloat(totalLoss).Round(4).Float64()
// 设置行高
f.SetRowHeight(sheet, currentRow, rowHeightTotal)
f.SetCellValue(sheet, fmt.Sprintf("A%d", currentRow), "合计")
// B列留空C列填充总亏损
f.SetCellValue(sheet, fmt.Sprintf("C%d", currentRow), totalLoss)
// 设置合计行样式
if styleTotalA != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("A%d", currentRow), styleTotalA)
}
if styleTotalB != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("B%d", currentRow), fmt.Sprintf("B%d", currentRow), styleTotalB)
}
if styleTotalC != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("C%d", currentRow), fmt.Sprintf("C%d", currentRow), styleTotalC)
}
if styleTotalD != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("D%d", currentRow), fmt.Sprintf("D%d", currentRow), styleTotalD)
}
if styleTotalE != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("E%d", currentRow), fmt.Sprintf("E%d", currentRow), styleTotalE)
}
// 取消合并合计行的A、B列
// f.MergeCell(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow))
// 6. 保存
return f.SaveAs(outputPath)
}

Binary file not shown.