445 lines
13 KiB
Go
445 lines
13 KiB
Go
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
|
||
}
|