cli-dolibarr 3.1.2__tar.gz → 3.2.0__tar.gz
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.
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/PKG-INFO +1 -1
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/README.md +5 -1
- cli_dolibarr-3.2.0/cli_dolibarr/dolibarr/__init__.py +1 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/schema.py +18 -4
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/schema_manager.py +271 -5
- cli_dolibarr-3.2.0/cli_dolibarr/dolibarr/models_supplement.json +4845 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/schema.json +2336 -783
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tests/test_enrich.py +4 -2
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr.egg-info/PKG-INFO +1 -1
- cli_dolibarr-3.1.2/cli_dolibarr/dolibarr/__init__.py +0 -1
- cli_dolibarr-3.1.2/cli_dolibarr/dolibarr/models_supplement.json +0 -1410
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/LICENSE +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/README.md +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/__init__.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/_template.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/accountancy.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/agendaevents.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/apibridge.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/bankaccounts.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/boms.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/categories.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/config.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/contacts.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/contracts.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/delivery.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/documents.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/donations.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/expensereports.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/interventions.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/invoices.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/knowledge.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/login.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/member_types.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/members.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/mobilehub.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/mobilehubapi.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/mos.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/multicurrencies.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/orderprocessor.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/orders.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/partnerships.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/pdfa.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/products.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/projects.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/proposals.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/quickprint.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/receptions.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/recruitments.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/salaries.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/setup.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/shipments.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/status.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/stock.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/stockmodule.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/stockmovements.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/subscriptions.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/supplier_invoices.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/supplier_orders.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/supplier_proposals.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/supplierinvoices.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/supplierorders.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/supplierproposals.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/system.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/tasks.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/thirdparties.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/tickets.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/users.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/warehouses.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/webhook.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/workstations.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/zapier.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/__init__.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/client.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/config.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/output.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/schema_agents.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/schema_generator.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/session_client.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/dolibarr_cli.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/extrafields_cache.json +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/hermes_plugin/__init__.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tests/__init__.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tests/test_core.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tests/test_full_e2e.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tests/test_tools.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tools/__init__.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tools/executor.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tools/tools_schema.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/utils/__init__.py +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr.egg-info/SOURCES.txt +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr.egg-info/dependency_links.txt +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr.egg-info/entry_points.txt +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr.egg-info/requires.txt +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr.egg-info/top_level.txt +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/pyproject.toml +0 -0
- {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/setup.cfg +0 -0
|
@@ -128,7 +128,7 @@ dolibarr> exit
|
|
|
128
128
|
|
|
129
129
|
| 命令 | 说明 |
|
|
130
130
|
|------|------|
|
|
131
|
-
| `schema refresh --admin-api-key KEY` | 一键刷新:sync → extrafields → enrich |
|
|
131
|
+
| `schema refresh --admin-api-key KEY` | 一键刷新:sync → extrafields → enrich → auto-infer |
|
|
132
132
|
| `schema sync` | 从 Dolibarr swagger 端点拉取最新 API 定义 |
|
|
133
133
|
| `schema extrafields --admin-api-key KEY` | 拉取自定义字段定义并注入 schema(需管理员 key) |
|
|
134
134
|
| `schema enrich` | 用 models_supplement.json 补全 model 字段定义(sync 后必须执行) |
|
|
@@ -233,6 +233,10 @@ python -m vllm.entrypoints.openai.api_server \
|
|
|
233
233
|
| `DOLIBARR_URL` | API 完整地址,例如 `https://your-dolibarr.example.com/api/index.php` |
|
|
234
234
|
| `DOLIBARR_API_KEY` | Dolibarr 用户设置中的 API Key |
|
|
235
235
|
| `DOLIBARR_VERIFY_SSL` | 设为 `1` 或 `true` 启用 SSL 证书验证 |
|
|
236
|
+
| `DOLIBARR_ADMIN_API_KEY` | 管理员 API Key,用于拉取自定义字段定义 |
|
|
237
|
+
| `OPENAI_API_KEY` | OpenAI 兼容 API Key,用于 auto-infer 生成精确中文描述(可选) |
|
|
238
|
+
| `OPENAI_BASE_URL` | OpenAI 兼容 API 地址(默认 `https://api.openai.com/v1`,可选) |
|
|
239
|
+
| `OPENAI_MODEL` | 使用的模型名称(默认 `gpt-4o-mini`,可选) |
|
|
236
240
|
|
|
237
241
|
配置文件路径:`~/.config/cli-dolibarr/config.json`
|
|
238
242
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "3.2.0"
|
|
@@ -348,12 +348,13 @@ def extrafields(ctx, admin_api_key):
|
|
|
348
348
|
help='Admin API key. Falls back to DOLIBARR_ADMIN_API_KEY env var.')
|
|
349
349
|
@click.pass_context
|
|
350
350
|
def refresh(ctx, admin_api_key):
|
|
351
|
-
"""Full schema refresh: sync + extrafields + enrich.
|
|
351
|
+
"""Full schema refresh: sync + extrafields + enrich + auto-infer.
|
|
352
352
|
|
|
353
353
|
One-command pipeline that:
|
|
354
354
|
1. Syncs API schema from Dolibarr swagger
|
|
355
355
|
2. Fetches extrafield definitions (admin key via --admin-api-key or DOLIBARR_ADMIN_API_KEY)
|
|
356
356
|
3. Enriches models with supplement definitions
|
|
357
|
+
4. Auto-infers remaining bad models
|
|
357
358
|
"""
|
|
358
359
|
client = _get_client(ctx)
|
|
359
360
|
resolved_key = _resolve_admin_key(ctx, admin_api_key)
|
|
@@ -363,7 +364,7 @@ def refresh(ctx, admin_api_key):
|
|
|
363
364
|
manager = SchemaManager()
|
|
364
365
|
click.echo("Refreshing schema...")
|
|
365
366
|
|
|
366
|
-
click.echo(" [1/
|
|
367
|
+
click.echo(" [1/4] Syncing from Dolibarr swagger...")
|
|
367
368
|
swagger = manager.fetch_swagger(client)
|
|
368
369
|
schema = manager.normalize(swagger)
|
|
369
370
|
manager.save(schema)
|
|
@@ -371,7 +372,7 @@ def refresh(ctx, admin_api_key):
|
|
|
371
372
|
models_count = len(schema['models'])
|
|
372
373
|
click.echo(f" {endpoints} endpoints, {models_count} models")
|
|
373
374
|
|
|
374
|
-
click.echo(" [2/
|
|
375
|
+
click.echo(" [2/4] Fetching extrafields...")
|
|
375
376
|
ef_stats = {'injected_models': 0, 'injected_fields': 0, 'elementtypes': 0}
|
|
376
377
|
try:
|
|
377
378
|
ef_data = manager.fetch_extrafields(client)
|
|
@@ -381,11 +382,24 @@ def refresh(ctx, admin_api_key):
|
|
|
381
382
|
except RuntimeError:
|
|
382
383
|
click.echo(" Skipped (requires admin API key)")
|
|
383
384
|
|
|
384
|
-
click.echo(" [3/
|
|
385
|
+
click.echo(" [3/4] Enriching models...")
|
|
385
386
|
enrich_stats = manager.enrich()
|
|
386
387
|
click.echo(f" {enrich_stats['enriched']} enriched, "
|
|
387
388
|
f"{enrich_stats['skipped_good']} already good")
|
|
388
389
|
|
|
390
|
+
click.echo(" [4/4] Auto-inferring remaining models...")
|
|
391
|
+
infer_stats = {'inferred': 0, 'skipped_action': 0, 'skipped_no_data': 0, 'skipped_no_endpoint': 0}
|
|
392
|
+
try:
|
|
393
|
+
infer_stats = manager.auto_infer(client)
|
|
394
|
+
if infer_stats['inferred'] > 0:
|
|
395
|
+
click.echo(f" {infer_stats['inferred']} models auto-inferred")
|
|
396
|
+
else:
|
|
397
|
+
click.echo(" No models to infer")
|
|
398
|
+
if infer_stats.get('skipped_no_endpoint', 0) > 0:
|
|
399
|
+
click.echo(f" {infer_stats['skipped_no_endpoint']} skipped (no GET endpoint)")
|
|
400
|
+
except Exception:
|
|
401
|
+
click.echo(" Skipped (unexpected error)")
|
|
402
|
+
|
|
389
403
|
click.echo("\nRefresh complete.")
|
|
390
404
|
except DolibarrAPIError as e:
|
|
391
405
|
click.echo(f"Error: {e.message}", err=True)
|
|
@@ -11,9 +11,12 @@ Schema file location: cli_dolibarr/dolibarr/schema.json
|
|
|
11
11
|
import csv
|
|
12
12
|
import io
|
|
13
13
|
import json
|
|
14
|
+
import os
|
|
14
15
|
from datetime import datetime, timezone
|
|
15
16
|
from pathlib import Path
|
|
16
|
-
from typing import Any, Dict, List, Optional
|
|
17
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
import requests
|
|
17
20
|
|
|
18
21
|
COMMANDS_DIR = Path(__file__).parent.parent / 'commands'
|
|
19
22
|
|
|
@@ -61,6 +64,24 @@ class SchemaManager:
|
|
|
61
64
|
'separate': 'string',
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
_READONLY_FIELDS = frozenset({
|
|
68
|
+
'id', 'rowid', 'entity', 'ref', 'ref_ext',
|
|
69
|
+
'date_creation', 'date_modification', 'tms',
|
|
70
|
+
'fk_user_creat', 'fk_user_modif', 'user_creation', 'user_modification',
|
|
71
|
+
'total_ht', 'total_tva', 'total_ttc', 'total_localtax1', 'total_localtax2',
|
|
72
|
+
'paye', 'close_code', 'close_note',
|
|
73
|
+
'linkedObjects', 'linkedObjectsIds', 'extraparams',
|
|
74
|
+
'rang', 'import_key', 'model_pdf', 'last_main_doc',
|
|
75
|
+
'fk_statut', 'statut',
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
_ACTION_MODEL_SUFFIXES = frozenset({
|
|
79
|
+
'ValidateModel', 'CloseModel', 'SettopaidModel', 'SettodraftModel',
|
|
80
|
+
'SettounpaidModel', 'ReopenModel', 'SetinvoicedModel',
|
|
81
|
+
'MakeorderModel', 'ReceiveOrderModel', 'ApproveModel',
|
|
82
|
+
'MarkAsCreditAvailableModel', 'ProduceandconsumeModel', 'ProduceandconsumeallModel',
|
|
83
|
+
})
|
|
84
|
+
|
|
64
85
|
def __init__(self):
|
|
65
86
|
self.data: Optional[Dict[str, Any]] = None
|
|
66
87
|
|
|
@@ -100,7 +121,9 @@ class SchemaManager:
|
|
|
100
121
|
|
|
101
122
|
for model_name, model_def in models.items():
|
|
102
123
|
props = model_def.get('properties', {})
|
|
103
|
-
|
|
124
|
+
prop_names = set(props.keys())
|
|
125
|
+
substantive = prop_names - {'request_data', 'dummy', 'array_options'}
|
|
126
|
+
is_bad = not substantive
|
|
104
127
|
|
|
105
128
|
if model_name in supplement:
|
|
106
129
|
if is_bad:
|
|
@@ -201,6 +224,242 @@ class SchemaManager:
|
|
|
201
224
|
'elementtypes': elementtypes,
|
|
202
225
|
}
|
|
203
226
|
|
|
227
|
+
# ── Auto-infer (T7) ─────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _infer_model_to_get_path(model_name: str, schema: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
231
|
+
"""Find a GET endpoint that returns data matching the given model.
|
|
232
|
+
|
|
233
|
+
Returns dict with 'path' template, 'is_line' flag, and 'group',
|
|
234
|
+
or None if no suitable endpoint found.
|
|
235
|
+
"""
|
|
236
|
+
for suffix in SchemaManager._ACTION_MODEL_SUFFIXES:
|
|
237
|
+
if model_name.endswith(suffix):
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
endpoints = schema.get('endpoints', [])
|
|
241
|
+
matched_groups: List[Tuple[str, bool]] = []
|
|
242
|
+
for ep in endpoints:
|
|
243
|
+
if ep.get('method') != 'POST':
|
|
244
|
+
continue
|
|
245
|
+
if ep.get('request_body') != model_name:
|
|
246
|
+
continue
|
|
247
|
+
path = ep.get('path', '')
|
|
248
|
+
group = SchemaManager._extract_group(path)
|
|
249
|
+
is_line = '/lines' in path
|
|
250
|
+
matched_groups.append((group, is_line))
|
|
251
|
+
|
|
252
|
+
if not matched_groups:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
group, is_line = matched_groups[0]
|
|
256
|
+
|
|
257
|
+
if is_line:
|
|
258
|
+
return {
|
|
259
|
+
'path': f'/{group}/{{id}}/lines',
|
|
260
|
+
'is_line': True,
|
|
261
|
+
'group': group,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for ep in endpoints:
|
|
265
|
+
if ep.get('method') != 'GET':
|
|
266
|
+
continue
|
|
267
|
+
ep_group = SchemaManager._extract_group(ep.get('path', ''))
|
|
268
|
+
if ep_group != group:
|
|
269
|
+
continue
|
|
270
|
+
path = ep.get('path', '')
|
|
271
|
+
if '{id}' not in path.split('/')[-1]:
|
|
272
|
+
return {
|
|
273
|
+
'path': f'/{group}',
|
|
274
|
+
'is_line': False,
|
|
275
|
+
'group': group,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _filter_writable_fields(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
282
|
+
"""Remove readonly fields from a GET response dict."""
|
|
283
|
+
filtered = {}
|
|
284
|
+
for key, value in data.items():
|
|
285
|
+
if key in SchemaManager._READONLY_FIELDS:
|
|
286
|
+
continue
|
|
287
|
+
if value is None and (key.startswith('date_') or key.startswith('fk_user_')):
|
|
288
|
+
continue
|
|
289
|
+
filtered[key] = value
|
|
290
|
+
return filtered
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def _infer_field_type(value: Any) -> str:
|
|
294
|
+
"""Infer JSON schema type from a Python value."""
|
|
295
|
+
if isinstance(value, bool):
|
|
296
|
+
return 'integer' # Dolibarr uses 0/1 booleans
|
|
297
|
+
if isinstance(value, int):
|
|
298
|
+
return 'integer'
|
|
299
|
+
if isinstance(value, float):
|
|
300
|
+
return 'number'
|
|
301
|
+
if isinstance(value, str):
|
|
302
|
+
return 'string'
|
|
303
|
+
if isinstance(value, list):
|
|
304
|
+
return 'array'
|
|
305
|
+
if isinstance(value, dict):
|
|
306
|
+
return 'object'
|
|
307
|
+
return 'string'
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _call_ai_for_descriptions(model_name: str, fields_with_types: Dict[str, str],
|
|
311
|
+
sample_data: Dict[str, Any]) -> Optional[Dict[str, str]]:
|
|
312
|
+
"""Call OpenAI-compatible API to generate Chinese descriptions for fields."""
|
|
313
|
+
api_key = os.environ.get('OPENAI_API_KEY')
|
|
314
|
+
if not api_key:
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
base_url = os.environ.get('OPENAI_BASE_URL', 'https://api.openai.com/v1')
|
|
318
|
+
model = os.environ.get('OPENAI_MODEL', 'gpt-4o-mini')
|
|
319
|
+
|
|
320
|
+
field_list = ', '.join(fields_with_types.keys())
|
|
321
|
+
prompt = (
|
|
322
|
+
f"Given the Dolibarr ERP model '{model_name}' with these fields: [{field_list}], "
|
|
323
|
+
f"and this sample API response data: {json.dumps(sample_data, default=str, ensure_ascii=False)[:2000]}, "
|
|
324
|
+
f"provide a short Chinese description for each field. "
|
|
325
|
+
f"Return ONLY a JSON object mapping field_name to description string. "
|
|
326
|
+
f"Example: {{\"socid\": \"客户 ID\"}}"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
resp = requests.post(
|
|
331
|
+
f"{base_url}/chat/completions",
|
|
332
|
+
headers={
|
|
333
|
+
'Authorization': f'Bearer {api_key}',
|
|
334
|
+
'Content-Type': 'application/json',
|
|
335
|
+
},
|
|
336
|
+
json={
|
|
337
|
+
'model': model,
|
|
338
|
+
'messages': [{'role': 'user', 'content': prompt}],
|
|
339
|
+
'temperature': 0.1,
|
|
340
|
+
},
|
|
341
|
+
timeout=30,
|
|
342
|
+
)
|
|
343
|
+
resp.raise_for_status()
|
|
344
|
+
content = resp.json()['choices'][0]['message']['content']
|
|
345
|
+
if '```' in content:
|
|
346
|
+
content = content.split('```')[1]
|
|
347
|
+
if content.startswith('json'):
|
|
348
|
+
content = content[4:]
|
|
349
|
+
return json.loads(content.strip())
|
|
350
|
+
except Exception:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
def auto_infer(self, client) -> Dict[str, int]:
|
|
354
|
+
"""Auto-infer field definitions for bad models by sampling GET endpoints.
|
|
355
|
+
|
|
356
|
+
Returns stats: {'inferred': N, 'skipped_action': N, 'skipped_no_data': N, 'skipped_no_endpoint': N}
|
|
357
|
+
"""
|
|
358
|
+
schema = self.load()
|
|
359
|
+
if schema is None:
|
|
360
|
+
return {'inferred': 0, 'skipped_action': 0, 'skipped_no_data': 0, 'skipped_no_endpoint': 0}
|
|
361
|
+
|
|
362
|
+
models = schema.get('models', {})
|
|
363
|
+
supplement = self._load_supplement()
|
|
364
|
+
|
|
365
|
+
bad_models: List[str] = []
|
|
366
|
+
for model_name, model_def in models.items():
|
|
367
|
+
props = model_def.get('properties', {})
|
|
368
|
+
prop_names = set(props.keys())
|
|
369
|
+
substantive = prop_names - {'request_data', 'dummy', 'array_options'}
|
|
370
|
+
if not substantive:
|
|
371
|
+
bad_models.append(model_name)
|
|
372
|
+
|
|
373
|
+
stats = {'inferred': 0, 'skipped_action': 0, 'skipped_no_data': 0, 'skipped_no_endpoint': 0}
|
|
374
|
+
|
|
375
|
+
base = client.base_url.rstrip('/')
|
|
376
|
+
|
|
377
|
+
for model_name in bad_models:
|
|
378
|
+
if model_name in supplement:
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
is_action = any(model_name.endswith(s) for s in self._ACTION_MODEL_SUFFIXES)
|
|
382
|
+
if is_action:
|
|
383
|
+
stats['skipped_action'] += 1
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
path_info = self._infer_model_to_get_path(model_name, schema)
|
|
387
|
+
if path_info is None:
|
|
388
|
+
stats['skipped_no_endpoint'] += 1
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
sample_data: Optional[Dict[str, Any]] = None
|
|
392
|
+
|
|
393
|
+
if path_info['is_line']:
|
|
394
|
+
group = path_info['group']
|
|
395
|
+
list_url = f"{base}/{group}"
|
|
396
|
+
try:
|
|
397
|
+
list_resp = client.session.get(list_url, params={'limit': 1})
|
|
398
|
+
if list_resp.status_code == 200:
|
|
399
|
+
list_data = list_resp.json()
|
|
400
|
+
if isinstance(list_data, list) and len(list_data) > 0:
|
|
401
|
+
parent_id = list_data[0].get('id') or list_data[0].get('rowid')
|
|
402
|
+
if parent_id:
|
|
403
|
+
detail_url = f"{base}/{group}/{parent_id}/lines"
|
|
404
|
+
detail_resp = client.session.get(detail_url, params={'limit': 1})
|
|
405
|
+
if detail_resp.status_code == 200:
|
|
406
|
+
detail_data = detail_resp.json()
|
|
407
|
+
if isinstance(detail_data, list) and len(detail_data) > 0:
|
|
408
|
+
sample_data = detail_data[0]
|
|
409
|
+
elif isinstance(detail_data, dict):
|
|
410
|
+
sample_data = detail_data
|
|
411
|
+
except Exception:
|
|
412
|
+
pass
|
|
413
|
+
else:
|
|
414
|
+
list_url = f"{base}{path_info['path']}"
|
|
415
|
+
try:
|
|
416
|
+
list_resp = client.session.get(list_url, params={'limit': 1})
|
|
417
|
+
if list_resp.status_code == 200:
|
|
418
|
+
list_data = list_resp.json()
|
|
419
|
+
if isinstance(list_data, list) and len(list_data) > 0:
|
|
420
|
+
sample_data = list_data[0]
|
|
421
|
+
except Exception:
|
|
422
|
+
pass
|
|
423
|
+
|
|
424
|
+
if sample_data is None:
|
|
425
|
+
stats['skipped_no_data'] += 1
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
writable = self._filter_writable_fields(sample_data)
|
|
429
|
+
if not writable:
|
|
430
|
+
stats['skipped_no_data'] += 1
|
|
431
|
+
continue
|
|
432
|
+
|
|
433
|
+
fields_with_types: Dict[str, str] = {}
|
|
434
|
+
for key, value in writable.items():
|
|
435
|
+
fields_with_types[key] = self._infer_field_type(value)
|
|
436
|
+
|
|
437
|
+
ai_desc = self._call_ai_for_descriptions(model_name, fields_with_types, sample_data)
|
|
438
|
+
|
|
439
|
+
properties: Dict[str, Any] = {}
|
|
440
|
+
for key, type_name in fields_with_types.items():
|
|
441
|
+
prop: Dict[str, Any] = {'type': type_name}
|
|
442
|
+
if ai_desc and key in ai_desc:
|
|
443
|
+
prop['description'] = ai_desc[key]
|
|
444
|
+
else:
|
|
445
|
+
prop['description'] = key
|
|
446
|
+
properties[key] = prop
|
|
447
|
+
|
|
448
|
+
new_def: Dict[str, Any] = {
|
|
449
|
+
'properties': properties,
|
|
450
|
+
'auto_inferred': True,
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
supplement[model_name] = new_def
|
|
454
|
+
stats['inferred'] += 1
|
|
455
|
+
|
|
456
|
+
if stats['inferred'] > 0:
|
|
457
|
+
self.SUPPLEMENT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
458
|
+
with open(self.SUPPLEMENT_PATH, 'w', encoding='utf-8') as f:
|
|
459
|
+
json.dump(supplement, f, indent=2, ensure_ascii=False)
|
|
460
|
+
|
|
461
|
+
return stats
|
|
462
|
+
|
|
204
463
|
def refresh(self, client) -> Dict[str, Any]:
|
|
205
464
|
"""Full schema refresh: sync + extrafields + enrich.
|
|
206
465
|
|
|
@@ -208,13 +467,12 @@ class SchemaManager:
|
|
|
208
467
|
1. Syncs API schema from Dolibarr swagger
|
|
209
468
|
2. Fetches extrafield definitions (best-effort, skips if no admin key)
|
|
210
469
|
3. Enriches models with supplement definitions
|
|
470
|
+
4. Auto-infers remaining bad models (best-effort)
|
|
211
471
|
"""
|
|
212
|
-
# Step 1: sync
|
|
213
472
|
swagger = self.fetch_swagger(client)
|
|
214
473
|
schema = self.normalize(swagger)
|
|
215
474
|
self.save(schema)
|
|
216
475
|
|
|
217
|
-
# Step 2: extrafields (best-effort)
|
|
218
476
|
ef_stats = {'injected_models': 0, 'injected_fields': 0, 'elementtypes': 0}
|
|
219
477
|
try:
|
|
220
478
|
ef_data = self.fetch_extrafields(client)
|
|
@@ -222,14 +480,22 @@ class SchemaManager:
|
|
|
222
480
|
except RuntimeError:
|
|
223
481
|
pass
|
|
224
482
|
|
|
225
|
-
# Step 3: enrich
|
|
226
483
|
enrich_stats = self.enrich()
|
|
227
484
|
|
|
485
|
+
infer_stats = {'inferred': 0, 'skipped_action': 0, 'skipped_no_data': 0, 'skipped_no_endpoint': 0}
|
|
486
|
+
try:
|
|
487
|
+
infer_stats = self.auto_infer(client)
|
|
488
|
+
if infer_stats['inferred'] > 0:
|
|
489
|
+
self.enrich()
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
|
|
228
493
|
return {
|
|
229
494
|
'endpoints': len(schema['endpoints']),
|
|
230
495
|
'models': len(schema['models']),
|
|
231
496
|
'extrafields': ef_stats,
|
|
232
497
|
'enrich': enrich_stats,
|
|
498
|
+
'auto_infer': infer_stats,
|
|
233
499
|
}
|
|
234
500
|
|
|
235
501
|
def fetch_swagger(self, client) -> Dict[str, Any]:
|