- configs package embeds agents/ + shared/ prompt assets; templates package embeds xlsx/ (cdo_quote.xlsx) - template.GetAgentInstructionFile: disk-first with embedded fallback for instructions and writing.md (dev behavior unchanged) - quote.CalculateAndFill: embedded xlsx fallback when the on-disk template walk misses (single-binary deployments) - presence test guards the embedded asset set - verified: vet + template/quote/bizdev/squad/arch green
221 lines
7.1 KiB
Go
221 lines
7.1 KiB
Go
package quote
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"chat-agent-system/templates"
|
|
"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
|
|
)
|
|
|
|
// ValidateInput B5 输入范围校验:畸形输入 fail-closed,不产出报价文件。
|
|
func ValidateInput(in Input) error {
|
|
if in.CustomerName == "" || in.ProductName == "" {
|
|
return fmt.Errorf("quote: customer_name and product_name are required")
|
|
}
|
|
if len(in.Hours) == 0 {
|
|
return fmt.Errorf("quote: hours must not be empty")
|
|
}
|
|
for role, h := range in.Hours {
|
|
if isInvalidFloat(h) || h < 0 {
|
|
return fmt.Errorf("quote: hours[%s]=%v must be a non-negative finite number", role, h)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isInvalidFloat(v float64) bool {
|
|
return v != v || v > 1.7976931348623157e+307*1.0000000000000002 || v < -1.7976931348623157e+307*1.0000000000000002
|
|
}
|
|
|
|
// reviewSummary B5 轻量复核:行合计与费率组合自洽性不变式,失衡返回错误。
|
|
func reviewSummary(summary *Summary) error {
|
|
var sumPY, sumCost, sumBuf, sumMar, sumTax, sumPrice float64
|
|
for _, l := range summary.Lines {
|
|
if l.Cost+bufferFudge(l.Cost) < 0 { // 防御:非有限值已被 ValidateInput 拦截,此处冗余校验
|
|
return fmt.Errorf("quote: line %s cost invalid", l.Code)
|
|
}
|
|
sumPY += l.PersonYear
|
|
sumCost += l.Cost
|
|
sumBuf += l.Buffer
|
|
sumMar += l.Margin
|
|
sumTax += l.Tax
|
|
sumPrice += l.Price
|
|
}
|
|
if !approxEqual(sumPrice, summary.TotalPrice) {
|
|
return fmt.Errorf("quote: line price sum %.2f != total %.2f", sumPrice, summary.TotalPrice)
|
|
}
|
|
if !approxEqual(sumBuf, sumCost*bufferRate) {
|
|
return fmt.Errorf("quote: buffer sum %.2f != cost*%.0f%%", sumBuf, bufferRate*100)
|
|
}
|
|
if !approxEqual(sumMar, (sumCost+sumBuf)*marginRate) {
|
|
return fmt.Errorf("quote: margin sum %.2f != (cost+buffer)*%.0f%%", sumMar, marginRate*100)
|
|
}
|
|
if !approxEqual(sumTax, (sumCost+sumBuf+sumMar)*taxRate) {
|
|
return fmt.Errorf("quote: tax sum %.2f != (cost+buffer+margin)*%.0f%%", sumTax, taxRate*100)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func bufferFudge(v float64) float64 { return 0 }
|
|
|
|
func approxEqual(a, b float64) bool {
|
|
diff := a - b
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
scale := b
|
|
if scale < 0 {
|
|
scale = -scale
|
|
}
|
|
if scale < 1 {
|
|
scale = 1
|
|
}
|
|
return diff <= scale*1e-6
|
|
}
|
|
|
|
func CalculateAndFill(in Input, templateXlsx string, outPath string) (*Summary, error) {
|
|
if err := ValidateInput(in); err != nil {
|
|
return nil, err
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
var f *excelize.File
|
|
if tf, err := excelize.OpenFile(templateXlsx); err == nil {
|
|
f = tf
|
|
} else if eb, eerr := templates.XlsxAssets.ReadFile(strings.TrimPrefix(filepath.ToSlash(templateXlsx), "templates/")); eerr == nil {
|
|
ef, oerr := excelize.OpenReader(bytes.NewReader(eb))
|
|
if oerr != nil {
|
|
return nil, fmt.Errorf("open embedded template %s: %w", templateXlsx, oerr)
|
|
}
|
|
f = ef
|
|
} else {
|
|
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},
|
|
},
|
|
}
|
|
|
|
// B5 轻量复核:不变式失衡即失败,不落盘 Excel。
|
|
if err := reviewSummary(summary); err != nil {
|
|
return nil, fmt.Errorf("quote review failed: %w", err)
|
|
}
|
|
|
|
sheet := "报价模型"
|
|
if idx, _ := f.GetSheetIndex(sheet); idx != -1 {
|
|
_ = f.SetCellValue(sheet, "B3", in.CustomerName)
|
|
_ = f.SetCellValue(sheet, "B4", in.ProductName)
|
|
_ = f.SetCellValue(sheet, "B5", totalPrice)
|
|
|
|
_ = f.SetCellValue(sheet, "A7", "报价代码")
|
|
_ = f.SetCellValue(sheet, "B7", "服务阶段与内容")
|
|
_ = f.SetCellValue(sheet, "D7", "人年(PM)")
|
|
_ = f.SetCellValue(sheet, "E7", "基础成本(元)")
|
|
_ = f.SetCellValue(sheet, "F7", "缓冲金(20%)")
|
|
_ = f.SetCellValue(sheet, "G7", "毛利(40%)")
|
|
_ = f.SetCellValue(sheet, "H7", "税费(7%)")
|
|
_ = f.SetCellValue(sheet, "J7", "综合报价(元)")
|
|
|
|
writeSummaryLine(f, sheet, 8, summary.Lines[0])
|
|
writeSummaryLine(f, sheet, 9, summary.Lines[1])
|
|
writeSummaryLine(f, sheet, 10, summary.Lines[2])
|
|
|
|
var totalPersonYear, totalCost, totalBuffer, totalMargin, totalTax float64
|
|
for _, line := range summary.Lines {
|
|
totalPersonYear += line.PersonYear
|
|
totalCost += line.Cost
|
|
totalBuffer += line.Buffer
|
|
totalMargin += line.Margin
|
|
totalTax += line.Tax
|
|
}
|
|
writeSummaryLine(f, sheet, 18, SummaryLine{
|
|
Code: "总计",
|
|
Name: "全阶段综合报价",
|
|
PersonYear: totalPersonYear,
|
|
Cost: totalCost,
|
|
Buffer: totalBuffer,
|
|
Margin: totalMargin,
|
|
Tax: totalTax,
|
|
Price: totalPrice,
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func writeSummaryLine(f *excelize.File, sheet string, row int, line SummaryLine) {
|
|
_ = f.SetCellValue(sheet, fmt.Sprintf("A%d", row), line.Code)
|
|
_ = f.SetCellValue(sheet, fmt.Sprintf("B%d", row), line.Name)
|
|
_ = f.SetCellValue(sheet, fmt.Sprintf("D%d", row), line.PersonYear)
|
|
_ = f.SetCellValue(sheet, fmt.Sprintf("E%d", row), line.Cost)
|
|
_ = f.SetCellValue(sheet, fmt.Sprintf("F%d", row), line.Buffer)
|
|
_ = f.SetCellValue(sheet, fmt.Sprintf("G%d", row), line.Margin)
|
|
_ = f.SetCellValue(sheet, fmt.Sprintf("H%d", row), line.Tax)
|
|
_ = f.SetCellValue(sheet, fmt.Sprintf("J%d", row), line.Price)
|
|
}
|