chore: move business tooling out of the public library; tidy filestore naming #77
@@ -1,42 +0,0 @@
|
|||||||
name: Release Please
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
actions: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release-please:
|
|
||||||
name: Release Please
|
|
||||||
if: github.server_url == 'https://github.com'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Run release-please
|
|
||||||
uses: googleapis/release-please-action@v4
|
|
||||||
id: release
|
|
||||||
with:
|
|
||||||
config-file: release-please-config.json
|
|
||||||
manifest-file: .release-please-manifest.json
|
|
||||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN != '' && secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
# Tags/releases created with GITHUB_TOKEN do not trigger other workflows
|
|
||||||
# (push:tags on Release.yml never fires). Dispatch GoReleaser explicitly.
|
|
||||||
- name: Trigger GoReleaser
|
|
||||||
if: ${{ steps.release.outputs.release_created == 'true' }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TAG: ${{ steps.release.outputs.tag_name }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
echo "Dispatching Release workflow for $TAG"
|
|
||||||
gh workflow run release.yml --repo "${{ github.repository }}" -f "tag=${TAG}"
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
release_created: ${{ steps.release.outputs.release_created }}
|
|
||||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
|
||||||
@@ -20,8 +20,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# Always clone default branch first. workflow_dispatch often races with
|
# Always clone default branch first. workflow_dispatch often races with
|
||||||
# release-please creating the tag; fetching refs/tags/X before it exists
|
# tag creation; fetching refs/tags/X before it exists fails checkout.
|
||||||
# fails checkout. Retry fetch, then check out the tag.
|
# Retry fetch, then check out the tag.
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
".": "0.18.0"
|
|
||||||
}
|
|
||||||
@@ -9,8 +9,8 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
|
|||||||
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
|
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
|
||||||
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
|
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
|
||||||
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`).
|
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`).
|
||||||
- `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `files_webdav.go`, `files_stem.go`, `retry.go`, `mails.go`, `invoices.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents** (`UpdateFile`, `UploadToFolderReplacing`). **`files_webdav.go`** — Documents module by id (`ListDavFolder`, `MoveDavItems`/`CopyDavItems` with per-operation error surfacing, `ListFileOps`). **`retry.go`** — `DoRetry`: deterministic exponential backoff (no jitter) on 429/502/503/504; every bulk tool routes API calls through it, and the HTTP transport + auth (`retryRaw`, `AuthenticateContext`) retry transient answers centrally. `ratelimit.go` adds a process-wide token bucket (`OO_RATE_LIMIT`/`OO_BURST`) and a shared 429 cooldown gate, installed via `pacedTransport` in `NewClient`; `Retry-After` is parsed into `*TransientError` and honoured. See [`docs/rate-limiting.md`](docs/rate-limiting.md). **`mails.go`** — OnlyOffice Workspace Mail. **`invoices.go`** — CRM invoices, PDF regen/cleanup, status. Association rules: [`docs/crm-associations.md`](docs/crm-associations.md).
|
- `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `files_webdav.go`, `files_stem.go`, `retry.go`, `mails.go`, `invoices.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents** (`UpdateFile`, `UploadToFolderReplacing`). **`files_webdav.go`** — Documents module by id (`ListDavFolder`, `MoveDavItems`/`CopyDavItems` with per-operation error surfacing, `ListFileOps`). **`retry.go`** — `DoRetry`: deterministic exponential backoff (no jitter) on 429/502/503/504; every bulk tool routes API calls through it, and the HTTP transport + auth (`retryRaw`, `AuthenticateContext`) retry transient answers centrally. `ratelimit.go` adds a process-wide token bucket (`OO_RATE_LIMIT`/`OO_BURST`) and a shared 429 cooldown gate, installed via `pacedTransport` in `NewClient`; `Retry-After` is parsed into `*TransientError` and honoured. See [`docs/rate-limiting.md`](docs/rate-limiting.md). **`mails.go`** — OnlyOffice Workspace Mail. **`invoices.go`** — CRM invoices, PDF regen/cleanup, status. Association rules live with the private `oo-workspace` tooling.
|
||||||
- **Unified file client (epic #34) — `file_core.go`, `file_rest.go`, `file_dav.go`, `file_pg.go`, `file_es.go`, `file_es_text.go`, `file_text_index.go`, `file_facade.go`.** `file_core.go` — model (`Entry`, `Kind`) + `FileStore`/`Searcher`; `file_rest.go`/`file_dav.go` — REST/WebDAV adapters; `file_pg.go` — **read-only** SQL store (PostgreSQL/MySQL, `ErrReadOnly` on writes); `file_es.go` — OnlyOffice Elasticsearch searcher; `file_es_text.go`/`file_text_index.go` — own PDF/scan index (`oo_docs_text`, PDF attachments via pdfdetach); `file_facade.go` — `FileClient` with read/write/search order and transient fallback. Use `c.Files()` (facade), `c.FileStore("rest"|"dav"|"pg"|"sql")` or `c.SQLFileStore()`; contract and how to add a backend: [`docs/unified-file-client.md`](docs/unified-file-client.md).
|
- **Unified file client (epic #34) — `filestore_core.go`, `filestore_rest.go`, `filestore_dav.go`, `filestore_pg.go`, `filestore_es.go`, `filestore_es_text.go`, `filestore_text_index.go`, `filestore_facade.go`.** `filestore_core.go` — model (`Entry`, `Kind`) + `FileStore`/`Searcher`; `filestore_rest.go`/`filestore_dav.go` — REST/WebDAV adapters; `filestore_pg.go` — **read-only** SQL store (PostgreSQL/MySQL, `ErrReadOnly` on writes); `filestore_es.go` — OnlyOffice Elasticsearch searcher; `filestore_es_text.go`/`filestore_text_index.go` — own PDF/scan index (`oo_docs_text`, PDF attachments via pdfdetach); `filestore_facade.go` — `FileClient` with read/write/search order and transient fallback. Use `c.Files()` (facade), `c.FileStore("rest"|"dav"|"pg"|"sql")` or `c.SQLFileStore()`; contract and how to add a backend: [`docs/unified-file-client.md`](docs/unified-file-client.md).
|
||||||
- Pure stdlib + `google/go-querystring`; no UI, no dotenv.
|
- Pure stdlib + `google/go-querystring`; no UI, no dotenv.
|
||||||
- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea):
|
- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea):
|
||||||
- `main.go` — entry point (docstring lists the command tree).
|
- `main.go` — entry point (docstring lists the command tree).
|
||||||
@@ -20,7 +20,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
|
|||||||
- **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`.
|
- **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`.
|
||||||
- **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables.
|
- **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables.
|
||||||
- **Shared bootstrap — `cmd/internal/bootstrap/`.** `LoadEnv()` + `NewClient(ctx)` extracted from `oo`; both binaries import it.
|
- **Shared bootstrap — `cmd/internal/bootstrap/`.** `LoadEnv()` + `NewClient(ctx)` extracted from `oo`; both binaries import it.
|
||||||
- **Bulk Documents tools — `cmd/ooscan/`, `cmd/pdfamount/`, `cmd/kontoblatt/`, `cmd/kontolink/`.** Single-purpose binaries (folder index, PDF amounts, Kontoblatt summary/linking). Pace requests, route API calls through `DoRetry`; usage in README.
|
- **Bulk Documents tools live in the private `oo-workspace` repo** (`ooscan`, `pdfamount`, `kontoblatt`, `kontolink`), not in this public tree. They use the public client and its `DoRetry` pacing.
|
||||||
- **Personal ops tooling** (disk inventory, dossier→CRM sync, SearXNG) lives in a private companion repo `eSlider/oo-workspace` (the `oow` CLI), not in this public tree.
|
- **Personal ops tooling** (disk inventory, dossier→CRM sync, SearXNG) lives in a private companion repo `eSlider/oo-workspace` (the `oow` CLI), not in this public tree.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|||||||
@@ -801,26 +801,11 @@ with `c.SQLFileStore()`, or register a backend on the facade
|
|||||||
fallback rules, env names and how to add a backend:
|
fallback rules, env names and how to add a backend:
|
||||||
[`docs/unified-file-client.md`](docs/unified-file-client.md).
|
[`docs/unified-file-client.md`](docs/unified-file-client.md).
|
||||||
|
|
||||||
### Bulk tools (`cmd/`)
|
### Bulk tools
|
||||||
|
|
||||||
Small single-purpose binaries for bulk Documents work. All of them pace
|
Business / one-off bulk tools (`ooscan`, `pdfamount`, `kontoblatt`,
|
||||||
requests through a process-wide token bucket (default ~4 req/s, `OO_RATE_LIMIT`/
|
`kontolink`) live in the private `oo-workspace` repo, not in this public
|
||||||
`OO_BURST`) and retry transient OnlyOffice answers (429/502/503/504) with a
|
library. They build on the public client and the same `DoRetry` pacing.
|
||||||
deterministic exponential backoff — no jitter, same waits on every run. A 429
|
|
||||||
opens a shared cooldown gate and `Retry-After` is honoured (see `DoRetry` and
|
|
||||||
[`docs/rate-limiting.md`](docs/rate-limiting.md)). Build with
|
|
||||||
`go build ./cmd/<tool>`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ooscan 659 # recursive index → TSV: file_id, folder_id, path, title
|
|
||||||
ooscan 659 666 > oo-index.tsv # several roots into one index
|
|
||||||
pdfamount 671 # "Zu zahlender Betrag" per PDF → TSV: file_id, title, amount
|
|
||||||
kontoblatt 1234 ./kontoblatt.xlsx # summary (Gegenkonto/Monat) uploaded next to source
|
|
||||||
kontolink IN.xlsx oo-index.tsv OUT.xlsx [FILE_ID] [AMOUNTS_TSV]
|
|
||||||
# kontolink writes DocEditor links into the Link column: Beleg → supplier+month
|
|
||||||
# → amount+date (5th arg = pdfamount output); with FILE_ID it updates the
|
|
||||||
# source file in place, else uploads an "(links)" copy next to it.
|
|
||||||
```
|
|
||||||
|
|
||||||
| Subject | Verbs |
|
| Subject | Verbs |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -934,8 +919,8 @@ Merge two known company ids (keeps `INTO`):
|
|||||||
oo contacts merge FROM_ID INTO_ID
|
oo contacts merge FROM_ID INTO_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
Company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail rules and OO quirks:
|
Company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail rules and OO quirks live
|
||||||
[docs/crm-associations.md](docs/crm-associations.md).
|
with the private `oo-workspace` tooling.
|
||||||
|
|
||||||
### Invoices (`oo invoices`)
|
### Invoices (`oo invoices`)
|
||||||
|
|
||||||
@@ -1125,24 +1110,20 @@ Live runs need credentials (`ONLYOFFICE_URL`, `ONLYOFFICE_USER`,
|
|||||||
|
|
||||||
### rclone WebDAV mount
|
### rclone WebDAV mount
|
||||||
|
|
||||||
`deploy/docker-compose.rclone-webdav.yml` mounts the Documents tree as a
|
The rclone WebDAV mount (compose + `oo-webdav` sidecar) is deployment
|
||||||
filesystem (compose, not systemd; container `rclone-webdav`) — read/write like
|
tooling and lives in the private `oo-workspace` repo. Set
|
||||||
a normal FS over the `oo-webdav` sidecar. Setup, smoke log and limitations:
|
`ONLYOFFICE_WEBDAV_URL` to point the client at it.
|
||||||
[`docs/rclone-webdav.md`](docs/rclone-webdav.md).
|
|
||||||
|
|
||||||
### CI / releases
|
### CI / releases
|
||||||
|
|
||||||
GitHub Actions (pattern from [`eSlider/go-config`](https://github.com/eSlider/go-config)):
|
|
||||||
|
|
||||||
| Workflow | Trigger | Purpose |
|
| Workflow | Trigger | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `test.yml` | push / PR | `go vet`, unit tests, build `oo` + `office` |
|
| `test.yml` | push / PR | `go vet`, unit tests, build `oo` + `office` |
|
||||||
| `release-please.yml` | push to `main` | semver PR from conventional commits |
|
|
||||||
| `release.yml` | tag `v*` | GoReleaser cross-platform `oo` + `office` binaries |
|
| `release.yml` | tag `v*` | GoReleaser cross-platform `oo` + `office` binaries |
|
||||||
|
|
||||||
Repo setting required once: **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests**.
|
Gitea is canonical; tags are created there per SemVer (`fix:` → patch,
|
||||||
|
`feat:` → minor, `!` → major). GoReleaser publishes assets to
|
||||||
Merge the release-please PR to tag a version; GoReleaser publishes assets to [GitHub Releases](https://github.com/eSlider/go-onlyoffice/releases).
|
[GitHub Releases](https://github.com/eSlider/go-onlyoffice/releases).
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
// Command kontoblatt builds a summary ("сводная таблица") of a Kontoblatt XLSX
|
|
||||||
// (Datum, Gegenkonto, Buchungstext, Beleg, Soll, Haben, Bemerkung) and uploads
|
|
||||||
// it back to the same OnlyOffice folder as the source file.
|
|
||||||
//
|
|
||||||
// Usage: kontoblatt <FILE_ID> <LOCAL_XLSX>
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
|
||||||
"github.com/xuri/excelize/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type agg struct {
|
|
||||||
count int
|
|
||||||
soll float64
|
|
||||||
haben float64
|
|
||||||
reFehlt int
|
|
||||||
}
|
|
||||||
|
|
||||||
type rec struct {
|
|
||||||
date, month, konto, text string
|
|
||||||
soll, haben float64
|
|
||||||
reFehlt bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var dateRe = regexp.MustCompile(`^\d{2}\.\d{2}\.\d{4}$`)
|
|
||||||
|
|
||||||
func parseAmount(s string) float64 {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
s = strings.ReplaceAll(s, "€", "")
|
|
||||||
s = strings.ReplaceAll(s, " ", "")
|
|
||||||
s = strings.ReplaceAll(s, ",", "") // German thousands separator
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if s == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
v, err := strconv.ParseFloat(s, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func cell(row []string, i int) string {
|
|
||||||
if i < len(row) {
|
|
||||||
return strings.TrimSpace(row[i])
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if len(os.Args) < 3 {
|
|
||||||
fmt.Fprintln(os.Stderr, "usage: kontoblatt <FILE_ID> <LOCAL_XLSX>")
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
fileID, path := os.Args[1], os.Args[2]
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
f, err := excelize.OpenFile(path)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
var recs []rec
|
|
||||||
for _, sh := range f.GetSheetList() {
|
|
||||||
rows, err := f.GetRows(sh)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, r := range rows {
|
|
||||||
d := cell(r, 0)
|
|
||||||
if !dateRe.MatchString(d) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
text := cell(r, 2)
|
|
||||||
recs = append(recs, rec{
|
|
||||||
date: d,
|
|
||||||
month: d[3:10],
|
|
||||||
konto: cell(r, 1),
|
|
||||||
text: text,
|
|
||||||
soll: parseAmount(cell(r, 4)),
|
|
||||||
haben: parseAmount(cell(r, 5)),
|
|
||||||
reFehlt: strings.Contains(strings.ToUpper(text), "FEHLT"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
byKonto := map[string]*agg{}
|
|
||||||
byMonth := map[string]*agg{}
|
|
||||||
getK := func(k string) *agg {
|
|
||||||
if byKonto[k] == nil {
|
|
||||||
byKonto[k] = &agg{}
|
|
||||||
}
|
|
||||||
return byKonto[k]
|
|
||||||
}
|
|
||||||
getM := func(k string) *agg {
|
|
||||||
if byMonth[k] == nil {
|
|
||||||
byMonth[k] = &agg{}
|
|
||||||
}
|
|
||||||
return byMonth[k]
|
|
||||||
}
|
|
||||||
var tot agg
|
|
||||||
for _, r := range recs {
|
|
||||||
k := getK(r.konto)
|
|
||||||
k.count++
|
|
||||||
k.soll += r.soll
|
|
||||||
k.haben += r.haben
|
|
||||||
if r.reFehlt {
|
|
||||||
k.reFehlt++
|
|
||||||
}
|
|
||||||
m := getM(r.month)
|
|
||||||
m.count++
|
|
||||||
m.soll += r.soll
|
|
||||||
m.haben += r.haben
|
|
||||||
if r.reFehlt {
|
|
||||||
m.reFehlt++
|
|
||||||
}
|
|
||||||
tot.count++
|
|
||||||
tot.soll += r.soll
|
|
||||||
tot.haben += r.haben
|
|
||||||
if r.reFehlt {
|
|
||||||
tot.reFehlt++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := excelize.NewFile()
|
|
||||||
defer out.Close()
|
|
||||||
writeSheet(out, "Nach Gegenkonto", "Gegenkonto", byKonto, tot)
|
|
||||||
writeSheet(out, "Nach Monat", "Monat", byMonth, tot)
|
|
||||||
outPath := "/tmp/opencode/kontoblatt-zusammenfassung.xlsx"
|
|
||||||
if err := out.SaveAs(outPath); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// upload next to the source file
|
|
||||||
creds := onlyoffice.GetEnvironmentCredentials()
|
|
||||||
c := onlyoffice.NewClient(creds)
|
|
||||||
var src *onlyoffice.FileEntry
|
|
||||||
if derr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error {
|
|
||||||
var err error
|
|
||||||
src, err = c.GetFile(ctx, fileID)
|
|
||||||
return err
|
|
||||||
}); derr != nil {
|
|
||||||
panic(derr)
|
|
||||||
}
|
|
||||||
folder := ""
|
|
||||||
if src.FolderID != nil {
|
|
||||||
folder = src.FolderID.String()
|
|
||||||
}
|
|
||||||
title := ""
|
|
||||||
if src.Title != nil {
|
|
||||||
title = *src.Title
|
|
||||||
}
|
|
||||||
fmt.Printf("source: id=%s title=%q folder=%s\n", fileID, title, folder)
|
|
||||||
|
|
||||||
name := "Kontoblatt-Zusammenfassung.xlsx"
|
|
||||||
tmp := "/tmp/opencode/" + name
|
|
||||||
data, _ := os.ReadFile(outPath)
|
|
||||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
var entry *onlyoffice.FileEntry
|
|
||||||
if derr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error {
|
|
||||||
var err error
|
|
||||||
entry, _, err = c.UploadToFolderReplacing(ctx, folder, tmp)
|
|
||||||
return err
|
|
||||||
}); derr != nil {
|
|
||||||
panic(derr)
|
|
||||||
}
|
|
||||||
fmt.Printf("uploaded: %s -> folder %s (id %v)\n", name, folder, entry.ID)
|
|
||||||
|
|
||||||
// print the summary
|
|
||||||
printAgg("Nach Gegenkonto", byKonto, tot)
|
|
||||||
printAgg("Nach Monat", byMonth, tot)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeSheet(f *excelize.File, sheet, key string, m map[string]*agg, tot agg) {
|
|
||||||
f.NewSheet(sheet)
|
|
||||||
rows := [][]any{{key, "Anzahl", "Soll", "Haben", "Saldo", `davon "fehlt"`}}
|
|
||||||
keys := make([]string, 0, len(m))
|
|
||||||
for k := range m {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
a := m[k]
|
|
||||||
rows = append(rows, []any{k, a.count, a.soll, a.haben, a.soll - a.haben, a.reFehlt})
|
|
||||||
}
|
|
||||||
rows = append(rows, []any{"GESAMT", tot.count, tot.soll, tot.haben, tot.soll - tot.haben, tot.reFehlt})
|
|
||||||
for i, row := range rows {
|
|
||||||
for j, v := range row {
|
|
||||||
cellRef, _ := excelize.CoordinatesToCellName(j+1, i+1)
|
|
||||||
_ = f.SetCellValue(sheet, cellRef, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func printAgg(title string, m map[string]*agg, tot agg) {
|
|
||||||
fmt.Printf("\n== %s ==\n", title)
|
|
||||||
keys := make([]string, 0, len(m))
|
|
||||||
for k := range m {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
fmt.Printf("%-12s %6s %12s %12s %12s %7s\n", "key", "count", "soll", "haben", "saldo", "fehlt")
|
|
||||||
for _, k := range keys {
|
|
||||||
a := m[k]
|
|
||||||
fmt.Printf("%-12s %6d %12.2f %12.2f %12.2f %7d\n", k, a.count, a.soll, a.haben, a.soll-a.haben, a.reFehlt)
|
|
||||||
}
|
|
||||||
fmt.Printf("%-12s %6d %12.2f %12.2f %12.2f %7d\n", "GESAMT", tot.count, tot.soll, tot.haben, tot.soll-tot.haben, tot.reFehlt)
|
|
||||||
}
|
|
||||||
@@ -1,492 +0,0 @@
|
|||||||
// Command kontolink fills the "Link" column of a Kontoblatt ("ungeklärte
|
|
||||||
// Posten") XLSX by matching each row to an OnlyOffice document.
|
|
||||||
//
|
|
||||||
// Strategy (deterministic, conservative — no LLM):
|
|
||||||
// 1. Beleg token (letters/digits from the "Beleg" column) appears in the file
|
|
||||||
// title; among candidates prefer (a) the row's month, (b) real invoices over
|
|
||||||
// copies/dupes, and require the result to be unique;
|
|
||||||
// 2. else supplier + row month + "rechnung", again unique.
|
|
||||||
//
|
|
||||||
// A file is linked at most once (rows already carrying a link are kept and their
|
|
||||||
// file counts as used). Ambiguous rows are left UNLINKED for manual review.
|
|
||||||
//
|
|
||||||
// Usage: kontolink <IN_XLSX> <INDEX_TSV> <OUT_XLSX>
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
|
||||||
"github.com/xuri/excelize/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
dateRe = regexp.MustCompile(`^\d{2}\.\d{2}\.\d{4}$`)
|
|
||||||
nonAln = regexp.MustCompile(`[^0-9a-z]+`)
|
|
||||||
fileID = regexp.MustCompile(`fileid=(\d+)`)
|
|
||||||
)
|
|
||||||
|
|
||||||
func parseDay(s string) (time.Time, bool) {
|
|
||||||
t, err := time.Parse("02.01.2006", strings.TrimSpace(s))
|
|
||||||
return t, err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func titleDay(title string) (time.Time, bool) {
|
|
||||||
if len(title) >= 10 {
|
|
||||||
if t, err := time.Parse("2006-01-02", title[:10]); err == nil {
|
|
||||||
return t, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// nearest picks the candidate whose title date is closest to rd. Ties and
|
|
||||||
// undated candidates (when >1) are rejected.
|
|
||||||
func nearest(cands []entry, rd time.Time) (entry, bool) {
|
|
||||||
if len(cands) == 1 {
|
|
||||||
return cands[0], true
|
|
||||||
}
|
|
||||||
best, bestD, tie := -1, 0.0, false
|
|
||||||
for i, e := range cands {
|
|
||||||
td, ok := titleDay(e.title)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
d := td.Sub(rd).Hours() / 24
|
|
||||||
if d < 0 {
|
|
||||||
d = -d
|
|
||||||
}
|
|
||||||
if best < 0 || d < bestD {
|
|
||||||
best, bestD, tie = i, d, false
|
|
||||||
} else if d == bestD {
|
|
||||||
tie = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if best < 0 || tie {
|
|
||||||
return entry{}, false
|
|
||||||
}
|
|
||||||
return cands[best], true
|
|
||||||
}
|
|
||||||
|
|
||||||
// portalBaseFromEnv resolves the portal base URL used to build document links.
|
|
||||||
func portalBaseFromEnv() string {
|
|
||||||
for _, k := range []string{"ONLYOFFICE_URL", "ONLYOFFICE_HOST", "OO_URL"} {
|
|
||||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
|
||||||
return strings.TrimRight(v, "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type entry struct {
|
|
||||||
id, path, title, norm string
|
|
||||||
}
|
|
||||||
|
|
||||||
func norm(s string) string { return nonAln.ReplaceAllString(strings.ToLower(s), "") }
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if len(os.Args) < 4 {
|
|
||||||
fmt.Fprintln(os.Stderr, "usage: kontolink <IN_XLSX> <INDEX_TSV> <OUT_XLSX>")
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
in, idxPath, out := os.Args[1], os.Args[2], os.Args[3]
|
|
||||||
|
|
||||||
portal := portalBaseFromEnv()
|
|
||||||
if portal == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "set ONLYOFFICE_URL (or ONLYOFFICE_HOST/OO_URL) to build document links")
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
idxRaw, err := os.ReadFile(idxPath)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
var entries []entry
|
|
||||||
for _, line := range strings.Split(string(idxRaw), "\n") {
|
|
||||||
parts := strings.Split(line, "\t")
|
|
||||||
if len(parts) < 4 || parts[0] == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entries = append(entries, entry{id: parts[0], path: parts[2], title: parts[3], norm: norm(parts[3])})
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := excelize.OpenFile(in)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
sheet := f.GetSheetList()[0]
|
|
||||||
rows, err := f.GetRows(sheet)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// optional 5th arg: amounts TSV "file_id\ttitle\tamount" (see cmd/pdfamount)
|
|
||||||
var amts []amtEntry
|
|
||||||
if len(os.Args) >= 6 && os.Args[5] != "" {
|
|
||||||
amts = loadAmounts(os.Args[5])
|
|
||||||
}
|
|
||||||
|
|
||||||
used := map[string]bool{}
|
|
||||||
for _, r := range rows {
|
|
||||||
if m := fileID.FindStringSubmatch(cell(r, 7)); m != nil {
|
|
||||||
used[m[1]] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var linked, byBeleg, bySupplier, byAmount, unmatched, ambiguous int
|
|
||||||
for i, r := range rows {
|
|
||||||
if i == 0 || !dateRe.MatchString(cell(r, 0)) || strings.TrimSpace(cell(r, 7)) != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
beleg := norm(cell(r, 3))
|
|
||||||
supplier := supplierNorm(cell(r, 2))
|
|
||||||
month := monthYear(cell(r, 0))
|
|
||||||
rd, _ := parseDay(cell(r, 0))
|
|
||||||
|
|
||||||
e, kind, ok := pick(entries, used, beleg, supplier, month, rd)
|
|
||||||
if !ok {
|
|
||||||
if ae, aok := amountPick(amts, used, supplier, rowAmount(r), rd); aok {
|
|
||||||
e, kind, ok = entry{id: ae.id, title: ae.title}, "amount", true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
if beleg != "" {
|
|
||||||
ambiguous++
|
|
||||||
} else {
|
|
||||||
unmatched++
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ref, _ := excelize.CoordinatesToCellName(8, i+1)
|
|
||||||
if err := f.SetCellValue(sheet, ref, portal+"/Products/Files/DocEditor.aspx?fileid="+e.id); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
used[e.id] = true
|
|
||||||
linked++
|
|
||||||
switch kind {
|
|
||||||
case "beleg":
|
|
||||||
byBeleg++
|
|
||||||
case "supplier":
|
|
||||||
bySupplier++
|
|
||||||
case "amount":
|
|
||||||
byAmount++
|
|
||||||
}
|
|
||||||
fmt.Printf("row %3d %-30s -> %s [%s]\n", i+1, cell(r, 2), e.title, kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := f.SaveAs(out); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
fmt.Printf("\nlinked=%d (beleg=%d, supplier=%d, amount=%d), ambiguous=%d, no-candidate=%d\n",
|
|
||||||
linked, byBeleg, bySupplier, byAmount, ambiguous, unmatched)
|
|
||||||
|
|
||||||
// Optional 4th arg: source OnlyOffice file id. Try to update it in place;
|
|
||||||
// if it is locked (OnlyOffice 500), upload a "(links)" copy next to it.
|
|
||||||
if len(os.Args) >= 5 && os.Args[4] != "" {
|
|
||||||
c := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
var src *onlyoffice.FileEntry
|
|
||||||
if derr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error {
|
|
||||||
var err error
|
|
||||||
src, err = c.GetFile(ctx, os.Args[4])
|
|
||||||
return err
|
|
||||||
}); derr != nil {
|
|
||||||
panic(derr)
|
|
||||||
}
|
|
||||||
folder, title := "", ""
|
|
||||||
if src.FolderID != nil {
|
|
||||||
folder = src.FolderID.String()
|
|
||||||
}
|
|
||||||
if src.Title != nil {
|
|
||||||
title = *src.Title
|
|
||||||
}
|
|
||||||
uderr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error {
|
|
||||||
_, err := c.UpdateFile(ctx, os.Args[4], out)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if uderr == nil {
|
|
||||||
fmt.Printf("updated file %s in place\n", os.Args[4])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf("in-place update failed (locked?); uploading a copy to folder %s\n", folder)
|
|
||||||
ext := filepath.Ext(title)
|
|
||||||
name := strings.TrimSuffix(title, ext) + " (links)" + ext
|
|
||||||
tmp := filepath.Join(os.TempDir(), name)
|
|
||||||
data, _ := os.ReadFile(out)
|
|
||||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if derr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error {
|
|
||||||
_, _, err := c.UploadToFolderReplacing(ctx, folder, tmp)
|
|
||||||
return err
|
|
||||||
}); derr != nil {
|
|
||||||
panic(derr)
|
|
||||||
}
|
|
||||||
fmt.Printf("uploaded copy: %s -> folder %s\n", name, folder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cell(r []string, i int) string {
|
|
||||||
if i < len(r) {
|
|
||||||
return strings.TrimSpace(r[i])
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func supplierNorm(s string) string {
|
|
||||||
s = strings.ToUpper(s)
|
|
||||||
if i := strings.Index(s, ","); i >= 0 {
|
|
||||||
s = s[:i]
|
|
||||||
}
|
|
||||||
for _, w := range []string{"RE FEHLT", "GS FEHLT", "WOFR", "WOFÜR"} {
|
|
||||||
s = strings.ReplaceAll(s, w, "")
|
|
||||||
}
|
|
||||||
return norm(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func monthYear(date string) string {
|
|
||||||
if len(date) == 10 {
|
|
||||||
return date[6:10] + "-" + date[3:5]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// pick returns an unused candidate. Beleg match wins; supplier+month is a
|
|
||||||
// fallback. When several candidates qualify, the one closest in time to the row
|
|
||||||
// date wins; a tie is rejected (ambiguous) rather than guessed.
|
|
||||||
func pick(entries []entry, used map[string]bool, beleg, supplier, month string, rd time.Time) (entry, string, bool) {
|
|
||||||
free := func(e entry) bool { return !used[e.id] }
|
|
||||||
|
|
||||||
if len(beleg) >= 5 {
|
|
||||||
var inMonth []entry
|
|
||||||
for _, e := range entries {
|
|
||||||
if free(e) && belegMatches(e.norm, beleg) &&
|
|
||||||
(month == "" || strings.Contains(e.title, month)) {
|
|
||||||
inMonth = append(inMonth, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if supplier != "" {
|
|
||||||
var s []entry
|
|
||||||
for _, e := range inMonth {
|
|
||||||
if strings.Contains(e.norm, supplier) {
|
|
||||||
s = append(s, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(s) > 0 {
|
|
||||||
inMonth = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inMonth = topRank(inMonth)
|
|
||||||
if e, ok := nearest(inMonth, rd); ok {
|
|
||||||
return e, "beleg", true
|
|
||||||
}
|
|
||||||
// A Beleg is present but no file carries it: do NOT fall back to a
|
|
||||||
// supplier guess (that links the wrong invoice).
|
|
||||||
return entry{}, "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
if supplier != "" && month != "" {
|
|
||||||
var c []entry
|
|
||||||
for _, e := range entries {
|
|
||||||
if free(e) && strings.Contains(e.norm, supplier) &&
|
|
||||||
strings.Contains(e.title, month) && strings.Contains(e.norm, "rechnung") {
|
|
||||||
c = append(c, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c = topRank(c)
|
|
||||||
if e, ok := nearest(c, rd); ok {
|
|
||||||
return e, "supplier", true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entry{}, "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// topRank keeps only the highest-ranked candidates (real invoice over copy /
|
|
||||||
// dupe / op), so a tie with a duplicate does not mask the real file.
|
|
||||||
func topRank(cands []entry) []entry {
|
|
||||||
if len(cands) < 2 {
|
|
||||||
return cands
|
|
||||||
}
|
|
||||||
best := 0
|
|
||||||
for _, e := range cands {
|
|
||||||
if rank(e) > best {
|
|
||||||
best = rank(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := cands[:0]
|
|
||||||
for _, e := range cands {
|
|
||||||
if rank(e) == best {
|
|
||||||
out = append(out, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func rank(e entry) int {
|
|
||||||
s := 0
|
|
||||||
if strings.Contains(e.path, "/2025") || strings.Contains(e.path, "/2024") {
|
|
||||||
s += 4
|
|
||||||
}
|
|
||||||
if strings.Contains(e.norm, "rechnung") {
|
|
||||||
s += 2
|
|
||||||
}
|
|
||||||
if strings.Contains(e.norm, "dupe") || strings.Contains(e.norm, "copy") ||
|
|
||||||
strings.Contains(e.norm, "op") {
|
|
||||||
s--
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// belegMatches reports whether a Beleg identifies the file: the whole normalized
|
|
||||||
// Beleg appears, or (for long numeric Belege, e.g. "24/641393110") an 8-digit
|
|
||||||
// window of its longest digit run appears.
|
|
||||||
func belegMatches(titleNorm, beleg string) bool {
|
|
||||||
if strings.Contains(titleNorm, beleg) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
run := longestDigitRun(beleg)
|
|
||||||
for i := 0; i+8 <= len(run); i++ {
|
|
||||||
if strings.Contains(titleNorm, run[i:i+8]) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func longestDigitRun(s string) string {
|
|
||||||
var best, cur strings.Builder
|
|
||||||
for _, r := range s {
|
|
||||||
if r >= '0' && r <= '9' {
|
|
||||||
cur.WriteRune(r)
|
|
||||||
if cur.Len() > best.Len() {
|
|
||||||
best.Reset()
|
|
||||||
best.WriteString(cur.String())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cur.Reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
type amtEntry struct {
|
|
||||||
id string
|
|
||||||
title string
|
|
||||||
norm string
|
|
||||||
amount float64
|
|
||||||
date time.Time
|
|
||||||
hasDate bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadAmounts(path string) []amtEntry {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []amtEntry
|
|
||||||
for _, line := range strings.Split(string(raw), "\n") {
|
|
||||||
p := strings.Split(line, "\t")
|
|
||||||
if len(p) < 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
v, err := strconv.ParseFloat(strings.TrimSpace(p[2]), 64)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
e := amtEntry{id: p[0], title: p[1], norm: norm(p[1]), amount: v}
|
|
||||||
if len(p[1]) >= 10 {
|
|
||||||
if t, err := time.Parse("2006-01-02", p[1][:10]); err == nil {
|
|
||||||
e.date, e.hasDate = t, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, e)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func rowAmount(r []string) float64 {
|
|
||||||
if v := parseAmount(cell(r, 4)); v != 0 {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return parseAmount(cell(r, 5))
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseAmount(s string) float64 {
|
|
||||||
s = strings.ReplaceAll(s, "€", "")
|
|
||||||
s = strings.ReplaceAll(s, " ", "")
|
|
||||||
s = strings.ReplaceAll(s, ",", ".")
|
|
||||||
if s == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
v, err := strconv.ParseFloat(s, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// amountPick matches a row to an O2 invoice by amount + nearest date. Scoped to
|
|
||||||
// Telefonica/O2 rows and O2 files, so it cannot cross-link other suppliers.
|
|
||||||
func amountPick(amts []amtEntry, used map[string]bool, supplier string, amt float64, rd time.Time) (amtEntry, bool) {
|
|
||||||
if amt <= 0 || len(amts) == 0 {
|
|
||||||
return amtEntry{}, false
|
|
||||||
}
|
|
||||||
if !strings.Contains(supplier, "telefonica") && !strings.Contains(supplier, "o2") {
|
|
||||||
return amtEntry{}, false
|
|
||||||
}
|
|
||||||
var cands []amtEntry
|
|
||||||
for _, a := range amts {
|
|
||||||
if used[a.id] || !strings.Contains(a.norm, "o2") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
d := a.amount - amt
|
|
||||||
if d < 0 {
|
|
||||||
d = -d
|
|
||||||
}
|
|
||||||
if d > 0.005 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if a.hasDate && !rd.IsZero() {
|
|
||||||
days := a.date.Sub(rd).Hours() / 24
|
|
||||||
if days < 0 {
|
|
||||||
days = -days
|
|
||||||
}
|
|
||||||
if days > 75 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cands = append(cands, a)
|
|
||||||
}
|
|
||||||
if len(cands) == 1 {
|
|
||||||
return cands[0], true
|
|
||||||
}
|
|
||||||
best, bestD, tie := -1, 0.0, false
|
|
||||||
for i, a := range cands {
|
|
||||||
if !a.hasDate {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
d := a.date.Sub(rd).Hours() / 24
|
|
||||||
if d < 0 {
|
|
||||||
d = -d
|
|
||||||
}
|
|
||||||
if best < 0 || d < bestD {
|
|
||||||
best, bestD, tie = i, d, false
|
|
||||||
} else if d == bestD {
|
|
||||||
tie = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if best < 0 || tie {
|
|
||||||
return amtEntry{}, false
|
|
||||||
}
|
|
||||||
return cands[best], true
|
|
||||||
}
|
|
||||||
+12
-52
@@ -14,7 +14,6 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,7 +30,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]
|
Upload XLSX: oo docs put-xlsx PROJECT_ID 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`,
|
||||||
@@ -810,64 +809,27 @@ func docsPutTxtCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func docsPutXlsxCmd() *cobra.Command {
|
func docsPutXlsxCmd() *cobra.Command {
|
||||||
var folderID, template, title, keepLocal string
|
var folderID, keepLocal string
|
||||||
var replace bool
|
var replace bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "put-xlsx PROJECT_ID [LOCAL_XLSX]",
|
Use: "put-xlsx PROJECT_ID LOCAL_XLSX",
|
||||||
Short: "Upload or generate an XLSX workbook into a project (excelize templates with formulas)",
|
Short: "Upload an XLSX workbook into a project (upsert by stem|ext)",
|
||||||
Long: `Spreadsheets live in OnlyOffice — not in git. Generate multi-sheet workbooks with
|
Long: `Spreadsheets live in OnlyOffice — not in git. Uploads an existing .xlsx into the
|
||||||
formulas (SUM/AVG, cross-sheet refs, named inputs) via --template, or upload an existing .xlsx.
|
project Documents (or --folder), replacing the same stem|ext by default.
|
||||||
|
|
||||||
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`,
|
oo docs put-xlsx 218 ./my.xlsx`,
|
||||||
Args: cobra.RangeArgs(1, 2),
|
Args: cobra.ExactArgs(2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
pid := args[0]
|
pid := args[0]
|
||||||
c, err := newOO(cmd)
|
c, err := newOO(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dir, err := os.MkdirTemp("", "oo-docs-put-xlsx-*")
|
xlsxPath := args[1]
|
||||||
if err != nil {
|
if docpipe.Ext(xlsxPath) != ".xlsx" {
|
||||||
return err
|
return fmt.Errorf("expected .xlsx, got %s", docpipe.Ext(xlsxPath))
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
srcLabel := xlsxPath
|
||||||
|
|
||||||
if keepLocal != "" {
|
if keepLocal != "" {
|
||||||
b, err := os.ReadFile(xlsxPath)
|
b, err := os.ReadFile(xlsxPath)
|
||||||
@@ -903,9 +865,7 @@ formulas (SUM/AVG, cross-sheet refs, named inputs) via --template, or upload an
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
|
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(&keepLocal, "keep-xlsx", "", "also copy the uploaded bytes to this local path")
|
||||||
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, "replace same stem|ext before upload (default); false = fail if name taken")
|
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -91,7 +91,7 @@ func invoiceCreateCmd() *cobra.Command {
|
|||||||
Long: `Create a CRM invoice (Draft) with a single line.
|
Long: `Create a CRM invoice (Draft) with a single line.
|
||||||
|
|
||||||
Always pass --opportunity when a deal exists (entity link at create). Updating
|
Always pass --opportunity when a deal exists (entity link at create). Updating
|
||||||
--opportunity later often fails with HTTP 400 — see docs/crm-associations.md.
|
--opportunity later often fails with HTTP 400 — see the CRM association rules.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
oo invoices create --number INV-2026-01 --contact CONTACT_ID --item ITEM_ID \
|
oo invoices create --number INV-2026-01 --contact CONTACT_ID --item ITEM_ID \
|
||||||
@@ -108,7 +108,7 @@ Example:
|
|||||||
issueDate = issueDate + "T00:00:00.0000000+01:00"
|
issueDate = issueDate + "T00:00:00.0000000+01:00"
|
||||||
}
|
}
|
||||||
if dueDate == "" {
|
if dueDate == "" {
|
||||||
dueDate = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02") + "T00:00:00.0000000+01:00"
|
dueDate = time.Now().Add(14*24*time.Hour).Format("2006-01-02") + "T00:00:00.0000000+01:00"
|
||||||
} else if !strings.Contains(dueDate, "T") {
|
} else if !strings.Contains(dueDate, "T") {
|
||||||
dueDate = dueDate + "T00:00:00.0000000+01:00"
|
dueDate = dueDate + "T00:00:00.0000000+01:00"
|
||||||
}
|
}
|
||||||
@@ -175,7 +175,7 @@ func invoiceUpdateCmd() *cobra.Command {
|
|||||||
Long: `Update Draft invoice fields.
|
Long: `Update Draft invoice fields.
|
||||||
|
|
||||||
--opportunity often returns HTTP 400 on existing invoices. Prefer
|
--opportunity often returns HTTP 400 on existing invoices. Prefer
|
||||||
oo invoices create … --opportunity, or delete+recreate. See docs/crm-associations.md.
|
oo invoices create … --opportunity, or delete+recreate. See the CRM association rules.
|
||||||
`,
|
`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -274,7 +274,7 @@ func invoiceStatusCmd() *cobra.Command {
|
|||||||
Long: `PUT /api/2.0/crm/invoice/status/{id}.
|
Long: `PUT /api/2.0/crm/invoice/status/{id}.
|
||||||
|
|
||||||
Billed invoices are not content-editable. Billed→Draft often does not work —
|
Billed invoices are not content-editable. Billed→Draft often does not work —
|
||||||
recreate as Draft instead (docs/crm-associations.md).
|
recreate as Draft instead (the CRM association rules).
|
||||||
`,
|
`,
|
||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@
|
|||||||
// oo search QUERY [--content] [--folder ID] [--limit N] [--backend oo|own] [--json]
|
// oo search QUERY [--content] [--folder ID] [--limit N] [--backend oo|own] [--json]
|
||||||
// oo index folder FOLDER_ID | files FILE_ID... [--recursive] [--exts pdf] [--dry-run]
|
// oo index folder FOLDER_ID | files FILE_ID... [--recursive] [--exts pdf] [--dry-run]
|
||||||
//
|
//
|
||||||
// CRM association rules: docs/crm-associations.md
|
// CRM association rules live with the private oo-workspace tooling.
|
||||||
//
|
//
|
||||||
// Every list supports `--output/-o json|table` (table is the default).
|
// Every list supports `--output/-o json|table` (table is the default).
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
// Command ooscan recursively lists OnlyOffice Documents folders into a TSV
|
|
||||||
// index: file_id, folder_id, path, title.
|
|
||||||
//
|
|
||||||
// Usage: ooscan <FOLDER_ID> [<FOLDER_ID>...]
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
ctx := context.Background()
|
|
||||||
c := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, root := range os.Args[1:] {
|
|
||||||
walk(ctx, c, root, "", 0, seen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func walk(ctx context.Context, c *onlyoffice.Client, folderID, path string, depth int, seen map[string]bool) {
|
|
||||||
if depth > 8 || seen[folderID] {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seen[folderID] = true
|
|
||||||
// Throttle: OnlyOffice rate-limits (429) and the host must not be flooded.
|
|
||||||
time.Sleep(350 * time.Millisecond)
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
var l *onlyoffice.DavListing
|
|
||||||
derr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error {
|
|
||||||
var err error
|
|
||||||
l, err = c.ListDavFolder(ctx, folderID)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if derr != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "list %s (%s): %v\n", path, folderID, derr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, f := range l.Files {
|
|
||||||
fmt.Printf("%s\t%s\t%s\t%s\n", f.ID, folderID, path, f.Title)
|
|
||||||
}
|
|
||||||
for _, sub := range l.Folders {
|
|
||||||
walk(ctx, c, sub.ID, path+"/"+sub.Title, depth+1, seen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
// Command pdfamount walks a Documents folder, downloads matching PDFs and
|
|
||||||
// extracts the payable amount, printing "file_id\ttitle\tamount".
|
|
||||||
//
|
|
||||||
// Usage: pdfamount <FOLDER_ID> [TITLE_FILTER_REGEX]
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
|
||||||
)
|
|
||||||
|
|
||||||
// amountPat is the amount capture shared by every amount regex.
|
|
||||||
const amountPat = `([0-9]+(?:[.,][0-9]+)*)`
|
|
||||||
|
|
||||||
// amountRE builds "<label> [optional (comment)] [: -] <number>".
|
|
||||||
func amountRE(label string) *regexp.Regexp {
|
|
||||||
return regexp.MustCompile(
|
|
||||||
`(?i)\b` + regexp.QuoteMeta(label) + `\b\s*(?:\([^)]*\))?\s*[:\-]?\s*` + amountPat)
|
|
||||||
}
|
|
||||||
|
|
||||||
// amountRes lists the payable-amount patterns in strict priority order: the
|
|
||||||
// first pattern with a usable amount wins, and a lower-priority label can never
|
|
||||||
// override a higher-priority one ("zu zahlender betrag" > "rechnungsbetrag" >
|
|
||||||
// "rechnungsendbetrag" > "gesamtbetrag" > "gesamtsumme (inkl. steuern)").
|
|
||||||
//
|
|
||||||
// "gesamtbetrag" and "gesamtsumme" are not in the original set but are the real
|
|
||||||
// labels on Diashop invoices ("Gesamtsumme (inkl. Steuern)"). The inclusive
|
|
||||||
// variant is matched before a plain "gesamtsumme". Everything after those
|
|
||||||
// primary labels is the broader fallback set, consulted only when no primary
|
|
||||||
// label yields an amount. Within one pattern the last usable amount is taken,
|
|
||||||
// because totals usually come last.
|
|
||||||
var amountRes = []*regexp.Regexp{
|
|
||||||
amountRE("zu zahlender betrag"),
|
|
||||||
amountRE("rechnungsbetrag"),
|
|
||||||
amountRE("rechnungsendbetrag"),
|
|
||||||
amountRE("gesamtbetrag"),
|
|
||||||
regexp.MustCompile(`(?i)\bgesamtsumme\b\s*\(\s*inkl\.?\s*steuern\s*\)\s*[:\-]?\s*` + amountPat),
|
|
||||||
amountRE("gesamtsumme"),
|
|
||||||
amountRE("endbetrag"),
|
|
||||||
amountRE("zahlbetrag"),
|
|
||||||
amountRE("bruttobetrag"),
|
|
||||||
amountRE("betrag"),
|
|
||||||
amountRE("total"),
|
|
||||||
amountRE("summe"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// taxLineRe marks a line whose number is a tax rate/percentage: an explicit
|
|
||||||
// percent sign or a VAT/tax keyword. "Steuern" (plural, as in "inkl. Steuern")
|
|
||||||
// is handled separately so the inclusive total stays usable.
|
|
||||||
var taxLineRe = regexp.MustCompile(`(?i)%|\bMwSt\b|\bUSt\b|\bProzent\b`)
|
|
||||||
|
|
||||||
// steuerRe finds "Steuer"/"Umsatzsteuer" etc. RE2 has no lookahead, so the
|
|
||||||
// plural "Steuern" is excluded in isTaxLine.
|
|
||||||
var steuerRe = regexp.MustCompile(`(?i)steuer`)
|
|
||||||
|
|
||||||
// percentAfterRe detects a percent sign directly after a number (spaces ok).
|
|
||||||
var percentAfterRe = regexp.MustCompile(`^\s*%`)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if len(os.Args) < 2 {
|
|
||||||
fmt.Fprintln(os.Stderr, "usage: pdfamount <FOLDER_ID> [TITLE_FILTER_REGEX]")
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
folder := os.Args[1]
|
|
||||||
filter := regexp.MustCompile(`(?i)rechnung`)
|
|
||||||
if len(os.Args) >= 3 {
|
|
||||||
filter = regexp.MustCompile(os.Args[2])
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
c := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
|
|
||||||
|
|
||||||
files := listAll(ctx, c, folder)
|
|
||||||
for _, f := range files {
|
|
||||||
if !filter.MatchString(f.title) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(strings.ToLower(f.title), ".pdf") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
amount, err := pdfAmount(ctx, c, f.id)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "%s: %v\n", f.title, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if amount == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Printf("%s\t%s\t%s\n", f.id, f.title, amount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type file struct{ id, title string }
|
|
||||||
|
|
||||||
func listAll(ctx context.Context, c *onlyoffice.Client, folder string) []file {
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var out []file
|
|
||||||
var walk func(string)
|
|
||||||
walk = func(id string) {
|
|
||||||
if seen[id] {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seen[id] = true
|
|
||||||
time.Sleep(300 * time.Millisecond)
|
|
||||||
l, err := c.ListDavFolder(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "list %s: %v\n", id, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, f := range l.Files {
|
|
||||||
out = append(out, file{f.ID, f.Title})
|
|
||||||
}
|
|
||||||
for _, sub := range l.Folders {
|
|
||||||
walk(sub.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(folder)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func pdfAmount(ctx context.Context, c *onlyoffice.Client, id string) (string, error) {
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
tmp, err := os.CreateTemp("", "pdf-*.pdf")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer os.Remove(tmp.Name())
|
|
||||||
derr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error {
|
|
||||||
_ = tmp.Truncate(0)
|
|
||||||
_, _ = tmp.Seek(0, 0)
|
|
||||||
_, err := c.DownloadFile(ctx, id, tmp)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if derr != nil {
|
|
||||||
tmp.Close()
|
|
||||||
return "", derr
|
|
||||||
}
|
|
||||||
tmp.Close()
|
|
||||||
var buf bytes.Buffer
|
|
||||||
cmd := exec.CommandContext(ctx, "pdftotext", "-layout", tmp.Name(), "-")
|
|
||||||
cmd.Stdout = &buf
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return extractAmount(buf.String()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractAmount returns the normalised ("1234.56") payable amount found in
|
|
||||||
// text, or "" if no usable amount matches.
|
|
||||||
//
|
|
||||||
// DKV invoices are special-cased first: they repeat a per-vehicle "TOTAL:" line
|
|
||||||
// and carry the real total only in the "Gesamtsummenaufstellung" section.
|
|
||||||
func extractAmount(text string) string {
|
|
||||||
if v, ok := dkvGrandTotal(text); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
for _, re := range amountRes {
|
|
||||||
if v, ok := lastUsableAmount(text, re); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// dkvGrandTotal extracts the total of a DKV "Gesamtsummenaufstellung" section.
|
|
||||||
//
|
|
||||||
// Rule: DKV invoices repeat a per-vehicle "TOTAL:" line, so the last TOTAL is
|
|
||||||
// not the invoice total. When a "Gesamtsummenaufstellung" section exists, its
|
|
||||||
// total wins over every "TOTAL:" line: the first amount after the "»" marker,
|
|
||||||
// or, if there is none, the last amount in the section. The section ends at the
|
|
||||||
// page break (form feed) or end of text.
|
|
||||||
func dkvGrandTotal(text string) (string, bool) {
|
|
||||||
idx := strings.Index(strings.ToLower(text), "gesamtsummenaufstellung")
|
|
||||||
if idx < 0 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
section := text[idx:]
|
|
||||||
if ff := strings.IndexByte(section, '\f'); ff >= 0 {
|
|
||||||
section = section[:ff]
|
|
||||||
}
|
|
||||||
if m := strings.Index(section, "»"); m >= 0 {
|
|
||||||
if v, ok := firstAmount(section[m:]); ok {
|
|
||||||
return v, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lastAmount(section)
|
|
||||||
}
|
|
||||||
|
|
||||||
// lastUsableAmount returns the last amount matched by re that is not a tax rate
|
|
||||||
// or percentage. Within one label the last usable amount wins.
|
|
||||||
func lastUsableAmount(text string, re *regexp.Regexp) (string, bool) {
|
|
||||||
ms := re.FindAllStringSubmatchIndex(text, -1)
|
|
||||||
for i := len(ms) - 1; i >= 0; i-- {
|
|
||||||
m := ms[i]
|
|
||||||
if isTaxRate(text, m[2], m[3]) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if v, ok := normalizeAmount(text[m[2]:m[3]]); ok {
|
|
||||||
return v, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// isTaxRate reports whether the number at text[start:end] is a tax rate or a
|
|
||||||
// percentage instead of a payable amount. A candidate is rejected when the
|
|
||||||
// token right after the number is "%" or the number's line carries a percent
|
|
||||||
// sign or a tax keyword. Rejecting is deliberate: office matching treats a
|
|
||||||
// known-but-different amount as a hard disqualifier, so an empty result is
|
|
||||||
// safer than the VAT rate.
|
|
||||||
func isTaxRate(text string, start, end int) bool {
|
|
||||||
if percentAfterRe.MatchString(text[end:]) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
lineStart := strings.LastIndexByte(text[:start], '\n') + 1
|
|
||||||
line := text[lineStart:]
|
|
||||||
if n := strings.IndexByte(text[end:], '\n'); n >= 0 {
|
|
||||||
line = text[lineStart : end+n]
|
|
||||||
}
|
|
||||||
return isTaxLine(line)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isTaxLine reports whether a line looks like a tax rate rather than a payable
|
|
||||||
// amount. "Steuern" is treated as a qualifier ("inkl. Steuern"), not a rate.
|
|
||||||
func isTaxLine(line string) bool {
|
|
||||||
if taxLineRe.MatchString(line) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, loc := range steuerRe.FindAllStringIndex(line, -1) {
|
|
||||||
if loc[1] >= len(line) || (line[loc[1]] != 'n' && line[loc[1]] != 'N') {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// numberRe finds bare numbers (with optional thousands/decimal separators).
|
|
||||||
var numberRe = regexp.MustCompile(`[0-9]+(?:[.,][0-9]+)*`)
|
|
||||||
|
|
||||||
func firstAmount(s string) (string, bool) {
|
|
||||||
for _, m := range numberRe.FindAllString(s, -1) {
|
|
||||||
if v, ok := normalizeAmount(m); ok {
|
|
||||||
return v, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
func lastAmount(s string) (string, bool) {
|
|
||||||
ms := numberRe.FindAllString(s, -1)
|
|
||||||
for i := len(ms) - 1; i >= 0; i-- {
|
|
||||||
if v, ok := normalizeAmount(ms[i]); ok {
|
|
||||||
return v, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeAmount turns "1.234,56" (DE), "1,234.56" (EN) or "1234.56" into
|
|
||||||
// "1234.56". The rightmost separator is decimal only when followed by one or
|
|
||||||
// two digits; otherwise every separator is a thousands separator.
|
|
||||||
func normalizeAmount(s string) (string, bool) {
|
|
||||||
last := -1
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
if s[i] == '.' || s[i] == ',' {
|
|
||||||
last = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var dec byte
|
|
||||||
if last >= 0 {
|
|
||||||
digits := 0
|
|
||||||
for i := last + 1; i < len(s); i++ {
|
|
||||||
if s[i] < '0' || s[i] > '9' {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
digits++
|
|
||||||
}
|
|
||||||
if digits == 1 || digits == 2 {
|
|
||||||
dec = s[last]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
switch c := s[i]; {
|
|
||||||
case c >= '0' && c <= '9':
|
|
||||||
b.WriteByte(c)
|
|
||||||
case (c == '.' || c == ',') && c == dec:
|
|
||||||
b.WriteByte('.')
|
|
||||||
case c == '.' || c == ',':
|
|
||||||
// thousands separator
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
v, err := strconv.ParseFloat(b.String(), 64)
|
|
||||||
if err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return strconv.FormatFloat(v, 'f', 2, 64), true
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestExtractAmount(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name, text, want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "rechnungsbetrag de format",
|
|
||||||
text: "Rechnungsbetrag: 1.234,56 €",
|
|
||||||
want: "1234.56",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rechnungsbetrag en thousands and dot",
|
|
||||||
text: "Rechnungsbetrag: 1,234.56",
|
|
||||||
want: "1234.56",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rechnungsbetrag plain dot",
|
|
||||||
text: "Rechnungsbetrag: 1234.56",
|
|
||||||
want: "1234.56",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rechnungsbetrag de comma only",
|
|
||||||
text: "Rechnungsbetrag: 1234,56",
|
|
||||||
want: "1234.56",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "currency suffix eur",
|
|
||||||
text: "Rechnungsbetrag: 1.234,56 EUR",
|
|
||||||
want: "1234.56",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "zu zahlender betrag wins over rechnungsbetrag",
|
|
||||||
text: "Zu zahlender Betrag: 10,00\nRechnungsbetrag: 99,00",
|
|
||||||
want: "10.00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rechnungsbetrag wins over endbetrag",
|
|
||||||
text: "Endbetrag: 20,00\nRechnungsbetrag: 30,00",
|
|
||||||
want: "30.00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bruttobetrag wins over bare betrag",
|
|
||||||
text: "Bruttobetrag: 50,00\nBetrag: 10,00",
|
|
||||||
want: "50.00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "gesamtbetrag wins over bare betrag",
|
|
||||||
text: "Gesamtbetrag: 80,00\nBetrag: 10,00",
|
|
||||||
want: "80.00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "last occurrence of same label wins",
|
|
||||||
text: "Rechnungsbetrag: 10,00\nRechnungsbetrag: 20,00",
|
|
||||||
want: "20.00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "endbetrag fallback",
|
|
||||||
text: "Endbetrag: 42,00",
|
|
||||||
want: "42.00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "zahlbetrag fallback without colon",
|
|
||||||
text: "Zahlbetrag 7,50 €",
|
|
||||||
want: "7.50",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rechnungsendbetrag beats endbetrag",
|
|
||||||
text: "Rechnungsendbetrag: 12,00\nEndbetrag: 13,00",
|
|
||||||
want: "12.00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dkv style total line",
|
|
||||||
text: "Kundenbezogene Daten\n» TOTAL: 123,45 100,00 23,45 123,45\n",
|
|
||||||
want: "123.45",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dkv gesamtsummenaufstellung grand total after marker",
|
|
||||||
text: "» TOTAL: 111,11 100,00 11,11 111,11\n" +
|
|
||||||
"» TOTAL: 222,22 200,00 22,22 222,22\n" +
|
|
||||||
"Gesamtsummenaufstellung\n" +
|
|
||||||
"Netto 240,00\n" +
|
|
||||||
"MwSt 47,25\n" +
|
|
||||||
"» 287,25\n",
|
|
||||||
want: "287.25",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dkv gesamtsummenaufstellung total on next line",
|
|
||||||
text: "» TOTAL: 111,11\nGesamtsummenaufstellung\n»\n287,25\n",
|
|
||||||
want: "287.25",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tax rate with percent sign is not an amount",
|
|
||||||
text: "Betrag: 19,00 % MwSt",
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mehrwertsteuer rate is not an amount",
|
|
||||||
text: "Gesamtsumme: 19,00% MwSt",
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "steuer word on the number line rejects it",
|
|
||||||
text: "Betrag: 2,83 Steuer",
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rejected primary falls back to a usable label",
|
|
||||||
text: "Gesamtsumme: 19,00 % MwSt\nEndbetrag: 42,00",
|
|
||||||
want: "42.00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "labeled zu zahlender betrag beats unlabeled larger number",
|
|
||||||
text: "unlabeled 999,99\nZu zahlender Betrag: 10,00",
|
|
||||||
want: "10.00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "labeled zu zahlender betrag beats lower label larger number",
|
|
||||||
text: "Endbetrag: 999,99\nZu zahlender Betrag: 10,00",
|
|
||||||
want: "10.00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "diashop style gesamtsumme with comment",
|
|
||||||
text: "Zwischensumme\n12,34 €\nZwischensumme\n12,34 €\nVersand & Bearbeitung\n4,95 €\nGesamtsumme (inkl. Steuern)\n17,29 €\n",
|
|
||||||
want: "17.29",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "diashop picks inclusive total last",
|
|
||||||
text: "Gesamtsumme (exkl. Steuern)\n12,34 €\nGesamtsumme (inkl. Steuern)\n17,29 €",
|
|
||||||
want: "17.29",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no label",
|
|
||||||
text: "some text without any amount label 12,34",
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if got := extractAmount(tc.text); got != tc.want {
|
|
||||||
t.Fatalf("extractAmount()=%q want %q", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeAmount(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
in string
|
|
||||||
want string
|
|
||||||
ok bool
|
|
||||||
}{
|
|
||||||
{"1.234,56", "1234.56", true},
|
|
||||||
{"1,234.56", "1234.56", true},
|
|
||||||
{"1234.56", "1234.56", true},
|
|
||||||
{"1234,56", "1234.56", true},
|
|
||||||
{"1.234.567,89", "1234567.89", true},
|
|
||||||
{"1,234,567.89", "1234567.89", true},
|
|
||||||
{"1.234", "1234.00", true},
|
|
||||||
{"12,5", "12.50", true},
|
|
||||||
{"12", "12.00", true},
|
|
||||||
{"", "0.00", false},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
got, ok := normalizeAmount(tc.in)
|
|
||||||
if ok != tc.ok {
|
|
||||||
t.Fatalf("normalizeAmount(%q) ok=%v want %v", tc.in, ok, tc.ok)
|
|
||||||
}
|
|
||||||
if ok && got != tc.want {
|
|
||||||
t.Fatalf("normalizeAmount(%q)=%q want %q", tc.in, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# rclone WebDAV mount of the oo-webdav sidecar (OnlyOffice Documents).
|
|
||||||
#
|
|
||||||
# The sidecar publishes the portal's WebDAV tree on the docker bridge
|
|
||||||
# (http://172.17.0.1:8098, prefix /webdav). rclone mounts it inside this
|
|
||||||
# container as a normal filesystem.
|
|
||||||
#
|
|
||||||
# Credentials are OnlyOffice portal credentials (the same the `oo` CLI uses).
|
|
||||||
# Export them before `docker compose up`, e.g.
|
|
||||||
# set -a; . .secrets/oo.env; set +a
|
|
||||||
# or copy .env.example to .env and fill it. Only names live in git.
|
|
||||||
#
|
|
||||||
# docker compose -f deploy/docker-compose.rclone-webdav.yml up -d
|
|
||||||
# docker exec rclone-webdav ls /mnt/onlyoffice
|
|
||||||
# docker compose -f deploy/docker-compose.rclone-webdav.yml down
|
|
||||||
#
|
|
||||||
# The mount point is the named volume `onlyoffice-mnt`; join it from another
|
|
||||||
# container with `external_volumes` to read the tree without the API.
|
|
||||||
name: rclone-webdav
|
|
||||||
|
|
||||||
services:
|
|
||||||
rclone-webdav:
|
|
||||||
image: rclone/rclone:latest
|
|
||||||
container_name: rclone-webdav
|
|
||||||
restart: unless-stopped
|
|
||||||
# rclone mount needs FUSE. The remote is defined on the fly via flags for
|
|
||||||
# the mount; the same credentials are written to an rclone config so that
|
|
||||||
# `docker exec rclone-webdav rclone ls webdav:` works without flags.
|
|
||||||
entrypoint: ["/bin/sh", "-c"]
|
|
||||||
command:
|
|
||||||
- |
|
|
||||||
set -eu
|
|
||||||
: "$${ONLYOFFICE_USER:?ONLYOFFICE_USER is required}"
|
|
||||||
: "$${ONLYOFFICE_PASSWORD:?ONLYOFFICE_PASSWORD is required}"
|
|
||||||
obscured=$$(rclone obscure "$${ONLYOFFICE_PASSWORD}")
|
|
||||||
mkdir -p /config/rclone
|
|
||||||
cat > /config/rclone/rclone.conf <<EOF
|
|
||||||
[webdav]
|
|
||||||
type = webdav
|
|
||||||
url = $${ONLYOFFICE_WEBDAV_URL}
|
|
||||||
vendor = other
|
|
||||||
user = $${ONLYOFFICE_USER}
|
|
||||||
pass = $${obscured}
|
|
||||||
EOF
|
|
||||||
exec rclone mount :webdav: /mnt/onlyoffice \
|
|
||||||
--webdav-url "$${ONLYOFFICE_WEBDAV_URL}" \
|
|
||||||
--webdav-user "$${ONLYOFFICE_USER}" \
|
|
||||||
--webdav-pass "$${obscured}" \
|
|
||||||
--vfs-cache-mode writes \
|
|
||||||
--cache-dir /cache \
|
|
||||||
--dir-cache-time 1m \
|
|
||||||
--allow-other \
|
|
||||||
--allow-non-empty
|
|
||||||
environment:
|
|
||||||
ONLYOFFICE_USER: ${ONLYOFFICE_USER:?ONLYOFFICE_USER is required}
|
|
||||||
ONLYOFFICE_PASSWORD: ${ONLYOFFICE_PASSWORD:-${ONLYOFFICE_PASS:?ONLYOFFICE_PASSWORD is required}}
|
|
||||||
ONLYOFFICE_WEBDAV_URL: ${ONLYOFFICE_WEBDAV_URL:-http://172.17.0.1:8098/webdav}
|
|
||||||
cap_add:
|
|
||||||
- SYS_ADMIN
|
|
||||||
devices:
|
|
||||||
- /dev/fuse
|
|
||||||
# Ubuntu hosts run AppArmor; FUSE mounts need it unconfined.
|
|
||||||
security_opt:
|
|
||||||
- apparmor:unconfined
|
|
||||||
volumes:
|
|
||||||
- onlyoffice-mnt:/mnt/onlyoffice
|
|
||||||
- rclone-cache:/cache
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
onlyoffice-mnt:
|
|
||||||
rclone-cache:
|
|
||||||
+1
-4
@@ -18,10 +18,7 @@ related:
|
|||||||
- [elasticsearch.md](elasticsearch.md) — поиск: индекс OnlyOffice `files_file`
|
- [elasticsearch.md](elasticsearch.md) — поиск: индекс OnlyOffice `files_file`
|
||||||
и свой `oo_docs_text` (PDF/сканы), туннель.
|
и свой `oo_docs_text` (PDF/сканы), туннель.
|
||||||
- [index-and-search.md](index-and-search.md) — карта контуров поиска и как
|
- [index-and-search.md](index-and-search.md) — карта контуров поиска и как
|
||||||
обновлять индексы (`oo index`, `ooscan`/`pdfamount` для match).
|
обновлять индексы (`oo index`, `oo search`).
|
||||||
- [rclone-webdav.md](rclone-webdav.md) — rclone-монтирование Documents
|
|
||||||
(`deploy/docker-compose.rclone-webdav.yml`), smoke, ограничения.
|
|
||||||
- [crm-associations.md](crm-associations.md) — правила ассоциаций CRM.
|
|
||||||
- [rate-limiting.md](rate-limiting.md) — rate limit, exponential backoff,
|
- [rate-limiting.md](rate-limiting.md) — rate limit, exponential backoff,
|
||||||
`Retry-After`, общий cooldown против 429; env `OO_RATE_LIMIT`/`OO_BURST`/
|
`Retry-After`, общий cooldown против 429; env `OO_RATE_LIMIT`/`OO_BURST`/
|
||||||
`OO_RETRY_*`.
|
`OO_RETRY_*`.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: reference
|
|||||||
status: current
|
status: current
|
||||||
related:
|
related:
|
||||||
- README.md
|
- README.md
|
||||||
- file_pg.go
|
- filestore_pg.go
|
||||||
- docs/elasticsearch.md
|
- docs/elasticsearch.md
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ related:
|
|||||||
|
|
||||||
## Что это
|
## Что это
|
||||||
|
|
||||||
Бэкенд `pgStore` (`file_pg.go`) читает файлы и папки **напрямую из БД
|
Бэкенд `pgStore` (`filestore_pg.go`) читает файлы и папки **напрямую из БД
|
||||||
Community Server**, без HTTP-слоя. Реализует `FileStore` (`List`/`Stat`/
|
Community Server**, без HTTP-слоя. Реализует `FileStore` (`List`/`Stat`/
|
||||||
`Download`) и `Searcher` по имени. Запись запрещена: все write-методы
|
`Download`) и `Searcher` по имени. Запись запрещена: все write-методы
|
||||||
возвращают `ErrReadOnly`.
|
возвращают `ErrReadOnly`.
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
# CRM associations (company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail)
|
|
||||||
|
|
||||||
Operational rules for the `oo` CLI and this library. Business SSOT remains
|
|
||||||
OnlyOffice Workspace CRM + Projects.
|
|
||||||
|
|
||||||
## Canonical graph
|
|
||||||
|
|
||||||
One **legal company** owns the relationship. Do not invent a second “bill-to”
|
|
||||||
company just for PDF layout.
|
|
||||||
|
|
||||||
```text
|
|
||||||
Company
|
|
||||||
├── Person (buyer contact) oo persons create --company-id
|
|
||||||
├── Opportunity / Deal oo opportunities … ; member-add company + person
|
|
||||||
├── Project (hub) oo projects … ; contacts add company + person
|
|
||||||
│ └── Epic + subtasks
|
|
||||||
└── Invoice (Draft → …) oo invoices create --contact COMPANY --opportunity DEAL
|
|
||||||
└── PDF file oo invoices pdf ID
|
|
||||||
└── Mail draft oo mails draft-invoice --invoice ID --to …
|
|
||||||
```
|
|
||||||
|
|
||||||
| Layer | CLI | Must link |
|
|
||||||
|-------|-----|-----------|
|
|
||||||
| Company | `oo companies create` | website, email, phone, **one** Billing address |
|
|
||||||
| Person | `oo persons create --company-id` / `oo persons update ID` | job title; never encode employer in `lastName`; **update uses JSON** (form PUT ignores `companyId`/`about`) |
|
|
||||||
| Deal | `oo opportunities create` + `member-add` | company **and** person as members |
|
|
||||||
| Project | `oo projects create` + `contacts add` | same company + person |
|
|
||||||
| Invoice | `oo invoices create --contact COMPANY --opportunity DEAL` | `entityId` at **create** |
|
|
||||||
| Mail | `oo mails draft-invoice` | attach current PDF; **do not send** until confirmed |
|
|
||||||
|
|
||||||
UI checks (same company card):
|
|
||||||
|
|
||||||
- `#contacts` → person
|
|
||||||
- `#deals` → opportunity
|
|
||||||
- `#projects` → hub project
|
|
||||||
- `#invoices` on the **deal** → invoice (needs `entity`)
|
|
||||||
- `#files` → preferably **one** current invoice PDF
|
|
||||||
|
|
||||||
**Project Team ≠ Project Contacts.** Team = portal users. CRM people/companies
|
|
||||||
show under the project **Contacts** tab (`oo projects contacts list`).
|
|
||||||
|
|
||||||
## Hard rules
|
|
||||||
|
|
||||||
1. **One company per legal entity.** Duplicate “bill-to” contacts empty Deals /
|
|
||||||
Projects / Contacts tabs and break merge. Prefer
|
|
||||||
`oo contacts merge FROM INTO` (keeps `INTO`) or `oo companies dedupe`.
|
|
||||||
2. **Link invoice → deal at create.**
|
|
||||||
`POST /crm/invoice` with `entityId` + `entityType: 0` (Opportunity).
|
|
||||||
`oo invoices update … --opportunity` often returns **400**
|
|
||||||
(“Value does not fall within the expected range”). If the link is missing,
|
|
||||||
delete the Draft and recreate with `--opportunity`.
|
|
||||||
3. **Bill To = company id**, not a throwaway contact. Person stays under the
|
|
||||||
company (`companyId`). Optional `consigneeId` for Empfänger when the portal
|
|
||||||
template prints it.
|
|
||||||
4. **Stay Draft until mail is ready.** Billed (`status id=2`) is **not editable**
|
|
||||||
via content PUT. Going Billed → Draft via `…/crm/invoice/status/1` usually
|
|
||||||
**does not work** — delete + recreate Draft instead.
|
|
||||||
5. **Do not regenerate PDF in a loop** without cleanup. Each
|
|
||||||
`GET …/crm/invoice/{id}/pdf` attaches a new file to the company (and often
|
|
||||||
the deal). Keep `invoice.fileID`; delete older PDFs with
|
|
||||||
`oo invoices pdf-cleanup ID` / Documents `fileops/delete`.
|
|
||||||
|
|
||||||
## Invoice PDF quirks
|
|
||||||
|
|
||||||
| Symptom | Workaround |
|
|
||||||
|---------|------------|
|
|
||||||
| Cached / stale PDF | Touch invoice (Draft PUT that clears `fileID`), then `GET …/pdf` — `oo invoices pdf ID --force` |
|
|
||||||
| Billing address missing on **new** PDFs | Temporary multiline `companyName` (`Line1\nLine2\n…`) on the **canonical** company → force PDF → restore clean name. Cached `fileID` keeps the multiline Bill To. |
|
|
||||||
| Separate bill-to company for newlines | **Forbidden** — merge back to the real company |
|
|
||||||
| Invoice **number** won’t change on PUT | Delete Draft and recreate with the desired number |
|
|
||||||
| Notizen / Bedingungen spacing | Leading `\n` and blank lines only — no HTML (tags print literally) |
|
|
||||||
| Issuer street lines | Organisation profile address (`street` with `\n`), not only terms |
|
|
||||||
|
|
||||||
Status ids commonly used: `1` Draft, `2` Billed, `3` Rejected, `4` Paid.
|
|
||||||
|
|
||||||
## Mail quirks
|
|
||||||
|
|
||||||
| Symptom | Workaround |
|
|
||||||
|---------|------------|
|
|
||||||
| Signature / body doubles chat URL | Put chat in **one** place only. UI drafts: signature. API send: body (API **does not** append signature). |
|
|
||||||
| Signature / body cuts URL at `#` | Plain text URLs — avoid `<a href="…#…">` (or encode `#` as `%23` in href) |
|
|
||||||
| German letter spacing | Blank `<p> </p>` between blocks (`MailHTMLWithBlankParagraphs`) |
|
|
||||||
| Send | `PUT /api/2.0/mail/messages/send.json` with `id/from/to/subject/body`; omit empty `cc`/`bcc`. Never auto-send; draft only until the human confirms |
|
|
||||||
|
|
||||||
Prefer OnlyOffice Mail (`/addons/mail/#drafts`) for invoice delivery until confirmed.
|
|
||||||
|
|
||||||
## Project / task quirks
|
|
||||||
|
|
||||||
- Hub title: `CC | Company` (e.g. `DE | Acme GmbH`).
|
|
||||||
- Streams = epics/tasks under the hub, not a third title segment (unless the
|
|
||||||
project itself is a named delivery stream).
|
|
||||||
- Closing a **subtask**:
|
|
||||||
`PUT /api/2.0/project/task/{epicId}/{subtaskId}/status` with `status=2`.
|
|
||||||
`oo tasks update SUBTASK -s closed` returns **404** for subtasks.
|
|
||||||
- After deleting a CRM contact, `GET /project/contact/{deletedId}` may still
|
|
||||||
return projects (ghost). Official project contact list should only show live
|
|
||||||
ids; unlink may 400 if the contact is gone.
|
|
||||||
|
|
||||||
## Merge / cleanup cheat sheet
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Keep the preferred company (INTO), drop the duplicate (FROM)
|
|
||||||
oo contacts merge FROM_ID INTO_ID
|
|
||||||
|
|
||||||
# Or by normalized name (careful — whole CRM)
|
|
||||||
oo companies dedupe
|
|
||||||
|
|
||||||
# Invoice ↔ deal must exist at create
|
|
||||||
oo invoices create --number P-YYYY-NN --contact COMPANY_ID --item ITEM_ID \
|
|
||||||
--price 300 --opportunity DEAL_ID --language de-DE …
|
|
||||||
|
|
||||||
# Fresh PDF + prune older PDFs on company/deal
|
|
||||||
oo invoices pdf INVOICE_ID --force
|
|
||||||
oo invoices pdf-cleanup INVOICE_ID
|
|
||||||
|
|
||||||
# Mail draft (no send)
|
|
||||||
oo mails draft-invoice --invoice INVOICE_ID --to billing@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- README § invoices / mail / CRM cleanup
|
|
||||||
- Personal workspace tooling (disk inventory, dossier sync) lives in a private
|
|
||||||
companion repo (`oo-workspace`, the `oow` CLI).
|
|
||||||
@@ -3,7 +3,7 @@ type: reference
|
|||||||
status: current
|
status: current
|
||||||
related:
|
related:
|
||||||
- README.md
|
- README.md
|
||||||
- file_es.go
|
- filestore_es.go
|
||||||
---
|
---
|
||||||
|
|
||||||
# Elasticsearch — полнотекстовый поиск OnlyOffice
|
# Elasticsearch — полнотекстовый поиск OnlyOffice
|
||||||
@@ -85,12 +85,12 @@ oo search "Rechnung" --folder 649 --limit 50 --json
|
|||||||
|
|
||||||
## Обновление индекса и карта поиска
|
## Обновление индекса и карта поиска
|
||||||
|
|
||||||
Обзор всех контуров поиска и как обновлять индексы (`oo index`,
|
Обзор всех контуров поиска и как обновлять индексы (`oo index`) —
|
||||||
`ooscan`/`pdfamount` для match) — [index-and-search.md](index-and-search.md).
|
[index-and-search.md](index-and-search.md).
|
||||||
|
|
||||||
## Библиотека
|
## Библиотека
|
||||||
|
|
||||||
`file_es.go` — `ESSearcher` (`Name() = "elasticsearch"`), прямой ES REST на
|
`filestore_es.go` — `ESSearcher` (`Name() = "elasticsearch"`), прямой ES REST на
|
||||||
stdlib `net/http`:
|
stdlib `net/http`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -103,7 +103,7 @@ hits, _ := es.Search(ctx, onlyoffice.SearchQuery{
|
|||||||
Запрос: `multi_match` по `title^2` (+ `document.attachment.content` при
|
Запрос: `multi_match` по `title^2` (+ `document.attachment.content` при
|
||||||
`InContent`), фильтры `tenantId` и `folders.folderId`, `_source`
|
`InContent`), фильтры `tenantId` и `folders.folderId`, `_source`
|
||||||
id/title/folders, `highlight` для фрагмента. Ответ → `[]SearchHit` (модель из
|
id/title/folders, `highlight` для фрагмента. Ответ → `[]SearchHit` (модель из
|
||||||
эпика #34; пока объявлена в `file_es.go`, переедет в `file_core.go` с F1 #35).
|
эпика #34; пока объявлена в `filestore_es.go`, переедет в `filestore_core.go` с F1 #35).
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
@@ -168,11 +168,11 @@ OnlyOffice PDF лежит только по имени.
|
|||||||
|
|
||||||
## Устройство
|
## Устройство
|
||||||
|
|
||||||
- `file_es_text.go` — `ESTextIndex` (`Name() = "es-text"`):
|
- `filestore_es_text.go` — `ESTextIndex` (`Name() = "es-text"`):
|
||||||
`Ensure` (создаёт индекс с явным маппингом), `Put` (bulk, `refresh`),
|
`Ensure` (создаёт индекс с явным маппингом), `Put` (bulk, `refresh`),
|
||||||
`Delete` (по `id`), `Search` (`multi_match` по `title^2` + `content`,
|
`Delete` (по `id`), `Search` (`multi_match` по `title^2` + `content`,
|
||||||
фильтры `folder`/`ext`, highlight).
|
фильтры `folder`/`ext`, highlight).
|
||||||
- `file_text_index.go` — `TextIndexer`: листает папки (`FileStore.List`),
|
- `filestore_text_index.go` — `TextIndexer`: листает папки (`FileStore.List`),
|
||||||
качает файлы (`FileStore.Download`), извлекает текст через
|
качает файлы (`FileStore.Download`), извлекает текст через
|
||||||
`internal/docpipe` (`pdftotext`, для сканов — `ocrmypdf`/`tesseract`),
|
`internal/docpipe` (`pdftotext`, для сканов — `ocrmypdf`/`tesseract`),
|
||||||
пишет в `TextIndex`. Пул воркеров (по умолчанию 3).
|
пишет в `TextIndex`. Пул воркеров (по умолчанию 3).
|
||||||
|
|||||||
+10
-25
@@ -17,21 +17,20 @@ related:
|
|||||||
| REST `@search` | только имена в БД | нет | — (живой запрос) | `oo search` (по умолчанию `--backend oo`) |
|
| REST `@search` | только имена в БД | нет | — (живой запрос) | `oo search` (по умолчанию `--backend oo`) |
|
||||||
| ES `files_file` | имя + текст Office | Elasticsearch портала | сервер, асинхронно | `oo search --content` |
|
| ES `files_file` | имя + текст Office | Elasticsearch портала | сервер, асинхронно | `oo search --content` |
|
||||||
| ES `oo_docs_text` | PDF/сканы (свой) | Elasticsearch портала | `oo index` | `oo search --backend own` |
|
| ES `oo_docs_text` | PDF/сканы (свой) | Elasticsearch портала | `oo index` | `oo search --backend own` |
|
||||||
| TSV `ooscan` | файлы папок | файл `*.tsv` | `ooscan <folder...>` | потребитель (не библиотека) |
|
|
||||||
|
|
||||||
## Карта кода
|
## Карта кода
|
||||||
|
|
||||||
- `file_core.go` — интерфейсы `Searcher`, модели `SearchQuery`/`SearchHit`.
|
- `filestore_core.go` — интерфейсы `Searcher`, модели `SearchQuery`/`SearchHit`.
|
||||||
- `file_es.go` — `ESSearcher` (индекс OnlyOffice `files_file`).
|
- `filestore_es.go` — `ESSearcher` (индекс OnlyOffice `files_file`).
|
||||||
- `file_es_text.go` — `ESTextIndex` (`oo_docs_text`): `Ensure`, `Put`, `Delete`,
|
- `filestore_es_text.go` — `ESTextIndex` (`oo_docs_text`): `Ensure`, `Put`, `Delete`,
|
||||||
`Search`.
|
`Search`.
|
||||||
- `file_text_index.go` — `TextIndexer`: обход папок (`FileStore.List`), download
|
- `filestore_text_index.go` — `TextIndexer`: обход папок (`FileStore.List`), download
|
||||||
(`FileStore.Download`), извлечение текста (`internal/docpipe`), запись в
|
(`FileStore.Download`), извлечение текста (`internal/docpipe`), запись в
|
||||||
`TextIndex`; пул воркеров.
|
`TextIndex`; пул воркеров.
|
||||||
- `file_facade.go` — связка бэкендов (`Files().Search()`, порядок и fallback).
|
- `filestore_facade.go` — связка бэкендов (`Files().Search()`, порядок и fallback).
|
||||||
- CLI: `cmd/oo/search.go`, `cmd/oo/index.go`.
|
- CLI: `cmd/oo/search.go`, `cmd/oo/index.go`.
|
||||||
- Разовые бинари для match: `cmd/ooscan/` (TSV-индекс папок),
|
- Разовые бинари для match (`ooscan`, `pdfamount`) живут в приватном
|
||||||
`cmd/pdfamount/` (суммы по PDF в папке).
|
`oo-workspace`.
|
||||||
- `internal/docpipe` — текст из PDF (pdftotext), для сканов OCR
|
- `internal/docpipe` — текст из PDF (pdftotext), для сканов OCR
|
||||||
(ocrmypdf/tesseract), вложения PDF (pdfdetach).
|
(ocrmypdf/tesseract), вложения PDF (pdfdetach).
|
||||||
|
|
||||||
@@ -81,24 +80,10 @@ oo index folder 649 --recursive --dry-run
|
|||||||
не индексируй корень целиком.
|
не индексируй корень целиком.
|
||||||
- Индексация PDF в `files_file` не делается — только `oo_docs_text`.
|
- Индексация PDF в `files_file` не делается — только `oo_docs_text`.
|
||||||
|
|
||||||
## Bulk-инструменты `ooscan` / `pdfamount`
|
## Bulk-инструменты
|
||||||
|
|
||||||
Плоский TSV без Elasticsearch, для внешних потребителей.
|
Плоские TSV-инструменты (`ooscan`, `pdfamount`) и сверка Excel живут в
|
||||||
|
приватном `oo-workspace`, не в публичной библиотеке.
|
||||||
```bash
|
|
||||||
# рекурсивный индекс папок: file_id, folder_id, path, title
|
|
||||||
ooscan <FOLDER_ID>... > index.tsv
|
|
||||||
# суммы по PDF папки: file_id, title, amount
|
|
||||||
pdfamount <FOLDER_ID> [TITLE_FILTER] > amounts.tsv
|
|
||||||
```
|
|
||||||
|
|
||||||
- `ooscan`: троттлинг 350 мс на папку, `DoRetry` на 429, глубина до 8, `path` —
|
|
||||||
путь внутри просканированного корня.
|
|
||||||
- `pdfamount`: строка с `%`/`MwSt`/`USt`/`Prozent`/`Steuer` суммой не считается;
|
|
||||||
приоритет меток (`zu zahlender betrag` > `rechnungsbetrag` > … > `summe`).
|
|
||||||
- Какие корни сканировать и как обновлять индекс — решает потребитель; это не
|
|
||||||
часть библиотеки. Сверка Excel — `office-assistant` (`cmd/match`,
|
|
||||||
`docs/reference/match-index.md`).
|
|
||||||
|
|
||||||
## Грабли
|
## Грабли
|
||||||
|
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
---
|
|
||||||
type: reference
|
|
||||||
status: current
|
|
||||||
related:
|
|
||||||
- deploy/docker-compose.rclone-webdav.yml
|
|
||||||
- docs/unified-file-client.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# rclone WebDAV mount — OnlyOffice Documents как файловая ФС
|
|
||||||
|
|
||||||
`deploy/docker-compose.rclone-webdav.yml` монтирует WebDAV-дерево OnlyOffice
|
|
||||||
через `rclone`. Не systemd, только compose. Контейнер — `rclone-webdav`.
|
|
||||||
|
|
||||||
## Что монтируется
|
|
||||||
|
|
||||||
- Источник — сайдкар `oo-webdav` (`ghcr.io/eslider/oo-webdav`).
|
|
||||||
- Адрес — `http://172.17.0.1:8098`, префикс `/webdav`.
|
|
||||||
- Basic-auth — креды портала OnlyOffice (те же, что у `oo`).
|
|
||||||
- Корень — `Dokumente der Projekte/…` (папка `Fibu EDL` внутри).
|
|
||||||
|
|
||||||
## Конфиг
|
|
||||||
|
|
||||||
- Образ — `rclone/rclone`.
|
|
||||||
- Remote — on-the-fly `:webdav:` плюс флаги `--webdav-url`, `--webdav-user`,
|
|
||||||
`--webdav-pass`.
|
|
||||||
- На старте контейнер пишет `/config/rclone/rclone.conf` (`[webdav]`), чтобы
|
|
||||||
`rclone ls webdav:` внутри контейнера работал без флагов.
|
|
||||||
- `ONLYOFFICE_PASSWORD` — plain; rclone сам зовёт `rclone obscure`.
|
|
||||||
- Флаги mount: `--vfs-cache-mode writes`, `--cache-dir /cache`,
|
|
||||||
`--dir-cache-time 1m`, `--allow-other`, `--allow-non-empty`.
|
|
||||||
- FUSE: `cap_add: [SYS_ADMIN]`, `devices: [/dev/fuse]`,
|
|
||||||
`security_opt: apparmor:unconfined`.
|
|
||||||
- Тома: `onlyoffice-mnt` → `/mnt/onlyoffice`, `rclone-cache` → `/cache`.
|
|
||||||
|
|
||||||
### env (только имена)
|
|
||||||
|
|
||||||
| Переменная | Значение |
|
|
||||||
|------------|----------|
|
|
||||||
| `ONLYOFFICE_USER` | пользователь портала |
|
|
||||||
| `ONLYOFFICE_PASSWORD` | пароль портала (алиас `ONLYOFFICE_PASS`) |
|
|
||||||
| `ONLYOFFICE_WEBDAV_URL` | по умолчанию `http://172.17.0.1:8098/webdav` |
|
|
||||||
|
|
||||||
## Команды
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# креды (или .env рядом с compose)
|
|
||||||
set -a; . .secrets/oo.env; set +a
|
|
||||||
|
|
||||||
docker compose -f deploy/docker-compose.rclone-webdav.yml up -d
|
|
||||||
docker compose -f deploy/docker-compose.rclone-webdav.yml ps
|
|
||||||
docker compose -f deploy/docker-compose.rclone-webdav.yml down
|
|
||||||
|
|
||||||
# дерево
|
|
||||||
docker exec rclone-webdav rclone ls webdav:
|
|
||||||
docker exec rclone-webdav rclone lsf webdav:
|
|
||||||
|
|
||||||
# содержимое точки монтирования
|
|
||||||
docker exec rclone-webdav ls /mnt/onlyoffice
|
|
||||||
|
|
||||||
# чтение/запись как обычная ФС
|
|
||||||
docker exec rclone-webdav cat "/mnt/onlyoffice/Meine Dokumente/x.txt"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Smoke (проверено 2026-09-16)
|
|
||||||
|
|
||||||
```text
|
|
||||||
$ docker exec rclone-webdav rclone ls webdav:
|
|
||||||
5357109 Meine Dokumente/ONLYOFFICE-Audiobeispiel.mp3
|
|
||||||
44805 Meine Dokumente/ONLYOFFICE-Beispiel-Tabellenblatt.xlsx
|
|
||||||
58988 Meine Dokumente/ONLYOFFICE-Beispieldokument.docx
|
|
||||||
...
|
|
||||||
|
|
||||||
$ TS=20260916-212459
|
|
||||||
$ docker exec rclone-webdav sh -c "printf 'rclone-webdav round-trip $TS\n' \
|
|
||||||
> '/mnt/onlyoffice/Meine Dokumente/rclone-smoke-$TS/hello.txt'"
|
|
||||||
# ждём появления на сервере (host HTTP, мимо монтирования):
|
|
||||||
GET /webdav/Meine%20Dokumente/rclone-smoke-$TS/hello.txt -> 200 (через 7s)
|
|
||||||
server bytes: rclone-webdav round-trip 20260916-212459
|
|
||||||
# читаем обратно через монтирование:
|
|
||||||
mount read: rclone-webdav round-trip 20260916-212459
|
|
||||||
MATCH: yes
|
|
||||||
|
|
||||||
$ docker exec rclone-webdav rm -f \
|
|
||||||
"/mnt/onlyoffice/Meine Dokumente/rclone-smoke-$TS/hello.txt"
|
|
||||||
GET /webdav/Meine%20Dokumente/rclone-smoke-$TS/hello.txt -> 404 (через 1s)
|
|
||||||
|
|
||||||
# пустой каталог: rmdir через монтирование на сервер не доходит,
|
|
||||||
# удаляем напрямую:
|
|
||||||
$ docker exec rclone-webdav rclone rmdir "webdav:Meine Dokumente/rclone-smoke-$TS"
|
|
||||||
PROPFIND rclone-smoke-20260916-212459/ -> 404
|
|
||||||
no rclone-smoke leftovers
|
|
||||||
```
|
|
||||||
|
|
||||||
Вывод: запись → чтение → удаление файла подтверждены. Файл удалён.
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Нужен FUSE: `SYS_ADMIN` + `/dev/fuse`. На хосте без FUSE не поедет.
|
|
||||||
- Сайдкар слушает docker-bridge `172.17.0.1:8098`, публично не выставлен.
|
|
||||||
Только localhost/локальные контейнеры.
|
|
||||||
- Запись идёт через VFS write-back (по умолчанию ~5s). Перед проверкой
|
|
||||||
«файл на сервере» опрашивать сервер, не доверять сразу после `write`.
|
|
||||||
- `rmdir` через монтирование НЕ доходит до `oo-webdav` (пустой каталог
|
|
||||||
остаётся на сервере). Удалять каталоги напрямую:
|
|
||||||
`rclone rmdir webdav:<path>` или `rclone purge webdav:<path>`.
|
|
||||||
- VFS-кэш растёт в томе `rclone-cache`; ограничить `--vfs-cache-max-size`.
|
|
||||||
- Блокировок между редактором OnlyOffice и монтированием нет. Не редактировать
|
|
||||||
один и тот же файл одновременно.
|
|
||||||
- Данные не шифруются на диске хоста в `rclone-cache` (том Docker).
|
|
||||||
@@ -3,8 +3,8 @@ type: reference
|
|||||||
status: current
|
status: current
|
||||||
related:
|
related:
|
||||||
- README.md
|
- README.md
|
||||||
- file_core.go
|
- filestore_core.go
|
||||||
- file_facade.go
|
- filestore_facade.go
|
||||||
- docs/elasticsearch.md
|
- docs/elasticsearch.md
|
||||||
- docs/community-server-db.md
|
- docs/community-server-db.md
|
||||||
---
|
---
|
||||||
@@ -14,7 +14,7 @@ related:
|
|||||||
## Что это
|
## Что это
|
||||||
|
|
||||||
Один файловый клиент на все бэкенды (эпик #34). Модель и интерфейсы —
|
Один файловый клиент на все бэкенды (эпик #34). Модель и интерфейсы —
|
||||||
`file_core.go`. Фасад `FileClient` — `file_facade.go`. Бэкенды:
|
`filestore_core.go`. Фасад `FileClient` — `filestore_facade.go`. Бэкенды:
|
||||||
REST, WebDAV, SQL (PostgreSQL/MySQL), Elasticsearch. Правило одно:
|
REST, WebDAV, SQL (PostgreSQL/MySQL), Elasticsearch. Правило одно:
|
||||||
код зовёт `c.Files()` и не знает про транспорт.
|
код зовёт `c.Files()` и не знает про транспорт.
|
||||||
|
|
||||||
@@ -56,18 +56,18 @@ type Searcher interface {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`TextIndex` (`file_es_text.go`) — свой индекс: `Put`, `Delete`, `Search`,
|
`TextIndex` (`filestore_es_text.go`) — свой индекс: `Put`, `Delete`, `Search`,
|
||||||
`Name`. `ESTextIndex` реализует и `Searcher`, и `TextIndex`.
|
`Name`. `ESTextIndex` реализует и `Searcher`, и `TextIndex`.
|
||||||
|
|
||||||
## Бэкенды
|
## Бэкенды
|
||||||
|
|
||||||
| бэкенд | провайдер | файл | что умеет |
|
| бэкенд | провайдер | файл | что умеет |
|
||||||
|--------|-----------|------|-----------|
|
|--------|-----------|------|-----------|
|
||||||
| REST | `rest` | `file_rest.go` | read + write, Documents API |
|
| REST | `rest` | `filestore_rest.go` | read + write, Documents API |
|
||||||
| WebDAV | `dav` | `file_dav.go` | read + write, Documents fileops |
|
| WebDAV | `dav` | `filestore_dav.go` | read + write, Documents fileops |
|
||||||
| SQL | `postgres` / `mysql` | `file_pg.go` | **read-only** |
|
| SQL | `postgres` / `mysql` | `filestore_pg.go` | **read-only** |
|
||||||
| OnlyOffice ES | `elasticsearch` | `file_es.go` | поиск (имя + контент Office) |
|
| OnlyOffice ES | `elasticsearch` | `filestore_es.go` | поиск (имя + контент Office) |
|
||||||
| свой ES-индекс | `es-text` | `file_es_text.go` | поиск + запись (PDF/сканы) |
|
| свой ES-индекс | `es-text` | `filestore_es_text.go` | поиск + запись (PDF/сканы) |
|
||||||
|
|
||||||
- REST: `Stat` знает только файлы; папки — через `List`.
|
- REST: `Stat` знает только файлы; папки — через `List`.
|
||||||
- WebDAV: `Move`/`Copy`/`Delete` сперва `Stat`-ят id (папка/файл), потом зовут
|
- WebDAV: `Move`/`Copy`/`Delete` сперва `Stat`-ят id (папка/файл), потом зовут
|
||||||
@@ -202,4 +202,3 @@ go test ./ -run 'FileStore|Facade|ESText|PG'
|
|||||||
- [README.md](README.md) — индекс справочников.
|
- [README.md](README.md) — индекс справочников.
|
||||||
- [elasticsearch.md](elasticsearch.md) — индекс OnlyOffice и свой `oo_docs_text`.
|
- [elasticsearch.md](elasticsearch.md) — индекс OnlyOffice и свой `oo_docs_text`.
|
||||||
- [community-server-db.md](community-server-db.md) — SQL-стор и схема БД.
|
- [community-server-db.md](community-server-db.md) — SQL-стор и схема БД.
|
||||||
- [rclone-webdav.md](rclone-webdav.md) — монтирование Documents как ФС.
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ type Searcher interface {
|
|||||||
// returned store surfaces that error on every operation instead of returning
|
// returned store surfaces that error on every operation instead of returning
|
||||||
// nil. Use SQLFileStore when the open error itself is needed. Unknown or empty
|
// nil. Use SQLFileStore when the open error itself is needed. Unknown or empty
|
||||||
// names select the REST backend. The composed facade (backend
|
// names select the REST backend. The composed facade (backend
|
||||||
// selection/fallback) lives on FileClient in file_facade.go.
|
// selection/fallback) lives on FileClient in filestore_facade.go.
|
||||||
func (c *Client) FileStore(backend string) FileStore {
|
func (c *Client) FileStore(backend string) FileStore {
|
||||||
switch strings.ToLower(strings.TrimSpace(backend)) {
|
switch strings.ToLower(strings.TrimSpace(backend)) {
|
||||||
case ProviderDAV, "webdav":
|
case ProviderDAV, "webdav":
|
||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// The canonical model (Kind, Entry, SearchQuery, SearchHit, Searcher) lives in
|
// The canonical model (Kind, Entry, SearchQuery, SearchHit, Searcher) lives in
|
||||||
// file_core.go (F1 #35).
|
// filestore_core.go (F1 #35).
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultESIndex = "files_file"
|
defaultESIndex = "files_file"
|
||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ProviderES is the composed Elasticsearch searcher. The SQL store owns
|
// ProviderES is the composed Elasticsearch searcher. The SQL store owns
|
||||||
// ProviderPG/ProviderMySQL (file_pg.go); the facade references ProviderPG in
|
// ProviderPG/ProviderMySQL (filestore_pg.go); the facade references ProviderPG in
|
||||||
// readOrder.
|
// readOrder.
|
||||||
const ProviderES = "elasticsearch"
|
const ProviderES = "elasticsearch"
|
||||||
|
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
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
-2
@@ -131,7 +131,7 @@ type UpdateInvoiceParams struct {
|
|||||||
// UpdateInvoice PUTs a full invoice body (OnlyOffice requires complete payload).
|
// UpdateInvoice PUTs a full invoice body (OnlyOffice requires complete payload).
|
||||||
//
|
//
|
||||||
// Linking an opportunity via EntityID on an existing invoice often returns HTTP 400
|
// Linking an opportunity via EntityID on an existing invoice often returns HTTP 400
|
||||||
// on this portal — prefer CreateInvoice with EntityID set. See docs/crm-associations.md.
|
// on this portal — prefer CreateInvoice with EntityID set. See the CRM association rules.
|
||||||
func (c *Client) UpdateInvoice(ctx context.Context, id string, p UpdateInvoiceParams) (map[string]any, error) {
|
func (c *Client) UpdateInvoice(ctx context.Context, id string, p UpdateInvoiceParams) (map[string]any, error) {
|
||||||
inv, err := c.GetInvoice(ctx, id)
|
inv, err := c.GetInvoice(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -249,7 +249,7 @@ const (
|
|||||||
|
|
||||||
// SetInvoiceStatus sets CRM invoice status for one or more invoice ids
|
// SetInvoiceStatus sets CRM invoice status for one or more invoice ids
|
||||||
// (PUT /api/2.0/crm/invoice/status/{statusId} with invoiceids).
|
// (PUT /api/2.0/crm/invoice/status/{statusId} with invoiceids).
|
||||||
// Note: Billed→Draft often does not stick; recreate Draft instead (see docs/crm-associations.md).
|
// Note: Billed→Draft often does not stick; recreate Draft instead (see the CRM association rules).
|
||||||
func (c *Client) SetInvoiceStatus(ctx context.Context, statusID int, invoiceIDs ...int64) (map[string]any, error) {
|
func (c *Client) SetInvoiceStatus(ctx context.Context, statusID int, invoiceIDs ...int64) (map[string]any, error) {
|
||||||
if statusID <= 0 {
|
if statusID <= 0 {
|
||||||
return nil, fmt.Errorf("status id is required")
|
return nil, fmt.Errorf("status id is required")
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
|
||||||
"release-type": "go",
|
|
||||||
"bump-minor-pre-major": true,
|
|
||||||
"bump-patch-for-minor-pre-major": false,
|
|
||||||
"include-v-in-tag": true,
|
|
||||||
"include-component-in-tag": false,
|
|
||||||
"pull-request-title-pattern": "chore${scope}: release ${version}",
|
|
||||||
"changelog-path": "CHANGELOG.md",
|
|
||||||
"changelog-sections": [
|
|
||||||
{ "type": "feat", "section": "Features" },
|
|
||||||
{ "type": "fix", "section": "Bug Fixes" },
|
|
||||||
{ "type": "perf", "section": "Performance Improvements" },
|
|
||||||
{ "type": "revert", "section": "Reverts" },
|
|
||||||
{ "type": "refactor", "section": "Code Refactoring" },
|
|
||||||
{ "type": "docs", "section": "Documentation" },
|
|
||||||
{ "type": "test", "section": "Tests", "hidden": true },
|
|
||||||
{ "type": "build", "section": "Build System", "hidden": true },
|
|
||||||
{ "type": "ci", "section": "Continuous Integration", "hidden": true },
|
|
||||||
{ "type": "chore", "section": "Miscellaneous", "hidden": true },
|
|
||||||
{ "type": "style", "section": "Styles", "hidden": true }
|
|
||||||
],
|
|
||||||
"packages": {
|
|
||||||
".": {
|
|
||||||
"package-name": "go-onlyoffice"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -163,7 +163,7 @@ func (c *Client) downloadFromMinio(ctx context.Context, objectKey string, dst io
|
|||||||
|
|
||||||
// downloadMinioObject streams objectKey from the configured MinIO bucket using
|
// downloadMinioObject streams objectKey from the configured MinIO bucket using
|
||||||
// hc. It is the shared implementation behind (*Client).downloadFromMinio and
|
// hc. It is the shared implementation behind (*Client).downloadFromMinio and
|
||||||
// the read-only SQL store (file_pg.go), which has no *Client.
|
// the read-only SQL store (filestore_pg.go), which has no *Client.
|
||||||
func downloadMinioObject(ctx context.Context, hc *http.Client, objectKey string, dst io.Writer) (int64, error) {
|
func downloadMinioObject(ctx context.Context, hc *http.Client, objectKey string, dst io.Writer) (int64, error) {
|
||||||
if objectKey == "" {
|
if objectKey == "" {
|
||||||
return 0, fmt.Errorf("onlyoffice: minio fallback: empty object key")
|
return 0, fmt.Errorf("onlyoffice: minio fallback: empty object key")
|
||||||
|
|||||||
Reference in New Issue
Block a user