Compare commits
43 Commits
b59cbb3546
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eadc1f6a6 | |||
| b758a0e09d | |||
| 242b0c0f1c | |||
| 097b76c7ef | |||
| b7151b3bc7 | |||
| 8a631f5ea4 | |||
| 1851b38c62 | |||
| 660c62c32c | |||
| a1acb781b0 | |||
| 54a6849563 | |||
| 6f54d765c4 | |||
| 5ec2990347 | |||
| 90b2c89ff1 | |||
| b941f367ae | |||
| 92de85cf53 | |||
| 706e628715 | |||
| 88dab289cc | |||
| cc34894ae4 | |||
| 35b53dbef5 | |||
| 702c2ecd2a | |||
| b37c2ac251 | |||
| d0a6cc1a0b | |||
| c4d6b5650e | |||
| 7b25e79e98 | |||
| fdebca500f | |||
| 37c68a4d9a | |||
| 808586e615 | |||
| 97ab56f928 | |||
| 5386f54922 | |||
| cb625a7918 | |||
| 8c5792da52 | |||
| 52b5f90521 | |||
| 03f561fdf7 | |||
| e5614f3b60 | |||
| 3a89c317e8 | |||
| e0bdc1707d | |||
| 3dad5c6088 | |||
| 7c722dae91 | |||
| 86923542fa | |||
| 7621b87c49 | |||
| 1a1b9ebe03 | |||
| 0ff44f7b78 | |||
| 9b95e15cbc |
@@ -14,7 +14,6 @@
|
||||
# reference the active profile's HOST — the server substitutes it at runtime.
|
||||
# Log access is automatically disabled when the active profile has SAAS=true.
|
||||
LOGS_PATH=\\{host}\inetpub\logs\LogFiles\Mecalux;\\{host}\ProgramData\Mecalux\ETLLogs
|
||||
LOG_FILE_PATTERN=*.log
|
||||
|
||||
# ----------------------------------------
|
||||
# Shared WMS settings (same across all profiles)
|
||||
|
||||
@@ -20,3 +20,6 @@ Thumbs.db
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Dumps temporaires (workflows exportes, etc.)
|
||||
temp/
|
||||
|
||||
@@ -1,669 +0,0 @@
|
||||
## Application
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [GET api/application/apiversion](https://10.255.255.2/ApplicationService/Help/Api/GET-api-application-apiversion) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/application/apichangelog](https://10.255.255.2/ApplicationService/Help/Api/GET-api-application-apichangelog) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/application/GetResources](https://10.255.255.2/ApplicationService/Help/Api/POST-api-application-GetResources) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## CommandExecute
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/CommandExecute](https://10.255.255.2/ApplicationService/Help/Api/POST-api-CommandExecute) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## CommandFiles
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/CommandUploadFile](https://10.255.255.2/ApplicationService/Help/Api/POST-api-CommandUploadFile) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/CommandDownloadFile?fileCode={fileCode}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-CommandDownloadFile_fileCode) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## Configuration
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [GET api/configuration/apiversion](https://10.255.255.2/ApplicationService/Help/Api/GET-api-configuration-apiversion) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/configuration/apichangelog](https://10.255.255.2/ApplicationService/Help/Api/GET-api-configuration-apichangelog) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updateorganization](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updateorganization) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updateusergroups](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updateusergroups) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updaterfmenuitems](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updaterfmenuitems) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updatejobs](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updatejobs) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/stopjobs](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-stopjobs) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/startjobs](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-startjobs) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/executejob](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-executejob) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updatesubscriptions](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updatesubscriptions) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updatetimelinetemplates](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updatetimelinetemplates) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updatetypes](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updatetypes) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updateprocesses](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updateprocesses) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updatequeries](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updatequeries) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updateDataWarehouseContext](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updateDataWarehouseContext) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updateresources](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updateresources) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updateapplication](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updateapplication) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updateapplications](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updateapplications) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updatelicense](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updatelicense) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updatehooks](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updatehooks) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updategenericsearch](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updategenericsearch) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/undoLatestMigration?applicationName={applicationName}](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-undoLatestMigration_applicationName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/enableapplication?applicationName={applicationName}](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-enableapplication_applicationName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/disableapplication?applicationName={applicationName}](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-disableapplication_applicationName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/enabletelemetry](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-enabletelemetry) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/disabletelemetry](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-disabletelemetry) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/configuration/tenant](https://10.255.255.2/ApplicationService/Help/Api/GET-api-configuration-tenant) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/enablefeature](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-enablefeature) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/disablefeature](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-disablefeature) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/getfeature](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-getfeature) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/configuration/applications](https://10.255.255.2/ApplicationService/Help/Api/GET-api-configuration-applications) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/configuration/applicationFeatures?applicationName={applicationName}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-configuration-applicationFeatures_applicationName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/configuration/tenants](https://10.255.255.2/ApplicationService/Help/Api/GET-api-configuration-tenants) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/configuration/tenantinfo?tenantCode={tenantCode}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-configuration-tenantinfo_tenantCode) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/configuration/serviceinfo](https://10.255.255.2/ApplicationService/Help/Api/GET-api-configuration-serviceinfo) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/configuration/updateservicemode](https://10.255.255.2/ApplicationService/Help/Api/POST-api-configuration-updateservicemode) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## Events
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/PublishEvents](https://10.255.255.2/ApplicationService/Help/Api/POST-api-PublishEvents) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## GatewayCommit
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/GatewayCommit](https://10.255.255.2/ApplicationService/Help/Api/POST-api-GatewayCommit) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## GatewayExecuteCommand
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/GatewayExecuteCommand](https://10.255.255.2/ApplicationService/Help/Api/POST-api-GatewayExecuteCommand) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## GatewayExecuteCustomCommand
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/GatewayExecuteCustomCommand](https://10.255.255.2/ApplicationService/Help/Api/POST-api-GatewayExecuteCustomCommand) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## GatewayReject
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/GatewayReject](https://10.255.255.2/ApplicationService/Help/Api/POST-api-GatewayReject) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## GatewayRollback
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/GatewayRollback](https://10.255.255.2/ApplicationService/Help/Api/POST-api-GatewayRollback) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## GatewayUpdateRoutes
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/GatewayUpdateRoutes](https://10.255.255.2/ApplicationService/Help/Api/POST-api-GatewayUpdateRoutes) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## GatewayUpdateStations
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/GatewayUpdateStations](https://10.255.255.2/ApplicationService/Help/Api/POST-api-GatewayUpdateStations) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## GenericSearch
|
||||
|
||||
Generic Search Controller.
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/GenericSearch/Search](https://10.255.255.2/ApplicationService/Help/Api/POST-api-GenericSearch-Search) |
|
||||
Searches the specified documents.
|
||||
|
||||
|
|
||||
| [POST api/GenericSearch/Document](https://10.255.255.2/ApplicationService/Help/Api/POST-api-GenericSearch-Document) |
|
||||
|
||||
Gets the specified category document.
|
||||
|
||||
|
|
||||
| [GET api/GenericSearch/Categories?applicationName={applicationName}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-GenericSearch-Categories_applicationName) |
|
||||
|
||||
Gets the categories.
|
||||
|
||||
|
|
||||
| [POST api/GenericSearch/Categories?applicationName={applicationName}](https://10.255.255.2/ApplicationService/Help/Api/POST-api-GenericSearch-Categories_applicationName) |
|
||||
|
||||
Gets the categories.
|
||||
|
||||
|
|
||||
|
||||
## Health
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [GET api/healthcheck?tenantCode={tenantCode}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-healthcheck_tenantCode) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/healthcheck?tenantCode={tenantCode}](https://10.255.255.2/ApplicationService/Help/Api/POST-api-healthcheck_tenantCode) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/ready?tenantCode={tenantCode}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-ready_tenantCode) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## Metadata
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [GET api/Metadata/Commands](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-Commands) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/Commands?applicationName={applicationName}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-Commands_applicationName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/CommandProperties?assemblyFullName={assemblyFullName}&fullName={fullName}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-CommandProperties_assemblyFullName_fullName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/CommandsAll](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-CommandsAll) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/CommandsAll?applicationName={applicationName}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-CommandsAll_applicationName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/Entities](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-Entities) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/Entities?applicationName={applicationName}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-Entities_applicationName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/EntityProperties?assemblyFullName={assemblyFullName}&fullName={fullName}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-EntityProperties_assemblyFullName_fullName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/EntitiesAll](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-EntitiesAll) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/EntitiesAll?applicationName={applicationName}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-EntitiesAll_applicationName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/Events](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-Events) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/Events?applicationName={applicationName}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-Events_applicationName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/EventProperties?assemblyFullName={assemblyFullName}&fullName={fullName}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-EventProperties_assemblyFullName_fullName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/EventsAll](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-EventsAll) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/EventsAll?applicationName={applicationName}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-EventsAll_applicationName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/Aggregates](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-Aggregates) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/Aggregates?applicationName={applicationName}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-Aggregates_applicationName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/AggregateProperties?assemblyFullName={assemblyFullName}&fullName={fullName}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-AggregateProperties_assemblyFullName_fullName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/AggregatesAll](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-AggregatesAll) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/Metadata/AggregatesAll?applicationName={applicationName}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-Metadata-AggregatesAll_applicationName) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## QueryCommands
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/QueryCommands](https://10.255.255.2/ApplicationService/Help/Api/POST-api-QueryCommands) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## QueryEvents
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/QueryEvents](https://10.255.255.2/ApplicationService/Help/Api/POST-api-QueryEvents) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/QueryCommandEvents](https://10.255.255.2/ApplicationService/Help/Api/POST-api-QueryCommandEvents) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/QueryCorrelationEvents](https://10.255.255.2/ApplicationService/Help/Api/POST-api-QueryCorrelationEvents) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## QueryExecute
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [GET api/QueryExecuteStream](https://10.255.255.2/ApplicationService/Help/Api/GET-api-QueryExecuteStream) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/QueryExecuteStream](https://10.255.255.2/ApplicationService/Help/Api/POST-api-QueryExecuteStream) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/QueryCancel](https://10.255.255.2/ApplicationService/Help/Api/POST-api-QueryCancel) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/QueriesCancel](https://10.255.255.2/ApplicationService/Help/Api/POST-api-QueriesCancel) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/QueryExecute](https://10.255.255.2/ApplicationService/Help/Api/POST-api-QueryExecute) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## QueryScalarExecute
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/QueryScalarExecuteAsync](https://10.255.255.2/ApplicationService/Help/Api/POST-api-QueryScalarExecuteAsync) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/QueryScalarExecute](https://10.255.255.2/ApplicationService/Help/Api/POST-api-QueryScalarExecute) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## QuerySnapshots
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/QuerySnapshots](https://10.255.255.2/ApplicationService/Help/Api/POST-api-QuerySnapshots) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## Workflow
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [POST api/Workflow/Logon](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-Logon) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/GetUserScreen](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-GetUserScreen) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/GetUserScreenInfo](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-GetUserScreenInfo) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/GetUserScreenParameters](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-GetUserScreenParameters) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/GetUserProcessAttributes](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-GetUserProcessAttributes) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/ExecuteUserAction](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-ExecuteUserAction) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/ExecuteUserActionGetScreen](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-ExecuteUserActionGetScreen) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/TerminateUserSession](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-TerminateUserSession) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/Start](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-Start) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/GetScreen](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-GetScreen) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/GetScreenParameters](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-GetScreenParameters) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/GetProcessAttributes](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-GetProcessAttributes) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/ExecuteAction](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-ExecuteAction) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/ExecuteActionGetScreen](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-ExecuteActionGetScreen) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/Terminate](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-Terminate) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/GetSessions](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-GetSessions) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/ExecuteProcessAction](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-ExecuteProcessAction) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/Cancel](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-Cancel) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [POST api/Workflow/ExecuteValidateDialogFormat](https://10.255.255.2/ApplicationService/Help/Api/POST-api-Workflow-ExecuteValidateDialogFormat) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
|
||||
## WorkflowLog
|
||||
|
||||
| API | Description |
|
||||
| --- | --- |
|
||||
| [GET api/WorkflowLog/GetInstances?processDefinitionId={processDefinitionId}&skip={skip}&take={take}&startDateFrom={startDateFrom}&startDateTo={startDateTo}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-WorkflowLog-GetInstances_processDefinitionId_skip_take_startDateFrom_startDateTo) |
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/WorkflowLog/GetInstances?processDefinitionId={processDefinitionId}&skip={skip}&take={take}&startDateFrom={startDateFrom}&startDateTo={startDateTo}&filterAttributeName={filterAttributeName}&filterAttributeValue={filterAttributeValue}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-WorkflowLog-GetInstances_processDefinitionId_skip_take_startDateFrom_startDateTo_filterAttributeName_filterAttributeValue) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/WorkflowLog/GetInstance?processId={processId}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-WorkflowLog-GetInstance_processId) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/WorkflowLog/GetLogs?processId={processId}&skip={skip}&take={take}&logDateFrom={logDateFrom}&logDateTo={logDateTo}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-WorkflowLog-GetLogs_processId_skip_take_logDateFrom_logDateTo) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/WorkflowLog/ValidateInstance?applicationName={applicationName}&processDefinitionId={processDefinitionId}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-WorkflowLog-ValidateInstance_applicationName_processDefinitionId) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/WorkflowLog/Validate?applicationName={applicationName}&processDefinitionId={processDefinitionId}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-WorkflowLog-Validate_applicationName_processDefinitionId) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/WorkflowLog/ValidateInstanceByVersionId?applicationName={applicationName}&processVersionId={processVersionId}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-WorkflowLog-ValidateInstanceByVersionId_applicationName_processVersionId) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
| [GET api/WorkflowLog/ValidateByVersionId?applicationName={applicationName}&processVersionId={processVersionId}](https://10.255.255.2/ApplicationService/Help/Api/GET-api-WorkflowLog-ValidateByVersionId_applicationName_processVersionId) |
|
||||
|
||||
No documentation available.
|
||||
|
||||
|
|
||||
+744
@@ -0,0 +1,744 @@
|
||||
# Décisions d'architecture et pièges vérifiés
|
||||
|
||||
Ce fichier consigne **pourquoi** le code est écrit comme il l'est. Chaque entrée
|
||||
décrit une décision prise ou un piège constaté **sur un WMS réel** — pas une
|
||||
supposition. Avant de « corriger » un comportement qui paraît étrange, cherchez-le
|
||||
ici : il est probablement volontaire.
|
||||
|
||||
Convention : une décision reste dans le fichier même si elle est révisée ; on
|
||||
ajoute alors une entrée `Révisée le …` plutôt que de réécrire l'histoire.
|
||||
|
||||
---
|
||||
|
||||
## D1 — 100 % API, aucun accès Oracle direct
|
||||
|
||||
**Décision.** Toutes les données transitent par les API REST du WMS. Aucune
|
||||
connexion base de données.
|
||||
|
||||
**Pourquoi.** Le serveur MCP doit fonctionner depuis un poste ou une VM sans
|
||||
credentials Oracle, sans client Oracle installé, et sans risque d'écriture
|
||||
directe en base. L'API impose en prime les règles métier et les droits du
|
||||
compte utilisé.
|
||||
|
||||
**Conséquence.** Les fichiers `src/services/oracle-service.js`,
|
||||
`src/resources/database.js` et `src/tools/database-tools.js` ont été supprimés
|
||||
(ils étaient de toute façon morts : non branchés, `oracledb` n'était même pas
|
||||
une dépendance). **Ne pas les réintroduire.** Si une donnée n'est pas
|
||||
atteignable par API, elle est hors périmètre — voir D16.
|
||||
|
||||
---
|
||||
|
||||
## D2 — OAuth : `tenant_code` est obligatoire
|
||||
|
||||
**Piège.** L'endpoint `/EasySTS/OAuth/Token` répond `400 Bad Request` si le
|
||||
paramètre `tenant_code` est absent, sans message explicite.
|
||||
|
||||
**Solution.** Le corps du grant `password` contient toujours les quatre
|
||||
paramètres :
|
||||
|
||||
```
|
||||
grant_type=password&tenant_code=<TENANT>&username=<USER>&password=<PASS>
|
||||
```
|
||||
|
||||
Voir `src/services/api-service.js`, méthode `authenticate()`.
|
||||
|
||||
---
|
||||
|
||||
## D3 — `QueryType: 0` (Reading), pas 1
|
||||
|
||||
**Piège.** `QueryType` (type `QueryContextType`) sélectionne le contexte de
|
||||
données interrogé. Il a **quatre** valeurs, pas deux — vérifiées une à une sur
|
||||
le tenant `LIMAGRAI2512` :
|
||||
|
||||
| Valeur | Contexte | Statut sur ce tenant |
|
||||
|---|---|---|
|
||||
| `0` | **Reading** — `ApplicationReadingContext` | opérationnel, champs de statut en **chaînes** (`"Release"`) |
|
||||
| `1` | Writing — `ApplicationWritingRepository` | opérationnel, champs de statut en **énumérations** |
|
||||
| `2` | DataWarehouse | **non configuré** : `Could not resolve serviceType 'IDataWarehouse…'` |
|
||||
| `3` | Metrics — `ApplicationMetricDataContext` | contexte présent, modèle de données non exploré |
|
||||
|
||||
Les comparaisons de statut par chaîne — de loin le cas le plus courant en
|
||||
debug — **échouent** en `QueryType: 1`. Le code force donc `0` dans
|
||||
`executeQuery()` et `executeScalarQuery()`.
|
||||
|
||||
Le message d'erreur nomme le contexte (`ApplicationReadingContext`,
|
||||
`ApplicationWritingRepository`, …) : c'est le moyen le plus rapide de savoir
|
||||
quel `QueryType` a réellement été utilisé.
|
||||
|
||||
**Attention.** D'anciens exemples (dont le PHP de référence) utilisent `1`. Ne
|
||||
les recopiez pas.
|
||||
|
||||
---
|
||||
|
||||
## D4 — Les API AD renvoient `{ entities: [...] }`, pas un tableau
|
||||
|
||||
**Piège.** `POST /AD/api/{Type}/GetByApplication` renvoie un objet enveloppe. Un
|
||||
code qui traite la réponse comme un tableau obtient silencieusement
|
||||
`0 élément` — le symptôme historique était « Successfully cached 0 workflows ».
|
||||
|
||||
**Solution.** Toujours extraire : `response?.entities || []`.
|
||||
|
||||
---
|
||||
|
||||
## D5 — Les propriétés arrivent en minuscules *ou* en majuscules
|
||||
|
||||
**Piège.** Selon le type d'élément et la version du WMS, l'API renvoie `name`
|
||||
ou `Name`, `id` ou `Id`.
|
||||
|
||||
**Solution.** Systématiquement `const name = w.name || w.Name || ''` avant tout
|
||||
filtrage ou tri. Une recherche qui « ne trouve pas » un élément qui existe est
|
||||
presque toujours ce bug.
|
||||
|
||||
---
|
||||
|
||||
## D6 — dotenv doit écrire sur stderr
|
||||
|
||||
**Piège.** dotenv affiche une bannière de version sur **stdout**. Or le
|
||||
protocole MCP réserve stdout au JSON : Claude Desktop échoue alors avec
|
||||
`Unexpected token 'd', "[dotenv@17."... is not valid JSON`.
|
||||
|
||||
**Solution.** `src/index.js` détourne `process.stdout.write` vers stderr le
|
||||
temps du chargement de dotenv, puis le restaure.
|
||||
|
||||
**Règle générale.** Dans tout le projet, on log avec `console.error()`.
|
||||
**Jamais** `console.log()`.
|
||||
|
||||
---
|
||||
|
||||
## D7 — Le `.env` est lu à côté de l'exécutable quand le serveur est packagé
|
||||
|
||||
**Décision.** `src/index.js` résout le chemin du `.env` selon le contexte :
|
||||
|
||||
| Contexte | Chemin du `.env` |
|
||||
|---|---|
|
||||
| Sources (`npm start`) | racine du projet |
|
||||
| Exécutable pkg (`process.pkg`) | dossier de `process.execPath` |
|
||||
|
||||
**Pourquoi.** Avec un chemin statique, pkg **embarque le `.env` dans le
|
||||
snapshot** de l'exe : les credentials sont figés dans le binaire et
|
||||
reconfigurer un déploiement impose un rebuild. Le chemin dynamique via
|
||||
`process.execPath` empêche pkg de le détecter, donc rien n'est embarqué, et
|
||||
`dist/.env` devient le fichier de configuration du déploiement.
|
||||
|
||||
**Vérification.** Sans `.env` à côté de l'exe, le serveur démarre en
|
||||
avertissant `WMS_PROFILES is empty` — preuve qu'aucune valeur n'est embarquée.
|
||||
|
||||
---
|
||||
|
||||
## D8 — Multi-profils au runtime plutôt qu'un serveur MCP par WMS
|
||||
|
||||
**Décision.** Un seul serveur MCP dessert plusieurs backends WMS ; Claude bascule
|
||||
avec `switch_wms_profile`.
|
||||
|
||||
**Pourquoi.** L'alternative — une entrée par client dans
|
||||
`claude_desktop_config.json` — multiplie les processus, les jeux de credentials
|
||||
et les caches, pour un usage où l'on ne consulte qu'un WMS à la fois.
|
||||
|
||||
**Conséquence.** L'état actif est **global au processus**. Un changement de
|
||||
profil doit invalider tout ce qui dépend du tenant. Les services s'abonnent via
|
||||
`profileManager.onSwitch()` :
|
||||
|
||||
| Service | Réaction au switch |
|
||||
|---|---|
|
||||
| `api-service` | `resetToken()` — le token OAuth appartient au tenant précédent |
|
||||
| `workflow-service` | `clearCache()` |
|
||||
| `ad-service` | `invalidateCache()` — tous les types |
|
||||
|
||||
**Ne jamais** appeler ces invalidations à la main depuis un autre module :
|
||||
l'abonnement suffit, et le doublon masquerait un oubli d'abonnement.
|
||||
|
||||
**Sans profil actif** (`DEFAULT_WMS_PROFILE` absent ou invalide), `getCurrent()`
|
||||
lève une erreur qui **énumère les profils disponibles**. C'est intentionnel :
|
||||
Claude lit ce message et enchaîne sur `switch_wms_profile` au lieu d'échouer.
|
||||
|
||||
---
|
||||
|
||||
## D9 — Profils SaaS : accès aux logs refusé, pas silencieux
|
||||
|
||||
**Décision.** Quand `<PROFIL>_SAAS=true`, `read_recent_logs`, `search_logs` et
|
||||
`list_log_files` **lèvent une erreur explicite** renvoyant vers les outils API.
|
||||
|
||||
**Pourquoi.** Le WMS est hébergé dans le cloud Mecalux : le partage
|
||||
`\\<host>\inetpub\logs\...` n'est pas joignable. Retourner « 0 fichier » ferait
|
||||
croire à une absence d'erreurs dans les logs, ce qui est un faux négatif
|
||||
dangereux en diagnostic. Voir [docs/logs.md](docs/logs.md).
|
||||
|
||||
---
|
||||
|
||||
## D10 — Chargement paresseux + cache 1 h
|
||||
|
||||
**Décision.** Workflows et éléments AD ne sont **pas** chargés au démarrage,
|
||||
mais à la première requête qui les concerne, puis mis en cache
|
||||
(`WORKFLOW_CACHE_TTL`, 3 600 000 ms par défaut).
|
||||
|
||||
**Pourquoi.** L'ensemble représente ~38 800 éléments dont 29 374 `Resource` :
|
||||
tout charger au boot ferait échouer le handshake MCP par timeout, pour des
|
||||
données souvent inutiles à la session.
|
||||
|
||||
**Pagination.** La taille de page est réglée **par type** dans
|
||||
`AD_ELEMENT_TYPES` (`src/services/ad-service.js`) : `View: 200`,
|
||||
`Workflow: 5000`, `Resource: 15000`, tout le reste `100000` (soit une seule
|
||||
page). Ces valeurs viennent de l'observation des timeouts serveur — les
|
||||
augmenter à l'aveugle fait échouer les types lourds.
|
||||
|
||||
---
|
||||
|
||||
## D11 — Les paramètres système : `Parameter` + `ParamValue`, fusionnés en JS
|
||||
|
||||
**Piège.** Le modèle Reading ne contient **pas** d'entité
|
||||
`CommandParameterData`. La configuration se lit dans deux entités :
|
||||
|
||||
| Entité | Contenu |
|
||||
|---|---|
|
||||
| `Parameter` | définition + `DefaultValue` |
|
||||
| `ParamValue` | surcharges par entrepôt, liées par `ParameterId` |
|
||||
|
||||
**Décision.** `get_system_parameters` charge les deux intégralement (~200 et
|
||||
~50 lignes) et fait la fusion **côté JavaScript**, en exposant la *valeur
|
||||
effective* par entrepôt (surcharge si présente, défaut sinon).
|
||||
|
||||
**Pourquoi côté JS.** Les filtres (`warehouse`, `param_class`, `search`,
|
||||
`only_overridden`) sont appliqués en JS pour éviter toute concaténation de
|
||||
chaîne LINQ à partir d'entrées utilisateur — pas d'injection possible, et pas
|
||||
de dépendance aux limites du traducteur LINQ (D12).
|
||||
|
||||
Fichier : `src/tools/config-tools.js`.
|
||||
|
||||
---
|
||||
|
||||
## D12 — Les dates relatives ne sont pas traduisibles en LINQ
|
||||
|
||||
**Piège vérifié en production.** `DateTime.Now`, `DateTime.Today` et
|
||||
`AddDays()` ne sont **pas** traduits par le moteur de requêtes : la requête
|
||||
échoue à la compilation.
|
||||
|
||||
**Solution.** Toujours une date littérale :
|
||||
|
||||
```csharp
|
||||
Context.OutboundOrders.Where(z => z.CreationDate > new DateTime(2026, 8, 1))
|
||||
```
|
||||
|
||||
C'est à l'appelant (donc à Claude) de calculer la date avant d'écrire la
|
||||
requête.
|
||||
|
||||
---
|
||||
|
||||
## D13 — `select_expression` reste instable
|
||||
|
||||
**État.** Les projections passées via le paramètre API `Select` déclenchent des
|
||||
erreurs de compilation côté serveur.
|
||||
|
||||
**Contournement actuel.** Interroger les lignes complètes et filtrer les
|
||||
colonnes côté client.
|
||||
|
||||
**Non résolu.** C'est le principal point ouvert du projet. Toute tentative de
|
||||
correction doit être validée sur un vrai WMS avant d'être documentée ici.
|
||||
|
||||
---
|
||||
|
||||
## D14 — `executeCommand` : pas de suffixe d'assembly
|
||||
|
||||
**Piège.** Ajouter `, Mecalux.ITSW.EasyWMS.Modules.Contracts` au nom de commande
|
||||
provoque une `FileLoadException`.
|
||||
|
||||
**Solution.** Utiliser le `command_name` **tel quel** :
|
||||
l'`InternalCommandName` fourni par l'AD contient déjà le nom pleinement
|
||||
qualifié correct.
|
||||
|
||||
---
|
||||
|
||||
## D15 — Validation TLS désactivée
|
||||
|
||||
**Décision.** `httpsAgent: new https.Agent({ rejectUnauthorized: false })`.
|
||||
|
||||
**Pourquoi.** Les WMS on-premise sont exposés en HTTPS avec un certificat
|
||||
auto-signé sur une IP privée.
|
||||
|
||||
**Limite assumée.** Acceptable sur réseau interne ou via VPN. Sur un profil
|
||||
SaaS joint par Internet, cela supprime la protection contre l'interception —
|
||||
à revoir si l'outil sort du cadre du diagnostic interne.
|
||||
|
||||
---
|
||||
|
||||
## D16 — Historique des shipment templates : hors périmètre
|
||||
|
||||
**Constat.** L'entité Reading `ShipmentTemplate` n'expose que la **dernière**
|
||||
exécution (`LastExecuteDate`, `Status`, `IsEnabled`).
|
||||
|
||||
L'historique complet n'existe que dans les logs texte
|
||||
`ApplyShipmentTemplates`, **absents de l'hôte joignable** (`10.255.255.2`) :
|
||||
ils résident sur les serveurs de production / ETL des clients.
|
||||
|
||||
**Décision.** Aucun outil d'analyse de ces logs n'a été construit — il n'aurait
|
||||
rien à lire. Les recettes purement API sont dans la resource
|
||||
`wms://query-examples`.
|
||||
|
||||
---
|
||||
|
||||
## D17 — `WorkflowAction` et `WritingModel` retirés de la liste AD
|
||||
|
||||
**Constat.** Les endpoints `/AD/api/WorkflowAction/GetByApplication` et
|
||||
`/AD/api/WritingModel/GetByApplication` répondent `404 Not Found`.
|
||||
|
||||
**Décision.** Ces deux types sont sortis de `AD_ELEMENT_TYPES` : il en reste
|
||||
**20**, dont 3 valides mais vides (`Dashboard`, `TimelineTemplate`, `Toggle`).
|
||||
Détail de la campagne de validation :
|
||||
[docs/ad-api-validation.md](docs/ad-api-validation.md).
|
||||
|
||||
---
|
||||
|
||||
## D18 — Build : `@yao-pkg/pkg` ciblant node22
|
||||
|
||||
**Décision.** Le build utilise `@yao-pkg/pkg` (fork maintenu de `pkg`, archivé
|
||||
depuis) avec la cible **`node22-win-x64`**.
|
||||
|
||||
**Pourquoi cette cible.** `node20-win-x64` n'a pas de binaire prébuilt
|
||||
disponible : pkg bascule alors sur une compilation de Node depuis les sources,
|
||||
qui échoue faute de `vcbuild.bat` (toolchain MSVC absente).
|
||||
|
||||
**Avertissements normaux au build.** `Cannot find module
|
||||
'@modelcontextprotocol/sdk/server/index.js'` et `Entry 'main' not found` :
|
||||
pkg ne sait pas résoudre statiquement la table `exports` du SDK. L'exécutable
|
||||
produit **fonctionne** — vérifié en démarrant l'exe. Ne pas chercher à
|
||||
« corriger » ces avertissements.
|
||||
|
||||
---
|
||||
|
||||
## D19 — La resource `docs://` a été supprimée
|
||||
|
||||
**Constat.** `src/resources/documentation.js` (193 lignes) exposait un index des
|
||||
`.md` de `docs/`, mais n'a **jamais été branché** dans `src/index.js` : le
|
||||
handler `resources/list` ne l'incluait pas et `resources/read` ne routait aucune
|
||||
URI `docs://`. `docs/README.md` promettait pourtant la fonctionnalité aux
|
||||
utilisateurs.
|
||||
|
||||
**Décision.** Fichier supprimé, `docs/README.md` corrigé. `docs/` reste un
|
||||
dossier de référence pour les humains et pour un agent qui lit le dépôt — pas
|
||||
une resource MCP.
|
||||
|
||||
**Si on veut la fonctionnalité un jour**, il faut la brancher réellement (2
|
||||
lignes dans `src/index.js`) *et* décider de son sort dans l'exécutable pkg, qui
|
||||
n'embarque pas `docs/`.
|
||||
|
||||
---
|
||||
|
||||
## D20 — Purge du dépôt (2026-08-24)
|
||||
|
||||
Supprimés lors du nettoyage :
|
||||
|
||||
| Élément | Raison |
|
||||
|---|---|
|
||||
| 5 `.md` dupliqués à la racine | copies md5-identiques de `docs/api/` et `docs/entities/` |
|
||||
| `JANITOR_main.js`, `JANITOR_entities.json` | application Electron sans lien avec le MCP |
|
||||
| `temp/*.json` | dumps de workflows versionnés par accident (`temp/` désormais ignoré) |
|
||||
| `claude_desktop_config_ssh.json` | **mots de passe en clair** + variables `ORACLE_*` de l'architecture supprimée (D1) |
|
||||
| `IMPLEMENTATION_SUMMARY.md` | doublon d'`AD_API_TEST_RESULTS.md`, déplacé en `docs/ad-api-validation.md` |
|
||||
| `src/resources/documentation.js` | code mort (D19) |
|
||||
| `src/config/constants.js` (83 l.) | module entier inutilisé : `require` présent dans `wms-query-service.js`, mais **aucune** de ses constantes n'était lue |
|
||||
| `log-service.js` : `findRecentErrors`, `readFullLog`, `getLogStats` (~100 l.) | exportées, jamais appelées — aucun outil MCP ne les exposait |
|
||||
| `RESOURCE_URIS.WORKFLOWS_CATEGORIES` | URI déclarée, jamais servie |
|
||||
| `LOG_FILE_PATTERN` | lue depuis `.env`, jamais utilisée (le scan filtre sur `.log` en dur) |
|
||||
|
||||
Les trois fonctions de `log-service.js` étaient fonctionnelles ; si l'une d'elles
|
||||
redevient utile (`findRecentErrors` en particulier), la reprendre depuis le
|
||||
commit `b59cbb3` et **l'exposer réellement** comme outil MCP plutôt que de la
|
||||
laisser inatteignable.
|
||||
|
||||
⚠️ **Credentials à faire tourner.** `claude_desktop_config_ssh.json` et
|
||||
l'ancienne version de `test-ad-api.ps1` contenaient des mots de passe en clair.
|
||||
Le fichier est retiré du répertoire de travail, **mais il reste dans
|
||||
l'historique git** (commit `b59cbb3`). Considérez ces mots de passe comme
|
||||
compromis et changez-les ; à défaut, réécrivez l'historique avant toute
|
||||
publication du dépôt.
|
||||
|
||||
---
|
||||
|
||||
## D21 — `Context.{...}` attend le `TableName` du Metadata, résolu par service
|
||||
|
||||
**Piège.** Les expressions LINQ de `QueryExecute` référencent les entités par
|
||||
le `TableName` de l'API Metadata, **pas** par le nom d'entité de l'Application
|
||||
Dictionary. Ce n'est pas une pluralisation : `Container` -> `Containers`, mais
|
||||
`Alias` -> `Alias` (invariant), et `Item` n'existe pas. Un nom faux part en
|
||||
HTTP 500 (erreur de compilation `'ApplicationReadingContext' ne contient pas
|
||||
de définition pour '...'`). Seul `TableName` fait foi — **ne réinventez pas de
|
||||
règle grammaticale**.
|
||||
|
||||
**Décision.** `src/services/entity-resolver.js` construit une table
|
||||
`Name | TableName (insensible à la casse) -> TableName` et tous les points
|
||||
d'interpolation (`wms-query-service`, `call_query_api`) passent par elle.
|
||||
Mesures du 24/08/2026 (`LIMAGRAI2512`) :
|
||||
|
||||
- Le contexte de lecture est **commun au tenant** : la table agrège le
|
||||
Metadata de toutes les applications. La liste vient de
|
||||
`GET /configuration/applications` (5 applications déployées avec version) —
|
||||
les applications EasyBuilder sans contexte requêtable (`CustomApp`…) n'y
|
||||
figurent pas et ne fournissent de toute façon **0 entité** Metadata.
|
||||
- 288 `TableName` distincts, aucun conflit `Name -> TableName` entre
|
||||
applications.
|
||||
|
||||
Comportements :
|
||||
|
||||
- **Nom inconnu** : échec avant tout appel réseau de requête, message avec
|
||||
suggestions proches et renvoi vers `get_entity_metadata`.
|
||||
- **Metadata injoignable** : le nom passe tel quel (comportement historique)
|
||||
et la réponse porte un `warning` — on ne bloque pas tout le serveur pour un
|
||||
cache irrécupérable.
|
||||
- Cache : TTL partagé (`WORKFLOW_CACHE_TTL`), chargement paresseux,
|
||||
invalidation par abonnement `onSwitch()` (D8, D10).
|
||||
|
||||
---
|
||||
|
||||
## D22 — Routage des outils par table explicite, plus par préfixe de nom
|
||||
|
||||
**Piège.** Le handler `tools/call` de `src/index.js` routait par préfixe de nom
|
||||
(`startsWith`, `includes`) dans une cascade de `else if`. Deux outils listés
|
||||
dans `tools/list` n'atteignaient jamais leur module — reproduits le
|
||||
24/08/2026 :
|
||||
|
||||
| Outil | Cause | Erreur renvoyée |
|
||||
|---|---|---|
|
||||
| `get_entity_metadata` | capté par `startsWith('get_entity_')` (branche `wms-query-tools`, placée avant la sienne) | `Unknown WMS query tool: get_entity_metadata` |
|
||||
| `list_log_files` | la branche logs testait `includes('_logs')`, or le nom contient `_log_files` | `Unknown tool: list_log_files` |
|
||||
|
||||
Le routage par préfixe fait dépendre la joignabilité d'un outil de l'**ordre
|
||||
des branches** et de conventions de nommage implicites : chaque ajout d'outil
|
||||
pouvait en casser un autre silencieusement.
|
||||
|
||||
**Décision.** Une table `nom d'outil → module` est construite au démarrage en
|
||||
parcourant les `listTools()` des 8 modules de `src/tools/`. `tools/list` est
|
||||
servi depuis cette même table et le dispatch est un lookup : un outil listé
|
||||
est un outil routé, **par construction**. Deux modules déclarant le même nom
|
||||
font échouer le serveur au démarrage (message nommant les deux modules) —
|
||||
c'est un bug de développement, pas un cas d'exécution.
|
||||
|
||||
La table ne présume rien de la signature des outils : `(name, args)` est
|
||||
transmis tel quel au `executeTool()` du module. Ajouter un paramètre à un
|
||||
outil ne la concerne pas.
|
||||
|
||||
---
|
||||
|
||||
## D23 — Le SDK ne valide pas les arguments : validation dans le wrapper
|
||||
|
||||
**Piège mesuré (24/08/2026).** Le SDK MCP (`@modelcontextprotocol/sdk` 1.x)
|
||||
ne valide **pas** les arguments d'appel contre l'`inputSchema` déclaré :
|
||||
`additionalProperties: false` est ignoré, et un paramètre inconnu
|
||||
(`read_recent_logs(lines: 60)`) retombe silencieusement sur les défauts
|
||||
(`count = 100`) sans le moindre signal.
|
||||
|
||||
**Décision.** Le wrapper `tools/call` de `src/index.js` valide chaque appel
|
||||
contre le schéma de la table de routage (D22) avant le dispatch — schéma
|
||||
déclaré = contrat appliqué, pour les 23 outils d'un coup :
|
||||
|
||||
- **paramètre inconnu** → erreur structurée nommant le paramètre fautif **et**
|
||||
les paramètres valides de l'outil ;
|
||||
- **paramètre `required` manquant** → même forme d'erreur.
|
||||
|
||||
Les 23 schémas portent aussi `additionalProperties: false` : inerte côté SDK,
|
||||
mais c'est le contrat que lisent les clients. La validation reste volontairement
|
||||
superficielle (noms et présence, pas les types) : le but est de supprimer le
|
||||
silence, pas de réimplémenter JSON Schema.
|
||||
|
||||
Le renommage des paramètres (`entity_type`/`query` uniformisés) a été **écarté**
|
||||
au profit de cette validation — voir ROADMAP « Écarté ».
|
||||
|
||||
---
|
||||
|
||||
## D24 — Contrat de troncature : borné + signalé, jamais un rejet silencieux
|
||||
|
||||
**Piège mesuré (24-25/08/2026).** Une réponse d'outil de ~70 000 caractères
|
||||
(`get_system_parameters` sans filtre ; `search_logs` atteignait 52-56 000 avec
|
||||
les seuls défauts) est **rejetée par le client MCP** — l'utilisateur voit un
|
||||
échec opaque au lieu d'un résultat partiel.
|
||||
|
||||
**Décision.** Tout outil susceptible de produire une sortie volumineuse borne
|
||||
sa réponse et **signale** la coupe. Le signal est commun :
|
||||
|
||||
| Champ | Sémantique |
|
||||
|---|---|
|
||||
| `truncated: true` | présent **uniquement** quand la réponse a été coupée — jamais `truncated: false` |
|
||||
| `hint` | présent ssi `truncated` ; actionnable : dit comment continuer (`offset` suivant) ou réduire (filtres, `context_lines`…) |
|
||||
| `returned` | nombre d'éléments effectivement renvoyés |
|
||||
| total (`totalParameters`, `totalResults`, `dataTotalChars`) | total **avant** la coupe — `truncated` se vérifie donc depuis la réponse elle-même |
|
||||
|
||||
Les mécanismes restent **volontairement locaux**, car ils diffèrent :
|
||||
`get_system_parameters` pagine (`limit`/`offset` au schéma — rien n'est perdu,
|
||||
on continue avec l'offset suivant) ; `search_logs` plafonne le volume
|
||||
(`MAX_LOG_SEARCH_CHARS`, défaut 25 000 caractères) en écartant des résultats
|
||||
**entiers** — jamais coupés au milieu de leurs lignes de contexte — et annonce
|
||||
en plus `omitted`, le compte écarté. Ces deux-là ne partagent pas de helper : le
|
||||
factoriser forcerait une abstraction commune à deux mécanismes qui n'en ont pas.
|
||||
Les **trois outils de requête**, eux, partagent le même mécanisme — ils
|
||||
partagent donc `src/services/response-limit.js` (voir ci-dessous). Le critère
|
||||
est le mécanisme, pas le nombre d'appelants.
|
||||
|
||||
**Périmètre étendu (lot 5, 25/08/2026).** Trois familles d'outils dépassaient
|
||||
encore le seuil, toutes mesurées sur `LIMAGRAI2512` :
|
||||
|
||||
`get_workflow_details` **fenêtre le blob `data`** (`max_data_chars`, défaut
|
||||
20 000 ; `data_offset`, défaut 0) — 79 092 caractères pour un StackerCrane
|
||||
(dont 71 512 de blob), 101 816 pour `CST_SendRejectContainersToPK` (92 362 de
|
||||
blob), ramenés à ~23 000. La tranche est **verbatim** : découpe de chaîne, rien
|
||||
d'autre. Ne jamais résumer, reformuler ni « parser » cette définition
|
||||
EasyBuilder — la concaténation des tranches dans l'ordre des offsets doit la
|
||||
reconstituer à l'octet près (vérifié : 20 000 + 20 000 + 20 000 + 11 512 =
|
||||
71 512, concaténation identique au blob d'origine). Les métadonnées du workflow
|
||||
restent complètes dans chaque tranche ; seul `data` est fenêtré, et
|
||||
`dataTotalChars` est porté par **toute** réponse — y compris non tronquée, où
|
||||
la seule différence avec l'ancienne réponse est ces trois champs de fenêtre
|
||||
(+65 caractères mesurés).
|
||||
|
||||
`query_wms_entities`, `call_query_api` et `search_wms_data` **plafonnent leur
|
||||
volume** (`MAX_QUERY_RESPONSE_CHARS`, défaut 25 000 — même ordre de grandeur que
|
||||
`MAX_LOG_SEARCH_CHARS`) en écartant des **lignes entières**, via le helper
|
||||
commun `src/services/response-limit.js` (recherche dichotomique : ~8
|
||||
constructions au lieu de 200 retraits ligne à ligne sur des charges utiles de
|
||||
~1 Mo) :
|
||||
|
||||
| Appel | Avant | Après |
|
||||
|---|---:|---:|
|
||||
| `query_wms_entities("Products", limit: 200)` | 957 234 | 24 432 (5 lignes sur 200) |
|
||||
| `search_wms_data("PAL")` | 847 543 | 22 992 (4 résultats sur 150) |
|
||||
| `call_query_api("Products", query_type: 1, limit: 1)` | 95 288 | 738 |
|
||||
|
||||
Trois points de cadrage, tous vérifiés en exécution :
|
||||
|
||||
- **Sous le plafond, rien ne change.** Aucun champ ajouté, réponse identique
|
||||
**octet pour octet** (mesuré sur `query_wms_entities("Container", limit: 1)`,
|
||||
`call_query_api`, `get_entity_schema`, `search_wms_data` sous plafond).
|
||||
`MAX_QUERY_ROWS` et les limites par défaut des outils sont inchangés :
|
||||
le correctif est le bornage signalé, pas une réduction silencieuse.
|
||||
- **Cas limite : une seule ligne dépasse le plafond.** Réel en Writing —
|
||||
`call_query_api("Products", query_type: 1, limit: 1)` répond `returned: 0`,
|
||||
`omitted: 1`, `truncated: true`, avec un hint qui explique le volume Writing
|
||||
et renvoie vers Reading. C'est moins bon qu'un résultat, mais c'est mieux
|
||||
qu'un rejet client opaque.
|
||||
- **`search_wms_data` répartit en tourniquet** les résultats gardés entre les
|
||||
entités, et porte `returned`/`omitted` par entité en plus des totaux. Sans
|
||||
cela, une entité volumineuse placée en tête consommerait tout le budget et
|
||||
les suivantes reviendraient à zéro résultat sans que rien ne le dise —
|
||||
exactement le faux négatif que corrige L5.4.
|
||||
|
||||
`count_wms_entities` n'est pas concerné (`QueryScalarExecute` renvoie un
|
||||
scalaire), et son `query_type` ne porte donc pas l'avertissement de volume
|
||||
ajouté aux deux autres.
|
||||
|
||||
Deux garde-fous de cadrage :
|
||||
|
||||
- **Ne pas réduire les défauts existants** (`max_results` 50, `context_lines` 2)
|
||||
pour passer sous le plafond : le correctif est le bornage signalé, pas un
|
||||
changement silencieux de comportement.
|
||||
- La taille qui fait foi est celle de `content[0].text` **mesurée via le
|
||||
protocole**, pas une estimation. Ordre de grandeur cible : ~20-25 000
|
||||
caractères par réponse.
|
||||
|
||||
Au passage, `totalParameters` a changé de sens : c'était le nombre brut
|
||||
d'entités `Parameter` chargées, c'est désormais le total correspondant aux
|
||||
filtres avant pagination (identique sans filtre).
|
||||
|
||||
---
|
||||
|
||||
## D25 — `query_type` : opt-in explicite, D3 reste la règle par défaut
|
||||
|
||||
**Contexte (mesures des 24-25/08/2026, `LIMAGRAI2512`).** `QueryType` était
|
||||
figé à `0` en dur dans `executeQuery()` et `executeScalarQuery()`, rendant le
|
||||
modèle Writing — opérationnel et mesuré (`Context.Products` en `QueryType: 1`
|
||||
répond) — inatteignable.
|
||||
|
||||
**Décision.** Un paramètre `query_type` (entier, défaut `0`) est exposé sur
|
||||
**trois outils** : `call_query_api`, `query_wms_entities`,
|
||||
`count_wms_entities`. `get_entity_schema` et `search_wms_data` restent des
|
||||
raccourcis Reading, sans paramètre.
|
||||
|
||||
**Rapport à D3.** D3 n'est pas révisée : le Reading reste la règle par défaut,
|
||||
car en Writing les statuts sont des **énumérations** — les comparaisons de
|
||||
chaînes (`== "Release"`), cas le plus courant en debug, y échouent. La bascule
|
||||
est un opt-in explicite et les descriptions d'outils portent l'avertissement.
|
||||
|
||||
Modalités :
|
||||
|
||||
- **Garde de valeur dans le code de l'outil**, pas dans le wrapper : D23 valide
|
||||
les noms de paramètres, pas les valeurs. Hors `0..3` (ou non entier) →
|
||||
erreur locale via `assertValidQueryType()` (`wms-query-service.js`), **avant
|
||||
tout appel réseau**, nommant les quatre contextes.
|
||||
- **`2` et `3` sont transmis tels quels** : le WMS répond et son diagnostic
|
||||
remonte entier (L1.1). Sur `LIMAGRAI2512` : `2` = DataWarehouse non configuré
|
||||
(`Could not resolve serviceType 'IDataWarehouse…'`), `3` = Metrics, contexte
|
||||
présent mais modèle distinct (`'ApplicationMetricDataContext' ne contient pas
|
||||
de définition pour 'Products'`).
|
||||
- **Interaction avec le resolver (D21)** : la table de résolution est
|
||||
construite sur le Metadata **Reading**. Quand `query_type != 0`, un nom qui
|
||||
se résout se résout normalement (`Products` marche en Writing, mesuré) ; un
|
||||
nom **inconnu** du Reading n'est **pas** bloqué — il passe tel quel avec un
|
||||
`warning` dans la réponse (`allowUnknown` du resolver, même mécanique que le
|
||||
repli « Metadata injoignable »), car le modèle Writing/Metrics peut contenir
|
||||
des entités hors Reading. Le `warning` est conservé aussi dans la réponse
|
||||
d'erreur si le WMS échoue ensuite.
|
||||
|
||||
---
|
||||
|
||||
## D26 — Paramètre `application` : caches par application, chargement toujours paresseux
|
||||
|
||||
**Contexte (mesures des 24-25/08/2026, `LIMAGRAI2512`).** L'application
|
||||
interrogée venait de `WMS_APPLICATION` (partagée par tous les profils) : le MCP
|
||||
ne voyait que `EasyWMS`. Or `POST /AD/api/Application/GetAll` déclare **9
|
||||
applications**, et **CustomApp porte le spécifique client** (153 workflows
|
||||
`CST_*` sur ce tenant) — précisément ce qu'on cherche en debug. Les 11 entités
|
||||
`CustomApp` ne sont requêtables dans aucun contexte : l'API AD est le seul
|
||||
accès au spécifique client.
|
||||
|
||||
**Décision.** Un paramètre `application` (défaut : l'application du profil,
|
||||
donc comportement strictement inchangé sans lui) sur six outils :
|
||||
`get_ad_elements`, `search_ad_elements`, `get_ad_element_details`,
|
||||
`search_workflows`, `get_workflow_details`, `list_workflow_categories`.
|
||||
|
||||
**Contrat de cache.**
|
||||
|
||||
| Service | Clé avant | Clé après |
|
||||
|---|---|---|
|
||||
| `ad-service` | un cache par type | un cache par **(application, type)** (`app::type`) |
|
||||
| `workflow-service` | un cache global | un cache par **application** |
|
||||
|
||||
Sans ces clés, un appel CustomApp polluerait le cache EasyWMS du même type.
|
||||
Règles associées :
|
||||
|
||||
- **L'invalidation reste l'abonnement `onSwitch()`** (D8) : la bascule de
|
||||
profil vide **tous** les caches, toutes applications confondues. Aucune
|
||||
invalidation manuelle inter-module.
|
||||
- **Pas de préchargement des 9 applications** (D10) : seule l'application
|
||||
effectivement demandée est chargée — le type `Resource` pèse 29 374 éléments
|
||||
sur la seule EasyWMS.
|
||||
- `workflow-service` cache aussi la liste de `Application/GetAll`, **allégée**
|
||||
(`name`, `id`, `version`) : chaque élément de la réponse brute embarque un
|
||||
blob `data` de ~100 Ko (la définition EasyBuilder complète) qu'on ne
|
||||
conserve pas.
|
||||
- `list_workflow_categories` est adossé à `Application/GetAll` (les 9
|
||||
applications) et non plus aux `applicationName` du seul cache actif. La note
|
||||
de L1.3 reste vraie — pas de champ catégorie ; les comptes de workflows ne
|
||||
sont affichés que pour les applications déjà chargées (paresseux). Le
|
||||
paramètre `category` de `search_workflows` (filtre sur `applicationName`)
|
||||
subsiste : `application` choisit le jeu chargé, `category` filtre dedans —
|
||||
leur articulation est documentée dans les descriptions.
|
||||
- `get_application_summary` regroupe l'état par application puis par type et
|
||||
ne détaille que les entrées **effectivement en cache** : la sortie reste
|
||||
bornée quel que soit le nombre d'applications interrogées (D24). Il expose
|
||||
aussi les caches de workflows par application.
|
||||
|
||||
**Le paramètre ne suffisait pas : il faut que la réponse le dise** (lot 5,
|
||||
25/08/2026). Cas réel : une session Cowork cherchant des workflows `CST_*` sans
|
||||
passer `application: "CustomApp"` a conclu que l'AD n'en contenait aucun — alors
|
||||
que `CST_PickingTasksSequencing_PR` et `CST_ChooseDestinationFromPS` existent.
|
||||
Le paramètre était disponible et documenté ; ce qui manquait, c'est que **rien
|
||||
dans la réponse ne disait qu'on n'avait regardé qu'une application sur neuf**.
|
||||
Un défaut silencieux se lit comme une exhaustivité.
|
||||
|
||||
`search_workflows` et `search_ad_elements` rappellent donc **toujours**
|
||||
l'application effectivement interrogée (plus seulement quand le paramètre a été
|
||||
passé), et ajoutent un `hint` quand la recherche revient **vide** :
|
||||
|
||||
- Seuil à **0 résultat**, pas « peu ». Toute valeur non nulle produirait un hint
|
||||
parasite sur une recherche légitimement étroite, et le mode d'échec observé
|
||||
est bien le zéro pris pour une absence.
|
||||
- Le hint nomme les autres applications depuis la liste allégée **déjà en
|
||||
cache** ; sans elle, il reste générique et renvoie vers
|
||||
`list_workflow_categories`. **Jamais de fetch pour construire un hint** —
|
||||
ce serait précisément le préchargement que cette décision interdit.
|
||||
- Il nomme `CustomApp` en clair, sauf quand c'est déjà l'application
|
||||
interrogée : c'est une connaissance statique, déjà portée par les
|
||||
descriptions d'outils, pas une donnée à aller chercher.
|
||||
|
||||
---
|
||||
|
||||
## D27 — Chargements paresseux sous concurrence : single-flight + génération
|
||||
|
||||
**Contexte (mesuré le 25/08/2026, `LIMAGRAI2512`).** Le serveur traite les
|
||||
`tools/call` **en concurrence** : une rafale d'appels dans une même session
|
||||
s'exécute en parallèle. Les trois services à cache chargeaient paresseusement
|
||||
sans se coordonner — le premier appelant qui trouve le cache invalide lance le
|
||||
fetch, et tous ceux qui arrivent pendant ce fetch trouvent le cache **encore**
|
||||
invalide et lancent le leur. Mesures avant correction :
|
||||
|
||||
| Rafale | Résultat |
|
||||
|---|---|
|
||||
| 6 × `search_workflows` (CustomApp) | 6 × `fetching from API` pour une seule clé |
|
||||
| 6 × `query_wms_entities` (Container) | 6 × `[EntityResolver] Cache expired or empty` — soit 30 GET Metadata |
|
||||
| 14 appels mixtes | l'API AD répond **HTTP 500** sur `Workflow/GetByApplication` (EasyWMS, ~4 000 workflows) — les 4 appels EasyWMS échouent, les mêmes passent en séquentiel |
|
||||
|
||||
La dernière ligne est le vrai coût : la duplication ne gaspille pas seulement
|
||||
des appels, elle **surcharge l'API AD au point de la faire échouer**.
|
||||
|
||||
**Décision.** Un motif unique, `src/services/single-flight.js`, partagé par
|
||||
`workflow-service`, `ad-service` et `entity-resolver` — une `Map` de promesses,
|
||||
pas une dépendance externe :
|
||||
|
||||
- **Une clé de single-flight par entrée de cache** : `workflows::<app>` et
|
||||
`applications` pour les workflows, `<app>::<type>` pour l'AD, une clé unique
|
||||
pour le resolver. Deux clés distinctes se chargent toujours **en parallèle** —
|
||||
le single-flight ne sérialise rien au-delà de la clé demandée, et
|
||||
n'introduit aucun préchargement (D26 intact).
|
||||
- **La promesse est retirée au règlement, succès *ou* échec.** Un fetch en
|
||||
erreur ne reste pas coincé dans la Map : l'appel suivant refetche. Les
|
||||
appelants joints reçoivent la même erreur, et rien n'est mis en cache.
|
||||
- **Le log de fetch reste l'observable** (D6) : une ligne `fetching from API`
|
||||
/ `Cache expired or empty` par chargement **réel**. Les appelants joints
|
||||
émettent une ligne distincte (`Fetch already in flight … joining it`) — ne
|
||||
fusionnez pas les deux, c'est ce qui rend la déduplication vérifiable depuis
|
||||
stderr.
|
||||
|
||||
**Garde de génération.** Un fetch parti *avant* une invalidation terminait
|
||||
*après* elle et écrivait quand même son résultat : le cache repartait peuplé
|
||||
avec les données de l'ancien tenant, timestamp neuf, `valid: true`. Défaut
|
||||
latent avant le single-flight, **déterministe après** (la promesse en vol
|
||||
survit à l'invalidation). D'où :
|
||||
|
||||
- Un **compteur de génération par service**, incrémenté à chaque invalidation
|
||||
(`clearCache()` / `invalidateCache()`, toujours déclenchées par
|
||||
`onSwitch()` — D8 inchangé). Le fetch capture la génération au départ.
|
||||
- **Les fonctions de chargement n'écrivent plus rien en cache** : la
|
||||
publication est un `commit` passé à `singleFlight.run`, appelé *seulement*
|
||||
si la génération n'a pas bougé. C'est structurel, pas conventionnel — un
|
||||
fetch ne peut plus publier par inadvertance.
|
||||
- L'invalidation vide aussi la Map des promesses en vol. **L'appelant reçoit
|
||||
quand même son résultat** — il l'a demandé avant la bascule ; c'est sa mise
|
||||
en cache qui est refusée, tracée par
|
||||
`Result for "…" discarded, not cached`.
|
||||
|
||||
Mesuré sur `[search_workflows(EasyWMS), switch_wms_profile(EUROTRAFIC)]` envoyé
|
||||
d'un bloc, puis `get_application_summary` en séquentiel : **3/3 avant**, le
|
||||
cache EasyWMS de LIMAGRAIN (3 944 workflows) survit à la bascule avec un
|
||||
timestamp neuf ; **3/3 après**, aucun cache peuplé.
|
||||
|
||||
**Vide anormal ≠ vide réel.** Les services lisaient `response?.entities || []`
|
||||
sur les réponses de l'API AD (enveloppe `{ entities: [...] }`, D4). Toute
|
||||
réponse d'une **autre forme** devenait donc un tableau vide, indistinguable
|
||||
d'une page finale légitime — et mise en cache avec un timestamp valide : un
|
||||
cache vide empoisonné pour tout le TTL, sans message. C'est la cause probable
|
||||
du `count: 0` mesuré sous rafale, et le mode d'échec le plus coûteux du lot :
|
||||
il se lit comme une réponse.
|
||||
|
||||
`src/services/ad-envelope.js` porte le contrat pour les trois sites
|
||||
(`Workflow/GetByApplication`, `Application/GetAll`, `<Type>/GetByApplication`) :
|
||||
|
||||
| Réponse | Traitement |
|
||||
|---|---|
|
||||
| `{ entities: [...] }`, `[]` réel compris | rendue telle quelle — une application peut être légitimement vide (`SmartUI` : 0 workflow ; 3 types AD valides mais vides, D17) |
|
||||
| toute autre forme | **lève** — l'appel échoue, rien n'est mis en cache, l'appel suivant refetche |
|
||||
|
||||
**Pas de retry, pas de résilience.** L'anomalie doit être **visible et non
|
||||
persistante** ; la rattraper la rendrait invisible, ce qui est exactement le
|
||||
défaut corrigé. `entity-resolver` était déjà conforme : il lève déjà si
|
||||
`/configuration/applications` ou le Metadata ne rendent aucune entité.
|
||||
|
||||
Mesures : `search_workflows` sur `SmartUI` → `count: 0`, `success: true`,
|
||||
cache posé (`count: 0`, `valid: true`) et hint L5.4 présent. Les trois sites
|
||||
face à une réponse `{}` → erreur levée, `{}` en cache, et le fetch suivant
|
||||
repart normalement.
|
||||
|
||||
**Mesures après.** Rafale de 6 (CustomApp) → 1 fetch + 5 joins, les 6 réponses
|
||||
à `count: 44`. Rafale de 6 (resolver) → 1 chargement, 5 GET Metadata au lieu de
|
||||
30. Rafale mixte EasyWMS + CustomApp → **un fetch par application**, deux au
|
||||
total, plus aucun HTTP 500. Le chemin séquentiel nominal est inchangé : 1 fetch
|
||||
puis 1 `Using cached data`, 0 join.
|
||||
|
||||
**Ce que cette décision ne couvre pas.** La bascule de profil concurrente aux
|
||||
appels en vol (un `switch_wms_profile` qui redirige des requêtes déjà parties)
|
||||
reste un point ouvert de la ROADMAP, distinct.
|
||||
@@ -1,215 +0,0 @@
|
||||
# WMS MCP Server - Implementation Summary
|
||||
|
||||
**Date:** 2026-04-02
|
||||
**Status:** ✅ **COMPLETE AND VALIDATED**
|
||||
|
||||
## Overview
|
||||
|
||||
Successfully implemented a comprehensive MCP (Model Context Protocol) server for the WMS (Warehouse Management System) with 100% API-based architecture. The server provides Claude Desktop with full access to WMS data, workflows, and Application Dictionary elements.
|
||||
|
||||
## Implementation Highlights
|
||||
|
||||
### ✅ Phase 1: Core Infrastructure
|
||||
- **API Service:** OAuth 2.0 authentication with automatic token refresh
|
||||
- **MCP Server:** Full MCP SDK implementation with resources and tools
|
||||
- **Configuration:** Environment-based configuration with .env support
|
||||
- **Error Handling:** Comprehensive error handling and logging
|
||||
|
||||
### ✅ Phase 2: WMS APIs
|
||||
- **Query API:** LINQ-based entity querying (12 entity types)
|
||||
- **Command API:** WMS command execution
|
||||
- **Workflow API:** Workflow fetching with lazy loading and caching
|
||||
- Successfully validated: 3,712 workflows retrieved
|
||||
|
||||
### ✅ Phase 3: Application Dictionary (NEW)
|
||||
- **20 Element Types:** Commands, Queries, Dialogs, Views, Entities, Events, etc.
|
||||
- **38,765 Total Elements:** Comprehensive coverage of application definitions
|
||||
- **17/19 Types Validated:** curl testing confirmed API endpoints work
|
||||
- **Lazy Loading + Cache:** Same pattern as workflows (1-hour TTL)
|
||||
|
||||
## Test Results
|
||||
|
||||
### curl Validation (Application Dictionary)
|
||||
|
||||
| Status | Count | Percentage |
|
||||
|--------|-------|------------|
|
||||
| ✅ Working | 17 | 89.5% |
|
||||
| ❌ Failed (404) | 2 | 10.5% |
|
||||
|
||||
**Working Types:**
|
||||
- Command (1,872)
|
||||
- Query (2,016)
|
||||
- Dialog (734)
|
||||
- View (373)
|
||||
- Entity (331)
|
||||
- Event (1,980)
|
||||
- FieldType (320)
|
||||
- Hook (60)
|
||||
- List (243)
|
||||
- Record (337)
|
||||
- Relationship (49)
|
||||
- Report (67)
|
||||
- Resource (29,374) ← Largest type
|
||||
- Subscription (500)
|
||||
- Validator (17)
|
||||
- ViewGroup (180)
|
||||
- Workflow (3,712)
|
||||
|
||||
**Failed Types (Removed):**
|
||||
- WorkflowAction (404 Not Found)
|
||||
- WritingModel (404 Not Found)
|
||||
|
||||
**Empty Types (Available):**
|
||||
- Dashboard (0)
|
||||
- TimelineTemplate (0)
|
||||
- Toggle (0)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### Services
|
||||
- ✅ `src/services/api-service.js` - OAuth + HTTP client
|
||||
- ✅ `src/services/workflow-service.js` - Workflow fetching
|
||||
- ✅ `src/services/ad-service.js` - **NEW: Application Dictionary service**
|
||||
- ✅ `src/services/wms-query-service.js` - LINQ query builder
|
||||
- ✅ `src/services/log-service.js` - Log file operations
|
||||
|
||||
### Tools
|
||||
- ✅ `src/tools/workflow-tools.js` - Workflow tools (3 tools)
|
||||
- ✅ `src/tools/ad-tools.js` - **NEW: AD tools (5 tools)**
|
||||
- ✅ `src/tools/wms-query-tools.js` - WMS query tools (3 tools)
|
||||
- ✅ `src/tools/api-tools.js` - API tools (2 tools)
|
||||
- ✅ `src/tools/log-tools.js` - Log tools (2 tools)
|
||||
|
||||
### Resources
|
||||
- ✅ `src/resources/wms-entities.js` - Entity catalog
|
||||
- ✅ `src/resources/entity-schemas.js` - Schema documentation
|
||||
- ✅ `src/resources/query-examples.js` - LINQ examples
|
||||
- ✅ `src/resources/workflows.js` - Workflow overview
|
||||
- ✅ `src/resources/apis.js` - API documentation
|
||||
- ✅ `src/resources/logs.js` - Log guide
|
||||
|
||||
### Core
|
||||
- ✅ `src/index.js` - Main MCP server with AD integration
|
||||
- ✅ `.env` - Environment configuration
|
||||
- ✅ `package.json` - Dependencies
|
||||
|
||||
### Documentation
|
||||
- ✅ `CLAUDE.md` - Updated with AD implementation details
|
||||
- ✅ `AD_API_TEST_RESULTS.md` - **NEW: Detailed curl test results**
|
||||
- ✅ `test-ad-api.ps1` - **NEW: PowerShell test script**
|
||||
- ✅ `IMPLEMENTATION_SUMMARY.md` - This file
|
||||
|
||||
## Total MCP Tools Implemented
|
||||
|
||||
| Category | Tool Count | Tool Names |
|
||||
|----------|------------|------------|
|
||||
| **Workflow** | 3 | search_workflows, get_workflow_details, list_workflow_categories |
|
||||
| **AD Elements** | 5 | get_application_summary, get_ad_elements, search_ad_elements, get_ad_element_details, list_ad_types |
|
||||
| **WMS Queries** | 3 | query_wms_entities, get_entity_schema, search_wms_data |
|
||||
| **APIs** | 2 | call_query_api, execute_command |
|
||||
| **Logs** | 2 | read_recent_logs, search_logs |
|
||||
| **TOTAL** | **15** | |
|
||||
|
||||
## Total MCP Resources Implemented
|
||||
|
||||
1. `wms://entities` - WMS entity catalog
|
||||
2. `wms://entity-schemas` - Entity schema details
|
||||
3. `wms://query-examples` - LINQ query examples
|
||||
4. `workflows://overview` - Workflow overview
|
||||
5. `api://catalog` - API catalog
|
||||
6. `logs://guide` - Log file guide
|
||||
|
||||
**TOTAL: 6 resources**
|
||||
|
||||
## Critical Fixes Applied
|
||||
|
||||
### 1. dotenv stdout pollution
|
||||
**Problem:** dotenv writes to stdout, breaking MCP protocol
|
||||
**Solution:** Redirect stdout to stderr during dotenv loading
|
||||
|
||||
### 2. Missing tenant_code
|
||||
**Problem:** OAuth returns 400 Bad Request
|
||||
**Solution:** Added `tenant_code: process.env.WMS_API_TENANT` to auth request
|
||||
|
||||
### 3. API response structure
|
||||
**Problem:** Code expected direct array, API returns `{entities: [...]}`
|
||||
**Solution:** Extract entities: `response?.entities || []`
|
||||
|
||||
### 4. Property name inconsistency
|
||||
**Problem:** API returns both lowercase and uppercase properties
|
||||
**Solution:** Support both: `e.name || e.Name`
|
||||
|
||||
### 5. WorkflowAction & WritingModel 404
|
||||
**Problem:** Two AD element types return 404 Not Found
|
||||
**Solution:** Removed from AD_ELEMENT_TYPES configuration
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
1. **Lazy Loading:** Data fetched only when needed (not at startup)
|
||||
2. **Caching:** 1-hour TTL for all cached data
|
||||
3. **Pagination:** High page sizes to minimize API calls
|
||||
- Workflows: 5,000 per page
|
||||
- Resource: 15,000 per page
|
||||
- Others: 100,000 per page (usually single fetch)
|
||||
4. **Property Access:** Flexible property name handling (lowercase/uppercase)
|
||||
|
||||
## Architecture Achievements
|
||||
|
||||
✅ **100% API-based:** No Oracle database dependency
|
||||
✅ **OAuth 2.0:** Secure token-based authentication
|
||||
✅ **Lazy Loading:** Minimal startup time
|
||||
✅ **Caching:** Reduced API load
|
||||
✅ **Error Resilient:** Comprehensive error handling
|
||||
✅ **MCP Compliant:** Full protocol compliance
|
||||
✅ **Validated:** curl testing confirmed functionality
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Query Workflows
|
||||
```
|
||||
"Search for workflows containing 'Order'"
|
||||
"Get details of workflow 'Helper_MasterOutboundOrderById'"
|
||||
```
|
||||
|
||||
### Query AD Elements
|
||||
```
|
||||
"Get all Command elements"
|
||||
"Search Queries containing 'Load'"
|
||||
"Get details of Dialog 'WorkStation_SelectDivision_V1'"
|
||||
"Show me application summary"
|
||||
```
|
||||
|
||||
### Query WMS Data
|
||||
```
|
||||
"Query the last 10 products"
|
||||
"Get schema for Products entity"
|
||||
```
|
||||
|
||||
### Read Logs
|
||||
```
|
||||
"Show me recent log entries"
|
||||
"Search logs for 'error' keyword"
|
||||
```
|
||||
|
||||
## Next Steps (Optional)
|
||||
|
||||
1. **Deploy to Production VM:** Copy to `C:\WMS\mcp\` on server
|
||||
2. **SSH Configuration:** Set up SSH for remote access from dev PC
|
||||
3. **Build Executable:** Create standalone .exe with `pkg`
|
||||
4. **Monitor Performance:** Track cache hit rates and API call frequency
|
||||
5. **Expand Coverage:** Add more AD element type tools as needed
|
||||
|
||||
## Conclusion
|
||||
|
||||
The WMS MCP Server implementation is **complete, validated, and ready for use**. All 17 working Application Dictionary element types have been tested with curl and confirmed functional. The server successfully integrates with Claude Desktop, providing comprehensive access to WMS workflows, application definitions, runtime data, and logs through a clean MCP interface.
|
||||
|
||||
**Total Coverage:**
|
||||
- ✅ 15 MCP Tools
|
||||
- ✅ 6 MCP Resources
|
||||
- ✅ 5 API endpoints (Query, Command, Workflow, AD x20, OAuth)
|
||||
- ✅ 20 AD element types (17 working, 3 empty)
|
||||
- ✅ 38,765 AD elements accessible
|
||||
- ✅ 3,712 workflows accessible
|
||||
- ✅ 12 WMS entity types queryable
|
||||
|
||||
The implementation exceeds the original requirements by adding comprehensive Application Dictionary support beyond the initial workflow-only scope.
|
||||
@@ -1,129 +0,0 @@
|
||||
{
|
||||
"entities": [
|
||||
{
|
||||
"id": "Stock",
|
||||
"name": "Stocks",
|
||||
"displayName": "Stocks",
|
||||
"component": "StockId",
|
||||
"remove_command": "Mecalux.ITSW.EasyWMS.Modules.Contracts.Commands.StockRemoveCommand"
|
||||
},
|
||||
{
|
||||
"id": "Containers",
|
||||
"name": "Containers",
|
||||
"displayName": "Supports",
|
||||
"component": "ComponentId",
|
||||
"remove_command": "Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ContainerRemoveCommand"
|
||||
},
|
||||
{
|
||||
"id": "ProductLocations",
|
||||
"name": "ProductLocations",
|
||||
"displayName": "Emplacements picking dédiés",
|
||||
"component": "ProductLocationId",
|
||||
"remove_command": "Mecalux.ITSW.EasyWMS.Modules.Expeditions.Contracts.Commands.ProductLocationRemoveCommand"
|
||||
},
|
||||
{
|
||||
"id": "Tasks",
|
||||
"name": "Tasks",
|
||||
"displayName": "Tâches",
|
||||
"component": "TaskId",
|
||||
"remove_command": "Mecalux.ITSW.EasyWMS.Modules.Contracts.Commands.TaskRemoveCommand"
|
||||
},
|
||||
{
|
||||
"id": "Products",
|
||||
"name": "Products",
|
||||
"displayName": "Articles",
|
||||
"component": "ProductId",
|
||||
"remove_command": "Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ProductRemoveCommand"
|
||||
},
|
||||
{
|
||||
"id": "Accounts",
|
||||
"name": "Accounts",
|
||||
"displayName": "Clients",
|
||||
"component": "AccountId",
|
||||
"remove_command": "Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.AccountRemoveCommand"
|
||||
},
|
||||
{
|
||||
"id": "Suppliers",
|
||||
"name": "Suppliers",
|
||||
"displayName": "Fournisseurs",
|
||||
"component": "SupplierId",
|
||||
"remove_command": "Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.SupplierRemoveCommand"
|
||||
},
|
||||
{
|
||||
"id": "Kits",
|
||||
"name": "Kits",
|
||||
"displayName": "Kits",
|
||||
"where1": "(z => z.IsEnable == true)",
|
||||
"where1_name": "Kits actifs",
|
||||
"btn1" : "Désactiver",
|
||||
"component": "KitId",
|
||||
"remove_command": "Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.KitDeleteCommand",
|
||||
"btn1_command": "Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.KitDisableCommand"
|
||||
},
|
||||
{
|
||||
"id": "Aliases",
|
||||
"name": "Aliases",
|
||||
"displayName": "Alias",
|
||||
"component": "AliasId",
|
||||
"remove_command": "Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.AliasRemoveCommand"
|
||||
},
|
||||
{
|
||||
"id": "InboundOrders",
|
||||
"name": "InboundOrders",
|
||||
"displayName": "Ordres d'entrée",
|
||||
"component": "OutboundOrderId",
|
||||
"remove_command": "Mecalux.ITSW.EasyWMS.Modules.Receptions.Contracts.Commands.InboundOrderRemoveCommand",
|
||||
|
||||
"where1": "(z => z.InboundStatus == Mecalux.ITSW.EasyWMS.Modules.Common.Domain.InboundStatus.ReceptionPending)",
|
||||
"where1_name" : "Réception en attente",
|
||||
"btn1" : "Annuler",
|
||||
"btn1_command" :"Mecalux.ITSW.EasyWMS.Modules.Receptions.Contracts.Commands.InboundOrderCancelCommandV2",
|
||||
|
||||
"where2": "(z => z.InboundStatus == Mecalux.ITSW.EasyWMS.Modules.Common.Domain.InboundStatus.Receiving)",
|
||||
"where2_name" : "Réception en cours",
|
||||
|
||||
"where3": "(z => z.InboundStatus == Mecalux.ITSW.EasyWMS.Modules.Common.Domain.InboundStatus.Completed)",
|
||||
"where3_name" : "Complété",
|
||||
"btn3": "Fermer",
|
||||
"btn3_command" : "Mecalux.ITSW.EasyWMS.Modules.Receptions.Contracts.Commands.InboundOrderClosingCommandV2",
|
||||
|
||||
"where4": "(z => z.InboundStatus == Mecalux.ITSW.EasyWMS.Modules.Common.Domain.InboundStatus.PartiallyReceived)",
|
||||
"where4_name" : "Reçu partiellement",
|
||||
|
||||
"where5": "(z => z.InboundStatus == Mecalux.ITSW.EasyWMS.Modules.Common.Domain.InboundStatus.Cancelled)",
|
||||
"where5_name" : "Annulé",
|
||||
|
||||
"where6": "(z => z.InboundStatus == Mecalux.ITSW.EasyWMS.Modules.Common.Domain.InboundStatus.Closing)",
|
||||
"where6_name" : "Fermeture"
|
||||
|
||||
|
||||
},
|
||||
{
|
||||
"id": "Receptions",
|
||||
"name": "Receptions",
|
||||
"displayName": "Réceptions",
|
||||
"component": "ReceptionId",
|
||||
"remove_command": "Mecalux.ITSW.EasyWMS.Modules.Receptions.Contracts.Commands.ReceptionRemoveCommand"
|
||||
},
|
||||
{
|
||||
"id": "OutboundOrders",
|
||||
"name": "OutboundOrders",
|
||||
"displayName": "Ordres de sortie",
|
||||
"component": "OutboundOrderId",
|
||||
"remove_command": "Mecalux.ITSW.EasyWMS.Modules.Expeditions.Contracts.Commands.OutboundOrderCancelCommand",
|
||||
|
||||
"where1": "(z => z.OutboundOrderStatus == Mecalux.ITSW.EasyWMS.Modules.Common.Domain.OutboundOrderStatus.Creating)",
|
||||
"where1_name" : "Création",
|
||||
"btn1" : "Annuler",
|
||||
"btn1_command" :"Mecalux.ITSW.EasyWMS.Modules.Expeditions.Contracts.Commands.OutboundOrderCancelCommand",
|
||||
|
||||
"where2": "(z => z.OutboundOrderStatus == Mecalux.ITSW.EasyWMS.Modules.Common.Domain.OutboundOrderStatus.Waiting)",
|
||||
"where2_name" : "En attente",
|
||||
"btn2" : "Annuler",
|
||||
"btn2_command" :"Mecalux.ITSW.EasyWMS.Modules.Expeditions.Contracts.Commands.OutboundOrderCancelCommand",
|
||||
|
||||
"where3": "(z => z.OutboundOrderStatus == Mecalux.ITSW.EasyWMS.Modules.Common.Domain.OutboundOrderStatus.Release)",
|
||||
"where3_name" : "Lancé"
|
||||
}
|
||||
]
|
||||
}
|
||||
-1246
File diff suppressed because it is too large
Load Diff
+225
@@ -0,0 +1,225 @@
|
||||
# Supervision du serveur MCP
|
||||
|
||||
Comment savoir si le serveur tourne, ce qu'il fait, et pourquoi il ne répond
|
||||
pas. Ce document décrit **l'existant** — il n'y a ni endpoint de santé, ni
|
||||
métriques exportées : toute l'observabilité passe par **stderr** et par le
|
||||
smoke test `npm test`.
|
||||
|
||||
Pour lire les logs **du WMS** (et non ceux du serveur MCP), voir
|
||||
[docs/logs.md](docs/logs.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Où regarder
|
||||
|
||||
| Quoi | Où |
|
||||
|---|---|
|
||||
| Logs du serveur MCP | `%APPDATA%\Claude\logs\` (fichier `mcp-server-wms.log`) |
|
||||
| Logs applicatifs du WMS | partages `\\<host>\...` — voir [docs/logs.md](docs/logs.md) |
|
||||
| État de la connexion WMS | `npm test` (voir §5) |
|
||||
| Profil actif / caches | outils `get_current_wms_profile`, `get_application_summary` |
|
||||
|
||||
**Tout passe par stderr.** stdout est réservé au JSON du protocole MCP : la
|
||||
moindre écriture sur stdout casse la session Claude Desktop (voir D6 dans
|
||||
[DECISIONS.md](DECISIONS.md)). En pratique, cela veut dire que **les logs sont
|
||||
la seule sortie observable**, et qu'ils sont complets.
|
||||
|
||||
Suivre les logs en direct :
|
||||
|
||||
```bash
|
||||
Get-Content -Wait -Tail 50 "$env:APPDATA\Claude\logs\mcp-server-wms.log"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Lire les préfixes
|
||||
|
||||
Chaque ligne est préfixée par son composant. Le préfixe suffit à localiser le
|
||||
problème.
|
||||
|
||||
| Préfixe | Composant | Ce qu'il signale |
|
||||
|---|---|---|
|
||||
| `[Server]` | `src/index.js` | démarrage, routage des outils, erreurs non rattrapées |
|
||||
| `[Profile]` | `config/profile-manager.js` | chargement des profils, bascule de profil |
|
||||
| `[API]` | `services/api-service.js` | OAuth, chaque requête HTTP, retries 401 |
|
||||
| `[Workflow]` | `services/workflow-service.js` | cache workflows, pagination |
|
||||
| `[AD]` | `services/ad-service.js` | cache par type d'élément, pagination |
|
||||
| `[Logs]` | `services/log-service.js` | chemins de logs illisibles ou absents |
|
||||
| `[WMSQuery]`, `[Metadata]` | services | construction des requêtes |
|
||||
| `[*Tools]` | `src/tools/` | exécution d'un outil précis |
|
||||
|
||||
---
|
||||
|
||||
## 3. Démarrage : à quoi ressemble un boot sain
|
||||
|
||||
```
|
||||
[dotenv@17.2.4] injecting env (30) from .env
|
||||
[Profile] Loaded 3 profile(s). Active: LIMAGRAIN
|
||||
[Server] Starting WMS MCP Server...
|
||||
[Server] Architecture: 100% API-based (no direct database access)
|
||||
[Server] Profiles available: AD, EUROTRAFIC, LIMAGRAIN
|
||||
[Server] Active profile: LIMAGRAIN
|
||||
[Server] WMS MCP Server running on stdio
|
||||
[Server] Ready to accept requests from Claude Desktop
|
||||
```
|
||||
|
||||
Trois points à contrôler dans cet ordre :
|
||||
|
||||
1. **`injecting env (N)`** — si `N` vaut 0, le `.env` n'a pas été trouvé. En
|
||||
mode packagé il est attendu **à côté de l'exe** (D7).
|
||||
2. **`Loaded N profile(s)`** — si 0, `WMS_PROFILES` est vide ou les variables
|
||||
`<NOM>_HOST/USERNAME/PASSWORD/TENANT` manquent.
|
||||
3. **`Active profile: …`** — si le message est `No active profile`, ce n'est
|
||||
**pas** une panne : Claude doit appeler `switch_wms_profile` avant la
|
||||
première requête, et l'erreur renvoyée le lui indique explicitement (D8).
|
||||
|
||||
Aucune connexion au WMS n'est tentée au démarrage : un boot propre ne prouve
|
||||
donc **pas** que le WMS est joignable. Pour cela, voir §5.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cycle de vie du token OAuth
|
||||
|
||||
Le token est obtenu **paresseusement**, à la première requête, puis rafraîchi
|
||||
automatiquement. Réglages dans `.env` :
|
||||
|
||||
| Variable | Défaut | Rôle |
|
||||
|---|---|---|
|
||||
| `TOKEN_REFRESH_THRESHOLD` | `1000` s | âge au-delà duquel un refresh est déclenché avant la requête |
|
||||
| `TOKEN_MAX_AGE` | `1190` s | âge au-delà duquel on ne tente plus le `refresh_token` mais une ré-authentification complète |
|
||||
| `QUERY_TIMEOUT` | `30000` ms | timeout HTTP de toute requête WMS |
|
||||
|
||||
Séquence observable :
|
||||
|
||||
```
|
||||
[API] Authenticating profile="LIMAGRAIN" tenant="LIMAGRAI2512" ...
|
||||
[API] Authentication successful. Token expires in ~1190s
|
||||
[API] POST /QueryExecute
|
||||
... (~17 min plus tard)
|
||||
[API] Refreshing token with refresh_token grant...
|
||||
[API] Token refreshed successfully
|
||||
```
|
||||
|
||||
Trois filets de sécurité, dans cet ordre :
|
||||
|
||||
1. **Avant la requête** — si `âge > TOKEN_REFRESH_THRESHOLD`, refresh préventif.
|
||||
2. **Refresh en échec** — bascule automatique sur le grant `password`
|
||||
(`[API] Token refresh failed, re-authenticating`).
|
||||
3. **Réponse 401** — un refresh est déclenché et la requête est **rejouée une
|
||||
fois** (`[API] Unauthorized, refreshing token and retrying...`).
|
||||
|
||||
**Ce qui est normal.** Une ligne `Token refresh failed` isolée suivie d'une
|
||||
authentification réussie : le filet a joué son rôle.
|
||||
|
||||
**Ce qui ne l'est pas.** Ces trois lignes en boucle rapprochée signalent des
|
||||
credentials invalides ou un tenant erroné — le serveur n'abandonne jamais de
|
||||
lui-même, il retentera à chaque requête.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test de bout en bout
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Teste le profil actif ; `npm test -- AD` cible un profil, `npm test -- --all`
|
||||
les teste tous. Quatre vérifications en lecture seule, aucune écriture WMS :
|
||||
|
||||
| Test | Ce qu'il prouve |
|
||||
|---|---|
|
||||
| OAuth | host joignable, credentials et tenant corrects |
|
||||
| `QueryExecute` | API ApplicationService opérationnelle |
|
||||
| `QueryScalarExecute` | requêtes scalaires (`Count`) opérationnelles |
|
||||
| AD API (`Validator`) | API Application Dictionary opérationnelle |
|
||||
|
||||
Sortie attendue :
|
||||
|
||||
```
|
||||
=== Profil LIMAGRAIN ===
|
||||
host=10.255.255.2 tenant=LIMAGRAI2512 saas=false
|
||||
OK OAuth - token obtenu (age max ~1190s)
|
||||
OK QueryExecute - 1 ligne(s)
|
||||
OK QueryScalarExecute - 51160 produit(s)
|
||||
OK AD API (Validator) - 10 element(s)
|
||||
-> 4/4 tests reussis
|
||||
```
|
||||
|
||||
Code de sortie `0` si tout passe, `1` sinon — utilisable tel quel dans une
|
||||
tâche planifiée.
|
||||
|
||||
Sonde plus légère, si l'on veut seulement savoir si le WMS répond (sans
|
||||
authentification applicative) : `GET https://<host>/ApplicationService/api/healthcheck?tenantCode=<TENANT>`
|
||||
et `.../api/ready?tenantCode=<TENANT>` renvoient 200. Elles ne disent rien de la
|
||||
validité des credentials — pour ça, `npm test`.
|
||||
|
||||
C'est le premier réflexe quand Claude signale une erreur WMS : il isole en
|
||||
quelques secondes une panne de connectivité d'un problème de requête.
|
||||
|
||||
---
|
||||
|
||||
## 6. État des caches
|
||||
|
||||
Deux caches, TTL commun `WORKFLOW_CACHE_TTL` (1 h par défaut), tous deux vidés
|
||||
à chaque `switch_wms_profile` (D8).
|
||||
|
||||
| Cache | Contenu | Purge |
|
||||
|---|---|---|
|
||||
| workflows | ~3 700 workflows | TTL, ou bascule de profil |
|
||||
| AD | un cache **par type** (20 types, ~38 800 éléments) | TTL par type, ou bascule de profil |
|
||||
|
||||
**Les inspecter sans redémarrer** : l'outil `get_application_summary` liste les
|
||||
types chargés et leur nombre d'éléments — un type absent signifie simplement
|
||||
qu'il n'a jamais été demandé dans cette session (D10).
|
||||
|
||||
Signature d'un chargement dans les logs :
|
||||
|
||||
```
|
||||
[AD] Cache expired or empty, fetching Resource...
|
||||
[AD] Fetching Resource: offset=0, pageSize=15000
|
||||
[AD] Fetched 15000 Resource (total: 15000)
|
||||
[AD] Fetching Resource: offset=15000, pageSize=15000
|
||||
...
|
||||
[AD] Successfully cached 29374 Resource
|
||||
[AD] Cache hit: Resource (29374 elements) <- appels suivants
|
||||
```
|
||||
|
||||
Une première requête sur `Resource` prend plusieurs dizaines de secondes : ce
|
||||
n'est pas un blocage, c'est la pagination. Les suivantes sont instantanées.
|
||||
|
||||
---
|
||||
|
||||
## 7. Symptômes → causes
|
||||
|
||||
| Symptôme | Cause probable | Vérification |
|
||||
|---|---|---|
|
||||
| Le serveur n'apparaît pas dans Claude Desktop | chemin invalide dans `claude_desktop_config.json`, ou Claude pas redémarré | ouvrir `%APPDATA%\Claude\logs\` |
|
||||
| `Unexpected token … is not valid JSON` | quelque chose a écrit sur **stdout** | chercher un `console.log()` ajouté (D6) |
|
||||
| `injecting env (0)` | `.env` introuvable | en packagé : le placer à côté de l'exe (D7) |
|
||||
| `No WMS profile selected` | `DEFAULT_WMS_PROFILE` absent ou invalide | c'est un état normal — appeler `switch_wms_profile` |
|
||||
| `Authentication failed: … 400` | `tenant_code` ou credentials erronés | `npm test -- <PROFIL>` (D2) |
|
||||
| Boucle `refresh failed` / `Authenticating` | credentials invalides | `npm test` |
|
||||
| `ETIMEDOUT` / `ECONNREFUSED` | host injoignable (VPN, pare-feu) | `Test-NetConnection <host> -Port 443` |
|
||||
| `timeout of 30000ms exceeded` | requête trop lourde | ajouter un `Where`, réduire `take`, ou augmenter `QUERY_TIMEOUT` |
|
||||
| `Successfully cached 0 workflows` | réponse non enveloppée par `entities` | D4 |
|
||||
| Recherche vide sur un élément existant | casse des propriétés (`name` vs `Name`) | D5 |
|
||||
| `Log access is disabled for SaaS profile` | profil `SAAS=true` | comportement voulu (D9), utiliser les outils API |
|
||||
| Erreur de compilation LINQ sur une date | `DateTime.Now` employé | date littérale (D12) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Ce qui n'est pas instrumenté
|
||||
|
||||
À connaître avant de promettre une supervision qui n'existe pas :
|
||||
|
||||
- **Pas de healthcheck** exposé, ni HTTP ni MCP. `npm test` est le seul contrôle
|
||||
automatisable, et il faut le lancer soi-même.
|
||||
- **Pas de métriques** : ni compteur d'appels, ni latence, ni taux d'erreur.
|
||||
- **Pas de fichier de log propre au serveur** : tout est capté par Claude
|
||||
Desktop, avec sa rotation à lui.
|
||||
- **Pas d'alerte** : une panne d'authentification n'est visible qu'au prochain
|
||||
appel d'un outil.
|
||||
- **`uncaughtException` et `unhandledRejection` sont journalisés mais
|
||||
n'arrêtent pas le processus** (`src/index.js`). Le serveur peut donc survivre
|
||||
dans un état dégradé — d'où l'intérêt de relire les logs jusqu'au début en cas
|
||||
de comportement erratique, et non seulement la dernière erreur.
|
||||
@@ -1,227 +0,0 @@
|
||||
## Request Information
|
||||
|
||||
### URI Parameters
|
||||
|
||||
None.
|
||||
|
||||
### Body Parameters
|
||||
|
||||
[QueryExecute](https://10.255.255.2/ApplicationService/Help/ResourceModel?modelName=QueryExecute)
|
||||
|
||||
| Name | Description | Type | Additional information |
|
||||
| --- | --- | --- | --- |
|
||||
| Application |
|
||||
Application name.
|
||||
|
||||
| String |
|
||||
|
||||
None.
|
||||
|
||||
|
|
||||
| ClientModule |
|
||||
|
||||
Client module that executes the query.
|
||||
|
||||
| String |
|
||||
|
||||
None.
|
||||
|
||||
|
|
||||
| QueryType |
|
||||
|
||||
Type of the query.
|
||||
|
||||
| [QueryContextType](https://10.255.255.2/ApplicationService/Help/ResourceModel?modelName=QueryContextType) |
|
||||
|
||||
Default: Reading
|
||||
|
||||
|
|
||||
| Expression |
|
||||
|
||||
Query expression.
|
||||
|
||||
| String |
|
||||
|
||||
None.
|
||||
|
||||
|
|
||||
| OrderBy |
|
||||
|
||||
Order by to be applied to the expression.
|
||||
|
||||
| String |
|
||||
|
||||
None.
|
||||
|
||||
|
|
||||
| Select |
|
||||
|
||||
Select to be applied to the expression.
|
||||
|
||||
| String |
|
||||
|
||||
None.
|
||||
|
||||
|
|
||||
| Skip |
|
||||
|
||||
Skip to be applied to the expression.
|
||||
|
||||
| Int32 |
|
||||
|
||||
None.
|
||||
|
||||
|
|
||||
| Take |
|
||||
|
||||
Take to be applied to the expression.
|
||||
|
||||
| Int32 |
|
||||
|
||||
None.
|
||||
|
||||
|
|
||||
| Parameters |
|
||||
|
||||
Dictionary with the parameters and their values for the query.
|
||||
|
||||
| Dictionary of String \[key\] and [QueryParameterValue](https://10.255.255.2/ApplicationService/Help/ResourceModel?modelName=QueryParameterValue) \[value\] |
|
||||
|
||||
None.
|
||||
|
||||
|
|
||||
| InlineCount |
|
||||
|
||||
Indicate if an inline count must be done to the query.
|
||||
|
||||
| Boolean |
|
||||
|
||||
None.
|
||||
|
||||
|
|
||||
| CommandTimeout |
|
||||
|
||||
Gets or sets the command/query timeout.
|
||||
|
||||
| Int32? |
|
||||
|
||||
None.
|
||||
|
||||
|
|
||||
| QueryId |
|
||||
|
||||
Gets or sets the query execution identifier.
|
||||
|
||||
| Guid? |
|
||||
|
||||
None.
|
||||
|
||||
|
|
||||
|
||||
### Request Formats
|
||||
|
||||
**Sample:**
|
||||
|
||||
```
|
||||
{
|
||||
"Application": "sample string 1",
|
||||
"ClientModule": "sample string 2",
|
||||
"QueryType": 0,
|
||||
"Expression": "sample string 3",
|
||||
"OrderBy": "sample string 4",
|
||||
"Select": "sample string 5",
|
||||
"Skip": 6,
|
||||
"Take": 7,
|
||||
"Parameters": {
|
||||
"sample string 1": {
|
||||
"TypeName": "sample string 1",
|
||||
"Value": {}
|
||||
},
|
||||
"sample string 2": {
|
||||
"TypeName": "sample string 1",
|
||||
"Value": {}
|
||||
}
|
||||
},
|
||||
"InlineCount": true,
|
||||
"CommandTimeout": 1,
|
||||
"QueryId": "e57b9e2d-1ba9-42c7-a035-fe964ce53fd6"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**Sample:**
|
||||
|
||||
Sample not available.
|
||||
|
||||
**Sample:**
|
||||
|
||||
```
|
||||
Binary JSON content. See http://bsonspec.org for details.
|
||||
|
||||
```
|
||||
|
||||
## Response Information
|
||||
|
||||
### Resource Description
|
||||
|
||||
[QueryResultData](https://10.255.255.2/ApplicationService/Help/ResourceModel?modelName=QueryResultData)
|
||||
|
||||
| Name | Description | Type | Additional information |
|
||||
| --- | --- | --- | --- |
|
||||
| InLineCount |
|
||||
Gets or sets the in line count.
|
||||
|
||||
| Int32 |
|
||||
|
||||
None.
|
||||
|
||||
|
|
||||
| Table |
|
||||
|
||||
Gets or sets the table.
|
||||
|
||||
| [QueryTable](https://10.255.255.2/ApplicationService/Help/ResourceModel?modelName=QueryTable) |
|
||||
|
||||
None.
|
||||
|
||||
|
|
||||
|
||||
### Response Formats
|
||||
|
||||
**Sample:**
|
||||
|
||||
```
|
||||
{
|
||||
"InLineCount": 1,
|
||||
"Table": {
|
||||
"TableName": "sample string 1",
|
||||
"Columns": [
|
||||
{
|
||||
"ColumnName": "sample string 1",
|
||||
"TypeName": "sample string 2"
|
||||
},
|
||||
{
|
||||
"ColumnName": "sample string 1",
|
||||
"TypeName": "sample string 2"
|
||||
}
|
||||
],
|
||||
"Rows": [
|
||||
null,
|
||||
{
|
||||
"Values": {
|
||||
"sample string 1": {},
|
||||
"sample string 3": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**Sample:**
|
||||
|
||||
```
|
||||
Binary JSON content. See http://bsonspec.org for details.
|
||||
|
||||
```
|
||||
@@ -0,0 +1,153 @@
|
||||
# WMS MCP Server
|
||||
|
||||
Serveur [MCP](https://modelcontextprotocol.io) qui donne à Claude un accès en
|
||||
lecture à un WMS **EasyWMS** (Mecalux), pour le diagnostic et l'analyse.
|
||||
|
||||
Concrètement, dans Claude Desktop :
|
||||
|
||||
> « Combien de commandes sont bloquées en statut Release sur LIMAGRAIN ? »
|
||||
> « Trouve les workflows qui touchent au réapprovisionnement. »
|
||||
> « Cherche `Order 4711` dans les logs. »
|
||||
> « Quels paramètres sont surchargés sur l'entrepôt 2 ? »
|
||||
|
||||
**Architecture : 100 % API REST.** Aucun accès direct à Oracle — voir D1 dans
|
||||
[DECISIONS.md](DECISIONS.md).
|
||||
|
||||
---
|
||||
|
||||
## Ce que le serveur expose
|
||||
|
||||
**23 outils** répartis en 8 familles :
|
||||
|
||||
| Famille | Outils |
|
||||
|---|---|
|
||||
| Requêtes WMS | `query_wms_entities`, `count_wms_entities`, `get_entity_schema`, `search_wms_data` |
|
||||
| API brutes | `call_query_api`, `execute_command` |
|
||||
| Workflows | `search_workflows`, `get_workflow_details`, `list_workflow_categories` |
|
||||
| Application Dictionary | `get_application_summary`, `get_ad_elements`, `search_ad_elements`, `get_ad_element_details`, `list_ad_types` |
|
||||
| Métadonnées | `get_entity_metadata`, `generic_search` |
|
||||
| Configuration | `get_system_parameters` |
|
||||
| Profils | `list_wms_profiles`, `get_current_wms_profile`, `switch_wms_profile` |
|
||||
| Logs | `read_recent_logs`, `list_log_files`, `search_logs` |
|
||||
|
||||
**6 resources** de contexte : `wms://entities`, `wms://entity-schemas`,
|
||||
`wms://query-examples`, `workflows://overview`, `api://catalog`, `logs://guide`.
|
||||
|
||||
**Multi-WMS.** Plusieurs backends (clients, tenants) coexistent dans un seul
|
||||
serveur ; Claude bascule à la demande avec `switch_wms_profile`.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Prérequis : Node.js 18+ et un accès réseau au WMS (VPN si nécessaire).
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Copiez `.env.example` en `.env` et renseignez au moins un profil :
|
||||
|
||||
```env
|
||||
WMS_API_AUTH=Basic R05BOklFNGU3aXFoZHQ=
|
||||
WMS_APPLICATION=EasyWMS
|
||||
WMS_API_PATH=/ApplicationService/api
|
||||
WMS_TOKEN_PATH=/EasySTS/OAuth/Token
|
||||
WORKFLOW_API_PATH=/AD/api
|
||||
|
||||
WMS_PROFILES=AD
|
||||
DEFAULT_WMS_PROFILE=AD
|
||||
|
||||
AD_HOST=10.255.255.2
|
||||
AD_USERNAME=...
|
||||
AD_PASSWORD=...
|
||||
AD_TENANT=AD
|
||||
AD_SAAS=false
|
||||
```
|
||||
|
||||
Vérifiez la connectivité — le test est en lecture seule :
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Sortie attendue : `4/4 tests reussis`. En cas d'échec, voir
|
||||
[MONITORING.md](MONITORING.md) §7.
|
||||
|
||||
---
|
||||
|
||||
## Brancher Claude Desktop
|
||||
|
||||
Éditez `%APPDATA%\Claude\claude_desktop_config.json` :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"wms": {
|
||||
"command": "node",
|
||||
"args": ["D:\\chemin\\vers\\mcp-wms-api\\src\\index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Puis **fermez et rouvrez complètement** Claude Desktop. Les logs du serveur
|
||||
apparaissent dans `%APPDATA%\Claude\logs\`.
|
||||
|
||||
---
|
||||
|
||||
## Ajouter un WMS
|
||||
|
||||
1. Ajoutez son nom à `WMS_PROFILES` (séparateur : virgule).
|
||||
2. Définissez `<NOM>_HOST`, `<NOM>_USERNAME`, `<NOM>_PASSWORD`, `<NOM>_TENANT`.
|
||||
3. Mettez `<NOM>_SAAS=true` si le WMS est hébergé dans le cloud Mecalux — les
|
||||
outils de log seront alors désactivés pour ce profil, à dessein.
|
||||
|
||||
Les URL se construisent à partir du host : rien d'autre à dupliquer. Testez
|
||||
avec `npm test -- <NOM>`.
|
||||
|
||||
---
|
||||
|
||||
## Compiler un exécutable Windows
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Produit `dist/wms-mcp-server.exe` (~76 Mo, autonome, cible `node22-win-x64`).
|
||||
|
||||
**Placez le `.env` à côté de l'exe** : en mode packagé, c'est là qu'il est lu,
|
||||
et aucun credential n'est embarqué dans le binaire (D7). Les avertissements
|
||||
`Cannot find module '@modelcontextprotocol/sdk/…'` pendant le build sont
|
||||
normaux et sans effet (D18).
|
||||
|
||||
Déploiement type sur la VM :
|
||||
|
||||
```
|
||||
C:\WMS\mcp\wms-mcp-server.exe
|
||||
C:\WMS\mcp\.env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Fichier | Contenu |
|
||||
|---|---|
|
||||
| [CLAUDE.md](CLAUDE.md) | Architecture, inventaire des outils, conventions de code |
|
||||
| [DECISIONS.md](DECISIONS.md) | **Pourquoi** le code est ainsi + pièges vérifiés en production |
|
||||
| [MONITORING.md](MONITORING.md) | Superviser le serveur MCP : logs, token, caches, symptômes |
|
||||
| [ROADMAP.md](ROADMAP.md) | Travaux planifiés par lot, et ce qui a été écarté |
|
||||
| [docs/logs.md](docs/logs.md) | Accès aux logs du WMS |
|
||||
| [docs/](docs/) | Références EasyWMS (API, entités) |
|
||||
|
||||
---
|
||||
|
||||
## Sécurité
|
||||
|
||||
- `.env` et `dist/` sont ignorés par git — **ne les committez jamais**.
|
||||
- La validation TLS est désactivée pour accepter les certificats auto-signés
|
||||
des WMS on-premise (D15).
|
||||
- ⚠️ L'historique git contient un ancien fichier de configuration avec des mots
|
||||
de passe en clair (commit `b59cbb3`). Ces credentials sont à considérer comme
|
||||
compromis — voir D20.
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
# Roadmap
|
||||
|
||||
Travaux planifiés, par lot. Chaque lot est livrable indépendamment.
|
||||
|
||||
Constats issus de la session de diagnostic du **24/08/2026** (profil `LIMAGRAIN`,
|
||||
host `10.255.255.2`, tenant `LIMAGRAI2512`), déclenchée par un rapport d'usage
|
||||
d'une session Cowork. Toutes les anomalies ci-dessous ont été **reproduites**
|
||||
contre le WMS réel — ce ne sont pas des hypothèses.
|
||||
|
||||
Les décisions actées vivent dans [DECISIONS.md](DECISIONS.md) ; ce fichier ne
|
||||
contient que ce qui reste à faire.
|
||||
|
||||
---
|
||||
|
||||
## Lot 4 — Modèle de données et applications
|
||||
|
||||
Deux angles morts constatés le 24/08/2026, plus larges que les lots 2 et 3. Les
|
||||
chiffres ci-dessous sont mesurés sur le tenant `LIMAGRAI2512`.
|
||||
|
||||
### L4.1 (reliquat) — Explorer le contexte Metrics
|
||||
|
||||
L'exposition de `query_type` est livrée (D25). Reste l'investigation : le
|
||||
contexte `Metrics` (`QueryType: 3`, `ApplicationMetricDataContext`) mérite une
|
||||
exploration à part — c'est probablement là que vivent les données agrégées
|
||||
produites par les jobs `MetricGatherer`. Livrable : un rapport, pas du code
|
||||
(même phase d'investigation que L4.4).
|
||||
|
||||
### L4.3 — Identifier le MCP dans les logs du WMS
|
||||
|
||||
Les requêtes du MCP apparaissent dans les logs du WMS sous
|
||||
`Execute error. Client: GNA` — le client OAuth partagé — donc indistinguables de
|
||||
celles du vrai client GNA.
|
||||
|
||||
**La piste `ClientModule` est invalidée** (mesuré le 24/08/2026, lot 1) : le
|
||||
champ est bien accepté par `QueryExecute` (pas d'erreur), mais il est **sans
|
||||
effet observable**. Une requête en échec envoyée avec
|
||||
`ClientModule: "MCP-WMS"` est tracée `Execute error. Client: GNA`, et ni
|
||||
`MCP-WMS` ni `ClientModule` n'apparaissent nulle part dans
|
||||
`ApplicationService.log` ni `HttpResponseTime.log`. Le `Client:` des logs vient
|
||||
du client OAuth, pas du payload — le champ n'a donc **pas** été renseigné.
|
||||
|
||||
Piste restante (non vérifiée) : un client OAuth dédié au MCP côté EasySTS
|
||||
changerait le `Client:` des logs, mais suppose une configuration côté WMS.
|
||||
|
||||
### L4.4 — Historique d'exécution des workflows par API
|
||||
|
||||
`ApplicationService` expose une API `WorkflowLog` que le MCP n'utilise pas :
|
||||
|
||||
| Endpoint | Usage |
|
||||
|---|---|
|
||||
| `GET /WorkflowLog/GetInstances?processDefinitionId=&skip=&take=&startDateFrom=&startDateTo=` | instances d'un workflow sur une plage de dates, filtrables par attribut |
|
||||
| `GET /WorkflowLog/GetInstance?processId=` | une instance |
|
||||
| `GET /WorkflowLog/GetLogs?processId=&skip=&take=&logDateFrom=&logDateTo=` | journal d'exécution d'une instance |
|
||||
| `GET /WorkflowLog/Validate?applicationName=&processDefinitionId=` | validation d'une définition |
|
||||
|
||||
Endpoints joignables et fonctionnels — `Validate` renvoie
|
||||
`{"Success":true,"ErrorMessage":null,"Warnings":[]}`. `GetInstances` répond `[]`
|
||||
sur le workflow testé : à confirmer sur un workflow ayant réellement tourné, la
|
||||
journalisation n'étant pas forcément active partout.
|
||||
|
||||
**Peut remettre en cause D16** (historique des shipment templates jugé hors de
|
||||
portée faute de logs fichier) : si l'historique d'exécution est disponible par
|
||||
API, la conclusion change. À vérifier avant d'écrire quoi que ce soit.
|
||||
|
||||
### L4.5 — Champs de `QueryExecute` inexploités
|
||||
|
||||
La référence de l'API documente des champs que le MCP n'envoie jamais :
|
||||
|
||||
| Champ | Intérêt |
|
||||
|---|---|
|
||||
| `Parameters` | requêtes **paramétrées** (dictionnaire `nom -> {TypeName, Value}`) — supprimerait toute concaténation de chaîne dans les filtres, et pourrait débloquer D13 (`Select`) |
|
||||
| `CommandTimeout` | timeout par requête, au lieu du timeout HTTP global de 30 s |
|
||||
| `QueryId` + `POST /QueryCancel` | annulation d'une requête longue |
|
||||
| `POST /QueryExecuteStream` | résultats en flux — piste long terme pour les sorties volumineuses, au-delà du bornage signalé de D24 |
|
||||
|
||||
Autres endpoints jamais utilisés, à évaluer : `QueryEvents`, `QueryCommands`,
|
||||
`QueryCorrelationEvents`, `QuerySnapshots` (event sourcing — utile en debug),
|
||||
`GET /Metadata/Commands|Events|Aggregates` et leurs variantes `…All`,
|
||||
`GET /configuration/applications` (liste les applications **avec leur version**,
|
||||
plus riche que `/AD/api/Application/GetAll`), `GET /healthcheck?tenantCode=` et
|
||||
`GET /ready?tenantCode=` (sondes de disponibilité, répondent 200).
|
||||
|
||||
---
|
||||
|
||||
## Écarté
|
||||
|
||||
| Proposition | Raison |
|
||||
|---|---|
|
||||
| Uniformiser les noms de paramètres (`entity_type` / `query` partout) | Casse les usages existants ; les alias de transition doublent la surface à maintenir. La cause réelle est traitée par L2.2. |
|
||||
| Exposer un `indexStatus` sur `generic_search` | `TotalDocuments: 0` est déjà le signal. Le MCP n'a aucun moyen d'interroger l'état de l'index de recherche. |
|
||||
| Outil dédié `get_query_syntax_help` | L'information doit se trouver dans le message d'erreur, là où elle est lue (L2.1), pas dans un outil qu'il faut penser à appeler. |
|
||||
|
||||
---
|
||||
|
||||
## Points ouverts (hors lots)
|
||||
|
||||
- **Profil `AD` : tenant introuvable** (mesuré le 24/08/2026). `npm test -- --all`
|
||||
échoue 0/4 sur ce profil ; le STS de `10.255.255.2` répond
|
||||
`400 {"error":"invalid_request","error_description":"Tenant not found"}` pour
|
||||
le tenant `AD`. L'hôte et le STS fonctionnent (LIMAGRAIN, même hôte, passe
|
||||
4/4) : c'est la valeur `AD_TENANT` du `.env` qui ne correspond plus à un
|
||||
tenant existant. Correction côté propriétaire du dépôt (mettre à jour ou
|
||||
retirer le profil) — pas un bug du code. Depuis L3.2, le corps de la réponse
|
||||
du STS (`Tenant not found`) remonte dans les erreurs d'outils et du smoke
|
||||
test.
|
||||
- **Bascule de profil concurrente aux appels en vol** (mesuré le 25/08/2026,
|
||||
révision du lot 2). Le serveur traite les `tools/call` **en concurrence** :
|
||||
un `switch_wms_profile` émis pendant que des requêtes sont en vol les fait
|
||||
partir sur le nouveau profil (observé : une requête destinée à EUROTRAFIC
|
||||
exécutée sur l'hôte `10.255.255.2` après la bascule suivante). Conséquence du
|
||||
singleton d'état global (D8). À traiter si un cas réel de mélange de profils
|
||||
est observé (piste : sérialiser les `tools/call` ou figer le profil résolu au
|
||||
début de chaque appel). La manifestation « caches » de la même concurrence
|
||||
est **traitée** (D27 : single-flight par clé, garde de génération) ; celle-ci
|
||||
ne l'est pas — D27 borne les chargements paresseux, pas le routage d'une
|
||||
requête déjà partie.
|
||||
- **L'API AD échoue sous appels concurrents nombreux** (mesuré le 25/08/2026,
|
||||
lot 6). Avant D27, une rafale de 14 `tools/call` faisait répondre **HTTP 500**
|
||||
à `POST /AD/api/Workflow/GetByApplication` pour `EasyWMS` (~4 000 workflows) —
|
||||
les 4 appels concernés en erreur, les mêmes corrects en séquentiel. C'est une
|
||||
limite du serveur AD, pas du MCP. D27 l'atténue fortement (un seul fetch par
|
||||
clé au lieu de N, et le 500 n'a pas reparu depuis), sans la supprimer : des
|
||||
clés **différentes** se chargent toujours en parallèle. À reconsidérer si le
|
||||
500 réapparaît — piste : plafonner le nombre de chargements simultanés, tous
|
||||
clés confondues. N'implémentez rien avant d'avoir une mesure : brider les
|
||||
chargements parallèles coûte de la latence sur le chemin nominal.
|
||||
- **`select_expression`** : les projections via le paramètre `Select` provoquent
|
||||
des erreurs de compilation côté serveur (D13). Irritant principal restant.
|
||||
- **Déploiement SSH sur la VM** : l'exécutable est validé, la configuration SSH
|
||||
reste à faire.
|
||||
- **Historique des shipment templates** : hors de portée, les logs concernés
|
||||
n'existent pas sur l'hôte joignable (D16).
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"wms": {
|
||||
"command": "ssh",
|
||||
"args": [
|
||||
"wms-vm",
|
||||
"C:\\Users\\mecalux\\Desktop\\wms-mcp-server\\dist\\wms-mcp-server.exe"
|
||||
],
|
||||
"env": {
|
||||
"ORACLE_USER": "db_read",
|
||||
"ORACLE_PASSWORD": "PF9.43uAq17$U",
|
||||
"ORACLE_CONNECTION_STRING": "localhost:1521/orcl",
|
||||
"ORACLE_USER_WORKFLOWS": "db_ad",
|
||||
"ORACLE_PASSWORD_WORKFLOWS": "PF9.43uAq17$U",
|
||||
"ORACLE_CONNECTION_STRING_WORKFLOWS": "localhost:1521/orcl",
|
||||
"WORKFLOWS_TABLE": "AD_WFPROCESSES",
|
||||
"DOCS_PATH": "C:\\Users\\mecalux\\Desktop\\wms-mcp-server\\docs",
|
||||
"LOGS_PATH": "C:\\inetpub\\logs\\LogFiles\\Mecalux;C:\\ProgramData\\Mecalux\\ETLLogs",
|
||||
"LOG_FILE_PATTERN": "*.log",
|
||||
"API_DEFINITIONS_TABLE": "API_DEFINITIONS",
|
||||
"WMS_API_BASE_URL": "https://localhost/ApplicationService/api",
|
||||
"WMS_API_TOKEN_URL": "https://localhost/EasySTS/OAuth/Token",
|
||||
"WMS_API_AUTH": "Basic R05BOklFNGU3aXFoZHQ=",
|
||||
"WMS_API_TENANT": "AD",
|
||||
"WMS_API_USERNAME": "mecalux",
|
||||
"WMS_API_PASSWORD": "ANDREZmlx6",
|
||||
"TOKEN_REFRESH_THRESHOLD": "1000",
|
||||
"TOKEN_MAX_AGE": "1190",
|
||||
"MAX_QUERY_ROWS": "1000",
|
||||
"QUERY_TIMEOUT": "30000",
|
||||
"NODE_TLS_REJECT_UNAUTHORIZED": "0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"preferences": {
|
||||
"coworkScheduledTasksEnabled": false,
|
||||
"sidebarMode": "chat"
|
||||
}
|
||||
}
|
||||
+38
-32
@@ -1,41 +1,47 @@
|
||||
# Documentation WMS
|
||||
# Documentation de référence
|
||||
|
||||
Bienvenue dans la documentation du WMS !
|
||||
Documents de référence sur EasyWMS et ses API, conservés dans le dépôt pour
|
||||
être consultables hors ligne et par un agent qui lit le code.
|
||||
|
||||
## Comment utiliser cette documentation
|
||||
> ⚠️ **Ce dossier n'est pas exposé comme resource MCP.** Une resource `docs://`
|
||||
> a existé sans jamais être branchée ; elle a été supprimée. Ces fichiers se
|
||||
> lisent directement depuis le dépôt. Voir D19 dans
|
||||
> [DECISIONS.md](../DECISIONS.md).
|
||||
|
||||
Cette documentation est automatiquement accessible via le serveur MCP. Claude peut lire tous les fichiers `.md` présents dans ce dossier et ses sous-dossiers.
|
||||
## Contenu
|
||||
|
||||
## Organisation
|
||||
| Fichier | Nature |
|
||||
|---|---|
|
||||
| [supervision.md](supervision.md) | **Rôle de supervision** — vérifier le MCP, réviser les livraisons des sessions de codage, rédiger les passations |
|
||||
| [handoff-lot1.md](handoff-lot1.md) | **Passation** — prompt autoportant pour le lot 1 de la [roadmap](../ROADMAP.md). À supprimer une fois le lot livré |
|
||||
| [logs.md](logs.md) | **Rédigé pour ce projet** — accès aux logs WMS, outils, limites |
|
||||
| [ad-api-validation.md](ad-api-validation.md) | **Rédigé pour ce projet** — campagne de validation curl des 19 types AD testés (17 valides) |
|
||||
| [api/Application Service API Reference.md](api/Application%20Service%20API%20Reference.md) | Référence de l'API ApplicationService |
|
||||
| [api/POST apiQueryExecute.md](api/POST%20apiQueryExecute.md) | Détail de l'endpoint `QueryExecute` |
|
||||
| [entities/easywms_reading_entites.md](entities/easywms_reading_entites.md) | Catalogue des entités du modèle **Reading** |
|
||||
| [entities/easywms_reading_entites_outboundorder.md](entities/easywms_reading_entites_outboundorder.md) | Détail de l'entité `OutboundOrder` |
|
||||
| [entities/easywms_reading_entites_outboundorder_OutboundOrderStatus.md](entities/easywms_reading_entites_outboundorder_OutboundOrderStatus.md) | Valeurs de `OutboundOrderStatus` |
|
||||
| `getting_started.md`, `docs_downloads/` | Extractions du portail documentaire Mecalux |
|
||||
| `reference-queries-api.php` | Client PHP d'origine, source des patterns d'API. ⚠️ utilise `QueryType: 1` — ne pas recopier, voir D3 |
|
||||
|
||||
Organisez vos fichiers de documentation comme vous le souhaitez :
|
||||
**Sur les extractions du portail** : ce sont des captures partielles. Leurs
|
||||
liens internes pointent vers des pages non téléchargées (`ReleaseNotes.md`,
|
||||
`video_tutorials/`, …) et ne fonctionnent pas. Le seul document réellement
|
||||
exploitable de cet ensemble est
|
||||
`docs_downloads/communications/EasyWMS_WebApi_en.html.md` (référence complète de
|
||||
la Web API, ~320 Ko).
|
||||
|
||||
```
|
||||
docs/
|
||||
├── README.md (ce fichier)
|
||||
├── getting-started.md (guide de démarrage)
|
||||
├── api/
|
||||
│ ├── overview.md
|
||||
│ └── endpoints.md
|
||||
├── workflows/
|
||||
│ ├── reception.md
|
||||
│ └── expedition.md
|
||||
└── troubleshooting/
|
||||
└── common-errors.md
|
||||
```
|
||||
## Où trouver le reste
|
||||
|
||||
## Comment ajouter de la documentation
|
||||
| Question | Document |
|
||||
|---|---|
|
||||
| À quoi sert ce projet, comment l'installer | [../README.md](../README.md) |
|
||||
| Architecture, outils, resources, conventions de code | [../CLAUDE.md](../CLAUDE.md) |
|
||||
| Pourquoi le code est écrit ainsi, pièges vérifiés | [../DECISIONS.md](../DECISIONS.md) |
|
||||
| Le serveur ne répond pas / comment le superviser | [../MONITORING.md](../MONITORING.md) |
|
||||
|
||||
1. Créez vos fichiers `.md` dans ce dossier ou dans des sous-dossiers
|
||||
2. Redémarrez Claude Desktop
|
||||
3. Claude pourra automatiquement lire tous vos fichiers de documentation
|
||||
## Ajouter un document
|
||||
|
||||
## Accéder à la documentation depuis Claude
|
||||
|
||||
Dans Claude Desktop, vous pouvez demander :
|
||||
|
||||
- "Montre-moi le sommaire de la documentation"
|
||||
- "Lis la documentation sur les workflows"
|
||||
- "Affiche-moi la documentation de l'API"
|
||||
|
||||
Claude aura accès à tous les fichiers `.md` présents ici !
|
||||
Déposez le `.md` dans le sous-dossier qui convient et **ajoutez sa ligne au
|
||||
tableau ci-dessus**. Un document non listé ici est un document que personne ne
|
||||
retrouvera.
|
||||
|
||||
@@ -119,8 +119,8 @@ $tokenResponse = Invoke-RestMethod -Uri "https://localhost/EasySTS/OAuth/Token"
|
||||
-Body @{
|
||||
grant_type = "password"
|
||||
tenant_code = "AD"
|
||||
username = "mecalux"
|
||||
password = "ANDREZmlx6"
|
||||
username = $env:WMS_USERNAME
|
||||
password = $env:WMS_PASSWORD
|
||||
}
|
||||
|
||||
$token = $tokenResponse.access_token
|
||||
@@ -137,13 +137,21 @@ $response = Invoke-RestMethod -Uri "https://localhost/AD/api/Command/GetByApplic
|
||||
$response.entities | Select-Object -First 5
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
## Suivi
|
||||
|
||||
1. ✅ Remove WorkflowAction and WritingModel from AD_ELEMENT_TYPES
|
||||
2. ✅ Update tool descriptions to reflect 20 types (not 22)
|
||||
3. ⏭️ Test MCP server with Claude Desktop
|
||||
4. ⏭️ Validate all 5 AD tools work correctly
|
||||
5. ⏭️ Update CLAUDE.md with final implementation details
|
||||
Toutes les actions issues de cette campagne sont closes :
|
||||
|
||||
- `WorkflowAction` et `WritingModel` retirés de `AD_ELEMENT_TYPES` (D17).
|
||||
- Descriptions des outils alignées sur **20** types.
|
||||
- Les 5 outils AD sont opérationnels et validés avec Claude Desktop.
|
||||
- Architecture et pièges consignés dans [../CLAUDE.md](../CLAUDE.md) et
|
||||
[../DECISIONS.md](../DECISIONS.md).
|
||||
|
||||
Pour rejouer la campagne complète :
|
||||
|
||||
```bash
|
||||
powershell -ExecutionPolicy Bypass -File ../scripts/test-ad-api.ps1 -WmsHost <host> -Username <user> -Password '***' -Tenant AD
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
> ⚠️ **Capture partielle du portail documentaire Mecalux.** Ce sommaire est
|
||||
> conservé pour mémoire : la quasi-totalité de ses liens pointe vers des pages
|
||||
> qui n'ont pas été téléchargées et ne résolvent pas. Seul
|
||||
> [docs_downloads/communications/EasyWMS_WebApi_en.html.md](docs_downloads/communications/EasyWMS_WebApi_en.html.md)
|
||||
> est exploitable hors ligne. Voir [README.md](README.md).
|
||||
|
||||
# MAP - Documentation Portal
|
||||
|
||||
## Table des matières
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
# Logs du WMS
|
||||
|
||||
Comment le serveur MCP accède aux fichiers de logs du WMS, ce qu'il sait faire
|
||||
et ce qu'il ne peut pas faire.
|
||||
|
||||
Pour superviser **le serveur MCP lui-même**, voir
|
||||
[MONITORING.md](../MONITORING.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Où sont les logs
|
||||
|
||||
Les logs sont lus **directement sur le système de fichiers** du serveur WMS,
|
||||
via des partages réseau Windows — jamais par API.
|
||||
|
||||
| Emplacement | Contenu |
|
||||
|---|---|
|
||||
| `\\<host>\inetpub\logs\LogFiles\Mecalux` | logs applicatifs IIS, organisés en sous-dossiers par composant |
|
||||
| `\\<host>\ProgramData\Mecalux\ETLLogs` | logs du middleware ETL |
|
||||
|
||||
Configuration dans `.env`, plusieurs chemins séparés par des `;` :
|
||||
|
||||
```env
|
||||
LOGS_PATH=\\{host}\inetpub\logs\LogFiles\Mecalux;\\{host}\ProgramData\Mecalux\ETLLogs
|
||||
```
|
||||
|
||||
**Le placeholder `{host}` est remplacé à chaque appel** par le host du profil
|
||||
actif. Il n'y a donc pas de chemin de logs à redéfinir par profil : un seul
|
||||
gabarit suffit, et il suit automatiquement `switch_wms_profile`.
|
||||
|
||||
Le scan est **récursif** et ne retient que les fichiers dont le nom se termine
|
||||
par `.log`. Un dossier absent ou inaccessible n'interrompt pas le scan : il est
|
||||
journalisé (`[Logs] Cannot scan directory …`) et ignoré.
|
||||
|
||||
---
|
||||
|
||||
## 2. Prérequis d'accès
|
||||
|
||||
Les partages `\\<host>\...` doivent être joignables **depuis la machine qui
|
||||
exécute le serveur MCP**, avec les droits de lecture du compte Windows courant.
|
||||
Le serveur ne présente aucun credential propre pour SMB — il hérite de la
|
||||
session Windows.
|
||||
|
||||
Vérification rapide, hors serveur MCP :
|
||||
|
||||
```bash
|
||||
Test-Path "\\10.255.255.2\inetpub\logs\LogFiles\Mecalux"
|
||||
```
|
||||
|
||||
Si cette commande renvoie `False`, aucun outil de log ne fonctionnera : le
|
||||
problème est réseau ou droits, pas applicatif.
|
||||
|
||||
---
|
||||
|
||||
## 3. Profils SaaS : les logs ne sont pas accessibles
|
||||
|
||||
Quand le profil actif est déclaré `<PROFIL>_SAAS=true`, les trois outils de log
|
||||
**échouent volontairement** avec :
|
||||
|
||||
> Log access is disabled for SaaS profile "X". The WMS is cloud-hosted — local
|
||||
> log files are not reachable. Use the WMS API (query/command/workflow tools)
|
||||
> instead.
|
||||
|
||||
C'est un choix explicite, pas une régression : le WMS est hébergé dans le cloud
|
||||
Mecalux, son système de fichiers n'est pas exposé. Retourner « 0 fichier »
|
||||
laisserait croire à une absence d'erreurs — un faux négatif dangereux en
|
||||
diagnostic. Voir D9 dans [DECISIONS.md](../DECISIONS.md).
|
||||
|
||||
**Sur un profil SaaS, le diagnostic passe donc uniquement par les API** :
|
||||
`query_wms_entities`, `count_wms_entities`, `search_wms_data`, les outils AD et
|
||||
`get_system_parameters`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Les trois outils
|
||||
|
||||
### `list_log_files`
|
||||
|
||||
Liste tous les `.log` trouvés sous `LOGS_PATH`, **triés du plus récent au plus
|
||||
ancien**, avec taille, date de modification et dossier d'origine. Point de
|
||||
départ naturel : il montre quels composants ont écrit récemment.
|
||||
|
||||
Si aucun fichier n'est trouvé, l'erreur **énumère les chemins scannés** — c'est
|
||||
généralement suffisant pour diagnostiquer un `{host}` mal résolu.
|
||||
|
||||
### `read_recent_logs(count, log_file)`
|
||||
|
||||
Renvoie les `count` dernières lignes (100 par défaut). Sans `log_file`, prend
|
||||
**le fichier le plus récemment modifié, tous dossiers confondus** — ce qui n'est
|
||||
pas forcément celui que l'on croit sur un serveur multi-composants ; préférez
|
||||
nommer le fichier.
|
||||
|
||||
La résolution de `log_file` se fait en trois passes, de la plus stricte à la
|
||||
plus permissive :
|
||||
|
||||
1. chemin direct sous le premier `LOGS_PATH`
|
||||
(ex. `ApplicationDictionary\ApplicationDictionary.log`) ;
|
||||
2. nom exact recherché dans chaque sous-dossier immédiat ;
|
||||
3. correspondance partielle, insensible à la casse.
|
||||
|
||||
En cas d'échec, l'erreur liste les fichiers disponibles.
|
||||
|
||||
### `search_logs(keyword, max_results, context_lines)`
|
||||
|
||||
Recherche insensible à la casse dans **tous** les fichiers, du plus récent au
|
||||
plus ancien, en s'arrêtant à `max_results` (50 par défaut). Chaque résultat
|
||||
porte son fichier, son numéro de ligne et `context_lines` lignes avant/après (2
|
||||
par défaut), la ligne trouvée étant marquée `isMatch`.
|
||||
|
||||
C'est l'outil à privilégier pour tracer un identifiant métier (numéro de
|
||||
commande, code produit, id de tâche) à travers les composants.
|
||||
|
||||
---
|
||||
|
||||
## 5. Format des lignes
|
||||
|
||||
`ApplicationService.log` suit ce format :
|
||||
|
||||
```
|
||||
YYYY-MM-DD HH:MM:SS.ffff [thread] [Level] [Component] [message]
|
||||
```
|
||||
|
||||
Exemple :
|
||||
|
||||
```
|
||||
2026-08-24 09:14:22.1873 [42] [ERROR] [OutboundOrderService] [Order 4711 not found]
|
||||
```
|
||||
|
||||
Conséquence pratique : une recherche par **date préfixe** (`2026-08-24 09:`)
|
||||
fonctionne bien, et le niveau se filtre par `[ERROR]` — crochets inclus, pour
|
||||
éviter les faux positifs sur le mot « error » dans un message.
|
||||
|
||||
Les logs ETL n'ont pas le même format ; ne présumez pas d'une structure commune
|
||||
entre les deux emplacements.
|
||||
|
||||
---
|
||||
|
||||
## 6. Limites à connaître
|
||||
|
||||
- **Lecture intégrale en mémoire.** `read_recent_logs` et `search_logs`
|
||||
chargent chaque fichier entier avant de le découper. Sur un log de plusieurs
|
||||
centaines de Mo, c'est lent et coûteux en RAM. Vérifiez les tailles avec
|
||||
`list_log_files` avant de lancer une recherche large.
|
||||
- **Recherche par sous-chaîne uniquement**, pas d'expression régulière.
|
||||
- **Pas de filtre temporel** : `search_logs` ne sait pas restreindre à une
|
||||
plage horaire. Le contournement est d'inclure le préfixe de date dans le
|
||||
mot-clé.
|
||||
- **Aucun filtre de nom de fichier configurable.** Le scan retient tous les
|
||||
`.log`. (Une variable `LOG_FILE_PATTERN` a existé dans `.env` sans jamais
|
||||
être appliquée ; elle a été supprimée — voir D20.)
|
||||
- **Pas de rotation ni de purge** : le serveur MCP lit, il n'écrit ni ne
|
||||
supprime rien.
|
||||
|
||||
---
|
||||
|
||||
## 7. Hors périmètre : l'historique des shipment templates
|
||||
|
||||
Les logs `ApplyShipmentTemplates` **ne sont pas présents** sur l'hôte joignable
|
||||
(`10.255.255.2`) : ils résident sur les serveurs de production / ETL des
|
||||
clients. Aucun outil ne les analyse, car il n'y aurait rien à lire.
|
||||
|
||||
Ce que l'on peut obtenir par API se limite à la **dernière** exécution, via
|
||||
l'entité Reading `ShipmentTemplate` (`LastExecuteDate`, `Status`,
|
||||
`IsEnabled`). Voir D16 dans [DECISIONS.md](../DECISIONS.md) et les recettes de
|
||||
la resource `wms://query-examples`.
|
||||
@@ -0,0 +1,253 @@
|
||||
# Supervision du projet mcp-wms-api
|
||||
|
||||
> **Comment s'en servir.** Ouvrir une session Claude Code dans
|
||||
> `D:\GIT\_PERSO\mcp-wms-api` et lui dire : « Lis `docs/supervision.md` et
|
||||
> prends ce rôle. »
|
||||
>
|
||||
> Document durable, contrairement aux passations `docs/handoff-*.md` qui sont à
|
||||
> usage unique.
|
||||
|
||||
---
|
||||
|
||||
Tu tiens le rôle de **superviseur** du serveur MCP `mcp-wms-api` : un serveur
|
||||
MCP (Node.js, CommonJS) qui donne à Claude un accès en lecture à un WMS EasyWMS
|
||||
de Mecalux, via ses API REST uniquement.
|
||||
|
||||
Tu n'écris pas les fonctionnalités. D'autres sessions Claude Code le font, à
|
||||
partir de prompts de passation que **tu** rédiges. Ton travail tient en trois
|
||||
gestes qui se répètent :
|
||||
|
||||
1. **Vérifier** l'état réel du MCP contre le WMS réel.
|
||||
2. **Réviser** ce que les sessions de codage ont livré, sans les croire sur
|
||||
parole.
|
||||
3. **Rédiger** la passation suivante.
|
||||
|
||||
Ta valeur tient entièrement à un principe : **tu mesures, tu ne supposes pas.**
|
||||
Un rapport d'agent, une doc, un commentaire de code sont des indices — la seule
|
||||
preuve est l'exécution contre le WMS.
|
||||
|
||||
---
|
||||
|
||||
## 1. Où vit la vérité
|
||||
|
||||
| Fichier | Rôle | Qui l'écrit |
|
||||
|---|---|---|
|
||||
| [../CLAUDE.md](../CLAUDE.md) | architecture, conventions de code | toi, quand le code change |
|
||||
| [../DECISIONS.md](../DECISIONS.md) | **pourquoi** le code est ainsi, pièges vérifiés (`D1`…) | toi, ou la session de codage sur consigne |
|
||||
| [../ROADMAP.md](../ROADMAP.md) | ce qui reste à faire, par lot, et ce qui est écarté | toi |
|
||||
| [../MONITORING.md](../MONITORING.md) | supervision du serveur MCP en exploitation | toi |
|
||||
| [logs.md](logs.md) | accès aux logs du WMS | toi |
|
||||
| `handoff-*.md` | passations à usage unique | toi, supprimées une fois livrées |
|
||||
|
||||
Règle de répartition, pour éviter que tout finisse en vrac dans le même
|
||||
fichier :
|
||||
|
||||
- un fait **mesuré et acté** → `DECISIONS.md`, avec un numéro `D<n>` ;
|
||||
- un travail **à faire** → `ROADMAP.md` ;
|
||||
- une **consigne à un agent** → un `handoff-*.md` ;
|
||||
- une proposition **écartée** → la section « Écarté » de `ROADMAP.md`, avec sa
|
||||
raison. Sans ça, elle sera reproposée dans trois mois.
|
||||
|
||||
**Numérotation des décisions.** `D21` est réservée au lot 2 (règle
|
||||
`TableName`), `D22` au lot 1 (routage par table explicite). Vérifie le dernier
|
||||
numéro utilisé avant d'en attribuer un.
|
||||
|
||||
---
|
||||
|
||||
## 2. Baseline : ce qui doit rester vrai
|
||||
|
||||
Toute session de codage doit laisser ces valeurs intactes. Un écart non
|
||||
expliqué est une régression, pas une amélioration.
|
||||
|
||||
| Contrôle | Attendu |
|
||||
|---|---|
|
||||
| `tools/list` | **23** outils |
|
||||
| `resources/list` | **6** resources |
|
||||
| `npm test` | **4/4**, code de sortie 0 |
|
||||
| Démarrage | aucune écriture sur stdout hors JSON-RPC |
|
||||
|
||||
Mesures de référence sur le tenant `LIMAGRAI2512` (24/08/2026). Elles dépendent
|
||||
du tenant : les revérifier plutôt que de les citer de mémoire sur un autre
|
||||
profil.
|
||||
|
||||
| Mesure | Valeur |
|
||||
|---|---|
|
||||
| Entités du Metadata `EasyWMS` | 232 |
|
||||
| Applications déclarées | 9 |
|
||||
| Workflows `EasyWMS` / `CustomApp` | 4012 / 153 |
|
||||
| Types AD | 20, ~38 800 éléments |
|
||||
| Contextes de requête utilisables | Reading (0), Writing (1), Metrics (3). DataWarehouse (2) non configuré |
|
||||
|
||||
---
|
||||
|
||||
## 3. Boîte à outils de vérification
|
||||
|
||||
Toutes ces commandes sont **en lecture seule** côté WMS. Elles ont été
|
||||
exécutées et fonctionnent telles quelles.
|
||||
|
||||
### Handshake MCP complet
|
||||
|
||||
```bash
|
||||
printf '%s\n%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' '{"jsonrpc":"2.0","id":3,"method":"resources/list"}' | node src/index.js 2>/dev/null | node -e "let b='';process.stdin.on('data',d=>b+=d).on('end',()=>{for(const l of b.split('\n').filter(Boolean)){const m=JSON.parse(l);if(m.id===2)console.log('tools:',m.result.tools.length);if(m.id===3)console.log('resources:',m.result.resources.length);}});"
|
||||
```
|
||||
|
||||
### Appeler un outil réellement, via le protocole
|
||||
|
||||
Ajoute une ligne `tools/call` après la notification `initialized`. C'est la
|
||||
**seule** façon de vérifier qu'un outil est routé — un outil peut apparaître
|
||||
dans `tools/list` et renvoyer `Unknown tool` (c'est arrivé pour
|
||||
`get_entity_metadata` et `list_log_files`).
|
||||
|
||||
```bash
|
||||
printf '%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"NOM_OUTIL","arguments":{}}}' | node src/index.js 2>/dev/null
|
||||
```
|
||||
|
||||
**Contrôle systématique après toute modification du routage** : chaque nom
|
||||
renvoyé par `tools/list` doit résoudre. Boucle sur les 23, ne teste pas
|
||||
seulement ceux qu'on vient de corriger.
|
||||
|
||||
### Sonder le WMS directement
|
||||
|
||||
Court-circuite les outils pour savoir ce que l'API répond vraiment :
|
||||
|
||||
```bash
|
||||
node -e "
|
||||
require('dotenv').config();
|
||||
const pm=require('./src/config/profile-manager'); pm.loadProfiles();
|
||||
const api=require('./src/services/api-service').getInstance();
|
||||
(async()=>{
|
||||
try{ const r=await api.executeQuery('Context.Products.OrderBy(z => z.Id)',{take:1}); console.log('OK', r.length); }
|
||||
catch(e){ console.log('status', e.response?.status); console.log(JSON.stringify(e.response?.data).slice(0,600)); }
|
||||
})();"
|
||||
```
|
||||
|
||||
C'est ce qui a révélé que les HTTP 500 portaient déjà le diagnostic complet
|
||||
dans leur corps. **Quand un outil échoue, descends toujours à ce niveau** avant
|
||||
de conclure quoi que ce soit sur la cause.
|
||||
|
||||
### Référence de l'API
|
||||
|
||||
`https://<host>/ApplicationService/Help` — page d'aide générée par le service,
|
||||
**source de vérité** sur les champs et les endpoints. Elle a déjà démenti deux
|
||||
de nos affirmations. La consulter avant d'affirmer qu'une capacité n'existe pas.
|
||||
|
||||
---
|
||||
|
||||
## 4. Réviser une livraison
|
||||
|
||||
Quand une session de codage rend son travail, applique cette grille. Ne saute
|
||||
pas d'étape parce que le compte-rendu a l'air soigné : les comptes-rendus les
|
||||
plus assurés sont souvent les moins vérifiés.
|
||||
|
||||
**a. Reproduis la vérification attendue toi-même.** Chaque passation en définit
|
||||
une par correctif. Rejoue-la. Si elle passe chez toi, c'est un fait ; si elle
|
||||
n'est pas rejouable, c'est une affirmation.
|
||||
|
||||
**b. Relance la baseline complète** (§2). Une correction qui casse le handshake
|
||||
ou `npm test` n'est pas une correction.
|
||||
|
||||
**c. Cherche la régression latérale.** Le correctif touche un point de passage
|
||||
partagé ? `api-service.post` sert tous les outils, `index.js` route tout,
|
||||
`workflow-service` alimente trois outils. Teste au-delà du périmètre annoncé.
|
||||
|
||||
**d. Lis le diff, pas seulement le compte-rendu.** `git show --stat` puis le
|
||||
diff complet. Tu cherches en particulier :
|
||||
|
||||
- un `console.log()` ajouté — casse la session Claude Desktop (D6) ;
|
||||
- un secret introduit dans un fichier suivi ;
|
||||
- une invalidation de cache faite à la main plutôt que par `onSwitch()` (D8) ;
|
||||
- un `git add -A` qui a emporté des fichiers hors périmètre ;
|
||||
- une valeur inventée là où l'agent aurait dû mesurer.
|
||||
|
||||
**e. Traque les quatre modes d'échec déjà observés sur ce dépôt.** Ils
|
||||
reviennent :
|
||||
|
||||
| Mode | Signature |
|
||||
|---|---|
|
||||
| Taxonomie inventée | l'agent dérive une catégorie d'un préfixe de nom faute de champ réel |
|
||||
| Casse supposée | `w.Name` alors que l'API renvoie `w.name` — objets vides, comptage correct (D5) |
|
||||
| Collision de préfixe | un outil listé et non routé, à cause d'un `startsWith` |
|
||||
| Hypothèse présentée en solution | « il suffit de… » sans exécution derrière |
|
||||
|
||||
**f. Vérifie la trace écrite.** Une décision prise pendant l'implémentation
|
||||
doit atterrir dans `DECISIONS.md` avec son numéro ; le lot livré doit sortir de
|
||||
`ROADMAP.md` ; une anomalie découverte hors périmètre doit y entrer.
|
||||
|
||||
**g. Rends un verdict net.** Ce qui est **mesuré**, ce qui est **déclaré mais
|
||||
non vérifiable**, ce qui est **à reprendre**. Pas de « globalement bon ».
|
||||
|
||||
---
|
||||
|
||||
## 5. Rédiger la passation suivante
|
||||
|
||||
Un `docs/handoff-<lot>.md`, autoportant : la session qui le lit n'a pas ton
|
||||
contexte et ne l'aura jamais.
|
||||
|
||||
Structure qui a fonctionné :
|
||||
|
||||
1. **Cadre** — le dépôt, la mission en une phrase, ce qui est explicitement
|
||||
**hors** périmètre.
|
||||
2. **Contexte matériel** — le profil qui marche, la baseline, les commandes de
|
||||
vérification copiables.
|
||||
3. **Contraintes non négociables** — `console.error` seulement, le contrat
|
||||
d'erreur des outils, pas d'accès base, ne pas toucher au `.env`.
|
||||
4. **Phase 0 s'il y a lieu** — vérifications avant de coder, avec les mesures
|
||||
déjà faites à confirmer.
|
||||
5. **Un bloc par correctif** — problème, **preuve mesurée**, ce qu'il faut
|
||||
faire, points d'attention, **vérification attendue**.
|
||||
6. **Méthode** — ordre des travaux, obligation de vérifier en exécution.
|
||||
7. **Livraison** — granularité des commits, mises à jour de doc, ne pas pousser.
|
||||
|
||||
Les six règles qui font la différence entre un prompt suivi et un prompt
|
||||
réinterprété :
|
||||
|
||||
- **Donne les preuves, pas les symptômes.** Colle la sortie brute, les clés
|
||||
réelles d'un objet, les numéros de ligne. Sinon l'agent refait le diagnostic
|
||||
et peut aboutir ailleurs.
|
||||
- **Marque ce qui est déjà tranché** — « ne le réinvestigue pas ». Économise des
|
||||
heures et évite les conclusions contradictoires.
|
||||
- **Time-boxe les investigations ouvertes** et autorise explicitement « non
|
||||
résolu » comme réponse. Sans ça, l'agent invente plutôt que d'admettre.
|
||||
- **Nomme la pente naturelle et interdis-la.** Exemple réel : « n'invente pas
|
||||
une taxonomie en dérivant des catégories d'un préfixe de nom ».
|
||||
- **Une vérification attendue par correctif**, formulée en résultat observable.
|
||||
- **Réserve les numéros** de décisions pour éviter les collisions entre lots
|
||||
menés en parallèle.
|
||||
|
||||
---
|
||||
|
||||
## 6. Surveillance courante
|
||||
|
||||
Entre deux livraisons, ce qui mérite un passage régulier :
|
||||
|
||||
- **`npm test` sur tous les profils** — `npm test -- --all`. Détecte une
|
||||
expiration de credentials ou un WMS injoignable avant que ça ne devienne un
|
||||
faux diagnostic.
|
||||
- **Cohérence doc / code.** Le nombre d'outils annoncé, les listes d'entités,
|
||||
les chemins de fichiers cités. Cette doc a déjà annoncé 7 resources pour 6, un
|
||||
`README.md` inexistant et une entité `Aliases` qui n'existe pas.
|
||||
- **Retours d'usage.** Une session Cowork ou Desktop qui bute est la meilleure
|
||||
source de bugs réels — mais **ses conclusions sont à revérifier**. Sur les
|
||||
8 anomalies du rapport du 24/08, 3 étaient réelles, 3 partiellement fausses,
|
||||
2 non fondées, et la cause racine n'y figurait pas.
|
||||
- **Les logs du WMS**, quand une erreur reste opaque : `search_logs`, ou les
|
||||
partages décrits dans [logs.md](logs.md). Ce sont eux qui ont livré la cause
|
||||
racine des HTTP 500.
|
||||
|
||||
---
|
||||
|
||||
## 7. Garde-fous
|
||||
|
||||
- **Lecture seule côté WMS.** `QueryExecute`, `QueryScalarExecute`, Metadata et
|
||||
l'API AD ne modifient rien. `execute_command` **écrit** : ne l'appelle pas
|
||||
pour tester.
|
||||
- **Ne pousse pas.** `main` a un remote (`git.arthur-ria.fr`). Le push est une
|
||||
décision du propriétaire du dépôt.
|
||||
- **Ne réécris pas l'historique.** Le dépôt est publié ; un `filter-repo`
|
||||
imposerait un force-push sur une branche partagée.
|
||||
- **Le `.env` contient des credentials réels** et est ignoré par git. Ne le
|
||||
modifie pas, ne le recopie pas ailleurs, n'en cite pas le contenu.
|
||||
- **`console.error()` uniquement.** stdout appartient au protocole MCP (D6).
|
||||
- **Ne corrige pas toi-même** ce que tu découvres en révisant, sauf trivialité
|
||||
évidente : consigne-le dans `ROADMAP.md` et mets-le dans la passation
|
||||
suivante. Sinon tu deviens l'implémenteur et plus personne ne te révise.
|
||||
File diff suppressed because one or more lines are too long
@@ -1,137 +0,0 @@
|
||||
## OutboundOrder
|
||||
|
||||
Full name: **Mecalux.ITSW.EasyWMS.Modules.Expeditions.Reading.Domain.OutboundOrder**
|
||||
|
||||
## Summary
|
||||
|
||||
Represents an outbound order - Is used for managing and tracking outbound orders. It includes key details.
|
||||
|
||||
## Properties
|
||||
|
||||
| Name | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| AccountCode | string | Gets or sets the \[Account\] code. The account code for the outbound order (An account is each of a company´s delivery points). Example: "ACC\_NIKE\_01025" |
|
||||
| AccountId | Guid? | Gets or sets the \[Account\] identifier. The account identifier for the outbound order (An account is each of a company´s delivery points). Example: 85f988c3-t2f4-44fc-bd2d-ca3db667812t. |
|
||||
| Address | [Address](https://msscc.mecalux.com/documentation/Development/master/ES/apis/easywms/Domain/Address.md) | Gets or sets the \[Address\]. The delivery address for the outbound order - Represents a physical address with properties (Street, number, door, floor, letter, city, neighborhood, country, zipcode and comments about the address). |
|
||||
| AgencyCode | string | Gets or sets the \[Agency\] code. The carrier code for the outbound order (Transport agency code. There must be the transport agency code). Example: "SEUR" |
|
||||
| AgencyId | Guid? | Gets or sets the \[Agency\] identifier. The carrier identifier for the outbound order (Transport agency code. There must be the transport agency code). Example: 15f9e8c3-g2f4-44fc-bd2d-ca3db667617y. |
|
||||
| AllowDynamicReplenishment | bool | Indicates whether the outbound order allows dynamic replenishment. If allows dynamic replenishment (true). Otherwise (false). Example: false |
|
||||
| AnyOutboundLineInWorkWave | bool | Flag that indicates if any of it's line is associated to a wave. Example: false |
|
||||
| AnyStockShipped | bool | Indicates if the order has any stock shipped. Rule to get: Count all of the outbound order lines with the identifier of the outbound order, if the order has lines with quantity shipped or quantity shipped substitute greater than zero (true); otherwise (false) Example: true |
|
||||
| AssignedUser | string | The user for whom the outbound order is assigned. Example: "mecalux001" |
|
||||
| AutoCreated | bool | \[CANNOT BE USED\] Indicates if the outbound order has been auto created. Currently it is always `false`. |
|
||||
| AutoRelease | bool | Indicates whether the outbound order should be auto released. If the outbound order should be auto released (true). Otherwise (false). Example: true |
|
||||
| AutoReleaseDate | DateTime? | Indicates when the outbound order will be automatically released if the auto release flag is set to true. Example: 03/25/2025 0:00:00. |
|
||||
| AutoReserve | bool | \[NOT IMPLEMENTED\] Indicates if the outbound order should reserve stock automatically. |
|
||||
| AutoReserveDate | DateTime? | \[NOT IMPLEMENTED\] Indicates when the outbound order will be automatically reserved if the auto reserve flag is set to `true` |
|
||||
| CalculatedNumAdjustStockIssues | long | Gets or sets the total number of adjust stock issues. |
|
||||
| CalculatedNumCommunicationErrorIssues | long | Gets or sets the total number of communication error issues. |
|
||||
| CalculatedNumExpiredDatesIssues | long | Gets or sets the total number of expired dates issues. |
|
||||
| CalculatedNumNeedReplenishmentIssues | long | Gets or sets the total number of need replenishment issues. |
|
||||
| ClientContainerTypeCode | string | The favourite container type code for picking. Example: "American". |
|
||||
| ClientContainerTypeId | Guid? | The favourite container type identifier for picking. Example: 75f828c3-f2f4-44fc-bd2d-ca3db667896r. |
|
||||
| Code | string | **Required**
|
||||
It is a unique code that identifies the outbound order. Example: "ORDER\_SU1". |
|
||||
| CreationDateOnERP | DateTime? | The ERP creation date for the outbound order. Example: 01/25/2024 0:00:00. |
|
||||
| CurrentOperation | string | The operation for the outbound order. The possible values are the ones in [OutboundOrderOperation](https://msscc.mecalux.com/documentation/Development/master/ES/apis/easywms/Domain/OutboundOrderOperation.md). Example: "Releasing" |
|
||||
| CustomAttribute | CustomAttribute | Gets or sets the \[Common.Domain.CustomAttribute\]. The outbound order's custom attributes. |
|
||||
| DeliveryInstruction | string | \[USED IN MULTI-CARRIER SHIPPING APP\] The delivery instructions Example: "Ensure the package is not exposed to direct sunlight or rain". |
|
||||
| Description | string | It is a description of the outbound order. Example: "Includes special packaging". |
|
||||
| Document | string | The outbound order document Example: "DOC01". |
|
||||
| ERPCode | string | The ERP code for the outbound order. Example: "ERP\_US\_001" |
|
||||
| ExpectedDockStationCode | string | The expected dock station code for the outbound order. (A dock is an area in the warehouse where trucks (or any other vehicle used to transport goods). Example: "DOCK\_01". |
|
||||
| ExpectedDockStationId | Guid? | The expected dock station identifier for the outbound order. (A dock is an area in the warehouse where trucks (or any other vehicle used transport goods). Example: 88f828c3-f334-44fc-bd2d-fa3db667896t. |
|
||||
| FirstTroubleDate | DateTime? | The first trouble date. It is null if no trouble has occurred. Example: 02/25/2025 0:00:00. |
|
||||
| FollowSequence | bool | Indicates whether the line number should be used as a sequencing method for the preparation. If the line number should be used as a sequencing method for the preparation (true). Otherwise (false). Example: false |
|
||||
| GroupType | string | The group type of the outbound order. The possible values are the ones in [GroupType](https://msscc.mecalux.com/documentation/Development/master/ES/apis/easywms/Domain/GroupType.md) + an empty string when it is not a group. Example: Merge |
|
||||
| HasTroubles | bool | Indicates whether the outbound order has troubles. Example: true. |
|
||||
| IncompleteOrder | bool | Indicates whether the outbound order is incomplete (An incomplete order is when a order is not fully fulfilled, missing items, incorrect items, or partial delivery). Example: false. |
|
||||
| InternalInfo | InternalInfo | Gets or sets the \[Application.Common.Domain.InternalInfo\]. The internal system information of the outbound order. |
|
||||
| IsActive | bool | Indicates if the outbound order is active in the system Example: true. |
|
||||
| IsSingleUnit | bool | Indicates if the outbound order is single unit. Example: true. |
|
||||
| IsTenseFlow | bool | Indicates if the order is tense flow (Tense Flow is strategy that synchronizes the arrival of goods at the warehouse with their departure for distribution, reducing storage needs and costs while improving operational efficiency). Example: true |
|
||||
| LoadDockStageLocationCode | string | The actual dock stage location code for the outbound order. Example: "DOCK\_03". |
|
||||
| LoadDockStageLocationId | Guid? | The actual dock stage location identifier for the outbound order. Example: 09f828c3-g334-44fc-bd2d-fa3db667885g. |
|
||||
| LoadDockStationCode | string | The actual dock station code for the outbound order. (A dock is an area in the warehouse where truck (or any other vehicle user to transport goods). Example: "DOCK\_02". |
|
||||
| LoadDockStationId | Guid? | The actual dock station identifier for the outbound order. (A dock is an area in the warehouse where truck (or any other vehicle user to transport goods). Example: 19f828c3-g334-44fc-bd2d-fa3db667896f. |
|
||||
| ManualEquipmentCode | string | The code of the manual equipment assigned to the outbound order. (It is managed by an operator using a radio frequency terminal. Manual equipment includes pallet, trucks, forklifts etc.). Example: "EQST\_PTL\_ONLYSTOCK\_01" |
|
||||
| ManualEquipmentId | Guid? | The identifier of the manual equipment assigned to the outbound order. (It is managed by an operator using a radio frequency terminal. Manual equipment includes pallet, trucks, forklifts etc.). Example: 41f838c3-h354-442c-bd2d-fa32b667913y. |
|
||||
| MinSupplyPercent | long | \[NOT IMPLEMENTED\] The minimum percentage of storaged stock necessary to supply stock to the order. |
|
||||
| NumCancelledLines | long | The number of outbound lines from the order that are in cancelled status. |
|
||||
| NumClientContainers | long | The number of client containers for the outbound order. |
|
||||
| NumClosedLines | long | The number of outbound lines from the order that are in closed status. |
|
||||
| NumCutInProcessTasks | long | The number of cut in process task for the outbound order. |
|
||||
| NumCutPendingTasks | long | The number of cut pending task for the outbound order. |
|
||||
| NumLoads | long | The number of loads for the outbound order. Example: 1 |
|
||||
| NumOutboundOrderChildren | long | The number of outbound order children for the group or merge. |
|
||||
| NumOutboundOrderLineDetails | long | The number of outbound order line details for the outbound order. Example: 2 |
|
||||
| NumOutboundOrderLines | long | **Obsolete("Use NumTotalOutboundOrderLines")**
|
||||
The number of outbound lines for the outbound order. Example: 2 |
|
||||
| NumPendingTasks | long | The number of pending tasks for the outbound order. |
|
||||
| NumPreparedTasks | long | The number of prepared tasks for the outbound order. |
|
||||
| NumReleasedLines | long | The number of outbound lines from the order that are in released status. |
|
||||
| NumStockLines | long | The number of stock lines for the outbound order. |
|
||||
| NumStoppedLines | long | The number of outbound lines from the order that are stopped when the whole order is being stopped. |
|
||||
| NumTotalOutboundOrderLines | long | The total number of outbound order lines. Rule to calculate: Count of all outbound order lines with the identifier of the outbound order and outbound order status different of the ´Cancelled´. Example: 5 |
|
||||
| NumWavedLines | long | **Obsolete("Waves are no longer supported. Use WorkWaves")**
|
||||
\[OBSOLETE\] The number of outbound lines from the outbound order included in a wave. |
|
||||
| NumWaves | long | **Obsolete("Waves are no longer supported. Use WorkWaves")**
|
||||
\[OBSOLETE\] The number of waves where the outbound order is included. |
|
||||
| OrderWeight | decimal | **Obsolete("This field is no longer maintained. Please perform the necessary query to obtain the value")**
|
||||
\[OBSOLETE\] The outbound order weight. |
|
||||
| OutboundClassCode | string | Gets or sets the \[OutboundClass\] code. The outbound class code for the outbound order (The outbound class is a classification of the routes and shipping orders). Example: "SHIPPING\_GROUP\_01" |
|
||||
| OutboundClassId | Guid? | Gets or sets the \[OutboundClass\] identifier. The outbound class identifier for the outbound order (The outbound class is a classification of the routes and shipping orders). Example: 08d877b3-t2e4-44fc-cd2d-ca3db667515t. |
|
||||
| OutboundIssueCodes | string | Gets or sets the issues. |
|
||||
| OutboundOrderGroupMasterCode | string | Gets or sets the \[OutboundOrderGroup\] code. The code of the master outbound order for the group the outbound order is part of. It is part of none, it is an empty string. Example: "GROUPING\_01". |
|
||||
| OutboundOrderGroupMasterId | Guid? | Gets or sets the \[OutboundOrderGroup\] identifier. The identifier of the master outbound order for the group the outbound order is part of. It is part of none, it is null. Example: 41f838c3-h354-442c-bd2d-fa32b667913y. |
|
||||
| OutboundOrderSituation | string | \[CANNOT BE USED\] The outbound order situation. |
|
||||
| OutboundOrderStatus | string | **Required**
|
||||
The outbound order's status. The possible values are the ones in [OutboundOrderStatus](https://msscc.mecalux.com/documentation/Development/master/ES/apis/easywms/Domain/OutboundOrderStatus.md). Example: "Creating" |
|
||||
| OutboundType | string | The outbound order's outbound type. The possible values are the ones in [OutboundType](https://msscc.mecalux.com/documentation/Development/master/ES/apis/easywms/Domain/OutboundType.md). Example: "Customer" |
|
||||
| OwnerCode | string | Gets or sets the \[Owner\] code. The owner code for the outbound order. Example: "VW\_02". |
|
||||
| OwnerId | Guid? | Gets or sets the \[Owner\] identifier. The owner identifier for the outbound order. Example: 65f828c3-t2f4-44fc-bd2d-ca3db667896g. |
|
||||
| PackagingLocationCode | string | The packaging location code for the outbound order. Example: "UDISTRIBUTION\_UAP\_02". |
|
||||
| PackagingLocationId | Guid? | The packaging location identifier for the outbound order. Example: 39f838c3-g354-442c-bd2d-fa32b667882g. |
|
||||
| PaperPick | bool | Indicates whether the outbound order will be prepared using paper pick. (Paper picking is a process by which the operator performs the picking manually, without using the RF terminal or the work station.) Example: true. |
|
||||
| PartialClosedSequence | long? | The last partial closed sequence. Example: 3 |
|
||||
| PendingReceipt | bool | Indicates whether the outbound order is waiting for receiving an ASN container. True if it is waiting for an ASN to be received or otherwise is false. Example: false. |
|
||||
| Planning | [Planning](https://msscc.mecalux.com/documentation/Development/master/ES/apis/easywms/Domain/Planning.md) | Gets or sets the \[Planning\]. The outbound order planning information. |
|
||||
| PrepackagingLogic | string | The pre-packaging logic for the outbound order. The possible values are the ones in [PrepackagingLogic](https://msscc.mecalux.com/documentation/Development/master/ES/apis/easywms/Domain/PrepackagingLogic.md). Example: MinimumParcels |
|
||||
| PrepackagingProcess | string | The pre-packaging type for the outbound order. The possible values are the ones in [PrepackagingProcess](https://msscc.mecalux.com/documentation/Development/master/ES/apis/easywms/Domain/PrepackagingProcess.md). Example: Preparation |
|
||||
| PrepackagingType | string | The pre-packaging type for the outbound order. The possible values are the ones in [PrepackagingType](https://msscc.mecalux.com/documentation/Development/master/ES/apis/easywms/Domain/PrepackagingType.md). Example: ERPInformed |
|
||||
| PrepackagingWorkingMode | string | The pre-packaging working mode for the outbound order. The possible values are the ones in [PrepackagingWorkingMode](https://msscc.mecalux.com/documentation/Development/master/ES/apis/easywms/Domain/PrepackagingWorkingMode.md). Example: Strict |
|
||||
| Priority | string | The outbound order priority. The possible values are the ones in [Priority](https://msscc.mecalux.com/documentation/Development/master/ES/apis/easywms/Domain/Priority.md). Example: "Urgent". |
|
||||
| RealReleaseDate | DateTime? | Indicates the first release date for the outbound order. Example: 03/25/2025 0:00:00. |
|
||||
| ReceivedFromERP | bool | Indicates whether the outbound order was received from the ERP. If received from the ERP (true). Otherwise (false). Example: true |
|
||||
| ReleasedBy | string | The first user who released the outbound order. Example: "mecalux567" |
|
||||
| RequiredClientContainerTypeCode | string | Gets or sets the \[ContainerType\] code. The required client container type code for picking. Example: "EuroPallet". |
|
||||
| RequiredClientContainerTypeId | Guid? | Gets or sets the \[ContainerType\] identifier. The required client container type identifier for picking. Example: 85f828c3-t2f4-44fc-bd2d-ca3db667896t. |
|
||||
| RouteCode | string | Gets or sets the \[Route\] code. The route code for the outbound order. Example: "NORTHERN\_ROUTE". |
|
||||
| RouteId | Guid? | Gets or sets the \[Route\] identifier. The route identifier for the outbound order. Example: 48d877b3-c2e4-44fc-bd2d-ca3db667515c. |
|
||||
| ShipExpiredStock | bool | Indicates if the outbound order shipped products with expired dates. |
|
||||
| ShipmentTemplateCode | string | Gets or sets the \[ShipmentTemplate\] code. The code of the shipment template used in the outbound order. Example: "ST\_SINGLE\_UNIT" |
|
||||
| ShipmentTemplateId | Guid? | Gets or sets the \[ShipmentTemplate\] identifier. The identifier of the shipment template used in the outbound order. Example: 21f838c3-e354-442c-td2d-ha32b667915u. |
|
||||
| ShippingDeadline | DateTime? | Gets the shipping deadline. |
|
||||
| ShippingDeadlineManuallySet | bool | Indicates whether the shipping deadline was received from the assistant. If received from the assistant (true). Otherwise (false). |
|
||||
| ShipReq | [ShippingRequirements](https://msscc.mecalux.com/documentation/Development/master/ES/apis/easywms/Domain/ShippingRequirements.md) | Gets or sets the \[ShippingRequirements\]. The outbound order shipping requirements. |
|
||||
| SkipTroubledTasks | bool | The flag to skip troubled tasks. When `true`, troubled tasks will be skipped. When `false`, they will block the execution of the following tasks. Example: true |
|
||||
| Source | string | The outbound order source. Example: "L10". |
|
||||
| SubwarehouseCode | string | Gets or sets the \[Subwarehouse\] code. The subwarehouse code for the outbound order (Subwarehouses is grouping locations to build rules that apply over a specific warehouse area or locations subset). Example: "ST\_SINGLE\_UNIT" |
|
||||
| SubwarehouseId | Guid? | Gets or sets the \[Subwarehouse\] identifier. The subwarehouse identifier for the outbound order (Subwarehouses is grouping locations to build rules that apply over a specific warehouse area or locations subset). Example: 32f838c3-ef54-442c-td2d-ha32b6679r5g. |
|
||||
| SupplierCode | string | Gets or sets the \[Supplier\] code. The supplier code for the outbound order (A supplier is a company or entity that supplies stock to the node). Example: "NIKE" |
|
||||
| SupplierId | Guid? | Gets or sets the \[Supplier\] identifier. The supplier identifier for the outbound order (A supplier is a company or entity that supplies stock to the node). Example: 25f9e8c3-g2f4-45fc-bd2d-ca3db667718u. |
|
||||
| TenseFlowStageCode | string | The tense flow stage code (Tense Flow is strategy that synchronizes the arrival of goods at the warehouse with their departure for distribution, reducing storage needs and costs while improving operational efficiency). Example: "STG\_01" |
|
||||
| TenseFlowStageId | Guid? | The tense flow stage identifier (Tense Flow is strategy that synchronizes the arrival of goods at the warehouse with their departure for distribution, reducing storage needs and costs while improving operational efficiency). Example: 35f838c3-fg54-542c-td2d-ha32b667190t. |
|
||||
| Transport | [OutboundOrderTransport](https://msscc.mecalux.com/documentation/Development/master/ES/apis/easywms/Domain/OutboundOrderTransport.md) | Gets or sets the \[OutboundOrderTransport\]. The outbound order transport information. |
|
||||
| ValidDate | DateTime? | \[NOT IMPLEMENTED\] The outbound order valid date. |
|
||||
| WarehouseCode | string | **Required**
|
||||
Gets or sets the \[Warehouse\] code. The warehouse code for the outbound order. Example: "KITDEMO\_4". |
|
||||
| WarehouseId | Guid | Gets or sets the \[Warehouse\] identifier. The warehouse identifier for the outbound order. Example: 07d877b3-c2e4-44fc-bd2d-ca3db667514a. |
|
||||
| WarehouseToCode | string | Gets or sets the \[Warehouse\] code. The destination warehouse code for the outbound order. Example: "WH\_STORE\_01". |
|
||||
| WarehouseToId | Guid? | Gets or sets the \[Warehouse\] identifier. The destination warehouse identifier for the outbound order. Example: 18d877b3-c2e4-44fc-bd2d-ca3db667515b. |
|
||||
| WorkOrderCode | string | Gets or sets the \[WorkOrder\] code. The work order code for the outbound order (assembly or disassembly of kit). Example: "WORK\_ORDER\_ASSEMBLY\_01" |
|
||||
| WorkOrderId | Guid? | Gets or sets the \[WorkOrder\] identifier. The work order identifier for the outbound order (assembly or disassembly of kit). Example: 78d877b3-f2e4-44fc-bd2d-ca3db667515g. |
|
||||
| WorkWaveCode | string | Gets or sets the \[WorkWave\] code. The code of the wave to which the outbound order is associated. Example: "Wave\_A" |
|
||||
| WorkWaveId | Guid? | Gets or sets the \[WorkWave\] identifier. Identifier used to track and manage wave. Example: 12f838c3-ff54-542c-td2d-ha32b6679r5t. |
|
||||
|
||||
If you have any suggestion or comment on this documentation, please submit it to [documentation@mecalux.com](mailto:documentation@mecalux.com)
|
||||
@@ -1,32 +0,0 @@
|
||||
## OutboundOrderStatus
|
||||
|
||||
Full name: **Mecalux.ITSW.EasyWMS.Modules.Common.Domain.OutboundOrderStatus**
|
||||
|
||||
## Summary
|
||||
|
||||
Represents the status of outbound orders.
|
||||
|
||||
## Values
|
||||
|
||||
| Name | Value | Description |
|
||||
| --- | --- | --- |
|
||||
| Creating | 0 | The outbound order is being created. this will occur when they are created by the ERP or manually through the user interface. |
|
||||
| Waiting | 1 | The outbound order is waiting to be released. this will occur when the order is correctly received from the ERP or when it's creation is finished manually through the user interface. |
|
||||
| Release | 2 | The outbound order is release and it's tasks may be generated. An order is released when it is released through the user interface, when it's auto release date is reached, when it is associated with a route with an auto release date which has been reached or when all the lines from the outbound order has been manually released. |
|
||||
| PartiallyRelease | 3 | The outbound order is partially release and it's tasks may be generated. An order is partially released when one or more lines from the order, always less than it's total lines, has been released. |
|
||||
| Working | 4 | The outbound order is working. The order will be in this status from the beggining to the end of it's preparation process. To be in this status, one of it's generated tasks must be "In Process" status. |
|
||||
| PartiallyWorking | 5 | The outbound order is partially working. The order will be in this status from the beggining to the end of it's preparation process. Only the orders with the status "PartiallyRelease" can change to this status. To be in this status, one of it's generated tasks must be "In Process" status. |
|
||||
| Pausing | 6 | The outbound order is being paused. Not used. |
|
||||
| Paused | 7 | The outbound order was paused. All it's work has been halted and it's tasks will be cancelled. This can occur when the order has been paused from the user interface. |
|
||||
| Cancelling | 8 | The outbound order is being cancelled. Not used. |
|
||||
| Cancelled | 9 | The outbound order was cancelled. All it's work will be halted, it's tasks will be cancelled and the assigned stock to the order will be unassigned |
|
||||
| Closing | 10 | The outbound order is being closed. Not used. |
|
||||
| Closed | 11 | The outbound order was closed. The preparation process of it's lines has been finished and the order is ready to ship or already shipped. This will occur when a user close the order through the user interface or when the system is configured to auto close al prepared orders. Also, the order will be archived if the system is configured as well to auto archive closed orders. |
|
||||
| Expired | 12 | The outbound order is outdated. The outbound order was not released before the valid date. Not used. |
|
||||
| StockFailure | 13 | The outbound order has all theirs lines on stock failure. An order will be in this status when it has been released and all of it's lines couldn't be assigned |
|
||||
| Merged | 14 | The outbound order was merged. The orders in this status has been fused through the user interface. |
|
||||
| Grouped | 15 | The outbound order was grouped. The orders in this status has been grouped through the user interface or automatically from a shipment template configured by the user. |
|
||||
| Secured | 16 | The outbound order has its stock secured. The orders in this status has been secured through the user interface and has a secured quantity. |
|
||||
| Assigned | 17 | The outbound order has its stock assigned. The orders in this status has been assigned through the user interface and has a assigned quantity. |
|
||||
|
||||
If you have any suggestion or comment on this documentation, please submit it to [documentation@mecalux.com](mailto:documentation@mecalux.com)
|
||||
Generated
+1998
-6
File diff suppressed because it is too large
Load Diff
+14
-5
@@ -1,17 +1,21 @@
|
||||
{
|
||||
"name": "wms-mcp-server",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP Server for WMS debugging and analysis",
|
||||
"description": "Serveur MCP pour le debug et l analyse d un WMS EasyWMS (100% API)",
|
||||
"main": "src/index.js",
|
||||
"bin": {
|
||||
"wms-mcp-server": "./src/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"test": "node test-connection.js",
|
||||
"build": "pkg . --targets node18-win-x64 --output dist/wms-mcp-server.exe"
|
||||
"test": "node scripts/test-connection.js",
|
||||
"build": "pkg . --targets node22-win-x64 --output dist/wms-mcp-server.exe"
|
||||
},
|
||||
"keywords": ["mcp", "wms", "debugging"],
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"wms",
|
||||
"debugging"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
@@ -24,7 +28,12 @@
|
||||
"assets": [
|
||||
"node_modules/@modelcontextprotocol/**/*"
|
||||
],
|
||||
"targets": ["node18-win-x64"],
|
||||
"targets": [
|
||||
"node22-win-x64"
|
||||
],
|
||||
"outputPath": "dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@yao-pkg/pkg": "^6.22.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
# AD API Testing Script
|
||||
# Tests each Application Dictionary element type endpoint
|
||||
|
||||
# Configuration
|
||||
$baseUrl = "https://localhost"
|
||||
# Configuration - aucun credential en dur : passer par les parametres ou l'environnement.
|
||||
# Exemple : . est-ad-api.ps1 -Host 10.255.255.2 -Username mecalux -Password '***' -Tenant AD
|
||||
param(
|
||||
[string]$WmsHost = $(if ($env:WMS_HOST) { $env:WMS_HOST } else { "localhost" }),
|
||||
[string]$Username = $env:WMS_USERNAME,
|
||||
[string]$Password = $env:WMS_PASSWORD,
|
||||
[string]$Tenant = $(if ($env:WMS_TENANT) { $env:WMS_TENANT } else { "AD" }),
|
||||
[string]$Auth = $(if ($env:WMS_API_AUTH) { $env:WMS_API_AUTH } else { "Basic R05BOklFNGU3aXFoZHQ=" }),
|
||||
[string]$Application = "EasyWMS"
|
||||
)
|
||||
|
||||
if (-not $Username -or -not $Password) {
|
||||
Write-Host "Username / Password manquants. Passez -Username / -Password ou definissez WMS_USERNAME / WMS_PASSWORD." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$baseUrl = "https://$WmsHost"
|
||||
$tokenUrl = "$baseUrl/EasySTS/OAuth/Token"
|
||||
$adApiBase = "$baseUrl/AD/api"
|
||||
$auth = "Basic R05BOklFNGU3aXFoZHQ="
|
||||
$username = "mecalux"
|
||||
$password = "ANDREZmlx6"
|
||||
$tenant = "AD"
|
||||
$application = "EasyWMS"
|
||||
$auth = $Auth
|
||||
$username = $Username
|
||||
$password = $Password
|
||||
$tenant = $Tenant
|
||||
$application = $Application
|
||||
|
||||
# Skip SSL certificate validation (self-signed cert)
|
||||
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Smoke test de connectivite WMS.
|
||||
*
|
||||
* npm test -> teste le profil DEFAULT_WMS_PROFILE
|
||||
* npm test -- LIMAGRAIN -> teste le profil nomme
|
||||
* npm test -- --all -> teste tous les profils declares dans WMS_PROFILES
|
||||
*
|
||||
* Verifie, pour chaque profil : chargement du profil, OAuth, QueryExecute,
|
||||
* QueryScalarExecute et l'API AD. N'ecrit rien dans le WMS.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||
|
||||
const profileManager = require('../src/config/profile-manager');
|
||||
const apiService = require('../src/services/api-service').getInstance();
|
||||
|
||||
function ok(label, detail) {
|
||||
console.log(` OK ${label}${detail ? ` - ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
function ko(label, error) {
|
||||
console.log(` FAIL ${label} - ${error.message}`);
|
||||
}
|
||||
|
||||
async function step(label, fn) {
|
||||
try {
|
||||
ok(label, await fn());
|
||||
return true;
|
||||
} catch (error) {
|
||||
ko(label, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testProfile(name) {
|
||||
console.log(`\n=== Profil ${name} ===`);
|
||||
profileManager.switchTo(name);
|
||||
const profile = profileManager.getCurrent();
|
||||
console.log(` host=${profile.host} tenant=${profile.tenant} saas=${profile.saas}`);
|
||||
|
||||
let passed = 0;
|
||||
const total = 4;
|
||||
|
||||
if (await step('OAuth', async () => {
|
||||
await apiService.authenticate();
|
||||
return `token obtenu (age max ~${process.env.TOKEN_MAX_AGE || 1190}s)`;
|
||||
})) passed++;
|
||||
|
||||
if (await step('QueryExecute', async () => {
|
||||
const rows = await apiService.executeQuery('Context.Products.OrderBy(z => z.Id)', { take: 1 });
|
||||
return `${Array.isArray(rows) ? rows.length : 0} ligne(s)`;
|
||||
})) passed++;
|
||||
|
||||
if (await step('QueryScalarExecute', async () => {
|
||||
const count = await apiService.executeScalarQuery('Context.Products.Count()');
|
||||
return `${count} produit(s)`;
|
||||
})) passed++;
|
||||
|
||||
if (await step('AD API (Validator)', async () => {
|
||||
const res = await apiService.post('/Validator/GetByApplication',
|
||||
[profile.application, profile.tenant, 10, 0], true);
|
||||
return `${res?.entities?.length ?? 0} element(s)`;
|
||||
})) passed++;
|
||||
|
||||
console.log(` -> ${passed}/${total} tests reussis`);
|
||||
return passed === total;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
profileManager.loadProfiles();
|
||||
const available = profileManager.listProfiles();
|
||||
|
||||
if (available.length === 0) {
|
||||
console.error('Aucun profil charge. Verifiez WMS_PROFILES et <NAME>_HOST/USERNAME/PASSWORD/TENANT dans .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const arg = process.argv[2];
|
||||
let targets;
|
||||
if (arg === '--all') {
|
||||
targets = available;
|
||||
} else if (arg) {
|
||||
targets = [arg];
|
||||
} else {
|
||||
targets = [profileManager.getCurrentName() || available[0]];
|
||||
}
|
||||
|
||||
let allOk = true;
|
||||
for (const name of targets) {
|
||||
try {
|
||||
allOk = (await testProfile(name)) && allOk;
|
||||
} catch (error) {
|
||||
console.log(`\n=== Profil ${name} ===\n FAIL ${error.message}`);
|
||||
allOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${allOk ? 'Tous les profils testes sont operationnels.' : 'Au moins un test a echoue (voir ci-dessus).'}`);
|
||||
process.exit(allOk ? 0 : 1);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Constants for WMS MCP Server
|
||||
*/
|
||||
|
||||
// WMS Entity Types available via Query API
|
||||
// Based on queries api.php reference
|
||||
const WMS_ENTITY_TYPES = [
|
||||
// Master Data
|
||||
'Products',
|
||||
'Containers',
|
||||
'Accounts',
|
||||
'Suppliers',
|
||||
'Kits',
|
||||
'Aliases',
|
||||
|
||||
// Operations
|
||||
'Tasks',
|
||||
'Stocks',
|
||||
'ProductLocations',
|
||||
|
||||
// Inbound
|
||||
'InboundOrders',
|
||||
'Receptions',
|
||||
|
||||
// Outbound
|
||||
'OutboundOrders'
|
||||
];
|
||||
|
||||
// Entity Categories for documentation
|
||||
const ENTITY_CATEGORIES = {
|
||||
'Master Data': ['Products', 'Containers', 'Accounts', 'Suppliers', 'Kits', 'Aliases'],
|
||||
'Operations': ['Tasks', 'Stocks', 'ProductLocations'],
|
||||
'Inbound': ['InboundOrders', 'Receptions'],
|
||||
'Outbound': ['OutboundOrders']
|
||||
};
|
||||
|
||||
// Common WMS Commands
|
||||
// These can be used with call_command_api tool
|
||||
const WMS_COMMANDS = {
|
||||
'ProductRemove': 'Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ProductRemoveCommand',
|
||||
'ProductUpdate': 'Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ProductUpdateCommand',
|
||||
'ContainerCreate': 'Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ContainerCreateCommand',
|
||||
'TaskCancel': 'Mecalux.ITSW.EasyWMS.Modules.Tasks.Contracts.Commands.TaskCancelCommand'
|
||||
// Add more as needed
|
||||
};
|
||||
|
||||
// MCP Resource URIs
|
||||
const RESOURCE_URIS = {
|
||||
WMS_ENTITIES: 'wms://entities',
|
||||
ENTITY_SCHEMAS: 'wms://entity-schemas',
|
||||
QUERY_EXAMPLES: 'wms://query-examples',
|
||||
WORKFLOWS_OVERVIEW: 'workflows://overview',
|
||||
WORKFLOWS_CATEGORIES: 'workflows://categories',
|
||||
API_CATALOG: 'api://catalog',
|
||||
LOGS_GUIDE: 'logs://guide'
|
||||
};
|
||||
|
||||
// Log patterns for error detection
|
||||
const LOG_ERROR_PATTERNS = [
|
||||
'ERROR',
|
||||
'EXCEPTION',
|
||||
'FATAL',
|
||||
'CRITICAL',
|
||||
'FAILED',
|
||||
'FAILURE',
|
||||
'WARNING'
|
||||
];
|
||||
|
||||
// Query limits
|
||||
const QUERY_LIMITS = {
|
||||
MAX_ROWS: parseInt(process.env.MAX_QUERY_ROWS) || 1000,
|
||||
DEFAULT_LIMIT: 100,
|
||||
TIMEOUT_MS: parseInt(process.env.QUERY_TIMEOUT) || 30000
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
WMS_ENTITY_TYPES,
|
||||
ENTITY_CATEGORIES,
|
||||
WMS_COMMANDS,
|
||||
RESOURCE_URIS,
|
||||
LOG_ERROR_PATTERNS,
|
||||
QUERY_LIMITS
|
||||
};
|
||||
+82
-45
@@ -14,9 +14,16 @@ const path = require('path');
|
||||
const originalStdoutWrite = process.stdout.write;
|
||||
process.stdout.write = process.stderr.write.bind(process.stderr);
|
||||
|
||||
require('dotenv').config({
|
||||
path: path.join(__dirname, '..', '.env')
|
||||
});
|
||||
// Resolve .env:
|
||||
// - packaged (.exe built with pkg): next to the executable, so the deployed
|
||||
// server can be reconfigured without a rebuild and no credential is ever
|
||||
// baked into the binary snapshot.
|
||||
// - from sources: project root.
|
||||
const ENV_PATH = process.pkg
|
||||
? path.join(path.dirname(process.execPath), '.env')
|
||||
: path.join(__dirname, '..', '.env');
|
||||
|
||||
require('dotenv').config({ path: ENV_PATH });
|
||||
|
||||
// Restore stdout
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
@@ -55,6 +62,68 @@ const metadataTools = require('./tools/metadata-tools.js');
|
||||
const configTools = require('./tools/config-tools.js');
|
||||
const profileTools = require('./tools/profile-tools.js');
|
||||
|
||||
const TOOL_MODULES = [
|
||||
{ moduleName: 'workflow-tools', module: workflowTools },
|
||||
{ moduleName: 'wms-query-tools', module: wmsQueryTools },
|
||||
{ moduleName: 'api-tools', module: apiTools },
|
||||
{ moduleName: 'log-tools', module: logTools },
|
||||
{ moduleName: 'ad-tools', module: adTools },
|
||||
{ moduleName: 'metadata-tools', module: metadataTools },
|
||||
{ moduleName: 'config-tools', module: configTools },
|
||||
{ moduleName: 'profile-tools', module: profileTools },
|
||||
];
|
||||
|
||||
// Table explicite nom d'outil -> module, construite depuis les listTools() de
|
||||
// chaque module : un outil listé est un outil routé, par construction. Le
|
||||
// routage par préfixe de nom laissait des outils listés mais injoignables
|
||||
// (get_entity_metadata capté par la mauvaise branche, list_log_files capté
|
||||
// par aucune).
|
||||
// Un nom déclaré par deux modules est un bug de développement : on échoue au
|
||||
// démarrage, pas à l'exécution.
|
||||
const toolRegistry = new Map();
|
||||
for (const { moduleName, module } of TOOL_MODULES) {
|
||||
for (const definition of module.listTools()) {
|
||||
const existing = toolRegistry.get(definition.name);
|
||||
if (existing) {
|
||||
throw new Error(
|
||||
`[Server] Duplicate tool name "${definition.name}" declared by both ` +
|
||||
`${existing.moduleName} and ${moduleName} — rename one of them`
|
||||
);
|
||||
}
|
||||
toolRegistry.set(definition.name, { moduleName, module, definition });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide les arguments d'un appel d'outil contre son inputSchema (D23).
|
||||
* Le SDK MCP ne valide pas les schémas d'entrée — mesuré le 24/08/2026 :
|
||||
* `additionalProperties: false` est ignoré et un paramètre inconnu retombe
|
||||
* silencieusement sur les défauts. La validation vit donc ici, pilotée par la
|
||||
* même table que tools/list : schéma déclaré = contrat appliqué.
|
||||
*/
|
||||
function validateToolArgs(definition, args) {
|
||||
const schema = definition.inputSchema || {};
|
||||
const properties = schema.properties || {};
|
||||
const validNames = Object.keys(properties);
|
||||
const validList = validNames.length > 0 ? validNames.join(', ') : '(aucun)';
|
||||
|
||||
const unknown = Object.keys(args || {}).filter(key => !(key in properties));
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(
|
||||
`Paramètre(s) inconnu(s) pour ${definition.name} : ${unknown.join(', ')}. ` +
|
||||
`Paramètres valides : ${validList}.`
|
||||
);
|
||||
}
|
||||
|
||||
const missing = (schema.required || []).filter(key => args?.[key] === undefined);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Paramètre(s) requis manquant(s) pour ${definition.name} : ${missing.join(', ')}. ` +
|
||||
`Paramètres valides : ${validList}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create MCP Server
|
||||
const server = new Server(
|
||||
{
|
||||
@@ -131,19 +200,10 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||
* List all available tools
|
||||
*/
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
const allTools = [
|
||||
...workflowTools.listTools(),
|
||||
...wmsQueryTools.listTools(),
|
||||
...apiTools.listTools(),
|
||||
...logTools.listTools(),
|
||||
...adTools.listTools(),
|
||||
...metadataTools.listTools(),
|
||||
...configTools.listTools(),
|
||||
...profileTools.listTools(),
|
||||
];
|
||||
|
||||
// Servi depuis la table de routage : la liste exposée et le dispatch ne
|
||||
// peuvent pas diverger.
|
||||
return {
|
||||
tools: allTools,
|
||||
tools: Array.from(toolRegistry.values(), entry => entry.definition),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -157,37 +217,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
try {
|
||||
console.error(`[Server] Executing tool: ${name}`);
|
||||
|
||||
// Route to the appropriate handler based on tool name
|
||||
if (name.startsWith('search_workflows') ||
|
||||
name.startsWith('get_workflow_') ||
|
||||
name.startsWith('list_workflow_')) {
|
||||
return await workflowTools.executeTool(name, args);
|
||||
} else if (name.startsWith('query_wms_') ||
|
||||
name.startsWith('count_wms_') ||
|
||||
name.startsWith('get_entity_') ||
|
||||
name.startsWith('search_wms_')) {
|
||||
return await wmsQueryTools.executeTool(name, args);
|
||||
} else if (name.startsWith('call_query_api') ||
|
||||
name.startsWith('execute_command')) {
|
||||
return await apiTools.executeTool(name, args);
|
||||
} else if (name.includes('_logs')) {
|
||||
return await logTools.executeTool(name, args);
|
||||
} else if (name.startsWith('get_application_') ||
|
||||
name.startsWith('get_ad_') ||
|
||||
name.startsWith('search_ad_') ||
|
||||
name.startsWith('list_ad_')) {
|
||||
return await adTools.executeTool(name, args);
|
||||
} else if (name === 'get_entity_metadata' || name === 'generic_search') {
|
||||
return await metadataTools.executeTool(name, args);
|
||||
} else if (name === 'get_system_parameters') {
|
||||
return await configTools.executeTool(name, args);
|
||||
} else if (name === 'list_wms_profiles' ||
|
||||
name === 'get_current_wms_profile' ||
|
||||
name === 'switch_wms_profile') {
|
||||
return await profileTools.executeTool(name, args);
|
||||
} else {
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
const entry = toolRegistry.get(name);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Unknown tool: ${name}. Available tools: ${Array.from(toolRegistry.keys()).join(', ')}`
|
||||
);
|
||||
}
|
||||
validateToolArgs(entry.definition, args);
|
||||
return await entry.module.executeTool(name, args);
|
||||
} catch (error) {
|
||||
console.error(`[Server] Error executing tool ${name}:`, error.message);
|
||||
return {
|
||||
|
||||
+63
-98
@@ -42,9 +42,13 @@ function getAPICatalog() {
|
||||
|
||||
The WMS provides several REST APIs for querying and modifying data.
|
||||
|
||||
**Base URL:** \`${process.env.WMS_API_BASE_URL || 'https://10.255.255.2/ApplicationService/api'}\`
|
||||
**Base URL:** \`https://<host>/ApplicationService/api\` — built from the active
|
||||
profile's host (see \`get_current_wms_profile\`).
|
||||
**Authentication:** OAuth 2.0 Bearer Token (automatic)
|
||||
|
||||
The generated help page at \`https://<host>/ApplicationService/Help\` is the
|
||||
authoritative reference for endpoints and fields.
|
||||
|
||||
---
|
||||
|
||||
## Query API
|
||||
@@ -60,44 +64,55 @@ Execute LINQ queries against WMS entities.
|
||||
\`\`\`json
|
||||
{
|
||||
"Application": "EasyWMS",
|
||||
"QueryType": 1,
|
||||
"Expression": "Context.{EntityType}.Select(z => z)"
|
||||
"QueryType": 0,
|
||||
"Expression": "Context.Products.Where(z => z.Code == \\"X\\").OrderBy(z => z.Id)",
|
||||
"Take": 100
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Supported Entity Types
|
||||
Rules (see the query tools for details):
|
||||
|
||||
| Entity Type | Description |
|
||||
|-------------|-------------|
|
||||
| Products | Product references and SKUs |
|
||||
| Containers | Pallets, boxes, and container types |
|
||||
| Stocks | Available inventory by location |
|
||||
| ProductLocations | Product placement in warehouse |
|
||||
| Tasks | WMS tasks (picks, puts, moves, etc.) |
|
||||
| Accounts | Customer accounts |
|
||||
| Suppliers | Supplier information |
|
||||
| Kits | Product kits and bundles |
|
||||
| Aliases | Product aliases and alternative codes |
|
||||
| InboundOrders | Inbound/receiving orders |
|
||||
| Receptions | Actual receptions |
|
||||
| OutboundOrders | Outbound/shipping orders |
|
||||
- **\`QueryType: 0\` (Reading) is the default** — status fields are strings
|
||||
(\`"Release"\`). \`QueryType: 1\` (Writing) exists but status fields become
|
||||
enums there: string comparisons fail. Old examples using \`1\` must not be
|
||||
copied. The query tools expose this as the opt-in \`query_type\` parameter.
|
||||
- **\`Where\` and \`OrderBy\` go in the Expression; \`Take\`/\`Skip\` are API
|
||||
parameters.** \`OrderBy\` is mandatory as soon as \`Take\` is used.
|
||||
- **No \`Select\` projections** — the \`Select\` parameter causes server-side
|
||||
compile errors. Query full rows.
|
||||
- **No relative dates** (\`DateTime.Now\`, \`AddDays()\`) — write literal dates:
|
||||
\`new DateTime(2026, 8, 1)\`.
|
||||
|
||||
### Example Queries
|
||||
### Entity Types
|
||||
|
||||
Common entities: Products, Containers, Stocks, ProductLocations, Tasks,
|
||||
Accounts, Suppliers, Kits, Alias (invariant — no plural form), InboundOrders,
|
||||
Receptions, OutboundOrders.
|
||||
|
||||
**The authoritative list (288 entities) comes from \`get_entity_metadata\`**
|
||||
(Metadata API) — entity names are resolved case-insensitively from the AD name
|
||||
(Container) or the TableName (Containers).
|
||||
|
||||
### Example Expressions
|
||||
|
||||
\`\`\`
|
||||
# Get all products (limited)
|
||||
Context.Products.Take(100).Select(z => z)
|
||||
# Filter + mandatory OrderBy (Take passed as API parameter, not in the expression)
|
||||
Context.Products.Where(z => z.Code.Contains("ABC")).OrderBy(z => z.Id)
|
||||
|
||||
# Get specific fields
|
||||
Context.Products.Select(z => new { z.Id, z.Code, z.Name })
|
||||
|
||||
# Filter and select
|
||||
Context.Tasks.Where(z => z.Status == "Pending").Take(50).Select(z => z)
|
||||
# Status comparison — strings in Reading (QueryType 0)
|
||||
Context.OutboundOrders.Where(z => z.OutboundOrderStatus == "Release").OrderBy(z => z.Id)
|
||||
\`\`\`
|
||||
|
||||
### MCP Tool
|
||||
### Counting
|
||||
|
||||
Use \`call_query_api\` tool to execute queries.
|
||||
**Endpoint:** \`/api/QueryScalarExecute\` — same body, expression ends with
|
||||
\`.Count()\` / \`.Sum(...)\`. Prefer the \`count_wms_entities\` tool for any
|
||||
"how many" question.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
\`query_wms_entities\`, \`count_wms_entities\`, \`call_query_api\`,
|
||||
\`get_entity_schema\`, \`search_wms_data\`.
|
||||
|
||||
---
|
||||
|
||||
@@ -114,31 +129,7 @@ Execute commands to modify WMS data.
|
||||
\`\`\`json
|
||||
[
|
||||
{
|
||||
"Name": "CommandName, Mecalux.ITSW.EasyWMS.Modules.Contracts",
|
||||
"Properties": {
|
||||
"PropertyName": "value"
|
||||
}
|
||||
}
|
||||
]
|
||||
\`\`\`
|
||||
|
||||
### Common Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| ProductRemoveCommand | Remove a product |
|
||||
| ProductUpdateCommand | Update product information |
|
||||
| ContainerCreateCommand | Create a new container |
|
||||
| TaskCancelCommand | Cancel a task |
|
||||
| InboundOrderCancelCommandV2 | Cancel an inbound order |
|
||||
| OutboundOrderCancelCommand | Cancel an outbound order |
|
||||
|
||||
### Example Command
|
||||
|
||||
\`\`\`json
|
||||
[
|
||||
{
|
||||
"Name": "Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ProductRemoveCommand, Mecalux.ITSW.EasyWMS.Modules.Contracts",
|
||||
"Name": "Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ProductRemoveCommand",
|
||||
"Properties": {
|
||||
"Id": "product-guid-here"
|
||||
}
|
||||
@@ -146,6 +137,11 @@ Execute commands to modify WMS data.
|
||||
]
|
||||
\`\`\`
|
||||
|
||||
**\`Name\` is the \`InternalCommandName\` from the Application Dictionary, used
|
||||
as-is.** Never append an assembly suffix (\`, Mecalux.ITSW...Contracts\`) — it
|
||||
causes a \`FileLoadException\`. Retrieve the exact name via
|
||||
\`get_ad_element_details\` before executing.
|
||||
|
||||
### MCP Tool
|
||||
|
||||
Use \`execute_command\` tool to execute commands.
|
||||
@@ -154,7 +150,7 @@ Use \`execute_command\` tool to execute commands.
|
||||
|
||||
---
|
||||
|
||||
## Workflow API
|
||||
## Workflow API (Application Dictionary)
|
||||
|
||||
Retrieve workflow definitions by application.
|
||||
|
||||
@@ -165,30 +161,23 @@ Retrieve workflow definitions by application.
|
||||
### Request Format
|
||||
|
||||
\`\`\`json
|
||||
["EasyWMS", "AD", 5000, 0]
|
||||
["EasyWMS", "<tenant>", 5000, 0]
|
||||
\`\`\`
|
||||
|
||||
Parameters:
|
||||
1. Application name (e.g., "EasyWMS")
|
||||
2. Tenant code (e.g., "AD")
|
||||
3. Page size (e.g., 5000)
|
||||
4. Offset (e.g., 0 for first page)
|
||||
Parameters (positional): application name, tenant code, page size, offset.
|
||||
|
||||
### Response
|
||||
|
||||
Array of workflow objects with:
|
||||
- Id, Code, Name
|
||||
- Category, Description
|
||||
- Version, Status
|
||||
- Created, Modified
|
||||
- Definition (JSON)
|
||||
An envelope object \`{ "entities": [...] }\` — **not** a bare array. Each
|
||||
workflow object carries lowercase keys: \`id\`, \`name\`, \`version\`,
|
||||
\`applicationName\`, \`commonInfo\` (createdBy, createDate, updateDate). There
|
||||
is no category, code or description field.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
Use workflow tools to interact with workflows:
|
||||
- \`search_workflows\` - Search by name, code, description
|
||||
- \`search_workflows\` - Search by name
|
||||
- \`get_workflow_details\` - Get full workflow definition
|
||||
- \`list_workflow_categories\` - List all categories
|
||||
- \`list_workflow_categories\` - List applications (workflows have no category field)
|
||||
|
||||
---
|
||||
|
||||
@@ -197,13 +186,13 @@ Use workflow tools to interact with workflows:
|
||||
All APIs use OAuth 2.0 authentication.
|
||||
|
||||
**Token Endpoint:** \`/EasySTS/OAuth/Token\`
|
||||
**Grant Types:** password, refresh_token
|
||||
**Grant Types:** password, refresh_token (\`tenant_code\` is mandatory)
|
||||
|
||||
### Token Management
|
||||
|
||||
- Tokens expire after ~1200 seconds
|
||||
- Automatic refresh when < 1000 seconds remaining
|
||||
- Credentials configured in .env file
|
||||
- Credentials come from the active profile (multi-profile \`.env\`)
|
||||
|
||||
The MCP server handles authentication automatically.
|
||||
|
||||
@@ -217,16 +206,10 @@ The MCP server handles authentication automatically.
|
||||
- \`400\` - Bad request (invalid query/command)
|
||||
- \`401\` - Unauthorized (token expired or invalid)
|
||||
- \`403\` - Forbidden (insufficient permissions)
|
||||
- \`500\` - Internal server error
|
||||
- \`500\` - Internal server error (incl. LINQ compile errors)
|
||||
|
||||
### Error Response Format
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"error": "Error message",
|
||||
"details": "Detailed error information"
|
||||
}
|
||||
\`\`\`
|
||||
The response body of a 500 carries the real diagnostic (e.g. the compile
|
||||
error naming the context) — MCP tools surface it in their error messages.
|
||||
|
||||
---
|
||||
|
||||
@@ -238,24 +221,6 @@ The MCP server handles authentication automatically.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
API settings are configured via environment variables:
|
||||
|
||||
\`\`\`env
|
||||
WMS_API_BASE_URL=https://10.255.255.2/ApplicationService/api
|
||||
WMS_API_TOKEN_URL=https://10.255.255.2/EasySTS/OAuth/Token
|
||||
WMS_API_TENANT=AD
|
||||
WMS_API_USERNAME=your-username
|
||||
WMS_API_PASSWORD=your-password
|
||||
WORKFLOW_API_BASE=https://10.255.255.2/AD/api
|
||||
WORKFLOW_PAGE_SIZE=5000
|
||||
MAX_QUERY_ROWS=1000
|
||||
QUERY_TIMEOUT=30000
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
**Note:** Use MCP tools to interact with these APIs. Direct API calls require proper authentication handling.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Resources MCP pour la documentation
|
||||
* Permet à Claude d'accéder à la documentation structurée en fichiers Markdown
|
||||
*/
|
||||
|
||||
// Chemin vers le dossier de documentation
|
||||
const DOCS_PATH = process.env.DOCS_PATH || path.join(__dirname, '..', '..', 'docs');
|
||||
|
||||
/**
|
||||
* Scanne récursivement un dossier pour trouver tous les fichiers .md
|
||||
* @param {string} dir - Dossier à scanner
|
||||
* @param {string} baseDir - Dossier de base pour les chemins relatifs
|
||||
* @returns {Promise<Array>} - Liste des fichiers .md
|
||||
*/
|
||||
async function scanMarkdownFiles(dir, baseDir = dir) {
|
||||
let files = [];
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Récursion dans les sous-dossiers
|
||||
const subFiles = await scanMarkdownFiles(fullPath, baseDir);
|
||||
files = files.concat(subFiles);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
// Fichier Markdown trouvé
|
||||
const relativePath = path.relative(baseDir, fullPath);
|
||||
files.push({
|
||||
name: entry.name,
|
||||
path: fullPath,
|
||||
relativePath: relativePath.replace(/\\/g, '/'), // Normaliser les slashes
|
||||
uri: `docs://${relativePath.replace(/\\/g, '/')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Dossier n'existe pas ou erreur de lecture
|
||||
console.error(`Error scanning directory ${dir}:`, err.message);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les resources disponibles pour la documentation
|
||||
* @returns {Promise<Array>} - Liste des resources
|
||||
*/
|
||||
async function listResources() {
|
||||
try {
|
||||
// Scanner les fichiers Markdown
|
||||
const mdFiles = await scanMarkdownFiles(DOCS_PATH);
|
||||
|
||||
const resources = [
|
||||
{
|
||||
uri: 'docs://index',
|
||||
name: 'Documentation Index',
|
||||
description: 'Sommaire de toute la documentation disponible',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
];
|
||||
|
||||
// Ajouter chaque fichier .md comme resource
|
||||
mdFiles.forEach((file) => {
|
||||
resources.push({
|
||||
uri: file.uri,
|
||||
name: file.name.replace('.md', ''),
|
||||
description: `Documentation: ${file.relativePath}`,
|
||||
mimeType: 'text/markdown',
|
||||
});
|
||||
});
|
||||
|
||||
return resources;
|
||||
} catch (err) {
|
||||
console.error('Error listing documentation resources:', err);
|
||||
return [
|
||||
{
|
||||
uri: 'docs://index',
|
||||
name: 'Documentation Index',
|
||||
description: 'Sommaire de toute la documentation disponible',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un index/sommaire de la documentation
|
||||
* @returns {Promise<string>} - Markdown avec le sommaire
|
||||
*/
|
||||
async function generateIndex() {
|
||||
try {
|
||||
const mdFiles = await scanMarkdownFiles(DOCS_PATH);
|
||||
|
||||
if (mdFiles.length === 0) {
|
||||
return `# Documentation\n\n*Aucun fichier de documentation trouvé dans \`${DOCS_PATH}\`*\n\n` +
|
||||
`Pour ajouter de la documentation :\n` +
|
||||
`1. Créez un dossier \`docs\` à la racine du projet\n` +
|
||||
`2. Ajoutez vos fichiers .md (organisation libre avec sous-dossiers)\n` +
|
||||
`3. Redémarrez le serveur MCP\n`;
|
||||
}
|
||||
|
||||
let markdown = '# Documentation WMS\n\n';
|
||||
markdown += `**${mdFiles.length} fichiers de documentation disponibles**\n\n`;
|
||||
markdown += `📁 Emplacement : \`${DOCS_PATH}\`\n\n`;
|
||||
|
||||
// Grouper par dossier
|
||||
const grouped = {};
|
||||
mdFiles.forEach((file) => {
|
||||
const dir = path.dirname(file.relativePath);
|
||||
const folder = dir === '.' ? '📄 Racine' : `📁 ${dir}`;
|
||||
|
||||
if (!grouped[folder]) {
|
||||
grouped[folder] = [];
|
||||
}
|
||||
grouped[folder].push(file);
|
||||
});
|
||||
|
||||
// Générer le sommaire
|
||||
markdown += '## Sommaire\n\n';
|
||||
|
||||
Object.keys(grouped).sort().forEach((folder) => {
|
||||
markdown += `### ${folder}\n\n`;
|
||||
|
||||
grouped[folder].forEach((file) => {
|
||||
markdown += `- **${file.name.replace('.md', '')}** - \`${file.uri}\`\n`;
|
||||
});
|
||||
|
||||
markdown += '\n';
|
||||
});
|
||||
|
||||
markdown += '---\n\n';
|
||||
markdown += '*Pour lire un fichier, demandez à Claude de lire la resource correspondante (par exemple: "Lis la documentation X")*\n';
|
||||
|
||||
return markdown;
|
||||
} catch (err) {
|
||||
return `# Erreur\n\nImpossible de générer l'index de documentation: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un fichier de documentation
|
||||
* @param {string} relativePath - Chemin relatif du fichier
|
||||
* @returns {Promise<string>} - Contenu Markdown du fichier
|
||||
*/
|
||||
async function readDocFile(relativePath) {
|
||||
try {
|
||||
const filePath = path.join(DOCS_PATH, relativePath);
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
return content;
|
||||
} catch (err) {
|
||||
return `# Erreur\n\nImpossible de lire le fichier \`${relativePath}\`: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit une resource documentation selon son URI
|
||||
* @param {string} uri - URI de la resource (format: docs://path/to/file.md)
|
||||
* @returns {Promise<Object>} - Contenu de la resource
|
||||
*/
|
||||
async function readResource(uri) {
|
||||
let content;
|
||||
|
||||
if (uri === 'docs://index') {
|
||||
content = await generateIndex();
|
||||
} else if (uri.startsWith('docs://')) {
|
||||
// Extraire le chemin relatif de l'URI
|
||||
const relativePath = uri.replace('docs://', '');
|
||||
content = await readDocFile(relativePath);
|
||||
} else {
|
||||
throw new Error(`Unknown documentation resource: ${uri}`);
|
||||
}
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: 'text/markdown',
|
||||
text: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listResources,
|
||||
readResource,
|
||||
};
|
||||
@@ -42,6 +42,20 @@ function getQueryExamples() {
|
||||
> (QueryType=Reading). Status/enum fields are **strings** (enum names), never integers.
|
||||
> Always verify enum values via \`docs://entities/\` or \`get_entity_metadata\` before filtering.
|
||||
|
||||
## Entity names — singular AD name or TableName, both accepted
|
||||
|
||||
\`entity_type\` is resolved case-insensitively against the Metadata API: the AD
|
||||
entity name (singular) and the TableName both work. The mapping is **not** a
|
||||
pluralisation rule — only the Metadata \`TableName\` is authoritative:
|
||||
|
||||
\`\`\`
|
||||
query_wms_entities(entity_type="Container") # AD name -> resolved to Containers
|
||||
query_wms_entities(entity_type="Containers") # TableName -> used as-is
|
||||
query_wms_entities(entity_type="Alias") # invariant: TableName IS "Alias" (no plural)
|
||||
query_wms_entities(entity_type="Item") # fails fast: not in the Reading model,
|
||||
# error lists close matches + get_entity_metadata
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Diagnostic Recipes
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Enveloppe des API AD — un vide anormal n'est pas un vide (D27)
|
||||
*
|
||||
* Les API AD renvoient `{ entities: [...] }` (D4). Les services lisaient
|
||||
* `response?.entities || []` : toute réponse d'une **autre forme** (pas de
|
||||
* champ `entities`, corps vide, objet d'erreur) devenait un tableau vide,
|
||||
* indistinguable d'une page finale légitime — donc mise en cache avec un
|
||||
* timestamp valide. Un cache vide empoisonné pour tout le TTL, sans le
|
||||
* moindre message.
|
||||
*
|
||||
* Deux cas, deux traitements :
|
||||
*
|
||||
* | Réponse | Traitement |
|
||||
* |---|---|
|
||||
* | `{ entities: [...] }`, y compris `[]` réel | rendue telle quelle — une application peut être légitimement vide (`SmartUI` : 0 workflow, D26) |
|
||||
* | tout le reste | **lève** — l'appel échoue, rien n'est mis en cache, l'appel suivant refetche |
|
||||
*
|
||||
* Volontairement sans retry ni résilience : le but est de rendre l'anomalie
|
||||
* **visible et non persistante**, pas de la rattraper.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Décrit la forme reçue, pour un message d'erreur exploitable (convention 4).
|
||||
*/
|
||||
function describeShape(response) {
|
||||
if (response === null) return 'null';
|
||||
if (response === undefined) return 'undefined';
|
||||
if (Array.isArray(response)) return `un tableau nu de ${response.length} élément(s)`;
|
||||
if (typeof response !== 'object') return `un ${typeof response}`;
|
||||
const keys = Object.keys(response);
|
||||
if (keys.length === 0) return 'un objet vide';
|
||||
return `un objet sans champ "entities" (champs reçus : ${keys.slice(0, 10).join(', ')})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le tableau `entities` d'une réponse d'API AD, ou lève.
|
||||
*
|
||||
* @param {any} response - la réponse brute de `apiService.post(..., true)`
|
||||
* @param {string} context - l'appel concerné, pour le message d'erreur
|
||||
* (ex. `Workflow/GetByApplication (application "EasyWMS", offset 0)`)
|
||||
* @returns {Array} le tableau `entities`, éventuellement vide
|
||||
* @throws {Error} si la réponse n'a pas la forme `{ entities: [...] }`
|
||||
*/
|
||||
function requireEntities(response, context) {
|
||||
const entities = response ? response.entities : undefined;
|
||||
|
||||
if (!Array.isArray(entities)) {
|
||||
throw new Error(
|
||||
`Réponse inattendue de l'API AD sur ${context} : ${describeShape(response)}, ` +
|
||||
`au lieu de l'enveloppe attendue { entities: [...] }. ` +
|
||||
`Rien n'a été mis en cache — relancez l'appel. ` +
|
||||
`Si l'erreur persiste, l'API AD est en défaut (elle échoue notamment sous appels concurrents nombreux).`
|
||||
);
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
module.exports = { requireEntities };
|
||||
+113
-60
@@ -6,12 +6,32 @@
|
||||
|
||||
const apiService = require('./api-service').getInstance();
|
||||
const profileManager = require('../config/profile-manager');
|
||||
const { createSingleFlight } = require('./single-flight');
|
||||
const { requireEntities } = require('./ad-envelope');
|
||||
|
||||
// Cache state - one cache per element type
|
||||
// Cache state - one cache per (application, element type) (D26)
|
||||
const cache = {};
|
||||
const cacheTimestamps = {};
|
||||
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour
|
||||
|
||||
// Déduplication des chargements concurrents, une clé par (application, type) (D27).
|
||||
const singleFlight = createSingleFlight('AD');
|
||||
|
||||
/**
|
||||
* Application effective : celle demandée, sinon celle du profil actif.
|
||||
*/
|
||||
function resolveApplication(application) {
|
||||
return (application && application.trim()) || profileManager.getCurrent().application;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clé de cache composite (D26) — sans elle, un appel CustomApp polluerait le
|
||||
* cache EasyWMS du même type.
|
||||
*/
|
||||
function cacheKey(application, elementType) {
|
||||
return `${application}::${elementType}`;
|
||||
}
|
||||
|
||||
// Invalidate all caches when profile changes — AD elements are per-tenant.
|
||||
profileManager.onSwitch(() => invalidateCache());
|
||||
|
||||
@@ -44,60 +64,87 @@ const AD_ELEMENT_TYPES = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if cache is valid for a given element type
|
||||
* Check if cache is valid for a given (application, element type)
|
||||
*/
|
||||
function isCacheValid(elementType) {
|
||||
if (!cache[elementType] || !cacheTimestamps[elementType]) {
|
||||
function isCacheValid(application, elementType) {
|
||||
const key = cacheKey(application, elementType);
|
||||
if (!cache[key] || !cacheTimestamps[key]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const age = now - cacheTimestamps[elementType];
|
||||
const age = now - cacheTimestamps[key];
|
||||
return age < CACHE_TTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all elements of a specific type from AD API
|
||||
* Implements lazy loading with caching and pagination
|
||||
* Implements lazy loading with caching and pagination.
|
||||
* Lazy par application (D26) : seule l'application demandée est chargée.
|
||||
*
|
||||
* @param {string} elementType - Type of element (Command, Query, Dialog, etc.)
|
||||
* @param {string} [application] - Application AD (défaut : profil actif)
|
||||
* @returns {Promise<Array>} Array of elements
|
||||
*/
|
||||
async function getElements(elementType) {
|
||||
async function getElements(elementType, application) {
|
||||
// Validate element type
|
||||
if (!AD_ELEMENT_TYPES[elementType]) {
|
||||
throw new Error(`Unknown element type: ${elementType}. Valid types: ${Object.keys(AD_ELEMENT_TYPES).join(', ')}`);
|
||||
}
|
||||
|
||||
const app = resolveApplication(application);
|
||||
const key = cacheKey(app, elementType);
|
||||
|
||||
// Check cache
|
||||
if (isCacheValid(elementType)) {
|
||||
console.error(`[AD] Cache hit: ${elementType} (${cache[elementType].length} elements)`);
|
||||
return cache[elementType];
|
||||
if (isCacheValid(app, elementType)) {
|
||||
console.error(`[AD] Cache hit: ${key} (${cache[key].length} elements)`);
|
||||
return cache[key];
|
||||
}
|
||||
|
||||
console.error(`[AD] Cache expired or empty, fetching ${elementType}...`);
|
||||
// Un seul chargement par (application, type), même sous rafale, et
|
||||
// publication refusée si le cache a été invalidé pendant le fetch (D27).
|
||||
return singleFlight.run(
|
||||
key,
|
||||
() => loadElements(app, elementType, key),
|
||||
(elements) => {
|
||||
cache[key] = elements;
|
||||
cacheTimestamps[key] = Date.now();
|
||||
console.error(`[AD] Successfully cached ${elements.length} ${key}`);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chargement réel d'un (application, type) (pagination complète).
|
||||
* Appelé au plus une fois par clé tant qu'il est en vol (D27).
|
||||
* N'écrit RIEN en cache : la publication est le `commit` de singleFlight.run.
|
||||
*/
|
||||
async function loadElements(app, elementType, key) {
|
||||
console.error(`[AD] Cache expired or empty, fetching ${key}...`);
|
||||
|
||||
try {
|
||||
let allElements = [];
|
||||
let offset = 0;
|
||||
const pageSize = AD_ELEMENT_TYPES[elementType];
|
||||
const profile = profileManager.getCurrent();
|
||||
const application = profile.application;
|
||||
const tenant = profile.tenant;
|
||||
const tenant = profileManager.getCurrent().tenant;
|
||||
|
||||
while (true) {
|
||||
const body = [application, tenant, pageSize, offset];
|
||||
const body = [app, tenant, pageSize, offset];
|
||||
|
||||
console.error(`[AD] Fetching ${elementType}: offset=${offset}, pageSize=${pageSize}`);
|
||||
console.error(`[AD] Fetching ${key}: offset=${offset}, pageSize=${pageSize}`);
|
||||
|
||||
// Use AD API (useAdApi=true)
|
||||
const response = await apiService.post(`/${elementType}/GetByApplication`, body, true);
|
||||
|
||||
// Extract entities array from response
|
||||
const elements = response?.entities || [];
|
||||
// Une réponse hors enveloppe { entities: [...] } lève au lieu de se
|
||||
// faire passer pour une page vide (D27).
|
||||
const elements = requireEntities(
|
||||
response,
|
||||
`${elementType}/GetByApplication (application "${app}", offset ${offset})`
|
||||
);
|
||||
|
||||
// Check if response is valid
|
||||
if (!elements || elements.length === 0) {
|
||||
// Vide réel : fin de pagination (3 types sont valides mais vides, D17).
|
||||
if (elements.length === 0) {
|
||||
console.error(`[AD] No more ${elementType} to fetch`);
|
||||
break;
|
||||
}
|
||||
@@ -114,15 +161,10 @@ async function getElements(elementType) {
|
||||
offset += pageSize;
|
||||
}
|
||||
|
||||
// Update cache
|
||||
cache[elementType] = allElements;
|
||||
cacheTimestamps[elementType] = Date.now();
|
||||
|
||||
console.error(`[AD] Successfully cached ${allElements.length} ${elementType}`);
|
||||
return allElements;
|
||||
} catch (error) {
|
||||
console.error(`[AD] Error fetching ${elementType}:`, error.message);
|
||||
throw new Error(`Failed to fetch ${elementType}: ${error.message}`);
|
||||
console.error(`[AD] Error fetching ${key}:`, error.message);
|
||||
throw new Error(`Failed to fetch ${elementType} for application "${app}": ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,9 +173,10 @@ async function getElements(elementType) {
|
||||
* @param {string} elementType - Type of element
|
||||
* @param {string} query - Search query (matches name, description, etc.)
|
||||
* @param {number} limit - Maximum results to return
|
||||
* @param {string} [application] - Application AD (défaut : profil actif)
|
||||
*/
|
||||
async function searchElements(elementType, query, limit = 50) {
|
||||
const elements = await getElements(elementType);
|
||||
async function searchElements(elementType, query, limit = 50, application) {
|
||||
const elements = await getElements(elementType, application);
|
||||
|
||||
if (!query) {
|
||||
return elements.slice(0, limit);
|
||||
@@ -157,9 +200,10 @@ async function searchElements(elementType, query, limit = 50) {
|
||||
* Get element details by ID or name
|
||||
* @param {string} elementType - Type of element
|
||||
* @param {string|number} elementId - Element ID or name
|
||||
* @param {string} [application] - Application AD (défaut : profil actif)
|
||||
*/
|
||||
async function getElementDetails(elementType, elementId) {
|
||||
const elements = await getElements(elementType);
|
||||
async function getElementDetails(elementType, elementId, application) {
|
||||
const elements = await getElements(elementType, application);
|
||||
|
||||
// Try to find by Id, id, Code, code, Name, or name
|
||||
const element = elements.find(e =>
|
||||
@@ -174,67 +218,75 @@ async function getElementDetails(elementType, elementId) {
|
||||
);
|
||||
|
||||
if (!element) {
|
||||
throw new Error(`${elementType} not found: ${elementId}`);
|
||||
const app = resolveApplication(application);
|
||||
throw new Error(
|
||||
`${elementType} not found: ${elementId} (application "${app}"). ` +
|
||||
`Utilisez search_ad_elements — pensez au paramètre application ` +
|
||||
`(ex: "CustomApp" pour le spécifique client).`
|
||||
);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get application summary (count of each element type)
|
||||
* Only loads types that are already cached to avoid long wait times
|
||||
* Get application summary — état des caches par (application, type) (D26).
|
||||
* Seules les entrées effectivement en cache sont détaillées, pour rester
|
||||
* borné quel que soit le nombre d'applications interrogées (D24).
|
||||
* @returns {Object} application -> type -> { count, cacheAge }
|
||||
*/
|
||||
function getApplicationSummary() {
|
||||
const summary = {};
|
||||
const byApplication = {};
|
||||
|
||||
Object.keys(AD_ELEMENT_TYPES).forEach(type => {
|
||||
if (cache[type]) {
|
||||
summary[type] = {
|
||||
count: cache[type].length,
|
||||
cached: true,
|
||||
cacheAge: cacheTimestamps[type] ? Math.floor((Date.now() - cacheTimestamps[type]) / 1000) : null
|
||||
Object.keys(cache).forEach(key => {
|
||||
const [app, type] = key.split('::');
|
||||
if (!byApplication[app]) byApplication[app] = {};
|
||||
byApplication[app][type] = {
|
||||
count: cache[key].length,
|
||||
cacheAge: cacheTimestamps[key] ? Math.floor((Date.now() - cacheTimestamps[key]) / 1000) : null
|
||||
};
|
||||
} else {
|
||||
summary[type] = {
|
||||
count: 0,
|
||||
cached: false,
|
||||
cacheAge: null
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return summary;
|
||||
return byApplication;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a specific type or all types
|
||||
* Invalidate cache for a specific type (across all applications) or all types
|
||||
*/
|
||||
function invalidateCache(elementType = null) {
|
||||
if (elementType) {
|
||||
delete cache[elementType];
|
||||
delete cacheTimestamps[elementType];
|
||||
Object.keys(cache)
|
||||
.filter(k => k.endsWith(`::${elementType}`))
|
||||
.forEach(k => {
|
||||
delete cache[k];
|
||||
delete cacheTimestamps[k];
|
||||
});
|
||||
singleFlight.invalidate();
|
||||
console.error(`[AD] Cache invalidated: ${elementType}`);
|
||||
} else {
|
||||
Object.keys(cache).forEach(k => {
|
||||
delete cache[k];
|
||||
delete cacheTimestamps[k];
|
||||
});
|
||||
singleFlight.invalidate();
|
||||
console.error('[AD] All caches invalidated');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache status
|
||||
* Get cache status, par application puis type (D26)
|
||||
*/
|
||||
function getCacheStatus() {
|
||||
const status = {};
|
||||
Object.keys(AD_ELEMENT_TYPES).forEach(type => {
|
||||
status[type] = {
|
||||
cached: !!cache[type],
|
||||
count: cache[type] ? cache[type].length : 0,
|
||||
timestamp: cacheTimestamps[type],
|
||||
age: cacheTimestamps[type] ? Math.floor((Date.now() - cacheTimestamps[type]) / 1000) : null,
|
||||
valid: isCacheValid(type)
|
||||
Object.keys(cache).forEach(key => {
|
||||
const [app, type] = key.split('::');
|
||||
if (!status[app]) status[app] = {};
|
||||
status[app][type] = {
|
||||
cached: true,
|
||||
count: cache[key].length,
|
||||
timestamp: cacheTimestamps[key],
|
||||
age: cacheTimestamps[key] ? Math.floor((Date.now() - cacheTimestamps[key]) / 1000) : null,
|
||||
valid: isCacheValid(app, type)
|
||||
};
|
||||
});
|
||||
return status;
|
||||
@@ -248,6 +300,7 @@ function getAvailableTypes() {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveApplication,
|
||||
getElements,
|
||||
searchElements,
|
||||
getElementDetails,
|
||||
|
||||
+105
-16
@@ -77,8 +77,12 @@ class APIService {
|
||||
console.error(`[API] Authentication successful. Token expires in ~${this.tokenMaxAge}s`);
|
||||
return this.token;
|
||||
} catch (error) {
|
||||
console.error('[API] Authentication failed:', error.message);
|
||||
throw new Error(`Authentication failed: ${error.message}`);
|
||||
// Statut + corps de la réponse STS dans le message : c'est là que vit le
|
||||
// diagnostic ("Tenant not found", ...). Payload volontairement omis — il
|
||||
// contient les credentials ; _enrichHttpError n'inclut jamais les headers.
|
||||
const enriched = this._enrichHttpError(error, 'POST', profile.tokenUrl, undefined);
|
||||
console.error('[API] Authentication failed:', enriched.message);
|
||||
throw new Error(`Authentication failed: ${enriched.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,17 +92,17 @@ class APIService {
|
||||
async refreshOAuthToken() {
|
||||
const tokenAge = this.getTokenAge();
|
||||
|
||||
try {
|
||||
// If token is too old (>= maxAge), use password grant
|
||||
if (tokenAge >= this.tokenMaxAge) {
|
||||
console.error('[API] Token too old, re-authenticating with password...');
|
||||
return await this.authenticate();
|
||||
}
|
||||
|
||||
const profile = profileManager.getCurrent();
|
||||
try {
|
||||
// Otherwise use refresh_token grant
|
||||
console.error('[API] Refreshing token with refresh_token grant...');
|
||||
|
||||
const profile = profileManager.getCurrent();
|
||||
const response = await this.httpClient.post(
|
||||
profile.tokenUrl,
|
||||
new URLSearchParams({
|
||||
@@ -120,7 +124,10 @@ class APIService {
|
||||
console.error('[API] Token refreshed successfully');
|
||||
return this.token;
|
||||
} catch (error) {
|
||||
console.error('[API] Token refresh failed, re-authenticating:', error.message);
|
||||
// Même enrichissement que authenticate() : statut + corps STS, sans le
|
||||
// payload (refresh_token) ni les headers.
|
||||
const enriched = this._enrichHttpError(error, 'POST', profile.tokenUrl, undefined);
|
||||
console.error('[API] Token refresh failed, re-authenticating:', enriched.message);
|
||||
return await this.authenticate();
|
||||
}
|
||||
}
|
||||
@@ -136,6 +143,71 @@ class APIService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the useful part of an HTTP error response body.
|
||||
* Structured WMS errors carry the diagnostic in Message / InnerException.Message —
|
||||
* a full JSON.stringify would drown it in WatsonBuckets / HResult noise.
|
||||
* @param {*} data - Response body (object, string, or anything axios parsed)
|
||||
* @returns {string|null} Truncated human-readable body, or null if empty
|
||||
*/
|
||||
_describeResponseBody(data) {
|
||||
const MAX_BODY_LENGTH = 2000;
|
||||
if (data == null || data === '') return null;
|
||||
if (typeof data === 'string') return data.slice(0, MAX_BODY_LENGTH);
|
||||
if (typeof data === 'object') {
|
||||
const parts = [];
|
||||
if (data.ClassName) parts.push(data.ClassName);
|
||||
if (data.Message) parts.push(data.Message);
|
||||
let inner = data.InnerException;
|
||||
while (inner && inner.Message) {
|
||||
// AggregateException répète souvent le même message dans InnerException
|
||||
if (inner.Message !== data.Message) parts.push(`Inner: ${inner.Message}`);
|
||||
inner = inner.InnerException;
|
||||
}
|
||||
const text = parts.length > 0 ? parts.join(' — ') : JSON.stringify(data);
|
||||
return text.slice(0, MAX_BODY_LENGTH);
|
||||
}
|
||||
return String(data).slice(0, MAX_BODY_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an enriched Error from a failed HTTP call: status, verb, full URL,
|
||||
* request payload and response body. The WMS puts the real diagnostic
|
||||
* (compile errors, unknown entity, ...) in the response body — without this,
|
||||
* every failure reads "Request failed with status code 500".
|
||||
* Never includes headers (Bearer token) — payloads passed through post/get
|
||||
* carry no credentials.
|
||||
* @param {Error} error - Original axios error
|
||||
* @param {string} method - HTTP verb ('POST' | 'GET')
|
||||
* @param {string} url - Full request URL
|
||||
* @param {*} payload - Request body (POST) or query params (GET)
|
||||
* @returns {Error} Enriched error (original kept in .cause, status in .status)
|
||||
*/
|
||||
_enrichHttpError(error, method, url, payload) {
|
||||
const status = error.response?.status;
|
||||
const parts = [`${method} ${url} failed${status != null ? ` (HTTP ${status})` : ''}: ${error.message}`];
|
||||
|
||||
if (payload !== undefined && payload !== null) {
|
||||
let serialized;
|
||||
try {
|
||||
serialized = JSON.stringify(payload);
|
||||
} catch {
|
||||
serialized = String(payload);
|
||||
}
|
||||
if (serialized !== '{}') {
|
||||
parts.push(`Request payload: ${serialized.slice(0, 1000)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const body = this._describeResponseBody(error.response?.data);
|
||||
if (body) parts.push(`Response body: ${body}`);
|
||||
|
||||
const enriched = new Error(parts.join('\n'));
|
||||
enriched.status = status;
|
||||
enriched.cause = error;
|
||||
return enriched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a POST request to WMS API
|
||||
* @param {string} endpoint - API endpoint (e.g., '/QueryExecute' or '/AD/api/Workflow/GetByApplication')
|
||||
@@ -162,13 +234,12 @@ class APIService {
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`[API] Request failed: ${error.message}`);
|
||||
|
||||
// If unauthorized, try refreshing token and retry once
|
||||
if (error.response?.status === 401) {
|
||||
console.error('[API] Unauthorized, refreshing token and retrying...');
|
||||
await this.refreshOAuthToken();
|
||||
|
||||
try {
|
||||
const retryResponse = await this.httpClient.post(url, data, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
@@ -178,9 +249,16 @@ class APIService {
|
||||
});
|
||||
|
||||
return retryResponse.data;
|
||||
} catch (retryError) {
|
||||
const enrichedRetry = this._enrichHttpError(retryError, 'POST', url, data);
|
||||
console.error(`[API] Retry after token refresh failed: ${enrichedRetry.message}`);
|
||||
throw enrichedRetry;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
const enriched = this._enrichHttpError(error, 'POST', url, data);
|
||||
console.error(`[API] Request failed: ${enriched.message}`);
|
||||
throw enriched;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,13 +288,12 @@ class APIService {
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`[API] Request failed: ${error.message}`);
|
||||
|
||||
// If unauthorized, try refreshing token and retry once
|
||||
if (error.response?.status === 401) {
|
||||
console.error('[API] Unauthorized, refreshing token and retrying...');
|
||||
await this.refreshOAuthToken();
|
||||
|
||||
try {
|
||||
const retryResponse = await this.httpClient.get(url, {
|
||||
params,
|
||||
headers: {
|
||||
@@ -226,9 +303,16 @@ class APIService {
|
||||
});
|
||||
|
||||
return retryResponse.data;
|
||||
} catch (retryError) {
|
||||
const enrichedRetry = this._enrichHttpError(retryError, 'GET', url, params);
|
||||
console.error(`[API] Retry after token refresh failed: ${enrichedRetry.message}`);
|
||||
throw enrichedRetry;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
const enriched = this._enrichHttpError(error, 'GET', url, params);
|
||||
console.error(`[API] Request failed: ${enriched.message}`);
|
||||
throw enriched;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,14 +323,17 @@ class APIService {
|
||||
* e.g. "Context.OutboundOrders.Where(z => z.OutboundOrderStatus == \"Release\").OrderBy(z => z.Id)"
|
||||
*
|
||||
* @param {string} expression - LINQ expression (Context.Entity or Context.Entity.Where(...))
|
||||
* @param {object} options - { take, skip, select, orderBy, inlineCount }
|
||||
* @param {object} options - { take, skip, select, orderBy, inlineCount, queryType }
|
||||
*/
|
||||
async executeQuery(expression, options = {}) {
|
||||
const { take, skip, select, orderBy, inlineCount } = options;
|
||||
const { take, skip, select, orderBy, inlineCount, queryType } = options;
|
||||
|
||||
const body = {
|
||||
Application: profileManager.getCurrent().application,
|
||||
QueryType: 0, // Reading = 0 (status fields are strings), Writing = 1 (enums)
|
||||
// Reading = 0 par défaut (statuts en chaînes) ; Writing/Metrics en
|
||||
// opt-in explicite via query_type (D25) — la garde de valeur vit dans
|
||||
// wms-query-service.assertValidQueryType, pas ici.
|
||||
QueryType: queryType ?? 0,
|
||||
Expression: expression,
|
||||
};
|
||||
|
||||
@@ -279,11 +366,13 @@ class APIService {
|
||||
* Execute a scalar LINQ query (Count, Sum, etc.) via QueryScalarExecute.
|
||||
* Returns the scalar value directly.
|
||||
* @param {string} fullExpression - e.g. "Context.OutboundOrders.Where(...).Count()"
|
||||
* @param {object} options - { queryType }
|
||||
*/
|
||||
async executeScalarQuery(fullExpression) {
|
||||
async executeScalarQuery(fullExpression, options = {}) {
|
||||
const body = {
|
||||
Application: profileManager.getCurrent().application,
|
||||
QueryType: 0, // Reading = 0 — string enum names in filters (Writing=1 fails with enum comparisons)
|
||||
// Reading = 0 par défaut — voir executeQuery / D25.
|
||||
QueryType: options.queryType ?? 0,
|
||||
Expression: fullExpression,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Entity Resolver Service
|
||||
* Résout un nom d'entité (Name de l'AD ou TableName, insensible à la casse)
|
||||
* vers le TableName attendu par Context.{...} dans les requêtes LINQ (D21).
|
||||
*
|
||||
* Le mapping n'est PAS une pluralisation (Container -> Containers, mais
|
||||
* Alias -> Alias) : seul le TableName de l'API Metadata fait foi. Le contexte
|
||||
* de lecture étant commun au tenant, la table agrège le Metadata de toutes
|
||||
* les applications installées.
|
||||
*/
|
||||
|
||||
const apiService = require('./api-service').getInstance();
|
||||
const profileManager = require('../config/profile-manager');
|
||||
const { createSingleFlight } = require('./single-flight');
|
||||
|
||||
// Cache state — même TTL que les autres caches (D10)
|
||||
let resolutionMap = null; // Map lower(Name | TableName) -> TableName
|
||||
let tableNames = null; // TableName[] triés (suggestions + comptage)
|
||||
let cacheTimestamp = null;
|
||||
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000;
|
||||
|
||||
// Table unique : une seule clé de single-flight (D27).
|
||||
const singleFlight = createSingleFlight('EntityResolver');
|
||||
const METADATA_KEY = 'metadata';
|
||||
|
||||
// La table de résolution est par tenant — invalidée à chaque bascule (D8).
|
||||
profileManager.onSwitch(() => invalidateCache());
|
||||
|
||||
function isCacheValid() {
|
||||
if (!resolutionMap || !cacheTimestamp) return false;
|
||||
return Date.now() - cacheTimestamp < CACHE_TTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge la table de résolution depuis l'API Metadata, agrégée sur toutes
|
||||
* les applications installées.
|
||||
* GET /configuration/applications ne liste que les applications déployées
|
||||
* avec une version — les applications EasyBuilder sans contexte requêtable
|
||||
* (CustomApp...) n'y figurent pas et ne fournissent de toute façon aucune
|
||||
* entité Metadata.
|
||||
*/
|
||||
async function loadResolutionMap() {
|
||||
if (isCacheValid()) return;
|
||||
|
||||
// Un seul chargement Metadata, même sous rafale concurrente (D27) : sans
|
||||
// lui, 6 appels concurrents déclenchaient 6 chargements complets. La
|
||||
// publication est refusée si le cache a été invalidé pendant le fetch.
|
||||
await singleFlight.run(METADATA_KEY, fetchResolutionMap, (loaded) => {
|
||||
resolutionMap = loaded.map;
|
||||
tableNames = loaded.names;
|
||||
cacheTimestamp = Date.now();
|
||||
console.error(`[EntityResolver] Cached ${tableNames.length} entities from ${loaded.applicationCount} application(s)`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Chargement réel de la table de résolution (D27). N'écrit rien en cache :
|
||||
* la publication est le `commit` de singleFlight.run.
|
||||
* @returns {Promise<{map: Map, names: string[], applicationCount: number}>}
|
||||
*/
|
||||
async function fetchResolutionMap() {
|
||||
console.error('[EntityResolver] Cache expired or empty, fetching Metadata...');
|
||||
|
||||
const apps = await apiService.get('/configuration/applications');
|
||||
const appNames = (Array.isArray(apps) ? apps : [])
|
||||
.map(a => a.Name || a.name)
|
||||
.filter(Boolean);
|
||||
|
||||
if (appNames.length === 0) {
|
||||
throw new Error('GET /configuration/applications returned no application');
|
||||
}
|
||||
|
||||
const map = new Map();
|
||||
const names = new Set();
|
||||
|
||||
for (const app of appNames) {
|
||||
const entities = await apiService.getMetadataEntities(app);
|
||||
for (const e of (Array.isArray(entities) ? entities : [])) {
|
||||
const tableName = e.TableName || e.tableName;
|
||||
const name = e.Name || e.name;
|
||||
if (!tableName) continue;
|
||||
names.add(tableName);
|
||||
map.set(tableName.toLowerCase(), tableName);
|
||||
if (name) map.set(name.toLowerCase(), tableName);
|
||||
}
|
||||
}
|
||||
|
||||
if (names.size === 0) {
|
||||
throw new Error('Metadata API returned no entity for any application');
|
||||
}
|
||||
|
||||
return {
|
||||
map,
|
||||
names: Array.from(names).sort(),
|
||||
applicationCount: appNames.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance de Levenshtein — uniquement pour suggérer des noms proches.
|
||||
*/
|
||||
function levenshtein(a, b) {
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
||||
for (let i = 1; i <= m; i++) {
|
||||
const curr = [i];
|
||||
for (let j = 1; j <= n; j++) {
|
||||
curr[j] = Math.min(
|
||||
prev[j] + 1,
|
||||
curr[j - 1] + 1,
|
||||
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
|
||||
);
|
||||
}
|
||||
prev = curr;
|
||||
}
|
||||
return prev[n];
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggère les TableName les plus proches d'un nom inconnu :
|
||||
* correspondances par sous-chaîne d'abord, puis distance d'édition.
|
||||
*/
|
||||
function suggestClosest(input, limit = 5) {
|
||||
const lower = input.toLowerCase();
|
||||
const scored = tableNames.map(tn => {
|
||||
const l = tn.toLowerCase();
|
||||
const score = (l.includes(lower) || lower.includes(l))
|
||||
? Math.abs(l.length - lower.length) // sous-chaîne : quasi-match
|
||||
: 100 + levenshtein(lower, l); // sinon : distance d'édition
|
||||
return { tn, score };
|
||||
});
|
||||
scored.sort((a, b) => a.score - b.score || a.tn.localeCompare(b.tn));
|
||||
const maxEditDistance = Math.max(3, Math.floor(lower.length / 2));
|
||||
return scored
|
||||
.filter(s => s.score < 100 + maxEditDistance)
|
||||
.slice(0, limit)
|
||||
.map(s => s.tn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout un nom d'entité vers son TableName.
|
||||
*
|
||||
* @param {string} entityType - Name AD ou TableName, insensible à la casse
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.allowUnknown=false] - Un nom inconnu du Reading
|
||||
* passe tel quel avec un warning au lieu d'échouer. Utilisé quand
|
||||
* query_type != 0 (D25) : la table est construite sur le Metadata Reading,
|
||||
* or le modèle Writing/Metrics peut contenir des entités hors Reading.
|
||||
* @returns {Promise<{tableName: string, warning?: string}>}
|
||||
* - nom connu : { tableName } (le TableName exact)
|
||||
* - Metadata injoignable : { tableName: entityType, warning } — on laisse
|
||||
* passer le nom tel quel (comportement historique) plutôt que de tout
|
||||
* bloquer, et on le dit dans la réponse
|
||||
* @throws {Error} nom inconnu du modèle Reading (sauf allowUnknown) — AVANT
|
||||
* tout appel réseau de requête, avec suggestions proches et renvoi vers
|
||||
* get_entity_metadata
|
||||
*/
|
||||
async function resolveEntityType(entityType, options = {}) {
|
||||
const { allowUnknown = false } = options;
|
||||
if (!entityType || typeof entityType !== 'string' || entityType.trim() === '') {
|
||||
throw new Error('entity_type est requis. Utilisez get_entity_metadata pour la liste des entités interrogeables.');
|
||||
}
|
||||
const trimmed = entityType.trim();
|
||||
|
||||
try {
|
||||
await loadResolutionMap();
|
||||
} catch (err) {
|
||||
console.error(`[EntityResolver] Metadata unreachable, passing "${trimmed}" through as-is: ${err.message}`);
|
||||
return {
|
||||
tableName: trimmed,
|
||||
warning: `Le nom d'entité "${trimmed}" n'a pas pu être validé (API Metadata injoignable : ${err.message}). Il est transmis tel quel au WMS.`,
|
||||
};
|
||||
}
|
||||
|
||||
const tableName = resolutionMap.get(trimmed.toLowerCase());
|
||||
if (tableName) {
|
||||
return { tableName };
|
||||
}
|
||||
|
||||
const suggestions = suggestClosest(trimmed);
|
||||
const closest = suggestions.length > 0 ? ` Proches : ${suggestions.join(', ')}.` : '';
|
||||
|
||||
if (allowUnknown) {
|
||||
console.error(`[EntityResolver] "${trimmed}" unknown to Reading metadata, passing through (allowUnknown)`);
|
||||
return {
|
||||
tableName: trimmed,
|
||||
warning: `"${trimmed}" est inconnu du modèle Reading (Metadata) ; il est transmis tel quel car query_type != 0 — le contexte demandé peut contenir des entités hors Reading.${closest}`,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`"${trimmed}" n'existe pas dans le modèle Reading.${closest} ` +
|
||||
`${tableNames.length} entités disponibles — utilisez get_entity_metadata pour la liste.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalide la table de résolution (bascule de profil).
|
||||
*/
|
||||
function invalidateCache() {
|
||||
resolutionMap = null;
|
||||
tableNames = null;
|
||||
cacheTimestamp = null;
|
||||
// Les fetchs déjà partis ne repeupleront pas la table (D27).
|
||||
singleFlight.invalidate();
|
||||
console.error('[EntityResolver] Cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* État du cache (exposé par get_application_summary si besoin).
|
||||
*/
|
||||
function getCacheStatus() {
|
||||
return {
|
||||
cached: resolutionMap !== null,
|
||||
count: tableNames ? tableNames.length : 0,
|
||||
timestamp: cacheTimestamp,
|
||||
age: cacheTimestamp ? Math.floor((Date.now() - cacheTimestamp) / 1000) : null,
|
||||
valid: isCacheValid(),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveEntityType,
|
||||
invalidateCache,
|
||||
getCacheStatus,
|
||||
};
|
||||
+4
-109
@@ -20,8 +20,6 @@ const DEFAULT_LOG_PATHS = [
|
||||
'\\\\{host}\\ProgramData\\Mecalux\\ETLLogs',
|
||||
];
|
||||
|
||||
const LOG_FILE_PATTERN = process.env.LOG_FILE_PATTERN || '*.log';
|
||||
|
||||
/**
|
||||
* Retourne les chemins de logs à scanner pour le profil actif.
|
||||
* Substitue {host} par l'hostname du profil.
|
||||
@@ -69,13 +67,13 @@ async function scanLogsRecursively(dir, fileList = []) {
|
||||
});
|
||||
} catch (statErr) {
|
||||
// Ignorer les fichiers inaccessibles
|
||||
console.error(`Cannot access file ${fullPath}: ${statErr.message}`);
|
||||
console.error(`[Logs] Cannot access file ${fullPath}: ${statErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ne pas planter si un dossier n'existe pas ou n'est pas accessible
|
||||
console.error(`Cannot scan directory ${dir}: ${err.message}`);
|
||||
console.error(`[Logs] Cannot scan directory ${dir}: ${err.message}`);
|
||||
}
|
||||
|
||||
return fileList;
|
||||
@@ -163,7 +161,7 @@ async function resolveLogFilePath(logFile) {
|
||||
try {
|
||||
subEntries = await fs.readdir(basePath, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
console.error(`Cannot read base log path ${basePath}: ${err.message}`);
|
||||
console.error(`[Logs] Cannot read base log path ${basePath}: ${err.message}`);
|
||||
}
|
||||
|
||||
const subDirs = subEntries.filter(e => e.isDirectory()).map(e => e.name);
|
||||
@@ -286,7 +284,7 @@ async function searchLogs(keyword, maxResults = 50, contextLines = 2) {
|
||||
}
|
||||
} catch (readErr) {
|
||||
// Ignorer les fichiers illisibles
|
||||
console.error(`Cannot read file ${file.path}: ${readErr.message}`);
|
||||
console.error(`[Logs] Cannot read file ${file.path}: ${readErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,112 +299,9 @@ async function searchLogs(keyword, maxResults = 50, contextLines = 2) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche des erreurs dans les logs récents
|
||||
* @param {number} maxResults - Nombre maximum de résultats
|
||||
* @returns {Promise<Object>} - Erreurs trouvées
|
||||
*/
|
||||
async function findRecentErrors(maxResults = 20) {
|
||||
const errorPatterns = ['error', 'exception', 'failed', 'fatal', 'critical'];
|
||||
const allErrors = [];
|
||||
|
||||
try {
|
||||
for (const pattern of errorPatterns) {
|
||||
if (allErrors.length >= maxResults) break;
|
||||
|
||||
const results = await searchLogs(pattern, maxResults - allErrors.length, 1);
|
||||
allErrors.push(...results.results);
|
||||
}
|
||||
|
||||
// Dédupliquer par numéro de ligne et fichier
|
||||
const unique = allErrors.filter(
|
||||
(error, index, self) =>
|
||||
index ===
|
||||
self.findIndex(
|
||||
(e) => e.fullPath === error.fullPath && e.lineNumber === error.lineNumber
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
totalErrors: unique.length,
|
||||
errors: unique.slice(0, maxResults),
|
||||
};
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to find errors: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit tout le contenu d'un fichier de log spécifique
|
||||
* @param {string} logFilePath - Chemin complet du fichier
|
||||
* @returns {Promise<Object>} - Contenu du fichier
|
||||
*/
|
||||
async function readFullLog(logFilePath) {
|
||||
try {
|
||||
const content = await fs.readFile(logFilePath, 'utf8');
|
||||
const lines = content.split('\n').filter((line) => line.trim() !== '');
|
||||
|
||||
return {
|
||||
file: path.basename(logFilePath),
|
||||
fullPath: logFilePath,
|
||||
totalLines: lines.length,
|
||||
content: lines,
|
||||
};
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read log file: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient des statistiques sur les logs
|
||||
* @returns {Promise<Object>} - Statistiques
|
||||
*/
|
||||
async function getLogStats() {
|
||||
try {
|
||||
const files = await listLogFiles();
|
||||
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
|
||||
|
||||
// Grouper par dossier
|
||||
const byDirectory = {};
|
||||
files.forEach(file => {
|
||||
const dir = file.directory;
|
||||
if (!byDirectory[dir]) {
|
||||
byDirectory[dir] = {
|
||||
directory: dir,
|
||||
count: 0,
|
||||
totalSize: 0,
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
byDirectory[dir].count++;
|
||||
byDirectory[dir].totalSize += file.size;
|
||||
byDirectory[dir].files.push({
|
||||
name: file.name,
|
||||
sizeMB: (file.size / (1024 * 1024)).toFixed(2),
|
||||
modified: file.modified.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
configuredPaths: getLogPaths(),
|
||||
totalFiles: files.length,
|
||||
totalSize,
|
||||
totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2),
|
||||
oldestFile: files[files.length - 1]?.name,
|
||||
newestFile: files[0]?.name,
|
||||
byDirectory: Object.values(byDirectory).sort((a, b) => b.count - a.count),
|
||||
};
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to get log stats: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listLogFiles,
|
||||
findLatestLogFile,
|
||||
readRecentLogs,
|
||||
searchLogs,
|
||||
findRecentErrors,
|
||||
readFullLog,
|
||||
getLogStats,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Response Limit
|
||||
* Garde de taille commune aux trois outils de requête (D24, lot 5).
|
||||
*
|
||||
* Mesures du 25/08/2026 sur `LIMAGRAI2512`, toutes au-dessus du seuil de rejet
|
||||
* du client MCP (~70 000 caractères) : 957 234 caractères pour 200 lignes
|
||||
* Reading, 847 543 pour `search_wms_data("PAL")`, et 95 288 pour **une seule**
|
||||
* ligne Writing — le modèle Writing sérialise l'agrégat complet (navigations,
|
||||
* `$id`…) là où la même ligne Reading fait ~4 500.
|
||||
*
|
||||
* Contrairement aux mécanismes de `get_system_parameters` et `search_logs`
|
||||
* (locaux car différents, D24), les trois outils de requête partagent le même
|
||||
* mécanisme — d'où ce module : on écarte des **lignes entières**, jamais
|
||||
* coupées au milieu.
|
||||
*/
|
||||
|
||||
const DEFAULT_MAX_RESPONSE_CHARS = 25000;
|
||||
|
||||
/**
|
||||
* Plafond en caractères d'une réponse d'outil de requête.
|
||||
* Même ordre de grandeur que `MAX_LOG_SEARCH_CHARS` (D24).
|
||||
*/
|
||||
function getMaxResponseChars() {
|
||||
return parseInt(process.env.MAX_QUERY_RESPONSE_CHARS) || DEFAULT_MAX_RESPONSE_CHARS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avertissement de volume propre aux contextes non-Reading (D25). Repris tel
|
||||
* quel dans les hints et dans la description du paramètre `query_type`.
|
||||
*/
|
||||
const WRITING_VOLUME_NOTE =
|
||||
'En query_type != 0, une ligne est un agrégat complet sérialisé (navigations, $id…) : ' +
|
||||
'95 288 caractères mesurés pour UNE seule ligne Products en Writing, contre ~4 500 en Reading. ' +
|
||||
'Repassez en query_type: 0 si le modèle Reading suffit.';
|
||||
|
||||
/**
|
||||
* Trouve le plus grand nombre d'éléments dont la réponse tient sous le plafond.
|
||||
*
|
||||
* @param {number} total - nombre d'éléments disponibles
|
||||
* @param {(kept: number) => string} buildText - construit la réponse sérialisée
|
||||
* pour `kept` éléments. Doit être croissante en `kept` et porter
|
||||
* elle-même les champs de troncature quand `kept < total`.
|
||||
* @returns {{ text: string, kept: number, truncated: boolean, cap: number }}
|
||||
*/
|
||||
function fitToCap(total, buildText) {
|
||||
const cap = getMaxResponseChars();
|
||||
|
||||
const full = buildText(total);
|
||||
if (full.length <= cap) {
|
||||
return { text: full, kept: total, truncated: false, cap };
|
||||
}
|
||||
|
||||
// Recherche dichotomique : ~8 constructions pour 200 lignes, là où un retrait
|
||||
// ligne à ligne en ferait 200 sur des charges utiles de ~1 Mo.
|
||||
let lo = 0;
|
||||
let hi = total - 1;
|
||||
let best = -1;
|
||||
let bestText = null;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
const text = buildText(mid);
|
||||
if (text.length <= cap) {
|
||||
best = mid;
|
||||
bestText = text;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Cas limite réel en Writing : une seule ligne dépasse déjà le plafond. On
|
||||
// renvoie l'enveloppe vide et signalée — moins bon qu'un résultat, mais mieux
|
||||
// qu'un rejet client opaque.
|
||||
if (best < 0) {
|
||||
best = 0;
|
||||
bestText = buildText(0);
|
||||
}
|
||||
|
||||
return { text: bestText, kept: best, truncated: true, cap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs de troncature communs aux outils de requête — vocabulaire D24 exact
|
||||
* (`truncated`, `returned`, `omitted`, `hint`). Le total avant la coupe est
|
||||
* ajouté par l'appelant : `query_wms_entities` et `search_wms_data` le portent
|
||||
* déjà (`count`, `totalFound`), `call_query_api` non.
|
||||
*
|
||||
* @param {number} returned - éléments effectivement renvoyés
|
||||
* @param {number} total - éléments disponibles avant la coupe
|
||||
* @param {number} queryType - QueryContextType de l'appel (D25)
|
||||
* @param {string} unit - nom de l'unité écartée, au singulier ('ligne', 'résultat')
|
||||
* @param {boolean} [feminine] - accord du hint sur `unit` ('ligne' est féminin)
|
||||
* @param {string} [extraHint] - phrase supplémentaire propre à l'outil
|
||||
*/
|
||||
function truncationSignal({ returned, total, queryType = 0, unit, feminine = false, extraHint }) {
|
||||
const cap = getMaxResponseChars();
|
||||
const omitted = total - returned;
|
||||
const plafond = `Plafond de taille de réponse atteint (${cap} caractères, MAX_QUERY_RESPONSE_CHARS)`;
|
||||
const e = feminine ? 'e' : '';
|
||||
const aucun = feminine ? 'Aucune' : 'Aucun';
|
||||
const unSeul = feminine ? 'une seule' : 'un seul';
|
||||
const entiers = feminine ? 'entières' : 'entiers';
|
||||
|
||||
// Cas limite réel en Writing : même une seule ligne dépasse le plafond.
|
||||
let hint = returned === 0
|
||||
? `${plafond} : ${aucun} ${unit} ne tient dans la réponse — ${unSeul} ${unit} dépasse déjà le plafond ` +
|
||||
`à ${feminine ? 'elle' : 'lui'} seul${e}. Restreignez la requête (filter plus étroit, autre entité) : ` +
|
||||
`le contenu n'est pas coupé au milieu, il est écarté en entier.`
|
||||
: `${plafond} : ${returned} ${unit}(s) renvoyé${e}(s) sur ${total}, ${omitted} écarté${e}(s) — des ` +
|
||||
`${unit}s ${entiers}, jamais coupé${e}s au milieu. Réduisez limit ou ajoutez un filter pour cibler.`;
|
||||
|
||||
if (extraHint) hint += ` ${extraHint}`;
|
||||
if (queryType) hint += ` ${WRITING_VOLUME_NOTE}`;
|
||||
|
||||
return { truncated: true, returned, omitted, hint };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getMaxResponseChars,
|
||||
fitToCap,
|
||||
truncationSignal,
|
||||
WRITING_VOLUME_NOTE,
|
||||
DEFAULT_MAX_RESPONSE_CHARS,
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Single-flight + génération de cache — chargements paresseux sous
|
||||
* concurrence (D27)
|
||||
*
|
||||
* Les services à cache (workflow, AD, resolver) chargent paresseusement : le
|
||||
* premier appelant qui trouve le cache invalide déclenche le fetch. Le serveur
|
||||
* traitant les `tools/call` en concurrence, deux défauts en découlaient, et ce
|
||||
* module porte les deux :
|
||||
*
|
||||
* 1. **Duplication** — N appelants arrivés pendant un fetch trouvaient tous le
|
||||
* cache invalide et lançaient N chaînes complètes (mesuré : 6 chargements
|
||||
* Metadata en parallèle pour une seule table). Une Map
|
||||
* `clé de cache -> promesse en vol` les fait rejoindre le fetch en cours.
|
||||
* 2. **Écriture post-invalidation** — un fetch parti avant une bascule de
|
||||
* profil (D8) terminait après elle et repeuplait le cache avec les données
|
||||
* de l'ancien tenant, timestamp neuf. Un compteur de génération, incrémenté
|
||||
* à chaque invalidation, fait **jeter** un résultat d'une génération
|
||||
* périmée au lieu de l'écrire.
|
||||
*
|
||||
* Le single-flight est **par clé** — deux applications différentes se chargent
|
||||
* toujours en parallèle (D26 : rien n'est préchargé, rien n'est sérialisé
|
||||
* au-delà de la clé demandée). Pas de dépendance externe : une Map.
|
||||
*
|
||||
* @param {string} label - préfixe de log du service appelant (D6, MONITORING §2)
|
||||
*/
|
||||
function createSingleFlight(label) {
|
||||
const inFlight = new Map(); // clé de cache -> promesse du chargement en cours
|
||||
let generation = 0; // incrémenté à chaque invalidation
|
||||
|
||||
/**
|
||||
* Exécute `fetcher` pour cette clé, ou rejoint le chargement déjà en vol,
|
||||
* puis publie le résultat via `commit` **si la génération n'a pas changé**.
|
||||
*
|
||||
* La promesse est retirée de la Map au règlement, succès **ou** échec : un
|
||||
* fetch en erreur ne reste pas coincé, l'appel suivant refetche.
|
||||
*
|
||||
* L'appelant reçoit toujours le résultat de son fetch, même périmé — c'est
|
||||
* sa **mise en cache** qui est refusée, pas sa réponse : il a demandé ces
|
||||
* données avant l'invalidation, il les obtient.
|
||||
*
|
||||
* @param {string} key - clé de cache (une par entrée de cache indépendante)
|
||||
* @param {() => Promise<any>} fetcher - le chargement réel, appelé au plus
|
||||
* une fois tant qu'il est en vol ; il ne doit **rien** écrire en cache
|
||||
* @param {(value: any) => void} [commit] - publication en cache, appelée
|
||||
* seulement si aucune invalidation n'est survenue pendant le fetch
|
||||
* @returns {Promise<any>} le résultat du chargement (partagé par les joignants)
|
||||
*/
|
||||
function run(key, fetcher, commit) {
|
||||
const pending = inFlight.get(key);
|
||||
if (pending) {
|
||||
console.error(`[${label}] Fetch already in flight for "${key}", joining it`);
|
||||
return pending;
|
||||
}
|
||||
|
||||
const startGeneration = generation;
|
||||
const promise = (async () => {
|
||||
const value = await fetcher();
|
||||
if (generation !== startGeneration) {
|
||||
console.error(
|
||||
`[${label}] Result for "${key}" discarded, not cached: ` +
|
||||
`cache invalidated during fetch (generation ${startGeneration} -> ${generation})`
|
||||
);
|
||||
return value;
|
||||
}
|
||||
if (commit) commit(value);
|
||||
return value;
|
||||
})();
|
||||
|
||||
inFlight.set(key, promise);
|
||||
|
||||
// Libération au règlement. Le test d'identité évite qu'une promesse
|
||||
// périmée (Map vidée par une invalidation, puis nouveau fetch démarré)
|
||||
// supprime l'entrée de son successeur.
|
||||
const release = () => {
|
||||
if (inFlight.get(key) === promise) inFlight.delete(key);
|
||||
};
|
||||
promise.then(release, release);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque toutes les données en vol comme périmées : la génération avance et
|
||||
* la Map est vidée. À appeler depuis l'invalidation du service (l'abonnement
|
||||
* `onSwitch()` reste le seul déclencheur, D8).
|
||||
*
|
||||
* Vider la Map ne coupe personne : les appelants déjà en attente gardent
|
||||
* leur référence à la promesse et reçoivent son résultat — simplement, ce
|
||||
* résultat ne sera pas mis en cache.
|
||||
*/
|
||||
function invalidate() {
|
||||
generation++;
|
||||
inFlight.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre de chargements en vol — diagnostic seulement.
|
||||
*/
|
||||
function pendingCount() {
|
||||
return inFlight.size;
|
||||
}
|
||||
|
||||
return { run, invalidate, pendingCount };
|
||||
}
|
||||
|
||||
module.exports = { createSingleFlight };
|
||||
@@ -4,7 +4,28 @@
|
||||
*/
|
||||
|
||||
const apiService = require('./api-service').getInstance();
|
||||
const constants = require('../config/constants');
|
||||
const entityResolver = require('./entity-resolver');
|
||||
|
||||
/**
|
||||
* Garde de valeur de query_type (D25). Le wrapper D23 valide les noms de
|
||||
* paramètres, pas les valeurs — cette garde s'exécute AVANT tout appel réseau
|
||||
* (y compris la résolution d'entité) et nomme les quatre contextes.
|
||||
* @param {*} queryType - valeur reçue de l'outil (défaut 0 si absent)
|
||||
* @returns {number} la valeur validée
|
||||
*/
|
||||
function assertValidQueryType(queryType) {
|
||||
if (queryType == null) return 0;
|
||||
if (!Number.isInteger(queryType) || queryType < 0 || queryType > 3) {
|
||||
throw new Error(
|
||||
`query_type invalide : ${JSON.stringify(queryType)}. Valeurs acceptées : ` +
|
||||
`0 = Reading (défaut — statuts en chaînes, ex. "Release"), ` +
|
||||
`1 = Writing (statuts en énumérations : les comparaisons de chaînes échouent), ` +
|
||||
`2 = DataWarehouse (souvent non configuré), ` +
|
||||
`3 = Metrics (modèle de données distinct).`
|
||||
);
|
||||
}
|
||||
return queryType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a LINQ select expression
|
||||
@@ -19,15 +40,27 @@ const constants = require('../config/constants');
|
||||
* @param {string} selectExpression - LINQ select expression
|
||||
* @param {string|null} filter - Optional filter
|
||||
* @param {number} limit - Result limit
|
||||
* @param {number} queryType - QueryContextType (0 = Reading par défaut, D25)
|
||||
*/
|
||||
async function queryEntities(entityType, selectExpression = 'z => z', filter = null, limit = 100) {
|
||||
async function queryEntities(entityType, selectExpression = 'z => z', filter = null, limit = 100, queryType = 0) {
|
||||
// Garde de valeur avant tout réseau (D25).
|
||||
queryType = assertValidQueryType(queryType);
|
||||
|
||||
// Résolution Name/TableName -> TableName (D21). Un nom inconnu échoue ici,
|
||||
// avant tout appel réseau de requête — l'erreur porte les suggestions.
|
||||
// En query_type != 0, un nom hors Reading passe tel quel avec warning : le
|
||||
// modèle Writing/Metrics peut contenir des entités hors Reading (D25).
|
||||
const { tableName, warning } = await entityResolver.resolveEntityType(entityType, {
|
||||
allowUnknown: queryType !== 0,
|
||||
});
|
||||
|
||||
try {
|
||||
// Enforce max limit
|
||||
const maxLimit = parseInt(process.env.MAX_QUERY_ROWS) || 1000;
|
||||
const actualLimit = Math.min(limit, maxLimit);
|
||||
|
||||
// Build expression: Context + optional Where + OrderBy (required by EF when Take is used)
|
||||
let expression = `Context.${entityType}`;
|
||||
let expression = `Context.${tableName}`;
|
||||
if (filter) {
|
||||
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
|
||||
expression += `.Where(${whereExpr})`;
|
||||
@@ -35,15 +68,19 @@ async function queryEntities(entityType, selectExpression = 'z => z', filter = n
|
||||
// OrderBy must be embedded in the expression (not as a separate API param)
|
||||
expression += `.OrderBy(z => z.Id)`;
|
||||
|
||||
console.error(`[WMSQuery] Querying ${entityType}: ${expression} | take=${actualLimit} select=${selectExpression}`);
|
||||
console.error(`[WMSQuery] Querying ${entityType}: ${expression} | take=${actualLimit} select=${selectExpression} queryType=${queryType}`);
|
||||
|
||||
const result = await apiService.executeQuery(expression, {
|
||||
take: actualLimit,
|
||||
select: selectExpression !== 'z => z' ? selectExpression : undefined,
|
||||
queryType,
|
||||
});
|
||||
|
||||
return {
|
||||
entityType,
|
||||
resolvedTableName: tableName,
|
||||
...(warning ? { warning } : {}),
|
||||
...(queryType !== 0 ? { queryType } : {}),
|
||||
expression,
|
||||
limit: actualLimit,
|
||||
count: Array.isArray(result) ? result.length : 0,
|
||||
@@ -51,7 +88,9 @@ async function queryEntities(entityType, selectExpression = 'z => z', filter = n
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[WMSQuery] Query failed:`, error.message);
|
||||
throw new Error(`Query failed for ${entityType}: ${error.message}`);
|
||||
// Le warning de résolution (nom hors Reading en query_type != 0) reste
|
||||
// visible même quand le WMS échoue ensuite.
|
||||
throw new Error(`Query failed for ${entityType}: ${error.message}${warning ? `\nWarning: ${warning}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,11 +182,21 @@ async function getEntitySchema(entityType) {
|
||||
* Count entities with optional filter
|
||||
* @param {string} entityType - Entity type
|
||||
* @param {string|null} filter - Optional filter
|
||||
* @param {number} queryType - QueryContextType (0 = Reading par défaut, D25)
|
||||
*/
|
||||
async function countEntities(entityType, filter = null) {
|
||||
async function countEntities(entityType, filter = null, queryType = 0) {
|
||||
// Garde de valeur avant tout réseau (D25).
|
||||
queryType = assertValidQueryType(queryType);
|
||||
|
||||
// Résolution Name/TableName -> TableName (D21) — échec avant appel réseau
|
||||
// sur nom inconnu, sauf en query_type != 0 (passage tel quel + warning, D25).
|
||||
const { tableName, warning } = await entityResolver.resolveEntityType(entityType, {
|
||||
allowUnknown: queryType !== 0,
|
||||
});
|
||||
|
||||
try {
|
||||
// Build: Context.Entity.Where(...).Count()
|
||||
const parts = [`Context.${entityType}`];
|
||||
const parts = [`Context.${tableName}`];
|
||||
if (filter) {
|
||||
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
|
||||
parts.push(`Where(${whereExpr})`);
|
||||
@@ -155,18 +204,21 @@ async function countEntities(entityType, filter = null) {
|
||||
parts.push('Count()');
|
||||
const fullExpression = parts.join('.');
|
||||
|
||||
console.error(`[WMSQuery] Counting ${entityType}: ${fullExpression}`);
|
||||
console.error(`[WMSQuery] Counting ${entityType}: ${fullExpression} | queryType=${queryType}`);
|
||||
|
||||
const count = await apiService.executeScalarQuery(fullExpression);
|
||||
const count = await apiService.executeScalarQuery(fullExpression, { queryType });
|
||||
|
||||
return {
|
||||
entityType,
|
||||
resolvedTableName: tableName,
|
||||
...(warning ? { warning } : {}),
|
||||
...(queryType !== 0 ? { queryType } : {}),
|
||||
filter,
|
||||
count
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[WMSQuery] Count failed:`, error.message);
|
||||
throw new Error(`Count failed for ${entityType}: ${error.message}`);
|
||||
throw new Error(`Count failed for ${entityType}: ${error.message}${warning ? `\nWarning: ${warning}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,4 +228,5 @@ module.exports = {
|
||||
searchEntities,
|
||||
getEntitySchema,
|
||||
countEntities,
|
||||
assertValidQueryType,
|
||||
};
|
||||
|
||||
+212
-100
@@ -2,67 +2,114 @@
|
||||
* Workflow Service
|
||||
* Handles workflow fetching with lazy loading and caching
|
||||
* Workflows are only loaded when first requested (not at startup)
|
||||
*
|
||||
* Un cache par application (D26) : le paramètre `application` des outils
|
||||
* sélectionne l'application AD interrogée (défaut : celle du profil actif).
|
||||
*/
|
||||
|
||||
const apiService = require('./api-service').getInstance();
|
||||
const profileManager = require('../config/profile-manager');
|
||||
const { createSingleFlight } = require('./single-flight');
|
||||
const { requireEntities } = require('./ad-envelope');
|
||||
|
||||
// Cache state
|
||||
let workflowCache = null;
|
||||
let cacheTimestamp = null;
|
||||
// Cache state — un cache de workflows par application (D26)
|
||||
let workflowCaches = {}; // application -> workflows[]
|
||||
let cacheTimestamps = {}; // application -> timestamp
|
||||
let applicationsCache = null; // liste allégée de POST /Application/GetAll
|
||||
let applicationsTimestamp = null;
|
||||
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour in milliseconds
|
||||
|
||||
// Déduplication des chargements concurrents, par clé de cache (D27). Deux
|
||||
// clés distinctes ici : une par application, plus la liste d'applications.
|
||||
const singleFlight = createSingleFlight('Workflow');
|
||||
|
||||
// Clear cache when profile changes — workflows are per-tenant, so the previous
|
||||
// profile's cache is meaningless after a switch.
|
||||
profileManager.onSwitch(() => clearCache());
|
||||
|
||||
/**
|
||||
* Check if cache is still valid
|
||||
* Application effective : celle demandée, sinon celle du profil actif.
|
||||
*/
|
||||
function isCacheValid() {
|
||||
if (!workflowCache || !cacheTimestamp) {
|
||||
function resolveApplication(application) {
|
||||
return (application && application.trim()) || profileManager.getCurrent().application;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cache is still valid for an application
|
||||
*/
|
||||
function isCacheValid(application) {
|
||||
if (!workflowCaches[application] || !cacheTimestamps[application]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const age = now - cacheTimestamp;
|
||||
const age = Date.now() - cacheTimestamps[application];
|
||||
return age < CACHE_TTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all workflows from API with pagination
|
||||
* Uses high page size (5000) to minimize API calls
|
||||
* Fetch all workflows of an application from API with pagination.
|
||||
* Uses high page size (5000) to minimize API calls.
|
||||
* Lazy : seule l'application effectivement demandée est chargée (D26) — ne
|
||||
* jamais précharger les 9 applications.
|
||||
* @param {string} [application] - Application AD (défaut : profil actif)
|
||||
*/
|
||||
async function fetchAllWorkflows() {
|
||||
async function fetchAllWorkflows(application) {
|
||||
const app = resolveApplication(application);
|
||||
|
||||
// Check cache validity
|
||||
if (isCacheValid()) {
|
||||
console.error('[Workflow] Using cached data');
|
||||
return workflowCache;
|
||||
if (isCacheValid(app)) {
|
||||
console.error(`[Workflow] Using cached data for "${app}"`);
|
||||
return workflowCaches[app];
|
||||
}
|
||||
|
||||
console.error('[Workflow] Cache expired or empty, fetching from API...');
|
||||
// Un seul chargement par application, même sous rafale concurrente, et
|
||||
// publication en cache seulement si aucune invalidation n'est survenue
|
||||
// pendant le fetch (D27).
|
||||
return singleFlight.run(
|
||||
`workflows::${app}`,
|
||||
() => loadWorkflows(app),
|
||||
(workflows) => {
|
||||
workflowCaches[app] = workflows;
|
||||
cacheTimestamps[app] = Date.now();
|
||||
console.error(`[Workflow] Successfully cached ${workflows.length} workflows for "${app}"`);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chargement réel des workflows d'une application (pagination complète).
|
||||
* Appelé au plus une fois par application tant qu'il est en vol (D27).
|
||||
* N'écrit RIEN en cache : la publication est le `commit` de singleFlight.run,
|
||||
* qui la refuse si le cache a été invalidé entre-temps.
|
||||
*/
|
||||
async function loadWorkflows(app) {
|
||||
console.error(`[Workflow] Cache expired or empty for "${app}", fetching from API...`);
|
||||
|
||||
try {
|
||||
let allWorkflows = [];
|
||||
let offset = 0;
|
||||
const pageSize = parseInt(process.env.WORKFLOW_PAGE_SIZE) || 5000;
|
||||
const profile = profileManager.getCurrent();
|
||||
const application = profile.application;
|
||||
const tenant = profile.tenant;
|
||||
const tenant = profileManager.getCurrent().tenant;
|
||||
|
||||
while (true) {
|
||||
const body = [application, tenant, pageSize, offset];
|
||||
const body = [app, tenant, pageSize, offset];
|
||||
|
||||
console.error(`[Workflow] Fetching page: offset=${offset}, pageSize=${pageSize}`);
|
||||
console.error(`[Workflow] Fetching page: application=${app}, offset=${offset}, pageSize=${pageSize}`);
|
||||
|
||||
// Use AD API (useAdApi=true)
|
||||
const response = await apiService.post('/Workflow/GetByApplication', body, true);
|
||||
|
||||
// Extract entities array from response
|
||||
const workflows = response?.entities || [];
|
||||
// Une réponse hors enveloppe { entities: [...] } lève au lieu de se
|
||||
// faire passer pour une page vide (D27) : un cache vide empoisonné
|
||||
// durerait tout le TTL.
|
||||
const workflows = requireEntities(
|
||||
response,
|
||||
`Workflow/GetByApplication (application "${app}", offset ${offset})`
|
||||
);
|
||||
|
||||
// Check if response is valid
|
||||
if (!workflows || workflows.length === 0) {
|
||||
// Vide réel : fin de pagination (une application peut n'avoir aucun
|
||||
// workflow — SmartUI, D26).
|
||||
if (workflows.length === 0) {
|
||||
console.error('[Workflow] No more workflows to fetch');
|
||||
break;
|
||||
}
|
||||
@@ -79,26 +126,102 @@ async function fetchAllWorkflows() {
|
||||
offset += pageSize;
|
||||
}
|
||||
|
||||
// Update cache
|
||||
workflowCache = allWorkflows;
|
||||
cacheTimestamp = Date.now();
|
||||
|
||||
console.error(`[Workflow] Successfully cached ${allWorkflows.length} workflows`);
|
||||
return allWorkflows;
|
||||
} catch (error) {
|
||||
console.error('[Workflow] Error fetching workflows:', error.message);
|
||||
throw new Error(`Failed to fetch workflows: ${error.message}`);
|
||||
console.error(`[Workflow] Error fetching workflows for "${app}":`, error.message);
|
||||
throw new Error(`Failed to fetch workflows for application "${app}": ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search workflows by query string
|
||||
* @param {string} query - Search query (matches name, description, etc.)
|
||||
* @param {string|null} category - Optional category filter
|
||||
* @param {number} limit - Maximum results to return
|
||||
* Liste les applications déclarées (POST /Application/GetAll, payload null).
|
||||
* La réponse est une enveloppe { entities: [...] } (D4) dont chaque élément
|
||||
* porte un blob `data` volumineux — on ne conserve que les champs légers.
|
||||
* Cache TTL commun, vidé au switch de profil.
|
||||
* @returns {Promise<Array<{name: string, id: string, version: number}>>}
|
||||
*/
|
||||
async function searchWorkflows(query, category = null, limit = 50) {
|
||||
const workflows = await fetchAllWorkflows();
|
||||
async function fetchApplications() {
|
||||
const cached = getCachedApplications();
|
||||
if (cached) return cached;
|
||||
|
||||
// Même déduplication et même garde de génération, sur sa propre clé (D27).
|
||||
return singleFlight.run('applications', loadApplications, (applications) => {
|
||||
applicationsCache = applications;
|
||||
applicationsTimestamp = Date.now();
|
||||
console.error(`[Workflow] Cached ${applications.length} application(s)`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Chargement réel de la liste d'applications (D27). N'écrit rien en cache.
|
||||
*/
|
||||
async function loadApplications() {
|
||||
console.error('[Workflow] Fetching application list (Application/GetAll)...');
|
||||
const response = await apiService.post('/Application/GetAll', null, true);
|
||||
const entities = requireEntities(response, 'Application/GetAll');
|
||||
|
||||
return entities.map(a => ({
|
||||
name: a.name || a.Name,
|
||||
id: a.id || a.Id,
|
||||
version: a.version ?? a.Version,
|
||||
})).filter(a => a.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des applications déjà en cache, ou null si le cache est vide/expiré.
|
||||
* Ne déclenche AUCUN appel réseau — c'est ce qui permet d'enrichir une réponse
|
||||
* de recherche sans jamais précharger une application non demandée (D26).
|
||||
* @returns {Array<{name: string, id: string, version: number}>|null}
|
||||
*/
|
||||
function getCachedApplications() {
|
||||
if (applicationsCache && applicationsTimestamp &&
|
||||
Date.now() - applicationsTimestamp < CACHE_TTL) {
|
||||
return applicationsCache;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hint de découvrabilité (L5.4). Une recherche n'interroge qu'UNE application
|
||||
* sur les neuf déclarées, et rien dans la réponse ne le disait : une session
|
||||
* cherchant des workflows `CST_*` sans `application: "CustomApp"` a conclu à
|
||||
* tort qu'il n'y en avait aucun (25/08/2026).
|
||||
*
|
||||
* Les autres applications sont nommées depuis la liste allégée **déjà en
|
||||
* cache** ; sans elle, le hint reste générique et renvoie vers
|
||||
* `list_workflow_categories` — jamais de fetch pour construire un hint.
|
||||
*
|
||||
* @param {string} application - application effectivement interrogée
|
||||
* @param {string} sujet - ce qui a été cherché ('workflow', 'élément Command'…)
|
||||
*/
|
||||
function buildOtherApplicationsHint(application, sujet) {
|
||||
const cached = getCachedApplications();
|
||||
const others = (cached || []).map(a => a.name).filter(n => n !== application);
|
||||
|
||||
const liste = others.length
|
||||
? `Autres applications déclarées sur ce tenant : ${others.join(', ')}.`
|
||||
: `Appelez list_workflow_categories pour lister les autres applications déclarées.`;
|
||||
|
||||
const custom = application.toLowerCase() === 'customapp'
|
||||
? ''
|
||||
: ` Le spécifique client (préfixe CST_) vit dans "CustomApp" : relancez avec application: "CustomApp".`;
|
||||
|
||||
return `Aucun ${sujet} trouvé dans l'application "${application}" — c'est la SEULE interrogée, ` +
|
||||
`les autres ne le sont jamais implicitement.${custom} ${liste}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search workflows by query string.
|
||||
* Real AD keys (lowercase, cf. D5): id, name, version, applicationName,
|
||||
* commonInfo — no description/code/category field exists.
|
||||
* @param {string} query - Search query (matches workflow name)
|
||||
* @param {string|null} category - Optional applicationName filter (the only
|
||||
* grouping the AD API provides)
|
||||
* @param {number} limit - Maximum results to return
|
||||
* @param {string} [application] - Application AD interrogée (défaut : profil)
|
||||
*/
|
||||
async function searchWorkflows(query, category = null, limit = 50, application) {
|
||||
const workflows = await fetchAllWorkflows(application);
|
||||
|
||||
let results = workflows;
|
||||
|
||||
@@ -107,21 +230,16 @@ async function searchWorkflows(query, category = null, limit = 50) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
results = results.filter(w => {
|
||||
const name = (w.name || w.Name || '').toLowerCase();
|
||||
const description = (w.description || w.Description || '').toLowerCase();
|
||||
const code = (w.code || w.Code || '').toLowerCase();
|
||||
|
||||
return name.includes(lowerQuery) ||
|
||||
description.includes(lowerQuery) ||
|
||||
code.includes(lowerQuery);
|
||||
return name.includes(lowerQuery);
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by category if provided
|
||||
// Filter by applicationName if provided
|
||||
if (category) {
|
||||
const lowerCategory = category.toLowerCase();
|
||||
results = results.filter(w => {
|
||||
const wfCategory = (w.category || w.Category || '').toLowerCase();
|
||||
return wfCategory.includes(lowerCategory);
|
||||
const applicationName = (w.applicationName || w.ApplicationName || '').toLowerCase();
|
||||
return applicationName.includes(lowerCategory);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -132,97 +250,91 @@ async function searchWorkflows(query, category = null, limit = 50) {
|
||||
/**
|
||||
* Get workflow details by ID
|
||||
* @param {string|number} workflowId - Workflow ID
|
||||
* @param {string} [application] - Application AD interrogée (défaut : profil)
|
||||
*/
|
||||
async function getWorkflowDetails(workflowId) {
|
||||
const workflows = await fetchAllWorkflows();
|
||||
async function getWorkflowDetails(workflowId, application) {
|
||||
// Garde d'entrée : sans elle, un workflow_id absent matchait le premier
|
||||
// workflow du cache (undefined === undefined sur les clés mortes ci-dessous).
|
||||
if (workflowId == null || workflowId === '') {
|
||||
throw new Error('workflow_id est requis (id ou nom exact du workflow). Utilisez search_workflows pour le trouver.');
|
||||
}
|
||||
|
||||
// Try to find by Id, id, Code, code, Name, or name
|
||||
const app = resolveApplication(application);
|
||||
const workflows = await fetchAllWorkflows(app);
|
||||
|
||||
// Clés réelles de l'API AD (minuscules, D5) : id, name. Les variantes
|
||||
// Id/Code/Name n'existent pas sur ces objets — les comparer faisait matcher
|
||||
// undefined === undefined dès que workflow_id manquait.
|
||||
const workflow = workflows.find(w =>
|
||||
w.id === workflowId ||
|
||||
w.Id === workflowId ||
|
||||
w.id === parseInt(workflowId) ||
|
||||
w.Id === parseInt(workflowId) ||
|
||||
w.Code === workflowId ||
|
||||
w.code === workflowId ||
|
||||
w.Name === workflowId ||
|
||||
w.name === workflowId
|
||||
);
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error(`Workflow not found: ${workflowId}`);
|
||||
throw new Error(
|
||||
`Workflow not found: ${workflowId} (application "${app}"). ` +
|
||||
`Utilisez search_workflows pour trouver l'id ou le nom exact — ` +
|
||||
`pensez au paramètre application (ex: "CustomApp" pour le spécifique client).`
|
||||
);
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all workflow categories
|
||||
* Get workflow statistics for one application
|
||||
* @param {string} [application] - Application AD interrogée (défaut : profil)
|
||||
*/
|
||||
async function listWorkflowCategories() {
|
||||
const workflows = await fetchAllWorkflows();
|
||||
|
||||
// Extract unique categories (try both lowercase and uppercase)
|
||||
const categories = new Set();
|
||||
workflows.forEach(w => {
|
||||
const category = w.category || w.Category;
|
||||
if (category) {
|
||||
categories.add(category);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort alphabetically
|
||||
return Array.from(categories).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow statistics
|
||||
*/
|
||||
async function getWorkflowStats() {
|
||||
const workflows = await fetchAllWorkflows();
|
||||
const categories = await listWorkflowCategories();
|
||||
|
||||
// Count workflows per category
|
||||
const categoryCounts = {};
|
||||
workflows.forEach(w => {
|
||||
const cat = w.category || w.Category || 'Uncategorized';
|
||||
categoryCounts[cat] = (categoryCounts[cat] || 0) + 1;
|
||||
});
|
||||
async function getWorkflowStats(application) {
|
||||
const app = resolveApplication(application);
|
||||
const workflows = await fetchAllWorkflows(app);
|
||||
|
||||
return {
|
||||
application: app,
|
||||
total: workflows.length,
|
||||
categories: categories.length,
|
||||
categoryCounts,
|
||||
cacheAge: cacheTimestamp ? Math.floor((Date.now() - cacheTimestamp) / 1000) : null
|
||||
cacheAge: cacheTimestamps[app] ? Math.floor((Date.now() - cacheTimestamps[app]) / 1000) : null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear workflow cache (force refresh on next request)
|
||||
* Clear workflow caches (force refresh on next request) — toutes applications.
|
||||
*/
|
||||
function clearCache() {
|
||||
workflowCache = null;
|
||||
cacheTimestamp = null;
|
||||
workflowCaches = {};
|
||||
cacheTimestamps = {};
|
||||
applicationsCache = null;
|
||||
applicationsTimestamp = null;
|
||||
// Les fetchs déjà partis ne repeupleront pas ce cache (D27).
|
||||
singleFlight.invalidate();
|
||||
console.error('[Workflow] Cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache status
|
||||
* Get cache status, per application (D26)
|
||||
* @returns {Object} application -> { cached, count, timestamp, age, valid }
|
||||
*/
|
||||
function getCacheStatus() {
|
||||
return {
|
||||
cached: workflowCache !== null,
|
||||
count: workflowCache ? workflowCache.length : 0,
|
||||
timestamp: cacheTimestamp,
|
||||
age: cacheTimestamp ? Math.floor((Date.now() - cacheTimestamp) / 1000) : null,
|
||||
valid: isCacheValid()
|
||||
const status = {};
|
||||
Object.keys(workflowCaches).forEach(app => {
|
||||
status[app] = {
|
||||
cached: true,
|
||||
count: workflowCaches[app].length,
|
||||
timestamp: cacheTimestamps[app],
|
||||
age: cacheTimestamps[app] ? Math.floor((Date.now() - cacheTimestamps[app]) / 1000) : null,
|
||||
valid: isCacheValid(app)
|
||||
};
|
||||
});
|
||||
return status;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchAllWorkflows,
|
||||
fetchApplications,
|
||||
getCachedApplications,
|
||||
buildOtherApplicationsHint,
|
||||
resolveApplication,
|
||||
searchWorkflows,
|
||||
getWorkflowDetails,
|
||||
listWorkflowCategories,
|
||||
getWorkflowStats,
|
||||
clearCache,
|
||||
getCacheStatus
|
||||
|
||||
+61
-31
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
const adService = require('../services/ad-service');
|
||||
const workflowService = require('../services/workflow-service');
|
||||
|
||||
/**
|
||||
* List available AD tools
|
||||
@@ -12,17 +13,19 @@ function listTools() {
|
||||
return [
|
||||
{
|
||||
name: 'get_application_summary',
|
||||
description: 'Get summary of Application Dictionary elements. Shows count of cached elements per type (Commands, Queries, Dialogs, Views, etc.). Only counts already-loaded types to avoid long waits.',
|
||||
description: 'Get summary of Application Dictionary caches, grouped by application then element type (D26), plus the per-application workflow caches. Only already-loaded entries are detailed to avoid long waits.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_ad_elements',
|
||||
description: 'Get all elements of a specific type from Application Dictionary. Supports: Command, Query, Dialog, View, Entity, Event, Hook, Report, Dashboard, and 11 other types (20 total). Elements are lazy-loaded and cached for 1 hour.',
|
||||
description: 'Get all elements of a specific type from Application Dictionary. Supports: Command, Query, Dialog, View, Entity, Event, Hook, Report, Dashboard, and 11 other types (20 total). Elements are lazy-loaded and cached for 1 hour, per (application, type).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
element_type: {
|
||||
type: 'string',
|
||||
@@ -33,6 +36,10 @@ function listTools() {
|
||||
description: 'Maximum number of elements to return (default: 100, max: 1000)',
|
||||
default: 100,
|
||||
},
|
||||
application: {
|
||||
type: 'string',
|
||||
description: 'AD application to query (default: the active profile\'s application, usually EasyWMS). Client-specific elements live in "CustomApp" (CST_* prefix). Full list via list_workflow_categories.',
|
||||
},
|
||||
},
|
||||
required: ['element_type'],
|
||||
},
|
||||
@@ -42,6 +49,7 @@ function listTools() {
|
||||
description: 'Search Application Dictionary elements by name, description, or code. Searches within a specific element type.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
element_type: {
|
||||
type: 'string',
|
||||
@@ -56,6 +64,10 @@ function listTools() {
|
||||
description: 'Maximum results (default: 50)',
|
||||
default: 50,
|
||||
},
|
||||
application: {
|
||||
type: 'string',
|
||||
description: 'AD application to search in (default: the active profile\'s application, usually EasyWMS). Client-specific elements live in "CustomApp" (CST_* prefix).',
|
||||
},
|
||||
},
|
||||
required: ['element_type', 'query'],
|
||||
},
|
||||
@@ -65,6 +77,7 @@ function listTools() {
|
||||
description: 'Get detailed information about a specific AD element by ID or name',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
element_type: {
|
||||
type: 'string',
|
||||
@@ -74,6 +87,10 @@ function listTools() {
|
||||
type: 'string',
|
||||
description: 'Element ID or name',
|
||||
},
|
||||
application: {
|
||||
type: 'string',
|
||||
description: 'AD application the element belongs to (default: the active profile\'s application, usually EasyWMS). Client-specific elements live in "CustomApp" (CST_* prefix).',
|
||||
},
|
||||
},
|
||||
required: ['element_type', 'element_id'],
|
||||
},
|
||||
@@ -83,6 +100,7 @@ function listTools() {
|
||||
description: 'List all available Application Dictionary element types',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
@@ -135,23 +153,22 @@ async function executeTool(name, args) {
|
||||
async function getApplicationSummaryTool(args) {
|
||||
console.error('[ADTools] Getting application summary');
|
||||
|
||||
const summary = adService.getApplicationSummary();
|
||||
// État par (application, type) — seules les entrées en cache sont
|
||||
// détaillées, la sortie reste bornée quel que soit le nombre
|
||||
// d'applications interrogées (D24, D26).
|
||||
const adByApplication = adService.getApplicationSummary();
|
||||
const workflowsByApplication = workflowService.getCacheStatus();
|
||||
|
||||
// Calculate totals
|
||||
let totalCached = 0;
|
||||
let cachedEntries = 0;
|
||||
let totalElements = 0;
|
||||
const cachedTypes = [];
|
||||
const uncachedTypes = [];
|
||||
|
||||
Object.entries(summary).forEach(([type, info]) => {
|
||||
if (info.cached) {
|
||||
totalCached++;
|
||||
Object.values(adByApplication).forEach(types => {
|
||||
Object.values(types).forEach(info => {
|
||||
cachedEntries++;
|
||||
totalElements += info.count;
|
||||
cachedTypes.push(type);
|
||||
} else {
|
||||
uncachedTypes.push(type);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const availableTypes = adService.getAvailableTypes();
|
||||
|
||||
return {
|
||||
content: [{
|
||||
@@ -159,14 +176,14 @@ async function getApplicationSummaryTool(args) {
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
summary: {
|
||||
totalTypes: Object.keys(summary).length,
|
||||
cachedTypes: totalCached,
|
||||
uncachedTypes: uncachedTypes.length,
|
||||
totalElements: totalElements
|
||||
availableTypes: availableTypes.length,
|
||||
cachedEntries,
|
||||
totalElements,
|
||||
applications: Object.keys(adByApplication)
|
||||
},
|
||||
elementCounts: summary,
|
||||
cached: cachedTypes,
|
||||
notCached: uncachedTypes
|
||||
adElementsByApplication: adByApplication,
|
||||
workflowCachesByApplication: workflowsByApplication,
|
||||
note: 'Caches AD par (application, type) et caches workflows par application — chargés paresseusement à la première demande. Types valides via list_ad_types.'
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
@@ -176,11 +193,11 @@ async function getApplicationSummaryTool(args) {
|
||||
* Tool: get_ad_elements
|
||||
*/
|
||||
async function getADElementsTool(args) {
|
||||
const { element_type, limit = 100 } = args;
|
||||
const { element_type, limit = 100, application } = args;
|
||||
|
||||
console.error(`[ADTools] Getting ${element_type} elements (limit: ${limit})`);
|
||||
console.error(`[ADTools] Getting ${element_type} elements (limit: ${limit}, application: ${application || '(profil)'})`);
|
||||
|
||||
const elements = await adService.getElements(element_type);
|
||||
const elements = await adService.getElements(element_type, application);
|
||||
|
||||
// Limit results
|
||||
const limitedElements = elements.slice(0, Math.min(limit, 1000));
|
||||
@@ -203,6 +220,7 @@ async function getADElementsTool(args) {
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
elementType: element_type,
|
||||
...(application ? { application } : {}),
|
||||
count: elements.length,
|
||||
returned: mappedElements.length,
|
||||
elements: mappedElements
|
||||
@@ -215,11 +233,11 @@ async function getADElementsTool(args) {
|
||||
* Tool: search_ad_elements
|
||||
*/
|
||||
async function searchADElementsTool(args) {
|
||||
const { element_type, query, limit = 50 } = args;
|
||||
const { element_type, query, limit = 50, application } = args;
|
||||
|
||||
console.error(`[ADTools] Searching ${element_type}: query="${query}", limit=${limit}`);
|
||||
console.error(`[ADTools] Searching ${element_type}: query="${query}", limit=${limit}, application=${application || '(profil)'}`);
|
||||
|
||||
const results = await adService.searchElements(element_type, query, limit);
|
||||
const results = await adService.searchElements(element_type, query, limit, application);
|
||||
|
||||
// Map to simplified format
|
||||
const mappedResults = results.map(e => ({
|
||||
@@ -229,14 +247,25 @@ async function searchADElementsTool(args) {
|
||||
code: e.code || e.Code
|
||||
}));
|
||||
|
||||
// L5.4 : même correctif que search_workflows — l'application interrogée est
|
||||
// toujours rappelée, et un résultat vide signale que les huit autres n'ont
|
||||
// pas été regardées. Le hint se construit depuis la liste d'applications
|
||||
// DÉJÀ en cache : aucun appel réseau, aucun préchargement (D26).
|
||||
const effectiveApplication = adService.resolveApplication(application);
|
||||
const hint = mappedResults.length === 0
|
||||
? workflowService.buildOtherApplicationsHint(effectiveApplication, `élément ${element_type}`)
|
||||
: null;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
elementType: element_type,
|
||||
application: effectiveApplication,
|
||||
query,
|
||||
count: mappedResults.length,
|
||||
...(hint ? { hint } : {}),
|
||||
elements: mappedResults
|
||||
}, null, 2)
|
||||
}]
|
||||
@@ -247,11 +276,11 @@ async function searchADElementsTool(args) {
|
||||
* Tool: get_ad_element_details
|
||||
*/
|
||||
async function getADElementDetailsTool(args) {
|
||||
const { element_type, element_id } = args;
|
||||
const { element_type, element_id, application } = args;
|
||||
|
||||
console.error(`[ADTools] Getting ${element_type} details: ${element_id}`);
|
||||
console.error(`[ADTools] Getting ${element_type} details: ${element_id} (application: ${application || '(profil)'})`);
|
||||
|
||||
const element = await adService.getElementDetails(element_type, element_id);
|
||||
const element = await adService.getElementDetails(element_type, element_id, application);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
@@ -259,6 +288,7 @@ async function getADElementDetailsTool(args) {
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
elementType: element_type,
|
||||
...(application ? { application } : {}),
|
||||
element
|
||||
}, null, 2)
|
||||
}]
|
||||
|
||||
+65
-16
@@ -1,4 +1,7 @@
|
||||
const apiService = require('../services/api-service').getInstance();
|
||||
const entityResolver = require('../services/entity-resolver');
|
||||
const { assertValidQueryType } = require('../services/wms-query-service');
|
||||
const { fitToCap, truncationSignal } = require('../services/response-limit');
|
||||
|
||||
/**
|
||||
* Tools MCP pour interagir avec les APIs WMS
|
||||
@@ -15,10 +18,11 @@ function listTools() {
|
||||
description: 'Appelle l\'API Query du WMS pour interroger des entités (Containers, Stocks, Tasks, Products, etc.)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
entity_type: {
|
||||
type: 'string',
|
||||
description: 'Type d\'entité (Containers, Stocks, ProductLocations, Tasks, Products, Accounts, Suppliers, Kits, Aliases, InboundOrders, Receptions, OutboundOrders)',
|
||||
description: 'Type d\'entité — nom AD (Container) ou TableName (Containers), insensible à la casse, résolu via l\'API Metadata. Ex: Containers, Stocks, ProductLocations, Tasks, Products, Accounts, Suppliers, Kits, Alias, InboundOrders, Receptions, OutboundOrders. Liste complète via get_entity_metadata.',
|
||||
},
|
||||
expression: {
|
||||
type: 'string',
|
||||
@@ -34,6 +38,11 @@ function listTools() {
|
||||
description: 'Limite de résultats (défaut: 100)',
|
||||
default: 100,
|
||||
},
|
||||
query_type: {
|
||||
type: 'number',
|
||||
description: 'QueryContextType (défaut: 0 = Reading — statuts en chaînes, à garder sauf raison explicite). Opt-in : 1 = Writing (statuts en ÉNUMÉRATIONS — les comparaisons de chaînes comme == "Release" ÉCHOUENT), 2 = DataWarehouse (souvent non configuré), 3 = Metrics (modèle de données distinct). En query_type != 0, un nom d\'entité inconnu du Metadata Reading est transmis tel quel avec un warning. ATTENTION VOLUME : une ligne Writing est un agrégat complet sérialisé — 95 288 caractères mesurés pour UNE ligne Products, contre ~4 500 en Reading. La réponse est plafonnée (MAX_QUERY_RESPONSE_CHARS) et les lignes en trop sont écartées avec un signal truncated.',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
required: ['entity_type'],
|
||||
},
|
||||
@@ -43,6 +52,7 @@ function listTools() {
|
||||
description: 'Exécute une commande WMS (ATTENTION: peut modifier des données). Toujours récupérer la commande via get_ad_elements/get_ad_element_details avant d\'exécuter.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
command_name: {
|
||||
type: 'string',
|
||||
@@ -82,11 +92,26 @@ async function executeTool(name, args) {
|
||||
* Tool: call_query_api
|
||||
*/
|
||||
async function callQueryAPI(args) {
|
||||
const { entity_type, expression = 'z => z', filter, limit = 100 } = args;
|
||||
const { entity_type, expression = 'z => z', filter, limit = 100, query_type = 0 } = args;
|
||||
|
||||
// Conservé hors du try : si la requête échoue ensuite côté WMS, le warning
|
||||
// de résolution (nom hors Reading) reste dans la réponse d'erreur.
|
||||
let resolution = null;
|
||||
|
||||
try {
|
||||
// Garde de valeur avant tout réseau (D25) — le wrapper D23 ne valide pas
|
||||
// les valeurs.
|
||||
const queryType = assertValidQueryType(query_type);
|
||||
|
||||
// Résolution Name/TableName -> TableName (D21) — échec avant appel réseau
|
||||
// sur nom inconnu, sauf en query_type != 0 (passage tel quel + warning, D25).
|
||||
resolution = await entityResolver.resolveEntityType(entity_type, {
|
||||
allowUnknown: queryType !== 0,
|
||||
});
|
||||
const { tableName, warning } = resolution;
|
||||
|
||||
// Expression = Context.Entity + optional Where + OrderBy (required by EF when Take is used)
|
||||
let linqExpression = `Context.${entity_type}`;
|
||||
let linqExpression = `Context.${tableName}`;
|
||||
if (filter) {
|
||||
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
|
||||
linqExpression += `.Where(${whereExpr})`;
|
||||
@@ -96,24 +121,45 @@ async function callQueryAPI(args) {
|
||||
const result = await apiService.executeQuery(linqExpression, {
|
||||
take: limit || undefined,
|
||||
select: expression !== 'z => z' ? expression : undefined,
|
||||
queryType,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
const head = {
|
||||
success: true,
|
||||
entityType: entity_type,
|
||||
result,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
resolvedTableName: tableName,
|
||||
...(warning ? { warning } : {}),
|
||||
...(queryType !== 0 ? { queryType } : {}),
|
||||
};
|
||||
|
||||
// Garde de taille (D24) : une SEULE ligne Writing faisait 95 288 caractères
|
||||
// — le modèle Writing sérialise l'agrégat complet. On écarte des lignes
|
||||
// entières ; sous le plafond, la réponse est strictement celle d'avant.
|
||||
if (!Array.isArray(result)) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ ...head, result }, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
const buildText = (kept) => {
|
||||
const payload = { ...head };
|
||||
if (kept < result.length) {
|
||||
// Cet outil ne porte pas de champ de total : on l'ajoute (D24).
|
||||
payload.totalRows = result.length;
|
||||
Object.assign(payload, truncationSignal({
|
||||
returned: kept,
|
||||
total: result.length,
|
||||
queryType,
|
||||
unit: 'ligne',
|
||||
feminine: true,
|
||||
}));
|
||||
}
|
||||
payload.result = result.slice(0, kept);
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
const { text } = fitToCap(result.length, buildText);
|
||||
return { content: [{ type: 'text', text }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
@@ -123,6 +169,8 @@ async function callQueryAPI(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'call_query_api',
|
||||
...(resolution?.warning ? { warning: resolution.warning } : {}),
|
||||
},
|
||||
null,
|
||||
2
|
||||
@@ -168,6 +216,7 @@ async function executeCommand(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'execute_command',
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -30,6 +30,7 @@ Examples:
|
||||
- get_system_parameters(search="CROSSDOCK") — parameters whose code/description matches`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
@@ -48,6 +49,16 @@ Examples:
|
||||
description: 'If true, return only parameters that have at least one warehouse override (default: false).',
|
||||
default: false,
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of parameters returned per call (default: 50). The full unfiltered list is ~70,000 characters — raise this only if you really need everything at once.',
|
||||
default: 50,
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Number of matching parameters to skip, for pagination (default: 0). Combine with limit to walk the full list.',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -63,9 +74,18 @@ async function executeTool(name, args) {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS_LIMIT = 50;
|
||||
|
||||
async function getSystemParameters(args) {
|
||||
const { warehouse, param_class, search, only_overridden = false } = args || {};
|
||||
|
||||
// Bornes de pagination — valeurs invalides ramenées aux défauts, la
|
||||
// validation du wrapper (D23) ne contrôle que les noms de paramètres.
|
||||
const rawLimit = Number(args && args.limit);
|
||||
const limit = Number.isFinite(rawLimit) && rawLimit >= 1 ? Math.floor(rawLimit) : DEFAULT_PARAMS_LIMIT;
|
||||
const rawOffset = Number(args && args.offset);
|
||||
const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? Math.floor(rawOffset) : 0;
|
||||
|
||||
try {
|
||||
// Both entities are small (a few hundred rows max) — fetch fully and merge
|
||||
// client-side to avoid LINQ string-injection and null-field pitfalls.
|
||||
@@ -123,18 +143,33 @@ async function getSystemParameters(args) {
|
||||
|
||||
rows.sort((a, b) => String(a.code).localeCompare(String(b.code)));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
// Pagination (L3.1) : sans elle la sortie sans filtre atteint ~70 000
|
||||
// caractères et se fait rejeter par les clients MCP. totalParameters est
|
||||
// le total correspondant aux filtres, AVANT pagination — le signal
|
||||
// truncated se vérifie donc depuis la réponse : offset + returned < total.
|
||||
const matched = rows.length;
|
||||
const page = rows.slice(offset, offset + limit);
|
||||
const truncated = offset + page.length < matched;
|
||||
|
||||
const payload = {
|
||||
success: true,
|
||||
warehouse: warehouse || '(none — effective value = default)',
|
||||
filters: { param_class: param_class || null, search: search || null, only_overridden },
|
||||
totalParameters: Array.isArray(parameters) ? parameters.length : 0,
|
||||
totalParameters: matched,
|
||||
totalOverrides: Array.isArray(paramValues) ? paramValues.length : 0,
|
||||
returned: rows.length,
|
||||
parameters: rows,
|
||||
}, null, 2),
|
||||
returned: page.length,
|
||||
offset,
|
||||
};
|
||||
if (truncated) {
|
||||
payload.truncated = true;
|
||||
payload.hint = `Showing parameters ${offset + 1}-${offset + page.length} of ${matched}. Call again with offset=${offset + page.length} for the next page, or narrow the result with param_class / search.`;
|
||||
}
|
||||
payload.parameters = page;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify(payload, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
+42
-14
@@ -15,6 +15,7 @@ function listTools() {
|
||||
description: 'Lit les dernières lignes des fichiers de logs',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
count: {
|
||||
type: 'number',
|
||||
@@ -33,6 +34,7 @@ function listTools() {
|
||||
description: 'Liste tous les fichiers de logs disponibles sous LOGS_PATH avec leur taille et date de modification',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
@@ -41,6 +43,7 @@ function listTools() {
|
||||
description: 'Recherche un mot-clé dans les fichiers de logs avec contexte',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
keyword: {
|
||||
type: 'string',
|
||||
@@ -110,7 +113,7 @@ async function listLogFiles() {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message }, null, 2),
|
||||
text: JSON.stringify({ success: false, error: err.message, tool: 'list_log_files' }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
@@ -153,6 +156,7 @@ async function readRecentLogs(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'read_recent_logs',
|
||||
},
|
||||
null,
|
||||
2
|
||||
@@ -171,24 +175,47 @@ async function searchLogs(args) {
|
||||
const { keyword, max_results = 50, context_lines = 2 } = args;
|
||||
|
||||
try {
|
||||
// Garde d'entrée : sans elle, un keyword absent plantait en
|
||||
// "Cannot read properties of undefined (reading 'toLowerCase')".
|
||||
if (typeof keyword !== 'string' || keyword.trim() === '') {
|
||||
throw new Error('Le paramètre "keyword" (mot-clé à rechercher) est requis. Exemple : search_logs({"keyword": "Execute error"}).');
|
||||
}
|
||||
|
||||
const result = await logService.searchLogs(keyword, max_results, context_lines);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
// Garde-fou de taille (L3.1) : max_results borne le nombre de résultats,
|
||||
// pas le volume — les context_lines multiplient la taille (55 954 chars
|
||||
// mesurés avec les seuls défauts, rejetés par le client MCP). Au-delà du
|
||||
// plafond on écarte des résultats ENTIERS (jamais coupés au milieu de
|
||||
// leur contexte) et on le signale : truncated + omitted + hint.
|
||||
const cap = parseInt(process.env.MAX_LOG_SEARCH_CHARS) || 25000;
|
||||
|
||||
const buildText = (kept) => {
|
||||
const omitted = result.results.length - kept.length;
|
||||
const payload = {
|
||||
success: true,
|
||||
keyword: result.keyword,
|
||||
totalResults: result.totalResults,
|
||||
results: result.results,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
returned: kept.length,
|
||||
};
|
||||
if (omitted > 0) {
|
||||
payload.truncated = true;
|
||||
payload.omitted = omitted;
|
||||
payload.hint = `Plafond de taille de réponse atteint (${cap} caractères) : ${kept.length} résultat(s) renvoyé(s) sur ${result.totalResults}, ${omitted} écarté(s). Affinez le keyword, réduisez context_lines ou baissez max_results.`;
|
||||
}
|
||||
payload.results = kept;
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
let kept = result.results.slice();
|
||||
let text = buildText(kept);
|
||||
while (text.length > cap && kept.length > 0) {
|
||||
kept.pop();
|
||||
text = buildText(kept);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -199,6 +226,7 @@ async function searchLogs(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'search_logs',
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -16,6 +16,7 @@ Use this to discover the exact field names and types for any entity before build
|
||||
- With entity_name (partial match ok, case-insensitive): returns field names + types for that entity`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
entity_name: {
|
||||
type: 'string',
|
||||
@@ -34,6 +35,7 @@ Examples:
|
||||
- generic_search() — list available search categories`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
@@ -107,6 +109,7 @@ async function getEntityMetadata(args) {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `No entity matching "${entity_name}" found`,
|
||||
tool: 'get_entity_metadata',
|
||||
availableCount: Array.isArray(entities) ? entities.length : '?',
|
||||
hint: 'Call get_entity_metadata without entity_name to see all entities',
|
||||
}, null, 2),
|
||||
@@ -148,7 +151,7 @@ async function getEntityMetadata(args) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message }, null, 2),
|
||||
text: JSON.stringify({ success: false, error: err.message, tool: 'get_entity_metadata' }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
@@ -191,7 +194,7 @@ async function genericSearch(args) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message }, null, 2),
|
||||
text: JSON.stringify({ success: false, error: err.message, tool: 'generic_search' }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ function listTools() {
|
||||
description: 'List all WMS profiles configured in .env (AD, LIMAGRAIN, ...) with their host and tenant. Use this to see which WMS backends are available.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
@@ -24,6 +25,7 @@ function listTools() {
|
||||
description: 'Return the currently active WMS profile (name, host, tenant, application). If no profile is active, returns an error explaining that switch_wms_profile must be called first.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
@@ -32,6 +34,7 @@ function listTools() {
|
||||
description: 'Switch the active WMS profile. Resets the OAuth token and clears workflow/AD caches so the next API call targets the new backend. Use list_wms_profiles to see valid names.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
profile: {
|
||||
type: 'string',
|
||||
@@ -107,6 +110,7 @@ function getCurrentProfileTool() {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'get_current_wms_profile',
|
||||
profiles: profileManager.listProfiles(),
|
||||
}, null, 2),
|
||||
}],
|
||||
@@ -124,6 +128,7 @@ function switchProfileTool(args) {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: 'Missing "profile" argument',
|
||||
tool: 'switch_wms_profile',
|
||||
profiles: profileManager.listProfiles(),
|
||||
}, null, 2),
|
||||
}],
|
||||
@@ -153,6 +158,7 @@ function switchProfileTool(args) {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'switch_wms_profile',
|
||||
profiles: profileManager.listProfiles(),
|
||||
}, null, 2),
|
||||
}],
|
||||
|
||||
+101
-26
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
const wmsQueryService = require('../services/wms-query-service');
|
||||
const { fitToCap, truncationSignal } = require('../services/response-limit');
|
||||
|
||||
/**
|
||||
* List available WMS query tools
|
||||
@@ -13,8 +14,9 @@ function listTools() {
|
||||
{
|
||||
name: 'query_wms_entities',
|
||||
description: `Query WMS entities using LINQ expressions. Returns rows (up to 1000).
|
||||
Uses QueryExecute with QueryType=Reading — status fields are STRINGS (enum names, not integers).
|
||||
Common entities: Products, Containers, Tasks, Stocks, ProductLocations, Location, InboundOrders, OutboundOrders, Receptions, Accounts, Suppliers, Kits, Aliases.
|
||||
Uses QueryExecute with QueryType=Reading by default — status fields are STRINGS (enum names, not integers). Other contexts via query_type (opt-in, see the parameter warning).
|
||||
Common entities: Products, Containers, Tasks, Stocks, ProductLocations, Locations, InboundOrders, OutboundOrders, Receptions, Accounts, Suppliers, Kits, Alias. Full list via get_entity_metadata.
|
||||
entity_type accepts the AD entity name (Container) or the TableName (Containers), case-insensitive — resolved via the Metadata API.
|
||||
|
||||
IMPORTANT — before building a filter with a status/enum field:
|
||||
1. Check docs first: read resource docs://entities/ (e.g. easywms_reading_entites_outboundorder_OutboundOrderStatus for OutboundOrders)
|
||||
@@ -23,10 +25,11 @@ IMPORTANT — before building a filter with a status/enum field:
|
||||
Never guess enum string values — they differ between Reading and Writing models.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
entity_type: {
|
||||
type: 'string',
|
||||
description: 'Entity type (Products, Containers, Tasks, Stocks, ProductLocations, InboundOrders, OutboundOrders, Accounts, Suppliers, Kits, Aliases, Receptions)',
|
||||
description: 'Entity type — AD name (Container) or TableName (Containers), case-insensitive, resolved via the Metadata API. E.g. Products, Containers, Tasks, Stocks, ProductLocations, InboundOrders, OutboundOrders, Accounts, Suppliers, Kits, Alias, Receptions.',
|
||||
},
|
||||
select_expression: {
|
||||
type: 'string',
|
||||
@@ -42,6 +45,11 @@ Never guess enum string values — they differ between Reading and Writing model
|
||||
description: 'Maximum results to return (default: 100, max: 1000)',
|
||||
default: 100,
|
||||
},
|
||||
query_type: {
|
||||
type: 'number',
|
||||
description: 'QueryContextType (default: 0 = Reading — status fields are strings, keep it unless you know why). Opt-in: 1 = Writing (status fields become ENUMS — string comparisons like == "Release" FAIL), 2 = DataWarehouse (often not configured), 3 = Metrics (different data model). Entity names unknown to the Reading metadata are passed through as-is with a warning when query_type != 0. VOLUME WARNING: a Writing row is a full serialised aggregate (navigations, $id…) — 95 288 characters measured for ONE Products row, against ~4 500 in Reading. The response is capped (MAX_QUERY_RESPONSE_CHARS) and excess rows are dropped whole, with a truncated signal.',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
required: ['entity_type'],
|
||||
},
|
||||
@@ -51,6 +59,7 @@ Never guess enum string values — they differ between Reading and Writing model
|
||||
description: 'Get the schema/structure of a WMS entity by querying one sample record',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
entity_type: {
|
||||
type: 'string',
|
||||
@@ -83,6 +92,7 @@ Verified values (curl-tested):
|
||||
(sur un emplacement: ajouter && z.LocationCode == "X")`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
entity_type: {
|
||||
type: 'string',
|
||||
@@ -92,6 +102,11 @@ Verified values (curl-tested):
|
||||
type: 'string',
|
||||
description: 'Optional LINQ filter condition. Status fields are strings (enum names from Reading model). Always verify enum values via docs://entities/ before use.',
|
||||
},
|
||||
query_type: {
|
||||
type: 'number',
|
||||
description: 'QueryContextType (default: 0 = Reading — status fields are strings, keep it unless you know why). Opt-in: 1 = Writing (status fields become ENUMS — string comparisons like == "Release" FAIL), 2 = DataWarehouse (often not configured), 3 = Metrics (different data model). Entity names unknown to the Reading metadata are passed through as-is with a warning when query_type != 0.',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
required: ['entity_type'],
|
||||
},
|
||||
@@ -101,6 +116,7 @@ Verified values (curl-tested):
|
||||
description: 'Search for a keyword across multiple WMS entities',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
keyword: {
|
||||
type: 'string',
|
||||
@@ -162,39 +178,63 @@ async function executeTool(name, args) {
|
||||
|
||||
/**
|
||||
* Tool: query_wms_entities
|
||||
*
|
||||
* Garde de taille (D24) : 200 lignes Reading faisaient 957 234 caractères, au
|
||||
* delà du seuil de rejet du client MCP. On écarte des lignes ENTIÈRES depuis la
|
||||
* fin ; sous le plafond, la réponse est strictement celle d'avant.
|
||||
*/
|
||||
async function queryWmsEntities(args) {
|
||||
const { entity_type, select_expression = 'z => z', filter, limit = 100 } = args;
|
||||
const { entity_type, select_expression = 'z => z', filter, limit = 100, query_type = 0 } = args;
|
||||
|
||||
console.error(`[WMSQueryTools] Querying ${entity_type}: limit=${limit}`);
|
||||
console.error(`[WMSQueryTools] Querying ${entity_type}: limit=${limit} query_type=${query_type}`);
|
||||
|
||||
const result = await wmsQueryService.queryEntities(
|
||||
entity_type,
|
||||
select_expression,
|
||||
filter,
|
||||
limit
|
||||
limit,
|
||||
query_type
|
||||
);
|
||||
|
||||
const { data, ...head } = result;
|
||||
|
||||
// Une réponse non tabulaire (forme inattendue) ne se borne pas par lignes.
|
||||
if (!Array.isArray(data)) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
...result
|
||||
}, null, 2)
|
||||
}]
|
||||
content: [{ type: 'text', text: JSON.stringify({ success: true, ...result }, null, 2) }]
|
||||
};
|
||||
}
|
||||
|
||||
// `count` (dans head) porte déjà le total avant la coupe — c'est le total
|
||||
// exigé par D24, inutile d'en ajouter un second.
|
||||
const buildText = (kept) => {
|
||||
const payload = { success: true, ...head };
|
||||
if (kept < data.length) {
|
||||
Object.assign(payload, truncationSignal({
|
||||
returned: kept,
|
||||
total: data.length,
|
||||
queryType: query_type,
|
||||
unit: 'ligne',
|
||||
feminine: true,
|
||||
}));
|
||||
}
|
||||
payload.data = data.slice(0, kept);
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
const { text } = fitToCap(data.length, buildText);
|
||||
return { content: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: count_wms_entities
|
||||
*/
|
||||
async function countWmsEntities(args) {
|
||||
const { entity_type, filter } = args;
|
||||
const { entity_type, filter, query_type = 0 } = args;
|
||||
|
||||
console.error(`[WMSQueryTools] Counting ${entity_type}${filter ? ` where ${filter}` : ''}`);
|
||||
console.error(`[WMSQueryTools] Counting ${entity_type}${filter ? ` where ${filter}` : ''} query_type=${query_type}`);
|
||||
|
||||
const result = await wmsQueryService.countEntities(entity_type, filter || null);
|
||||
const result = await wmsQueryService.countEntities(entity_type, filter || null, query_type);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
@@ -256,17 +296,52 @@ async function searchWmsData(args) {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
keyword,
|
||||
totalFound,
|
||||
results
|
||||
}, null, 2)
|
||||
}]
|
||||
// Garde de taille (D24) : search_wms_data("PAL") faisait 847 543 caractères.
|
||||
// L'unité écartée est un RÉSULTAT entier ; les résultats gardés sont répartis
|
||||
// en tourniquet entre les entités, pour qu'une entité volumineuse placée en
|
||||
// tête n'efface pas silencieusement les suivantes — c'est exactement le
|
||||
// faux négatif que L5.4 corrige par ailleurs.
|
||||
const entityKeys = Object.keys(results).filter(k => Array.isArray(results[k].data));
|
||||
const slots = [];
|
||||
const maxRows = entityKeys.reduce((m, k) => Math.max(m, results[k].data.length), 0);
|
||||
for (let i = 0; i < maxRows; i++) {
|
||||
for (const k of entityKeys) {
|
||||
if (i < results[k].data.length) slots.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
const buildText = (kept) => {
|
||||
const keepCount = {};
|
||||
entityKeys.forEach(k => { keepCount[k] = 0; });
|
||||
for (let i = 0; i < kept; i++) keepCount[slots[i]]++;
|
||||
|
||||
const payload = { success: true, keyword, totalFound };
|
||||
if (kept < slots.length) {
|
||||
Object.assign(payload, truncationSignal({
|
||||
returned: kept,
|
||||
total: slots.length,
|
||||
unit: 'résultat',
|
||||
extraHint: 'Les résultats gardés sont répartis entre les entités : voir returned/omitted par entité. ' +
|
||||
'Relancez query_wms_entities entité par entité avec un filter plus précis pour voir le reste.',
|
||||
}));
|
||||
}
|
||||
|
||||
payload.results = {};
|
||||
for (const [k, v] of Object.entries(results)) {
|
||||
if (!Array.isArray(v.data)) {
|
||||
payload.results[k] = v; // entité en erreur : { error, count }, déjà minuscule
|
||||
continue;
|
||||
}
|
||||
const keptRows = v.data.slice(0, keepCount[k]);
|
||||
payload.results[k] = keptRows.length < v.data.length
|
||||
? { count: v.count, returned: keptRows.length, omitted: v.data.length - keptRows.length, data: keptRows }
|
||||
: { count: v.count, data: keptRows };
|
||||
}
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
const { text } = fitToCap(slots.length, buildText);
|
||||
return { content: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
+149
-34
@@ -5,6 +5,12 @@
|
||||
|
||||
const workflowService = require('../services/workflow-service');
|
||||
|
||||
// Taille par défaut d'une tranche du blob `data` de get_workflow_details.
|
||||
// Ordre de grandeur cible de D24 (~20-25 000 caractères par réponse) : avec
|
||||
// l'échappement JSON et les métadonnées, 20 000 caractères de blob tiennent
|
||||
// sous ~23 000 caractères de réponse.
|
||||
const DEFAULT_MAX_DATA_CHARS = 20000;
|
||||
|
||||
/**
|
||||
* List available workflow tools
|
||||
*/
|
||||
@@ -12,35 +18,56 @@ function listTools() {
|
||||
return [
|
||||
{
|
||||
name: 'search_workflows',
|
||||
description: 'Search workflows by name, description, or code. Returns matching workflows with metadata. Workflows are lazy-loaded from API on first request and cached for 1 hour.',
|
||||
description: 'Search workflows by name. Returns matching workflows with metadata. Workflows are lazy-loaded from API on first request and cached for 1 hour, per application. Client-specific workflows (CST_* prefix) live in the "CustomApp" application — pass application: "CustomApp" to search them.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query (searches in name, description, code)',
|
||||
description: 'Search query (searches in workflow name)',
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
description: 'Filter by workflow category/application',
|
||||
description: 'Filter by the applicationName field of the returned workflows (workflows have no category field). Since `application` selects which application is fetched, all its workflows share the same applicationName — prefer `application` to change scope; `category` only narrows within the fetched set.',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum results to return (default: 50)',
|
||||
default: 50,
|
||||
},
|
||||
application: {
|
||||
type: 'string',
|
||||
description: 'AD application whose workflows are searched (default: the active profile\'s application, usually EasyWMS). Client-specific workflows live in "CustomApp". Full list via list_workflow_categories.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_workflow_details',
|
||||
description: 'Get full details of a specific workflow by ID or code',
|
||||
description: `Get full details of a specific workflow by ID or name.
|
||||
The EasyBuilder definition (the \`data\` blob) is large — 71 512 characters for a StackerCrane workflow, 92 362 for CST_SendRejectContainersToPK — so it is returned as a VERBATIM WINDOW (max_data_chars / data_offset). Workflow metadata is always complete; only \`data\` is windowed. dataTotalChars always carries the full blob size, and concatenating the slices in offset order reproduces the definition byte for byte.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
workflow_id: {
|
||||
type: 'string',
|
||||
description: 'Workflow ID or code',
|
||||
description: 'Workflow ID or exact name',
|
||||
},
|
||||
application: {
|
||||
type: 'string',
|
||||
description: 'AD application the workflow belongs to (default: the active profile\'s application, usually EasyWMS). Client-specific workflows (CST_*) live in "CustomApp".',
|
||||
},
|
||||
max_data_chars: {
|
||||
type: 'number',
|
||||
description: `Maximum number of characters of the \`data\` blob returned by this call (default: ${DEFAULT_MAX_DATA_CHARS}). The slice is verbatim — never summarised, reformatted or parsed. Pass 0 for metadata only.`,
|
||||
default: DEFAULT_MAX_DATA_CHARS,
|
||||
},
|
||||
data_offset: {
|
||||
type: 'number',
|
||||
description: 'Character offset in the `data` blob where the returned slice starts (default: 0). When the response carries truncated: true, its hint gives the next offset to pass here.',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
required: ['workflow_id'],
|
||||
@@ -48,10 +75,16 @@ function listTools() {
|
||||
},
|
||||
{
|
||||
name: 'list_workflow_categories',
|
||||
description: 'List all available workflow categories',
|
||||
description: 'List the AD applications declared on the tenant (Application/GetAll) with their workflow counts where already loaded. Workflows have no category field — the application is the only grouping. Use the `application` parameter of the workflow/AD tools to query a specific one (e.g. "CustomApp" for client-specific CST_* workflows).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
application: {
|
||||
type: 'string',
|
||||
description: 'Load and count the workflows of this application (default: the active profile\'s application). Other applications are listed without loading them.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -95,50 +128,116 @@ async function executeTool(name, args) {
|
||||
* Tool: search_workflows
|
||||
*/
|
||||
async function searchWorkflows(args) {
|
||||
const { query, category, limit = 50 } = args;
|
||||
const { query, category, limit = 50, application } = args;
|
||||
|
||||
console.error(`[WorkflowTools] Searching workflows: query="${query}", category="${category}", limit=${limit}`);
|
||||
console.error(`[WorkflowTools] Searching workflows: query="${query}", category="${category}", limit=${limit}, application=${application || '(profil)'}`);
|
||||
|
||||
const results = await workflowService.searchWorkflows(query, category, limit);
|
||||
const results = await workflowService.searchWorkflows(query, category, limit, application);
|
||||
|
||||
// L5.4 : l'application interrogée est TOUJOURS rappelée (pas seulement quand
|
||||
// elle a été passée), et un résultat vide dit qu'une seule application sur
|
||||
// neuf a été regardée — c'est ce silence qui avait fait conclure à tort à
|
||||
// l'absence de workflows CST_.
|
||||
const effectiveApplication = workflowService.resolveApplication(application);
|
||||
const hint = results.length === 0
|
||||
? workflowService.buildOtherApplicationsHint(effectiveApplication, 'workflow')
|
||||
: null;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
application: effectiveApplication,
|
||||
count: results.length,
|
||||
workflows: results.map(w => ({
|
||||
id: w.Id,
|
||||
code: w.Code,
|
||||
name: w.Name,
|
||||
category: w.Category,
|
||||
description: w.Description,
|
||||
version: w.Version,
|
||||
created: w.Created,
|
||||
modified: w.Modified
|
||||
}))
|
||||
...(hint ? { hint } : {}),
|
||||
// Clés réelles de l'API AD (minuscules, cf. D5) : id, name, version,
|
||||
// applicationName, commonInfo. Pas de code/category/description.
|
||||
workflows: results.map(w => {
|
||||
const commonInfo = w.commonInfo || w.CommonInfo || {};
|
||||
return {
|
||||
id: w.id || w.Id,
|
||||
name: w.name || w.Name,
|
||||
applicationName: w.applicationName || w.ApplicationName,
|
||||
version: w.version || w.Version,
|
||||
createdBy: commonInfo.createdBy,
|
||||
createDate: commonInfo.createDate,
|
||||
updateDate: commonInfo.updateDate
|
||||
};
|
||||
})
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde de valeur des paramètres de fenêtre. Le wrapper D23 valide les noms de
|
||||
* paramètres, pas les valeurs — la garde vit donc ici, avant tout appel réseau.
|
||||
*/
|
||||
function assertWindowValue(value, fallback, paramName) {
|
||||
if (value == null) return fallback;
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
const attendu = paramName === 'max_data_chars'
|
||||
? `taille max de la tranche du blob data, défaut ${DEFAULT_MAX_DATA_CHARS}, 0 = métadonnées seules`
|
||||
: 'offset de départ dans le blob data, défaut 0';
|
||||
throw new Error(
|
||||
`${paramName} invalide : ${JSON.stringify(value)}. Attendu : un entier >= 0 (${attendu}).`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: get_workflow_details
|
||||
*
|
||||
* La définition EasyBuilder (blob `data`) fait à elle seule 71 512 caractères
|
||||
* sur un StackerCrane et 92 362 sur CST_SendRejectContainersToPK : la réponse
|
||||
* complète dépassait le seuil de rejet du client MCP (D24). On renvoie une
|
||||
* TRANCHE VERBATIM du blob (découpe de chaîne, rien d'autre) : les métadonnées
|
||||
* restent complètes, et concaténer les tranches dans l'ordre des offsets
|
||||
* reconstitue la définition à l'octet près. Ne jamais résumer ni « parser » ce
|
||||
* blob pour n'en renvoyer que des morceaux jugés utiles.
|
||||
*/
|
||||
async function getWorkflowDetails(args) {
|
||||
const { workflow_id } = args;
|
||||
const { workflow_id, application, max_data_chars, data_offset } = args;
|
||||
|
||||
console.error(`[WorkflowTools] Getting workflow details: ${workflow_id}`);
|
||||
const maxDataChars = assertWindowValue(max_data_chars, DEFAULT_MAX_DATA_CHARS, 'max_data_chars');
|
||||
const dataOffset = assertWindowValue(data_offset, 0, 'data_offset');
|
||||
|
||||
const workflow = await workflowService.getWorkflowDetails(workflow_id);
|
||||
console.error(`[WorkflowTools] Getting workflow details: ${workflow_id} (application: ${application || '(profil)'}, max_data_chars=${maxDataChars}, data_offset=${dataOffset})`);
|
||||
|
||||
const workflow = await workflowService.getWorkflowDetails(workflow_id, application);
|
||||
|
||||
const payload = { success: true, workflow };
|
||||
|
||||
// Seul un blob `data` textuel se fenêtre ; un workflow sans définition (ou
|
||||
// d'une forme inattendue) sort inchangé.
|
||||
if (typeof workflow?.data === 'string') {
|
||||
const total = workflow.data.length;
|
||||
const slice = workflow.data.slice(dataOffset, dataOffset + maxDataChars);
|
||||
const nextOffset = dataOffset + slice.length;
|
||||
|
||||
payload.workflow = { ...workflow, data: slice };
|
||||
// La taille totale est portée par TOUTE réponse : truncated se vérifie
|
||||
// depuis la réponse elle-même (D24).
|
||||
payload.dataTotalChars = total;
|
||||
payload.dataOffset = dataOffset;
|
||||
payload.returned = slice.length;
|
||||
|
||||
if (nextOffset < total) {
|
||||
payload.truncated = true;
|
||||
payload.hint =
|
||||
`Blob \`data\` tronqué : ${slice.length} caractère(s) sur ${total} renvoyé(s) depuis l'offset ${dataOffset}. ` +
|
||||
`Rappelez get_workflow_details avec les mêmes workflow_id/application et data_offset: ${nextOffset} pour la tranche ` +
|
||||
`suivante (max_data_chars change la taille des tranches). Les tranches sont verbatim : les concaténer dans l'ordre ` +
|
||||
`des offsets reconstitue la définition EasyBuilder à l'octet près.`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
workflow
|
||||
}, null, 2)
|
||||
text: JSON.stringify(payload, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
@@ -147,21 +246,37 @@ async function getWorkflowDetails(args) {
|
||||
* Tool: list_workflow_categories
|
||||
*/
|
||||
async function listWorkflowCategories(args) {
|
||||
console.error('[WorkflowTools] Listing workflow categories');
|
||||
const { application } = args || {};
|
||||
|
||||
const categories = await workflowService.listWorkflowCategories();
|
||||
const stats = await workflowService.getWorkflowStats();
|
||||
console.error(`[WorkflowTools] Listing applications (workflow groupings), application=${application || '(profil)'}`);
|
||||
|
||||
// La liste vient d'Application/GetAll (9 applications sur le tenant mesuré),
|
||||
// pas des applicationName du seul cache actif (D26). Seule l'application
|
||||
// demandée (ou celle du profil) est chargée — pas de préchargement des
|
||||
// autres (D10) : leurs comptes n'apparaissent que si déjà en cache.
|
||||
const applications = await workflowService.fetchApplications();
|
||||
const stats = await workflowService.getWorkflowStats(application);
|
||||
const cacheStatus = workflowService.getCacheStatus();
|
||||
|
||||
const enriched = applications.map(a => ({
|
||||
name: a.name,
|
||||
version: a.version,
|
||||
...(cacheStatus[a.name]
|
||||
? { workflowCount: cacheStatus[a.name].count, cacheAge: cacheStatus[a.name].age }
|
||||
: { workflowCount: null }),
|
||||
}));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
totalCategories: categories.length,
|
||||
categories,
|
||||
stats: {
|
||||
note: 'Workflows have no category field in the AD API — the application is the only grouping. workflowCount is only known for applications already loaded (lazy loading); pass application to search_workflows/get_ad_elements to load one.',
|
||||
totalApplications: applications.length,
|
||||
applications: enriched,
|
||||
loaded: {
|
||||
application: stats.application,
|
||||
totalWorkflows: stats.total,
|
||||
categoryCounts: stats.categoryCounts,
|
||||
cacheAge: stats.cacheAge
|
||||
}
|
||||
}, null, 2)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user