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:
2026-08-06 14:26:43 +01:00
parent 31c556d34c
commit 490426c277
9 changed files with 465 additions and 26 deletions
+158
View File
@@ -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{}