Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 5s
Tests / Test (Go 1.25) (pull_request) Successful in 1m16s
Tests / Test (Go stable) (pull_request) Successful in 1m36s
Promote the generic, reusable parts of the private oo-workspace into the public library/CLI, so oo-workspace can shrink to business glue. - board.go: Board types + (c *Client) SyncBoard — upsert project milestones/tasks by exact title from a YAML board (dry-run when apply=false). CLI: oo projects board-sync. - crm_audit.go: OpportunityAudit + (c *Client) AuditOpportunities — file/task/member counts with a generic class (ok|dup|empty|junk-title). CLI: oo crm audit [--out]. - catalog/names.go: CleanPersonNames / GuessNameFromEmail / FormatProjectTitle (ported from oo-workspace). - catalog: adopt the newer apply/match logic — name repair (never encode company in lastName), company grouping key, contact-info type normalization, preserve an already-applied oo_id. Keeps the local Address/OOProjects fields and the config-driven classifier. Business-only oo-workspace commands (crm clean/migrate, folders, search) deliberately stay private.
127 lines
3.2 KiB
Go
127 lines
3.2 KiB
Go
package onlyoffice
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// OpportunityAudit is one CRM opportunity with its resource counts and a coarse
|
|
// class, for hygiene reporting (see `oo crm audit`).
|
|
type OpportunityAudit struct {
|
|
ID int64 `json:"id"`
|
|
Title string `json:"title"`
|
|
Created string `json:"created,omitempty"`
|
|
Files int `json:"files"`
|
|
OpenTasks int `json:"open_tasks"`
|
|
ClosedTasks int `json:"closed_tasks"`
|
|
Members int `json:"members"`
|
|
GroupKey string `json:"group_key,omitempty"`
|
|
Class string `json:"class"` // ok | dup | empty | junk-title
|
|
}
|
|
|
|
// AuditOpportunities lists every opportunity with file/task/member counts and a
|
|
// coarse class. The classification is generic and rule-free:
|
|
//
|
|
// dup — another opportunity shares the same title key
|
|
// empty — no files, tasks or members
|
|
// junk-title — title is not of the "Role @ Company" shape
|
|
// ok — everything else
|
|
//
|
|
// Callers that need stricter business rules can post-process the result.
|
|
func (c *Client) AuditOpportunities(ctx context.Context) ([]OpportunityAudit, error) {
|
|
deals, err := c.ListAllOpportunities(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tasks, _, err := c.ListCRMTasks(ctx, 5000, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
open, closed := taskCountsByOpportunity(tasks)
|
|
|
|
out := make([]OpportunityAudit, 0, len(deals))
|
|
for _, row := range deals {
|
|
id := auditID(row["id"])
|
|
if id == 0 {
|
|
continue
|
|
}
|
|
title := auditStr(row["title"])
|
|
files := 0
|
|
if fl, ferr := c.ListOpportunityFiles(ctx, strconv.FormatInt(id, 10)); ferr == nil {
|
|
files = len(fl)
|
|
}
|
|
key := strconv.FormatInt(id, 10)
|
|
out = append(out, OpportunityAudit{
|
|
ID: id,
|
|
Title: title,
|
|
Created: auditStr(row["created"]),
|
|
Files: files,
|
|
OpenTasks: open[key],
|
|
ClosedTasks: closed[key],
|
|
Members: len(OpportunityMembers(row)),
|
|
GroupKey: DealTitleKey(title, false),
|
|
})
|
|
}
|
|
|
|
groupCount := map[string]int{}
|
|
for _, a := range out {
|
|
groupCount[a.GroupKey]++
|
|
}
|
|
for i := range out {
|
|
a := &out[i]
|
|
switch {
|
|
case groupCount[a.GroupKey] > 1:
|
|
a.Class = "dup"
|
|
case a.Files == 0 && a.OpenTasks == 0 && a.ClosedTasks == 0 && a.Members == 0:
|
|
a.Class = "empty"
|
|
case !strings.Contains(a.Title, "@") || strings.HasPrefix(strings.TrimSpace(a.Title), "@"):
|
|
a.Class = "junk-title"
|
|
default:
|
|
a.Class = "ok"
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// taskCountsByOpportunity buckets CRM task statuses per opportunity id.
|
|
func taskCountsByOpportunity(tasks []map[string]any) (open, closed map[string]int) {
|
|
open, closed = map[string]int{}, map[string]int{}
|
|
for _, t := range tasks {
|
|
ent, ok := t["entity"].(map[string]any)
|
|
if !ok || auditStr(ent["entityType"]) != "opportunity" {
|
|
continue
|
|
}
|
|
eid := auditStr(ent["entityId"])
|
|
status := strings.ToLower(auditStr(t["status"]))
|
|
if status == "2" || status == "closed" {
|
|
closed[eid]++
|
|
} else {
|
|
open[eid]++
|
|
}
|
|
}
|
|
return open, closed
|
|
}
|
|
|
|
func auditID(v any) int64 {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return int64(x)
|
|
case int:
|
|
return int64(x)
|
|
case int64:
|
|
return x
|
|
default:
|
|
n, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(x)), 10, 64)
|
|
return n
|
|
}
|
|
}
|
|
|
|
func auditStr(v any) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprint(v)
|
|
}
|