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 }