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.
Files changed (96) hide show
  1. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/PKG-INFO +1 -1
  2. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/README.md +5 -1
  3. cli_dolibarr-3.2.0/cli_dolibarr/dolibarr/__init__.py +1 -0
  4. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/schema.py +18 -4
  5. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/schema_manager.py +271 -5
  6. cli_dolibarr-3.2.0/cli_dolibarr/dolibarr/models_supplement.json +4845 -0
  7. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/schema.json +2336 -783
  8. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tests/test_enrich.py +4 -2
  9. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr.egg-info/PKG-INFO +1 -1
  10. cli_dolibarr-3.1.2/cli_dolibarr/dolibarr/__init__.py +0 -1
  11. cli_dolibarr-3.1.2/cli_dolibarr/dolibarr/models_supplement.json +0 -1410
  12. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/LICENSE +0 -0
  13. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/README.md +0 -0
  14. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/__init__.py +0 -0
  15. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/_template.py +0 -0
  16. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/accountancy.py +0 -0
  17. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/agendaevents.py +0 -0
  18. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/apibridge.py +0 -0
  19. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/bankaccounts.py +0 -0
  20. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/boms.py +0 -0
  21. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/categories.py +0 -0
  22. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/config.py +0 -0
  23. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/contacts.py +0 -0
  24. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/contracts.py +0 -0
  25. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/delivery.py +0 -0
  26. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/documents.py +0 -0
  27. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/donations.py +0 -0
  28. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/expensereports.py +0 -0
  29. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/interventions.py +0 -0
  30. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/invoices.py +0 -0
  31. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/knowledge.py +0 -0
  32. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/login.py +0 -0
  33. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/member_types.py +0 -0
  34. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/members.py +0 -0
  35. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/mobilehub.py +0 -0
  36. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/mobilehubapi.py +0 -0
  37. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/mos.py +0 -0
  38. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/multicurrencies.py +0 -0
  39. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/orderprocessor.py +0 -0
  40. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/orders.py +0 -0
  41. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/partnerships.py +0 -0
  42. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/pdfa.py +0 -0
  43. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/products.py +0 -0
  44. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/projects.py +0 -0
  45. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/proposals.py +0 -0
  46. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/quickprint.py +0 -0
  47. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/receptions.py +0 -0
  48. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/recruitments.py +0 -0
  49. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/salaries.py +0 -0
  50. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/setup.py +0 -0
  51. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/shipments.py +0 -0
  52. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/status.py +0 -0
  53. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/stock.py +0 -0
  54. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/stockmodule.py +0 -0
  55. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/stockmovements.py +0 -0
  56. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/subscriptions.py +0 -0
  57. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/supplier_invoices.py +0 -0
  58. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/supplier_orders.py +0 -0
  59. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/supplier_proposals.py +0 -0
  60. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/supplierinvoices.py +0 -0
  61. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/supplierorders.py +0 -0
  62. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/supplierproposals.py +0 -0
  63. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/system.py +0 -0
  64. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/tasks.py +0 -0
  65. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/thirdparties.py +0 -0
  66. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/tickets.py +0 -0
  67. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/users.py +0 -0
  68. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/warehouses.py +0 -0
  69. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/webhook.py +0 -0
  70. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/workstations.py +0 -0
  71. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/commands/zapier.py +0 -0
  72. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/__init__.py +0 -0
  73. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/client.py +0 -0
  74. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/config.py +0 -0
  75. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/output.py +0 -0
  76. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/schema_agents.py +0 -0
  77. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/schema_generator.py +0 -0
  78. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/core/session_client.py +0 -0
  79. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/dolibarr_cli.py +0 -0
  80. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/extrafields_cache.json +0 -0
  81. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/hermes_plugin/__init__.py +0 -0
  82. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tests/__init__.py +0 -0
  83. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tests/test_core.py +0 -0
  84. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tests/test_full_e2e.py +0 -0
  85. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tests/test_tools.py +0 -0
  86. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tools/__init__.py +0 -0
  87. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tools/executor.py +0 -0
  88. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/tools/tools_schema.py +0 -0
  89. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr/dolibarr/utils/__init__.py +0 -0
  90. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr.egg-info/SOURCES.txt +0 -0
  91. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr.egg-info/dependency_links.txt +0 -0
  92. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr.egg-info/entry_points.txt +0 -0
  93. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr.egg-info/requires.txt +0 -0
  94. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/cli_dolibarr.egg-info/top_level.txt +0 -0
  95. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/pyproject.toml +0 -0
  96. {cli_dolibarr-3.1.2 → cli_dolibarr-3.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-dolibarr
3
- Version: 3.1.2
3
+ Version: 3.2.0
4
4
  Summary: CLI tool for Dolibarr ERP/CRM REST API management
5
5
  Author: CLI Anything
6
6
  License-Expression: MIT
@@ -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/3] Syncing from Dolibarr swagger...")
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/3] Fetching extrafields...")
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/3] Enriching models...")
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
- is_bad = len(props) == 1 and 'request_data' in props
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]: