ai_scheduler/internal/tools/bbxt/excel.go

445 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package bbxt
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"github.com/go-kratos/kratos/v2/log"
"github.com/shopspring/decimal"
"github.com/xuri/excelize/v2"
)
// 最简单的通用函数
func (b *BbxtTools) SimpleFillExcel(templatePath, outputPath string, dataSlice interface{}) error {
// 1. 打开模板
f, err := excelize.OpenFile(templatePath)
if err != nil {
return err
}
defer f.Close()
sheet := f.GetSheetName(0)
// 1.1 获取第二行模板样式
resellerTplRow := 2
styleIDReseller, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", resellerTplRow))
if err != nil {
log.Errorf("获取分销商总计样式失败: %v", err)
styleIDReseller = 0
}
// 1.2 获取分销商总计行高
rowHeightReseller, err := f.GetRowHeight(sheet, resellerTplRow)
if err != nil {
log.Errorf("获取分销商总计行高失败: %v", err)
rowHeightReseller = 31 // 默认高度
}
// 2. 反射获取切片数据
v := reflect.ValueOf(dataSlice)
if v.Kind() != reflect.Slice {
return fmt.Errorf("dataSlice must be a slice")
}
// 3. 从第2行开始填充
row := 2
for i := 0; i < v.Len(); i++ {
item := v.Index(i).Interface()
currentRow := row + i
// 4. 将item转换为一行数据
var rowData []interface{}
// 如果是切片
if reflect.TypeOf(item).Kind() == reflect.Slice {
itemV := reflect.ValueOf(item)
for j := 0; j < itemV.Len(); j++ {
rowData = append(rowData, itemV.Index(j).Interface())
}
} else if reflect.TypeOf(item).Kind() == reflect.Struct {
itemV := reflect.ValueOf(item)
for j := 0; j < itemV.NumField(); j++ {
if itemV.Field(j).CanInterface() {
rowData = append(rowData, itemV.Field(j).Interface())
}
}
} else {
rowData = []interface{}{item}
}
// 4.1 设置行高
f.SetRowHeight(sheet, currentRow, rowHeightReseller)
// 5. 填充到Excel
for col, value := range rowData {
cell := fmt.Sprintf("%c%d", 'A'+col, currentRow)
f.SetCellValue(sheet, cell, value)
}
// 5.1 使用第二行模板样式
if styleIDReseller != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow), styleIDReseller)
}
}
excelBytes, err := f.WriteToBuffer()
if err != nil {
return fmt.Errorf("write to bytes failed: %v", err)
}
picBytes, err := b.excel2picPy(templatePath, excelBytes.Bytes())
if err != nil {
return fmt.Errorf("excel2picPy failed: %v", err)
}
// b.savePic("temp.png", picBytes) // 本地生成图片,仅测试
// outputPath 提取文件名(不包含扩展名)
filename := filepath.Base(outputPath)
filename = strings.TrimSuffix(filename, filepath.Ext(filename))
imgUrl := b.uploadToOSS(filename, picBytes)
log.Infof("imgUrl: %s", imgUrl)
// 6. 保存
return f.SaveAs(outputPath)
}
// 分销商负利润详情填充excel
// 1.使用模板文件作为输出文件
// 2.分销商总计使用第二行样式(宽高、背景、颜色等)
// 3.商品详情使用第三行样式(宽高、背景、颜色等)
// 4.保存为新文件
func (b *BbxtTools) resellerDetailFillExcel(templatePath, outputPath string, dataSlice []*ResellerLoss) error {
// 1. 读取模板
f, err := excelize.OpenFile(templatePath)
if err != nil {
return err
}
defer f.Close()
sheet := f.GetSheetName(0)
// 获取模板样式1第二行-分销商总计
resellerTplRow := 2
styleIDReseller, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", resellerTplRow))
if err != nil {
log.Errorf("获取分销商总计样式失败: %v", err)
styleIDReseller = 0
}
rowHeightReseller, err := f.GetRowHeight(sheet, resellerTplRow)
if err != nil {
log.Errorf("获取分销商总计行高失败: %v", err)
rowHeightReseller = 31 // 默认高度
}
// 获取模板样式2第三行-产品亏损明细
productTplRow := 3
styleIDProduct, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", productTplRow))
if err != nil {
log.Errorf("获取商品详情样式失败: %v", err)
styleIDProduct = 0
}
rowHeightProduct, err := f.GetRowHeight(sheet, productTplRow)
if err != nil {
log.Errorf("获取商品详情行高失败: %v", err)
rowHeightProduct = 25 // 默认高度
}
currentRow := 2
for _, reseller := range dataSlice {
// 3. 填充经销商数据 (ResellerName, Total)
// 设置行高
f.SetRowHeight(sheet, currentRow, rowHeightReseller)
// 设置单元格值
f.SetCellValue(sheet, fmt.Sprintf("A%d", currentRow), reseller.ResellerName)
f.SetCellValue(sheet, fmt.Sprintf("B%d", currentRow), reseller.Total)
// 应用样式
if styleIDReseller != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow), styleIDReseller)
}
currentRow++
// 4. 填充产品亏损明细
// 先对 ProductLoss 进行排序
var products []ProductLoss
for _, p := range reseller.ProductLoss {
products = append(products, p)
}
// 按 Loss 升序排序 (亏损越多越靠前,负数越小)
sort.Slice(products, func(i, j int) bool {
return products[i].Loss < products[j].Loss
})
for _, p := range products {
// 设置行高
f.SetRowHeight(sheet, currentRow, rowHeightProduct)
// 设置单元格值
f.SetCellValue(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("·%s", p.ProductName))
f.SetCellValue(sheet, fmt.Sprintf("B%d", currentRow), p.Loss)
// 应用样式
if styleIDProduct != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow), styleIDProduct)
}
currentRow++
}
}
// 6. 保存
return f.SaveAs(outputPath)
}
// 分销商负利润详情填充excel-V2
// 1.使用模板文件作为输出文件,从第二行开始填充
// 2.整体为3列1.分销商名称以ResellerName为分组分销商名称列使用的样式为 2.商品名称p.ProductName 3.亏损金额p.Loss
// 3.分销商名称列使用的样式为 A2商品名称、亏损金额使用的样式为 B2、C2样式包括宽高、背景、颜色等
// 4.以ResellerName分组合并单元格
// 5.在文件末尾使用“合计”,合计行样式为模板第四行
// 6.保存为新文件
func (b *BbxtTools) resellerDetailFillExcelV2(templatePath, outputPath string, dataSlice []*ResellerLoss) error {
// 1. 读取模板
f, err := excelize.OpenFile(templatePath)
if err != nil {
return err
}
defer f.Close()
sheet := f.GetSheetName(0)
// ---------------- 样式获取 ----------------
// 模板第2行数据行样式
tplRowData := 2
styleA2, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", tplRowData))
if err != nil {
styleA2 = 0
}
// B2和C2通常样式一致这里取B2作为明细列样式
styleB2, err := f.GetCellStyle(sheet, fmt.Sprintf("B%d", tplRowData))
if err != nil {
styleB2 = 0
}
styleC2, err := f.GetCellStyle(sheet, fmt.Sprintf("C%d", tplRowData))
if err != nil {
styleC2 = 0
}
rowHeightData, err := f.GetRowHeight(sheet, tplRowData)
if err != nil {
rowHeightData = 20
}
// 模板第4行合计行样式
tplRowTotal := 4
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
}
rowHeightTotal, err := f.GetRowHeight(sheet, tplRowTotal)
if err != nil {
rowHeightTotal = 30
}
// ----------------------------------------
currentRow := 2
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)
// 设置样式
if styleA2 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("A%d", currentRow), styleA2)
}
if styleB2 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("B%d", currentRow), fmt.Sprintf("B%d", currentRow), styleB2)
}
if styleC2 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("C%d", currentRow), fmt.Sprintf("C%d", currentRow), styleC2)
}
totalLoss += p.Loss
currentRow++
}
endRow := currentRow - 1
// 合并单元格 (如果多于1行)
if endRow > startRow {
f.MergeCell(sheet, fmt.Sprintf("A%d", startRow), fmt.Sprintf("A%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)
}
// 取消合并合计行的A、B列
// f.MergeCell(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow))
excelBytes, err := f.WriteToBuffer()
if err != nil {
return fmt.Errorf("write to bytes failed: %v", err)
}
picBytes, err := b.excel2picPy(templatePath, excelBytes.Bytes())
if err != nil {
return fmt.Errorf("excel2picPy failed: %v", err)
}
// b.savePic("temp.png", picBytes) // 本地生成图片,仅测试
// outputPath 提取文件名(不包含扩展名)
filename := filepath.Base(outputPath)
filename = strings.TrimSuffix(filename, filepath.Ext(filename))
imgUrl := b.uploadToOSS(filename, picBytes)
log.Infof("imgUrl: %s", imgUrl)
// 6. 保存
return f.SaveAs(outputPath)
}
// excel2picPy 将excel转换为图片python
// python 接口如下:
// curl --location --request POST 'http://192.168.6.109:8010/api/v1/convert' \
// --header 'Content-Type: multipart/form-data; boundary=--------------------------952147881043913664015069' \
// --form 'file=@"C:\\Users\\Administrator\\Downloads\\销售同比分析2025-12-29 0-12点.xlsx"' \
// --form 'sheet_name="销售同比分析"'
func (b *BbxtTools) excel2picPy(templatePath string, excelBytes []byte) ([]byte, error) {
// 1. 获取 Sheet Name
// 尝试从 excelBytes 解析,如果失败则使用默认值 "Sheet1"
sheetName := "Sheet1"
f, err := excelize.OpenReader(bytes.NewReader(excelBytes))
if err == nil {
sheetName = f.GetSheetName(0)
if sheetName == "" {
sheetName = "Sheet1"
}
f.Close()
}
// 2. 构造 Multipart 请求
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// 添加文件字段
// 使用 templatePath 的文件名作为上传文件名,如果没有则用 default.xlsx
filename := "default.xlsx"
if templatePath != "" {
filename = filepath.Base(templatePath)
}
part, err := writer.CreateFormFile("file", filename)
if err != nil {
return nil, fmt.Errorf("create form file failed: %v", err)
}
if _, err = part.Write(excelBytes); err != nil {
return nil, fmt.Errorf("write file part failed: %v", err)
}
// 添加 sheet_name 字段
if err = writer.WriteField("sheet_name", sheetName); err != nil {
return nil, fmt.Errorf("write field sheet_name failed: %v", err)
}
if err = writer.Close(); err != nil {
return nil, fmt.Errorf("close writer failed: %v", err)
}
// 3. 发送 HTTP POST 请求
url := "http://192.168.6.109:8010/api/v1/convert"
req, err := http.NewRequest("POST", url, body)
if err != nil {
return nil, fmt.Errorf("create request failed: %v", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("send request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("api request failed with status: %d, body: %s", resp.StatusCode, string(respBody))
}
// 4. 读取响应 Body (图片内容)
picBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body failed: %v", err)
}
return picBytes, nil
}
// savePic 保存图片到本地
func (b *BbxtTools) savePic(outputPath string, picBytes []byte) error {
dir := filepath.Dir(outputPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory failed: %v", err)
}
return os.WriteFile(outputPath, picBytes, 0644)
}
// uploadToOSS 上传至 oss 返回图片url
func (b *BbxtTools) uploadToOSS(fileName string, fileBytes []byte) string {
objectKey := fmt.Sprintf("ai-scheduler/data-analytics/images/%s.png", fileName)
url, err := b.ossClient.UploadBytes(objectKey, fileBytes)
if err != nil {
log.Errorf("oss upload failed: %v", err)
return ""
}
return url
}