diff --git a/internal/bizdev/engine.go b/internal/bizdev/engine.go new file mode 100644 index 00000000..3ed34f19 --- /dev/null +++ b/internal/bizdev/engine.go @@ -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 +} diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index a5e7647d..6e372094 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -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) diff --git a/internal/bizdev/service_test.go b/internal/bizdev/service_test.go index b0a591ae..9ca50733 100644 --- a/internal/bizdev/service_test.go +++ b/internal/bizdev/service_test.go @@ -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") + } }