docs(crm): capture association graph and invoice/mail quirks
Document Medex-learned OO rules (one company, entityId at create, PDF cache/cleanup, Matrix # truncation) and add invoices pdf/status helpers.
This commit is contained in:
@@ -9,7 +9,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
|
||||
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
|
||||
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
|
||||
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`).
|
||||
- `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `mails.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents** (`GetProjectFiles`, `UploadProjectFile`, `GetTaskFiles`, `AttachFilesToTask`, `UploadTaskFile`, `DetachTaskFile`, `GetFile`, `RenameFile`, `DeleteFiles`, `DownloadFile`). **`mails.go`** — OnlyOffice Workspace Mail addon (`ListMailAccounts`, `ListMailFolders`, `ListMailMessages`, `GetMailMessage`, `RemoveMailMessages`).
|
||||
- `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `mails.go`, `invoices.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents**. **`mails.go`** — OnlyOffice Workspace Mail. **`invoices.go`** — CRM invoices, PDF regen/cleanup, status. Association rules: [`docs/crm-associations.md`](docs/crm-associations.md).
|
||||
- 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):
|
||||
- `main.go` — entry point (docstring lists the command tree).
|
||||
|
||||
@@ -4,6 +4,18 @@ All notable changes to this project are documented here. The format is based on
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
||||
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
* **docs:** CRM association graph and OO quirks (`docs/crm-associations.md`)
|
||||
* **crm:** `ForceRegenerateInvoicePDF`, `SetInvoiceStatus`, `PurgeStaleInvoicePDFs`, contact/opportunity file list helpers
|
||||
* **oo:** `invoices pdf`, `pdf-cleanup`, `status`; create `--consignee`; draft-invoice force-regens PDF
|
||||
|
||||
### Fixed
|
||||
|
||||
* Document that invoice→deal must be set at create (`update --opportunity` often HTTP 400)
|
||||
|
||||
## [0.9.0](https://github.com/eSlider/go-onlyoffice/compare/v0.8.3...v0.9.0) (2026-07-26)
|
||||
|
||||
|
||||
|
||||
@@ -623,13 +623,11 @@ oo tasks files detach 208 12345
|
||||
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`) |
|
||||
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
|
||||
| `users` | `list`, `self` (alias: `oo whoami`) |
|
||||
| `contacts` | `list`, `get`, `delete`, `info-add` |
|
||||
| `persons` | `list` (filtered), `create`, `delete` |
|
||||
| `companies` | `list` (filtered), `create`, `delete` |
|
||||
| `contacts` | `list`, `get`, `delete`, `info-add`, `dedupe-info` |
|
||||
| `contacts` | `list`, `get`, `delete`, `info-add`, `merge`, `dedupe-info` |
|
||||
| `persons` | `list`, `create`, `delete`, `dedupe` |
|
||||
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
|
||||
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
|
||||
| `invoices` | `list`, `get`, `create`, `update`, `pdf`, `pdf-cleanup`, `status`, `delete`, `items …` |
|
||||
| `crm` | `cleanup` |
|
||||
| `mails` | `accounts`, `folders`, `list`, `get`, `draft`, `attach`, `draft-invoice`, `delete` |
|
||||
| `cases` | `list`, `create`, `delete`, `member-add` |
|
||||
@@ -718,6 +716,32 @@ oo opportunities dedupe-members
|
||||
oo opportunities fix-titles
|
||||
```
|
||||
|
||||
Merge two known company ids (keeps `INTO`):
|
||||
|
||||
```bash
|
||||
oo contacts merge FROM_ID INTO_ID
|
||||
```
|
||||
|
||||
Company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail rules and OO quirks:
|
||||
[docs/crm-associations.md](docs/crm-associations.md).
|
||||
|
||||
### Invoices (`oo invoices`)
|
||||
|
||||
**Problem:** Bill a client deal as Draft, regenerate PDF, prune duplicate PDF
|
||||
attachments, prepare OnlyOffice Mail — without inventing a second bill-to company.
|
||||
|
||||
```bash
|
||||
# Always pass --opportunity at create (update --opportunity often HTTP 400)
|
||||
oo invoices create --number INV-2026-01 --contact COMPANY_ID --item ITEM_ID \
|
||||
--price 300 --opportunity DEAL_ID --language de-DE \
|
||||
--line-description "…" --terms $'…' --description $'…' --po "Deal #DEAL_ID"
|
||||
|
||||
oo invoices pdf 41 --force
|
||||
oo invoices pdf-cleanup INVOICE_ID
|
||||
oo invoices status INVOICE_ID --status draft
|
||||
oo mails draft-invoice --invoice INVOICE_ID --to info@client.de
|
||||
```
|
||||
|
||||
**Deal grouping flag** — when the same role at the same company created
|
||||
separate deals (`Engineer @ Acme` vs `Engineer`):
|
||||
|
||||
@@ -829,10 +853,13 @@ oo mails draft --to client@example.com --subject "Rechnung INV-2026-01" \
|
||||
# Attach an OnlyOffice Files document (e.g. invoice PDF file id) to a draft
|
||||
oo mails attach 7301 --file-id 12345
|
||||
|
||||
# Regenerate invoice PDF + draft + attach (does not send)
|
||||
# Regenerate invoice PDF (force) + draft + attach (does not send)
|
||||
oo mails draft-invoice --invoice 16 --to info@example.com
|
||||
```
|
||||
|
||||
Matrix / chat URLs in signatures: use plain text
|
||||
`chat: https://matrix.to/#/@user:server` — HTML `<a href="…#…">` truncates at `#`.
|
||||
|
||||
**Table output** splits the `from` header into `fromName` and `fromAddress`
|
||||
(e.g. `Bitfinex` + `no-reply@bitfinex.com`). **JSON output** returns the raw
|
||||
API payload.
|
||||
|
||||
+136
-7
@@ -21,6 +21,9 @@ func init() {
|
||||
invoicesCmd.AddCommand(invoiceGetCmd())
|
||||
invoicesCmd.AddCommand(invoiceCreateCmd())
|
||||
invoicesCmd.AddCommand(invoiceUpdateCmd())
|
||||
invoicesCmd.AddCommand(invoicePDFCmd())
|
||||
invoicesCmd.AddCommand(invoicePDFCleanupCmd())
|
||||
invoicesCmd.AddCommand(invoiceStatusCmd())
|
||||
invoicesCmd.AddCommand(invoiceDeleteCmd())
|
||||
invoicesCmd.AddCommand(invoiceItemsCmd())
|
||||
}
|
||||
@@ -78,7 +81,7 @@ func invoiceGetCmd() *cobra.Command {
|
||||
func invoiceCreateCmd() *cobra.Command {
|
||||
var (
|
||||
number, issueDate, dueDate, language, currency, terms, description, po string
|
||||
contactID, itemID, opportunityID int64
|
||||
contactID, consigneeID, itemID, opportunityID int64
|
||||
price, qty float64
|
||||
lineDesc string
|
||||
)
|
||||
@@ -87,8 +90,11 @@ func invoiceCreateCmd() *cobra.Command {
|
||||
Short: "Create a draft invoice with one line",
|
||||
Long: `Create a CRM invoice (Draft) with a single line.
|
||||
|
||||
Always pass --opportunity when a deal exists (entity link at create). Updating
|
||||
--opportunity later often fails with HTTP 400 — see docs/crm-associations.md.
|
||||
|
||||
Example:
|
||||
oo invoices create --number INV-2026-01 --contact 123 --item 10 \
|
||||
oo invoices create --number INV-2026-01 --contact CONTACT_ID --item 12 \
|
||||
--price 300 --opportunity OPPORTUNITY_ID --line-description "Service package" \
|
||||
--terms "…payment terms…"
|
||||
`,
|
||||
@@ -118,6 +124,7 @@ Example:
|
||||
IssueDate: issueDate,
|
||||
DueDate: dueDate,
|
||||
ContactID: contactID,
|
||||
ConsigneeID: consigneeID,
|
||||
EntityID: opportunityID,
|
||||
EntityType: 0, // Opportunity
|
||||
Language: language,
|
||||
@@ -142,8 +149,9 @@ Example:
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&number, "number", "", "invoice number (e.g. INV-2026-01)")
|
||||
cmd.Flags().Int64Var(&contactID, "contact", 0, "bill-to contact id (company preferred)")
|
||||
cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "link to CRM opportunity/deal id")
|
||||
cmd.Flags().Int64Var(&contactID, "contact", 0, "bill-to company contact id (canonical company, not a PDF-only clone)")
|
||||
cmd.Flags().Int64Var(&consigneeID, "consignee", 0, "optional Empfänger person/contact id")
|
||||
cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "link to CRM opportunity/deal id (set at create)")
|
||||
cmd.Flags().Int64Var(&itemID, "item", 0, "catalog invoice item id")
|
||||
cmd.Flags().Float64Var(&price, "price", 0, "line price")
|
||||
cmd.Flags().Float64Var(&qty, "qty", 1, "line quantity")
|
||||
@@ -163,8 +171,13 @@ func invoiceUpdateCmd() *cobra.Command {
|
||||
var description, po, terms string
|
||||
cmd := &cobra.Command{
|
||||
Use: "update INVOICE_ID",
|
||||
Short: "Update invoice (opportunity link, notes, PO, terms)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Update invoice (notes, PO, terms; opportunity link unreliable)",
|
||||
Long: `Update Draft invoice fields.
|
||||
|
||||
--opportunity often returns HTTP 400 on existing invoices. Prefer
|
||||
oo invoices create … --opportunity, or delete+recreate. See docs/crm-associations.md.
|
||||
`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if opportunityID == 0 && !cmd.Flags().Changed("description") && !cmd.Flags().Changed("po") && !cmd.Flags().Changed("terms") {
|
||||
return fmt.Errorf("set --opportunity and/or --description and/or --po and/or --terms")
|
||||
@@ -190,13 +203,129 @@ func invoiceUpdateCmd() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "CRM opportunity/deal id to link")
|
||||
cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "CRM opportunity/deal id (may 400 — prefer create)")
|
||||
cmd.Flags().StringVar(&description, "description", "", "invoice notes (Notizen); use \\n for line breaks")
|
||||
cmd.Flags().StringVar(&po, "po", "", "purchase order number")
|
||||
cmd.Flags().StringVar(&terms, "terms", "", "payment terms / footer (Bedingungen)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func invoicePDFCmd() *cobra.Command {
|
||||
var force bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "pdf INVOICE_ID",
|
||||
Short: "Return / regenerate invoice PDF file metadata",
|
||||
Long: `GET /api/2.0/crm/invoice/{id}/pdf.
|
||||
|
||||
With --force, touches the Draft to clear cached fileID first (layout changes).
|
||||
`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out map[string]any
|
||||
if force {
|
||||
out, err = c.ForceRegenerateInvoicePDF(cmd.Context(), args[0])
|
||||
} else {
|
||||
out, err = c.InvoicePDFFile(cmd.Context(), args[0])
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&force, "force", false, "clear PDF cache then regenerate")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func invoicePDFCleanupCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "pdf-cleanup INVOICE_ID",
|
||||
Short: "Delete older P-*.pdf copies on company/deal; keep invoice.fileID",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleted, err := c.PurgeStaleInvoicePDFs(cmd.Context(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outputFormat == "json" {
|
||||
printObject(map[string]any{"deleted": deleted})
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("deleted %d stale PDF file(s): %v\n", len(deleted), deleted)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func invoiceStatusCmd() *cobra.Command {
|
||||
var status string
|
||||
cmd := &cobra.Command{
|
||||
Use: "status INVOICE_ID [INVOICE_ID...]",
|
||||
Short: "Set invoice status (draft|billed|rejected|paid)",
|
||||
Long: `PUT /api/2.0/crm/invoice/status/{id}.
|
||||
|
||||
Billed invoices are not content-editable. Billed→Draft often does not work —
|
||||
recreate as Draft instead (docs/crm-associations.md).
|
||||
`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
statusID, err := parseInvoiceStatus(status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]int64, 0, len(args))
|
||||
for _, a := range args {
|
||||
var id int64
|
||||
if _, err := fmt.Sscan(a, &id); err != nil || id <= 0 {
|
||||
return fmt.Errorf("invalid invoice id %q", a)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := c.SetInvoiceStatus(cmd.Context(), statusID, ids...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&status, "status", "draft", "draft|billed|rejected|paid or numeric id")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseInvoiceStatus(s string) (int, error) {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
switch s {
|
||||
case "", "draft", "1":
|
||||
return onlyoffice.InvoiceStatusDraft, nil
|
||||
case "billed", "2":
|
||||
return onlyoffice.InvoiceStatusBilled, nil
|
||||
case "rejected", "3":
|
||||
return onlyoffice.InvoiceStatusRejected, nil
|
||||
case "paid", "4":
|
||||
return onlyoffice.InvoiceStatusPaid, nil
|
||||
default:
|
||||
var n int
|
||||
if _, err := fmt.Sscan(s, &n); err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("unknown status %q (draft|billed|rejected|paid)", s)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
|
||||
func invoiceDeleteCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete INVOICE_ID [INVOICE_ID...]",
|
||||
|
||||
+1
-1
@@ -229,7 +229,7 @@ Does not send. Open /addons/mail/#drafts to review.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pdf, err := c.InvoicePDFFile(cmd.Context(), strconv.Itoa(invoiceID))
|
||||
pdf, err := c.ForceRegenerateInvoicePDF(cmd.Context(), strconv.Itoa(invoiceID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+5
-3
@@ -6,18 +6,20 @@
|
||||
// oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete)
|
||||
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
|
||||
// oo users list | self (alias: oo whoami)
|
||||
// oo contacts list | get | delete | info-add | dedupe-info
|
||||
// oo contacts list | get | delete | info-add | merge | dedupe-info
|
||||
// oo persons list | create | delete | dedupe
|
||||
// oo companies list | create | delete | dedupe | dedupe-persons
|
||||
// oo opportunities list | get | create | delete | stages | member-add | dedupe | dedupe-members | fix-titles
|
||||
// oo cases list | create | delete | member-add
|
||||
// oo crm-tasks list | create | delete | categories
|
||||
// oo crm cleanup
|
||||
// oo mails accounts | folders | list | get | delete
|
||||
// oo invoices list | get | create | delete | items (list|create|delete)
|
||||
// oo mails accounts | folders | list | get | draft | attach | draft-invoice | delete
|
||||
// oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items …
|
||||
// oo applications sync
|
||||
// oo catalog scan-contacts | scan-projects | scan-thunderbird | merge | match | apply
|
||||
//
|
||||
// CRM association rules: docs/crm-associations.md
|
||||
//
|
||||
// Every list supports `--output/-o json|table` (table is the default).
|
||||
//
|
||||
// Build & install:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# CRM associations (company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail)
|
||||
|
||||
Operational rules learned from real billing work (AcmeClient INV-2026-01, AcmeProject INV-2026-01).
|
||||
Tooling: `oo` CLI + this library. Business SSOT remains OnlyOffice (`ASR-0002`).
|
||||
|
||||
## Canonical graph
|
||||
|
||||
One **legal company** owns the relationship. Do not invent a second “bill-to”
|
||||
company just for PDF layout.
|
||||
|
||||
```text
|
||||
Company (#CONTACT_ID)
|
||||
├── Person (GF / 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` | job title; never encode employer in `lastName` |
|
||||
| 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 `P-….pdf`
|
||||
|
||||
## 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 `P-*.pdf` 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 NIE / street lines | Organisation profile address (`street` with `\n`), not only terms |
|
||||
|
||||
Status ids commonly used on this portal: `1` Draft, `2` Billed, `3` Rejected, `4` Paid.
|
||||
|
||||
## Mail quirks
|
||||
|
||||
| Symptom | Workaround |
|
||||
|---------|------------|
|
||||
| Signature / body cuts Matrix URL at `#` | Plain text `chat: https://matrix.to/#/@user:server` — avoid `<a href="…#…">` (or encode `#` as `%23` in href) |
|
||||
| German letter spacing | Blank `<p> </p>` between blocks (`MailHTMLWithBlankParagraphs`) |
|
||||
| Send | Never auto-send; draft only until the human confirms |
|
||||
|
||||
Prefer OnlyOffice Mail (`/addons/mail/#drafts`) over Gmail MCP for invoice delivery.
|
||||
|
||||
## Project / task quirks
|
||||
|
||||
- Hub title: `CC | Company` (e.g. `DE | AcmeClient Ambulanter Pflegedienst 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 human-created company (INTO), drop the duplicate (FROM)
|
||||
oo contacts merge 1334 1328
|
||||
|
||||
# Or by normalized name (careful — whole CRM)
|
||||
oo companies dedupe
|
||||
|
||||
# Invoice ↔ deal must exist at create
|
||||
oo invoices create --number P-YYYY-NN --contact CONTACT_ID --item N \
|
||||
--price 300 --opportunity OPPORTUNITY_ID --language de-DE …
|
||||
|
||||
# Fresh PDF + prune older P-*.pdf on company/deal
|
||||
oo invoices pdf 41 --force
|
||||
oo invoices pdf-cleanup INVOICE_ID
|
||||
|
||||
# Mail draft (no send)
|
||||
oo mails draft-invoice --invoice INVOICE_ID --to info@client.de
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- README § invoices / mail / CRM cleanup
|
||||
- Skill `oo-clients-projects` (Cursor)
|
||||
- inventar ASR-0014 / `ops/oo-clients-contacts-sync.md`
|
||||
+158
@@ -129,6 +129,9 @@ type UpdateInvoiceParams struct {
|
||||
}
|
||||
|
||||
// UpdateInvoice PUTs a full invoice body (OnlyOffice requires complete payload).
|
||||
//
|
||||
// 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.
|
||||
func (c *Client) UpdateInvoice(ctx context.Context, id string, p UpdateInvoiceParams) (map[string]any, error) {
|
||||
inv, err := c.GetInvoice(ctx, id)
|
||||
if err != nil {
|
||||
@@ -236,6 +239,161 @@ func (c *Client) DeleteInvoice(ctx context.Context, id string) (map[string]any,
|
||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s.json", url.PathEscape(id)))
|
||||
}
|
||||
|
||||
// Invoice status ids used by OnlyOffice CRM on produktor.io.
|
||||
const (
|
||||
InvoiceStatusDraft = 1
|
||||
InvoiceStatusBilled = 2
|
||||
InvoiceStatusRejected = 3
|
||||
InvoiceStatusPaid = 4
|
||||
)
|
||||
|
||||
// SetInvoiceStatus sets CRM invoice status for one or more invoice ids
|
||||
// (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).
|
||||
func (c *Client) SetInvoiceStatus(ctx context.Context, statusID int, invoiceIDs ...int64) (map[string]any, error) {
|
||||
if statusID <= 0 {
|
||||
return nil, fmt.Errorf("status id is required")
|
||||
}
|
||||
if len(invoiceIDs) == 0 {
|
||||
return nil, fmt.Errorf("at least one invoice id is required")
|
||||
}
|
||||
ids := make([]string, 0, len(invoiceIDs))
|
||||
for _, id := range invoiceIDs {
|
||||
ids = append(ids, strconv.FormatInt(id, 10))
|
||||
}
|
||||
fields := url.Values{}
|
||||
fields.Set("invoiceids", strings.Join(ids, ","))
|
||||
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/status/%d", statusID), fields)
|
||||
}
|
||||
|
||||
// InvoicePDFFile returns invoice PDF file metadata (id, title, viewUrl).
|
||||
// Without force, OnlyOffice may return a cached fileID with stale layout.
|
||||
func (c *Client) InvoicePDFFile(ctx context.Context, invoiceID string) (map[string]any, error) {
|
||||
id := strings.TrimSpace(invoiceID)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("InvoicePDFFile: invoice id is required")
|
||||
}
|
||||
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s/pdf", url.PathEscape(id)))
|
||||
}
|
||||
|
||||
// ForceRegenerateInvoicePDF clears the cached PDF (Draft touch) then requests a new file.
|
||||
// No-op touch when the invoice is not editable (e.g. Billed) — falls back to GET /pdf.
|
||||
func (c *Client) ForceRegenerateInvoicePDF(ctx context.Context, invoiceID string) (map[string]any, error) {
|
||||
id := strings.TrimSpace(invoiceID)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("ForceRegenerateInvoicePDF: invoice id is required")
|
||||
}
|
||||
inv, err := c.GetInvoice(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canEdit := true
|
||||
if v, ok := inv["canEdit"].(bool); ok {
|
||||
canEdit = v
|
||||
}
|
||||
if canEdit {
|
||||
desc := stringField(inv, "description")
|
||||
// Append/remove a trailing space so PUT clears fileID without visible change.
|
||||
touch := desc + " "
|
||||
if strings.HasSuffix(desc, " ") {
|
||||
touch = strings.TrimSuffix(desc, " ")
|
||||
}
|
||||
if _, err := c.UpdateInvoice(ctx, id, UpdateInvoiceParams{
|
||||
Description: touch,
|
||||
DescriptionSet: true,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("ForceRegenerateInvoicePDF: clear cache: %w", err)
|
||||
}
|
||||
}
|
||||
return c.InvoicePDFFile(ctx, id)
|
||||
}
|
||||
|
||||
// ListCRMContactFiles lists Documents attached on a CRM contact card (#files).
|
||||
func (c *Client) ListCRMContactFiles(ctx context.Context, contactID string) ([]map[string]any, error) {
|
||||
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/files.json", url.PathEscape(contactID)))
|
||||
}
|
||||
|
||||
// ListOpportunityFiles lists Documents attached on a CRM opportunity.
|
||||
func (c *Client) ListOpportunityFiles(ctx context.Context, opportunityID string) ([]map[string]any, error) {
|
||||
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/files.json", url.PathEscape(opportunityID)))
|
||||
}
|
||||
|
||||
// PurgeStaleInvoicePDFs deletes older P-*.pdf copies on the invoice contact (and linked
|
||||
// opportunity) while keeping the invoice's current fileID. Returns deleted file ids.
|
||||
func (c *Client) PurgeStaleInvoicePDFs(ctx context.Context, invoiceID string) ([]int, error) {
|
||||
inv, err := c.GetInvoice(ctx, invoiceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keep := flexInt(inv["fileID"])
|
||||
number := strings.TrimSpace(stringField(inv, "number"))
|
||||
base := number
|
||||
for strings.HasSuffix(base, "b") || strings.HasSuffix(base, "B") {
|
||||
base = base[:len(base)-1]
|
||||
}
|
||||
if base == "" {
|
||||
base = "P-"
|
||||
}
|
||||
|
||||
seen := map[int]struct{}{}
|
||||
var candidates []int
|
||||
addFiles := func(files []map[string]any) {
|
||||
for _, f := range files {
|
||||
title := stringField(f, "title")
|
||||
id := int(flexInt(f["id"]))
|
||||
if id == 0 || id == int(keep) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(title, "P-") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(title, base) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
candidates = append(candidates, id)
|
||||
}
|
||||
}
|
||||
|
||||
if m, ok := inv["contact"].(map[string]any); ok {
|
||||
cid := strconv.FormatInt(flexInt(m["id"]), 10)
|
||||
if cid != "0" {
|
||||
files, err := c.ListCRMContactFiles(ctx, cid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addFiles(files)
|
||||
}
|
||||
}
|
||||
if ent, ok := inv["entity"].(map[string]any); ok && ent != nil {
|
||||
if strings.EqualFold(fmt.Sprint(ent["entityType"]), "opportunity") || flexInt(ent["entityType"]) == 0 {
|
||||
oid := strconv.FormatInt(flexInt(ent["entityId"]), 10)
|
||||
if oid != "0" {
|
||||
files, err := c.ListOpportunityFiles(ctx, oid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addFiles(files)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := c.DeleteFiles(ctx, candidates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// CRM may still list deleted files briefly; also try CRM unlink.
|
||||
for _, fid := range candidates {
|
||||
_, _ = c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/files/%d.json", fid))
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// ListInvoiceItems returns catalog invoice items.
|
||||
func (c *Client) ListInvoiceItems(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
|
||||
q := url.Values{}
|
||||
|
||||
@@ -198,15 +198,6 @@ func (c *Client) AttachMailDocument(ctx context.Context, messageID string, fileI
|
||||
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/mail/messages/%s/document", url.PathEscape(id)), fields)
|
||||
}
|
||||
|
||||
// InvoicePDFFile regenerates/returns the invoice PDF file metadata (id, title, viewUrl).
|
||||
func (c *Client) InvoicePDFFile(ctx context.Context, invoiceID string) (map[string]any, error) {
|
||||
id := strings.TrimSpace(invoiceID)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("InvoicePDFFile: invoice id is required")
|
||||
}
|
||||
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s/pdf", url.PathEscape(id)))
|
||||
}
|
||||
|
||||
// PlainTextToMailHTML turns plain text into simple HTML paragraphs for drafts.
|
||||
// If s already looks like HTML, it is returned unchanged.
|
||||
func PlainTextToMailHTML(s string) string {
|
||||
|
||||
Reference in New Issue
Block a user