makea-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- makea_cli/__init__.py +1 -0
- makea_cli/__main__.py +6 -0
- makea_cli/api_client.py +376 -0
- makea_cli/auth_pkce.py +251 -0
- makea_cli/commands/__init__.py +23 -0
- makea_cli/commands/_util.py +16 -0
- makea_cli/commands/auth/__init__.py +3 -0
- makea_cli/commands/auth/cmd.py +41 -0
- makea_cli/commands/directory/__init__.py +3 -0
- makea_cli/commands/directory/cmd.py +39 -0
- makea_cli/commands/document/__init__.py +3 -0
- makea_cli/commands/document/cmd.py +182 -0
- makea_cli/commands/email/__init__.py +3 -0
- makea_cli/commands/email/cmd.py +122 -0
- makea_cli/commands/metrics/__init__.py +0 -0
- makea_cli/commands/metrics/cmd.py +98 -0
- makea_cli/commands/orders/__init__.py +3 -0
- makea_cli/commands/orders/cmd.py +202 -0
- makea_cli/commands/product/__init__.py +3 -0
- makea_cli/commands/product/cmd.py +160 -0
- makea_cli/commands/supplier/__init__.py +3 -0
- makea_cli/commands/supplier/cmd.py +305 -0
- makea_cli/config.py +74 -0
- makea_cli/main.py +29 -0
- makea_cli-0.1.1.dist-info/METADATA +98 -0
- makea_cli-0.1.1.dist-info/RECORD +28 -0
- makea_cli-0.1.1.dist-info/WHEEL +4 -0
- makea_cli-0.1.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from makea_cli import api_client
|
|
7
|
+
from makea_cli.commands._util import print_json_or_exit
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(app: typer.Typer) -> None:
|
|
13
|
+
@app.command(
|
|
14
|
+
"list-product-orders",
|
|
15
|
+
help=(
|
|
16
|
+
"GET get_all_product_orders — designer product (SKU) list. "
|
|
17
|
+
"For 大货 use list-all-production-orders or list-production-orders-by-product."
|
|
18
|
+
),
|
|
19
|
+
)
|
|
20
|
+
def list_product_orders(
|
|
21
|
+
limit: int | None = typer.Option(
|
|
22
|
+
None,
|
|
23
|
+
"--limit",
|
|
24
|
+
"-n",
|
|
25
|
+
help="Optional page size.",
|
|
26
|
+
),
|
|
27
|
+
offset: int = typer.Option(0, "--offset", help="Pagination offset."),
|
|
28
|
+
user_id: str | None = typer.Option(
|
|
29
|
+
None,
|
|
30
|
+
"--user-id",
|
|
31
|
+
help="If set, only product orders for this designer user id.",
|
|
32
|
+
),
|
|
33
|
+
searchby: str | None = typer.Option(
|
|
34
|
+
None,
|
|
35
|
+
"--searchby",
|
|
36
|
+
help="Optional search string (server-defined matching).",
|
|
37
|
+
),
|
|
38
|
+
) -> None:
|
|
39
|
+
"""
|
|
40
|
+
List designer product orders (admin “product” / project rows).
|
|
41
|
+
|
|
42
|
+
This is NOT the same as production (bulk) orders: ``get_all_product_orders`` returns
|
|
43
|
+
product-order records. To see **大货 / production orders**, use
|
|
44
|
+
``list-all-production-orders`` or ``list-production-orders-by-product <product_id>`` —
|
|
45
|
+
production POs are exposed on ``/admin/production_orders``, not only via this list.
|
|
46
|
+
|
|
47
|
+
Input: optional --limit, --offset, --user-id, --searchby on
|
|
48
|
+
GET /admin/designer/get_all_product_orders.
|
|
49
|
+
|
|
50
|
+
Output: JSON with success, result (serialized product rows), and pagination
|
|
51
|
+
(total, limit, offset, has_more).
|
|
52
|
+
"""
|
|
53
|
+
print_json_or_exit(
|
|
54
|
+
console,
|
|
55
|
+
lambda: api_client.get_all_product_orders(
|
|
56
|
+
limit=limit,
|
|
57
|
+
offset=offset,
|
|
58
|
+
user_id=user_id,
|
|
59
|
+
searchby=searchby,
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@app.command(
|
|
64
|
+
"list-all-sampling-orders",
|
|
65
|
+
help="GET /admin/sampling_orders/get_all — paginated list of all sampling orders (admin).",
|
|
66
|
+
)
|
|
67
|
+
def list_all_sampling_orders(
|
|
68
|
+
limit: int | None = typer.Option(
|
|
69
|
+
None,
|
|
70
|
+
"--limit",
|
|
71
|
+
"-n",
|
|
72
|
+
help="Optional page size.",
|
|
73
|
+
),
|
|
74
|
+
offset: int = typer.Option(0, "--offset", help="Pagination offset."),
|
|
75
|
+
no_document_metadata: bool = typer.Option(
|
|
76
|
+
False,
|
|
77
|
+
"--no-document-metadata",
|
|
78
|
+
help="Set include_document_metadata=false (default on API is true).",
|
|
79
|
+
),
|
|
80
|
+
) -> None:
|
|
81
|
+
"""
|
|
82
|
+
List all sampling orders across products (admin).
|
|
83
|
+
|
|
84
|
+
Input: optional --limit, --offset, and --no-document-metadata (query
|
|
85
|
+
include_document_metadata, default true when omitted).
|
|
86
|
+
|
|
87
|
+
Output: JSON from GET /admin/sampling_orders/get_all (success, orders, pagination, etc.).
|
|
88
|
+
"""
|
|
89
|
+
include_meta: bool | None = False if no_document_metadata else None
|
|
90
|
+
print_json_or_exit(
|
|
91
|
+
console,
|
|
92
|
+
lambda: api_client.get_all_sampling_orders(
|
|
93
|
+
limit=limit,
|
|
94
|
+
offset=offset,
|
|
95
|
+
include_document_metadata=include_meta,
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@app.command(
|
|
100
|
+
"list-all-production-orders",
|
|
101
|
+
help="GET /admin/production_orders — paginated list of production / restock orders (admin).",
|
|
102
|
+
)
|
|
103
|
+
def list_all_production_orders(
|
|
104
|
+
limit: int | None = typer.Option(
|
|
105
|
+
None,
|
|
106
|
+
"--limit",
|
|
107
|
+
"-n",
|
|
108
|
+
help="Optional page size.",
|
|
109
|
+
),
|
|
110
|
+
offset: int = typer.Option(0, "--offset", help="Pagination offset."),
|
|
111
|
+
order_type: str | None = typer.Option(
|
|
112
|
+
None,
|
|
113
|
+
"--type",
|
|
114
|
+
help="Filter: PRODUCTION or RESTOCK (omit for all types the API returns).",
|
|
115
|
+
),
|
|
116
|
+
) -> None:
|
|
117
|
+
"""
|
|
118
|
+
List all production (bulk) orders (admin).
|
|
119
|
+
|
|
120
|
+
Use this (or list-production-orders-by-product) for **大货** PO data — not
|
|
121
|
+
list-product-orders alone.
|
|
122
|
+
|
|
123
|
+
Input: optional --limit, --offset, --type (PRODUCTION | RESTOCK).
|
|
124
|
+
|
|
125
|
+
Output: JSON from GET /admin/production_orders.
|
|
126
|
+
"""
|
|
127
|
+
if order_type is not None:
|
|
128
|
+
ot = order_type.strip().upper()
|
|
129
|
+
if ot not in ("PRODUCTION", "RESTOCK"):
|
|
130
|
+
console.print(
|
|
131
|
+
"[red]--type must be PRODUCTION or RESTOCK (or omit).[/red]"
|
|
132
|
+
)
|
|
133
|
+
raise typer.Exit(1)
|
|
134
|
+
order_type = ot
|
|
135
|
+
print_json_or_exit(
|
|
136
|
+
console,
|
|
137
|
+
lambda: api_client.get_all_production_orders(
|
|
138
|
+
limit=limit,
|
|
139
|
+
offset=offset,
|
|
140
|
+
order_type=order_type,
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
@app.command(
|
|
145
|
+
"list-sampling-orders-by-product",
|
|
146
|
+
help="GET /admin/products/{id}/sampling_orders — sampling orders for one product (admin).",
|
|
147
|
+
)
|
|
148
|
+
def list_sampling_orders_by_product(
|
|
149
|
+
product_reference_id: str = typer.Argument(
|
|
150
|
+
...,
|
|
151
|
+
help="Product / product-order reference id (UUID path segment).",
|
|
152
|
+
),
|
|
153
|
+
) -> None:
|
|
154
|
+
"""
|
|
155
|
+
List sampling orders for one product (admin).
|
|
156
|
+
|
|
157
|
+
Input: product_reference_id as URL path on
|
|
158
|
+
GET /admin/products/{product_reference_id}/sampling_orders.
|
|
159
|
+
|
|
160
|
+
Output: JSON { success, result } where result is a list of sampling order payloads.
|
|
161
|
+
"""
|
|
162
|
+
print_json_or_exit(
|
|
163
|
+
console,
|
|
164
|
+
lambda: api_client.get_sampling_orders_by_product(product_reference_id),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@app.command(
|
|
168
|
+
"list-production-orders-by-product",
|
|
169
|
+
help=(
|
|
170
|
+
"GET /admin/products/{id}/production_orders — 大货 POs for one product; "
|
|
171
|
+
"see also list-all-production-orders."
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
def list_production_orders_by_product(
|
|
175
|
+
product_reference_id: str = typer.Argument(
|
|
176
|
+
...,
|
|
177
|
+
help="Product / product-order reference id (UUID path segment).",
|
|
178
|
+
),
|
|
179
|
+
limit: int | None = typer.Option(
|
|
180
|
+
None,
|
|
181
|
+
"--limit",
|
|
182
|
+
"-n",
|
|
183
|
+
help="Optional page size.",
|
|
184
|
+
),
|
|
185
|
+
offset: int = typer.Option(0, "--offset", help="Pagination offset."),
|
|
186
|
+
) -> None:
|
|
187
|
+
"""
|
|
188
|
+
List production orders for one product (admin).
|
|
189
|
+
|
|
190
|
+
Input: product_reference_id (path) and optional --limit / --offset query params on
|
|
191
|
+
GET /admin/products/{product_reference_id}/production_orders.
|
|
192
|
+
|
|
193
|
+
Output: JSON { success, production_orders, count, product_id }.
|
|
194
|
+
"""
|
|
195
|
+
print_json_or_exit(
|
|
196
|
+
console,
|
|
197
|
+
lambda: api_client.get_production_orders_by_product(
|
|
198
|
+
product_reference_id,
|
|
199
|
+
limit=limit,
|
|
200
|
+
offset=offset,
|
|
201
|
+
),
|
|
202
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from makea_cli import api_client
|
|
11
|
+
from makea_cli.commands._util import print_json_or_exit
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _technical_specifications_for_persist(specs: list[Any]) -> list[dict[str, Any]]:
|
|
17
|
+
"""Strip extract-only fields; keep the shape expected by POST technical-specifications."""
|
|
18
|
+
out: list[dict[str, Any]] = []
|
|
19
|
+
for raw in specs:
|
|
20
|
+
if not isinstance(raw, dict):
|
|
21
|
+
continue
|
|
22
|
+
if "component" not in raw:
|
|
23
|
+
continue
|
|
24
|
+
out.append(
|
|
25
|
+
{
|
|
26
|
+
"component": raw["component"],
|
|
27
|
+
"specification": list(raw.get("specification") or []),
|
|
28
|
+
"notes_and_quality_expectations": list(
|
|
29
|
+
raw.get("notes_and_quality_expectations") or []
|
|
30
|
+
),
|
|
31
|
+
"need_proposal_from_supplier": bool(
|
|
32
|
+
raw.get("need_proposal_from_supplier", False)
|
|
33
|
+
),
|
|
34
|
+
"documents": list(raw.get("documents") or []),
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_technical_specifications_array(raw: Any) -> list[Any]:
|
|
41
|
+
"""Accept root JSON as [...] or {\"technical_specifications\": [...]} or extract-shaped dict."""
|
|
42
|
+
if isinstance(raw, list):
|
|
43
|
+
return raw
|
|
44
|
+
if isinstance(raw, dict):
|
|
45
|
+
inner = raw.get("technical_specifications")
|
|
46
|
+
if isinstance(inner, list):
|
|
47
|
+
return inner
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"JSON must be a list of specification objects, or an object with key "
|
|
50
|
+
"'technical_specifications' (e.g. output from extract-ai-tech-spec)."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def register(app: typer.Typer) -> None:
|
|
55
|
+
@app.command(
|
|
56
|
+
"extract-ai-tech-spec",
|
|
57
|
+
help="GET /admin/products/{id}/extract-tech-specifications — AI-generate specs (not saved).",
|
|
58
|
+
)
|
|
59
|
+
def extract_ai_tech_spec(
|
|
60
|
+
product_reference_id: str = typer.Argument(
|
|
61
|
+
...,
|
|
62
|
+
help="Product / product-order reference id (UUID in the URL path).",
|
|
63
|
+
),
|
|
64
|
+
timeout: float = typer.Option(
|
|
65
|
+
300.0,
|
|
66
|
+
"--timeout",
|
|
67
|
+
"-t",
|
|
68
|
+
help="HTTP client timeout in seconds (AI extraction can be slow).",
|
|
69
|
+
min=30.0,
|
|
70
|
+
max=3600.0,
|
|
71
|
+
),
|
|
72
|
+
) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Run the admin AI pipeline to build Technical Specifications for one product.
|
|
75
|
+
|
|
76
|
+
This calls GET /admin/products/{product_reference_id}/extract-tech-specifications only.
|
|
77
|
+
Results are not written to the product; use save-product-tech-spec to persist.
|
|
78
|
+
|
|
79
|
+
Input: product_reference_id (path). Optional --timeout (default 300s).
|
|
80
|
+
|
|
81
|
+
Output: JSON with success, product_reference_id, technical_specifications, count,
|
|
82
|
+
optional review_notes; or success=false with error.
|
|
83
|
+
"""
|
|
84
|
+
print_json_or_exit(
|
|
85
|
+
console,
|
|
86
|
+
lambda: api_client.extract_ai_tech_specifications(
|
|
87
|
+
product_reference_id,
|
|
88
|
+
timeout=timeout,
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@app.command(
|
|
93
|
+
"save-product-tech-spec",
|
|
94
|
+
help="POST /admin/products/{id}/technical-specifications — persist specs from a JSON file.",
|
|
95
|
+
)
|
|
96
|
+
def save_product_tech_spec(
|
|
97
|
+
product_reference_id: str = typer.Argument(
|
|
98
|
+
...,
|
|
99
|
+
help="Product / product-order reference id (UUID in the URL path).",
|
|
100
|
+
),
|
|
101
|
+
file: Path = typer.Option(
|
|
102
|
+
...,
|
|
103
|
+
"--file",
|
|
104
|
+
"-f",
|
|
105
|
+
exists=True,
|
|
106
|
+
dir_okay=False,
|
|
107
|
+
readable=True,
|
|
108
|
+
help=(
|
|
109
|
+
"UTF-8 JSON: a list of spec objects, or an object with "
|
|
110
|
+
"'technical_specifications' (e.g. saved extract-ai-tech-spec output)."
|
|
111
|
+
),
|
|
112
|
+
),
|
|
113
|
+
timeout: float = typer.Option(
|
|
114
|
+
120.0,
|
|
115
|
+
"--timeout",
|
|
116
|
+
"-t",
|
|
117
|
+
help="HTTP client timeout in seconds for the multipart POST.",
|
|
118
|
+
min=30.0,
|
|
119
|
+
max=600.0,
|
|
120
|
+
),
|
|
121
|
+
) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Save technical specifications onto the product (admin).
|
|
124
|
+
|
|
125
|
+
Calls POST /admin/products/{product_reference_id}/technical-specifications with
|
|
126
|
+
multipart form field ``technical_specifications`` set to a JSON array string, matching
|
|
127
|
+
the admin API contract.
|
|
128
|
+
|
|
129
|
+
Input: product_reference_id and --file. The file may be:
|
|
130
|
+
- a JSON array of component rows (same shape as ``technical_specifications`` in extract output);
|
|
131
|
+
- or a JSON object that includes ``technical_specifications`` (e.g. full response from
|
|
132
|
+
extract-ai-tech-spec redirected to a file).
|
|
133
|
+
|
|
134
|
+
Output: JSON from the API (success, technical_specifications, updated_by, message, etc.).
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
raw = json.loads(file.read_text(encoding="utf-8"))
|
|
138
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
139
|
+
console.print(f"[red]Invalid JSON file: {e}[/red]")
|
|
140
|
+
raise typer.Exit(1) from e
|
|
141
|
+
try:
|
|
142
|
+
arr = _load_technical_specifications_array(raw)
|
|
143
|
+
except ValueError as e:
|
|
144
|
+
console.print(f"[red]{e}[/red]")
|
|
145
|
+
raise typer.Exit(1) from e
|
|
146
|
+
specs = _technical_specifications_for_persist(arr)
|
|
147
|
+
if not specs:
|
|
148
|
+
console.print(
|
|
149
|
+
"[red]No valid specification rows after parsing (need 'component' on each).[/red]"
|
|
150
|
+
)
|
|
151
|
+
raise typer.Exit(1)
|
|
152
|
+
|
|
153
|
+
print_json_or_exit(
|
|
154
|
+
console,
|
|
155
|
+
lambda: api_client.update_product_technical_specifications(
|
|
156
|
+
product_reference_id,
|
|
157
|
+
specs,
|
|
158
|
+
timeout=timeout,
|
|
159
|
+
),
|
|
160
|
+
)
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from makea_cli import api_client
|
|
10
|
+
from makea_cli.commands._util import print_json_or_exit
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _normalize_add_supplier_body(raw: dict) -> dict:
|
|
16
|
+
"""API expects {"supplier": {...}}; allow file root to be the inner supplier object only."""
|
|
17
|
+
inner = raw.get("supplier")
|
|
18
|
+
if isinstance(inner, dict):
|
|
19
|
+
return raw
|
|
20
|
+
return {"supplier": raw}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def register(app: typer.Typer) -> None:
|
|
24
|
+
@app.command(
|
|
25
|
+
"list-suppliers",
|
|
26
|
+
help="GET /admin/supplier/get_all_suppliers — list all suppliers (admin).",
|
|
27
|
+
)
|
|
28
|
+
def list_suppliers() -> None:
|
|
29
|
+
"""
|
|
30
|
+
List all suppliers (admin).
|
|
31
|
+
|
|
32
|
+
Input: none (GET /admin/supplier/get_all_suppliers).
|
|
33
|
+
|
|
34
|
+
Output: JSON body (typically success, result).
|
|
35
|
+
"""
|
|
36
|
+
print_json_or_exit(console, api_client.get_all_suppliers)
|
|
37
|
+
|
|
38
|
+
@app.command(
|
|
39
|
+
"add-supplier",
|
|
40
|
+
help="POST /admin/supplier/add_supplier — create supplier from JSON file (admin).",
|
|
41
|
+
)
|
|
42
|
+
def add_supplier(
|
|
43
|
+
file: Path = typer.Option(
|
|
44
|
+
...,
|
|
45
|
+
"--file",
|
|
46
|
+
"-f",
|
|
47
|
+
exists=True,
|
|
48
|
+
dir_okay=False,
|
|
49
|
+
readable=True,
|
|
50
|
+
help='Path to UTF-8 JSON: either the supplier object alone or {"supplier": {...}}.',
|
|
51
|
+
),
|
|
52
|
+
) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Create a supplier (admin).
|
|
55
|
+
|
|
56
|
+
HTTP: POST /admin/supplier/add_supplier with JSON body {\"supplier\": <object>}.
|
|
57
|
+
|
|
58
|
+
Input file
|
|
59
|
+
- Root may be the supplier fields object only (recommended), or the full envelope
|
|
60
|
+
{\"supplier\": {...}}.
|
|
61
|
+
- The server parses this with Supplier.to_domain; omitted supplier_id is assigned a
|
|
62
|
+
new UUID; created_at / updated_at are set server-side when missing.
|
|
63
|
+
|
|
64
|
+
Supplier object — fields the backend understands (all optional unless you need them
|
|
65
|
+
in the UI; empty strings are accepted for several core fields):
|
|
66
|
+
|
|
67
|
+
- Identity / classification: supplier_id (omit to auto-generate), name or
|
|
68
|
+
supplier_name, type or supplier_type, location or supplier_location,
|
|
69
|
+
manufacturer_type (string or list), sub_categories, status, website,
|
|
70
|
+
supplier_user_id (Cognito user id if the supplier logs in).
|
|
71
|
+
- Profile: specialization or specialisation, description, internal_notes,
|
|
72
|
+
rating_internal, number_of_employees or factory_size, year_established or
|
|
73
|
+
established_in, machinery_equipment, monthly_capacity, leadtime_guidance,
|
|
74
|
+
sample_leadtime_guidance, MOQ_guidance, MOQ_guidance_type, moq_by_category,
|
|
75
|
+
trade_terms, sourcing_support, tech_pack_support, impexp_license,
|
|
76
|
+
impexp_license_experience, customs_experience, stock_availabilities,
|
|
77
|
+
number_of_in_stock_product, product_update_methods, payment_terms_guidance
|
|
78
|
+
(string or list), currency (string or list), primary_markets, clients,
|
|
79
|
+
end_consumer_focus, material_system, qc_options, has_inhouse_product_development_team,
|
|
80
|
+
product_development_structure, has_sourcing_support, business_registration_number,
|
|
81
|
+
parent_company, extra_info, onboard_source.
|
|
82
|
+
- Primary contact: contact_name, contact_email, contact_phone, contact_title,
|
|
83
|
+
contact_whatsapp_wechat_id.
|
|
84
|
+
- point_of_contact: list of {name, email, phone, title?, whatsapp_wechat_id?}.
|
|
85
|
+
- certificates: list of {name, id_number, file_url?}.
|
|
86
|
+
- Documents (each item a Document-shaped dict): catalogue, images, videos,
|
|
87
|
+
business_license, tax_registration_certificate, insurance_certificate.
|
|
88
|
+
- banking_details (optional object): bank_region (CHINA|EUROPE|INDIA|USA|OTHER),
|
|
89
|
+
beneficiary_name, beneficiary_address, bank_name, bank_address, swift_code;
|
|
90
|
+
iban (EU), account_number (USA/China/India/Other), routing_number (USA),
|
|
91
|
+
ifsc_code (India), phone_number (China), banking_info_files (document list).
|
|
92
|
+
|
|
93
|
+
Output: JSON {success, result} with HTTP 201 on success; result is the stored supplier
|
|
94
|
+
as a flat dict (dataclass asdict), including the assigned supplier_id.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
raw = json.loads(file.read_text(encoding="utf-8"))
|
|
98
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
99
|
+
console.print(f"[red]Invalid JSON file: {e}[/red]")
|
|
100
|
+
raise typer.Exit(1) from e
|
|
101
|
+
if not isinstance(raw, dict):
|
|
102
|
+
console.print("[red]JSON root must be an object.[/red]")
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
body = _normalize_add_supplier_body(raw)
|
|
105
|
+
print_json_or_exit(console, lambda: api_client.add_supplier(body))
|
|
106
|
+
|
|
107
|
+
@app.command(
|
|
108
|
+
"backfill-supplier-id",
|
|
109
|
+
help=(
|
|
110
|
+
"Migrate supplier_id from a wrong UUID to the Cognito user sub (user_id): "
|
|
111
|
+
"profile + Cognito supplierReferenceId, optional S3/DOCUMENT#, patch product "
|
|
112
|
+
"supplier_links, link→quote-request backfill (archived migration QRs), "
|
|
113
|
+
"sampling/production order supplier fields, optional delete old SUPPLIER# "
|
|
114
|
+
"partition. Default is dry-run; use --apply to write. "
|
|
115
|
+
"HTTP: POST /admin/supplier/backfill_supplier_id_with_quote_requests."
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
def backfill_supplier_id(
|
|
119
|
+
user_id: str = typer.Option(
|
|
120
|
+
...,
|
|
121
|
+
"--user-id",
|
|
122
|
+
help=(
|
|
123
|
+
"Cognito sub of the supplier user — this becomes the canonical supplier_id "
|
|
124
|
+
"after migration."
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
old_supplier_id: str = typer.Option(
|
|
128
|
+
...,
|
|
129
|
+
"--old-supplier-id",
|
|
130
|
+
help=(
|
|
131
|
+
"Current wrong supplier_id stored on SUPPLIER#.../METADATA and links "
|
|
132
|
+
"(the UUID to migrate away from)."
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
dry_run: bool = typer.Option(
|
|
136
|
+
True,
|
|
137
|
+
"--dry-run/--apply",
|
|
138
|
+
help=(
|
|
139
|
+
"Default: --dry-run (server previews only). "
|
|
140
|
+
"--apply sends dry_run=false and performs Dynamo/Cognito/S3 writes per flags."
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
skip_s3_documents: bool = typer.Option(
|
|
144
|
+
False,
|
|
145
|
+
"--skip-s3-documents",
|
|
146
|
+
help=(
|
|
147
|
+
"Skip server step 2b: copy suppliers/{old}/documents/* in S3, rewrite "
|
|
148
|
+
"METADATA s3_key paths, migrate DOCUMENT# rows to SUPPLIER#{user_id}."
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
skip_quote_request_backfill: bool = typer.Option(
|
|
152
|
+
False,
|
|
153
|
+
"--skip-quote-request-backfill",
|
|
154
|
+
help=(
|
|
155
|
+
"Skip per-product link→quote_request backfill AND step 5b (no automatic "
|
|
156
|
+
"archiving of QRs still keyed by old_supplier_id from this run)."
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
quote_request_only: bool = typer.Option(
|
|
160
|
+
False,
|
|
161
|
+
"--quote-request-only",
|
|
162
|
+
help=(
|
|
163
|
+
"Server skips profile creation, Cognito, link supplier_id patch, and "
|
|
164
|
+
"order patches; only scans products and runs link→QR backfill (+ 5b). "
|
|
165
|
+
"Use when profile/links are already fixed."
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
delete_old_supplier_at_end: bool = typer.Option(
|
|
169
|
+
False,
|
|
170
|
+
"--delete-old-supplier-at-end",
|
|
171
|
+
help=(
|
|
172
|
+
"After a successful full run, delete all items with PK=SUPPLIER#{old_id}. "
|
|
173
|
+
"Server refuses if old METADATA is not marked migrated or if QRs still "
|
|
174
|
+
"use old_supplier_id. Non-dry-run requires --confirm-delete-old-supplier."
|
|
175
|
+
),
|
|
176
|
+
),
|
|
177
|
+
confirm_delete_old_supplier: bool = typer.Option(
|
|
178
|
+
False,
|
|
179
|
+
"--confirm-delete-old-supplier",
|
|
180
|
+
help=(
|
|
181
|
+
"Acknowledge destructive delete of the old supplier partition. Required "
|
|
182
|
+
"with --delete-old-supplier-at-end when using --apply (server enforces too)."
|
|
183
|
+
),
|
|
184
|
+
),
|
|
185
|
+
) -> None:
|
|
186
|
+
"""
|
|
187
|
+
Consolidated supplier **user_id ↔ supplier_id** migration (admin).
|
|
188
|
+
|
|
189
|
+
**When to use**
|
|
190
|
+
|
|
191
|
+
Production expects ``supplier_id`` to equal the supplier's Cognito ``sub``
|
|
192
|
+
(``user_id``). If the DynamoDB profile was created under a random UUID
|
|
193
|
+
(``old_supplier_id``), this command runs the full migration on the server.
|
|
194
|
+
|
|
195
|
+
**What runs on the server** (same as ``manage.py backfill_supplier_id``)
|
|
196
|
+
|
|
197
|
+
- Write new profile under ``SUPPLIER#{user_id}``, mark old as migrated.
|
|
198
|
+
- Set Cognito ``custom:supplierReferenceId`` → ``user_id``.
|
|
199
|
+
- Unless ``--skip-s3-documents``: S3 document copy + ``DOCUMENT#`` row migration.
|
|
200
|
+
- Patch ``supplier_links[].supplier_id`` from old → ``user_id``.
|
|
201
|
+
- Link→quote-request backfill (unless skipped); migration QRs are archived.
|
|
202
|
+
- Patch sampling/production orders referencing the old id.
|
|
203
|
+
- Optionally delete the old ``SUPPLIER#{old_supplier_id}`` partition at end.
|
|
204
|
+
|
|
205
|
+
**Not available through this API** (use garmentby-service venv + ``manage.py``):
|
|
206
|
+
|
|
207
|
+
- ``--s3-documents-only``
|
|
208
|
+
- ``--delete-old-supplier-only``
|
|
209
|
+
|
|
210
|
+
**Output**
|
|
211
|
+
|
|
212
|
+
JSON includes ``output`` (server command stdout) and ``errors`` (stderr). Read
|
|
213
|
+
them after dry-run before ``--apply``.
|
|
214
|
+
|
|
215
|
+
**After migration**
|
|
216
|
+
|
|
217
|
+
If quote request **stage/status** still look wrong, repair with
|
|
218
|
+
``manage.py reconcile_quote_request_stage_status`` on **garmentby-service**
|
|
219
|
+
for the **new** supplier id (usually ``user_id``).
|
|
220
|
+
|
|
221
|
+
**Skill**
|
|
222
|
+
|
|
223
|
+
Operator runbook: ``.claude/skills/supplier-user-id-migration/SKILL.md`` in this repo.
|
|
224
|
+
"""
|
|
225
|
+
if (
|
|
226
|
+
delete_old_supplier_at_end
|
|
227
|
+
and not confirm_delete_old_supplier
|
|
228
|
+
and not dry_run
|
|
229
|
+
):
|
|
230
|
+
console.print(
|
|
231
|
+
"[red]--confirm-delete-old-supplier is required with "
|
|
232
|
+
"--delete-old-supplier-at-end when using --apply.[/red]"
|
|
233
|
+
)
|
|
234
|
+
raise typer.Exit(1)
|
|
235
|
+
|
|
236
|
+
print_json_or_exit(
|
|
237
|
+
console,
|
|
238
|
+
lambda: api_client.backfill_supplier_id_with_quote_requests(
|
|
239
|
+
user_id=user_id,
|
|
240
|
+
old_supplier_id=old_supplier_id,
|
|
241
|
+
dry_run=dry_run,
|
|
242
|
+
skip_s3_documents=skip_s3_documents,
|
|
243
|
+
skip_quote_request_backfill=skip_quote_request_backfill,
|
|
244
|
+
quote_request_only=quote_request_only,
|
|
245
|
+
delete_old_supplier_at_end=delete_old_supplier_at_end,
|
|
246
|
+
confirm_delete_old_supplier=confirm_delete_old_supplier,
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
@app.command(
|
|
251
|
+
"get-quote-requests-by-supplier-id",
|
|
252
|
+
help=(
|
|
253
|
+
"GET /admin/quote_requests/supplier/{supplier_id} — direct backend query."
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
def get_quote_requests_by_supplier_id(
|
|
257
|
+
supplier_id: str = typer.Argument(..., help="Supplier id to filter by."),
|
|
258
|
+
status: str | None = typer.Option(
|
|
259
|
+
None,
|
|
260
|
+
"--status",
|
|
261
|
+
help="Optional quote request status filter.",
|
|
262
|
+
),
|
|
263
|
+
limit: int | None = typer.Option(
|
|
264
|
+
None,
|
|
265
|
+
"--limit",
|
|
266
|
+
"-n",
|
|
267
|
+
help="Optional max quote requests to return (backend-enforced).",
|
|
268
|
+
),
|
|
269
|
+
) -> None:
|
|
270
|
+
"""
|
|
271
|
+
Direct backend lookup by supplier id.
|
|
272
|
+
"""
|
|
273
|
+
print_json_or_exit(
|
|
274
|
+
console,
|
|
275
|
+
lambda: api_client.get_quote_requests_by_supplier_id(
|
|
276
|
+
supplier_id=supplier_id,
|
|
277
|
+
status=status,
|
|
278
|
+
limit=limit,
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
@app.command(
|
|
283
|
+
"get-supplier-links-by-supplier-id",
|
|
284
|
+
help=(
|
|
285
|
+
"GET /admin/product/linked_suppliers/by_supplier/{supplier_id} — direct backend query."
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
def get_supplier_links_by_supplier_id(
|
|
289
|
+
supplier_id: str = typer.Argument(..., help="Supplier id to filter by."),
|
|
290
|
+
link_type: str | None = typer.Option(
|
|
291
|
+
None,
|
|
292
|
+
"--link-type",
|
|
293
|
+
help="Optional filter: SAMPLING or PRODUCTION.",
|
|
294
|
+
),
|
|
295
|
+
) -> None:
|
|
296
|
+
"""
|
|
297
|
+
Direct backend lookup by supplier id.
|
|
298
|
+
"""
|
|
299
|
+
print_json_or_exit(
|
|
300
|
+
console,
|
|
301
|
+
lambda: api_client.get_supplier_links_by_supplier_id(
|
|
302
|
+
supplier_id=supplier_id,
|
|
303
|
+
link_type=link_type,
|
|
304
|
+
),
|
|
305
|
+
)
|