feat(docs): put-xlsx с excelize — multi-sheet бюджеты (cutover-portugal) #12

Merged
eSlider merged 1 commits from feat/docs-put-xlsx into main 2026-08-30 13:41:25 +01:00
9 changed files with 800 additions and 16 deletions
Showing only changes of commit 68445b0dfd - Show all commits
+123 -5
View File
@@ -8,6 +8,7 @@ import (
onlyoffice "github.com/eslider/go-onlyoffice" onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/internal/docpipe" "github.com/eslider/go-onlyoffice/internal/docpipe"
"github.com/eslider/go-onlyoffice/internal/xlspipe"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -24,6 +25,7 @@ func docsCmd() *cobra.Command {
OnlyOffice Documents UI is poor for .md/.txt — keep sources in git, store .docx in OO. OnlyOffice Documents UI is poor for .md/.txt — keep sources in git, store .docx in OO.
Upload Markdown as DOCX: oo docs put-md PROJECT_ID file.md Upload Markdown as DOCX: oo docs put-md PROJECT_ID file.md
Upload plain text: oo docs put-txt PROJECT_ID file.txt (preserves line breaks) Upload plain text: oo docs put-txt PROJECT_ID file.txt (preserves line breaks)
Upload/generate XLSX: oo docs put-xlsx PROJECT_ID [--template cutover-portugal | FILE.xlsx]
Read an OO file as MD: oo docs as-md FILE_ID Read an OO file as MD: oo docs as-md FILE_ID
OCR a scan locally: oo docs ocr scan.pdf --md out.md OCR a scan locally: oo docs ocr scan.pdf --md out.md
Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`, Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`,
@@ -35,6 +37,7 @@ Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`,
cmd.AddCommand(docsAsMDCmd()) cmd.AddCommand(docsAsMDCmd())
cmd.AddCommand(docsPutMDCmd()) cmd.AddCommand(docsPutMDCmd())
cmd.AddCommand(docsPutTxtCmd()) cmd.AddCommand(docsPutTxtCmd())
cmd.AddCommand(docsPutXlsxCmd())
cmd.AddCommand(docsToolsCmd()) cmd.AddCommand(docsToolsCmd())
return cmd return cmd
} }
@@ -122,11 +125,11 @@ text overlays that break OnlyOffice DocEditor. Skips OCR; rewrites via gs pdfwri
} }
outChars, _ := t.PDFTextLayerChars(out) outChars, _ := t.PDFTextLayerChars(out)
printObject(map[string]any{ printObject(map[string]any{
"in": in, "in": in,
"pdf": out, "pdf": out,
"text_chars_in": chars, "text_chars_in": chars,
"text_chars_out": outChars, "text_chars_out": outChars,
"note": "native text layer preserved; no OCR overlay", "note": "native text layer preserved; no OCR overlay",
}) })
return nil return nil
}, },
@@ -540,3 +543,118 @@ func docsPutTxtCmd() *cobra.Command {
cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files in folder before upload (put-txt upsert)") cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files in folder before upload (put-txt upsert)")
return cmd return cmd
} }
func docsPutXlsxCmd() *cobra.Command {
var folderID, template, title, keepLocal string
var replace bool
cmd := &cobra.Command{
Use: "put-xlsx PROJECT_ID [LOCAL_XLSX]",
Short: "Upload or generate an XLSX workbook into a project (excelize templates with formulas)",
Long: `Spreadsheets live in OnlyOffice — not in git. Generate multi-sheet workbooks with
formulas (SUM/AVG, cross-sheet refs, named inputs) via --template, or upload an existing .xlsx.
oo docs put-xlsx 218 --template cutover-portugal
oo docs put-xlsx 218 --template cutover-portugal --title 2026-08-28-cutover-budget.xlsx
oo docs put-xlsx 218 ./my.xlsx`,
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
pid := args[0]
c, err := newOO(cmd)
if err != nil {
return err
}
dir, err := os.MkdirTemp("", "oo-docs-put-xlsx-*")
if err != nil {
return err
}
defer os.RemoveAll(dir)
var xlsxPath string
var srcLabel string
switch {
case template != "":
wb, err := xlspipe.BuildTemplate(template)
if err != nil {
return err
}
name := title
if name == "" {
name = "cutover-budget.xlsx"
if template == xlspipe.TemplateCutoverPortugal {
name = "2026-08-28-cutover-budget.xlsx"
}
}
if !strings.HasSuffix(strings.ToLower(name), ".xlsx") {
name += ".xlsx"
}
xlsxPath = filepath.Join(dir, name)
if err := xlspipe.Save(wb, xlsxPath); err != nil {
wb.Close()
return err
}
wb.Close()
srcLabel = "template:" + template
case len(args) == 2:
xlsxPath = args[1]
if docpipe.Ext(xlsxPath) != ".xlsx" {
return fmt.Errorf("expected .xlsx, got %s", docpipe.Ext(xlsxPath))
}
srcLabel = xlsxPath
default:
return fmt.Errorf("pass LOCAL_XLSX or --template")
}
if keepLocal != "" {
b, err := os.ReadFile(xlsxPath)
if err != nil {
return err
}
if err := docpipe.EnsureDir(keepLocal); err != nil {
return err
}
if err := os.WriteFile(keepLocal, b, 0o644); err != nil {
return err
}
}
ctx := cmd.Context()
var ent *onlyoffice.FileEntry
var deleted []int
if folderID != "" {
if replace {
ent, deleted, err = c.UploadToFolderReplacing(ctx, folderID, xlsxPath)
} else {
ent, err = c.UploadToFolder(ctx, folderID, xlsxPath)
}
} else {
if replace {
ent, deleted, err = c.UploadProjectFileReplacing(ctx, pid, xlsxPath)
} else {
ent, err = c.UploadProjectFile(ctx, pid, xlsxPath)
}
}
if err != nil {
return err
}
obj := map[string]any{
"project_id": pid,
"source": srcLabel,
"uploaded": fileEntryToMap(ent),
}
if folderID != "" {
obj["folder_id"] = folderID
}
if len(deleted) > 0 {
obj["replaced_file_ids"] = deleted
}
printObject(obj)
return nil
},
}
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
cmd.Flags().StringVar(&template, "template", "", "built-in workbook template (cutover-portugal)")
cmd.Flags().StringVar(&title, "title", "", "upload file name when using --template")
cmd.Flags().StringVar(&keepLocal, "keep-xlsx", "", "also write generated/uploaded bytes to this local path")
cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files before upload")
return cmd
}
+10 -2
View File
@@ -29,6 +29,7 @@ func projectFilesCmd() *cobra.Command {
cmd.AddCommand(aliasDocsAsMD()) cmd.AddCommand(aliasDocsAsMD())
cmd.AddCommand(aliasDocsPutMD()) cmd.AddCommand(aliasDocsPutMD())
cmd.AddCommand(aliasDocsPutTxt()) cmd.AddCommand(aliasDocsPutTxt())
cmd.AddCommand(aliasDocsPutXlsx())
return cmd return cmd
} }
@@ -53,6 +54,13 @@ func aliasDocsPutTxt() *cobra.Command {
return c return c
} }
func aliasDocsPutXlsx() *cobra.Command {
c := docsPutXlsxCmd()
c.Use = "put-xlsx PROJECT_ID [LOCAL_XLSX]"
c.Short = "Alias of `oo docs put-xlsx` — generate/upload XLSX with formulas"
return c
}
func prjFilesListCmd() *cobra.Command { func prjFilesListCmd() *cobra.Command {
var showFolders bool var showFolders bool
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -75,8 +83,8 @@ func prjFilesListCmd() *cobra.Command {
continue continue
} }
frows = append(frows, map[string]any{ frows = append(frows, map[string]any{
"id": folderIDStr(f), "id": folderIDStr(f),
"title": derefString(f.Title), "title": derefString(f.Title),
"filesCount": derefInt(f.FilesCount), "filesCount": derefInt(f.FilesCount),
"foldersCount": derefInt(f.FoldersCount), "foldersCount": derefInt(f.FoldersCount),
}) })
+9
View File
@@ -237,6 +237,15 @@ func (c *Client) UploadProjectFile(ctx context.Context, projectID, localPath str
return decodeResponseFileEntry(raw) return decodeResponseFileEntry(raw)
} }
// UploadProjectFileReplacing upserts by stem|ext in the project Documents folder.
func (c *Client) UploadProjectFileReplacing(ctx context.Context, projectID, localPath string) (*FileEntry, []int, error) {
folderID, err := c.projectFolderID(ctx, projectID)
if err != nil {
return nil, nil, err
}
return c.UploadToFolderReplacing(ctx, folderID, localPath)
}
// GetFile returns file metadata including viewUrl for download. // GetFile returns file metadata including viewUrl for download.
func (c *Client) GetFile(ctx context.Context, fileID string) (*FileEntry, error) { func (c *Client) GetFile(ctx context.Context, fileID string) (*FileEntry, error) {
if fileID == "" { if fileID == "" {
+10 -3
View File
@@ -17,6 +17,7 @@ require (
github.com/mattn/go-runewidth v0.0.15 github.com/mattn/go-runewidth v0.0.15
github.com/muesli/termenv v0.16.0 github.com/muesli/termenv v0.16.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/xuri/excelize/v2 v2.11.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.56.0 modernc.org/sqlite v1.56.0
) )
@@ -42,15 +43,21 @@ require (
github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/reflow v0.3.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/richardlehane/mscfb v1.0.7 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark v1.8.2 // indirect
github.com/yuin/goldmark-emoji v1.0.3 // indirect github.com/yuin/goldmark-emoji v1.0.3 // indirect
golang.org/x/net v0.55.0 // indirect golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.21.0 // indirect golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.43.0 // indirect golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.38.0 // indirect
modernc.org/libc v1.74.4 // indirect modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
+26 -6
View File
@@ -31,6 +31,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20240715153702-9ba8adf781c4/go.mod
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -84,6 +86,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -97,25 +103,39 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4= github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+393
View File
@@ -0,0 +1,393 @@
package xlspipe
import (
"fmt"
"github.com/xuri/excelize/v2"
)
// Sheet names (Russian tabs) for cutover Portugal workbook.
const (
SheetInputs = "Ввод"
SheetDom6 = "Вс 6.09"
SheetJue3 = "Чт 3.09"
SheetWedHyp = "Ср гип"
SheetSummary = "Сводка"
)
// CutoverPortugalDefaults holds live FO values (2026-08-28).
type CutoverPortugalDefaults struct {
FerryDom6 float64
FerryJue3 float64
FerrySuperiorDelta float64
HousingLow float64
HousingMid float64
HousingHigh float64
DriveLow float64
DriveMid float64
DriveHigh float64
BoardLow float64
BoardMid float64
BoardHigh float64
FoodLow float64
FoodMid float64
FoodHigh float64
SimLow float64
SimMid float64
SimHigh float64
MonthCap float64
ExtraNightsDom6 float64
ExtraNightsJue3 float64
ExtraNightsWed float64
}
// DefaultCutoverPortugal returns FO live snapshot from portugal track (28.08.2026).
func DefaultCutoverPortugal() CutoverPortugalDefaults {
return CutoverPortugalDefaults{
FerryDom6: 457.89,
FerryJue3: 484.09,
FerrySuperiorDelta: 19.64,
HousingLow: 509,
HousingMid: 600,
HousingHigh: 650,
DriveLow: 70,
DriveMid: 85,
DriveHigh: 100,
BoardLow: 40,
BoardMid: 60,
BoardHigh: 80,
FoodLow: 150,
FoodMid: 220,
FoodHigh: 300,
SimLow: 50,
SimMid: 100,
SimHigh: 150,
MonthCap: 2500,
ExtraNightsDom6: 0,
ExtraNightsJue3: 0,
ExtraNightsWed: 4,
}
}
type inputField struct {
name string // defined name (ASCII, for formulas)
label string
value float64
note string
comment string
}
// BuildCutoverPortugalWorkbook creates a multi-sheet cutover budget with Russian labels,
// cell comments on non-obvious inputs, named ranges, and cross-sheet formulas.
func BuildCutoverPortugalWorkbook(d CutoverPortugalDefaults) (*excelize.File, error) {
f := excelize.NewFile()
defaultSheet := f.GetSheetName(0)
if err := f.SetSheetName(defaultSheet, SheetInputs); err != nil {
f.Close()
return nil, err
}
for _, name := range []string{SheetDom6, SheetJue3, SheetWedHyp, SheetSummary} {
if _, err := f.NewSheet(name); err != nil {
f.Close()
return nil, err
}
}
if err := writeInputsSheet(f, d); err != nil {
f.Close()
return nil, err
}
scenarios := []struct {
sheet, ferry, extra, note string
}{
{SheetDom6, "ferry_dom6", "extra_nights_dom6", "Живой слот вс 6.09 20:30; заезд в квартиру вт 8.09"},
{SheetJue3, "ferry_jue3", "extra_nights_jue3", "Живой чт 3.09; T1a 05–12 если TF-крыша кончается раньше вс"},
{SheetWedHyp, "ferry_jue3", "extra_nights_wed", "Гипотеза: ср 2.09 20:00 по тарифу Jue3; заезд пт 4.09"},
}
for _, sc := range scenarios {
if err := writeScenarioSheet(f, sc.sheet, sc.ferry, sc.extra, sc.note); err != nil {
f.Close()
return nil, err
}
}
if err := writeSummarySheet(f); err != nil {
f.Close()
return nil, err
}
f.SetActiveSheet(0)
if err := finalizeWorkbook(f); err != nil {
f.Close()
return nil, err
}
return f, nil
}
func writeInputsSheet(f *excelize.File, d CutoverPortugalDefaults) error {
if err := setHeaders(f, SheetInputs, "Параметр", "Значение €", "Кратко"); err != nil {
return err
}
fields := []inputField{
{
name: "ferry_dom6", label: "Паром вс 6.09 (Básica, без residencia)",
value: d.FerryDom6, note: "FO live: 2взр+младенец+авто",
comment: "Fred Olsen Dom 6.09 20:30 SC→Huelva, прибытие Mar 8 09:00. Butaca Normal/Básica без субсидии канарского residencia (−183€). Меняйте после нового live FO.",
},
{
name: "ferry_jue3", label: "Паром чт 3.09 (Básica, без residencia)",
value: d.FerryJue3, note: "FO live Jue 3 20:00",
comment: "Прямой рейс 35 ч. Дороже вс на ~26€. Используется также для листа «Ср гип», если своего рейса ср 2.09 нет в продаже.",
},
{
name: "ferry_superior_uplift", label: "Доплата VIP / Butaca Superior",
value: d.FerrySuperiorDelta, note: "Superior − Normal (Jue3 live)",
comment: "Разница между Superior и Normal на live Jue3 ≈19,64€. На Dom6 Superior в сессии не перевыбирали — оценка по этой дельте. VIP = salón, не каюта.",
},
{
name: "housing_7n_low", label: "Крыша 7 ночей Setúbal — минимум",
value: d.HousingLow, note: "Airbnb low band",
comment: "Короткая аренда T1a (#11): 08–15.09, 1–2BR с парковкой. Низкая граница live Airbnb Setúbal.",
},
{
name: "housing_7n_mid", label: "Крыша 7 ночей Setúbal — целевой mid",
value: d.HousingMid, note: "Цель #11: 500–650€/нед",
comment: "Рабочая оценка для брони. Основной столбец mid на листах сценариев.",
},
{
name: "housing_7n_high", label: "Крыша 7 ночей Setúbal — максимум",
value: d.HousingHigh, note: "Airbnb high band",
},
{
name: "drive_low", label: "Проезд Huelva → Setúbal — мин",
value: d.DriveLow, note: "OSRM ~3,8 ч",
comment: "≈342 км: топливо + платные дороги. Низкая/средняя/высокая оценка.",
},
{name: "drive_mid", label: "Проезд Huelva → Setúbal — mid", value: d.DriveMid},
{name: "drive_high", label: "Проезд Huelva → Setúbal — макс", value: d.DriveHigh},
{
name: "board_low", label: "Еда на пароме — мин",
value: d.BoardLow, note: "Меню FO",
comment: "Питание на борту (Fred Olsen). George 0–3 обычно бесплатно как пассажир — еда отдельно.",
},
{name: "board_mid", label: "Еда на пароме — mid", value: d.BoardMid},
{name: "board_high", label: "Еда на пароме — макс", value: d.BoardHigh},
{
name: "food_7d_low", label: "Еда 7 дней в PT — мин",
value: d.FoodLow, note: "Готовим в apt",
comment: "Первая неделя в Setúbal после парома — продукты, не рестораны.",
},
{name: "food_7d_mid", label: "Еда 7 дней в PT — mid", value: d.FoodMid},
{name: "food_7d_high", label: "Еда 7 дней в PT — макс", value: d.FoodHigh},
{
name: "sim_low", label: "SIM + документы — мин",
value: d.SimLow, note: "Разовые cutover",
comment: "eSIM, копии, мелкие госпошлины при cutover. Не включает депозит аренды.",
},
{name: "sim_mid", label: "SIM + документы — mid", value: d.SimMid},
{name: "sim_high", label: "SIM + документы — макс", value: d.SimHigh},
{
name: "month_cap", label: "Потолок бюджета на месяц (€)",
value: d.MonthCap, note: "SoT: 2500€",
comment: "Жёсткий потолок Sep из source-of-truth. «Остаток» = потолок − итого mid сценария.",
},
{
name: "extra_nights_dom6", label: "Лишние ночи в PT (вс 6.09)",
value: d.ExtraNightsDom6, note: "0 = TF до вс",
comment: "Платные ночи в PT до начала 7-дневной крыши. Для Dom6 обычно 0: остаёмся на Тенерифе до вс, заезд вт 8.09.",
},
{
name: "extra_nights_jue3", label: "Лишние ночи в PT (чт 3.09)",
value: d.ExtraNightsJue3, note: "0 если TF до вс",
comment: "Если крыша TF кончается раньше вс — нужны ночи 05–07.09 до Airbnb. По умолчанию 0.",
},
{
name: "extra_nights_wed", label: "Лишние ночи в PT (ср гип)",
value: d.ExtraNightsWed, note: "Гип: заезд пт 4.09",
comment: "Гипотетический слот ср 2.09 → заезд пт 4.09 = 4 лишних ночи до типичного 7н блока. Рейса ср в FO нет — тариф как Jue3.",
},
}
for i, fld := range fields {
row := i + 2
if err := f.SetCellStr(SheetInputs, fmt.Sprintf("A%d", row), fld.label); err != nil {
return err
}
if err := defineInput(f, SheetInputs, fld.name, row, fld.value, fld.note, fld.comment); err != nil {
return err
}
}
if err := f.SetCellStr(SheetInputs, "A25", "—"); err != nil {
return err
}
if err := f.SetCellStr(SheetInputs, "B25", "Редактируйте жёлтые ячейки"); err != nil {
return err
}
if err := f.SetCellStr(SheetInputs, "C25", "Формулы на листах сценариев и «Сводка» пересчитаются в OnlyOffice"); err != nil {
return err
}
return styleSheet(f, SheetInputs, SheetInputs)
}
func writeScenarioSheet(f *excelize.File, sheet, ferryName, extraNightsName, scenarioNote string) error {
if err := setHeaders(f, sheet, "Статья", "Мин €", "Mid €", "Макс €", "Пояснение"); err != nil {
return err
}
if err := f.SetCellStr(sheet, "A2", "Паром Básica"); err != nil {
return err
}
for col, ref := range []string{ferryName, ferryName, ferryName} {
cell, _ := excelize.CoordinatesToCellName(col+2, 2)
if err := f.SetCellFormula(sheet, cell, "="+ref); err != nil {
return err
}
}
if err := addCellComment(f, sheet, "A2", "Тариф парома для этого сценария. Берётся с листа «Ввод»."); err != nil {
return err
}
lines := []struct {
label string
low, mid, high, note string
comment string
}{
{"Крыша 7 н Setúbal", "housing_7n_low", "housing_7n_mid", "housing_7n_high", "Airbnb T1a #11", ""},
{"Huelva → Setúbal", "drive_low", "drive_mid", "drive_high", "OSRM ~3,8 ч", ""},
{"Еда на борту", "board_low", "board_mid", "board_high", "Меню FO", ""},
{"Еда 7 д в PT", "food_7d_low", "food_7d_mid", "food_7d_high", "Готовим дома", ""},
{"SIM / документы", "sim_low", "sim_mid", "sim_high", "Cutover", ""},
}
for i, ln := range lines {
row := i + 3
if err := setFormulaRow(f, sheet, row, ln.label,
"="+ln.low, "="+ln.mid, "="+ln.high, ln.note); err != nil {
return err
}
}
if err := f.SetCellStr(sheet, "A8", "Итого Básica"); err != nil {
return err
}
for col := 2; col <= 4; col++ {
cell, _ := excelize.CoordinatesToCellName(col, 8)
colL, _ := excelize.CoordinatesToCellName(col, 2)
colH, _ := excelize.CoordinatesToCellName(col, 7)
if err := f.SetCellFormula(sheet, cell, fmt.Sprintf("=SUM(%s:%s)", colL, colH)); err != nil {
return err
}
}
if err := f.SetCellStr(sheet, "E8", "SUM строк 2–7"); err != nil {
return err
}
if err := setFormulaRow(f, sheet, 9, "Итого Superior",
"=B8+ferry_superior_uplift", "=C8+ferry_superior_uplift", "=D8+ferry_superior_uplift",
"Básica + VIP"); err != nil {
return err
}
if err := addCellComment(f, sheet, "A9", "Butaca Superior / VIP salón. Доплата с листа «Ввод»."); err != nil {
return err
}
if err := f.SetCellStr(sheet, "A10", "Лишние ночи PT"); err != nil {
return err
}
for col, housing := range []string{"housing_7n_low", "housing_7n_mid", "housing_7n_high"} {
cell, _ := excelize.CoordinatesToCellName(col+2, 10)
formula := fmt.Sprintf("=%s*%s/7", extraNightsName, housing)
if err := f.SetCellFormula(sheet, cell, formula); err != nil {
return err
}
}
if err := f.SetCellStr(sheet, "E10", "ночей × (крыша/7)"); err != nil {
return err
}
if err := addCellComment(f, sheet, "A10", "Платное жильё до начала 7-дневной брони. Число ночей — на листе «Ввод» для этого сценария."); err != nil {
return err
}
if err := setFormulaRow(f, sheet, 11, "Итого с ночами",
"=B8+B10", "=C8+C10", "=D8+D10", scenarioNote); err != nil {
return err
}
if err := f.SetCellStr(sheet, "A12", "Остаток от потолка"); err != nil {
return err
}
for _, col := range []string{"B", "C", "D"} {
if err := f.SetCellFormula(sheet, col+"12", "=month_cap-C11"); err != nil {
return err
}
}
if err := f.SetCellStr(sheet, "E12", "потолок − mid итого"); err != nil {
return err
}
if err := addCellComment(f, sheet, "C12", "Сколько остаётся от месячного потолка 2500€ после cutover (mid)."); err != nil {
return err
}
if err := f.SetCellStr(sheet, "A13", "Среднее по строкам"); err != nil {
return err
}
for col := 2; col <= 4; col++ {
cell, _ := excelize.CoordinatesToCellName(col, 13)
colL, _ := excelize.CoordinatesToCellName(col, 2)
colH, _ := excelize.CoordinatesToCellName(col, 7)
if err := f.SetCellFormula(sheet, cell, fmt.Sprintf("=AVERAGE(%s:%s)", colL, colH)); err != nil {
return err
}
}
if err := f.SetCellStr(sheet, "E13", "AVG статей 2–7"); err != nil {
return err
}
return styleSheet(f, sheet, SheetInputs)
}
func writeSummarySheet(f *excelize.File) error {
if err := setHeaders(f, SheetSummary,
"Сценарий", "Básica mid", "Superior mid", "Итого mid", "Остаток", "Δ vs вс"); err != nil {
return err
}
rows := []struct {
label, sheet string
}{
{"Вс 6.09 (live)", SheetDom6},
{"Чт 3.09 (live)", SheetJue3},
{"Ср 2.09 (гипотеза)", SheetWedHyp},
}
qs := quoteSheet
for i, r := range rows {
row := i + 2
if err := f.SetCellStr(SheetSummary, fmt.Sprintf("A%d", row), r.label); err != nil {
return err
}
pairs := []struct {
col int
ref string
}{
{2, fmt.Sprintf("%s!C8", qs(r.sheet))},
{3, fmt.Sprintf("%s!C9", qs(r.sheet))},
{4, fmt.Sprintf("%s!C11", qs(r.sheet))},
{5, fmt.Sprintf("%s!C12", qs(r.sheet))},
}
for _, p := range pairs {
cell, _ := excelize.CoordinatesToCellName(p.col, row)
if err := f.SetCellFormula(SheetSummary, cell, "="+p.ref); err != nil {
return err
}
}
cell, _ := excelize.CoordinatesToCellName(6, row)
if err := f.SetCellFormula(SheetSummary, cell, fmt.Sprintf("=D%d-$D$2", row)); err != nil {
return err
}
}
if err := f.SetCellStr(SheetSummary, "A6", "Потолок месяца"); err != nil {
return err
}
if err := f.SetCellFormula(SheetSummary, "D6", "=month_cap"); err != nil {
return err
}
if err := f.SetCellStr(SheetSummary, "A7", "Лучший итого mid"); err != nil {
return err
}
if err := f.SetCellFormula(SheetSummary, "D7", "=MIN(D2:D4)"); err != nil {
return err
}
if err := f.SetCellStr(SheetSummary, "E7", "MIN по сценариям"); err != nil {
return err
}
if err := addCellComment(f, SheetSummary, "D7", "Минимальный mid «Итого с ночами» среди трёх сценариев. Сейчас обычно вс 6.09."); err != nil {
return err
}
return styleSheet(f, SheetSummary, SheetInputs)
}
+68
View File
@@ -0,0 +1,68 @@
package xlspipe
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/xuri/excelize/v2"
)
func parseCalcFloat(s string) (float64, error) {
s = strings.ReplaceAll(s, ",", "")
s = strings.TrimSpace(s)
var val float64
_, err := fmt.Sscan(s, &val)
return val, err
}
func TestBuildCutoverPortugalWorkbook_Formulas(t *testing.T) {
f, err := BuildCutoverPortugalWorkbook(DefaultCutoverPortugal())
if err != nil {
t.Fatal(err)
}
defer f.Close()
dir := t.TempDir()
path := filepath.Join(dir, "cutover.xlsx")
if err := Save(f, path); err != nil {
t.Fatal(err)
}
st, err := os.Stat(path)
if err != nil || st.Size() < 4096 {
t.Fatalf("xlsx too small: %v", err)
}
opened, err := excelize.OpenFile(path)
if err != nil {
t.Fatal(err)
}
defer opened.Close()
cases := []struct {
sheet, cell string
want float64
tol float64
}{
{SheetDom6, "C8", 1522.89, 0.02},
{SheetJue3, "C8", 1549.09, 0.02},
{SheetWedHyp, "C11", 1891.95, 0.05},
{SheetSummary, "D2", 1522.89, 0.02},
{SheetSummary, "D7", 1522.89, 0.02},
}
for _, tc := range cases {
got, err := opened.CalcCellValue(tc.sheet, tc.cell)
if err != nil {
t.Fatalf("%s!%s calc: %v", tc.sheet, tc.cell, err)
}
val, err := parseCalcFloat(got)
if err != nil {
t.Fatalf("%s!%s parse %q: %v", tc.sheet, tc.cell, got, err)
}
if diff := val - tc.want; diff < -tc.tol || diff > tc.tol {
t.Fatalf("%s!%s = %v want ~%v", tc.sheet, tc.cell, val, tc.want)
}
}
}
+26
View File
@@ -0,0 +1,26 @@
package xlspipe
import (
"fmt"
"strings"
"github.com/xuri/excelize/v2"
)
// Template names for oo docs put-xlsx --template.
const (
TemplateCutoverPortugal = "cutover-portugal"
)
// BuildTemplate returns an xlsx workbook for a known template name.
func BuildTemplate(name string) (*File, error) {
switch strings.ToLower(strings.TrimSpace(name)) {
case TemplateCutoverPortugal, "portugal-cutover", "cutover":
return BuildCutoverPortugalWorkbook(DefaultCutoverPortugal())
default:
return nil, fmt.Errorf("unknown xlsx template %q (try: %s)", name, TemplateCutoverPortugal)
}
}
// File is an alias so cmd/oo can refer to excelize.File without importing excelize in every handler.
type File = excelize.File
+135
View File
@@ -0,0 +1,135 @@
package xlspipe
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/xuri/excelize/v2"
)
const commentAuthor = "oo"
// Save writes the workbook to path (creates parent dirs).
func Save(f *excelize.File, path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return f.SaveAs(path)
}
// quoteSheet returns an Excel sheet reference safe for formulas ('Name'!A1).
func quoteSheet(name string) string {
return "'" + strings.ReplaceAll(name, "'", "''") + "'"
}
// defineInput registers a named range on sheet!B{row}, sets value, note, optional comment.
func defineInput(f *excelize.File, sheet, name string, row int, value float64, note, comment string) error {
cell := fmt.Sprintf("B%d", row)
if err := f.SetCellFloat(sheet, cell, value, 2, 64); err != nil {
return err
}
if note != "" {
if err := f.SetCellStr(sheet, fmt.Sprintf("C%d", row), note); err != nil {
return err
}
}
ref := fmt.Sprintf("%s!$B$%d", quoteSheet(sheet), row)
if err := f.SetDefinedName(&excelize.DefinedName{Name: name, RefersTo: ref}); err != nil {
return err
}
if comment != "" {
return addCellComment(f, sheet, cell, comment)
}
return nil
}
func addCellComment(f *excelize.File, sheet, cell, text string) error {
return f.AddComment(sheet, excelize.Comment{
Cell: cell,
Author: commentAuthor,
Text: text,
Width: 280,
Height: 120,
})
}
func setHeaders(f *excelize.File, sheet string, headers ...string) error {
for i, h := range headers {
cell, err := excelize.CoordinatesToCellName(i+1, 1)
if err != nil {
return err
}
if err := f.SetCellStr(sheet, cell, h); err != nil {
return err
}
}
return nil
}
func setFormulaRow(f *excelize.File, sheet string, row int, label string, low, mid, high, note string) error {
if err := f.SetCellStr(sheet, fmt.Sprintf("A%d", row), label); err != nil {
return err
}
for col, formula := range []string{low, mid, high} {
cell, err := excelize.CoordinatesToCellName(col+2, row)
if err != nil {
return err
}
if err := f.SetCellFormula(sheet, cell, formula); err != nil {
return err
}
}
if note != "" {
return f.SetCellStr(sheet, fmt.Sprintf("E%d", row), note)
}
return nil
}
func styleSheet(f *excelize.File, sheet, inputsSheet string) error {
widths := map[string]float64{"A": 34, "B": 12, "C": 12, "D": 12, "E": 40}
for col, w := range widths {
if err := f.SetColWidth(sheet, col, col, w); err != nil {
return err
}
}
headerStyle, err := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true},
Fill: excelize.Fill{Type: "pattern", Color: []string{"#E8F0FE"}, Pattern: 1},
Alignment: &excelize.Alignment{Horizontal: "center", WrapText: true},
})
if err != nil {
return err
}
euroStyle, err := f.NewStyle(&excelize.Style{NumFmt: 4})
if err != nil {
return err
}
inputStyle, err := f.NewStyle(&excelize.Style{
NumFmt: 4,
Fill: excelize.Fill{Type: "pattern", Color: []string{"#FFF9E6"}, Pattern: 1},
})
if err != nil {
return err
}
if err := f.SetCellStyle(sheet, "A1", "E1", headerStyle); err != nil {
return err
}
if sheet == inputsSheet {
return f.SetCellStyle(sheet, "B2", "B30", inputStyle)
}
if err := f.SetCellStyle(sheet, "B2", "D20", euroStyle); err != nil {
return err
}
return nil
}
func finalizeWorkbook(f *excelize.File) error {
if err := f.SetCalcProps(&excelize.CalcPropsOptions{FullCalcOnLoad: boolPtr(true)}); err != nil {
return err
}
return f.UpdateLinkedValue()
}
func boolPtr(v bool) *bool { return &v }