chore: prepare docfill project for gitea
This commit is contained in:
@@ -4,12 +4,12 @@ import (
|
||||
"archive/zip"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
type config struct {
|
||||
Vars map[string]string `json:"vars"`
|
||||
Data dataConfig `json:"data"`
|
||||
Filter filterConfig `json:"filter"`
|
||||
Template templateConfig `json:"template"`
|
||||
Output outputConfig `json:"output"`
|
||||
}
|
||||
@@ -34,6 +35,11 @@ type dataConfig struct {
|
||||
HeaderRow int `json:"header_row"`
|
||||
}
|
||||
|
||||
type filterConfig struct {
|
||||
Equals map[string]string `json:"equals"`
|
||||
Expect string `json:"expect"`
|
||||
}
|
||||
|
||||
type templateConfig struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
@@ -45,6 +51,7 @@ type outputConfig struct {
|
||||
|
||||
type RunOptions struct {
|
||||
Vars map[string]string
|
||||
Logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
type RunResult struct {
|
||||
@@ -68,6 +75,7 @@ func Run(configPath string) error {
|
||||
|
||||
// RunWithOptions executes one configured fill task and returns generated files.
|
||||
func RunWithOptions(configPath string, opts RunOptions) (RunResult, error) {
|
||||
logf(opts, "load config path=%s", configPath)
|
||||
cfg, err := loadConfig(configPath)
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
@@ -77,12 +85,24 @@ func RunWithOptions(configPath string, opts RunOptions) (RunResult, error) {
|
||||
}
|
||||
|
||||
vars := mergeVars(cfg.Vars, opts.Vars)
|
||||
logf(opts, "read data type=%s path=%s", cfg.Data.Type, cfg.Data.Path)
|
||||
rows, err := readRowsWithVars(cfg.Data, vars)
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return RunResult{}, errors.New("data returned no rows")
|
||||
return RunResult{}, appError(CodeDataEmpty, "data returned no rows").
|
||||
withHint("Check the data source, sheet/header row, SQL where clause, or --var values.").
|
||||
withContext("data", cfg.Data.Path)
|
||||
}
|
||||
logf(opts, "read data type=%s path=%s rows=%d", cfg.Data.Type, cfg.Data.Path, len(rows))
|
||||
sourceRowCount := len(rows)
|
||||
rows, err = filterRows(cfg.Filter, rows, vars)
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
if hasFilter(cfg.Filter) {
|
||||
logf(opts, "filter rows=%d matched=%d expect=%s", sourceRowCount, len(rows), filterExpect(cfg.Filter))
|
||||
}
|
||||
|
||||
result := RunResult{Files: make([]string, 0, len(rows))}
|
||||
@@ -94,11 +114,16 @@ func RunWithOptions(configPath string, opts RunOptions) (RunResult, error) {
|
||||
|
||||
outputPath := replacePathPlaceholders(cfg.Output.Path, context)
|
||||
if hasPlaceholder(outputPath) {
|
||||
return RunResult{}, fmt.Errorf("render row %d: unresolved placeholder in output path %q", i+1, outputPath)
|
||||
return RunResult{}, fmt.Errorf("render row %d: %w", i+1, appError(CodeTemplateUnresolved, "unresolved placeholder in output path").
|
||||
withHint("Check output.path placeholders and available data fields or --var values.").
|
||||
withContext("output", outputPath))
|
||||
}
|
||||
if !cfg.Output.Overwrite && fileExists(outputPath) {
|
||||
return RunResult{}, fmt.Errorf("render row %d: output already exists: %s", i+1, outputPath)
|
||||
return RunResult{}, fmt.Errorf("render row %d: %w", i+1, appError(CodeOutputExists, "output already exists").
|
||||
withHint("Change output.path or set output.overwrite to true.").
|
||||
withContext("output", outputPath))
|
||||
}
|
||||
logf(opts, "render row=%d output=%s", i+1, outputPath)
|
||||
if err := renderTemplate(cfg.Template.Path, outputPath, context); err != nil {
|
||||
return RunResult{}, fmt.Errorf("render row %d: %w", i+1, err)
|
||||
}
|
||||
@@ -109,19 +134,32 @@ func RunWithOptions(configPath string, opts RunOptions) (RunResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func logf(opts RunOptions, format string, args ...any) {
|
||||
if opts.Logf != nil {
|
||||
opts.Logf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig(path string) (config, error) {
|
||||
if path == "" {
|
||||
return config{}, errors.New("config path is required")
|
||||
return config{}, appError(CodeConfigValidate, "config path is required").
|
||||
withHint("Pass -c task.json or --config task.json.")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return config{}, fmt.Errorf("read config: %w", err)
|
||||
return config{}, appError(CodeConfigRead, "read config").
|
||||
withHint("Check that the config file exists and is readable.").
|
||||
withContext("config", path).
|
||||
withCause(err)
|
||||
}
|
||||
|
||||
var cfg config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return config{}, fmt.Errorf("parse config json: %w", err)
|
||||
return config{}, appError(CodeConfigParse, "parse config json").
|
||||
withHint("Fix task.json syntax and make sure it is valid JSON.").
|
||||
withContext("config", path).
|
||||
withCause(err)
|
||||
}
|
||||
resolveConfigPaths(&cfg, filepath.Dir(absPath(path)))
|
||||
return cfg, nil
|
||||
@@ -129,19 +167,27 @@ func loadConfig(path string) (config, error) {
|
||||
|
||||
func validateConfig(cfg config) error {
|
||||
if cfg.Data.Type == "" {
|
||||
return errors.New("data.type is required")
|
||||
return appError(CodeConfigValidate, "data.type is required").
|
||||
withHint("Set data.type to excel or sqlite.")
|
||||
}
|
||||
if cfg.Data.Path == "" {
|
||||
return errors.New("data.path is required")
|
||||
return appError(CodeConfigValidate, "data.path is required").
|
||||
withHint("Set data.path to an .xlsx or .db file.")
|
||||
}
|
||||
if cfg.Template.Path == "" {
|
||||
return errors.New("template.path is required")
|
||||
return appError(CodeConfigValidate, "template.path is required").
|
||||
withHint("Set template.path to a .docx or .xlsx file.")
|
||||
}
|
||||
if cfg.Output.Path == "" {
|
||||
return errors.New("output.path is required")
|
||||
return appError(CodeConfigValidate, "output.path is required").
|
||||
withHint("Set output.path to the generated file pattern.")
|
||||
}
|
||||
if cfg.Data.HeaderRow < 0 {
|
||||
return errors.New("data.header_row must not be negative")
|
||||
return appError(CodeConfigValidate, "data.header_row must not be negative").
|
||||
withHint("Use a 1-based header_row value, or omit it for 1.")
|
||||
}
|
||||
if err := validateFilterConfig(cfg.Filter); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -157,14 +203,19 @@ func readRowsWithVars(cfg dataConfig, vars map[string]string) ([]map[string]stri
|
||||
case "sqlite":
|
||||
return readSQLiteRows(cfg, vars)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported data.type %q", cfg.Type)
|
||||
return nil, appError(CodeConfigValidate, fmt.Sprintf("unsupported data.type %q", cfg.Type)).
|
||||
withHint("Set data.type to excel or sqlite.").
|
||||
withContext("data.type", cfg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func readExcelRows(cfg dataConfig) ([]map[string]string, error) {
|
||||
f, err := excelize.OpenFile(cfg.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open excel data: %w", err)
|
||||
return nil, appError(CodeDataExcel, "open excel data").
|
||||
withHint("Check that data.path points to a readable .xlsx file.").
|
||||
withContext("data", cfg.Path).
|
||||
withCause(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
@@ -173,15 +224,24 @@ func readExcelRows(cfg dataConfig) ([]map[string]string, error) {
|
||||
sheet = f.GetSheetName(0)
|
||||
}
|
||||
if sheet == "" {
|
||||
return nil, errors.New("excel data has no sheets")
|
||||
return nil, appError(CodeDataExcel, "excel data has no sheets").
|
||||
withHint("Check the Excel data file.").
|
||||
withContext("data", cfg.Path)
|
||||
}
|
||||
|
||||
rawRows, err := f.GetRows(sheet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read excel sheet %q: %w", sheet, err)
|
||||
return nil, appError(CodeDataExcel, fmt.Sprintf("read excel sheet %q", sheet)).
|
||||
withHint("Check data.sheet and the workbook contents.").
|
||||
withContext("data", cfg.Path).
|
||||
withContext("sheet", sheet).
|
||||
withCause(err)
|
||||
}
|
||||
if len(rawRows) == 0 {
|
||||
return nil, errors.New("excel data has no header row")
|
||||
return nil, appError(CodeDataExcel, "excel data has no header row").
|
||||
withHint("Add a header row or set data.header_row to the correct row.").
|
||||
withContext("data", cfg.Path).
|
||||
withContext("sheet", sheet)
|
||||
}
|
||||
|
||||
headerRow := cfg.HeaderRow
|
||||
@@ -189,7 +249,10 @@ func readExcelRows(cfg dataConfig) ([]map[string]string, error) {
|
||||
headerRow = 1
|
||||
}
|
||||
if headerRow > len(rawRows) {
|
||||
return nil, fmt.Errorf("excel header_row %d is outside sheet %q", headerRow, sheet)
|
||||
return nil, appError(CodeDataExcel, fmt.Sprintf("excel header_row %d is outside sheet %q", headerRow, sheet)).
|
||||
withHint("Use a header_row value within the sheet row count.").
|
||||
withContext("data", cfg.Path).
|
||||
withContext("sheet", sheet)
|
||||
}
|
||||
|
||||
headers := make([]string, len(rawRows[headerRow-1]))
|
||||
@@ -221,12 +284,16 @@ func readExcelRows(cfg dataConfig) ([]map[string]string, error) {
|
||||
|
||||
func readSQLiteRows(cfg dataConfig, vars map[string]string) ([]map[string]string, error) {
|
||||
if cfg.Query == "" {
|
||||
return nil, errors.New("data.query is required for sqlite")
|
||||
return nil, appError(CodeConfigValidate, "data.query is required for sqlite").
|
||||
withHint("Set data.query to the SQL select statement.")
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", cfg.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite data: %w", err)
|
||||
return nil, appError(CodeDataSQLite, "open sqlite data").
|
||||
withHint("Check that data.path points to a readable SQLite database.").
|
||||
withContext("data", cfg.Path).
|
||||
withCause(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
@@ -237,13 +304,18 @@ func readSQLiteRows(cfg dataConfig, vars map[string]string) ([]map[string]string
|
||||
|
||||
result, err := db.Query(cfg.Query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query sqlite data: %w", err)
|
||||
return nil, appError(CodeDataSQLite, "query sqlite data").
|
||||
withHint("Check data.query, data.params, and --var values.").
|
||||
withContext("data", cfg.Path).
|
||||
withCause(err)
|
||||
}
|
||||
defer result.Close()
|
||||
|
||||
columns, err := result.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read sqlite columns: %w", err)
|
||||
return nil, appError(CodeDataSQLite, "read sqlite columns").
|
||||
withContext("data", cfg.Path).
|
||||
withCause(err)
|
||||
}
|
||||
|
||||
var rows []map[string]string
|
||||
@@ -254,7 +326,9 @@ func readSQLiteRows(cfg dataConfig, vars map[string]string) ([]map[string]string
|
||||
scanTargets[i] = &values[i]
|
||||
}
|
||||
if err := result.Scan(scanTargets...); err != nil {
|
||||
return nil, fmt.Errorf("scan sqlite row: %w", err)
|
||||
return nil, appError(CodeDataSQLite, "scan sqlite row").
|
||||
withContext("data", cfg.Path).
|
||||
withCause(err)
|
||||
}
|
||||
|
||||
row := make(map[string]string)
|
||||
@@ -264,18 +338,154 @@ func readSQLiteRows(cfg dataConfig, vars map[string]string) ([]map[string]string
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if err := result.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate sqlite rows: %w", err)
|
||||
return nil, appError(CodeDataSQLite, "iterate sqlite rows").
|
||||
withContext("data", cfg.Path).
|
||||
withCause(err)
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func validateFilterConfig(cfg filterConfig) error {
|
||||
expect := strings.ToLower(strings.TrimSpace(cfg.Expect))
|
||||
if expect != "" && expect != "one" && expect != "many" {
|
||||
return appError(CodeConfigValidate, fmt.Sprintf("unsupported filter.expect %q", cfg.Expect)).
|
||||
withHint("Set filter.expect to one or many.").
|
||||
withContext("filter.expect", cfg.Expect)
|
||||
}
|
||||
if expect != "" && len(cfg.Equals) == 0 {
|
||||
return appError(CodeConfigValidate, "filter.equals is required when filter.expect is set").
|
||||
withHint("Add at least one filter.equals condition or remove filter.expect.")
|
||||
}
|
||||
for field := range cfg.Equals {
|
||||
if strings.TrimSpace(field) == "" {
|
||||
return appError(CodeConfigValidate, "filter.equals field name must not be empty").
|
||||
withHint("Use a data column name as each filter.equals key.")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func filterRows(cfg filterConfig, rows []map[string]string, vars map[string]string) ([]map[string]string, error) {
|
||||
if !hasFilter(cfg) {
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
conditions, err := filterConditions(cfg, vars)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
if err := validateFilterFields(rows[0], conditions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
matches := make([]map[string]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if rowMatchesFilter(row, conditions) {
|
||||
matches = append(matches, row)
|
||||
}
|
||||
}
|
||||
|
||||
expect := filterExpect(cfg)
|
||||
if len(matches) == 0 {
|
||||
return nil, appError(CodeDataFilterNoMatch, "filter matched no rows").
|
||||
withHint("Check filter.equals, --var values, or source table contents.").
|
||||
withContext("filter", filterSummary(conditions)).
|
||||
withContext("expect", expect)
|
||||
}
|
||||
if expect == "one" && len(matches) != 1 {
|
||||
return nil, appError(CodeDataFilterMultiMatch, fmt.Sprintf("filter matched %d rows but expected one", len(matches))).
|
||||
withHint("Add another filter condition or clean duplicate rows in the source table.").
|
||||
withContext("filter", filterSummary(conditions)).
|
||||
withContext("matches", strconv.Itoa(len(matches)))
|
||||
}
|
||||
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func hasFilter(cfg filterConfig) bool {
|
||||
return len(cfg.Equals) > 0
|
||||
}
|
||||
|
||||
func filterExpect(cfg filterConfig) string {
|
||||
expect := strings.ToLower(strings.TrimSpace(cfg.Expect))
|
||||
if expect == "" {
|
||||
return "one"
|
||||
}
|
||||
return expect
|
||||
}
|
||||
|
||||
func filterConditions(cfg filterConfig, vars map[string]string) (map[string]string, error) {
|
||||
conditions := make(map[string]string, len(cfg.Equals))
|
||||
for field, pattern := range cfg.Equals {
|
||||
field = strings.TrimSpace(field)
|
||||
context := map[string]string{"filter": field}
|
||||
if err := ensurePlaceholdersAvailableWithCode(
|
||||
fmt.Sprintf("filter.equals[%s]", field),
|
||||
pattern,
|
||||
vars,
|
||||
CodeDataFilterValue,
|
||||
"Provide the missing filter value with vars or --var.",
|
||||
context,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value := replacePlaceholders(pattern, vars)
|
||||
if hasPlaceholder(value) {
|
||||
return nil, appError(CodeDataFilterValue, fmt.Sprintf("unresolved placeholder in filter.equals[%s]", field)).
|
||||
withHint("Provide the missing filter value with vars or --var.").
|
||||
withContext("filter", field)
|
||||
}
|
||||
conditions[field] = strings.TrimSpace(value)
|
||||
}
|
||||
return conditions, nil
|
||||
}
|
||||
|
||||
func validateFilterFields(row map[string]string, conditions map[string]string) error {
|
||||
for field := range conditions {
|
||||
if _, ok := row[field]; !ok {
|
||||
return appError(CodeDataFilterField, fmt.Sprintf("filter field %q is not in data", field)).
|
||||
withHint("Check the data header/query columns or adjust filter.equals.").
|
||||
withContext("field", field)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rowMatchesFilter(row map[string]string, conditions map[string]string) bool {
|
||||
for field, want := range conditions {
|
||||
if strings.TrimSpace(row[field]) != want {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func filterSummary(conditions map[string]string) string {
|
||||
keys := make([]string, 0, len(conditions))
|
||||
for key := range conditions {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+"="+conditions[key])
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func renderTemplate(templatePath, outputPath string, row map[string]string) error {
|
||||
if err := validateTemplateFields(templatePath, row); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create output directory: %w", err)
|
||||
return appError(CodeOutputWrite, "create output directory").
|
||||
withHint("Check output.path directory permissions.").
|
||||
withContext("output", outputPath).
|
||||
withCause(err)
|
||||
}
|
||||
|
||||
switch strings.ToLower(filepath.Ext(templatePath)) {
|
||||
@@ -284,21 +494,29 @@ func renderTemplate(templatePath, outputPath string, row map[string]string) erro
|
||||
case ".docx":
|
||||
return renderDOCX(templatePath, outputPath, row)
|
||||
default:
|
||||
return fmt.Errorf("unsupported template extension %q", filepath.Ext(templatePath))
|
||||
return appError(CodeConfigValidate, fmt.Sprintf("unsupported template extension %q", filepath.Ext(templatePath))).
|
||||
withHint("Use a .docx or .xlsx template.").
|
||||
withContext("template", templatePath)
|
||||
}
|
||||
}
|
||||
|
||||
func renderXLSX(templatePath, outputPath string, row map[string]string) error {
|
||||
f, err := excelize.OpenFile(templatePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open xlsx template: %w", err)
|
||||
return appError(CodeTemplateOpen, "open xlsx template").
|
||||
withHint("Check that template.path points to a readable .xlsx file.").
|
||||
withContext("template", templatePath).
|
||||
withCause(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
for _, sheet := range f.GetSheetList() {
|
||||
rows, err := f.GetRows(sheet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read template sheet %q: %w", sheet, err)
|
||||
return appError(CodeTemplateOpen, fmt.Sprintf("read template sheet %q", sheet)).
|
||||
withContext("template", templatePath).
|
||||
withContext("sheet", sheet).
|
||||
withCause(err)
|
||||
}
|
||||
for r, values := range rows {
|
||||
for c, value := range values {
|
||||
@@ -311,17 +529,25 @@ func renderXLSX(templatePath, outputPath string, row map[string]string) error {
|
||||
}
|
||||
rendered := replacePlaceholders(value, row)
|
||||
if hasPlaceholder(rendered) {
|
||||
return fmt.Errorf("unresolved placeholder in %s!%s", sheet, cell)
|
||||
return appError(CodeTemplateUnresolved, fmt.Sprintf("unresolved placeholder in %s!%s", sheet, cell)).
|
||||
withHint("Check source data values and template placeholders.").
|
||||
withContext("template", templatePath).
|
||||
withContext("cell", sheet+"!"+cell)
|
||||
}
|
||||
if err := f.SetCellValue(sheet, cell, rendered); err != nil {
|
||||
return fmt.Errorf("set cell %s!%s: %w", sheet, cell, err)
|
||||
return appError(CodeOutputWrite, fmt.Sprintf("set cell %s!%s", sheet, cell)).
|
||||
withContext("output", outputPath).
|
||||
withCause(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := f.SaveAs(outputPath); err != nil {
|
||||
return fmt.Errorf("save xlsx output: %w", err)
|
||||
return appError(CodeOutputWrite, "save xlsx output").
|
||||
withHint("Check output.path and file permissions.").
|
||||
withContext("output", outputPath).
|
||||
withCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -329,13 +555,19 @@ func renderXLSX(templatePath, outputPath string, row map[string]string) error {
|
||||
func renderDOCX(templatePath, outputPath string, row map[string]string) error {
|
||||
reader, err := zip.OpenReader(templatePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open docx template: %w", err)
|
||||
return appError(CodeTemplateOpen, "open docx template").
|
||||
withHint("Check that template.path points to a readable .docx file.").
|
||||
withContext("template", templatePath).
|
||||
withCause(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
output, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create docx output: %w", err)
|
||||
return appError(CodeOutputWrite, "create docx output").
|
||||
withHint("Check output.path and file permissions.").
|
||||
withContext("output", outputPath).
|
||||
withCause(err)
|
||||
}
|
||||
|
||||
writer := zip.NewWriter(output)
|
||||
@@ -348,10 +580,14 @@ func renderDOCX(templatePath, outputPath string, row map[string]string) error {
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
_ = output.Close()
|
||||
return fmt.Errorf("close docx output: %w", err)
|
||||
return appError(CodeOutputWrite, "close docx output").
|
||||
withContext("output", outputPath).
|
||||
withCause(err)
|
||||
}
|
||||
if err := output.Close(); err != nil {
|
||||
return fmt.Errorf("close docx file: %w", err)
|
||||
return appError(CodeOutputWrite, "close docx file").
|
||||
withContext("output", outputPath).
|
||||
withCause(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -364,7 +600,8 @@ func copyDocxPart(writer *zip.Writer, source *zip.File, row map[string]string) e
|
||||
|
||||
dest, err := writer.CreateHeader(&header)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create docx part %s: %w", source.Name, err)
|
||||
return appError(CodeOutputWrite, fmt.Sprintf("create docx part %s", source.Name)).
|
||||
withCause(err)
|
||||
}
|
||||
|
||||
if source.FileInfo().IsDir() {
|
||||
@@ -373,13 +610,15 @@ func copyDocxPart(writer *zip.Writer, source *zip.File, row map[string]string) e
|
||||
|
||||
src, err := source.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open docx part %s: %w", source.Name, err)
|
||||
return appError(CodeTemplateOpen, fmt.Sprintf("open docx part %s", source.Name)).
|
||||
withCause(err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
data, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read docx part %s: %w", source.Name, err)
|
||||
return appError(CodeTemplateOpen, fmt.Sprintf("read docx part %s", source.Name)).
|
||||
withCause(err)
|
||||
}
|
||||
if isDocxXMLPart(source.Name) {
|
||||
rendered, err := renderDocxXML(string(data), row)
|
||||
@@ -387,13 +626,15 @@ func copyDocxPart(writer *zip.Writer, source *zip.File, row map[string]string) e
|
||||
return fmt.Errorf("render docx part %s: %w", source.Name, err)
|
||||
}
|
||||
if docxHasPlaceholder(rendered) {
|
||||
return fmt.Errorf("unresolved placeholder in docx part %s", source.Name)
|
||||
return appError(CodeTemplateUnresolved, fmt.Sprintf("unresolved placeholder in docx part %s", source.Name)).
|
||||
withHint("Check source data values and template placeholders.")
|
||||
}
|
||||
data = []byte(rendered)
|
||||
}
|
||||
|
||||
if _, err := dest.Write(data); err != nil {
|
||||
return fmt.Errorf("write docx part %s: %w", source.Name, err)
|
||||
return appError(CodeOutputWrite, fmt.Sprintf("write docx part %s", source.Name)).
|
||||
withCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -471,12 +712,20 @@ func renderContext(vars, row map[string]string, rowNumber int) map[string]string
|
||||
func sqliteParams(params []string, vars map[string]string) ([]any, error) {
|
||||
args := make([]any, 0, len(params))
|
||||
for i, param := range params {
|
||||
if err := ensurePlaceholdersAvailable(fmt.Sprintf("sqlite params[%d]", i), param, vars); err != nil {
|
||||
if err := ensurePlaceholdersAvailableWithCode(
|
||||
fmt.Sprintf("sqlite params[%d]", i),
|
||||
param,
|
||||
vars,
|
||||
CodeDataSQLite,
|
||||
"Provide the missing SQLite parameter with vars or --var.",
|
||||
nil,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rendered := replacePlaceholders(param, vars)
|
||||
if hasPlaceholder(rendered) {
|
||||
return nil, fmt.Errorf("unresolved placeholder in sqlite params[%d]", i)
|
||||
return nil, appError(CodeDataSQLite, fmt.Sprintf("unresolved placeholder in sqlite params[%d]", i)).
|
||||
withHint("Provide the missing SQLite parameter with vars or --var.")
|
||||
}
|
||||
args = append(args, rendered)
|
||||
}
|
||||
@@ -490,7 +739,10 @@ func validateTemplateFields(templatePath string, row map[string]string) error {
|
||||
}
|
||||
for field := range fields {
|
||||
if _, ok := row[field]; !ok {
|
||||
return fmt.Errorf("missing template field %q", field)
|
||||
return appError(CodeTemplateFieldMissing, fmt.Sprintf("missing template field %q", field)).
|
||||
withHint(fmt.Sprintf("Add column %q to the data source or pass --var %s=...", field, field)).
|
||||
withContext("field", field).
|
||||
withContext("template", templatePath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -510,7 +762,10 @@ func templatePlaceholders(templatePath string) (map[string]struct{}, error) {
|
||||
func xlsxPlaceholders(templatePath string) (map[string]struct{}, error) {
|
||||
f, err := excelize.OpenFile(templatePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open xlsx template: %w", err)
|
||||
return nil, appError(CodeTemplateOpen, "open xlsx template").
|
||||
withHint("Check that template.path points to a readable .xlsx file.").
|
||||
withContext("template", templatePath).
|
||||
withCause(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
@@ -518,7 +773,10 @@ func xlsxPlaceholders(templatePath string) (map[string]struct{}, error) {
|
||||
for _, sheet := range f.GetSheetList() {
|
||||
rows, err := f.GetRows(sheet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read template sheet %q: %w", sheet, err)
|
||||
return nil, appError(CodeTemplateOpen, fmt.Sprintf("read template sheet %q", sheet)).
|
||||
withContext("template", templatePath).
|
||||
withContext("sheet", sheet).
|
||||
withCause(err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
for _, value := range row {
|
||||
@@ -532,7 +790,10 @@ func xlsxPlaceholders(templatePath string) (map[string]struct{}, error) {
|
||||
func docxPlaceholders(templatePath string) (map[string]struct{}, error) {
|
||||
reader, err := zip.OpenReader(templatePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open docx template: %w", err)
|
||||
return nil, appError(CodeTemplateOpen, "open docx template").
|
||||
withHint("Check that template.path points to a readable .docx file.").
|
||||
withContext("template", templatePath).
|
||||
withCause(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
@@ -543,15 +804,21 @@ func docxPlaceholders(templatePath string) (map[string]struct{}, error) {
|
||||
}
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open docx part %s: %w", file.Name, err)
|
||||
return nil, appError(CodeTemplateOpen, fmt.Sprintf("open docx part %s", file.Name)).
|
||||
withContext("template", templatePath).
|
||||
withCause(err)
|
||||
}
|
||||
data, readErr := io.ReadAll(rc)
|
||||
closeErr := rc.Close()
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("read docx part %s: %w", file.Name, readErr)
|
||||
return nil, appError(CodeTemplateOpen, fmt.Sprintf("read docx part %s", file.Name)).
|
||||
withContext("template", templatePath).
|
||||
withCause(readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("close docx part %s: %w", file.Name, closeErr)
|
||||
return nil, appError(CodeTemplateOpen, fmt.Sprintf("close docx part %s", file.Name)).
|
||||
withContext("template", templatePath).
|
||||
withCause(closeErr)
|
||||
}
|
||||
xml := string(data)
|
||||
addPlaceholders(fields, xml)
|
||||
@@ -561,11 +828,28 @@ func docxPlaceholders(templatePath string) (map[string]struct{}, error) {
|
||||
}
|
||||
|
||||
func ensurePlaceholdersAvailable(label, text string, row map[string]string) error {
|
||||
return ensurePlaceholdersAvailableWithCode(
|
||||
label,
|
||||
text,
|
||||
row,
|
||||
CodeTemplateFieldMissing,
|
||||
"Add the missing field to the data source or pass it with --var.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func ensurePlaceholdersAvailableWithCode(label, text string, row map[string]string, code ErrorCode, hint string, context map[string]string) error {
|
||||
fields := make(map[string]struct{})
|
||||
addPlaceholders(fields, text)
|
||||
for field := range fields {
|
||||
if _, ok := row[field]; !ok {
|
||||
return fmt.Errorf("missing %s field %q", label, field)
|
||||
err := appError(code, fmt.Sprintf("missing %s field %q", label, field)).
|
||||
withHint(hint).
|
||||
withContext("field", field)
|
||||
for key, value := range context {
|
||||
err.withContext(key, value)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -637,7 +921,8 @@ func renderDocxTextGroup(xml string, row map[string]string) (string, error) {
|
||||
}
|
||||
rendered := replaceDocxPlaceholders(joined, row)
|
||||
if hasPlaceholder(rendered) {
|
||||
return "", errors.New("unresolved placeholder in docx text")
|
||||
return "", appError(CodeTemplateUnresolved, "unresolved placeholder in docx text").
|
||||
withHint("Check source data values and template placeholders.")
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
|
||||
@@ -153,6 +153,30 @@ func TestRenderDOCXReplacesPlaceholderSplitAcrossTextNodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDOCXReplacesMultipleSplitPlaceholdersInSameParagraph(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
templatePath := filepath.Join(dir, "template.docx")
|
||||
outputPath := filepath.Join(dir, "out.docx")
|
||||
writeMinimalDocx(t, templatePath, `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>员工:{{na</w:t></w:r><w:r><w:t>me}}</w:t></w:r><w:r><w:t>,完成:{{do</w:t></w:r><w:r><w:t>ne}}</w:t></w:r><w:r><w:t>,备注:{{note}}</w:t></w:r></w:p></w:body></w:document>`)
|
||||
|
||||
err := renderTemplate(templatePath, outputPath, map[string]string{
|
||||
"name": "Alice",
|
||||
"done": "接口数据整理",
|
||||
"note": "保持同段落",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("renderTemplate returned error: %v", err)
|
||||
}
|
||||
|
||||
got := readZipEntry(t, outputPath, "word/document.xml")
|
||||
if !strings.Contains(got, "员工:Alice,完成:接口数据整理,备注:保持同段落") {
|
||||
t.Fatalf("document.xml = %q, want rendered split placeholders", got)
|
||||
}
|
||||
if strings.Contains(got, "{{") || strings.Contains(got, "}}") {
|
||||
t.Fatalf("document.xml still has placeholder markers: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGeneratesOneOutputPerInputRow(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataPath := filepath.Join(dir, "data.xlsx")
|
||||
|
||||
74
internal/docfill/errors.go
Normal file
74
internal/docfill/errors.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package docfill
|
||||
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
CodeCLIUsage ErrorCode = "CLI_USAGE"
|
||||
CodeConfigRead ErrorCode = "CONFIG_READ"
|
||||
CodeConfigParse ErrorCode = "CONFIG_PARSE"
|
||||
CodeConfigValidate ErrorCode = "CONFIG_VALIDATE"
|
||||
CodeDataExcel ErrorCode = "DATA_EXCEL"
|
||||
CodeDataSQLite ErrorCode = "DATA_SQLITE"
|
||||
CodeDataEmpty ErrorCode = "DATA_EMPTY"
|
||||
CodeDataFilterField ErrorCode = "DATA_FILTER_FIELD"
|
||||
CodeDataFilterValue ErrorCode = "DATA_FILTER_VALUE"
|
||||
CodeDataFilterNoMatch ErrorCode = "DATA_FILTER_NO_MATCH"
|
||||
CodeDataFilterMultiMatch ErrorCode = "DATA_FILTER_MULTI_MATCH"
|
||||
CodeTemplateOpen ErrorCode = "TEMPLATE_OPEN"
|
||||
CodeTemplateFieldMissing ErrorCode = "TEMPLATE_FIELD_MISSING"
|
||||
CodeTemplateUnresolved ErrorCode = "TEMPLATE_UNRESOLVED"
|
||||
CodeOutputExists ErrorCode = "OUTPUT_EXISTS"
|
||||
CodeOutputWrite ErrorCode = "OUTPUT_WRITE"
|
||||
CodeInternal ErrorCode = "INTERNAL"
|
||||
)
|
||||
|
||||
type AppError struct {
|
||||
Code ErrorCode
|
||||
Message string
|
||||
Hint string
|
||||
Context map[string]string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *AppError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Cause == nil {
|
||||
return e.Message
|
||||
}
|
||||
return e.Message + ": " + e.Cause.Error()
|
||||
}
|
||||
|
||||
func (e *AppError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
func appError(code ErrorCode, message string) *AppError {
|
||||
return &AppError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Context: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AppError) withHint(hint string) *AppError {
|
||||
e.Hint = hint
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *AppError) withCause(cause error) *AppError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *AppError) withContext(key, value string) *AppError {
|
||||
if e.Context == nil {
|
||||
e.Context = make(map[string]string)
|
||||
}
|
||||
e.Context[key] = value
|
||||
return e
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package docfill
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -176,6 +177,16 @@ func TestRunRefusesToOverwriteUnlessConfigured(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("expected already exists error, got %v", err)
|
||||
}
|
||||
var appErr *AppError
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("expected AppError, got %T", err)
|
||||
}
|
||||
if appErr.Code != CodeOutputExists {
|
||||
t.Fatalf("error code = %s, want %s", appErr.Code, CodeOutputExists)
|
||||
}
|
||||
if appErr.Hint == "" || appErr.Context["output"] != outputPath {
|
||||
t.Fatalf("expected hint and output context, got %#v", appErr)
|
||||
}
|
||||
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
@@ -226,6 +237,19 @@ func TestRunFailsWhenTemplatePlaceholderIsMissing(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "missing template field") {
|
||||
t.Fatalf("expected missing template field error, got %v", err)
|
||||
}
|
||||
var appErr *AppError
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("expected AppError, got %T", err)
|
||||
}
|
||||
if appErr.Code != CodeTemplateFieldMissing {
|
||||
t.Fatalf("error code = %s, want %s", appErr.Code, CodeTemplateFieldMissing)
|
||||
}
|
||||
if appErr.Context["field"] != "missing" || appErr.Context["template"] != templatePath {
|
||||
t.Fatalf("unexpected context: %#v", appErr.Context)
|
||||
}
|
||||
if !strings.Contains(appErr.Hint, "missing") {
|
||||
t.Fatalf("unexpected hint: %q", appErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFailsWhenRenderedTemplateStillHasPlaceholder(t *testing.T) {
|
||||
@@ -259,6 +283,282 @@ func TestRunFailsWhenRenderedTemplateStillHasPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFiltersExcelRowsByMultipleEqualsAndVars(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataPath := filepath.Join(dir, "tickets.xlsx")
|
||||
templatePath := filepath.Join(dir, "ticket-template.xlsx")
|
||||
configPath := filepath.Join(dir, "task.json")
|
||||
|
||||
writeWorkbook(t, dataPath, "工单", [][]string{
|
||||
{"日期", "工单编号", "客户名称", "处理结果"},
|
||||
{"2026-06-18", "GD-001", "A 公司", "已派单"},
|
||||
{"2026-06-18", "GD-002", "B 公司", "已完成"},
|
||||
{"2026-06-19", "GD-001", "C 公司", "待复核"},
|
||||
})
|
||||
writeWorkbook(t, templatePath, "工单", [][]string{
|
||||
{"编号", "{{工单编号}}"},
|
||||
{"客户", "{{客户名称}}"},
|
||||
{"结果", "{{处理结果}}"},
|
||||
})
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"vars": map[string]any{
|
||||
"date": "2026-06-17",
|
||||
},
|
||||
"data": map[string]any{
|
||||
"type": "excel",
|
||||
"path": dataPath,
|
||||
"sheet": "工单",
|
||||
},
|
||||
"filter": map[string]any{
|
||||
"equals": map[string]any{
|
||||
"日期": "{{date}}",
|
||||
"工单编号": "{{ticket_no}}",
|
||||
},
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": filepath.Join(dir, "out", "工单_{{日期}}_{{工单编号}}.xlsx"),
|
||||
},
|
||||
})
|
||||
|
||||
result, err := RunWithOptions(configPath, RunOptions{
|
||||
Vars: map[string]string{
|
||||
"date": "2026-06-18",
|
||||
"ticket_no": "GD-002",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunWithOptions returned error: %v", err)
|
||||
}
|
||||
|
||||
wantPath := filepath.Join(dir, "out", "工单_2026-06-18_GD-002.xlsx")
|
||||
if len(result.Files) != 1 || result.Files[0] != wantPath {
|
||||
t.Fatalf("result files = %#v, want only %q", result.Files, wantPath)
|
||||
}
|
||||
assertWorkbookCell(t, wantPath, "工单", "B2", "B 公司")
|
||||
assertWorkbookCell(t, wantPath, "工单", "B3", "已完成")
|
||||
}
|
||||
|
||||
func TestRunFilterAllowsManyRowsWhenConfigured(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "tickets.db")
|
||||
templatePath := filepath.Join(dir, "ticket-template.xlsx")
|
||||
configPath := filepath.Join(dir, "task.json")
|
||||
|
||||
seedP0SQLite(t, dbPath, `
|
||||
create table tickets (
|
||||
report_date text,
|
||||
department text,
|
||||
ticket_no text,
|
||||
result text
|
||||
);
|
||||
insert into tickets values
|
||||
('2026-06-18', '营销部', 'GD-001', '已派单'),
|
||||
('2026-06-18', '客服部', 'GD-002', '已完成'),
|
||||
('2026-06-19', '营销部', 'GD-003', '待复核');
|
||||
`)
|
||||
writeWorkbook(t, templatePath, "工单", [][]string{
|
||||
{"编号", "{{ticket_no}}"},
|
||||
{"部门", "{{department}}"},
|
||||
{"结果", "{{result}}"},
|
||||
})
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "sqlite",
|
||||
"path": dbPath,
|
||||
"query": "select report_date, department, ticket_no, result from tickets order by ticket_no",
|
||||
},
|
||||
"filter": map[string]any{
|
||||
"equals": map[string]any{
|
||||
"report_date": "{{date}}",
|
||||
},
|
||||
"expect": "many",
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": filepath.Join(dir, "out", "工单_{{ticket_no}}_{{_row}}.xlsx"),
|
||||
},
|
||||
})
|
||||
|
||||
result, err := RunWithOptions(configPath, RunOptions{Vars: map[string]string{"date": "2026-06-18"}})
|
||||
if err != nil {
|
||||
t.Fatalf("RunWithOptions returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Files) != 2 {
|
||||
t.Fatalf("expected 2 filtered files, got %#v", result.Files)
|
||||
}
|
||||
assertWorkbookCell(t, filepath.Join(dir, "out", "工单_GD-001_1.xlsx"), "工单", "B3", "已派单")
|
||||
assertWorkbookCell(t, filepath.Join(dir, "out", "工单_GD-002_2.xlsx"), "工单", "B2", "客服部")
|
||||
}
|
||||
|
||||
func TestRunFilterFailsWhenNoRowsMatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataPath := filepath.Join(dir, "tickets.xlsx")
|
||||
templatePath := filepath.Join(dir, "template.xlsx")
|
||||
configPath := filepath.Join(dir, "task.json")
|
||||
|
||||
writeWorkbook(t, dataPath, "工单", [][]string{
|
||||
{"日期", "工单编号", "处理结果"},
|
||||
{"2026-06-18", "GD-001", "已派单"},
|
||||
})
|
||||
writeWorkbook(t, templatePath, "工单", [][]string{{"{{工单编号}}", "{{处理结果}}"}})
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "excel",
|
||||
"path": dataPath,
|
||||
"sheet": "工单",
|
||||
},
|
||||
"filter": map[string]any{
|
||||
"equals": map[string]any{
|
||||
"日期": "{{date}}",
|
||||
"工单编号": "{{ticket_no}}",
|
||||
},
|
||||
"expect": "one",
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": filepath.Join(dir, "out", "工单_{{工单编号}}.xlsx"),
|
||||
},
|
||||
})
|
||||
|
||||
err := RunWithOptionsError(configPath, RunOptions{Vars: map[string]string{
|
||||
"date": "2026-06-18",
|
||||
"ticket_no": "GD-999",
|
||||
}})
|
||||
assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_NO_MATCH"))
|
||||
}
|
||||
|
||||
func TestRunFilterFailsWhenOneRowExpectedButManyMatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataPath := filepath.Join(dir, "tickets.xlsx")
|
||||
templatePath := filepath.Join(dir, "template.xlsx")
|
||||
configPath := filepath.Join(dir, "task.json")
|
||||
|
||||
writeWorkbook(t, dataPath, "工单", [][]string{
|
||||
{"日期", "工单编号", "处理结果"},
|
||||
{"2026-06-18", "GD-001", "已派单"},
|
||||
{"2026-06-18", "GD-002", "已完成"},
|
||||
})
|
||||
writeWorkbook(t, templatePath, "工单", [][]string{{"{{工单编号}}", "{{处理结果}}"}})
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "excel",
|
||||
"path": dataPath,
|
||||
"sheet": "工单",
|
||||
},
|
||||
"filter": map[string]any{
|
||||
"equals": map[string]any{
|
||||
"日期": "{{date}}",
|
||||
},
|
||||
"expect": "one",
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": filepath.Join(dir, "out", "工单_{{工单编号}}.xlsx"),
|
||||
},
|
||||
})
|
||||
|
||||
err := RunWithOptionsError(configPath, RunOptions{Vars: map[string]string{"date": "2026-06-18"}})
|
||||
assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_MULTI_MATCH"))
|
||||
}
|
||||
|
||||
func TestRunFilterFailsWhenFieldIsMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataPath := filepath.Join(dir, "tickets.xlsx")
|
||||
templatePath := filepath.Join(dir, "template.xlsx")
|
||||
configPath := filepath.Join(dir, "task.json")
|
||||
|
||||
writeWorkbook(t, dataPath, "工单", [][]string{
|
||||
{"日期", "处理结果"},
|
||||
{"2026-06-18", "已派单"},
|
||||
})
|
||||
writeWorkbook(t, templatePath, "工单", [][]string{{"{{处理结果}}"}})
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "excel",
|
||||
"path": dataPath,
|
||||
"sheet": "工单",
|
||||
},
|
||||
"filter": map[string]any{
|
||||
"equals": map[string]any{
|
||||
"工单编号": "{{ticket_no}}",
|
||||
},
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": filepath.Join(dir, "out", "工单.xlsx"),
|
||||
},
|
||||
})
|
||||
|
||||
err := RunWithOptionsError(configPath, RunOptions{Vars: map[string]string{"ticket_no": "GD-001"}})
|
||||
assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_FIELD"))
|
||||
}
|
||||
|
||||
func TestRunFilterFailsWhenVariableIsMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataPath := filepath.Join(dir, "tickets.xlsx")
|
||||
templatePath := filepath.Join(dir, "template.xlsx")
|
||||
configPath := filepath.Join(dir, "task.json")
|
||||
|
||||
writeWorkbook(t, dataPath, "工单", [][]string{
|
||||
{"日期", "工单编号", "处理结果"},
|
||||
{"2026-06-18", "GD-001", "已派单"},
|
||||
})
|
||||
writeWorkbook(t, templatePath, "工单", [][]string{{"{{工单编号}}", "{{处理结果}}"}})
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "excel",
|
||||
"path": dataPath,
|
||||
"sheet": "工单",
|
||||
},
|
||||
"filter": map[string]any{
|
||||
"equals": map[string]any{
|
||||
"工单编号": "{{ticket_no}}",
|
||||
},
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": filepath.Join(dir, "out", "工单_{{工单编号}}.xlsx"),
|
||||
},
|
||||
})
|
||||
|
||||
err := RunWithOptionsError(configPath, RunOptions{})
|
||||
assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_VALUE"))
|
||||
}
|
||||
|
||||
func RunWithOptionsError(configPath string, opts RunOptions) error {
|
||||
_, err := RunWithOptions(configPath, opts)
|
||||
return err
|
||||
}
|
||||
|
||||
func assertAppErrorCode(t *testing.T, err error, want ErrorCode) {
|
||||
t.Helper()
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("expected %s error, got nil", want)
|
||||
}
|
||||
var appErr *AppError
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("expected AppError, got %T: %v", err, err)
|
||||
}
|
||||
if appErr.Code != want {
|
||||
t.Fatalf("error code = %s, want %s; error=%v", appErr.Code, want, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedP0SQLite(t *testing.T, path, statements string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user