diff --git a/go.mod b/go.mod index 49c4407e..a2995d40 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,15 @@ module chat-agent-system go 1.27.1 + +require ( + github.com/richardlehane/mscfb v1.0.7 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/excelize/v2 v2.11.0 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/text v0.38.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..44cf10f8 --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0= +github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88= +github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= diff --git a/internal/quote/models.go b/internal/quote/models.go new file mode 100644 index 00000000..722303f5 --- /dev/null +++ b/internal/quote/models.go @@ -0,0 +1,27 @@ +package quote + +type Input struct { + CustomerName string `json:"customer_name"` + ProductName string `json:"product_name"` + Author string `json:"author"` + Hours map[string]float64 `json:"hours"` +} + +type SummaryLine struct { + Code string `json:"code"` + Name string `json:"name"` + PersonYear float64 `json:"person_year"` + Cost float64 `json:"cost"` + Buffer float64 `json:"buffer"` + Margin float64 `json:"margin"` + Tax float64 `json:"tax"` + Price float64 `json:"price"` +} + +type Summary struct { + CustomerName string `json:"customer_name"` + ProductName string `json:"product_name"` + Lines []SummaryLine `json:"lines"` + TotalPrice float64 `json:"total_price"` + Notes []string `json:"notes"` +} diff --git a/internal/quote/quote.go b/internal/quote/quote.go new file mode 100644 index 00000000..464e8953 --- /dev/null +++ b/internal/quote/quote.go @@ -0,0 +1,91 @@ +package quote + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/xuri/excelize/v2" +) + +var StatutoryNotes = []string{ + "1)以上价格覆盖服务范围:原理样机验证,工程样机开发,送检样机开发", + "2)以上价格不含样机物料费用,如需乙方采购,另行商议", + "3)以上不含开发阶段使用的专用测试设备和工具工装费用", + "4)以上价格不包括药监局、医疗器械检验所等任何委托测试的第三方费用,第三方费用由甲方直接与第三方结算", + "5)以上未计算临床阶段费用", + "6)不含无菌耗材委托生产费用(CMO)", + "7)不含体系搭建费用", +} + +const ( + bufferRate = 0.20 + marginRate = 0.40 + taxRate = 0.07 + hoursPerPM = 160.0 +) + +func CalculateAndFill(in Input, templateXlsx string, outPath string) (*Summary, error) { + if _, err := os.Stat(templateXlsx); err != nil { + wd, _ := os.Getwd() + dir := wd + for i := 0; i < 10; i++ { + p := filepath.Join(dir, templateXlsx) + if _, err := os.Stat(p); err == nil { + templateXlsx = p + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + } + + f, err := excelize.OpenFile(templateXlsx) + if err != nil { + return nil, fmt.Errorf("open template %s: %w", templateXlsx, err) + } + defer f.Close() + + var totalHours float64 + for _, h := range in.Hours { + totalHours += h + } + + pm := totalHours / hoursPerPM + baseCost := totalHours * 150.0 + bufferVal := baseCost * bufferRate + marginVal := (baseCost + bufferVal) * marginRate + preTax := baseCost + bufferVal + marginVal + taxVal := preTax * taxRate + totalPrice := preTax + taxVal + + summary := &Summary{ + CustomerName: in.CustomerName, + ProductName: in.ProductName, + TotalPrice: totalPrice, + Notes: StatutoryNotes, + Lines: []SummaryLine{ + {Code: "ID2", Name: "产品规划", PersonYear: pm * 0.1, Cost: baseCost * 0.1, Buffer: bufferVal * 0.1, Margin: marginVal * 0.1, Tax: taxVal * 0.1, Price: totalPrice * 0.1}, + {Code: "ID3.3.1", Name: "设计开发工程化", PersonYear: pm * 0.7, Cost: baseCost * 0.7, Buffer: bufferVal * 0.7, Margin: marginVal * 0.7, Tax: taxVal * 0.7, Price: totalPrice * 0.7}, + {Code: "ID6", Name: "DHF文档编写", PersonYear: pm * 0.2, Cost: baseCost * 0.2, Buffer: bufferVal * 0.2, Margin: marginVal * 0.2, Tax: taxVal * 0.2, Price: totalPrice * 0.2}, + }, + } + + sheet := "报价模型" + if idx, _ := f.GetSheetIndex(sheet); idx != -1 { + _ = f.SetCellValue(sheet, "B3", in.CustomerName) + _ = f.SetCellValue(sheet, "B4", in.ProductName) + } + + if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { + return nil, err + } + if err := f.SaveAs(outPath); err != nil { + return nil, fmt.Errorf("save filled quote xlsx: %w", err) + } + + return summary, nil +} diff --git a/internal/quote/quote_test.go b/internal/quote/quote_test.go new file mode 100644 index 00000000..1c7e6101 --- /dev/null +++ b/internal/quote/quote_test.go @@ -0,0 +1,42 @@ +package quote_test + +import ( + "os" + "path/filepath" + "testing" + + "chat-agent-system/internal/quote" +) + +func TestCDOQuoteGeneration(t *testing.T) { + tmpDir := t.TempDir() + outXlsx := filepath.Join(tmpDir, "CDO报价核算-V01.xlsx") + + in := quote.Input{ + CustomerName: "水木测试医疗", + ProductName: "腹腔微创手术导航系统", + Author: "商务小队测试", + Hours: map[string]float64{ + "结构工程师": 320, + "硬件工程师": 320, + "软件工程师": 480, + "项目经理": 160, + }, + } + + summary, err := quote.CalculateAndFill(in, "templates/xlsx/cdo_quote.xlsx", outXlsx) + if err != nil { + t.Fatalf("quote calculation failed: %v", err) + } + + if _, err := os.Stat(outXlsx); err != nil { + t.Fatalf("expected output xlsx to exist: %v", err) + } + + if summary.TotalPrice <= 0 { + t.Errorf("expected positive total price, got %f", summary.TotalPrice) + } + if len(summary.Notes) != 7 { + t.Errorf("expected 7 statutory notes, got %d", len(summary.Notes)) + } +}