feat(crm): add invoices CLI and opportunity update
Expose CRM invoice list/get/create/delete and catalog items via oo, plus contact address/about helpers and opportunity bid/stage update. Needed for Medex VM billing and other draft invoices.
This commit is contained in:
+2
-2
@@ -11,7 +11,7 @@ func TestRootRegistersSubjects(t *testing.T) {
|
|||||||
want := []string{
|
want := []string{
|
||||||
"calendar", "projects", "tasks", "users", "whoami",
|
"calendar", "projects", "tasks", "users", "whoami",
|
||||||
"contacts", "persons", "companies",
|
"contacts", "persons", "companies",
|
||||||
"opportunities", "cases", "crm-tasks", "applications", "crm", "mails", "catalog",
|
"opportunities", "cases", "crm-tasks", "applications", "crm", "mails", "catalog", "invoices",
|
||||||
}
|
}
|
||||||
got := make(map[string]bool, len(rootCmd.Commands()))
|
got := make(map[string]bool, len(rootCmd.Commands()))
|
||||||
for _, c := range rootCmd.Commands() {
|
for _, c := range rootCmd.Commands() {
|
||||||
@@ -38,7 +38,7 @@ func TestRootHelpListsSubjects(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
help := out.String()
|
help := out.String()
|
||||||
for _, snippet := range []string{"calendar", "projects", "tasks", "users", "opportunities"} {
|
for _, snippet := range []string{"calendar", "projects", "tasks", "users", "opportunities", "invoices"} {
|
||||||
if !strings.Contains(help, snippet) {
|
if !strings.Contains(help, snippet) {
|
||||||
t.Fatalf("help missing %q", snippet)
|
t.Fatalf("help missing %q", snippet)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ var outputFormat = "table"
|
|||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "oo",
|
Use: "oo",
|
||||||
Short: "OnlyOffice Workspace CLI — subject-based command tree",
|
Short: "OnlyOffice Workspace CLI — subject-based command tree",
|
||||||
Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, applications, catalog).",
|
Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, applications, catalog, invoices).",
|
||||||
Version: version,
|
Version: version,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: false,
|
SilenceErrors: false,
|
||||||
|
|||||||
+35
-2
@@ -201,7 +201,7 @@ func contactsInfoAddCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func personsCreateCmd() *cobra.Command {
|
func personsCreateCmd() *cobra.Command {
|
||||||
var first, last, email, linkedin string
|
var first, last, email, linkedin, phone string
|
||||||
var companyID int
|
var companyID int
|
||||||
var jobTitle, about string
|
var jobTitle, about string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -224,6 +224,9 @@ func personsCreateCmd() *cobra.Command {
|
|||||||
if email != "" {
|
if email != "" {
|
||||||
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
|
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
|
||||||
}
|
}
|
||||||
|
if phone != "" {
|
||||||
|
_, _ = c.AddContactInfo(cmd.Context(), pid, "Phone", phone, "Work", true)
|
||||||
|
}
|
||||||
if linkedin != "" {
|
if linkedin != "" {
|
||||||
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false)
|
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false)
|
||||||
}
|
}
|
||||||
@@ -237,6 +240,7 @@ func personsCreateCmd() *cobra.Command {
|
|||||||
cmd.Flags().StringVar(&jobTitle, "job-title", "", "")
|
cmd.Flags().StringVar(&jobTitle, "job-title", "", "")
|
||||||
cmd.Flags().StringVar(&about, "about", "", "about / bio")
|
cmd.Flags().StringVar(&about, "about", "", "about / bio")
|
||||||
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
|
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
|
||||||
|
cmd.Flags().StringVar(&phone, "phone", "", "primary phone (adds ContactInfo)")
|
||||||
cmd.Flags().StringVar(&linkedin, "linkedin", "", "linkedin url (adds ContactInfo)")
|
cmd.Flags().StringVar(&linkedin, "linkedin", "", "linkedin url (adds ContactInfo)")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -347,7 +351,7 @@ func contactEmailsFromMap(p map[string]any) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func companiesCreateCmd() *cobra.Command {
|
func companiesCreateCmd() *cobra.Command {
|
||||||
var name, email, website string
|
var name, email, website, phone, about, street, city, state, zip, country string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "create",
|
Use: "create",
|
||||||
Aliases: []string{"add"},
|
Aliases: []string{"add"},
|
||||||
@@ -365,12 +369,34 @@ func companiesCreateCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cid := strconv.Itoa(int(flexIDFloat(out["id"])))
|
cid := strconv.Itoa(int(flexIDFloat(out["id"])))
|
||||||
|
if about != "" || street != "" {
|
||||||
|
aboutText := about
|
||||||
|
if street != "" {
|
||||||
|
addr := strings.TrimSpace(strings.Join([]string{street, zip, city, state, country}, ", "))
|
||||||
|
if aboutText != "" {
|
||||||
|
aboutText = aboutText + "\n" + addr
|
||||||
|
} else {
|
||||||
|
aboutText = "Billing address: " + addr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if updated, err := c.UpdateCompany(cmd.Context(), cid, name, aboutText); err == nil {
|
||||||
|
out = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
if email != "" {
|
if email != "" {
|
||||||
_, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true)
|
_, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true)
|
||||||
}
|
}
|
||||||
if website != "" {
|
if website != "" {
|
||||||
_, _ = c.AddContactInfo(cmd.Context(), cid, "Website", website, "Work", false)
|
_, _ = c.AddContactInfo(cmd.Context(), cid, "Website", website, "Work", false)
|
||||||
}
|
}
|
||||||
|
if phone != "" {
|
||||||
|
_, _ = c.AddContactInfo(cmd.Context(), cid, "Phone", phone, "Work", true)
|
||||||
|
}
|
||||||
|
if street != "" {
|
||||||
|
if _, err := c.AddContactAddress(cmd.Context(), cid, street, city, state, zip, country, "Billing", true); err != nil {
|
||||||
|
fmt.Fprintf(cmd.ErrOrStderr(), "warning: address API failed (stored in about): %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
printObject(out)
|
printObject(out)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -378,6 +404,13 @@ func companiesCreateCmd() *cobra.Command {
|
|||||||
cmd.Flags().StringVar(&name, "name", "", "company name")
|
cmd.Flags().StringVar(&name, "name", "", "company name")
|
||||||
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
|
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
|
||||||
cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)")
|
cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)")
|
||||||
|
cmd.Flags().StringVar(&phone, "phone", "", "primary phone (adds ContactInfo)")
|
||||||
|
cmd.Flags().StringVar(&about, "about", "", "about / notes")
|
||||||
|
cmd.Flags().StringVar(&street, "street", "", "billing street")
|
||||||
|
cmd.Flags().StringVar(&city, "city", "", "billing city")
|
||||||
|
cmd.Flags().StringVar(&state, "state", "", "billing state")
|
||||||
|
cmd.Flags().StringVar(&zip, "zip", "", "billing zip")
|
||||||
|
cmd.Flags().StringVar(&country, "country", "", "billing country")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var invoicesCmd = &cobra.Command{
|
||||||
|
Use: "invoices",
|
||||||
|
Aliases: []string{"invoice"},
|
||||||
|
Short: "CRM invoices (billing)",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(invoicesCmd)
|
||||||
|
invoicesCmd.AddCommand(invoiceListCmd())
|
||||||
|
invoicesCmd.AddCommand(invoiceGetCmd())
|
||||||
|
invoicesCmd.AddCommand(invoiceCreateCmd())
|
||||||
|
invoicesCmd.AddCommand(invoiceDeleteCmd())
|
||||||
|
invoicesCmd.AddCommand(invoiceItemsCmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceListCmd() *cobra.Command {
|
||||||
|
var count, offset int
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List CRM invoices",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
list, total, err := c.ListInvoices(cmd.Context(), count, offset)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([]map[string]any, 0, len(list))
|
||||||
|
for _, inv := range list {
|
||||||
|
rows = append(rows, onlyoffice.FlattenInvoiceRow(inv))
|
||||||
|
}
|
||||||
|
if outputFormat == "table" {
|
||||||
|
fmt.Printf("total: %d (shown: %d)\n", total, len(rows))
|
||||||
|
}
|
||||||
|
printTable([]string{"id", "number", "cost", "statusTitle", "contactName", "issueDate", "dueDate"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().IntVar(&count, "count", 50, "")
|
||||||
|
cmd.Flags().IntVar(&offset, "offset", 0, "")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceGetCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "get INVOICE_ID",
|
||||||
|
Short: "Show an invoice by id (incl. lines)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := c.GetInvoice(cmd.Context(), args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(out)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceCreateCmd() *cobra.Command {
|
||||||
|
var (
|
||||||
|
number, issueDate, dueDate, language, currency, terms, description, po string
|
||||||
|
contactID, itemID int64
|
||||||
|
price, qty float64
|
||||||
|
lineDesc string
|
||||||
|
)
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "create",
|
||||||
|
Short: "Create a draft invoice with one line",
|
||||||
|
Long: `Create a CRM invoice (Draft) with a single line.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
oo invoices create --number INV-2026-01 --contact 123 --item 10 \
|
||||||
|
--price 300 --description "Service package" \
|
||||||
|
--terms "…payment terms…"
|
||||||
|
`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if number == "" || contactID == 0 || itemID == 0 {
|
||||||
|
return fmt.Errorf("--number, --contact and --item are required")
|
||||||
|
}
|
||||||
|
if issueDate == "" {
|
||||||
|
issueDate = time.Now().Format("2006-01-02") + "T00:00:00.0000000+01:00"
|
||||||
|
} else if !strings.Contains(issueDate, "T") {
|
||||||
|
issueDate = issueDate + "T00:00:00.0000000+01:00"
|
||||||
|
}
|
||||||
|
if dueDate == "" {
|
||||||
|
dueDate = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02") + "T00:00:00.0000000+01:00"
|
||||||
|
} else if !strings.Contains(dueDate, "T") {
|
||||||
|
dueDate = dueDate + "T00:00:00.0000000+01:00"
|
||||||
|
}
|
||||||
|
if qty == 0 {
|
||||||
|
qty = 1
|
||||||
|
}
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := c.CreateInvoice(cmd.Context(), onlyoffice.CreateInvoiceParams{
|
||||||
|
Number: number,
|
||||||
|
IssueDate: issueDate,
|
||||||
|
DueDate: dueDate,
|
||||||
|
ContactID: contactID,
|
||||||
|
Language: language,
|
||||||
|
Currency: currency,
|
||||||
|
ExchangeRate: 1,
|
||||||
|
PurchaseOrderNumber: po,
|
||||||
|
Terms: terms,
|
||||||
|
Description: description,
|
||||||
|
Lines: []onlyoffice.InvoiceLine{{
|
||||||
|
InvoiceItemID: itemID,
|
||||||
|
Description: lineDesc,
|
||||||
|
Quantity: qty,
|
||||||
|
Price: price,
|
||||||
|
SortOrder: 0,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(out)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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(&itemID, "item", 0, "catalog invoice item id")
|
||||||
|
cmd.Flags().Float64Var(&price, "price", 0, "line price")
|
||||||
|
cmd.Flags().Float64Var(&qty, "qty", 1, "line quantity")
|
||||||
|
cmd.Flags().StringVar(&lineDesc, "line-description", "", "line description override")
|
||||||
|
cmd.Flags().StringVar(&issueDate, "issue-date", "", "YYYY-MM-DD (default today)")
|
||||||
|
cmd.Flags().StringVar(&dueDate, "due-date", "", "YYYY-MM-DD (default +14d)")
|
||||||
|
cmd.Flags().StringVar(&language, "language", "de-DE", "invoice language")
|
||||||
|
cmd.Flags().StringVar(¤cy, "currency", "EUR", "currency abbreviation")
|
||||||
|
cmd.Flags().StringVar(&terms, "terms", "", "payment terms / footer")
|
||||||
|
cmd.Flags().StringVar(&description, "description", "", "invoice description")
|
||||||
|
cmd.Flags().StringVar(&po, "po", "", "purchase order number")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceDeleteCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "delete INVOICE_ID [INVOICE_ID...]",
|
||||||
|
Short: "Delete one or more invoices",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range args {
|
||||||
|
if _, err := c.DeleteInvoice(cmd.Context(), id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("deleted", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceItemsCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "items",
|
||||||
|
Aliases: []string{"item"},
|
||||||
|
Short: "Invoice catalog items",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(invoiceItemsListCmd())
|
||||||
|
cmd.AddCommand(invoiceItemsCreateCmd())
|
||||||
|
cmd.AddCommand(invoiceItemsDeleteCmd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceItemsListCmd() *cobra.Command {
|
||||||
|
var count, offset int
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List catalog invoice items",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
list, total, err := c.ListInvoiceItems(cmd.Context(), count, offset)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if outputFormat == "table" {
|
||||||
|
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
|
||||||
|
for _, row := range list {
|
||||||
|
if cur, ok := row["currency"].(map[string]any); ok {
|
||||||
|
row["currency"] = cur["abbreviation"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printTable([]string{"id", "title", "price", "currency", "description"}, list)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().IntVar(&count, "count", 50, "")
|
||||||
|
cmd.Flags().IntVar(&offset, "offset", 0, "")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceItemsCreateCmd() *cobra.Command {
|
||||||
|
var title, description, currency string
|
||||||
|
var price float64
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "create",
|
||||||
|
Short: "Create a catalog invoice item",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if title == "" {
|
||||||
|
return fmt.Errorf("--title is required")
|
||||||
|
}
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := c.CreateInvoiceItem(cmd.Context(), title, description, price, currency)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(out)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&title, "title", "", "item title")
|
||||||
|
cmd.Flags().StringVar(&description, "description", "", "item description")
|
||||||
|
cmd.Flags().Float64Var(&price, "price", 0, "unit price")
|
||||||
|
cmd.Flags().StringVar(¤cy, "currency", "EUR", "currency")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceItemsDeleteCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "delete ITEM_ID [ITEM_ID...]",
|
||||||
|
Short: "Delete catalog invoice items",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range args {
|
||||||
|
if _, err := c.DeleteInvoiceItem(cmd.Context(), id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("deleted", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
// oo crm-tasks list | create | delete | categories
|
// oo crm-tasks list | create | delete | categories
|
||||||
// oo crm cleanup
|
// oo crm cleanup
|
||||||
// oo mails accounts | folders | list | get | delete
|
// oo mails accounts | folders | list | get | delete
|
||||||
|
// oo invoices list | get | create | delete | items (list|create|delete)
|
||||||
// oo applications sync
|
// oo applications sync
|
||||||
// oo catalog scan-contacts | scan-projects | scan-thunderbird | merge | match | apply
|
// oo catalog scan-contacts | scan-projects | scan-thunderbird | merge | match | apply
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func init() {
|
|||||||
opportunitiesCmd.AddCommand(oppListCmd())
|
opportunitiesCmd.AddCommand(oppListCmd())
|
||||||
opportunitiesCmd.AddCommand(oppGetCmd())
|
opportunitiesCmd.AddCommand(oppGetCmd())
|
||||||
opportunitiesCmd.AddCommand(oppCreateCmd())
|
opportunitiesCmd.AddCommand(oppCreateCmd())
|
||||||
|
opportunitiesCmd.AddCommand(oppUpdateCmd())
|
||||||
opportunitiesCmd.AddCommand(oppDeleteCmd())
|
opportunitiesCmd.AddCommand(oppDeleteCmd())
|
||||||
opportunitiesCmd.AddCommand(oppStagesCmd())
|
opportunitiesCmd.AddCommand(oppStagesCmd())
|
||||||
opportunitiesCmd.AddCommand(oppMemberAddCmd())
|
opportunitiesCmd.AddCommand(oppMemberAddCmd())
|
||||||
@@ -118,6 +119,40 @@ func oppCreateCmd() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func oppUpdateCmd() *cobra.Command {
|
||||||
|
var title, desc string
|
||||||
|
var stage int64
|
||||||
|
var bid float64
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "update OPPORTUNITY_ID",
|
||||||
|
Short: "Update opportunity title, description, stage, or bid",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := c.UpdateOpportunity(cmd.Context(), args[0], onlyoffice.UpdateOpportunityParams{
|
||||||
|
Title: title,
|
||||||
|
Description: desc,
|
||||||
|
StageID: stage,
|
||||||
|
BidValue: bid,
|
||||||
|
BidValueSet: cmd.Flags().Changed("bid"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(out)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&title, "title", "", "new title")
|
||||||
|
cmd.Flags().StringVar(&desc, "description", "", "new description")
|
||||||
|
cmd.Flags().Int64Var(&stage, "stage", 0, "pipeline stage id")
|
||||||
|
cmd.Flags().Float64Var(&bid, "bid", 0, "bid value")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
func oppDeleteCmd() *cobra.Command {
|
func oppDeleteCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "delete OPPORTUNITY_ID [OPPORTUNITY_ID...]",
|
Use: "delete OPPORTUNITY_ID [OPPORTUNITY_ID...]",
|
||||||
|
|||||||
@@ -244,6 +244,58 @@ func (c *Client) putJSONObject(ctx context.Context, path string, body any) (map[
|
|||||||
return unmarshalResponseObject(raw)
|
return unmarshalResponseObject(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postJSONObject issues an authenticated POST with JSON body and decodes response.
|
||||||
|
func (c *Client) postJSONObject(ctx context.Context, path string, body any) (map[string]any, error) {
|
||||||
|
raw, err := c.postJSON(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return unmarshalResponseObject(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// postJSON issues an authenticated POST with application/json body.
|
||||||
|
func (c *Client) postJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
||||||
|
auth, err := c.authHeader()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rdr io.Reader
|
||||||
|
switch b := body.(type) {
|
||||||
|
case nil:
|
||||||
|
rdr = strings.NewReader("{}")
|
||||||
|
case []byte:
|
||||||
|
rdr = bytes.NewReader(b)
|
||||||
|
case string:
|
||||||
|
rdr = strings.NewReader(b)
|
||||||
|
default:
|
||||||
|
buf, err := json.Marshal(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rdr = bytes.NewReader(buf)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+path, rdr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", auth)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("POST JSON %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
// putJSON issues an authenticated PUT with application/json body.
|
// putJSON issues an authenticated PUT with application/json body.
|
||||||
func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
||||||
auth, err := c.authHeader()
|
auth, err := c.authHeader()
|
||||||
|
|||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// CRM Invoices and invoice catalog items (billing).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InvoiceLine is one line on a create-invoice request.
|
||||||
|
type InvoiceLine struct {
|
||||||
|
InvoiceItemID int64 `json:"invoiceItemID"`
|
||||||
|
InvoiceTax1ID int64 `json:"invoiceTax1ID"`
|
||||||
|
InvoiceTax2ID int64 `json:"invoiceTax2ID"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Discount float64 `json:"discount"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvoiceParams holds fields for POST /api/2.0/crm/invoice.
|
||||||
|
type CreateInvoiceParams struct {
|
||||||
|
Number string
|
||||||
|
IssueDate string // ISO-8601
|
||||||
|
DueDate string // ISO-8601
|
||||||
|
ContactID int64
|
||||||
|
ConsigneeID int64 // 0 = omit
|
||||||
|
Language string
|
||||||
|
Currency string
|
||||||
|
ExchangeRate float64
|
||||||
|
PurchaseOrderNumber string
|
||||||
|
Terms string
|
||||||
|
Description string
|
||||||
|
TemplateType int
|
||||||
|
Lines []InvoiceLine
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInvoices returns a page of CRM invoices and the total count.
|
||||||
|
func (c *Client) ListInvoices(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("count", strconv.Itoa(count))
|
||||||
|
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||||
|
raw, err := c.getJSON(ctx, "/api/2.0/crm/invoice/filter.json?"+q.Encode())
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Response []map[string]any `json:"response"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
total := env.Total
|
||||||
|
if total == 0 && len(env.Response) > 0 {
|
||||||
|
total = len(env.Response)
|
||||||
|
}
|
||||||
|
return env.Response, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInvoice returns a single invoice by id (includes invoiceLines).
|
||||||
|
func (c *Client) GetInvoice(ctx context.Context, id string) (map[string]any, error) {
|
||||||
|
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s.json", url.PathEscape(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvoice creates a draft invoice with the given lines.
|
||||||
|
func (c *Client) CreateInvoice(ctx context.Context, p CreateInvoiceParams) (map[string]any, error) {
|
||||||
|
if p.ContactID == 0 {
|
||||||
|
return nil, fmt.Errorf("contactId is required")
|
||||||
|
}
|
||||||
|
if p.Number == "" {
|
||||||
|
return nil, fmt.Errorf("number is required")
|
||||||
|
}
|
||||||
|
if len(p.Lines) == 0 {
|
||||||
|
return nil, fmt.Errorf("at least one invoice line is required")
|
||||||
|
}
|
||||||
|
if p.Language == "" {
|
||||||
|
p.Language = "de-DE"
|
||||||
|
}
|
||||||
|
if p.Currency == "" {
|
||||||
|
p.Currency = "EUR"
|
||||||
|
}
|
||||||
|
if p.ExchangeRate == 0 {
|
||||||
|
p.ExchangeRate = 1
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"number": p.Number,
|
||||||
|
"issueDate": p.IssueDate,
|
||||||
|
"dueDate": p.DueDate,
|
||||||
|
"contactId": p.ContactID,
|
||||||
|
"language": p.Language,
|
||||||
|
"currency": p.Currency,
|
||||||
|
"exchangeRate": p.ExchangeRate,
|
||||||
|
"purchaseOrderNumber": p.PurchaseOrderNumber,
|
||||||
|
"terms": p.Terms,
|
||||||
|
"description": p.Description,
|
||||||
|
"templateType": p.TemplateType,
|
||||||
|
"invoiceLines": p.Lines,
|
||||||
|
}
|
||||||
|
if p.ConsigneeID != 0 {
|
||||||
|
body["consigneeId"] = p.ConsigneeID
|
||||||
|
}
|
||||||
|
return c.postJSONObject(ctx, "/api/2.0/crm/invoice", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteInvoice removes an invoice by id.
|
||||||
|
func (c *Client) DeleteInvoice(ctx context.Context, id string) (map[string]any, error) {
|
||||||
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s.json", url.PathEscape(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInvoiceItems returns catalog invoice items.
|
||||||
|
func (c *Client) ListInvoiceItems(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("count", strconv.Itoa(count))
|
||||||
|
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||||
|
raw, err := c.getJSON(ctx, "/api/2.0/crm/invoiceitem/filter.json?"+q.Encode())
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Response []map[string]any `json:"response"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
total := env.Total
|
||||||
|
if total == 0 && len(env.Response) > 0 {
|
||||||
|
total = len(env.Response)
|
||||||
|
}
|
||||||
|
return env.Response, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvoiceItem creates a reusable catalog line item.
|
||||||
|
func (c *Client) CreateInvoiceItem(ctx context.Context, title, description string, price float64, currency string) (map[string]any, error) {
|
||||||
|
if title == "" {
|
||||||
|
return nil, fmt.Errorf("title is required")
|
||||||
|
}
|
||||||
|
if currency == "" {
|
||||||
|
currency = "EUR"
|
||||||
|
}
|
||||||
|
fields := url.Values{}
|
||||||
|
fields.Set("title", title)
|
||||||
|
fields.Set("description", description)
|
||||||
|
fields.Set("price", strconv.FormatFloat(price, 'f', 2, 64))
|
||||||
|
fields.Set("currency", currency)
|
||||||
|
fields.Set("stockKeepingUnit", "")
|
||||||
|
fields.Set("trackInventory", "false")
|
||||||
|
fields.Set("quantity", "0")
|
||||||
|
fields.Set("invoiceTax1ID", "0")
|
||||||
|
fields.Set("invoiceTax2ID", "0")
|
||||||
|
return c.postFormObject(ctx, "/api/2.0/crm/invoiceitem", fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteInvoiceItem removes a catalog invoice item.
|
||||||
|
func (c *Client) DeleteInvoiceItem(ctx context.Context, id string) (map[string]any, error) {
|
||||||
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/invoiceitem/%s.json", url.PathEscape(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddContactAddress attaches a postal address to a contact.
|
||||||
|
// category: Home|Postal|Office|Billing|Other|Work (or numeric string).
|
||||||
|
func (c *Client) AddContactAddress(ctx context.Context, contactID, street, city, state, zip, country, category string, isPrimary bool) (map[string]any, error) {
|
||||||
|
if category == "" {
|
||||||
|
category = "Billing"
|
||||||
|
}
|
||||||
|
fields := url.Values{}
|
||||||
|
fields.Set("street", street)
|
||||||
|
fields.Set("city", city)
|
||||||
|
fields.Set("state", state)
|
||||||
|
fields.Set("zip", zip)
|
||||||
|
fields.Set("country", country)
|
||||||
|
fields.Set("category", category)
|
||||||
|
fields.Set("isPrimary", strconv.FormatBool(isPrimary))
|
||||||
|
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/address", url.PathEscape(contactID)), fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCompany updates company name and optional about text.
|
||||||
|
func (c *Client) UpdateCompany(ctx context.Context, companyID, name, about string) (map[string]any, error) {
|
||||||
|
fields := url.Values{}
|
||||||
|
fields.Set("companyName", name)
|
||||||
|
if about != "" {
|
||||||
|
fields.Set("about", about)
|
||||||
|
}
|
||||||
|
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/company/%s.json", url.PathEscape(companyID)), fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOpportunityParams are optional fields for UpdateOpportunity.
|
||||||
|
// Zero/empty values mean "keep existing" except BidValueSet.
|
||||||
|
type UpdateOpportunityParams struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
StageID int64
|
||||||
|
BidValue float64
|
||||||
|
BidValueSet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOpportunity loads a deal and PUTs updated fields.
|
||||||
|
func (c *Client) UpdateOpportunity(ctx context.Context, id string, p UpdateOpportunityParams) (map[string]any, error) {
|
||||||
|
opp, err := c.GetOpportunity(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
title := p.Title
|
||||||
|
if title == "" {
|
||||||
|
title = stringField(opp, "title")
|
||||||
|
}
|
||||||
|
body := opportunityUpdateBody(opp, title)
|
||||||
|
if p.Description != "" {
|
||||||
|
body["description"] = p.Description
|
||||||
|
}
|
||||||
|
if p.StageID != 0 {
|
||||||
|
body["stageid"] = p.StageID
|
||||||
|
}
|
||||||
|
if p.BidValueSet {
|
||||||
|
body["bidValue"] = p.BidValue
|
||||||
|
}
|
||||||
|
return c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoiceContactName extracts displayName from nested contact on an invoice row.
|
||||||
|
func InvoiceContactName(inv map[string]any) string {
|
||||||
|
if m, ok := inv["contact"].(map[string]any); ok {
|
||||||
|
return stringField(m, "displayName")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoiceStatusTitle extracts status.title from an invoice row.
|
||||||
|
func InvoiceStatusTitle(inv map[string]any) string {
|
||||||
|
if m, ok := inv["status"].(map[string]any); ok {
|
||||||
|
return stringField(m, "title")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlattenInvoiceRow copies nested fields for table output.
|
||||||
|
func FlattenInvoiceRow(inv map[string]any) map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
for k, v := range inv {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
out["contactName"] = InvoiceContactName(inv)
|
||||||
|
out["statusTitle"] = InvoiceStatusTitle(inv)
|
||||||
|
if cur, ok := inv["currency"].(map[string]any); ok {
|
||||||
|
out["currency"] = stringField(cur, "abbreviation")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindInvoiceItemByTitle returns the first catalog item with an exact title match.
|
||||||
|
func FindInvoiceItemByTitle(items []map[string]any, title string) map[string]any {
|
||||||
|
want := strings.TrimSpace(title)
|
||||||
|
for _, it := range items {
|
||||||
|
if strings.TrimSpace(stringField(it, "title")) == want {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user