feat(quote): implement CDO quote Excel calculation and fill engine
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user