From 68445b0dfdcf742ccdbaa2402e423cd97199fefc Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Fri, 28 Aug 2026 17:37:53 +0100 Subject: [PATCH] feat(docs): put-xlsx with excelize cutover workbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add internal/xlspipe (github.com/xuri/excelize/v2) and oo docs put-xlsx to generate multi-sheet budgets with named inputs, SUM/AVG/MIN formulas, cross-sheet links, Russian labels, and cell comments for OnlyOffice. Template cutover-portugal: sheets Ввод, Вс 6.09, Чт 3.09, Ср гип, Сводка. Upload via UploadProjectFileReplacing (--replace default). Co-authored-by: Cursor --- cmd/oo/docs.go | 128 ++++++- cmd/oo/projects_files.go | 12 +- files.go | 9 + go.mod | 13 +- go.sum | 32 +- internal/xlspipe/cutover_portugal.go | 393 ++++++++++++++++++++++ internal/xlspipe/cutover_portugal_test.go | 68 ++++ internal/xlspipe/template.go | 26 ++ internal/xlspipe/xlspipe.go | 135 ++++++++ 9 files changed, 800 insertions(+), 16 deletions(-) create mode 100644 internal/xlspipe/cutover_portugal.go create mode 100644 internal/xlspipe/cutover_portugal_test.go create mode 100644 internal/xlspipe/template.go create mode 100644 internal/xlspipe/xlspipe.go diff --git a/cmd/oo/docs.go b/cmd/oo/docs.go index 4bbfa37..35b4b6c 100644 --- a/cmd/oo/docs.go +++ b/cmd/oo/docs.go @@ -8,6 +8,7 @@ import ( onlyoffice "github.com/eslider/go-onlyoffice" "github.com/eslider/go-onlyoffice/internal/docpipe" + "github.com/eslider/go-onlyoffice/internal/xlspipe" "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. 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/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 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`, @@ -35,6 +37,7 @@ Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`, cmd.AddCommand(docsAsMDCmd()) cmd.AddCommand(docsPutMDCmd()) cmd.AddCommand(docsPutTxtCmd()) + cmd.AddCommand(docsPutXlsxCmd()) cmd.AddCommand(docsToolsCmd()) return cmd } @@ -122,11 +125,11 @@ text overlays that break OnlyOffice DocEditor. Skips OCR; rewrites via gs pdfwri } outChars, _ := t.PDFTextLayerChars(out) printObject(map[string]any{ - "in": in, - "pdf": out, - "text_chars_in": chars, - "text_chars_out": outChars, - "note": "native text layer preserved; no OCR overlay", + "in": in, + "pdf": out, + "text_chars_in": chars, + "text_chars_out": outChars, + "note": "native text layer preserved; no OCR overlay", }) 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)") 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 +} diff --git a/cmd/oo/projects_files.go b/cmd/oo/projects_files.go index 346cb6c..df726d1 100644 --- a/cmd/oo/projects_files.go +++ b/cmd/oo/projects_files.go @@ -29,6 +29,7 @@ func projectFilesCmd() *cobra.Command { cmd.AddCommand(aliasDocsAsMD()) cmd.AddCommand(aliasDocsPutMD()) cmd.AddCommand(aliasDocsPutTxt()) + cmd.AddCommand(aliasDocsPutXlsx()) return cmd } @@ -53,6 +54,13 @@ func aliasDocsPutTxt() *cobra.Command { 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 { var showFolders bool cmd := &cobra.Command{ @@ -75,8 +83,8 @@ func prjFilesListCmd() *cobra.Command { continue } frows = append(frows, map[string]any{ - "id": folderIDStr(f), - "title": derefString(f.Title), + "id": folderIDStr(f), + "title": derefString(f.Title), "filesCount": derefInt(f.FilesCount), "foldersCount": derefInt(f.FoldersCount), }) diff --git a/files.go b/files.go index 61da206..43a740e 100644 --- a/files.go +++ b/files.go @@ -237,6 +237,15 @@ func (c *Client) UploadProjectFile(ctx context.Context, projectID, localPath str 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. func (c *Client) GetFile(ctx context.Context, fileID string) (*FileEntry, error) { if fileID == "" { diff --git a/go.mod b/go.mod index 6bb29de..efbac23 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/mattn/go-runewidth v0.0.15 github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.10.2 + github.com/xuri/excelize/v2 v2.11.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.56.0 ) @@ -42,15 +43,21 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/ncruces/go-strftime v1.0.0 // 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/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-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/sys v0.47.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 8f397bd..f3dff4b 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 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/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 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.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= 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/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 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/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +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/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= 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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= 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.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +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/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/xlspipe/cutover_portugal.go b/internal/xlspipe/cutover_portugal.go new file mode 100644 index 0000000..f438369 --- /dev/null +++ b/internal/xlspipe/cutover_portugal.go @@ -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) +} diff --git a/internal/xlspipe/cutover_portugal_test.go b/internal/xlspipe/cutover_portugal_test.go new file mode 100644 index 0000000..51d922b --- /dev/null +++ b/internal/xlspipe/cutover_portugal_test.go @@ -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) + } + } +} diff --git a/internal/xlspipe/template.go b/internal/xlspipe/template.go new file mode 100644 index 0000000..b6aa734 --- /dev/null +++ b/internal/xlspipe/template.go @@ -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 diff --git a/internal/xlspipe/xlspipe.go b/internal/xlspipe/xlspipe.go new file mode 100644 index 0000000..d219b1a --- /dev/null +++ b/internal/xlspipe/xlspipe.go @@ -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 } -- 2.54.0