feat: upstream generic workspace tooling (board-sync, crm audit, catalog names) #76
@@ -549,6 +549,8 @@ type Task struct {
|
|||||||
| `SignJWT(secret, payload)` | HS256 JWT, stdlib only |
|
| `SignJWT(secret, payload)` | HS256 JWT, stdlib only |
|
||||||
| `ConvertDocument(ctx, docsBase, secret, req)` | OnlyOffice DocumentServer conversion (`/converter`; legacy `/ConvertService.ashx`) |
|
| `ConvertDocument(ctx, docsBase, secret, req)` | OnlyOffice DocumentServer conversion (`/converter`; legacy `/ConvertService.ashx`) |
|
||||||
| `DownloadURLTo(ctx, url, w)` | Stream an absolute URL into a writer |
|
| `DownloadURLTo(ctx, url, w)` | Stream an absolute URL into a writer |
|
||||||
|
| `SyncBoard(ctx, board, apply)` | Upsert project milestones/tasks from a YAML board (dry-run when apply=false) |
|
||||||
|
| `AuditOpportunities(ctx)` | Opportunities with file/task/member counts and a coarse class |
|
||||||
| `WorkbookSheetNames(data)` | Worksheet names of an XLS/XLSX/ODS workbook |
|
| `WorkbookSheetNames(data)` | Worksheet names of an XLS/XLSX/ODS workbook |
|
||||||
| `WorkbookSheetCSV(data, sheet, delim)` | One worksheet → CSV (sheet-aware, excelize) |
|
| `WorkbookSheetCSV(data, sheet, delim)` | One worksheet → CSV (sheet-aware, excelize) |
|
||||||
| `WorkbookSheetJSON(data, sheet)` | One worksheet → rows as objects (first row = header) |
|
| `WorkbookSheetJSON(data, sheet)` | One worksheet → rows as objects (first row = header) |
|
||||||
@@ -823,7 +825,7 @@ kontolink IN.xlsx oo-index.tsv OUT.xlsx [FILE_ID] [AMOUNTS_TSV]
|
|||||||
| Subject | Verbs |
|
| Subject | Verbs |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `calendar` | `list`, `events`, `add`, `delete` |
|
| `calendar` | `list`, `events`, `add`, `delete` |
|
||||||
| `projects` | `list`, `get`, `milestones`, `milestone-create`, `create`, `update`, `delete`, `contacts` (`add`, `remove`), `link-authors`, `link-git`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`, `dedupe`, `as-md`, `put-md`, `put-txt`, `put-xlsx`) |
|
| `projects` | `list`, `get`, `milestones`, `milestone-create`, `board-sync`, `create`, `update`, `delete`, `contacts` (`add`, `remove`), `link-authors`, `link-git`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`, `dedupe`, `as-md`, `put-md`, `put-txt`, `put-xlsx`) |
|
||||||
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
|
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
|
||||||
| `users` | `list`, `self` (alias: `oo whoami`) |
|
| `users` | `list`, `self` (alias: `oo whoami`) |
|
||||||
| `contacts` | `list`, `get`, `delete`, `info-add`, `merge`, `dedupe-info`, `tags`, `tag-add`, `tag-create`, `tag-remove` |
|
| `contacts` | `list`, `get`, `delete`, `info-add`, `merge`, `dedupe-info`, `tags`, `tag-add`, `tag-create`, `tag-remove` |
|
||||||
@@ -831,7 +833,7 @@ kontolink IN.xlsx oo-index.tsv OUT.xlsx [FILE_ID] [AMOUNTS_TSV]
|
|||||||
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
|
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
|
||||||
| `opportunities` | `list`, `get`, `create`, `update`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
|
| `opportunities` | `list`, `get`, `create`, `update`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
|
||||||
| `invoices` | `list`, `get`, `create`, `update`, `pdf`, `pdf-cleanup`, `status`, `delete`, `items …` |
|
| `invoices` | `list`, `get`, `create`, `update`, `pdf`, `pdf-cleanup`, `status`, `delete`, `items …` |
|
||||||
| `crm` | `cleanup` |
|
| `crm` | `audit`, `cleanup` |
|
||||||
| `mails` | `accounts`, `folders`, `list`, `get`, `download-attachment`, `draft`, `attach`, `draft-invoice`, `send`, `delete` |
|
| `mails` | `accounts`, `folders`, `list`, `get`, `download-attachment`, `draft`, `attach`, `draft-invoice`, `send`, `delete` |
|
||||||
| `cases` | `list`, `create`, `delete`, `member-add` |
|
| `cases` | `list`, `create`, `delete`, `member-add` |
|
||||||
| `crm-tasks` | `list`, `create`, `delete`, `categories`, `reassign-self` |
|
| `crm-tasks` | `list`, `create`, `delete`, `categories`, `reassign-self` |
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// Project board (Gantt) upsert from a YAML board file.
|
||||||
|
//
|
||||||
|
// A board describes projects, their milestones and tasks by exact title. Sync
|
||||||
|
// creates only what is missing: existing milestones/tasks (matched by title)
|
||||||
|
// are left untouched, so the file can be the source of truth for a project
|
||||||
|
// plan and re-applied safely. Dry-run (apply=false) reports counts without
|
||||||
|
// writing.
|
||||||
|
//
|
||||||
|
// The file format is deliberately small and presentation-free:
|
||||||
|
//
|
||||||
|
// projects:
|
||||||
|
// - id: 42
|
||||||
|
// name: "Example"
|
||||||
|
// milestones:
|
||||||
|
// - title: "Kickoff"
|
||||||
|
// deadline: "2026-01-15"
|
||||||
|
// key: true
|
||||||
|
// tasks:
|
||||||
|
// - title: "Draft"
|
||||||
|
// start: "2026-01-02"
|
||||||
|
// deadline: "2026-01-10"
|
||||||
|
// description: "…"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Board is a YAML mapping from a project plan onto OnlyOffice milestones/tasks.
|
||||||
|
type Board struct {
|
||||||
|
Projects []BoardProject `yaml:"projects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoardProject is one project with its milestones.
|
||||||
|
type BoardProject struct {
|
||||||
|
ID int `yaml:"id"`
|
||||||
|
Name string `yaml:"name,omitempty"`
|
||||||
|
Milestones []BoardMilestone `yaml:"milestones"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoardMilestone is a milestone ("key" marks it as a key milestone).
|
||||||
|
type BoardMilestone struct {
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
Deadline string `yaml:"deadline"`
|
||||||
|
Key bool `yaml:"key,omitempty"`
|
||||||
|
Tasks []BoardTask `yaml:"tasks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoardTask is a task inside a milestone. Start falls back to Deadline.
|
||||||
|
type BoardTask struct {
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
Start string `yaml:"start,omitempty"`
|
||||||
|
Deadline string `yaml:"deadline"`
|
||||||
|
Description string `yaml:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoardSyncResult counts what SyncBoard created or skipped.
|
||||||
|
type BoardSyncResult struct {
|
||||||
|
CreatedMilestones int
|
||||||
|
SkippedMilestones int
|
||||||
|
CreatedTasks int
|
||||||
|
SkippedTasks int
|
||||||
|
DryRun bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadBoard reads a board YAML file.
|
||||||
|
func LoadBoard(path string) (*Board, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ParseBoard(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBoard decodes a board from YAML bytes.
|
||||||
|
func ParseBoard(data []byte) (*Board, error) {
|
||||||
|
var board Board
|
||||||
|
if err := yaml.Unmarshal(data, &board); err != nil {
|
||||||
|
return nil, fmt.Errorf("board: %w", err)
|
||||||
|
}
|
||||||
|
if len(board.Projects) == 0 {
|
||||||
|
return nil, fmt.Errorf("board: no projects")
|
||||||
|
}
|
||||||
|
return &board, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boardDay(s string) (Time, error) {
|
||||||
|
t, err := time.Parse("2006-01-02", s)
|
||||||
|
if err != nil {
|
||||||
|
return Time{}, err
|
||||||
|
}
|
||||||
|
return Time(t), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boardMilestoneIDs(ms []*Milestone) map[string]int64 {
|
||||||
|
out := map[string]int64{}
|
||||||
|
for _, m := range ms {
|
||||||
|
if m == nil || m.Title == nil || m.ID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[*m.Title] = *m.ID
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func boardTaskTitles(rows []map[string]any) map[string]struct{} {
|
||||||
|
out := map[string]struct{}{}
|
||||||
|
for _, r := range rows {
|
||||||
|
if t, _ := r["title"].(string); t != "" {
|
||||||
|
out[t] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncBoard upserts milestones and tasks by exact title. With apply=false it
|
||||||
|
// only counts what would be created.
|
||||||
|
func (c *Client) SyncBoard(ctx context.Context, board *Board, apply bool) (*BoardSyncResult, error) {
|
||||||
|
if board == nil || len(board.Projects) == 0 {
|
||||||
|
return nil, fmt.Errorf("board: no projects")
|
||||||
|
}
|
||||||
|
res := &BoardSyncResult{DryRun: !apply}
|
||||||
|
for _, p := range board.Projects {
|
||||||
|
pid := p.ID
|
||||||
|
existing, err := c.GetProjectMilestones(&Project{ID: &pid})
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("project %d milestones: %w", pid, err)
|
||||||
|
}
|
||||||
|
haveMS := boardMilestoneIDs(existing)
|
||||||
|
tasks, err := c.ListTasks(ctx, strconv.Itoa(pid), "")
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("project %d tasks: %w", pid, err)
|
||||||
|
}
|
||||||
|
haveTask := boardTaskTitles(tasks)
|
||||||
|
|
||||||
|
for _, m := range p.Milestones {
|
||||||
|
msID, ok := haveMS[m.Title]
|
||||||
|
if !ok {
|
||||||
|
res.CreatedMilestones++
|
||||||
|
if apply {
|
||||||
|
dl, err := boardDay(m.Deadline)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("milestone %q deadline: %w", m.Title, err)
|
||||||
|
}
|
||||||
|
created, err := c.CreateMilestone(NewMilestoneRequest{
|
||||||
|
ProjectID: pid,
|
||||||
|
Title: m.Title,
|
||||||
|
Deadline: dl,
|
||||||
|
IsKey: m.Key,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("create milestone %q: %w", m.Title, err)
|
||||||
|
}
|
||||||
|
if created.ID != nil {
|
||||||
|
msID = *created.ID
|
||||||
|
}
|
||||||
|
haveMS[m.Title] = msID
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.SkippedMilestones++
|
||||||
|
}
|
||||||
|
for _, t := range m.Tasks {
|
||||||
|
if _, exists := haveTask[t.Title]; exists {
|
||||||
|
res.SkippedTasks++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.CreatedTasks++
|
||||||
|
if !apply {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := t.Start
|
||||||
|
if start == "" {
|
||||||
|
start = t.Deadline
|
||||||
|
}
|
||||||
|
st, err := boardDay(start)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("task %q start: %w", t.Title, err)
|
||||||
|
}
|
||||||
|
dl, err := boardDay(t.Deadline)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("task %q deadline: %w", t.Title, err)
|
||||||
|
}
|
||||||
|
if _, err := c.CreateProjectTask(NewProjectTaskRequest{
|
||||||
|
ProjectId: pid,
|
||||||
|
Title: t.Title,
|
||||||
|
Description: t.Description,
|
||||||
|
StartDate: st,
|
||||||
|
Deadline: dl,
|
||||||
|
MilestoneId: int(msID),
|
||||||
|
}); err != nil {
|
||||||
|
return res, fmt.Errorf("create task %q: %w", t.Title, err)
|
||||||
|
}
|
||||||
|
haveTask[t.Title] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseBoard(t *testing.T) {
|
||||||
|
b, err := ParseBoard([]byte(`
|
||||||
|
projects:
|
||||||
|
- id: 13
|
||||||
|
name: Example
|
||||||
|
milestones:
|
||||||
|
- title: "[lq] Test"
|
||||||
|
deadline: "2026-01-15"
|
||||||
|
key: true
|
||||||
|
tasks:
|
||||||
|
- title: Draft
|
||||||
|
start: "2026-01-02"
|
||||||
|
deadline: "2026-01-10"
|
||||||
|
description: "…"
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(b.Projects) != 1 || b.Projects[0].ID != 13 {
|
||||||
|
t.Fatalf("projects: %+v", b.Projects)
|
||||||
|
}
|
||||||
|
ms := b.Projects[0].Milestones[0]
|
||||||
|
if ms.Title != "[lq] Test" || !ms.Key || ms.Deadline != "2026-01-15" {
|
||||||
|
t.Fatalf("milestone: %+v", ms)
|
||||||
|
}
|
||||||
|
if len(ms.Tasks) != 1 || ms.Tasks[0].Title != "Draft" || ms.Tasks[0].Start != "2026-01-02" {
|
||||||
|
t.Fatalf("task: %+v", ms.Tasks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBoardEmpty(t *testing.T) {
|
||||||
|
if _, err := ParseBoard([]byte("projects: []")); err == nil {
|
||||||
|
t.Fatal("expected error for empty board")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardMilestoneIDs(t *testing.T) {
|
||||||
|
title := "[lq] Test"
|
||||||
|
id := int64(9)
|
||||||
|
got := boardMilestoneIDs([]*Milestone{{Title: &title, ID: &id}, nil})
|
||||||
|
if got[title] != 9 {
|
||||||
|
t.Fatalf("%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardTaskTitles(t *testing.T) {
|
||||||
|
got := boardTaskTitles([]map[string]any{{"title": "a"}, {"title": "b"}, {"nope": 1}})
|
||||||
|
if _, ok := got["a"]; !ok {
|
||||||
|
t.Fatal("missing a")
|
||||||
|
}
|
||||||
|
if _, ok := got["b"]; !ok {
|
||||||
|
t.Fatal("missing b")
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardDay(t *testing.T) {
|
||||||
|
if _, err := boardDay("2026-01-15"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := boardDay("15.01.2026"); err == nil {
|
||||||
|
t.Fatal("expected error for non-ISO date")
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-15
@@ -115,20 +115,13 @@ func applyCompany(ctx context.Context, client *onlyoffice.Client, e *Entry) (boo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyPerson(ctx context.Context, client *onlyoffice.Client, e *Entry) (bool, error) {
|
func applyPerson(ctx context.Context, client *onlyoffice.Client, e *Entry) (bool, error) {
|
||||||
first := strings.TrimSpace(e.First)
|
org := strings.TrimSpace(e.Org)
|
||||||
last := strings.TrimSpace(e.Last)
|
first, last := CleanPersonNames(e.First, e.Last, e.Name, org, e.Emails)
|
||||||
if first == "" && last == "" {
|
e.First, e.Last = first, last
|
||||||
first, last = SplitDisplayName(e.Name)
|
|
||||||
}
|
|
||||||
if first == "" {
|
|
||||||
first = strings.TrimSpace(e.Name)
|
|
||||||
}
|
|
||||||
if first == "" {
|
if first == "" {
|
||||||
return false, fmt.Errorf("person missing name")
|
return false, fmt.Errorf("person missing name")
|
||||||
}
|
}
|
||||||
if last == "" {
|
e.Name = strings.TrimSpace(first + " " + strings.Trim(last, "-"))
|
||||||
last = "-"
|
|
||||||
}
|
|
||||||
|
|
||||||
var p map[string]any
|
var p map[string]any
|
||||||
var err error
|
var err error
|
||||||
@@ -144,21 +137,27 @@ func applyPerson(ctx context.Context, client *onlyoffice.Client, e *Entry) (bool
|
|||||||
}
|
}
|
||||||
created := false
|
created := false
|
||||||
companyID := 0
|
companyID := 0
|
||||||
if e.Org != "" {
|
if org != "" {
|
||||||
if co, ferr := client.FindCompany(ctx, e.Org); ferr == nil && co != nil {
|
if co, ferr := client.FindCompany(ctx, org); ferr == nil && co != nil {
|
||||||
companyID, _ = strconv.Atoi(contactIDString(co))
|
companyID, _ = strconv.Atoi(contactIDString(co))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if p == nil {
|
if p == nil {
|
||||||
about := ""
|
about := ""
|
||||||
if e.Org != "" {
|
if org != "" {
|
||||||
about = "org: " + e.Org
|
about = "org: " + org
|
||||||
}
|
}
|
||||||
p, err = client.CreatePerson(ctx, first, last, companyID, "", about)
|
p, err = client.CreatePerson(ctx, first, last, companyID, "", about)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
created = true
|
created = true
|
||||||
|
} else {
|
||||||
|
// Repair names + ensure company link (never encode company in lastName).
|
||||||
|
id := contactIDString(p)
|
||||||
|
if _, err := client.UpdatePerson(ctx, id, first, last, companyID, "", ""); err != nil {
|
||||||
|
return false, fmt.Errorf("update person %s: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
id := contactIDString(p)
|
id := contactIDString(p)
|
||||||
e.OOID = id
|
e.OOID = id
|
||||||
|
|||||||
+9
-4
@@ -27,7 +27,7 @@ func MatchAgainstOO(ctx context.Context, client *onlyoffice.Client, doc *Documen
|
|||||||
byEmail[NormalizeEmail(em)] = c
|
byEmail[NormalizeEmail(em)] = c
|
||||||
}
|
}
|
||||||
if isCo {
|
if isCo {
|
||||||
key := NormalizeName(fmt.Sprint(c["displayName"]))
|
key := onlyoffice.CompanyGroupingKey(fmt.Sprint(c["displayName"]))
|
||||||
if key != "" {
|
if key != "" {
|
||||||
byCompanyName[key] = c
|
byCompanyName[key] = c
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ func MatchAgainstOO(ctx context.Context, client *onlyoffice.Client, doc *Documen
|
|||||||
}
|
}
|
||||||
if !matched {
|
if !matched {
|
||||||
if e.Kind == "company" {
|
if e.Kind == "company" {
|
||||||
if c, ok := byCompanyName[NormalizeName(e.Name)]; ok {
|
if c, ok := byCompanyName[onlyoffice.CompanyGroupingKey(e.Name)]; ok {
|
||||||
oo = c
|
oo = c
|
||||||
matched = true
|
matched = true
|
||||||
}
|
}
|
||||||
@@ -92,6 +92,12 @@ func MatchAgainstOO(ctx context.Context, client *onlyoffice.Client, doc *Documen
|
|||||||
e.OOID = contactIDString(oo)
|
e.OOID = contactIDString(oo)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Keep a previously applied oo_id (list payloads often omit emails, so
|
||||||
|
// email match can miss persons that already exist in CRM).
|
||||||
|
if strings.TrimSpace(e.OOID) != "" {
|
||||||
|
e.Status = "exists"
|
||||||
|
continue
|
||||||
|
}
|
||||||
e.Status = "new"
|
e.Status = "new"
|
||||||
e.OOID = ""
|
e.OOID = ""
|
||||||
}
|
}
|
||||||
@@ -120,8 +126,7 @@ func contactEmails(c map[string]any) []string {
|
|||||||
out = append(out, em)
|
out = append(out, em)
|
||||||
}
|
}
|
||||||
for _, row := range onlyoffice.ContactInfoRows(c) {
|
for _, row := range onlyoffice.ContactInfoRows(c) {
|
||||||
t := strings.ToLower(fmt.Sprint(row["infoType"]))
|
if onlyoffice.NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "email" {
|
||||||
if t != "email" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
data := strings.TrimSpace(fmt.Sprint(row["data"]))
|
data := strings.TrimSpace(fmt.Sprint(row["data"]))
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
parenSuffixRE = regexp.MustCompile(`(?i)\s*[\(\[\{][^)\]\}]*[\)\]\}]\s*$`)
|
||||||
|
dashCompanyRE = regexp.MustCompile(`(?i)\s+[-–—]\s+[A-Za-z0-9].*$`)
|
||||||
|
emailLocalRE = regexp.MustCompile(`(?i)^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
|
||||||
|
nonNameTokenRE = regexp.MustCompile(`[^a-zA-ZÀ-öø-ÿĀ-ž0-9'’.\-]+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// CleanPersonNames strips company annotations from display names and fills
|
||||||
|
// first/last from the email local-part when the source used an address as the
|
||||||
|
// name. Company affiliation belongs on Org / the CRM companyId — never in LastName.
|
||||||
|
func CleanPersonNames(first, last, display, org string, emails []string) (cleanFirst, cleanLast string) {
|
||||||
|
first = strings.TrimSpace(first)
|
||||||
|
last = strings.TrimSpace(last)
|
||||||
|
display = strings.TrimSpace(display)
|
||||||
|
org = strings.TrimSpace(org)
|
||||||
|
|
||||||
|
if looksLikeEmail(first) {
|
||||||
|
ef, el := GuessNameFromEmail(first)
|
||||||
|
first, last = ef, el
|
||||||
|
}
|
||||||
|
if looksLikeEmail(display) && first == "" && last == "" {
|
||||||
|
display = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if first == "" && last == "" && display != "" {
|
||||||
|
first, last = SplitDisplayName(display)
|
||||||
|
}
|
||||||
|
|
||||||
|
first = stripCompanyAnnotation(first, org)
|
||||||
|
last = stripCompanyAnnotation(last, org)
|
||||||
|
|
||||||
|
// "Smith - Acme" / "Jones (Acme)" landed in last.
|
||||||
|
last = stripCompanyAnnotation(last, org)
|
||||||
|
if i := strings.IndexAny(first, "(["); i > 0 {
|
||||||
|
first = strings.TrimSpace(first[:i])
|
||||||
|
}
|
||||||
|
// Entire last name is just the company (e.g. last="Acme").
|
||||||
|
if org != "" && personLastIsOrg(last, org) {
|
||||||
|
last = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first == "" || looksLikeEmail(first)) && len(emails) > 0 {
|
||||||
|
ef, el := GuessNameFromEmail(emails[0])
|
||||||
|
if first == "" || looksLikeEmail(first) {
|
||||||
|
first = ef
|
||||||
|
}
|
||||||
|
if last == "" || last == "-" {
|
||||||
|
last = el
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
first = strings.TrimSpace(first)
|
||||||
|
last = strings.TrimSpace(last)
|
||||||
|
if last == "" {
|
||||||
|
last = "-"
|
||||||
|
}
|
||||||
|
return first, last
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripCompanyAnnotation(s, org string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s = parenSuffixRE.ReplaceAllString(s, "")
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
s = dashCompanyRE.ReplaceAllString(s, "")
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if org != "" {
|
||||||
|
for _, sep := range []string{" - ", " – ", " — ", " / "} {
|
||||||
|
if i := strings.LastIndex(strings.ToLower(s), strings.ToLower(sep+org)); i >= 0 {
|
||||||
|
s = strings.TrimSpace(s[:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suf := " (" + org + ")"
|
||||||
|
if strings.HasSuffix(strings.ToLower(s), strings.ToLower(suf)) {
|
||||||
|
s = strings.TrimSpace(s[:len(s)-len(suf)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func personLastIsOrg(last, org string) bool {
|
||||||
|
last = NormalizeName(last)
|
||||||
|
org = NormalizeName(org)
|
||||||
|
if last == "" || org == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if last == org {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// "Acme" vs "Acme GmbH & Co. KG"
|
||||||
|
return strings.HasPrefix(org, last+" ") || strings.HasPrefix(org, last+",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeEmail(s string) bool {
|
||||||
|
return emailLocalRE.MatchString(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuessNameFromEmail turns local@domain into Title-Case first/last when the
|
||||||
|
// local part looks like first.last / first_last / first-last.
|
||||||
|
func GuessNameFromEmail(email string) (first, last string) {
|
||||||
|
email = NormalizeEmail(email)
|
||||||
|
local, _, ok := strings.Cut(email, "@")
|
||||||
|
if !ok || local == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
local = strings.Split(local, "+")[0]
|
||||||
|
parts := strings.FieldsFunc(local, func(r rune) bool {
|
||||||
|
return r == '.' || r == '_' || r == '-'
|
||||||
|
})
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return titleToken(local), ""
|
||||||
|
}
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return titleToken(parts[0]), ""
|
||||||
|
}
|
||||||
|
return titleToken(parts[0]), titleToken(strings.Join(parts[1:], " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleToken(s string) string {
|
||||||
|
s = nonNameTokenRE.ReplaceAllString(s, " ")
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(strings.ToLower(s))
|
||||||
|
runes[0] = unicode.ToTitle(runes[0])
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatProjectTitle builds "CC | Company | Title" (spaces around |).
|
||||||
|
// Country should be a short code (DE, TF, UA, …). Empty segments are dropped.
|
||||||
|
func FormatProjectTitle(country, company, title string) string {
|
||||||
|
parts := make([]string, 0, 3)
|
||||||
|
for _, p := range []string{country, company, title} {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
p = strings.ReplaceAll(p, "|", "/")
|
||||||
|
if p != "" {
|
||||||
|
parts = append(parts, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " | ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCleanPersonNamesStripsCompanyParen(t *testing.T) {
|
||||||
|
f, l := CleanPersonNames("John", "Smith (Acme)", "John Smith (Acme)", "Acme", nil)
|
||||||
|
if f != "John" || l != "Smith" {
|
||||||
|
t.Fatalf("got %q %q", f, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanPersonNamesStripsDashCompany(t *testing.T) {
|
||||||
|
f, l := CleanPersonNames("Jens", "Meyer - Acme", "", "Acme", nil)
|
||||||
|
if f != "Jens" || l != "Meyer" {
|
||||||
|
t.Fatalf("got %q %q", f, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanPersonNamesFromEmail(t *testing.T) {
|
||||||
|
f, l := CleanPersonNames("david.patzke@acme.example", "-", "", "Acme",
|
||||||
|
[]string{"david.patzke@acme.example"})
|
||||||
|
if f != "David" || l != "Patzke" {
|
||||||
|
t.Fatalf("got %q %q", f, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanPersonNamesLastIsCompany(t *testing.T) {
|
||||||
|
f, l := CleanPersonNames("Thorsten", "Acme", "", "Acme GmbH & Co. KG", nil)
|
||||||
|
if f != "Thorsten" || l != "-" {
|
||||||
|
t.Fatalf("got %q %q", f, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatProjectTitle(t *testing.T) {
|
||||||
|
if got := FormatProjectTitle("DE", "Acme", "Golang"); got != "DE | Acme | Golang" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
if got := FormatProjectTitle("", "Acme", ""); got != "Acme" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
var crmCmd = &cobra.Command{
|
var crmCmd = &cobra.Command{
|
||||||
Use: "crm",
|
Use: "crm",
|
||||||
Short: "CRM maintenance (dedupe, cleanup)",
|
Short: "CRM maintenance (audit, dedupe, cleanup)",
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
crmCmd.AddCommand(crmAuditCmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
func crmAuditCmd() *cobra.Command {
|
||||||
|
var outPath string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "audit",
|
||||||
|
Short: "Audit opportunities (files/tasks/members per deal) and classify",
|
||||||
|
Long: `Lists every opportunity with its file/task/member counts and a coarse class
|
||||||
|
(ok | dup | empty | junk-title). --out writes the full JSON audit for later use.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
audits, err := c.AuditOpportunities(cmd.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if outPath != "" {
|
||||||
|
b, merr := json.MarshalIndent(audits, "", " ")
|
||||||
|
if merr != nil {
|
||||||
|
return merr
|
||||||
|
}
|
||||||
|
if werr := os.WriteFile(outPath, b, 0o644); werr != nil {
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byClass := map[string]int{}
|
||||||
|
for _, a := range audits {
|
||||||
|
byClass[a.Class]++
|
||||||
|
}
|
||||||
|
classes := make([]string, 0, len(byClass))
|
||||||
|
for k := range byClass {
|
||||||
|
classes = append(classes, k)
|
||||||
|
}
|
||||||
|
sort.Strings(classes)
|
||||||
|
rows := make([]map[string]any, 0, len(classes))
|
||||||
|
for _, k := range classes {
|
||||||
|
rows = append(rows, map[string]any{"class": k, "count": byClass[k]})
|
||||||
|
}
|
||||||
|
printTable([]string{"class", "count"}, rows)
|
||||||
|
if outPath != "" {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "wrote %d audits → %s\n", len(audits), outPath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&outPath, "out", "", "write audit JSON to this file")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
+2
-2
@@ -3,7 +3,7 @@
|
|||||||
// Command tree is subject-based (mirrors the library split and the `tea` CLI):
|
// Command tree is subject-based (mirrors the library split and the `tea` CLI):
|
||||||
//
|
//
|
||||||
// oo calendar list | events | add | delete
|
// oo calendar list | events | add | delete
|
||||||
// oo projects list | get | milestones | milestone-create | milestone-delete | create | update | delete | contacts (add|remove) | team (list|add|remove|set) | link-authors | link-git | files (list|upload|replace-in|update|download|rename|delete|dedupe|as-md|put-md|put-txt|put-xlsx)
|
// oo projects list | get | milestones | milestone-create | milestone-delete | board-sync | create | update | delete | contacts (add|remove) | team (list|add|remove|set) | link-authors | link-git | files (list|upload|replace-in|update|download|rename|delete|dedupe|as-md|put-md|put-txt|put-xlsx)
|
||||||
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
|
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
|
||||||
// oo users list | self | get | create | update | delete | block | unblock | password | check (alias: oo whoami)
|
// oo users list | self | get | create | update | delete | block | unblock | password | check (alias: oo whoami)
|
||||||
// oo link FILE_ID [FILE_ID...] DocEditor deep links (Products/Files/DocEditor.aspx?fileid=…)
|
// oo link FILE_ID [FILE_ID...] DocEditor deep links (Products/Files/DocEditor.aspx?fileid=…)
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
// oo opportunities list | get | create | update | delete | stages | member-add | dedupe | dedupe-members | fix-titles
|
// oo opportunities list | get | create | update | delete | stages | member-add | dedupe | dedupe-members | fix-titles
|
||||||
// oo cases list | create | delete | member-add
|
// oo cases list | create | delete | member-add
|
||||||
// oo crm-tasks list | create | delete | categories | reassign-self
|
// oo crm-tasks list | create | delete | categories | reassign-self
|
||||||
// oo crm cleanup
|
// oo crm audit | cleanup
|
||||||
// oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | send | delete
|
// oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | send | delete
|
||||||
// oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items …
|
// oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items …
|
||||||
// oo docs tools | convert | pdf | presigned | csv | json | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx
|
// oo docs tools | convert | pdf | presigned | csv | json | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
projectsCmd.AddCommand(prjBoardSyncCmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
// prjBoardSyncCmd upserts project milestones/tasks from a board YAML.
|
||||||
|
func prjBoardSyncCmd() *cobra.Command {
|
||||||
|
var apply bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "board-sync BOARD.yaml",
|
||||||
|
Short: "Upsert project milestones/tasks from a board YAML (dry-run by default)",
|
||||||
|
Long: `Reads a board YAML (projects → milestones → tasks) and creates only the
|
||||||
|
milestones/tasks that are missing, matching by exact title. Existing entries are
|
||||||
|
left untouched, so the same file can be re-applied safely.
|
||||||
|
|
||||||
|
Dry-run by default; pass --apply to write to OnlyOffice.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
board, err := onlyoffice.LoadBoard(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := c.SyncBoard(cmd.Context(), board, apply)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mode := "dry-run"
|
||||||
|
if apply {
|
||||||
|
mode = "apply"
|
||||||
|
}
|
||||||
|
printObject(map[string]any{
|
||||||
|
"mode": mode,
|
||||||
|
"projects": len(board.Projects),
|
||||||
|
"created_milestones": res.CreatedMilestones,
|
||||||
|
"skipped_milestones": res.SkippedMilestones,
|
||||||
|
"created_tasks": res.CreatedTasks,
|
||||||
|
"skipped_tasks": res.SkippedTasks,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&apply, "apply", false, "write to OnlyOffice (default: dry-run)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAuditID(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in any
|
||||||
|
want int64
|
||||||
|
}{
|
||||||
|
{float64(12), 12},
|
||||||
|
{7, 7},
|
||||||
|
{int64(9), 9},
|
||||||
|
{"42", 42},
|
||||||
|
{nil, 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := auditID(c.in); got != c.want {
|
||||||
|
t.Fatalf("auditID(%v) = %d, want %d", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskCountsByOpportunity(t *testing.T) {
|
||||||
|
open, closed := taskCountsByOpportunity([]map[string]any{
|
||||||
|
{"id": 1, "status": 1, "entity": map[string]any{"entityType": "opportunity", "entityId": 10}},
|
||||||
|
{"id": 2, "status": "2", "entity": map[string]any{"entityType": "opportunity", "entityId": 10}},
|
||||||
|
{"id": 3, "status": 1, "entity": map[string]any{"entityType": "contact", "entityId": 10}},
|
||||||
|
{"id": 4, "entity": "not-a-map"},
|
||||||
|
})
|
||||||
|
if open["10"] != 1 || closed["10"] != 1 {
|
||||||
|
t.Fatalf("open=%v closed=%v", open, closed)
|
||||||
|
}
|
||||||
|
if len(open) != 1 || len(closed) != 1 {
|
||||||
|
t.Fatalf("unexpected buckets: open=%v closed=%v", open, closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user