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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user