@miraiva_test/miravia-order-report 0.1.13

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.
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Miravia Order Report - Configuration
4
+
5
+ Important convention (since v0.2):
6
+ appKey / appSecret / accessToken NO LONGER have any built-in mock defaults.
7
+ The only way to obtain credentials:
8
+ 1) Start a local callback service: python auth_cli.py login
9
+ 2) Browser redirects to staging authorization page:
10
+ https://pre-sellercenter.miravia.es/apps/seller/skill/auth
11
+ 3) After seller completes authorization, frontend sends encryptedPayload
12
+ back to local callback via redirect_uri.
13
+ 4) CLI uses private key from .keypair.json for segmented RSA decryption,
14
+ writes to credentials.json.
15
+
16
+ All callers (including MiraviaClient) must complete the above authorization
17
+ first, otherwise an error is raised.
18
+ """
19
+
20
+ import json
21
+ import os
22
+
23
+ _SKILL_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
24
+
25
+ # Local credentials file (used only by this Skill; .qoder/skills/ is in .gitignore)
26
+ # Path: <repo>/.qoder/skills/miravia-order-report/credentials.json
27
+ CRED_FILE = os.getenv(
28
+ "MIRAVIA_CRED_FILE",
29
+ os.path.join(_SKILL_DIR, "credentials.json"),
30
+ )
31
+
32
+ # Local RSA keypair file (private key generated by backend generateKeyPair, manually saved)
33
+ # Path: <repo>/.qoder/skills/miravia-order-report/.keypair.json
34
+ KEYPAIR_FILE = os.getenv(
35
+ "MIRAVIA_KEYPAIR_FILE",
36
+ os.path.join(_SKILL_DIR, ".keypair.json"),
37
+ )
38
+
39
+
40
+ # JSON field name to environment variable mapping (credentials.json uses snake_case short names)
41
+ _JSON_KEY_MAP = {
42
+ "MIRAVIA_APP_KEY": "app_key",
43
+ "MIRAVIA_APP_SECRET": "app_secret",
44
+ "MIRAVIA_ACCESS_TOKEN": "access_token",
45
+ "MIRAVIA_TOKEN_EXPIRES_AT": "token_expires_at",
46
+ "MIRAVIA_LAST_LOGIN_AT": "last_login_at",
47
+ }
48
+
49
+ _SENSITIVE_JSON_KEYS = ("app_key", "app_secret", "access_token")
50
+
51
+
52
+ def _maybe_decrypt(json_key: str, value: str) -> str:
53
+ """Decrypt if this is a sensitive field with ``enc:v1:`` prefix.
54
+
55
+ If a sensitive field is plaintext, return empty string (treated as invalid),
56
+ letting require_business_credentials() prompt re-authorization later.
57
+ """
58
+ if json_key not in _SENSITIVE_JSON_KEYS:
59
+ return value
60
+ if not value:
61
+ return ""
62
+ if not isinstance(value, str) or not value.startswith("enc:"):
63
+ # Plaintext credentials are no longer accepted, treated as invalid
64
+ return ""
65
+ try:
66
+ from cred_crypto import decrypt_value, CredCryptoError
67
+ except ImportError:
68
+ return ""
69
+ try:
70
+ return decrypt_value(value)
71
+ except CredCryptoError:
72
+ return ""
73
+
74
+
75
+ def _read_cred_json(key: str) -> str:
76
+ """Read a key from credentials.json at import time (auto-decrypts)."""
77
+ if not os.path.exists(CRED_FILE):
78
+ return ""
79
+ json_key = _JSON_KEY_MAP.get(key)
80
+ if not json_key:
81
+ return ""
82
+ try:
83
+ with open(CRED_FILE, "r", encoding="utf-8") as f:
84
+ data = json.load(f)
85
+ raw = data.get(json_key, "")
86
+ if raw is None:
87
+ return ""
88
+ return _maybe_decrypt(json_key, str(raw))
89
+ except (OSError, json.JSONDecodeError):
90
+ pass
91
+ return ""
92
+
93
+
94
+ def _resolve(key: str) -> str:
95
+ """Unified credential lookup order: env var > credentials.json > empty string."""
96
+ return os.getenv(key) or _read_cred_json(key) or ""
97
+
98
+
99
+ # Business credentials (NEVER write default values); if empty at runtime, caller raises error and prompts authorization.
100
+ APP_KEY = _resolve("MIRAVIA_APP_KEY")
101
+ APP_SECRET = _resolve("MIRAVIA_APP_SECRET")
102
+ ACCESS_TOKEN = _resolve("MIRAVIA_ACCESS_TOKEN")
103
+
104
+ API_BASE = os.getenv("MIRAVIA_API_BASE", "https://pre-api.miravia.es/rest")
105
+ SIGN_METHOD = "sha256"
106
+
107
+ # Skill Hub authorization page (frontend page, POST /skill/auth is handled internally)
108
+ # Staging: https://pre-sellercenter.miravia.es/apps/seller/skill/auth
109
+ # Production: https://sellercenter.miravia.es/apps/seller/skill/auth
110
+ SKILL_HUB_AUTH_URL = os.getenv(
111
+ "MIRAVIA_SKILL_HUB_AUTH_URL",
112
+ "https://pre-sellercenter.miravia.es/apps/seller/skill/auth",
113
+ )
114
+
115
+ # Current skill version — single source of truth: npm-package/package.json
116
+ # At runtime, reads VERSION file (stamped by build-dist.js during npm publish).
117
+ # Falls back to npm-package/package.json for local development in the source repo.
118
+ def _read_version_from_package_json() -> str:
119
+ """Read version: VERSION file (installed) > npm-package/package.json (dev) > fallback."""
120
+ # 1. VERSION file — exists on user machines after npm install/upgrade
121
+ version_file = os.path.join(_SKILL_DIR, "VERSION")
122
+ try:
123
+ with open(version_file, "r", encoding="utf-8") as f:
124
+ v = f.read().strip()
125
+ if v:
126
+ return v
127
+ except OSError:
128
+ pass
129
+ # 2. npm-package/package.json — exists in source repo during development
130
+ pkg_path = os.path.join(_SKILL_DIR, "npm-package", "package.json")
131
+ try:
132
+ with open(pkg_path, "r", encoding="utf-8") as f:
133
+ return json.load(f).get("version", "0.0.0")
134
+ except (OSError, json.JSONDecodeError):
135
+ pass
136
+ return "0.0.0"
137
+
138
+ SKILL_VERSION = _read_version_from_package_json()
139
+
140
+ # Default skill_code: env var > .keypair.json > empty.
141
+ # Falls back to .keypair.json to support out-of-the-box version check without extra config.
142
+ def _read_skill_code_from_keypair() -> str:
143
+ """Read skill_code from .keypair.json as fallback."""
144
+ try:
145
+ kp_path = os.path.join(_SKILL_DIR, ".keypair.json")
146
+ if os.path.exists(kp_path):
147
+ with open(kp_path, "r", encoding="utf-8") as f:
148
+ return json.load(f).get("skill_code", "")
149
+ except (OSError, json.JSONDecodeError):
150
+ pass
151
+ return ""
152
+
153
+
154
+ DEFAULT_SKILL_CODE = os.getenv("MIRAVIA_SKILL_CODE", "") or _read_skill_code_from_keypair()
155
+
156
+ # /orders/get page size limit (API max 100)
157
+ PAGE_SIZE = 100
158
+
159
+ # Max sub-orders per query (cap 200000; exceeding prompts user to split into multiple queries)
160
+ DEFAULT_MAX_ORDERS = 200000
161
+
162
+ # Max main orders per time window (exceeding this triggers auto time-window splitting)
163
+ # Although officially offset+limit <= 10000, testing shows API returns empty data at offset>=5000
164
+ # (inflated countTotal issue), so safe cap is set to 5000.
165
+ ORDERS_PER_WINDOW = 5000
166
+
167
+ # Network timeout and retry
168
+ HTTP_TIMEOUT = 30
169
+ HTTP_RETRY = 3
170
+
171
+ # Concurrent worker threads (for parallel time-window fetching)
172
+ # Set to 10 balancing platform QPS limits and performance; override via MIRAVIA_CONCURRENT_WORKERS.
173
+ CONCURRENT_WORKERS = int(os.getenv("MIRAVIA_CONCURRENT_WORKERS", "10"))
174
+
175
+ # Concurrency trigger threshold: enable concurrent fetching when main orders exceed this value
176
+ CONCURRENT_THRESHOLD = int(os.getenv("MIRAVIA_CONCURRENT_THRESHOLD", "1000"))
177
+
178
+ # Adaptive degradation threshold: when weekly window orders exceed this, auto-fallback to daily splitting.
179
+ # Set to 1.2x ORDERS_PER_WINDOW to prevent silent data loss in the 5001~49999 range
180
+ # (iter_orders truncates at ORDERS_PER_WINDOW due to API offset>=5000 empty-data issue).
181
+ ADAPTIVE_DEGRADE_THRESHOLD = int(os.getenv("MIRAVIA_ADAPTIVE_DEGRADE_THRESHOLD", str(int(ORDERS_PER_WINDOW * 1.2))))
182
+
183
+ # Default timezone: Spain +02:00 (summer CEST); for winter time set MIRAVIA_TZ_OFFSET=+01:00.
184
+ DEFAULT_TZ_OFFSET = os.getenv("MIRAVIA_TZ_OFFSET", "+02:00")
185
+
186
+
187
+ class CredentialMissingError(RuntimeError):
188
+ """Business credentials missing (not authorized or credentials.json is corrupted)."""
189
+
190
+
191
+ def require_business_credentials() -> None:
192
+ """Pre-call validation: APP_KEY / APP_SECRET / ACCESS_TOKEN must all be present."""
193
+ missing = []
194
+ for k, v in (
195
+ ("MIRAVIA_APP_KEY", APP_KEY),
196
+ ("MIRAVIA_APP_SECRET", APP_SECRET),
197
+ ("MIRAVIA_ACCESS_TOKEN", ACCESS_TOKEN),
198
+ ):
199
+ if not v:
200
+ missing.append(k)
201
+ if missing:
202
+ raise CredentialMissingError(
203
+ "missing Miravia credentials: " + ", ".join(missing) + ".\n"
204
+ "Please run authorization in the skill root directory:\n"
205
+ " python scripts/auth_cli.py login\n"
206
+ f"Credentials file: {CRED_FILE}\n"
207
+ f"Authorization URL: {SKILL_HUB_AUTH_URL}"
208
+ )
209
+
210
+
211
+ def mask_token(token: str) -> str:
212
+ if not token:
213
+ return "<empty>"
214
+ if len(token) <= 8:
215
+ return "***"
216
+ return f"***{token[-6:]}"
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Local credential encryption (satisfies PRD §4.5.a: App Token stored encrypted, never plaintext).
5
+
6
+ Design highlights:
7
+ - AES-256-GCM symmetric encryption with independent 12-byte nonce per value.
8
+ - Encryption key derived via HKDF-SHA256 from .keypair.json private key PEM + skill_code
9
+ + machine fingerprint (hostname + OS username).
10
+ Benefits: (1) Even if all sellers share the same .keypair.json (skill packaged distribution),
11
+ different machines derive different keys, so attackers cannot decrypt credentials.json
12
+ + .keypair.json on another machine; (2) No system keychain dependency, cross-platform;
13
+ (3) Credentials auto-expire on machine change/hostname change (reasonable security behavior,
14
+ user must re-run auth_cli.py login).
15
+ - Output format: ``enc:v1:<base64url(nonce || ciphertext || tag)>``.
16
+ - Sensitive fields managed centrally (``SENSITIVE_KEYS``); other fields remain plaintext for status display.
17
+
18
+ Both read/write sides use this module's encrypt_value / decrypt_value; callers need not
19
+ know KDF details. If .keypair.json is missing, this module degrades to "no encryption";
20
+ the upper layer decides whether to refuse (default: refuse writing new credentials,
21
+ but allow reading historical plaintext for backward compatibility).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import hashlib
28
+ import json
29
+ import os
30
+ from typing import Optional, Tuple
31
+
32
+ # Encryption marker prefix; can upgrade to ``enc:v2:`` if algorithm changes in the future.
33
+ ENC_PREFIX = "enc:v1:"
34
+
35
+ # Fields that require encrypted persistence. Other fields (expiry time, login time, etc.)
36
+ # remain plaintext for ``auth_cli.py status`` display and to avoid treating metadata as secrets.
37
+ SENSITIVE_KEYS = (
38
+ "MIRAVIA_APP_KEY",
39
+ "MIRAVIA_APP_SECRET",
40
+ "MIRAVIA_ACCESS_TOKEN",
41
+ )
42
+
43
+
44
+ class CredCryptoError(RuntimeError):
45
+ """Credential encryption/decryption error (missing .keypair.json, corrupted ciphertext, key mismatch, etc.)."""
46
+
47
+
48
+ def _machine_fingerprint() -> bytes:
49
+ """Generate machine fingerprint: hostname + OS username.
50
+
51
+ Purpose: Even if all sellers share the same .keypair.json (skill packaged distribution),
52
+ different machines derive different AES keys. An attacker with only .keypair.json +
53
+ credentials.json ciphertext cannot decrypt on their own machine.
54
+
55
+ Note: Credentials expire after machine change/hostname change/user change;
56
+ user must re-run auth_cli.py login.
57
+ """
58
+ import getpass
59
+ import socket
60
+
61
+ hostname = socket.gethostname() or "unknown-host"
62
+ username = getpass.getuser() or "unknown-user"
63
+ return f"{hostname}|{username}".encode("utf-8")
64
+
65
+
66
+ def _load_aesgcm_key(keypair_path: Optional[str] = None) -> bytes:
67
+ """Derive 32-byte AES-256-GCM key from .keypair.json + machine fingerprint.
68
+
69
+ KDF: HKDF-SHA256
70
+ IKM = SHA256(private_key_pem || "|" || skill_code || "|" || machine_fingerprint)
71
+ salt = "miravia-skill-cred-v2"
72
+ info = "aes-256-gcm"
73
+
74
+ v2 change: Added machine_fingerprint to bind key to specific machine,
75
+ solving the "just copy credentials.json to decrypt" problem in shared private key scenarios.
76
+ """
77
+ import config # Lazy import to avoid circular dependency
78
+
79
+ path = keypair_path or config.KEYPAIR_FILE
80
+ if not os.path.exists(path):
81
+ raise CredCryptoError(
82
+ f"Missing .keypair.json (path: {path}), cannot derive encryption key.\n"
83
+ f"Please persist the Skill Hub-issued private key + skill_code to this file first."
84
+ )
85
+ try:
86
+ with open(path, "r", encoding="utf-8") as f:
87
+ raw = json.load(f)
88
+ except (OSError, json.JSONDecodeError) as e:
89
+ raise CredCryptoError(f".keypair.json read failed: {e}") from e
90
+
91
+ skill_code = (raw.get("skill_code") or "").strip()
92
+ private_key = (raw.get("private_key") or "").strip()
93
+ if not skill_code or not private_key:
94
+ raise CredCryptoError(".keypair.json missing skill_code or private_key")
95
+
96
+ fingerprint = _machine_fingerprint()
97
+ ikm = hashlib.sha256(
98
+ private_key.encode("utf-8") + b"|" + skill_code.encode("utf-8") + b"|" + fingerprint
99
+ ).digest()
100
+ return _hkdf_sha256(ikm, salt=b"miravia-skill-cred-v2", info=b"aes-256-gcm", length=32)
101
+
102
+
103
+ def _hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
104
+ """HKDF-SHA256 (RFC 5869), pure standard library implementation."""
105
+ import hmac
106
+
107
+ if not salt:
108
+ salt = b"\x00" * hashlib.sha256().digest_size
109
+ prk = hmac.new(salt, ikm, hashlib.sha256).digest()
110
+
111
+ out = b""
112
+ t = b""
113
+ counter = 1
114
+ while len(out) < length:
115
+ t = hmac.new(prk, t + info + bytes([counter]), hashlib.sha256).digest()
116
+ out += t
117
+ counter += 1
118
+ return out[:length]
119
+
120
+
121
+ def _b64url_encode(data: bytes) -> str:
122
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
123
+
124
+
125
+ def _b64url_decode(s: str) -> bytes:
126
+ pad = "=" * (-len(s) % 4)
127
+ return base64.urlsafe_b64decode(s + pad)
128
+
129
+
130
+ def is_encrypted(value: str) -> bool:
131
+ return isinstance(value, str) and value.startswith(ENC_PREFIX)
132
+
133
+
134
+ def encrypt_value(plaintext: str, key: Optional[bytes] = None) -> str:
135
+ """Encrypt a single field value, returns ``enc:v1:<...>`` string. Empty/None returns empty string."""
136
+ if plaintext is None or plaintext == "":
137
+ return ""
138
+ try:
139
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
140
+ except ImportError as e: # pragma: no cover
141
+ raise CredCryptoError(
142
+ "Missing cryptography dependency. Run: pip install -r requirements.txt"
143
+ ) from e
144
+
145
+ if key is None:
146
+ key = _load_aesgcm_key()
147
+ nonce = os.urandom(12)
148
+ ct = AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), associated_data=None)
149
+ blob = nonce + ct # AESGCM appends 16-byte tag to ct
150
+ return ENC_PREFIX + _b64url_encode(blob)
151
+
152
+
153
+ def decrypt_value(value: str, key: Optional[bytes] = None) -> str:
154
+ """Decrypt a single field. If non-empty and not prefixed with ``enc:v1:``, treat as plaintext residue and raise error requiring re-authorization."""
155
+ if value is None or value == "":
156
+ return ""
157
+ if not is_encrypted(value):
158
+ raise CredCryptoError(
159
+ f"Detected plaintext credentials (unencrypted). Please re-run auth_cli.py login to encrypt."
160
+ )
161
+ try:
162
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
163
+ except ImportError as e: # pragma: no cover
164
+ raise CredCryptoError(
165
+ "Missing cryptography dependency. Run: pip install -r requirements.txt"
166
+ ) from e
167
+
168
+ if key is None:
169
+ key = _load_aesgcm_key()
170
+ try:
171
+ blob = _b64url_decode(value[len(ENC_PREFIX):])
172
+ except (ValueError, base64.binascii.Error) as e:
173
+ raise CredCryptoError(f"Ciphertext base64 decode failed: {e}") from e
174
+ if len(blob) < 12 + 16:
175
+ raise CredCryptoError("Ciphertext length abnormal (< nonce+tag)")
176
+ nonce, ct = blob[:12], blob[12:]
177
+ try:
178
+ return AESGCM(key).decrypt(nonce, ct, associated_data=None).decode("utf-8")
179
+ except Exception as e: # InvalidTag etc.
180
+ raise CredCryptoError(
181
+ f"AES-GCM decryption failed: {e.__class__.__name__}; "
182
+ f"Possible causes: (1) Machine/hostname/OS user changed (key is bound to machine fingerprint); "
183
+ f"(2) .keypair.json was replaced; (3) credentials.json is corrupted. "
184
+ f"Please re-run auth_cli.py login to re-authorize."
185
+ ) from e
186
+
187
+
188
+ def encrypt_creds(creds: dict, key: Optional[bytes] = None) -> dict:
189
+ """Batch-encrypt sensitive fields in a credentials dict; other fields pass through unchanged."""
190
+ if key is None:
191
+ key = _load_aesgcm_key()
192
+ out = dict(creds)
193
+ for k in SENSITIVE_KEYS:
194
+ v = out.get(k, "")
195
+ if v and not is_encrypted(v):
196
+ out[k] = encrypt_value(v, key=key)
197
+ return out
198
+
199
+
200
+ def decrypt_creds(creds: dict, key: Optional[bytes] = None) -> dict:
201
+ """Batch-decrypt sensitive fields in a credentials dict; non-sensitive fields pass through unchanged.
202
+
203
+ If a sensitive field is plaintext (no ``enc:v1:`` prefix), raises CredCryptoError
204
+ requiring re-authorization. Not backward-compatible with old plaintext credentials.
205
+ """
206
+ out = dict(creds)
207
+ if key is None:
208
+ key = _load_aesgcm_key()
209
+ for k in SENSITIVE_KEYS:
210
+ v = out.get(k, "")
211
+ if v:
212
+ out[k] = decrypt_value(v, key=key)
213
+ return out
214
+
215
+
216
+ def has_keypair() -> bool:
217
+ """Quick check if .keypair.json is available, avoiding exceptions during import."""
218
+ try:
219
+ _load_aesgcm_key()
220
+ return True
221
+ except CredCryptoError:
222
+ return False
223
+
224
+
225
+ __all__ = [
226
+ "ENC_PREFIX",
227
+ "SENSITIVE_KEYS",
228
+ "CredCryptoError",
229
+ "is_encrypted",
230
+ "encrypt_value",
231
+ "decrypt_value",
232
+ "encrypt_creds",
233
+ "decrypt_creds",
234
+ "has_keypair",
235
+ ]
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Lightweight order count probe & time estimator.
5
+
6
+ Calls /orders/get once (limit=1, offset=0) to get countTotal,
7
+ then estimates generation time based on 2000 orders/minute formula.
8
+
9
+ Note: countTotal is a server-side estimate and may not match actual fetched count.
10
+ Agent should only show estimated time to user; never display total_orders value.
11
+
12
+ Usage:
13
+ python3 scripts/estimate_time.py --agent-type claude --last-days 365
14
+ python3 scripts/estimate_time.py --agent-type claude --created-after 2025-06-01 --created-before 2026-06-16
15
+ python3 scripts/estimate_time.py --agent-type claude --last-days 30 --status shipped
16
+ python3 scripts/estimate_time.py --agent-type claude --last-days 30 --channel AE --ship-to ES
17
+
18
+ Output (stdout, structured):
19
+ estimate : {"total_orders": 8371, "estimated_minutes": 5, "estimated_seconds": 252}
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import math
25
+ import os
26
+ import sys
27
+ from datetime import datetime, timedelta, timezone
28
+ from pathlib import Path
29
+ from typing import Optional
30
+
31
+ import config
32
+ from miravia_client import MiraviaClient
33
+ from skill_logger import set_agent_type
34
+
35
+ # Estimation formula: 2000 orders/minute (includes LLM analysis & insight injection overhead)
36
+ ORDERS_PER_MINUTE = 2000
37
+
38
+ # Valid status values (same as export_orders.py)
39
+ VALID_STATUSES = [
40
+ "all",
41
+ # -- aggregated --
42
+ "all_to_ship", "all_shipped",
43
+ # -- specific --
44
+ "topack", "packed", "rts", "canceled",
45
+ "shipped", "delivered",
46
+ ]
47
+
48
+ STATUS_ALIASES = {}
49
+
50
+
51
+ def _tz() -> timezone:
52
+ """Derive tzinfo from config.DEFAULT_TZ_OFFSET, supports env var override (e.g., +02:00 / +01:00)."""
53
+ off = config.DEFAULT_TZ_OFFSET or "+02:00"
54
+ sign = 1 if off[0] == "+" else -1
55
+ hh, mm = off[1:].split(":")
56
+ return timezone(sign * timedelta(hours=int(hh), minutes=int(mm)))
57
+
58
+
59
+ def _resolve_time_range(args) -> tuple:
60
+ """Parse time range arguments, returns (after_iso, before_iso)."""
61
+ tz = _tz()
62
+ now = datetime.now(tz)
63
+
64
+ if args.last_days:
65
+ start = (now - timedelta(days=args.last_days - 1)).replace(
66
+ hour=0, minute=0, second=0, microsecond=0
67
+ )
68
+ end = now.replace(hour=23, minute=59, second=59, microsecond=0)
69
+ else:
70
+ if not args.created_after or not args.created_before:
71
+ print("error: must provide --last-days or both --created-after and --created-before",
72
+ file=sys.stderr)
73
+ sys.exit(1)
74
+ start = datetime.strptime(args.created_after, "%Y-%m-%d").replace(
75
+ hour=0, minute=0, second=0, tzinfo=tz
76
+ )
77
+ end = datetime.strptime(args.created_before, "%Y-%m-%d").replace(
78
+ hour=23, minute=59, second=59, tzinfo=tz
79
+ )
80
+
81
+ fmt = "%Y-%m-%dT%H:%M:%S%z"
82
+ return start.strftime(fmt), end.strftime(fmt)
83
+
84
+
85
+ # Cache file for daily version check (avoids upgrade fatigue)
86
+ _UPGRADE_CACHE_FILE = Path(os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))) / ".upgrade_check_cache.json"
87
+ _UPGRADE_CHECK_INTERVAL_SECONDS = 86400 # 24 hours
88
+
89
+
90
+ def _read_upgrade_cache() -> Optional[dict]:
91
+ """Read cached upgrade check result. Returns None if cache is missing or stale (>24h)."""
92
+ try:
93
+ if not _UPGRADE_CACHE_FILE.exists():
94
+ return None
95
+ cache = json.loads(_UPGRADE_CACHE_FILE.read_text(encoding="utf-8"))
96
+ checked_at = cache.get("checked_at", 0)
97
+ if (datetime.now(timezone.utc).timestamp() - checked_at) > _UPGRADE_CHECK_INTERVAL_SECONDS:
98
+ return None # Cache expired
99
+ return cache.get("result")
100
+ except Exception:
101
+ return None
102
+
103
+
104
+ def _write_upgrade_cache(result: dict) -> None:
105
+ """Write upgrade check result to cache file."""
106
+ try:
107
+ cache = {
108
+ "checked_at": datetime.now(timezone.utc).timestamp(),
109
+ "result": result,
110
+ }
111
+ _UPGRADE_CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False), encoding="utf-8")
112
+ except Exception:
113
+ pass # Non-blocking: cache write failure is acceptable
114
+
115
+
116
+ def _check_upgrade(client: MiraviaClient) -> None:
117
+ """Check if skill version is outdated and output upgrade suggestion.
118
+
119
+ Rate-limited to once per day via local file cache to avoid upgrade fatigue.
120
+ Non-blocking: on any failure, silently skip and proceed with normal flow.
121
+ Output format:
122
+ upgrade : {"need_upgrade": true, "current_version": "...", ...}
123
+ The Agent should present this to the seller and ask for confirmation:
124
+ - If seller confirms upgrade → run the npx upgrade command first
125
+ - If seller skips → proceed directly with export/html generation
126
+ """
127
+ # Check cache first: if checked within last 24h, suppress upgrade notification
128
+ # to avoid upgrade fatigue (Agent won't repeatedly ask about upgrades)
129
+ cached = _read_upgrade_cache()
130
+ if cached is not None:
131
+ # Already checked today: always report need_upgrade=false to silence Agent
132
+ print(f"upgrade : {json.dumps({'need_upgrade': False, 'current_version': config.SKILL_VERSION, 'cached': True})}", flush=True)
133
+ return
134
+
135
+ try:
136
+ data = client.check_skill_upgrade()
137
+ need_upgrade = data.get("need_upgrade", 0)
138
+ if need_upgrade:
139
+ latest = data.get("latest_skill", {})
140
+ upgrade_info = {
141
+ "need_upgrade": True,
142
+ "current_version": config.SKILL_VERSION,
143
+ "latest_version": latest.get("version", "unknown"),
144
+ "upgrade_command": latest.get("npx_command", "npx @miraiva_test/miravia-order-report@latest upgrade"),
145
+ "description": latest.get("description", ""),
146
+ "message": (
147
+ f"A newer skill version ({latest.get('version', '?')}) is available. "
148
+ f"Current version: {config.SKILL_VERSION}. "
149
+ "Please confirm whether to upgrade before proceeding. "
150
+ "You can skip the upgrade and continue with the current version."
151
+ ),
152
+ }
153
+ print(f"upgrade : {json.dumps(upgrade_info)}", flush=True)
154
+ _write_upgrade_cache(upgrade_info)
155
+ else:
156
+ # Version is up-to-date, output for agent awareness
157
+ result = {"need_upgrade": False, "current_version": config.SKILL_VERSION}
158
+ print(f"upgrade : {json.dumps(result)}", flush=True)
159
+ _write_upgrade_cache(result)
160
+ except Exception:
161
+ # Non-blocking: version check failure should never block the estimate flow
162
+ print(f"upgrade : {json.dumps({'need_upgrade': False, 'current_version': config.SKILL_VERSION, 'check_skipped': True})}", flush=True)
163
+
164
+
165
+ def main():
166
+ parser = argparse.ArgumentParser(description="Quick order count probe & time estimator")
167
+ parser.add_argument("--last-days", type=int, help="Relative range: last N days")
168
+ parser.add_argument("--created-after", help="Start date (YYYY-MM-DD)")
169
+ parser.add_argument("--created-before", help="End date (YYYY-MM-DD)")
170
+ parser.add_argument("--status", default="all", choices=VALID_STATUSES,
171
+ help="Order status filter (default: all)")
172
+ parser.add_argument("--channel", default=None, choices=["AE", "Miravia"],
173
+ help="Channel filter: AE (M2A/AliExpress) or Miravia")
174
+ parser.add_argument("--ship-to", default=None,
175
+ help="Destination country filter (e.g. ES, PT, IT)")
176
+ parser.add_argument("--order-numbers", default=None,
177
+ help="Comma-separated order numbers for direct query")
178
+ parser.add_argument("--agent-type", required=True,
179
+ help="Agent/IDE type identifier (required), dynamically passed by calling Agent")
180
+ args = parser.parse_args()
181
+
182
+ # Set global agent_type (CLI required, sole source)
183
+ set_agent_type(args.agent_type)
184
+
185
+ # Parse time range
186
+ after, before = _resolve_time_range(args)
187
+
188
+ # Single API call to get total count
189
+ client = MiraviaClient()
190
+
191
+ # --- Skill version upgrade check (non-blocking) ---
192
+ _check_upgrade(client)
193
+
194
+ # Map CLI status value to actual API parameter (same logic as export_orders.py)
195
+ api_status = STATUS_ALIASES.get(args.status, args.status) if args.status != "all" else None
196
+
197
+ try:
198
+ data = client.get_orders_page(
199
+ created_after=after, created_before=before,
200
+ status=api_status, limit=1, offset=0,
201
+ channel=args.channel,
202
+ ship_to=getattr(args, 'ship_to', None),
203
+ order_numbers=args.order_numbers,
204
+ )
205
+ except Exception as e:
206
+ print(f"error: API call failed - {e}", file=sys.stderr)
207
+ sys.exit(1)
208
+
209
+ total_orders = int(data.get("countTotal", 0))
210
+
211
+ # Zero orders: output no_data signal and exit early
212
+ if total_orders == 0:
213
+ result = {
214
+ "total_orders": 0,
215
+ "estimated_minutes": 0,
216
+ "estimated_seconds": 0,
217
+ "no_data": True,
218
+ "range_start": after,
219
+ "range_end": before,
220
+ }
221
+ print(f"estimate : {json.dumps(result)}", flush=True)
222
+ return
223
+
224
+ # Compute estimate (based on 2000 orders/minute throughput)
225
+ raw_seconds = math.ceil(total_orders / (ORDERS_PER_MINUTE / 60))
226
+ # Minimum 60 seconds: even with very few orders, LLM data analysis + insight injection takes time
227
+ estimated_seconds = max(raw_seconds, 60)
228
+ estimated_minutes = math.ceil(estimated_seconds / 60)
229
+
230
+ result = {
231
+ "total_orders": total_orders,
232
+ "estimated_minutes": estimated_minutes,
233
+ "estimated_seconds": estimated_seconds,
234
+ "no_data": False,
235
+ "range_start": after,
236
+ "range_end": before,
237
+ }
238
+
239
+ # Structured output
240
+ print(f"estimate : {json.dumps(result)}", flush=True)
241
+
242
+
243
+ if __name__ == "__main__":
244
+ main()