package main import ( "context" "encoding/base64" "encoding/json" "fmt" "mime" "net/http" "os" "os/exec" "path/filepath" "sort" "strings" "time" "github.com/wailsapp/wails/v2/pkg/runtime" "github.com/xuri/excelize/v2" ) type AfterSalesIssue struct { ID string `json:"id"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` ConversationID string `json:"conversationId"` RoomName string `json:"roomName"` SourceClientID int32 `json:"sourceClientId"` SourceAccountUserID string `json:"sourceAccountUserId"` SourceAccountName string `json:"sourceAccountName"` CustomerUserID string `json:"customerUserId"` CustomerName string `json:"customerName"` IssueContent string `json:"issueContent"` ImagePaths []string `json:"imagePaths"` ImageRefs []string `json:"imageRefs"` FileAttachments []AfterSalesFileAttachment `json:"fileAttachments"` AISuggestion string `json:"aiSuggestion"` Status string `json:"status"` SourceMessageIDs []string `json:"sourceMessageIds"` Fingerprint string `json:"fingerprint"` CollectBatchID string `json:"collectBatchId"` AIConfidence float64 `json:"aiConfidence"` AISuggestionEdited bool `json:"aiSuggestionEdited"` AssignedEngineerID string `json:"assignedEngineerId"` AssignedEngineerName string `json:"assignedEngineerName"` DispatchStatus string `json:"dispatchStatus"` DispatchReason string `json:"dispatchReason"` DispatchRuleID string `json:"dispatchRuleId"` DispatchConfidence float64 `json:"dispatchConfidence"` DispatchSource string `json:"dispatchSource"` NotifyStatus string `json:"notifyStatus"` LastNotifiedAt int64 `json:"lastNotifiedAt"` NotifyError string `json:"notifyError"` NotifyCount int `json:"notifyCount"` ResolutionContent string `json:"resolutionContent"` ResolvedAt string `json:"resolvedAt"` KnowledgeArchivedAt string `json:"knowledgeArchivedAt"` KnowledgeSourcePath string `json:"knowledgeSourcePath"` } type AfterSalesFileAttachment struct { Name string `json:"name"` Path string `json:"path"` Ref string `json:"ref"` Content string `json:"content"` ExtractStatus string `json:"extractStatus"` SourceMessageID string `json:"sourceMessageId"` } type AfterSalesHistoryImportRequest struct { ConversationID string `json:"conversationId"` RoomName string `json:"roomName"` RawText string `json:"rawText"` } type AfterSalesKnowledgeArchive struct { ID string `json:"id"` FileName string `json:"fileName"` Path string `json:"path"` CreatedAt string `json:"createdAt"` IssueCount int `json:"issueCount"` IssueIDs []string `json:"issueIds,omitempty"` MissingFile bool `json:"missingFile,omitempty"` DisplayTime string `json:"displayTime,omitempty"` } type AfterSalesKnowledgeCase struct { IssueID string `json:"issueId"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` ResolvedAt string `json:"resolvedAt"` KnowledgeArchivedAt string `json:"knowledgeArchivedAt"` ConversationID string `json:"conversationId"` RoomName string `json:"roomName"` CustomerUserID string `json:"customerUserId"` CustomerName string `json:"customerName"` IssueContent string `json:"issueContent"` AISuggestion string `json:"aiSuggestion"` ResolutionContent string `json:"resolutionContent"` AssignedEngineerID string `json:"assignedEngineerId"` AssignedEngineerName string `json:"assignedEngineerName"` ImageCount int `json:"imageCount"` MarkdownPath string `json:"markdownPath"` MissingMarkdown bool `json:"missingMarkdown,omitempty"` } type AfterSalesKnowledgeSummary struct { PendingCount int `json:"pendingCount"` TotalCount int `json:"totalCount"` ArchiveCount int `json:"archiveCount"` } type afterSalesKnowledgeManifest struct { Archives []AfterSalesKnowledgeArchive `json:"archives"` } // GetIssues returns all locally collected after-sales issues. func (a *App) GetIssues() []AfterSalesIssue { issues, err := a.fetchAfterSalesIssues() if err != nil { globalLogger.Warn("闂備礁鍚嬮崕鎶藉床閼艰翰浜归柛銉墮閼歌銇勯弬鍨倯闁稿﹦鏁诲濠氬磼閵堝懏鐝濆銈忕秬婵倝骞嗛弮鍫濈闁绘劖鍨濋弶顓㈡煟? %v", err) return []AfterSalesIssue{} } return issues } // SaveIssue creates or updates one after-sales issue. func (a *App) SaveIssue(issue AfterSalesIssue) (bool, string) { result, err := a.postHelperJSON("/api/after-sales/issues/save", issue) if err != nil { return false, err.Error() } return helperResultOK(result) } // DeleteIssue removes one after-sales issue. func (a *App) DeleteIssue(id string) (bool, string) { result, err := a.postHelperJSON("/api/after-sales/issues/delete", map[string]interface{}{"id": id}) if err != nil { return false, err.Error() } return helperResultOK(result) } // ResolveAfterSalesIssue marks one issue resolved and saves it as a knowledge case. func (a *App) ResolveAfterSalesIssue(issueId string, resolutionContent string) interface{} { result, err := a.postHelperJSON("/api/after-sales/issues/resolve", map[string]interface{}{ "issueId": issueId, "resolutionContent": resolutionContent, }) if err != nil { if result != nil { return result } return map[string]interface{}{"success": false, "message": err.Error()} } return result } // ListAfterSalesKnowledgeCases returns resolved after-sales knowledge cases. func (a *App) ListAfterSalesKnowledgeCases() interface{} { result, err := a.getHelperJSON("/api/after-sales/knowledge/cases") if err != nil { return map[string]interface{}{"success": false, "message": err.Error(), "data": []AfterSalesKnowledgeCase{}} } return result } // UpdateAfterSalesKnowledgeCase updates the final resolution of a knowledge case. func (a *App) UpdateAfterSalesKnowledgeCase(issueId string, resolutionContent string) interface{} { result, err := a.postHelperJSON("/api/after-sales/knowledge/cases/update", map[string]interface{}{ "issueId": issueId, "resolutionContent": resolutionContent, }) if err != nil { if result != nil { return result } return map[string]interface{}{"success": false, "message": err.Error()} } return result } // RevealAfterSalesKnowledgeCase opens the generated Markdown file for one case. func (a *App) RevealAfterSalesKnowledgeCase(issueId string) (bool, string) { result, err := a.postHelperJSON("/api/after-sales/knowledge/cases/reveal", map[string]interface{}{"issueId": issueId}) if err != nil { if result != nil { return helperResultOK(result) } return false, err.Error() } return helperResultOK(result) } // ExportIssuesToExcel asks the user for an xlsx path and writes the current issues. func (a *App) ExportIssuesToExcel() (bool, string) { issues, err := a.fetchAfterSalesIssues() if err != nil { return false, err.Error() } defaultName := fmt.Sprintf("after_sales_issues_%s.xlsx", time.Now().Format("2006-01-02_1504")) path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ Title: "Export after-sales issues", DefaultFilename: defaultName, Filters: []runtime.FileFilter{{ DisplayName: "Excel workbook (*.xlsx)", Pattern: "*.xlsx", }}, CanCreateDirectories: true, }) if err != nil { return false, err.Error() } if strings.TrimSpace(path) == "" { return false, "export canceled" } if strings.ToLower(filepath.Ext(path)) != ".xlsx" { path += ".xlsx" } if err := writeAfterSalesIssuesExcel(path, issues); err != nil { return false, err.Error() } return true, path } // ListAfterSalesKnowledgeArchives returns all saved after-sales Excel archives. func (a *App) ListAfterSalesKnowledgeArchives() interface{} { manifest, err := readAfterSalesKnowledgeManifest() if err != nil { return map[string]interface{}{"success": false, "message": err.Error(), "data": []AfterSalesKnowledgeArchive{}} } archives := append([]AfterSalesKnowledgeArchive(nil), manifest.Archives...) for i := range archives { archives[i].MissingFile = !fileExists(archives[i].Path) archives[i].DisplayTime = formatAfterSalesExcelTime(archives[i].CreatedAt) } sortAfterSalesKnowledgeArchives(archives) return map[string]interface{}{"success": true, "message": "ok", "data": archives} } // GetPendingAfterSalesArchiveSummary reports how many issues have not been saved // into the local Excel knowledge archive yet. func (a *App) GetPendingAfterSalesArchiveSummary() interface{} { issues, err := a.fetchAfterSalesIssues() if err != nil { return map[string]interface{}{"success": false, "message": err.Error(), "data": AfterSalesKnowledgeSummary{}} } manifest, err := readAfterSalesKnowledgeManifest() if err != nil { return map[string]interface{}{"success": false, "message": err.Error(), "data": AfterSalesKnowledgeSummary{}} } summary := AfterSalesKnowledgeSummary{ PendingCount: len(filterPendingAfterSalesArchiveIssues(issues, manifest)), TotalCount: len(issues), ArchiveCount: len(manifest.Archives), } return map[string]interface{}{"success": true, "message": "ok", "data": summary} } // ArchivePendingAfterSalesIssues writes one new timestamped Excel archive for // issues that have not been saved to the knowledge archive yet. func (a *App) ArchivePendingAfterSalesIssues() interface{} { archive, archived, err := a.archivePendingAfterSalesIssues() if err != nil { return map[string]interface{}{"success": false, "message": err.Error(), "data": archive} } if !archived { return map[string]interface{}{"success": true, "message": "no pending issues", "data": archive} } return map[string]interface{}{"success": true, "message": "saved to knowledge archive", "data": archive} } func (a *App) confirmArchivePendingAfterSalesBeforeClose(ctx context.Context) bool { summaryResult := a.GetPendingAfterSalesArchiveSummary() summaryMap, _ := summaryResult.(map[string]interface{}) if ok, _ := summaryMap["success"].(bool); !ok { return false } summary, ok := summaryMap["data"].(AfterSalesKnowledgeSummary) if !ok || summary.PendingCount <= 0 { return false } message := fmt.Sprintf("There are %d after-sales issues not saved to the knowledge archive. Save them before exit?", summary.PendingCount) choice, err := runtime.MessageDialog(ctx, runtime.MessageDialogOptions{ Type: runtime.QuestionDialog, Title: "Save after-sales issues", Message: message, DefaultButton: "Yes", }) if err != nil || choice != "Yes" { confirmExit, confirmErr := runtime.MessageDialog(ctx, runtime.MessageDialogOptions{ Type: runtime.QuestionDialog, Title: "Confirm exit", Message: "Exit without saving after-sales issues to the knowledge archive? Choose No to return to the app.", DefaultButton: "No", }) return confirmErr != nil || confirmExit != "Yes" } archive, archived, err := a.archivePendingAfterSalesIssues() if err != nil { _, _ = runtime.MessageDialog(ctx, runtime.MessageDialogOptions{ Type: runtime.ErrorDialog, Title: "Save failed", Message: "Saving to the knowledge archive failed; exit canceled: " + err.Error(), }) return true } if archived { globalLogger.Info("Saved after-sales knowledge archive before exit: %s (%d issues)", archive.Path, archive.IssueCount) } return false } // RevealAfterSalesKnowledgeArchive opens an archive file or the archive folder. func (a *App) RevealAfterSalesKnowledgeArchive(path string) (bool, string) { path = strings.Trim(strings.TrimSpace(path), "\"'") if path == "" { path = afterSalesKnowledgeDir() } if err := os.MkdirAll(afterSalesKnowledgeDir(), 0755); err != nil { return false, err.Error() } if !filepath.IsAbs(path) { path = filepath.Join(afterSalesKnowledgeDir(), filepath.Clean(path)) } if !isPathInside(path, afterSalesKnowledgeDir()) { return false, "can only open files inside the after-sales knowledge directory" } target := path if !fileExists(target) { target = afterSalesKnowledgeDir() } cmd := exec.Command("explorer.exe", target) if err := cmd.Start(); err != nil { return false, err.Error() } return true, "opened" } // RevealAfterSalesAttachment opens a locally saved after-sales attachment. func (a *App) RevealAfterSalesAttachment(path string) (bool, string) { path = strings.Trim(strings.TrimSpace(path), "\"'") if path == "" { return false, "empty attachment path" } if !filepath.IsAbs(path) { return false, "attachment path must be absolute" } if !fileExists(path) { return false, "attachment file is missing" } if !isAllowedAfterSalesAttachmentPath(path) { return false, "can only open files saved by this app" } cmd := exec.Command("explorer.exe", path) if err := cmd.Start(); err != nil { return false, err.Error() } return true, "opened" } // TriggerManualCollect starts one asynchronous after-sales issue collection. func (a *App) TriggerManualCollect(conversationID string) (bool, string) { result, err := a.postHelperJSON("/api/after-sales/collect", map[string]interface{}{"conversationId": conversationID}) if err != nil { if result != nil { return helperResultOK(result) } return false, err.Error() } return helperResultOK(result) } // ImportAfterSalesHistory imports copied WeCom chat history and runs collection. func (a *App) ImportAfterSalesHistory(req AfterSalesHistoryImportRequest) (bool, string) { result, err := a.postHelperJSON("/api/after-sales/import-history", req) if err != nil { if result != nil { return helperResultOK(result) } return false, err.Error() } return helperResultOK(result) } // PrepareWeComHistoryCopy activates WeCom and sends the copy shortcut. // The frontend owns clipboard reads so the UI can show progress and timeouts. func (a *App) PrepareWeComHistoryCopy() (bool, string) { return a.prepareWeComHistoryCopy() } // SyncCurrentWeComChatHistory is kept for older frontends. New UI should call // PrepareWeComHistoryCopy, read clipboard text, then ImportAfterSalesHistory. func (a *App) SyncCurrentWeComChatHistory(req AfterSalesHistoryImportRequest) (bool, string) { req.ConversationID = strings.TrimSpace(req.ConversationID) req.RoomName = strings.TrimSpace(req.RoomName) if req.ConversationID == "" || strings.EqualFold(req.ConversationID, "all") { return false, "please select a specific group before syncing history" } if req.RawText == "" { return false, "raw history text is empty" } result, err := a.postHelperJSON("/api/after-sales/import-history", req) if err != nil { if result != nil { return helperResultOK(result) } return false, err.Error() } return helperResultOK(result) } // SetAutoCollectTask persists the hourly collection switch. func (a *App) SetAutoCollectTask(enabled bool) (bool, string) { result, err := a.postHelperJSON("/api/after-sales/auto-collect", map[string]interface{}{"enabled": enabled}) if err != nil { return false, err.Error() } return helperResultOK(result) } // GetAfterSalesIssueStatus returns collection status. func (a *App) GetAfterSalesIssueStatus() interface{} { result, err := a.getHelperJSON("/api/after-sales/status") if err != nil { return map[string]interface{}{"success": false, "message": err.Error()} } return result } // GetAfterSalesImageData returns a local after-sales image as a browser-safe data URL. func (a *App) GetAfterSalesImageData(path string) (string, error) { path = strings.Trim(strings.TrimSpace(path), "\"'") if path == "" { return "", fmt.Errorf("image path is empty") } if !filepath.IsAbs(path) { path = filepath.Clean(path) } ext := strings.ToLower(filepath.Ext(path)) switch ext { case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp": default: return "", fmt.Errorf("unsupported image type: %s", ext) } data, err := os.ReadFile(path) if err != nil { return "", err } if len(data) == 0 { return "", fmt.Errorf("image file is empty") } mimeType := mime.TypeByExtension(ext) if mimeType == "" { mimeType = http.DetectContentType(data) } if !strings.HasPrefix(mimeType, "image/") { return "", fmt.Errorf("not an image file") } return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data), nil } func (a *App) fetchAfterSalesIssues() ([]AfterSalesIssue, error) { result, err := a.getHelperJSON("/api/after-sales/issues") if err != nil { return nil, err } if ok, _ := result["success"].(bool); !ok { return nil, fmt.Errorf("%v", result["message"]) } data, err := json.Marshal(result["data"]) if err != nil { return nil, err } var issues []AfterSalesIssue if err := json.Unmarshal(data, &issues); err != nil { return nil, err } if issues == nil { issues = []AfterSalesIssue{} } return issues, nil } func (a *App) archivePendingAfterSalesIssues() (AfterSalesKnowledgeArchive, bool, error) { issues, err := a.fetchAfterSalesIssues() if err != nil { return AfterSalesKnowledgeArchive{}, false, err } manifest, err := readAfterSalesKnowledgeManifest() if err != nil { return AfterSalesKnowledgeArchive{}, false, err } pending := filterPendingAfterSalesArchiveIssues(issues, manifest) if len(pending) == 0 { return AfterSalesKnowledgeArchive{}, false, nil } sort.Slice(pending, func(i, j int) bool { return pending[i].CreatedAt > pending[j].CreatedAt }) if err := os.MkdirAll(afterSalesKnowledgeDir(), 0755); err != nil { return AfterSalesKnowledgeArchive{}, false, err } now := time.Now().Local() path := nextAfterSalesKnowledgeExcelPath(now) if err := writeAfterSalesIssuesExcel(path, pending); err != nil { return AfterSalesKnowledgeArchive{}, false, err } archive := AfterSalesKnowledgeArchive{ ID: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), FileName: filepath.Base(path), Path: path, CreatedAt: now.Format(time.RFC3339), IssueCount: len(pending), IssueIDs: afterSalesIssueIDs(pending), DisplayTime: now.Format("2006-01-02 15:04"), } manifest.Archives = append(manifest.Archives, archive) sortAfterSalesKnowledgeArchives(manifest.Archives) if err := writeAfterSalesKnowledgeManifest(manifest); err != nil { return AfterSalesKnowledgeArchive{}, false, err } return archive, true, nil } func filterPendingAfterSalesArchiveIssues(issues []AfterSalesIssue, manifest afterSalesKnowledgeManifest) []AfterSalesIssue { archived := make(map[string]bool) for _, archive := range manifest.Archives { for _, id := range archive.IssueIDs { id = strings.TrimSpace(id) if id != "" { archived[id] = true } } } pending := make([]AfterSalesIssue, 0) for _, issue := range issues { if strings.TrimSpace(issue.ID) == "" || archived[issue.ID] { continue } pending = append(pending, issue) } return pending } func readAfterSalesKnowledgeManifest() (afterSalesKnowledgeManifest, error) { var manifest afterSalesKnowledgeManifest data, err := os.ReadFile(afterSalesKnowledgeManifestPath()) if err != nil { if os.IsNotExist(err) { return manifest, nil } return manifest, err } if len(strings.TrimSpace(string(data))) == 0 { return manifest, nil } if err := json.Unmarshal(data, &manifest); err != nil { return manifest, err } for i := range manifest.Archives { if manifest.Archives[i].ID == "" { manifest.Archives[i].ID = strings.TrimSuffix(manifest.Archives[i].FileName, filepath.Ext(manifest.Archives[i].FileName)) } if manifest.Archives[i].Path == "" && manifest.Archives[i].FileName != "" { manifest.Archives[i].Path = filepath.Join(afterSalesKnowledgeDir(), manifest.Archives[i].FileName) } } return manifest, nil } func writeAfterSalesKnowledgeManifest(manifest afterSalesKnowledgeManifest) error { if err := os.MkdirAll(afterSalesKnowledgeDir(), 0755); err != nil { return err } data, err := json.MarshalIndent(manifest, "", " ") if err != nil { return err } tmp := afterSalesKnowledgeManifestPath() + ".tmp" if err := os.WriteFile(tmp, data, 0644); err != nil { return err } return os.Rename(tmp, afterSalesKnowledgeManifestPath()) } func afterSalesKnowledgeDir() string { base, err := os.Executable() if err != nil { if wd, wdErr := os.Getwd(); wdErr == nil { base = wd } } if base != "" && filepath.Ext(base) != "" { base = filepath.Dir(base) } return filepath.Join(base, "config", "after_sales_knowledge") } func afterSalesKnowledgeManifestPath() string { return filepath.Join(afterSalesKnowledgeDir(), "manifest.json") } func nextAfterSalesKnowledgeExcelPath(now time.Time) string { baseName := fmt.Sprintf("after_sales_knowledge_%s", now.Format("2006-01-02_1504")) path := filepath.Join(afterSalesKnowledgeDir(), baseName+".xlsx") if !fileExists(path) { return path } secondName := fmt.Sprintf("after_sales_knowledge_%s", now.Format("2006-01-02_150405")) path = filepath.Join(afterSalesKnowledgeDir(), secondName+".xlsx") if !fileExists(path) { return path } for i := 2; ; i++ { path = filepath.Join(afterSalesKnowledgeDir(), fmt.Sprintf("%s_%02d.xlsx", secondName, i)) if !fileExists(path) { return path } } } func afterSalesIssueIDs(issues []AfterSalesIssue) []string { ids := make([]string, 0, len(issues)) for _, issue := range issues { if id := strings.TrimSpace(issue.ID); id != "" { ids = append(ids, id) } } return ids } func sortAfterSalesKnowledgeArchives(archives []AfterSalesKnowledgeArchive) { sort.Slice(archives, func(i, j int) bool { if archives[i].CreatedAt != archives[j].CreatedAt { return archives[i].CreatedAt > archives[j].CreatedAt } return archives[i].FileName > archives[j].FileName }) } func fileExists(path string) bool { if strings.TrimSpace(path) == "" { return false } _, err := os.Stat(path) return err == nil } func isPathInside(path string, root string) bool { absPath, err := filepath.Abs(path) if err != nil { return false } absRoot, err := filepath.Abs(root) if err != nil { return false } rel, err := filepath.Rel(absRoot, absPath) if err != nil { return false } return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)) } func isAllowedAfterSalesAttachmentPath(path string) bool { roots := make([]string, 0, 6) if wd, err := os.Getwd(); err == nil && strings.TrimSpace(wd) != "" { roots = append(roots, wd, filepath.Join(wd, "temp")) } if exe, err := os.Executable(); err == nil && strings.TrimSpace(exe) != "" { exeDir := filepath.Dir(exe) roots = append(roots, exeDir, filepath.Join(exeDir, "temp"), filepath.Join(filepath.Dir(exeDir), "temp")) } if tmp := os.TempDir(); strings.TrimSpace(tmp) != "" { roots = append(roots, tmp) } cleaned := filepath.Clean(path) for _, root := range roots { if strings.TrimSpace(root) != "" && isPathInside(cleaned, root) { return true } } return false } func helperResultOK(result map[string]interface{}) (bool, string) { if ok, _ := result["success"].(bool); ok { if msg := strings.TrimSpace(fmt.Sprint(result["message"])); msg != "" && msg != "" { return true, msg } return true, "success" } msg := strings.TrimSpace(fmt.Sprint(result["message"])) if msg == "" || msg == "" { msg = "operation failed" } return false, msg } func writeAfterSalesIssuesExcel(path string, issues []AfterSalesIssue) error { file := excelize.NewFile() sheet := "AfterSalesIssues" defaultSheet := file.GetSheetName(0) if defaultSheet != sheet { if err := file.SetSheetName(defaultSheet, sheet); err != nil { return err } } headers := []string{"Created At", "问题来源账号", "Group", "Customer", "Issue Content", "Images", "Files", "File Content", "AI Suggestion", "Status", "Engineer", "Dispatch Status", "Notify Status"} for i, header := range headers { cell, _ := excelize.CoordinatesToCellName(i+1, 1) _ = file.SetCellValue(sheet, cell, header) } for row, issue := range issues { values := []interface{}{ formatAfterSalesExcelTime(issue.CreatedAt), displayAfterSalesSourceAccount(issue), issue.RoomName, displayAfterSalesCustomerName(issue.CustomerName), issue.IssueContent, strings.Join(append(append([]string{}, issue.ImagePaths...), issue.ImageRefs...), "\n"), formatAfterSalesIssueFiles(issue.FileAttachments, false), formatAfterSalesIssueFiles(issue.FileAttachments, true), issue.AISuggestion, afterSalesStatusLabel(issue.Status), displayAfterSalesEngineerName(issue), afterSalesDispatchStatusLabel(issue.DispatchStatus), afterSalesNotifyStatusLabel(issue.NotifyStatus), } for col, value := range values { cell, _ := excelize.CoordinatesToCellName(col+1, row+2) _ = file.SetCellValue(sheet, cell, value) } } _ = file.SetColWidth(sheet, "A", "A", 18) _ = file.SetColWidth(sheet, "B", "D", 24) _ = file.SetColWidth(sheet, "E", "I", 42) _ = file.SetColWidth(sheet, "J", "M", 14) return file.SaveAs(path) } func formatAfterSalesIssueFiles(files []AfterSalesFileAttachment, includeContent bool) string { if len(files) == 0 { return "" } lines := make([]string, 0, len(files)) for _, file := range files { name := strings.TrimSpace(file.Name) if name == "" { name = strings.TrimSpace(filepath.Base(file.Path)) } if name == "" { name = strings.TrimSpace(file.Ref) } if name == "" { name = "attachment" } line := name if strings.TrimSpace(file.Path) != "" { line += " | " + strings.TrimSpace(file.Path) } else if strings.TrimSpace(file.Ref) != "" { line += " | " + strings.TrimSpace(file.Ref) } if strings.TrimSpace(file.ExtractStatus) != "" { line += " | " + strings.TrimSpace(file.ExtractStatus) } if includeContent && strings.TrimSpace(file.Content) != "" { line += "\n" + truncateAfterSalesExcelText(file.Content, 1200) } lines = append(lines, line) } return strings.Join(lines, "\n\n") } func truncateAfterSalesExcelText(text string, limit int) string { text = strings.TrimSpace(text) if limit <= 0 || len([]rune(text)) <= limit { return text } runes := []rune(text) return string(runes[:limit]) + "..." } func displayAfterSalesCustomerName(name string) string { name = strings.TrimSpace(name) if name == "" { return "unknown customer" } return name } func displayAfterSalesSourceAccount(issue AfterSalesIssue) string { parts := make([]string, 0, 3) if name := strings.TrimSpace(issue.SourceAccountName); name != "" { parts = append(parts, name) } if userID := strings.TrimSpace(issue.SourceAccountUserID); userID != "" && userID != strings.TrimSpace(issue.SourceAccountName) { parts = append(parts, userID) } if issue.SourceClientID != 0 { parts = append(parts, fmt.Sprintf("client %d", issue.SourceClientID)) } if len(parts) == 0 { return "-" } return strings.Join(parts, " / ") } func formatAfterSalesExcelTime(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } if t, err := time.Parse(time.RFC3339, value); err == nil { return t.Local().Format("2006-01-02 15:04") } return value } func afterSalesStatusLabel(status string) string { switch strings.ToLower(strings.TrimSpace(status)) { case "resolved": return "resolved" case "ignored": return "ignored" default: return "pending" } } func displayAfterSalesEngineerName(issue AfterSalesIssue) string { if strings.TrimSpace(issue.AssignedEngineerName) != "" { return strings.TrimSpace(issue.AssignedEngineerName) } return strings.TrimSpace(issue.AssignedEngineerID) } func afterSalesDispatchStatusLabel(status string) string { switch strings.ToLower(strings.TrimSpace(status)) { case "assigned": return "assigned" case "suggested": return "suggested" default: return "unassigned" } } func afterSalesNotifyStatusLabel(status string) string { switch strings.ToLower(strings.TrimSpace(status)) { case "sent": return "sent" case "failed": return "failed" default: return "not_sent" } }