fix(service): backfill CDO quote markdown table from excel summary and align workspace

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-07 16:41:29 +08:00
co-authored by Cursor
parent d97098371d
commit 7a41b4f5be
3 changed files with 145 additions and 2 deletions
+108
View File
@@ -0,0 +1,108 @@
package bizdev
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"chat-agent-system/internal/quote"
)
// FormatMoney formats a currency amount for display in markdown (no grouping separators).
func FormatMoney(v float64) string {
if v == 0 {
return "0"
}
if v == float64(int64(v)) {
return strconv.FormatInt(int64(v), 10)
}
return strconv.FormatFloat(v, 'f', 2, 64)
}
// RenderCDOQuoteTable builds the pricing table from quote summary lines and total.
func RenderCDOQuoteTable(summary *quote.Summary) string {
var b strings.Builder
b.WriteString("| 报价代码 | 服务内容 | 人数 | 人年 | 成本 | 加Buffer20% | 加毛利40% | 加税7% | 设备设施等费用 | 税后价格 |\n")
b.WriteString("|---|---|---:|---:|---:|---:|---:|---:|---:|---:|\n")
var totalPersonYear, totalCost, totalBuffer, totalMargin, totalTax, totalPrice float64
for _, line := range summary.Lines {
fmt.Fprintf(&b, "| %s | %s | — | %s | %s | %s | %s | %s | — | %s |\n",
line.Code, line.Name,
FormatMoney(line.PersonYear),
FormatMoney(line.Cost),
FormatMoney(line.Buffer),
FormatMoney(line.Margin),
FormatMoney(line.Tax),
FormatMoney(line.Price),
)
totalPersonYear += line.PersonYear
totalCost += line.Cost
totalBuffer += line.Buffer
totalMargin += line.Margin
totalTax += line.Tax
totalPrice += line.Price
}
fmt.Fprintf(&b, "| | **总计** | — | **%s** | **%s** | **%s** | **%s** | **%s** | — | **%s** |\n",
FormatMoney(totalPersonYear),
FormatMoney(totalCost),
FormatMoney(totalBuffer),
FormatMoney(totalMargin),
FormatMoney(totalTax),
FormatMoney(summary.TotalPrice),
)
return b.String()
}
// BackfillCDOQuoteMarkdown replaces placeholder sections with computed pricing from summary.
func BackfillCDOQuoteMarkdown(body string, summary *quote.Summary) string {
conclusion := fmt.Sprintf(
"按公司 CDO 定价核算模板计算,税后合计 **%s 元**(含 Buffer 20%%、毛利 40%%、税 7%%)。数字与配套 Excel「报价模型」一致。",
FormatMoney(summary.TotalPrice),
)
body = replaceSection(body, "## 结论", conclusion)
tableIntro := "表中「人年」列与 Excel 模板同一列,对应模型投入人月合计(工时/160)。\n\n"
body = replaceSection(body, "## 报价模型", tableIntro+RenderCDOQuoteTable(summary))
return body
}
func replaceSection(body, heading, content string) string {
start := strings.Index(body, heading)
if start == -1 {
return body
}
rest := body[start+len(heading):]
nextHeading := strings.Index(rest, "\n## ")
if nextHeading == -1 {
return body[:start] + heading + "\n\n" + content + "\n"
}
end := start + len(heading) + nextHeading
return body[:start] + heading + "\n\n" + content + "\n" + body[end:]
}
// WriteMaterials persists uploaded material text into workspace materials_text/.
func WriteMaterials(workDir string, materials []MaterialInput) error {
if len(materials) == 0 {
return nil
}
dir := filepath.Join(workDir, "materials_text")
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create materials_text: %w", err)
}
for _, m := range materials {
name := filepath.Base(m.Filename)
if name == "" || name == "." {
name = "material.txt"
}
path := filepath.Join(dir, name)
if err := os.WriteFile(path, m.Content, 0o644); err != nil {
return fmt.Errorf("write material %s: %w", name, err)
}
}
return nil
}
+10 -2
View File
@@ -55,11 +55,14 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
workDir := req.WorkDir
if workDir == "" {
ts := time.Now().UnixNano()
workDir = filepath.Join(s.cfg.DataDir, "workspaces", fmt.Sprintf("task_%s_%d", req.TaskKey, ts))
workDir = filepath.Join(s.cfg.DataDir, "workspaces", "bizdev", fmt.Sprintf("%s_%d", req.TaskKey, ts))
}
if err := os.MkdirAll(workDir, 0o755); err != nil {
return nil, fmt.Errorf("create workdir: %w", err)
}
if err := WriteMaterials(workDir, req.Materials); err != nil {
return nil, err
}
tmpl, err := template.GetDocTemplate(req.TaskKey)
if err != nil {
@@ -68,6 +71,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
vars := map[string]string{
"project_name": req.ProjectName,
"product_name": req.ProjectName,
"customer_name": req.CustomerName,
"device_type": req.DeviceType,
"intended_use": req.IntendedUse,
@@ -125,9 +129,13 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
"项目经理": 160,
},
}
if _, err := quote.CalculateAndFill(in, "templates/xlsx/cdo_quote.xlsx", xlsxPath); err != nil {
summary, err := quote.CalculateAndFill(in, "templates/xlsx/cdo_quote.xlsx", xlsxPath)
if err != nil {
return nil, fmt.Errorf("cdo quote: %w", err)
}
markdownBody = BackfillCDOQuoteMarkdown(markdownBody, summary)
_ = os.WriteFile(filepath.Join(workDir, "cdo_quote.md"), []byte(markdownBody), 0o644)
data, err := os.ReadFile(xlsxPath)
if err != nil {
return nil, fmt.Errorf("read cdo quote xlsx: %w", err)
+27
View File
@@ -2,12 +2,14 @@ package bizdev_test
import (
"context"
"path/filepath"
"strings"
"testing"
"time"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
"chat-agent-system/internal/quote"
)
func TestExecuteTaskIndependent(t *testing.T) {
@@ -51,4 +53,29 @@ func TestExecuteTaskIndependent(t *testing.T) {
if quoteRes.Attachments[0].Filename != "CDO报价核算-V01.xlsx" {
t.Errorf("unexpected attachment filename: %s", quoteRes.Attachments[0].Filename)
}
in := quote.Input{
CustomerName: "北京天工",
ProductName: "微创吻合器研发",
Hours: map[string]float64{
"结构工程师": 320,
"硬件工程师": 320,
"软件工程师": 480,
"项目经理": 160,
},
}
summary, err := quote.CalculateAndFill(in, "templates/xlsx/cdo_quote.xlsx", filepath.Join(tmpDir, "ref.xlsx"))
if err != nil {
t.Fatalf("reference quote calculation failed: %v", err)
}
expectedTotal := bizdev.FormatMoney(summary.TotalPrice)
if !strings.Contains(quoteRes.MarkdownBody, expectedTotal) {
t.Errorf("markdown missing formatted total price %s matching Excel summary", expectedTotal)
}
if !strings.Contains(quoteRes.MarkdownBody, "ID2") || !strings.Contains(quoteRes.MarkdownBody, "ID6") {
t.Errorf("markdown missing backfilled quote table lines")
}
if !strings.Contains(quoteRes.MarkdownBody, "微创吻合器研发") {
t.Errorf("markdown missing product_name")
}
}