feat: 初始化shop-crm-agent项目,实现基础CLI与CRM操作能力

本提交初始化完整的shop-crm-agent项目:
1. 创建基础项目结构与配置文件(.gitignore、go.mod/go.sum)
2. 添加项目许可协议与文档(README.md、AGENTS.md、SKILL.md)
3. 实现核心CLI入口与命令框架
4. 封装Protobuf API客户端与完整的命令实现
5. 添加单元测试覆盖核心功能与边界情况
6. 提供构建脚本与版本管理能力
This commit is contained in:
2026-08-01 01:36:37 +08:00
commit 3466e19d13
15 changed files with 8554 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
/bin/
.env
.env.*
*.log
.DS_Store
.idea/
.vscode/
*.swp
*.tmp
+37
View File
@@ -0,0 +1,37 @@
# AGENTS.md
本仓库是 `shop-crm-agent` 的唯一源码仓库。默认使用中文沟通。
## 项目边界
- 客户端只通过 `/api/shop/crm-agent/v1` Protobuf HTTP API 工作,不直接访问数据库、Redis、LightCore 配置或后端运行容器。
- Go module 固定为 `code.zhecent.com/open/shop-crm-agent`,不得引入 LightCore backend module、Gin、GORM、数据库驱动或 Redis 客户端。
- `internal/api/shop_crm_agent.pb.go` 是生成代码,不得手工编辑。
- Protobuf schema 的唯一真相位于同级 `../LightCore/backend/application/shop/interfaces/crm_agent_proto/proto/shop_crm_agent.proto`
- 协议变更必须从 `../LightCore/backend` 执行 `bash scripts/proto_gen_shop_crm_agent.sh`,同时检查两个仓库的生成结果和测试。
## CLI 契约
- 所有业务命令输出 Protobuf JSON,不输出表格或混合说明文本。
- 业务失败先输出服务端结构化响应再返回非零退出码;运输或解析失败不得伪造业务响应。
- 写命令必须使用稳定且唯一的 `--request-id`;同一操作重试时复用原 request ID 和原参数。
- 报价编辑固定使用 `begin -> command -> preview -> commit`,放弃时使用 `discard`
- 项目和报价删除必须先执行对应 `delete-preview`,不得绕过 revision 与 `changeSetId`
- CLI 只读取 `LIGHTCORE_API_BASE_URL``LIGHTCORE_SHOP_CRM_AGENT_TOKEN`;缺失时明确失败,不回退 localhost。
- Token 不得写入源码、文件、日志或输出;测试只能使用明显虚构的占位值。
## 开发流程
1. 先读取相邻实现和 `SKILL.md`,确认命令边界与安全流程。
2. 只修改职责明确的 command/client 代码,不在客户端复制服务端业务规则。
3. 生成代码只通过 LightCore 的协议生成脚本更新。
4. 执行 `go test ./...``go build ./...`
5. 执行 `go run ./scripts/build.go`,确认 `bin/shop-crm-agent[.exe] --version` 可运行。
6.`rg` 检查 Token、Secret、内部绝对路径和意外的后端依赖。
## Git 与更新
- `main` 必须始终可测试、可构建;禁止 force push。
- 更新只使用 `git pull --ff-only`
- 工作区有本地修改时不得自动覆盖、重置或清理。
- 本仓库不提交编译产物,不提供二进制自更新或预编译 Release。
+12
View File
@@ -0,0 +1,12 @@
Copyright (c) 2026 Zhecent. All rights reserved.
This source code is made publicly readable for review and for authorized
Zhecent employees to use solely for Zhecent's internal business purposes.
No permission is granted to any other person or organization to copy, modify,
distribute, sublicense, sell, or otherwise use this source code without prior
written permission from Zhecent.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
+75
View File
@@ -0,0 +1,75 @@
# shop-crm-agent
`shop-crm-agent` 是通过正式 Protobuf HTTP API 操作 Shop CRM 项目和独立报价的受控命令行客户端。源码允许匿名读取,但使用业务 API 仍需要管理员签发的 CRM Agent 凭据。
## 环境要求
- Git
- Go 1.26 或更高版本
- 可访问目标 LightCore API 的网络环境
- 运行环境提供 `LIGHTCORE_API_BASE_URL``LIGHTCORE_SHOP_CRM_AGENT_TOKEN`
仓库在开发工作区中固定与 `LightCore` 同级。例如:
```text
<workspace>/
├── LightCore/
└── shop-crm-agent/
```
普通构建不依赖 `LightCore` 仓库;只有修改 Protobuf 契约时才需要同级的 LightCore 源码。
## 首次安装
```bash
git clone https://code.zhecent.com/open/shop-crm-agent.git
cd shop-crm-agent
go run ./scripts/build.go
./bin/shop-crm-agent --version
```
Windows 构建产物为 `bin/shop-crm-agent.exe`。构建脚本执行测试后,只编译当前操作系统和 CPU 架构,不安装系统服务,也不修改全局 PATH。
运行业务命令前,由当前 Shell 或 Agent 的受控运行环境提供配置:
```bash
export LIGHTCORE_API_BASE_URL=https://api.example.com
```
`LIGHTCORE_SHOP_CRM_AGENT_TOKEN` 必须通过密码管理器、受控 Secret 注入或其他不会回显和记录明文的机制提供。不要在交互式命令中直接输入真实 Token,也不得把它写入仓库、Skill、`.env`、命令历史、普通日志或聊天内容。
## 更新
收到内部更新通知后,让 Agent 在仓库根目录执行:
```bash
git status --short
git pull --ff-only
```
确认拉取成功后,Agent 必须重新读取最新的 `SKILL.md`,再执行:
```bash
go run ./scripts/build.go
./bin/shop-crm-agent --version
```
如果工作区存在本地修改,必须先停止更新并确认修改来源,不得强制重置。只有重新构建和版本探针都通过后才可继续执行 CRM 操作。
## 开发验证
```bash
go test ./...
go build ./...
go run ./scripts/build.go
```
`internal/api/shop_crm_agent.pb.go` 是生成代码,不得手工编辑。协议源文件位于同级 LightCore 仓库,生成入口为:
```bash
(cd ../LightCore/backend && bash scripts/proto_gen_shop_crm_agent.sh)
```
## 许可
本仓库保留全部权利,仅授权 Zhecent 员工用于公司内部业务。详见 `LICENSE`
+50
View File
@@ -0,0 +1,50 @@
---
name: lc-shop-crm-agent
description: 安装、更新并安全使用 shop-crm-agent 查询和维护 Shop CRM 项目与独立报价。
---
# lc-shop-crm-agent - Shop CRM Agent 操作流程
## 目的
指导 AI Agent 从公开源码构建 `shop-crm-agent`,并通过正式 Protobuf API 安全操作 CRM 项目和独立报价。
## 安装与更新
1. 仓库固定 clone 为工作区中的 `shop-crm-agent` 目录;与 LightCore 联合开发时二者必须同级。
2. 缺少仓库时,从 `https://code.zhecent.com/open/shop-crm-agent.git` 匿名 clone,不使用来源不明的压缩包或二进制。
3. 检查 `go version` 满足 `go.mod`;缺少 Go 时安装官方 Go 工具链,再继续构建。
4. 首次安装在仓库根目录执行 `go run ./scripts/build.go`,使用 `bin/shop-crm-agent`Windows 使用 `bin/shop-crm-agent.exe`
5. 收到更新通知后先确认 `git status --short` 为空,再执行 `git pull --ff-only`;拉取后重新读取本文件并重新构建。
6. 本地存在修改、拉取失败、测试失败、构建失败或版本探针失败时停止,不得强制重置或继续使用半完成产物。
## 运行配置
- 确认 `LIGHTCORE_API_BASE_URL``LIGHTCORE_SHOP_CRM_AGENT_TOKEN` 已由运行环境提供;不得显示、记录或提交 Token。
- 凭据只通过 Shop 管理后台创建和撤销;CLI 不管理凭据。
- 业务命令使用仓库 `bin/` 下的当前构建产物,精确参数以 `<binary> <subcommand> --help` 为准。
## 操作流程
1. 创建项目前用 `customer-list` 获取真实客户 ID;其他操作先查询真实项目、报价、SKU、层级和产品行 ID。
2. 每个写命令使用稳定且唯一的 `--request-id`;重试同一操作时复用原 ID 和完全相同的参数。
3. 报价内容修改严格执行 `begin -> command -> preview -> commit`;不提交时执行 `discard`
4. 删除先执行 `delete-preview`,只使用其返回的 revision 和 `changeSetId``allowed=false` 时停止。
5. 读取 JSON 响应的 `header.code``header.replayed`、revision、`changeSetId``affectedLineIds`,不得只凭进程退出码推断业务状态。
## 阻塞检查点
- 需求超出项目主线和独立报价时停止;不得转向客户维护、合同、设计画板、跟进、资金、文件、采购或产品库维护。
- 产品库行只提交真实 `sku-id`,不得尝试修改产品库成本;自定义行仅通过明确 flag 修改自身成本。
- 非空层级删除只有用户意图明确时才使用 `--cascade`
- 项目非空、报价锁定、revision 变化、changeSet 不一致或回执状态未知时不得强行继续。
- 请求取消、超时或结果不确定时先查询资源状态,不得盲目生成新 request ID 重复写入。
## 验证
```bash
go test ./...
go build ./...
go run ./scripts/build.go
./bin/shop-crm-agent --version
```
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"fmt"
"os"
"code.zhecent.com/open/shop-crm-agent/internal/command"
)
var version = "dev"
func main() {
if err := command.New(version).Execute(); err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+13
View File
@@ -0,0 +1,13 @@
module code.zhecent.com/open/shop-crm-agent
go 1.26
require (
github.com/spf13/cobra v1.10.2
google.golang.org/protobuf v1.36.11
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
+14
View File
@@ -0,0 +1,14 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
File diff suppressed because it is too large Load Diff
+166
View File
@@ -0,0 +1,166 @@
package client
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
api "code.zhecent.com/open/shop-crm-agent/internal/api"
"google.golang.org/protobuf/proto"
)
const (
apiPath = "/api/shop/crm-agent/v1"
maxResponseSize = 16 << 20
)
type Client struct {
baseURL string
token string
httpClient *http.Client
}
func New(baseURL, token string, httpClient *http.Client) (*Client, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, errors.New("LIGHTCORE_API_BASE_URL 必须是有效的 http/https URL")
}
if strings.TrimSpace(token) == "" {
return nil, errors.New("LIGHTCORE_SHOP_CRM_AGENT_TOKEN 不能为空")
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
return &Client{baseURL: baseURL, token: strings.TrimSpace(token), httpClient: httpClient}, nil
}
type responseWithHeader interface {
proto.Message
GetHeader() *api.ResponseHeader
}
func doRequest[Req proto.Message, Resp responseWithHeader](ctx context.Context, client *Client, path string, request Req, response Resp) (Resp, error) {
data, err := proto.Marshal(request)
if err != nil {
return response, fmt.Errorf("编码 Protobuf 请求失败: %w", err)
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, client.baseURL+apiPath+path, bytes.NewReader(data))
if err != nil {
return response, err
}
httpRequest.Header.Set("Content-Type", "application/x-protobuf")
httpRequest.Header.Set("Accept", "application/x-protobuf")
httpRequest.Header.Set("Authorization", "Bearer "+client.token)
httpResponse, err := client.httpClient.Do(httpRequest)
if err != nil {
return response, err
}
defer httpResponse.Body.Close()
body, err := io.ReadAll(io.LimitReader(httpResponse.Body, maxResponseSize+1))
if err != nil {
return response, err
}
if len(body) > maxResponseSize {
return response, errors.New("CRM Agent API 响应超过16 MiB限制")
}
if err := proto.Unmarshal(body, response); err != nil {
return response, fmt.Errorf("解析 Protobuf 响应失败(HTTP %d: %w", httpResponse.StatusCode, err)
}
header := response.GetHeader()
if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 {
if header != nil && header.Message != "" {
return response, fmt.Errorf("CRM Agent API HTTP %d: %s", httpResponse.StatusCode, header.Message)
}
return response, fmt.Errorf("CRM Agent API HTTP %d", httpResponse.StatusCode)
}
if header == nil {
return response, errors.New("CRM Agent API 响应缺少 ResponseHeader")
}
if header.Code != 200 {
return response, fmt.Errorf("CRM Agent API 错误 %d: %s", header.Code, header.Message)
}
return response, nil
}
func (c *Client) CustomerList(ctx context.Context, req *api.CustomerListRequest) (*api.CustomerListResponse, error) {
return doRequest(ctx, c, "/customers/list", req, &api.CustomerListResponse{})
}
func (c *Client) ProjectList(ctx context.Context, req *api.ProjectListRequest) (*api.ProjectListResponse, error) {
return doRequest(ctx, c, "/projects/list", req, &api.ProjectListResponse{})
}
func (c *Client) ProjectGet(ctx context.Context, req *api.ProjectGetRequest) (*api.ProjectResponse, error) {
return doRequest(ctx, c, "/projects/get", req, &api.ProjectResponse{})
}
func (c *Client) ProjectCreate(ctx context.Context, req *api.ProjectCreateRequest) (*api.ProjectResponse, error) {
return doRequest(ctx, c, "/projects/create", req, &api.ProjectResponse{})
}
func (c *Client) ProjectUpdate(ctx context.Context, req *api.ProjectUpdateRequest) (*api.ProjectResponse, error) {
return doRequest(ctx, c, "/projects/update", req, &api.ProjectResponse{})
}
func (c *Client) ProjectStatus(ctx context.Context, req *api.ProjectStatusRequest) (*api.ProjectResponse, error) {
return doRequest(ctx, c, "/projects/status", req, &api.ProjectResponse{})
}
func (c *Client) ProjectDeletePreview(ctx context.Context, req *api.ProjectDeletePreviewRequest) (*api.DeletePreviewResponse, error) {
return doRequest(ctx, c, "/projects/delete/preview", req, &api.DeletePreviewResponse{})
}
func (c *Client) ProjectDelete(ctx context.Context, req *api.ProjectDeleteRequest) (*api.DeletePreviewResponse, error) {
return doRequest(ctx, c, "/projects/delete", req, &api.DeletePreviewResponse{})
}
func (c *Client) QuotationList(ctx context.Context, req *api.QuotationListRequest) (*api.QuotationListResponse, error) {
return doRequest(ctx, c, "/quotations/list", req, &api.QuotationListResponse{})
}
func (c *Client) QuotationGet(ctx context.Context, req *api.QuotationGetRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotations/get", req, &api.QuotationResponse{})
}
func (c *Client) QuotationCreate(ctx context.Context, req *api.QuotationCreateRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotations/create", req, &api.QuotationResponse{})
}
func (c *Client) QuotationCopy(ctx context.Context, req *api.QuotationCopyRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotations/copy", req, &api.QuotationResponse{})
}
func (c *Client) QuotationTitle(ctx context.Context, req *api.QuotationTitleRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotations/title", req, &api.QuotationResponse{})
}
func (c *Client) QuotationStatus(ctx context.Context, req *api.QuotationStatusRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotations/status", req, &api.QuotationResponse{})
}
func (c *Client) QuotationLock(ctx context.Context, req *api.QuotationLockRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotations/lock", req, &api.QuotationResponse{})
}
func (c *Client) QuotationUnlock(ctx context.Context, req *api.QuotationLockRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotations/unlock", req, &api.QuotationResponse{})
}
func (c *Client) QuotationDeletePreview(ctx context.Context, req *api.QuotationDeletePreviewRequest) (*api.DeletePreviewResponse, error) {
return doRequest(ctx, c, "/quotations/delete/preview", req, &api.DeletePreviewResponse{})
}
func (c *Client) QuotationDelete(ctx context.Context, req *api.QuotationDeleteRequest) (*api.DeletePreviewResponse, error) {
return doRequest(ctx, c, "/quotations/delete", req, &api.DeletePreviewResponse{})
}
func (c *Client) CatalogSearch(ctx context.Context, req *api.CatalogSearchRequest) (*api.CatalogSearchResponse, error) {
return doRequest(ctx, c, "/catalog/search", req, &api.CatalogSearchResponse{})
}
func (c *Client) EditBegin(ctx context.Context, req *api.QuotationEditBeginRequest) (*api.EditSessionResponse, error) {
return doRequest(ctx, c, "/quotation/edit/begin", req, &api.EditSessionResponse{})
}
func (c *Client) EditCommand(ctx context.Context, req *api.QuotationEditCommandRequest) (*api.QuotationEditCommandResponse, error) {
return doRequest(ctx, c, "/quotation/edit/command", req, &api.QuotationEditCommandResponse{})
}
func (c *Client) EditPreview(ctx context.Context, req *api.QuotationEditPreviewRequest) (*api.QuotationEditPreviewResponse, error) {
return doRequest(ctx, c, "/quotation/edit/preview", req, &api.QuotationEditPreviewResponse{})
}
func (c *Client) EditCommit(ctx context.Context, req *api.QuotationEditCommitRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotation/edit/commit", req, &api.QuotationResponse{})
}
func (c *Client) EditDiscard(ctx context.Context, req *api.QuotationEditDiscardRequest) (*api.EditSessionResponse, error) {
return doRequest(ctx, c, "/quotation/edit/discard", req, &api.EditSessionResponse{})
}
+156
View File
@@ -0,0 +1,156 @@
package client
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
api "code.zhecent.com/open/shop-crm-agent/internal/api"
"google.golang.org/protobuf/proto"
)
func TestClientSendsBearerProtobufAndChecksBusinessHeader(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != apiPath+"/projects/get" {
t.Errorf("path = %s", request.URL.Path)
}
if request.Header.Get("Authorization") != "Bearer test-token" {
t.Errorf("authorization = %q", request.Header.Get("Authorization"))
}
if request.Header.Get("Content-Type") != "application/x-protobuf" {
t.Errorf("content type = %q", request.Header.Get("Content-Type"))
}
data, _ := proto.Marshal(&api.ProjectResponse{Header: &api.ResponseHeader{Code: 200, Message: "success"}, Project: &api.Project{Id: 9}})
writer.Header().Set("Content-Type", "application/x-protobuf")
_, _ = writer.Write(data)
}))
defer server.Close()
client, err := New(server.URL, "test-token", server.Client())
if err != nil {
t.Fatalf("new client: %v", err)
}
response, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{ProjectId: 9})
if err != nil {
t.Fatalf("project get: %v", err)
}
if response.Project.GetId() != 9 {
t.Fatalf("project id = %d", response.Project.GetId())
}
}
func TestClientReportsHTTPProtocolAndSizeFailures(t *testing.T) {
t.Run("http error preserves protobuf header", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusUnauthorized)
data, _ := proto.Marshal(&api.ProjectResponse{Header: &api.ResponseHeader{Code: 401, Message: "unauthorized"}})
_, _ = writer.Write(data)
}))
defer server.Close()
client, _ := New(server.URL, "token", server.Client())
response, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{})
if err == nil || !strings.Contains(err.Error(), "HTTP 401: unauthorized") {
t.Fatalf("error = %v", err)
}
if response.GetHeader().GetCode() != 401 {
t.Fatalf("header = %#v", response.GetHeader())
}
})
t.Run("malformed protobuf", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write([]byte{0xff})
}))
defer server.Close()
client, _ := New(server.URL, "token", server.Client())
if _, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{}); err == nil || !strings.Contains(err.Error(), "解析 Protobuf 响应失败") {
t.Fatalf("error = %v", err)
}
})
t.Run("response size limit", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write(make([]byte, maxResponseSize+1))
}))
defer server.Close()
client, _ := New(server.URL, "token", server.Client())
if _, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{}); err == nil || !strings.Contains(err.Error(), "16 MiB") {
t.Fatalf("error = %v", err)
}
})
}
func TestClientHonorsHTTPClientTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
time.Sleep(50 * time.Millisecond)
}))
defer server.Close()
httpClient := server.Client()
httpClient.Timeout = time.Millisecond
client, _ := New(server.URL, "token", httpClient)
_, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{})
if err == nil || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("error = %v", err)
}
}
func TestClientRepresentativeOperationRoutes(t *testing.T) {
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
paths = append(paths, request.URL.Path)
var response proto.Message
switch request.URL.Path {
case apiPath + "/projects/create":
response = &api.ProjectResponse{Header: &api.ResponseHeader{Code: 200}}
case apiPath + "/quotations/delete/preview":
response = &api.DeletePreviewResponse{Header: &api.ResponseHeader{Code: 200}}
case apiPath + "/quotation/edit/command":
response = &api.QuotationEditCommandResponse{Header: &api.ResponseHeader{Code: 200}}
default:
t.Fatalf("unexpected path %q", request.URL.Path)
}
data, _ := proto.Marshal(response)
_, _ = writer.Write(data)
}))
defer server.Close()
client, _ := New(server.URL, "token", server.Client())
if _, err := client.ProjectCreate(context.Background(), &api.ProjectCreateRequest{}); err != nil {
t.Fatal(err)
}
if _, err := client.QuotationDeletePreview(context.Background(), &api.QuotationDeletePreviewRequest{}); err != nil {
t.Fatal(err)
}
if _, err := client.EditCommand(context.Background(), &api.QuotationEditCommandRequest{}); err != nil {
t.Fatal(err)
}
want := []string{apiPath + "/projects/create", apiPath + "/quotations/delete/preview", apiPath + "/quotation/edit/command"}
if strings.Join(paths, "|") != strings.Join(want, "|") {
t.Fatalf("paths = %v", paths)
}
}
func TestClientRejectsMissingConfigurationAndBusinessError(t *testing.T) {
if _, err := New("", "token", nil); err == nil {
t.Fatal("empty base URL accepted")
}
if _, err := New("https://example.com", "", nil); err == nil {
t.Fatal("empty token accepted")
}
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
data, _ := proto.Marshal(&api.CommonResponse{Header: &api.ResponseHeader{Code: 400, Message: "bad request", RequestId: "request-123", Replayed: true}})
_, _ = writer.Write(data)
}))
defer server.Close()
client, _ := New(server.URL, "token", server.Client())
response, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{})
if err == nil {
t.Fatal("business error was accepted")
}
if response.GetHeader() == nil || !response.GetHeader().Replayed || response.GetHeader().RequestId != "request-123" {
t.Fatalf("business error response = %#v", response.GetHeader())
}
}
+455
View File
@@ -0,0 +1,455 @@
package command
import (
"fmt"
"io"
"os"
"strings"
dto "code.zhecent.com/open/shop-crm-agent/internal/api"
crmClient "code.zhecent.com/open/shop-crm-agent/internal/client"
"github.com/spf13/cobra"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
type crmAgentWriteOptions struct{ requestID string }
func New(version string) *cobra.Command {
cmd := &cobra.Command{
Use: "shop-crm-agent",
Short: "通过 Protobuf API 操作 Shop CRM",
Version: version,
Args: cobra.NoArgs,
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
cmd.AddCommand(newCrmAgentCustomerListCmd(), newCrmAgentProjectCmd(), newCrmAgentQuotationCmd(), newCrmAgentCatalogCmd(), newCrmAgentEditCmd())
return cmd
}
func newCrmAgentHTTPClient() (*crmClient.Client, error) {
return crmClient.New(os.Getenv("LIGHTCORE_API_BASE_URL"), os.Getenv("LIGHTCORE_SHOP_CRM_AGENT_TOKEN"), nil)
}
func writeCrmAgentProto(writer io.Writer, message proto.Message) error {
data, err := (protojson.MarshalOptions{Indent: " ", EmitUnpopulated: true}).Marshal(message)
if err != nil {
return err
}
_, err = fmt.Fprintln(writer, string(data))
return err
}
type crmAgentHeaderResponse interface {
proto.Message
GetHeader() *dto.ResponseHeader
}
func writeCrmAgentResult(writer io.Writer, response crmAgentHeaderResponse, commandErr error) error {
if response != nil && response.GetHeader() != nil {
if err := writeCrmAgentProto(writer, response); err != nil {
return err
}
}
return commandErr
}
func printCrmAgentResult(response crmAgentHeaderResponse, commandErr error) error {
return writeCrmAgentResult(os.Stdout, response, commandErr)
}
func bindCrmAgentWrite(cmd *cobra.Command, options *crmAgentWriteOptions) {
cmd.Flags().StringVar(&options.requestID, "request-id", "", "8-80位幂等请求 ID")
_ = cmd.MarkFlagRequired("request-id")
}
func crmAgentCommandHeader(options crmAgentWriteOptions) *dto.CommandHeader {
return &dto.CommandHeader{RequestId: strings.TrimSpace(options.requestID)}
}
func newCrmAgentCustomerListCmd() *cobra.Command {
var keyword string
var page, pageSize int32
cmd := &cobra.Command{Use: "customer-list", Short: "查询创建项目可引用的 CRM 客户", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.CustomerList(cmd.Context(), &dto.CustomerListRequest{Keyword: keyword, Page: page, PageSize: pageSize})
return printCrmAgentResult(response, err)
}}
cmd.Flags().StringVar(&keyword, "keyword", "", "客户姓名或手机号")
cmd.Flags().Int32Var(&page, "page", 1, "页码")
cmd.Flags().Int32Var(&pageSize, "page-size", 20, "每页数量")
return cmd
}
func newCrmAgentProjectCmd() *cobra.Command {
cmd := &cobra.Command{Use: "project", Short: "查询和维护 CRM 项目", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }}
cmd.AddCommand(newCrmAgentProjectListCmd(), newCrmAgentProjectGetCmd(), newCrmAgentProjectCreateCmd(), newCrmAgentProjectUpdateCmd(), newCrmAgentProjectStatusCmd(), newCrmAgentProjectDeletePreviewCmd(), newCrmAgentProjectDeleteCmd())
return cmd
}
func newCrmAgentProjectListCmd() *cobra.Command {
var keyword, status, houseType string
var page, pageSize int32
cmd := &cobra.Command{Use: "list", Short: "分页查询项目", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.ProjectList(cmd.Context(), &dto.ProjectListRequest{Keyword: keyword, Status: status, HouseType: houseType, Page: page, PageSize: pageSize})
return printCrmAgentResult(response, err)
}}
cmd.Flags().StringVar(&keyword, "keyword", "", "项目关键词")
cmd.Flags().StringVar(&status, "status", "", "项目状态")
cmd.Flags().StringVar(&houseType, "house-type", "", "房屋类型")
cmd.Flags().Int32Var(&page, "page", 1, "页码")
cmd.Flags().Int32Var(&pageSize, "page-size", 20, "每页数量")
return cmd
}
func newCrmAgentProjectGetCmd() *cobra.Command {
var projectID uint64
cmd := &cobra.Command{Use: "get", Short: "读取项目详情", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.ProjectGet(cmd.Context(), &dto.ProjectGetRequest{ProjectId: projectID})
return printCrmAgentResult(response, err)
}}
cmd.Flags().Uint64Var(&projectID, "project-id", 0, "项目 ID")
_ = cmd.MarkFlagRequired("project-id")
return cmd
}
type crmAgentProjectFields struct {
name string
customerID uint64
houseType, province, provinceCode, city, cityCode, district, districtCode, address, remark string
}
func bindCrmAgentProjectFields(cmd *cobra.Command, fields *crmAgentProjectFields, includeCustomer, requireCore bool) {
cmd.Flags().StringVar(&fields.name, "name", "", "项目名称")
cmd.Flags().StringVar(&fields.houseType, "house-type", "", "房屋类型: flat/duplex/villa/commercial/other")
if includeCustomer {
cmd.Flags().Uint64Var(&fields.customerID, "customer-id", 0, "客户 ID")
_ = cmd.MarkFlagRequired("customer-id")
}
cmd.Flags().StringVar(&fields.province, "province", "", "省份")
cmd.Flags().StringVar(&fields.provinceCode, "province-code", "", "省份编码")
cmd.Flags().StringVar(&fields.city, "city", "", "城市")
cmd.Flags().StringVar(&fields.cityCode, "city-code", "", "城市编码")
cmd.Flags().StringVar(&fields.district, "district", "", "区县")
cmd.Flags().StringVar(&fields.districtCode, "district-code", "", "区县编码")
cmd.Flags().StringVar(&fields.address, "address", "", "详细地址")
cmd.Flags().StringVar(&fields.remark, "remark", "", "备注")
if requireCore {
_ = cmd.MarkFlagRequired("name")
_ = cmd.MarkFlagRequired("house-type")
}
}
func newCrmAgentProjectCreateCmd() *cobra.Command {
var fields crmAgentProjectFields
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: "create", Short: "创建项目", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.ProjectCreate(cmd.Context(), &dto.ProjectCreateRequest{Command: crmAgentCommandHeader(write), Name: fields.name, CustomerId: fields.customerID, HouseType: fields.houseType, Province: fields.province, ProvinceCode: fields.provinceCode, City: fields.city, CityCode: fields.cityCode, District: fields.district, DistrictCode: fields.districtCode, Address: fields.address, Remark: fields.remark})
return printCrmAgentResult(response, err)
}}
bindCrmAgentProjectFields(cmd, &fields, true, true)
bindCrmAgentWrite(cmd, &write)
return cmd
}
func newCrmAgentProjectUpdateCmd() *cobra.Command {
var projectID uint64
var fields crmAgentProjectFields
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: "update", Short: "更新项目资料", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.ProjectUpdate(cmd.Context(), &dto.ProjectUpdateRequest{
Command: crmAgentCommandHeader(write), ProjectId: projectID,
Name: crmAgentChangedString(cmd, "name", fields.name), HouseType: crmAgentChangedString(cmd, "house-type", fields.houseType),
Province: crmAgentChangedString(cmd, "province", fields.province), ProvinceCode: crmAgentChangedString(cmd, "province-code", fields.provinceCode),
City: crmAgentChangedString(cmd, "city", fields.city), CityCode: crmAgentChangedString(cmd, "city-code", fields.cityCode),
District: crmAgentChangedString(cmd, "district", fields.district), DistrictCode: crmAgentChangedString(cmd, "district-code", fields.districtCode),
Address: crmAgentChangedString(cmd, "address", fields.address),
Remark: crmAgentChangedString(cmd, "remark", fields.remark),
})
return printCrmAgentResult(response, err)
}}
cmd.Flags().Uint64Var(&projectID, "project-id", 0, "项目 ID")
_ = cmd.MarkFlagRequired("project-id")
bindCrmAgentProjectFields(cmd, &fields, false, false)
bindCrmAgentWrite(cmd, &write)
return cmd
}
func crmAgentChangedString(cmd *cobra.Command, flagName, value string) *string {
if !cmd.Flags().Changed(flagName) {
return nil
}
return &value
}
func newCrmAgentProjectStatusCmd() *cobra.Command {
var projectID uint64
var status string
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: "status", Short: "修改项目状态", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.ProjectStatus(cmd.Context(), &dto.ProjectStatusRequest{Command: crmAgentCommandHeader(write), ProjectId: projectID, Status: status})
return printCrmAgentResult(response, err)
}}
cmd.Flags().Uint64Var(&projectID, "project-id", 0, "项目 ID")
cmd.Flags().StringVar(&status, "status", "", "状态: active/completed/shelved")
_ = cmd.MarkFlagRequired("project-id")
_ = cmd.MarkFlagRequired("status")
bindCrmAgentWrite(cmd, &write)
return cmd
}
func newCrmAgentProjectDeletePreviewCmd() *cobra.Command {
var projectID uint64
cmd := &cobra.Command{Use: "delete-preview", Short: "预览空项目删除", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.ProjectDeletePreview(cmd.Context(), &dto.ProjectDeletePreviewRequest{ProjectId: projectID})
return printCrmAgentResult(response, err)
}}
cmd.Flags().Uint64Var(&projectID, "project-id", 0, "项目 ID")
_ = cmd.MarkFlagRequired("project-id")
return cmd
}
func newCrmAgentProjectDeleteCmd() *cobra.Command {
var projectID uint64
var changeSet string
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: "delete", Short: "删除已预览的空项目", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.ProjectDelete(cmd.Context(), &dto.ProjectDeleteRequest{Command: crmAgentCommandHeader(write), ProjectId: projectID, ChangeSetId: changeSet})
return printCrmAgentResult(response, err)
}}
cmd.Flags().Uint64Var(&projectID, "project-id", 0, "项目 ID")
cmd.Flags().StringVar(&changeSet, "change-set-id", "", "delete-preview 返回的 changeSetId")
_ = cmd.MarkFlagRequired("project-id")
_ = cmd.MarkFlagRequired("change-set-id")
bindCrmAgentWrite(cmd, &write)
return cmd
}
func newCrmAgentQuotationCmd() *cobra.Command {
cmd := &cobra.Command{Use: "quotation", Short: "查询和维护独立报价", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }}
cmd.AddCommand(newCrmAgentQuotationListCmd(), newCrmAgentQuotationGetCmd(), newCrmAgentQuotationCreateCmd(), newCrmAgentQuotationCopyCmd(), newCrmAgentQuotationTitleCmd(), newCrmAgentQuotationStatusCmd(), newCrmAgentQuotationLockCmd(true), newCrmAgentQuotationLockCmd(false), newCrmAgentQuotationDeletePreviewCmd(), newCrmAgentQuotationDeleteCmd())
return cmd
}
func newCrmAgentQuotationListCmd() *cobra.Command {
var projectID uint64
var keyword, status string
var page, pageSize int32
cmd := &cobra.Command{Use: "list", Short: "分页查询报价", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.QuotationList(cmd.Context(), &dto.QuotationListRequest{ProjectId: projectID, Keyword: keyword, Status: status, Page: page, PageSize: pageSize})
return printCrmAgentResult(response, err)
}}
cmd.Flags().Uint64Var(&projectID, "project-id", 0, "项目 ID")
cmd.Flags().StringVar(&keyword, "keyword", "", "关键词")
cmd.Flags().StringVar(&status, "status", "", "报价状态")
cmd.Flags().Int32Var(&page, "page", 1, "页码")
cmd.Flags().Int32Var(&pageSize, "page-size", 20, "每页数量")
return cmd
}
func newCrmAgentQuotationGetCmd() *cobra.Command {
var id uint64
cmd := &cobra.Command{Use: "get", Short: "读取报价详情", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.QuotationGet(cmd.Context(), &dto.QuotationGetRequest{QuotationId: id})
return printCrmAgentResult(response, err)
}}
cmd.Flags().Uint64Var(&id, "quotation-id", 0, "报价 ID")
_ = cmd.MarkFlagRequired("quotation-id")
return cmd
}
func newCrmAgentQuotationCreateCmd() *cobra.Command {
var projectID uint64
var title string
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: "create", Short: "创建空报价", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.QuotationCreate(cmd.Context(), &dto.QuotationCreateRequest{Command: crmAgentCommandHeader(write), ProjectId: projectID, Title: title})
return printCrmAgentResult(response, err)
}}
cmd.Flags().Uint64Var(&projectID, "project-id", 0, "项目 ID")
cmd.Flags().StringVar(&title, "title", "报价", "报价标题")
_ = cmd.MarkFlagRequired("project-id")
bindCrmAgentWrite(cmd, &write)
return cmd
}
func newCrmAgentQuotationCopyCmd() *cobra.Command {
var id uint64
var title string
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: "copy", Short: "复制独立报价", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.QuotationCopy(cmd.Context(), &dto.QuotationCopyRequest{Command: crmAgentCommandHeader(write), QuotationId: id, Title: title})
return printCrmAgentResult(response, err)
}}
cmd.Flags().Uint64Var(&id, "quotation-id", 0, "源报价 ID")
cmd.Flags().StringVar(&title, "title", "", "副本标题")
_ = cmd.MarkFlagRequired("quotation-id")
_ = cmd.MarkFlagRequired("title")
bindCrmAgentWrite(cmd, &write)
return cmd
}
func newCrmAgentQuotationTitleCmd() *cobra.Command {
var id uint64
var title string
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: "title", Short: "修改报价标题", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.QuotationTitle(cmd.Context(), &dto.QuotationTitleRequest{Command: crmAgentCommandHeader(write), QuotationId: id, Title: title})
return printCrmAgentResult(response, err)
}}
cmd.Flags().Uint64Var(&id, "quotation-id", 0, "报价 ID")
cmd.Flags().StringVar(&title, "title", "", "标题")
_ = cmd.MarkFlagRequired("quotation-id")
_ = cmd.MarkFlagRequired("title")
bindCrmAgentWrite(cmd, &write)
return cmd
}
func newCrmAgentQuotationStatusCmd() *cobra.Command {
var id uint64
var status string
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: "status", Short: "修改报价状态", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.QuotationStatus(cmd.Context(), &dto.QuotationStatusRequest{Command: crmAgentCommandHeader(write), QuotationId: id, Status: status})
return printCrmAgentResult(response, err)
}}
cmd.Flags().Uint64Var(&id, "quotation-id", 0, "报价 ID")
cmd.Flags().StringVar(&status, "status", "", "报价状态")
_ = cmd.MarkFlagRequired("quotation-id")
_ = cmd.MarkFlagRequired("status")
bindCrmAgentWrite(cmd, &write)
return cmd
}
func newCrmAgentQuotationLockCmd(locked bool) *cobra.Command {
use, short := "unlock", "解锁报价"
if locked {
use, short = "lock", "锁定报价"
}
var id uint64
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: use, Short: short, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
request := &dto.QuotationLockRequest{Command: crmAgentCommandHeader(write), QuotationId: id}
var response *dto.QuotationResponse
if locked {
response, err = client.QuotationLock(cmd.Context(), request)
} else {
response, err = client.QuotationUnlock(cmd.Context(), request)
}
return printCrmAgentResult(response, err)
}}
cmd.Flags().Uint64Var(&id, "quotation-id", 0, "报价 ID")
_ = cmd.MarkFlagRequired("quotation-id")
bindCrmAgentWrite(cmd, &write)
return cmd
}
func newCrmAgentQuotationDeletePreviewCmd() *cobra.Command {
var id uint64
cmd := &cobra.Command{Use: "delete-preview", Short: "预览报价删除", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.QuotationDeletePreview(cmd.Context(), &dto.QuotationDeletePreviewRequest{QuotationId: id})
return printCrmAgentResult(response, err)
}}
cmd.Flags().Uint64Var(&id, "quotation-id", 0, "报价 ID")
_ = cmd.MarkFlagRequired("quotation-id")
return cmd
}
func newCrmAgentQuotationDeleteCmd() *cobra.Command {
var id uint64
var revision int32
var changeSet string
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: "delete", Short: "按 revision 删除报价", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.QuotationDelete(cmd.Context(), &dto.QuotationDeleteRequest{Command: crmAgentCommandHeader(write), QuotationId: id, Revision: revision, ChangeSetId: changeSet})
return printCrmAgentResult(response, err)
}}
cmd.Flags().Uint64Var(&id, "quotation-id", 0, "报价 ID")
cmd.Flags().Int32Var(&revision, "revision", 0, "delete-preview 返回的 revision")
cmd.Flags().StringVar(&changeSet, "change-set-id", "", "delete-preview 返回的 changeSetId")
_ = cmd.MarkFlagRequired("quotation-id")
_ = cmd.MarkFlagRequired("revision")
_ = cmd.MarkFlagRequired("change-set-id")
bindCrmAgentWrite(cmd, &write)
return cmd
}
func newCrmAgentCatalogCmd() *cobra.Command {
var keyword string
var categoryID, rootCategoryID, brandID uint64
var page, pageSize int32
cmd := &cobra.Command{Use: "catalog-search", Short: "查询可加入报价的产品库 SKU", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.CatalogSearch(cmd.Context(), &dto.CatalogSearchRequest{Keyword: keyword, CategoryId: categoryID, RootCategoryId: rootCategoryID, BrandId: brandID, Page: page, PageSize: pageSize})
return printCrmAgentResult(response, err)
}}
cmd.Flags().StringVar(&keyword, "keyword", "", "关键词")
cmd.Flags().Uint64Var(&categoryID, "category-id", 0, "分类 ID")
cmd.Flags().Uint64Var(&rootCategoryID, "root-category-id", 0, "根分类 ID")
cmd.Flags().Uint64Var(&brandID, "brand-id", 0, "品牌 ID")
cmd.Flags().Int32Var(&page, "page", 1, "页码")
cmd.Flags().Int32Var(&pageSize, "page-size", 20, "每页数量")
return cmd
}
+94
View File
@@ -0,0 +1,94 @@
package command
import (
"bytes"
"errors"
"testing"
dto "code.zhecent.com/open/shop-crm-agent/internal/api"
"google.golang.org/protobuf/encoding/protojson"
)
func TestRootCommandExposesOnlyCrmOperations(t *testing.T) {
cmd := New("test-version")
want := map[string]bool{
"customer-list": true, "project": true,
"quotation": true, "catalog-search": true, "edit": true,
}
for _, child := range cmd.Commands() {
if child.Name() == "credential" {
t.Fatal("external client exposes credential administration")
}
if child.Name() == "progress-template-list" {
t.Fatal("external client exposes retired progress template query")
}
delete(want, child.Name())
}
if len(want) != 0 {
t.Fatalf("missing root commands = %v", want)
}
if cmd.Version != "test-version" {
t.Fatalf("version = %q", cmd.Version)
}
}
func TestProjectCommandsExcludeProgressAndTemplateFlag(t *testing.T) {
root := New("test-version")
project, _, err := root.Find([]string{"project"})
if err != nil {
t.Fatal(err)
}
for _, child := range project.Commands() {
if child.Name() == "progress-complete" {
t.Fatal("project command exposes retired progress completion")
}
}
for _, name := range []string{"create", "update"} {
command, _, findErr := root.Find([]string{"project", name})
if findErr != nil {
t.Fatal(findErr)
}
if command.Flags().Lookup("progress-template-key") != nil {
t.Fatalf("project %s exposes retired progress template flag", name)
}
}
}
func TestRemoteCommandRequiresEnvironmentConfiguration(t *testing.T) {
t.Setenv("LIGHTCORE_API_BASE_URL", "")
t.Setenv("LIGHTCORE_SHOP_CRM_AGENT_TOKEN", "")
cmd := New("test")
cmd.SetArgs([]string{"customer-list"})
if err := cmd.Execute(); err == nil {
t.Fatal("missing environment configuration was accepted")
}
}
func TestWriteCrmAgentResultPrintsStructuredBusinessError(t *testing.T) {
commandErr := errors.New("CRM Agent API 错误 400: 报价已锁定")
response := &dto.ProjectResponse{Header: &dto.ResponseHeader{
Code: 400, Message: "报价已锁定", RequestId: "request-123", Replayed: true,
}}
var output bytes.Buffer
err := writeCrmAgentResult(&output, response, commandErr)
if !errors.Is(err, commandErr) {
t.Fatalf("command error = %v", err)
}
printed := &dto.ProjectResponse{}
if err := protojson.Unmarshal(output.Bytes(), printed); err != nil {
t.Fatalf("decode output: %v", err)
}
if printed.GetHeader() == nil || printed.GetHeader().Code != 400 || printed.GetHeader().RequestId != "request-123" || !printed.GetHeader().Replayed {
t.Fatalf("printed header = %#v", printed.GetHeader())
}
}
func TestWriteCrmAgentResultSkipsMissingTransportResponse(t *testing.T) {
commandErr := errors.New("connection refused")
var output bytes.Buffer
err := writeCrmAgentResult(&output, &dto.ProjectResponse{}, commandErr)
if !errors.Is(err, commandErr) || output.Len() != 0 {
t.Fatalf("error=%v output=%q", err, output.String())
}
}
+568
View File
@@ -0,0 +1,568 @@
package command
import (
"errors"
dto "code.zhecent.com/open/shop-crm-agent/internal/api"
"github.com/spf13/cobra"
)
func newCrmAgentEditCmd() *cobra.Command {
cmd := &cobra.Command{Use: "edit", Short: "通过服务端会话编辑报价", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }}
cmd.AddCommand(newCrmAgentEditBeginCmd(), newCrmAgentEditPreviewCmd(), newCrmAgentEditCommitCmd(), newCrmAgentEditDiscardCmd(), newCrmAgentEditFloorCmd(), newCrmAgentEditSpaceCmd(), newCrmAgentEditCategoryCmd(), newCrmAgentEditLineCmd(), newCrmAgentEditFeeCmd(), newCrmAgentEditPricingCmd())
return cmd
}
func newCrmAgentEditBeginCmd() *cobra.Command {
var quotationID uint64
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: "begin", Short: "开始报价编辑会话", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.EditBegin(cmd.Context(), &dto.QuotationEditBeginRequest{Command: crmAgentCommandHeader(write), QuotationId: quotationID})
return printCrmAgentResult(response, err)
}}
cmd.Flags().Uint64Var(&quotationID, "quotation-id", 0, "报价 ID")
_ = cmd.MarkFlagRequired("quotation-id")
bindCrmAgentWrite(cmd, &write)
return cmd
}
func newCrmAgentEditPreviewCmd() *cobra.Command {
var sessionID string
cmd := &cobra.Command{Use: "preview", Short: "预览编辑并生成 changeSetId", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.EditPreview(cmd.Context(), &dto.QuotationEditPreviewRequest{SessionId: sessionID})
return printCrmAgentResult(response, err)
}}
bindSessionID(cmd, &sessionID)
return cmd
}
func newCrmAgentEditCommitCmd() *cobra.Command {
var sessionID, changeSet string
var revision int32
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: "commit", Short: "按 revision 和 changeSetId 提交编辑", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.EditCommit(cmd.Context(), &dto.QuotationEditCommitRequest{Command: crmAgentCommandHeader(write), SessionId: sessionID, Revision: revision, ChangeSetId: changeSet})
return printCrmAgentResult(response, err)
}}
bindSessionID(cmd, &sessionID)
cmd.Flags().Int32Var(&revision, "revision", 0, "preview 返回的 revision")
cmd.Flags().StringVar(&changeSet, "change-set-id", "", "preview 返回的 changeSetId")
_ = cmd.MarkFlagRequired("revision")
_ = cmd.MarkFlagRequired("change-set-id")
bindCrmAgentWrite(cmd, &write)
return cmd
}
func newCrmAgentEditDiscardCmd() *cobra.Command {
var sessionID string
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: "discard", Short: "丢弃编辑会话", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.EditDiscard(cmd.Context(), &dto.QuotationEditDiscardRequest{Command: crmAgentCommandHeader(write), SessionId: sessionID})
return printCrmAgentResult(response, err)
}}
bindSessionID(cmd, &sessionID)
bindCrmAgentWrite(cmd, &write)
return cmd
}
func bindSessionID(cmd *cobra.Command, value *string) {
cmd.Flags().StringVar(value, "session-id", "", "报价编辑会话 ID")
_ = cmd.MarkFlagRequired("session-id")
}
func newCrmAgentEditMutationCmd(use, short string, bind func(*cobra.Command), build func() *dto.QuotationEditCommandRequest) *cobra.Command {
var sessionID string
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: use, Short: short, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
request := build()
if request == nil {
return errors.New("编辑命令构造失败")
}
request.Command, request.SessionId = crmAgentCommandHeader(write), sessionID
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.EditCommand(cmd.Context(), request)
return printCrmAgentResult(response, err)
}}
bindSessionID(cmd, &sessionID)
bindCrmAgentWrite(cmd, &write)
if bind != nil {
bind(cmd)
}
return cmd
}
func newCrmAgentEditFloorCmd() *cobra.Command {
cmd := &cobra.Command{Use: "floor", Short: "增改排序和移除楼层", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }}
cmd.AddCommand(newCrmAgentFloorAddCmd(), newCrmAgentFloorRenameCmd(), newCrmAgentFloorReorderCmd(), newCrmAgentFloorRemoveCmd())
return cmd
}
func newCrmAgentFloorAddCmd() *cobra.Command {
var id, name string
var index int32 = -1
return newCrmAgentEditMutationCmd("add", "添加楼层", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&id, "id", "", "可选稳定楼层 ID")
cmd.Flags().StringVar(&name, "name", "", "楼层名称")
cmd.Flags().Int32Var(&index, "index", -1, "插入索引,-1追加")
_ = cmd.MarkFlagRequired("name")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_AddFloor{AddFloor: &dto.AddFloor{Id: id, Name: name, Index: index}}}
})
}
func newCrmAgentFloorRenameCmd() *cobra.Command {
var id, name string
return newCrmAgentEditMutationCmd("rename", "重命名楼层", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&id, "floor-id", "", "楼层 ID")
cmd.Flags().StringVar(&name, "name", "", "新名称")
_ = cmd.MarkFlagRequired("floor-id")
_ = cmd.MarkFlagRequired("name")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_RenameFloor{RenameFloor: &dto.RenameFloor{FloorId: id, Name: name}}}
})
}
func newCrmAgentFloorReorderCmd() *cobra.Command {
var id string
var index int32
return newCrmAgentEditMutationCmd("reorder", "调整楼层顺序", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&id, "floor-id", "", "楼层 ID")
cmd.Flags().Int32Var(&index, "index", 0, "目标索引")
_ = cmd.MarkFlagRequired("floor-id")
_ = cmd.MarkFlagRequired("index")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_ReorderFloor{ReorderFloor: &dto.ReorderFloor{FloorId: id, Index: index}}}
})
}
func newCrmAgentFloorRemoveCmd() *cobra.Command {
var id string
var cascade bool
return newCrmAgentEditMutationCmd("remove", "移除楼层", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&id, "floor-id", "", "楼层 ID")
cmd.Flags().BoolVar(&cascade, "cascade", false, "同时移除下属空间、分类和产品行")
_ = cmd.MarkFlagRequired("floor-id")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_RemoveFloor{RemoveFloor: &dto.RemoveFloor{FloorId: id, Cascade: cascade}}}
})
}
func newCrmAgentEditSpaceCmd() *cobra.Command {
cmd := &cobra.Command{Use: "space", Short: "增改移动排序和移除空间", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }}
cmd.AddCommand(newCrmAgentSpaceAddCmd(), newCrmAgentSpaceRenameCmd(), newCrmAgentSpaceMoveCmd(), newCrmAgentSpaceReorderCmd(), newCrmAgentSpaceRemoveCmd())
return cmd
}
func newCrmAgentSpaceAddCmd() *cobra.Command {
var id, floorID, name string
var index int32 = -1
return newCrmAgentEditMutationCmd("add", "添加空间", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&id, "id", "", "可选稳定空间 ID")
cmd.Flags().StringVar(&floorID, "floor-id", "", "楼层 ID")
cmd.Flags().StringVar(&name, "name", "", "空间名称")
cmd.Flags().Int32Var(&index, "index", -1, "插入索引")
_ = cmd.MarkFlagRequired("floor-id")
_ = cmd.MarkFlagRequired("name")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_AddSpace{AddSpace: &dto.AddSpace{Id: id, FloorId: floorID, Name: name, Index: index}}}
})
}
func newCrmAgentSpaceRenameCmd() *cobra.Command {
var id, name string
return newCrmAgentEditMutationCmd("rename", "重命名空间", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&id, "space-id", "", "空间 ID")
cmd.Flags().StringVar(&name, "name", "", "新名称")
_ = cmd.MarkFlagRequired("space-id")
_ = cmd.MarkFlagRequired("name")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_RenameSpace{RenameSpace: &dto.RenameSpace{SpaceId: id, Name: name}}}
})
}
func newCrmAgentSpaceMoveCmd() *cobra.Command {
var id, floorID string
var index int32 = -1
return newCrmAgentEditMutationCmd("move", "移动空间到其他楼层", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&id, "space-id", "", "空间 ID")
cmd.Flags().StringVar(&floorID, "target-floor-id", "", "目标楼层 ID")
cmd.Flags().Int32Var(&index, "index", -1, "目标索引")
_ = cmd.MarkFlagRequired("space-id")
_ = cmd.MarkFlagRequired("target-floor-id")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_MoveSpace{MoveSpace: &dto.MoveSpace{SpaceId: id, TargetFloorId: floorID, Index: index}}}
})
}
func newCrmAgentSpaceReorderCmd() *cobra.Command {
var id string
var index int32
return newCrmAgentEditMutationCmd("reorder", "调整空间顺序", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&id, "space-id", "", "空间 ID")
cmd.Flags().Int32Var(&index, "index", 0, "目标索引")
_ = cmd.MarkFlagRequired("space-id")
_ = cmd.MarkFlagRequired("index")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_ReorderSpace{ReorderSpace: &dto.ReorderSpace{SpaceId: id, Index: index}}}
})
}
func newCrmAgentSpaceRemoveCmd() *cobra.Command {
var id string
var cascade bool
return newCrmAgentEditMutationCmd("remove", "移除空间", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&id, "space-id", "", "空间 ID")
cmd.Flags().BoolVar(&cascade, "cascade", false, "同时移除分类和产品行")
_ = cmd.MarkFlagRequired("space-id")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_RemoveSpace{RemoveSpace: &dto.RemoveSpace{SpaceId: id, Cascade: cascade}}}
})
}
func newCrmAgentEditCategoryCmd() *cobra.Command {
cmd := &cobra.Command{Use: "category", Short: "增改排序和移除分类", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }}
cmd.AddCommand(newCrmAgentCategoryAddCmd(), newCrmAgentCategoryRenameCmd(), newCrmAgentCategoryReorderCmd(), newCrmAgentCategoryRemoveCmd())
return cmd
}
func newCrmAgentCategoryAddCmd() *cobra.Command {
var id, floorID, spaceID, name, source, sourceKey string
var index int32 = -1
return newCrmAgentEditMutationCmd("add", "添加分类", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&id, "id", "", "可选稳定分类 ID")
cmd.Flags().StringVar(&floorID, "floor-id", "", "楼层 ID")
cmd.Flags().StringVar(&spaceID, "space-id", "", "空间 ID")
cmd.Flags().StringVar(&name, "name", "", "分类名称")
cmd.Flags().StringVar(&source, "source", "custom", "来源")
cmd.Flags().StringVar(&sourceKey, "source-key", "", "来源 key")
cmd.Flags().Int32Var(&index, "index", -1, "插入索引")
_ = cmd.MarkFlagRequired("floor-id")
_ = cmd.MarkFlagRequired("space-id")
_ = cmd.MarkFlagRequired("name")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_AddCategory{AddCategory: &dto.AddCategory{Id: id, FloorId: floorID, SpaceId: spaceID, Name: name, Source: source, SourceKey: sourceKey, Index: index}}}
})
}
func newCrmAgentCategoryRenameCmd() *cobra.Command {
var id, name string
return newCrmAgentEditMutationCmd("rename", "重命名分类", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&id, "category-id", "", "分类 ID")
cmd.Flags().StringVar(&name, "name", "", "新名称")
_ = cmd.MarkFlagRequired("category-id")
_ = cmd.MarkFlagRequired("name")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_RenameCategory{RenameCategory: &dto.RenameCategory{CategoryId: id, Name: name}}}
})
}
func newCrmAgentCategoryReorderCmd() *cobra.Command {
var id string
var index int32
return newCrmAgentEditMutationCmd("reorder", "调整分类顺序", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&id, "category-id", "", "分类 ID")
cmd.Flags().Int32Var(&index, "index", 0, "目标索引")
_ = cmd.MarkFlagRequired("category-id")
_ = cmd.MarkFlagRequired("index")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_ReorderCategory{ReorderCategory: &dto.ReorderCategory{CategoryId: id, Index: index}}}
})
}
func newCrmAgentCategoryRemoveCmd() *cobra.Command {
var id string
var cascade bool
return newCrmAgentEditMutationCmd("remove", "移除分类", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&id, "category-id", "", "分类 ID")
cmd.Flags().BoolVar(&cascade, "cascade", false, "同时移除产品行")
_ = cmd.MarkFlagRequired("category-id")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_RemoveCategory{RemoveCategory: &dto.RemoveCategory{CategoryId: id, Cascade: cascade}}}
})
}
func newCrmAgentEditLineCmd() *cobra.Command {
cmd := &cobra.Command{Use: "line", Short: "增改移动排序启停和移除产品行", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }}
cmd.AddCommand(newCrmAgentLineAddCatalogCmd(), newCrmAgentLineAddCustomCmd(), newCrmAgentLineUpdateCmd(), newCrmAgentLineMoveCmd(), newCrmAgentLineReorderCmd(), newCrmAgentLineActiveCmd(true), newCrmAgentLineActiveCmd(false), newCrmAgentLineRemoveCmd())
return cmd
}
type crmAgentLineTarget struct {
floorID, spaceID, categoryID string
index int32
}
func bindLineTarget(cmd *cobra.Command, target *crmAgentLineTarget) {
cmd.Flags().StringVar(&target.floorID, "floor-id", "", "楼层 ID")
cmd.Flags().StringVar(&target.spaceID, "space-id", "", "空间 ID")
cmd.Flags().StringVar(&target.categoryID, "category-id", "", "分类 ID")
cmd.Flags().Int32Var(&target.index, "index", -1, "分类内索引,-1追加")
_ = cmd.MarkFlagRequired("floor-id")
_ = cmd.MarkFlagRequired("space-id")
_ = cmd.MarkFlagRequired("category-id")
}
func newCrmAgentLineAddCatalogCmd() *cobra.Command {
var target crmAgentLineTarget
var skuID uint64
var quantity float64 = 1
var discountRate int32 = 100
var discountAmount int64
var remark string
return newCrmAgentEditMutationCmd("add-catalog", "按 SKU 添加产品库行", func(cmd *cobra.Command) {
bindLineTarget(cmd, &target)
cmd.Flags().Uint64Var(&skuID, "sku-id", 0, "产品库 SKU ID")
cmd.Flags().Float64Var(&quantity, "quantity", 1, "数量")
cmd.Flags().Int32Var(&discountRate, "discount-rate", 100, "折扣率0-100")
cmd.Flags().Int64Var(&discountAmount, "discount-amount", 0, "优惠金额(分)")
cmd.Flags().StringVar(&remark, "remark", "", "备注")
_ = cmd.MarkFlagRequired("sku-id")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_AddCatalogLine{AddCatalogLine: &dto.AddCatalogLine{FloorId: target.floorID, SpaceId: target.spaceID, CategoryId: target.categoryID, SkuId: skuID, Quantity: quantity, DiscountRate: discountRate, DiscountAmount: discountAmount, Remark: remark, Index: target.index}}}
})
}
func newCrmAgentLineAddCustomCmd() *cobra.Command {
var target crmAgentLineTarget
var name, spec, remark string
var quantity float64 = 1
var unitPrice, costPrice, discountAmount int64
var discountRate int32 = 100
return newCrmAgentEditMutationCmd("add-custom", "添加自定义产品行", func(cmd *cobra.Command) {
bindLineTarget(cmd, &target)
cmd.Flags().StringVar(&name, "name", "", "产品名称")
cmd.Flags().StringVar(&spec, "spec", "", "规格")
cmd.Flags().Float64Var(&quantity, "quantity", 1, "数量")
cmd.Flags().Int64Var(&unitPrice, "unit-price", 0, "售价(分)")
cmd.Flags().Int64Var(&costPrice, "cost-price", 0, "成本(分)")
cmd.Flags().Int32Var(&discountRate, "discount-rate", 100, "折扣率0-100")
cmd.Flags().Int64Var(&discountAmount, "discount-amount", 0, "优惠金额(分)")
cmd.Flags().StringVar(&remark, "remark", "", "备注")
_ = cmd.MarkFlagRequired("name")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_AddCustomLine{AddCustomLine: &dto.AddCustomLine{FloorId: target.floorID, SpaceId: target.spaceID, CategoryId: target.categoryID, GoodsName: name, SpecCombination: spec, Quantity: quantity, UnitPrice: unitPrice, CostPrice: costPrice, DiscountRate: discountRate, DiscountAmount: discountAmount, Remark: remark, Index: target.index}}}
})
}
func newCrmAgentLineUpdateCmd() *cobra.Command {
var sessionID, lineID, name, spec, remark string
var quantity float64
var unitPrice, costPrice, discountAmount int64
var discountRate int32
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: "update", Short: "按已提供 flags 更新产品行", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
update := &dto.UpdateLine{LineId: lineID}
if cmd.Flags().Changed("name") {
update.GoodsName = &name
}
if cmd.Flags().Changed("spec") {
update.SpecCombination = &spec
}
if cmd.Flags().Changed("quantity") {
update.Quantity = &quantity
}
if cmd.Flags().Changed("unit-price") {
update.UnitPrice = &unitPrice
}
if cmd.Flags().Changed("cost-price") {
update.CostPrice = &costPrice
}
if cmd.Flags().Changed("discount-rate") {
update.DiscountRate = &discountRate
}
if cmd.Flags().Changed("discount-amount") {
update.DiscountAmount = &discountAmount
}
if cmd.Flags().Changed("remark") {
update.Remark = &remark
}
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.EditCommand(cmd.Context(), &dto.QuotationEditCommandRequest{Command: crmAgentCommandHeader(write), SessionId: sessionID, Edit: &dto.QuotationEditCommandRequest_UpdateLine{UpdateLine: update}})
return printCrmAgentResult(response, err)
}}
bindSessionID(cmd, &sessionID)
bindCrmAgentWrite(cmd, &write)
cmd.Flags().StringVar(&lineID, "line-id", "", "产品行 ID")
cmd.Flags().StringVar(&name, "name", "", "产品名称(仅自定义行)")
cmd.Flags().StringVar(&spec, "spec", "", "规格(仅自定义行)")
cmd.Flags().Float64Var(&quantity, "quantity", 0, "数量")
cmd.Flags().Int64Var(&unitPrice, "unit-price", 0, "售价(分)")
cmd.Flags().Int64Var(&costPrice, "cost-price", 0, "成本(分,仅自定义行)")
cmd.Flags().Int32Var(&discountRate, "discount-rate", 0, "折扣率0-100")
cmd.Flags().Int64Var(&discountAmount, "discount-amount", 0, "优惠金额(分)")
cmd.Flags().StringVar(&remark, "remark", "", "备注")
_ = cmd.MarkFlagRequired("line-id")
return cmd
}
func newCrmAgentLineMoveCmd() *cobra.Command {
var target crmAgentLineTarget
var lineID string
return newCrmAgentEditMutationCmd("move", "移动产品行", func(cmd *cobra.Command) {
bindLineTarget(cmd, &target)
cmd.Flags().StringVar(&lineID, "line-id", "", "产品行 ID")
_ = cmd.MarkFlagRequired("line-id")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_MoveLine{MoveLine: &dto.MoveLine{LineId: lineID, FloorId: target.floorID, SpaceId: target.spaceID, CategoryId: target.categoryID, Index: target.index}}}
})
}
func newCrmAgentLineReorderCmd() *cobra.Command {
var lineID string
var index int32
return newCrmAgentEditMutationCmd("reorder", "调整产品行在分类内的顺序", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&lineID, "line-id", "", "产品行 ID")
cmd.Flags().Int32Var(&index, "index", 0, "目标索引")
_ = cmd.MarkFlagRequired("line-id")
_ = cmd.MarkFlagRequired("index")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_ReorderLine{ReorderLine: &dto.ReorderLine{LineId: lineID, Index: index}}}
})
}
func newCrmAgentLineActiveCmd(active bool) *cobra.Command {
use, short := "disable", "停用产品行"
if active {
use, short = "enable", "启用产品行"
}
var lineID string
return newCrmAgentEditMutationCmd(use, short, func(cmd *cobra.Command) {
cmd.Flags().StringVar(&lineID, "line-id", "", "产品行 ID")
_ = cmd.MarkFlagRequired("line-id")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_SetLineActive{SetLineActive: &dto.SetLineActive{LineId: lineID, Active: active}}}
})
}
func newCrmAgentLineRemoveCmd() *cobra.Command {
var lineID string
return newCrmAgentEditMutationCmd("remove", "移除产品行", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&lineID, "line-id", "", "产品行 ID")
_ = cmd.MarkFlagRequired("line-id")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_RemoveLine{RemoveLine: &dto.RemoveLine{LineId: lineID}}}
})
}
func newCrmAgentEditFeeCmd() *cobra.Command {
cmd := &cobra.Command{Use: "fee", Short: "增改排序和移除费用", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }}
cmd.AddCommand(newCrmAgentFeeAddCmd(), newCrmAgentFeeUpdateCmd(), newCrmAgentFeeReorderCmd(), newCrmAgentFeeRemoveCmd())
return cmd
}
func newCrmAgentFeeAddCmd() *cobra.Command {
var name, feeType, remark string
var fixedAmount int64
var percentage, index int32
index = -1
return newCrmAgentEditMutationCmd("add", "添加费用", func(cmd *cobra.Command) {
cmd.Flags().StringVar(&name, "name", "", "费用名称")
cmd.Flags().StringVar(&feeType, "type", "fixed", "fixed/discounted_percent/original_percent")
cmd.Flags().Int64Var(&fixedAmount, "fixed-amount", 0, "固定金额(分)")
cmd.Flags().Int32Var(&percentage, "percentage", 0, "百分比0-100")
cmd.Flags().Int32Var(&index, "index", -1, "插入索引")
cmd.Flags().StringVar(&remark, "remark", "", "备注")
_ = cmd.MarkFlagRequired("name")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_AddFee{AddFee: &dto.AddFee{Name: name, Type: feeType, FixedAmount: fixedAmount, Percentage: percentage, Index: index, Remark: remark}}}
})
}
func newCrmAgentFeeUpdateCmd() *cobra.Command {
var sessionID, name, feeType, remark string
var index, percentage int32
var fixedAmount int64
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: "update", Short: "按已提供 flags 更新费用", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
update := &dto.UpdateFee{Index: index}
if cmd.Flags().Changed("name") {
update.Name = &name
}
if cmd.Flags().Changed("type") {
update.Type = &feeType
}
if cmd.Flags().Changed("fixed-amount") {
update.FixedAmount = &fixedAmount
}
if cmd.Flags().Changed("percentage") {
update.Percentage = &percentage
}
if cmd.Flags().Changed("remark") {
update.Remark = &remark
}
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.EditCommand(cmd.Context(), &dto.QuotationEditCommandRequest{Command: crmAgentCommandHeader(write), SessionId: sessionID, Edit: &dto.QuotationEditCommandRequest_UpdateFee{UpdateFee: update}})
return printCrmAgentResult(response, err)
}}
bindSessionID(cmd, &sessionID)
bindCrmAgentWrite(cmd, &write)
cmd.Flags().Int32Var(&index, "index", -1, "费用索引")
cmd.Flags().StringVar(&name, "name", "", "名称")
cmd.Flags().StringVar(&feeType, "type", "", "费用类型")
cmd.Flags().Int64Var(&fixedAmount, "fixed-amount", 0, "固定金额(分)")
cmd.Flags().Int32Var(&percentage, "percentage", 0, "百分比0-100")
cmd.Flags().StringVar(&remark, "remark", "", "备注")
_ = cmd.MarkFlagRequired("index")
return cmd
}
func newCrmAgentFeeReorderCmd() *cobra.Command {
var index, target int32
return newCrmAgentEditMutationCmd("reorder", "调整费用顺序", func(cmd *cobra.Command) {
cmd.Flags().Int32Var(&index, "index", -1, "当前索引")
cmd.Flags().Int32Var(&target, "target-index", 0, "目标索引")
_ = cmd.MarkFlagRequired("index")
_ = cmd.MarkFlagRequired("target-index")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_ReorderFee{ReorderFee: &dto.ReorderFee{Index: index, TargetIndex: target}}}
})
}
func newCrmAgentFeeRemoveCmd() *cobra.Command {
var index int32
return newCrmAgentEditMutationCmd("remove", "移除费用", func(cmd *cobra.Command) {
cmd.Flags().Int32Var(&index, "index", -1, "费用索引")
_ = cmd.MarkFlagRequired("index")
}, func() *dto.QuotationEditCommandRequest {
return &dto.QuotationEditCommandRequest{Edit: &dto.QuotationEditCommandRequest_RemoveFee{RemoveFee: &dto.RemoveFee{Index: index}}}
})
}
func newCrmAgentEditPricingCmd() *cobra.Command {
var sessionID, remark string
var discountRate int32
var discountAmount, actualAmount int64
var adjusted bool
var write crmAgentWriteOptions
cmd := &cobra.Command{Use: "pricing", Short: "修改整单折扣、优惠、实际金额或调价标记", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
pricing := &dto.SetPricing{}
if cmd.Flags().Changed("discount-rate") {
pricing.DiscountRate = &discountRate
}
if cmd.Flags().Changed("discount-amount") {
pricing.DiscountAmount = &discountAmount
}
if cmd.Flags().Changed("actual-amount") {
pricing.ActualAmount = &actualAmount
}
if cmd.Flags().Changed("adjusted") {
pricing.HasPriceAdjustment = &adjusted
}
if cmd.Flags().Changed("remark") {
pricing.Remark = &remark
}
client, err := newCrmAgentHTTPClient()
if err != nil {
return err
}
response, err := client.EditCommand(cmd.Context(), &dto.QuotationEditCommandRequest{Command: crmAgentCommandHeader(write), SessionId: sessionID, Edit: &dto.QuotationEditCommandRequest_SetPricing{SetPricing: pricing}})
return printCrmAgentResult(response, err)
}}
bindSessionID(cmd, &sessionID)
bindCrmAgentWrite(cmd, &write)
cmd.Flags().Int32Var(&discountRate, "discount-rate", 0, "整单折扣率0-100")
cmd.Flags().Int64Var(&discountAmount, "discount-amount", 0, "整单优惠金额(分)")
cmd.Flags().Int64Var(&actualAmount, "actual-amount", 0, "实际成交金额(分)")
cmd.Flags().BoolVar(&adjusted, "adjusted", false, "是否标记人工调价")
cmd.Flags().StringVar(&remark, "remark", "", "报价备注")
return cmd
}
+129
View File
@@ -0,0 +1,129 @@
//go:build ignore
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
const modulePath = "code.zhecent.com/open/shop-crm-agent"
func main() {
if err := run(); err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
repoRoot, err := os.Getwd()
if err != nil {
return err
}
if _, err := os.Stat(filepath.Join(repoRoot, "go.mod")); err != nil {
return errors.New("build.go must run from the shop-crm-agent repository root")
}
moduleCommand := exec.Command("go", "list", "-m", "-f", "{{.Path}}")
moduleCommand.Dir = repoRoot
moduleOutput, err := moduleCommand.CombinedOutput()
if err != nil {
return fmt.Errorf("read Go module path: %w: %s", err, strings.TrimSpace(string(moduleOutput)))
}
if actualModule := strings.TrimSpace(string(moduleOutput)); actualModule != modulePath {
return fmt.Errorf("unexpected Go module; want %s", modulePath)
}
env := withEnv(os.Environ(), "CGO_ENABLED", "0")
if err := runCommand(repoRoot, env, "go", "test", "./..."); err != nil {
return fmt.Errorf("test shop-crm-agent: %w", err)
}
version := gitVersion(repoRoot)
binaryName := "shop-crm-agent"
if runtime.GOOS == "windows" {
binaryName += ".exe"
}
binDir := filepath.Join(repoRoot, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
return err
}
tempFile, err := os.CreateTemp(binDir, "."+binaryName+".tmp-")
if err != nil {
return err
}
tempPath := tempFile.Name()
if err := tempFile.Close(); err != nil {
return err
}
if err := os.Remove(tempPath); err != nil {
return err
}
defer os.Remove(tempPath)
ldflags := "-s -w -X main.version=" + version
if err := runCommand(repoRoot, env, "go", "build", "-trimpath", "-ldflags", ldflags, "-o", tempPath, "./cmd/shop-crm-agent"); err != nil {
return fmt.Errorf("build shop-crm-agent: %w", err)
}
if runtime.GOOS != "windows" {
if err := os.Chmod(tempPath, 0o755); err != nil {
return err
}
}
probe := exec.Command(tempPath, "--version")
probe.Dir = repoRoot
probeOutput, err := probe.CombinedOutput()
if err != nil {
return fmt.Errorf("probe built binary: %w: %s", err, strings.TrimSpace(string(probeOutput)))
}
targetPath := filepath.Join(binDir, binaryName)
if err := replaceBinary(tempPath, targetPath); err != nil {
return err
}
fmt.Printf("installed: %s\n", filepath.Join("bin", binaryName))
fmt.Printf("version: %s\n", strings.TrimSpace(string(probeOutput)))
return nil
}
func gitVersion(repoRoot string) string {
command := exec.Command("git", "describe", "--tags", "--always", "--dirty")
command.Dir = repoRoot
output, err := command.Output()
if err != nil || strings.TrimSpace(string(output)) == "" {
return "dev"
}
return strings.TrimSpace(string(output))
}
func replaceBinary(source, target string) error {
if err := os.Rename(source, target); err != nil {
return fmt.Errorf("install built binary: %w", err)
}
return nil
}
func runCommand(dir string, env []string, name string, args ...string) error {
command := exec.Command(name, args...)
command.Dir = dir
command.Env = env
command.Stdout = os.Stdout
command.Stderr = os.Stderr
return command.Run()
}
func withEnv(env []string, key, value string) []string {
prefix := key + "="
result := make([]string, 0, len(env)+1)
for _, entry := range env {
if !strings.HasPrefix(entry, prefix) {
result = append(result, entry)
}
}
return append(result, prefix+value)
}