efarmz-slackbot-data 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.clever.json +12 -0
- package/.dockerignore +13 -0
- package/.env.example +28 -0
- package/.github/workflows/deploy-production.yaml +34 -0
- package/.prettierrc +6 -0
- package/.tasks/F1-bootstrap.md +110 -0
- package/.tasks/F2-domain-layer.md +173 -0
- package/.tasks/F3-application-layer.md +166 -0
- package/.tasks/F4-infrastructure-layer.md +229 -0
- package/.tasks/F5-config-main.md +160 -0
- package/.tasks/F6-schemas-deployment.md +129 -0
- package/CLAUDE.md +163 -0
- package/Dockerfile +15 -0
- package/PRD.md +119 -0
- package/docs/schemas/.gitkeep +0 -0
- package/docs/schemas/_guidelines.md +89 -0
- package/docs/schemas/efarmz_db.md +759 -0
- package/docs/schemas/example.md +16 -0
- package/eslint.config.mjs +18 -0
- package/package.json +54 -0
- package/releaserc.json +15 -0
- package/src/.gitkeep +0 -0
- package/src/application/agent/.gitkeep +0 -0
- package/src/application/agent/AgentContext.test.ts +263 -0
- package/src/application/agent/AgentContext.ts +93 -0
- package/src/application/agent/AgentLoop.test.ts +275 -0
- package/src/application/agent/AgentLoop.ts +101 -0
- package/src/application/agent/AgentRunResult.ts +11 -0
- package/src/application/agent/LLMMessage.ts +16 -0
- package/src/application/agent/tools/RunSqlTool.ts +23 -0
- package/src/application/formatting/.gitkeep +0 -0
- package/src/application/formatting/CsvRenderer.test.ts +162 -0
- package/src/application/formatting/CsvRenderer.ts +34 -0
- package/src/application/formatting/MonospaceTableRenderer.test.ts +129 -0
- package/src/application/formatting/MonospaceTableRenderer.ts +58 -0
- package/src/application/formatting/RenderedResponse.ts +7 -0
- package/src/application/formatting/ResponseRenderer.test.ts +159 -0
- package/src/application/formatting/ResponseRenderer.ts +39 -0
- package/src/application/formatting/ScalarRenderer.test.ts +36 -0
- package/src/application/formatting/ScalarRenderer.ts +12 -0
- package/src/application/usecases/.gitkeep +0 -0
- package/src/application/usecases/AnswerQuestion.test.ts +362 -0
- package/src/application/usecases/AnswerQuestion.ts +69 -0
- package/src/application/usecases/ParseQuestion.test.ts +39 -0
- package/src/application/usecases/ParseQuestion.ts +9 -0
- package/src/config/.gitkeep +0 -0
- package/src/config/Container.test.ts +35 -0
- package/src/config/Container.ts +74 -0
- package/src/config/constants.ts +9 -0
- package/src/config/env.test.ts +103 -0
- package/src/config/env.ts +41 -0
- package/src/domain/entities/.gitkeep +0 -0
- package/src/domain/entities/Conversation.test.ts +69 -0
- package/src/domain/entities/Conversation.ts +26 -0
- package/src/domain/entities/ConversationMessage.test.ts +49 -0
- package/src/domain/entities/ConversationMessage.ts +18 -0
- package/src/domain/entities/index.ts +2 -0
- package/src/domain/errors/.gitkeep +0 -0
- package/src/domain/errors/AgentLoopExceededError.ts +12 -0
- package/src/domain/errors/DomainError.test.ts +106 -0
- package/src/domain/errors/DomainError.ts +11 -0
- package/src/domain/errors/InvalidSqlError.ts +15 -0
- package/src/domain/errors/LLMError.ts +15 -0
- package/src/domain/errors/SchemaLoadError.ts +15 -0
- package/src/domain/errors/SqlExecutionError.ts +15 -0
- package/src/domain/errors/index.ts +15 -0
- package/src/domain/ports/.gitkeep +0 -0
- package/src/domain/ports/AdminLogger.ts +16 -0
- package/src/domain/ports/ConversationRepository.ts +10 -0
- package/src/domain/ports/LLMProvider.ts +33 -0
- package/src/domain/ports/Logger.ts +8 -0
- package/src/domain/ports/SchemaCatalog.ts +5 -0
- package/src/domain/ports/SlackMessenger.ts +8 -0
- package/src/domain/ports/SqlExecutor.ts +8 -0
- package/src/domain/ports/SqlValidator.ts +5 -0
- package/src/domain/ports/index.ts +17 -0
- package/src/domain/value-objects/.gitkeep +0 -0
- package/src/domain/value-objects/LLMProviderName.ts +6 -0
- package/src/domain/value-objects/QueryResult.test.ts +51 -0
- package/src/domain/value-objects/QueryResult.ts +18 -0
- package/src/domain/value-objects/Question.test.ts +59 -0
- package/src/domain/value-objects/Question.ts +22 -0
- package/src/domain/value-objects/QuestionFlags.test.ts +59 -0
- package/src/domain/value-objects/QuestionFlags.ts +18 -0
- package/src/domain/value-objects/ResponseRendering.ts +7 -0
- package/src/domain/value-objects/SqlQuery.test.ts +40 -0
- package/src/domain/value-objects/SqlQuery.ts +12 -0
- package/src/domain/value-objects/ThreadId.test.ts +68 -0
- package/src/domain/value-objects/ThreadId.ts +27 -0
- package/src/domain/value-objects/index.ts +13 -0
- package/src/infrastructure/llm/.gitkeep +0 -0
- package/src/infrastructure/llm/AnthropicLLMProvider.test.ts +229 -0
- package/src/infrastructure/llm/AnthropicLLMProvider.ts +45 -0
- package/src/infrastructure/llm/index.ts +4 -0
- package/src/infrastructure/llm/mappers/AnthropicMessageMapper.test.ts +173 -0
- package/src/infrastructure/llm/mappers/AnthropicMessageMapper.ts +34 -0
- package/src/infrastructure/llm/prompts/SystemPromptBuilder.test.ts +41 -0
- package/src/infrastructure/llm/prompts/SystemPromptBuilder.ts +31 -0
- package/src/infrastructure/llm/prompts/ToolDefinitions.ts +7 -0
- package/src/infrastructure/logging/.gitkeep +0 -0
- package/src/infrastructure/logging/PinoLogger.test.ts +59 -0
- package/src/infrastructure/logging/PinoLogger.ts +28 -0
- package/src/infrastructure/logging/index.ts +1 -0
- package/src/infrastructure/persistence/.gitkeep +0 -0
- package/src/infrastructure/persistence/InMemoryConversationRepository.test.ts +325 -0
- package/src/infrastructure/persistence/InMemoryConversationRepository.ts +69 -0
- package/src/infrastructure/persistence/PostgresPoolFactory.ts +11 -0
- package/src/infrastructure/persistence/PostgresSqlExecutor.test.ts +130 -0
- package/src/infrastructure/persistence/PostgresSqlExecutor.ts +34 -0
- package/src/infrastructure/persistence/index.ts +3 -0
- package/src/infrastructure/schemas/.gitkeep +0 -0
- package/src/infrastructure/schemas/FileSystemSchemaCatalog.test.ts +163 -0
- package/src/infrastructure/schemas/FileSystemSchemaCatalog.ts +35 -0
- package/src/infrastructure/schemas/index.ts +4 -0
- package/src/infrastructure/slack/.gitkeep +0 -0
- package/src/infrastructure/slack/BoltSlackMessenger.test.ts +59 -0
- package/src/infrastructure/slack/BoltSlackMessenger.ts +36 -0
- package/src/infrastructure/slack/SlackAdminLogger.test.ts +54 -0
- package/src/infrastructure/slack/SlackAdminLogger.ts +27 -0
- package/src/infrastructure/slack/SlackApp.ts +9 -0
- package/src/infrastructure/slack/handlers/AppMentionHandler.ts +52 -0
- package/src/infrastructure/slack/handlers/DirectMessageHandler.ts +65 -0
- package/src/infrastructure/slack/index.ts +5 -0
- package/src/infrastructure/sql/.gitkeep +0 -0
- package/src/infrastructure/sql/RegexSqlValidator.test.ts +242 -0
- package/src/infrastructure/sql/RegexSqlValidator.ts +53 -0
- package/src/infrastructure/sql/index.ts +1 -0
- package/src/main.ts +19 -0
- package/tsconfig.json +23 -0
- package/vitest.config.ts +15 -0
- package/vitest.setup.ts +23 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
---
|
|
2
|
+
schema: efarmz_db
|
|
3
|
+
sgbd: postgresql
|
|
4
|
+
last_updated: 2026-05-15
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
> Schéma principal : `efarmz_db`
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Conventions globales
|
|
12
|
+
|
|
13
|
+
**Gotchas PostgreSQL** — appliquables à toutes les requêtes sur ce schéma.
|
|
14
|
+
|
|
15
|
+
- ⚠️ `ROUND(double precision, int)` n'existe pas → toujours caster en `::numeric` avant `ROUND(..., 2)`
|
|
16
|
+
- ⚠️ `NULLIF` obligatoire sur tous les dénominateurs (division par zéro sinon)
|
|
17
|
+
- ⚠️ `artid` est `char(n)` (longueur fixe) → les valeurs sont paddées avec des espaces. Pour les patterns qui ciblent la fin de la chaîne (`LIKE '%P'`, regex `$`, `SUBSTRING ... P$`), toujours utiliser `RTRIM(artid)` d'abord.
|
|
18
|
+
- Tie-breaker `w_orderid ASC` dans `ROW_NUMBER() ... ORDER BY dlvdate` (pour les cas où deux commandes même client même jour)
|
|
19
|
+
- Factoriser en CTE si une sous-requête est réutilisée ≥ 2×
|
|
20
|
+
- Casts fréquents : `::date`, `::timestamp`, `::numeric`, `date_trunc('week', ...)`
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Tables
|
|
25
|
+
|
|
26
|
+
### Commandes
|
|
27
|
+
|
|
28
|
+
#### `eorderh` — En-têtes de commandes (historique)
|
|
29
|
+
|
|
30
|
+
| Colonne | Type | Rôle | Description |
|
|
31
|
+
|---|---|---|---|
|
|
32
|
+
| `w_orderid` | `varchar` | PK | ID commande |
|
|
33
|
+
| `c_uuid` | `varchar` | FK → `users.id` | Identifiant client |
|
|
34
|
+
| `dlvdate` | `timestamp` | — | Date de livraison (caster en `::date`) |
|
|
35
|
+
| `status` | `int` | — | Statut de la commande (voir Règles métier / Filtres) |
|
|
36
|
+
| `ordertype` | `int` | — | `1` = one-shot, `2` = abonnement |
|
|
37
|
+
| `s_credate` | `timestamp` | — | Date de création |
|
|
38
|
+
| `totalpaid` | `numeric` | — | Montant total payé |
|
|
39
|
+
|
|
40
|
+
#### `eorderd` — Lignes de commandes (détail picking)
|
|
41
|
+
|
|
42
|
+
| Colonne | Type | Rôle | Description |
|
|
43
|
+
|---|---|---|---|
|
|
44
|
+
| `w_orderid` | `varchar` | FK → `eorderh.w_orderid` | ID commande |
|
|
45
|
+
| `artid` | `char(n)` | FK → `art.artid` | ID produit (alias `product_id` selon contexte) |
|
|
46
|
+
| `qty` | `numeric` | — | Quantité commandée |
|
|
47
|
+
| `unitprice` | `numeric` | — | Prix unitaire de la ligne |
|
|
48
|
+
| `vatpc` | `numeric` | — | Taux de TVA appliqué à la ligne (%) |
|
|
49
|
+
| `boxid` | `varchar` | — | ID box (NULL si pas de box) |
|
|
50
|
+
|
|
51
|
+
#### `lgrefund` — Avoirs clients
|
|
52
|
+
|
|
53
|
+
| Colonne | Type | Rôle | Description |
|
|
54
|
+
|---|---|---|---|
|
|
55
|
+
| `pkid` | `serial` | PK | ID avoir |
|
|
56
|
+
| `artid` | `char(20)` | FK → `art.artid` | Alias `product_id` — produit concerné |
|
|
57
|
+
| `amount` | `double precision` | — | Montant de l'avoir |
|
|
58
|
+
| `date` | `timestamp` | — | Date de l'avoir |
|
|
59
|
+
| `id_product` | `int` | FK → `art` | Produit concerné |
|
|
60
|
+
| `id_cust` | `int` | FK → `users.id` | Client concerné |
|
|
61
|
+
| `id_order` | `int` | — | Commande concernée |
|
|
62
|
+
| `comment` | `text` | — | Commentaire |
|
|
63
|
+
| `refundtype` | `int` | — | `1` = caution, `2` = manquant, `3` = plainte, `9` = commun |
|
|
64
|
+
| `id_order_applied` | `int` | — | Numéro de commande si avoir appliqué |
|
|
65
|
+
| `status` | `int` | — | Statut de l'avoir |
|
|
66
|
+
| `issend` | `int` | — | Envoi effectué |
|
|
67
|
+
| `s_credate` | `timestamp` | — | Date de création |
|
|
68
|
+
| `s_moddate` | `timestamp` | — | Date de modification |
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
### Catalogue
|
|
73
|
+
|
|
74
|
+
#### `art` — Catalogue produits
|
|
75
|
+
|
|
76
|
+
| Colonne | Type | Rôle | Description |
|
|
77
|
+
|---|---|---|---|
|
|
78
|
+
| `artid` | `char(n)` | PK | ID produit |
|
|
79
|
+
| `name1` | `varchar` | — | Nom du produit |
|
|
80
|
+
| `finalprice` | `numeric` | — | Prix non-abonnés |
|
|
81
|
+
| `catalogue1` | `numeric` | — | Prix abonnés |
|
|
82
|
+
| `unit` | `varchar` | — | Unité |
|
|
83
|
+
| `a_zone` | `varchar` | — | Zone picking : `SEC`, `FRAIS`, `UFRAIS` |
|
|
84
|
+
| `a_prodtype` | `varchar` | — | Type : `PRODUIT`, `PANIER`, `ADMIN`, `PLAT` |
|
|
85
|
+
| `ana1` | `varchar` | FK → `anacode.code` | Catégorie 1 |
|
|
86
|
+
| `ana2` | `varchar` | FK → `anacode.code` | Catégorie 2 |
|
|
87
|
+
| `ana4` | `varchar` | FK → `anacode.code` | Marque/brand |
|
|
88
|
+
| `buyprice` | `numeric` | — | Prix d'achat |
|
|
89
|
+
| `discount` | `numeric` | — | Remise fournisseur (%) |
|
|
90
|
+
| `vatid` | `numeric` | — | Taux de TVA (%) |
|
|
91
|
+
| `cp_stock` | `numeric` | — | Stock physique |
|
|
92
|
+
| `cp_res` | `numeric` | — | Stock réservé |
|
|
93
|
+
| `stocktype` | `varchar` | — | Type de stock |
|
|
94
|
+
| `a_gesttype` | `varchar` | — | Type de gestion |
|
|
95
|
+
| `a_okabo` | `int` | — | Disponible en abonnement |
|
|
96
|
+
| `psecotax` | `numeric` | — | Écotaxe |
|
|
97
|
+
| `a_parentid` | `varchar` | FK → `art.artid` | ID produit parent (pour plats multi-portions) |
|
|
98
|
+
|
|
99
|
+
#### `ARTPART` — Composition des meals (ingrédients)
|
|
100
|
+
|
|
101
|
+
| Colonne | Type | Rôle | Description |
|
|
102
|
+
|---|---|---|---|
|
|
103
|
+
| `ARTID` | `char(n)` | FK → `art.artid` | ID meal parent |
|
|
104
|
+
| `ARTPARTID` | `char(n)` | FK → `art.artid` | ID ingrédient |
|
|
105
|
+
| `QTY` | `numeric` | — | Quantité ingrédient |
|
|
106
|
+
| `ORDER` | `int` | — | Numéro de ligne recette |
|
|
107
|
+
| `S_MODDATE` | `timestamp` | — | Date modif (pour dédupliquer via ROW_NUMBER) |
|
|
108
|
+
|
|
109
|
+
#### `anacode` — Référentiel catégories / marques
|
|
110
|
+
|
|
111
|
+
| Colonne | Type | Rôle | Description |
|
|
112
|
+
|---|---|---|---|
|
|
113
|
+
| `code` | `varchar` | PK | FK depuis `art.ana1`, `art.ana2`, `art.ana4` |
|
|
114
|
+
| `name1` | `varchar` | — | Libellé |
|
|
115
|
+
|
|
116
|
+
#### `artfeature` — Association produit / tag
|
|
117
|
+
|
|
118
|
+
| Colonne | Type | Rôle | Description |
|
|
119
|
+
|---|---|---|---|
|
|
120
|
+
| `artid` | `char(n)` | FK → `art.artid` | ID produit |
|
|
121
|
+
| `featureid` | `int` | FK → `feature.featureid` | ID tag |
|
|
122
|
+
|
|
123
|
+
#### `feature` — Tags produits
|
|
124
|
+
|
|
125
|
+
| Colonne | Type | Rôle | Description |
|
|
126
|
+
|---|---|---|---|
|
|
127
|
+
| `featureid` | `int` | PK | ID tag |
|
|
128
|
+
| `name1` | `varchar` | — | Libellé du tag |
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
### Abonnements
|
|
133
|
+
|
|
134
|
+
#### `subscriptions` — Abonnements
|
|
135
|
+
|
|
136
|
+
| Colonne | Type | Rôle | Description |
|
|
137
|
+
|---|---|---|---|
|
|
138
|
+
| `id` | `int` | PK | ID abonnement |
|
|
139
|
+
| `user_id` | `int` | FK → `users.id` | ID client |
|
|
140
|
+
| `next_delivery_date` | `timestamp` | — | Prochaine livraison |
|
|
141
|
+
| `status` | `int` | — | `1` = actif (voir Règles métier / Validité abonnement) |
|
|
142
|
+
| `deleted_at` | `timestamp` | — | NULL si non supprimé |
|
|
143
|
+
|
|
144
|
+
#### `subscription_items` — Produits fixes de l'abonnement
|
|
145
|
+
|
|
146
|
+
| Colonne | Type | Rôle | Description |
|
|
147
|
+
|---|---|---|---|
|
|
148
|
+
| `subscription_id` | `int` | FK → `subscriptions.id` | ID abonnement |
|
|
149
|
+
| `product_id` | `char(n)` | FK → `art.artid` | ID produit |
|
|
150
|
+
| `quantity` | `numeric` | — | Quantité |
|
|
151
|
+
|
|
152
|
+
#### `subscription_add_items` — Produits additionnels ponctuels
|
|
153
|
+
|
|
154
|
+
| Colonne | Type | Rôle | Description |
|
|
155
|
+
|---|---|---|---|
|
|
156
|
+
| `subscription_id` | `int` | FK → `subscriptions.id` | ID abonnement |
|
|
157
|
+
| `product_id` | `char(n)` | FK → `art.artid` | ID produit |
|
|
158
|
+
| `quantity` | `numeric` | — | Quantité |
|
|
159
|
+
| `delivery_date` | `timestamp` | — | Date de livraison concernée |
|
|
160
|
+
|
|
161
|
+
#### `subscription_generation_status` — Statut de génération d'abonnement
|
|
162
|
+
|
|
163
|
+
| Colonne | Type | Rôle | Description |
|
|
164
|
+
|---|---|---|---|
|
|
165
|
+
| `id` | `bigserial` | PK | — |
|
|
166
|
+
| `subscription_id` | `int` | FK → `subscriptions.id` | ID abonnement |
|
|
167
|
+
| `temp_order_ref` | `varchar(255)` | — | Référence commande temporaire |
|
|
168
|
+
| `payment_id` | `varchar(255)` | — | ID paiement |
|
|
169
|
+
| `generation_date` | `date` | — | Date de génération de l'abonnement |
|
|
170
|
+
| `type` | `varchar(255)` | — | `'short'` = produits shop, `'long'` = plats (voir Règles métier / Cutoffs abonnement) |
|
|
171
|
+
| `status` | `varchar(255)` | — | `IS_ON_HOLD` paused · `NO_DELIVERY_WINDOW` · `CARRIER_OFF` · `EFARMZ_OFF` · `AMOUNT_TOO_LOW` · `PAYMENT_SUCCESS` · `NO_ITEMS` · `SUBSCRIPTION_OFF` · `UNEXPECTED_ERROR` · `AMOUNT_TOO_HIGH` · `PAYMENT_FAILED` · `USER_MISSING` · `USER_DELETED` |
|
|
172
|
+
| `payment_link_id` | `varchar(255)` | — | ID lien de paiement |
|
|
173
|
+
| `delivery_date` | `date` | — | Date de livraison de la commande |
|
|
174
|
+
| `created_at` | `timestamp` | — | Date de création |
|
|
175
|
+
| `updated_at` | `timestamp` | — | Date de mise à jour |
|
|
176
|
+
|
|
177
|
+
#### `subscription_missing_product` — Produits manquants lors de la génération
|
|
178
|
+
|
|
179
|
+
| Colonne | Type | Rôle | Description |
|
|
180
|
+
|---|---|---|---|
|
|
181
|
+
| `id` | `bigserial` | PK | — |
|
|
182
|
+
| `user_id` | `int` | FK → `users.id` | Client concerné |
|
|
183
|
+
| `subscription_id` | `int` | FK → `subscriptions.id` | Abonnement concerné |
|
|
184
|
+
| `product_id` | `varchar(255)` | FK → `art.artid` | Produit manquant |
|
|
185
|
+
| `quantity_available` | `int` | — | Quantité disponible au moment de la génération |
|
|
186
|
+
| `quantity_requested` | `int` | — | Quantité demandée |
|
|
187
|
+
| `price` | `double precision` | — | Prix unitaire |
|
|
188
|
+
| `generation_date` | `date` | — | Date de génération de l'abonnement |
|
|
189
|
+
| `delivery_date` | `date` | — | Date de livraison de la commande |
|
|
190
|
+
| `created_at` | `timestamp` | — | Date de création |
|
|
191
|
+
| `updated_at` | `timestamp` | — | Date de mise à jour |
|
|
192
|
+
|
|
193
|
+
#### `subscription_off` — Pauses d'abonnement
|
|
194
|
+
|
|
195
|
+
| Colonne | Type | Rôle | Description |
|
|
196
|
+
|---|---|---|---|
|
|
197
|
+
| `subscription_id` | `int` | FK → `subscriptions.id` | ID abonnement |
|
|
198
|
+
| `start_date` | `timestamp` | — | Date de pause (= `next_delivery_date` du jour) |
|
|
199
|
+
|
|
200
|
+
#### `meals_selected` — Meals choisis par l'abonné
|
|
201
|
+
|
|
202
|
+
| Colonne | Type | Rôle | Description |
|
|
203
|
+
|---|---|---|---|
|
|
204
|
+
| `subscription_id` | `int` | FK → `subscriptions.id` | ID abonnement |
|
|
205
|
+
| `product_id` | `char(n)` | FK → `art.artid` | ID meal kit (contient `%-%`) |
|
|
206
|
+
| `week` | `timestamp` | — | Semaine du meal |
|
|
207
|
+
| `created_at` | `timestamp` | — | Date de création de la sélection |
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
### Livraison
|
|
212
|
+
|
|
213
|
+
#### `delivery_windows` — Fenêtres de livraison
|
|
214
|
+
|
|
215
|
+
| Colonne | Type | Rôle | Description |
|
|
216
|
+
|---|---|---|---|
|
|
217
|
+
| `id` | `bigserial` | PK | — |
|
|
218
|
+
| `user_id` | `varchar(255)` | FK → `users.id` | Client associé |
|
|
219
|
+
| `carrier_id` | `varchar(255)` | FK → `carriers.id` | Transporteur choisi |
|
|
220
|
+
| `postalcode` | `varchar(255)` | — | Code postal de livraison |
|
|
221
|
+
| `city` | `varchar(255)` | — | Ville de livraison |
|
|
222
|
+
| `country` | `varchar(255)` | — | Code pays ISO 2 (défaut `BE`) |
|
|
223
|
+
| `addressline1` | `varchar(255)` | — | Adresse de livraison |
|
|
224
|
+
| `created_at` | `timestamp` | — | Date de création |
|
|
225
|
+
| `updated_at` | `timestamp` | — | Date de mise à jour |
|
|
226
|
+
|
|
227
|
+
#### `delivery_window_order` — Pivot commande ↔ fenêtre de livraison
|
|
228
|
+
|
|
229
|
+
| Colonne | Type | Rôle | Description |
|
|
230
|
+
|---|---|---|---|
|
|
231
|
+
| `delivery_window_id` | `int` | FK → `delivery_windows.id` | Fenêtre de livraison |
|
|
232
|
+
| `w_orderid` | `varchar(255)` | FK → `eorderh.w_orderid` | Commande associée |
|
|
233
|
+
|
|
234
|
+
#### `carriers` — Transporteurs
|
|
235
|
+
|
|
236
|
+
| Colonne | Type | Rôle | Description |
|
|
237
|
+
|---|---|---|---|
|
|
238
|
+
| `id` | `bigint` | PK | — |
|
|
239
|
+
| `name` | `varchar(255)` | — | Nom du transporteur |
|
|
240
|
+
| `type` | `int` | — | `0` = à domicile, `1` = point dépôt |
|
|
241
|
+
| `address1` | `varchar(255)` | — | Adresse du point dépôt |
|
|
242
|
+
| `postcode` | `varchar(255)` | — | Code postal du point dépôt |
|
|
243
|
+
| `city` | `varchar(255)` | — | Ville du point dépôt |
|
|
244
|
+
| `lat` | `varchar(255)` | — | Latitude du point dépôt |
|
|
245
|
+
| `lng` | `varchar(255)` | — | Longitude du point dépôt |
|
|
246
|
+
| `d1` | `boolean` | — | Livre le lundi |
|
|
247
|
+
| `d2` | `boolean` | — | Livre le mardi |
|
|
248
|
+
| `d3` | `boolean` | — | Livre le mercredi |
|
|
249
|
+
| `d4` | `boolean` | — | Livre le jeudi |
|
|
250
|
+
| `d5` | `boolean` | — | Livre le vendredi |
|
|
251
|
+
| `d6` | `boolean` | — | Livre le samedi |
|
|
252
|
+
| `d7` | `boolean` | — | Livre le dimanche |
|
|
253
|
+
| `active` | `boolean` | — | `true` = actif |
|
|
254
|
+
|
|
255
|
+
#### `areas` — Zones de livraison
|
|
256
|
+
|
|
257
|
+
| Colonne | Type | Rôle | Description |
|
|
258
|
+
|---|---|---|---|
|
|
259
|
+
| `id` | `bigserial` | PK | — |
|
|
260
|
+
| `postcode` | `varchar(255)` | — | Code postal |
|
|
261
|
+
| `name` | `varchar(255)` | — | Nom |
|
|
262
|
+
| `commune` | `varchar(255)` | — | Commune |
|
|
263
|
+
| `province` | `varchar(255)` | — | Province |
|
|
264
|
+
| `region` | `varchar(255)` | — | Code région |
|
|
265
|
+
| `region_name` | `varchar(255)` | — | Nom de la région |
|
|
266
|
+
| `country` | `varchar(255)` | — | Code pays ISO 2 (défaut `BE`) |
|
|
267
|
+
| `lat` | `numeric(5,3)` | — | Latitude |
|
|
268
|
+
| `lng` | `numeric(5,3)` | — | Longitude |
|
|
269
|
+
|
|
270
|
+
#### `area_carrier` — Association zone ↔ transporteur
|
|
271
|
+
|
|
272
|
+
| Colonne | Type | Rôle | Description |
|
|
273
|
+
|---|---|---|---|
|
|
274
|
+
| `area_id` | `bigint` | FK → `areas.id` | Zone de livraison |
|
|
275
|
+
| `carrier_id` | `bigint` | FK → `carriers.id` | Transporteur actif sur la zone |
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
### Avis
|
|
280
|
+
|
|
281
|
+
#### `feedback` — En-têtes d'avis
|
|
282
|
+
|
|
283
|
+
| Colonne | Type | Rôle | Description |
|
|
284
|
+
|---|---|---|---|
|
|
285
|
+
| `id` | `bigserial` | PK | — |
|
|
286
|
+
| `user_id` | `int` | FK → `users.id` | Client ayant donné l'avis |
|
|
287
|
+
| `delivered_at` | `date` | — | Date de livraison concernée |
|
|
288
|
+
| `created_at` | `timestamp` | — | Date de création |
|
|
289
|
+
| `updated_at` | `timestamp` | — | Date de mise à jour |
|
|
290
|
+
|
|
291
|
+
#### `feedback_entries` — Détail des avis par plat
|
|
292
|
+
|
|
293
|
+
| Colonne | Type | Rôle | Description |
|
|
294
|
+
|---|---|---|---|
|
|
295
|
+
| `id` | `bigserial` | PK | — |
|
|
296
|
+
| `feedback_id` | `int` | FK → `feedback.id` | En-tête de l'avis |
|
|
297
|
+
| `meal_id` | `text` | FK → `art.artid` | Alias `product_id` — plat évalué |
|
|
298
|
+
| `meal_name` | `text` | — | Nom du plat au moment de l'avis |
|
|
299
|
+
| `meal_image` | `text` | — | URL image du plat |
|
|
300
|
+
| `rating` | `smallint` | — | Note globale de 0 à 10 |
|
|
301
|
+
| `would_order_again` | `boolean` | — | Commanderait à nouveau |
|
|
302
|
+
| `ease` | `smallint` | — | Facilité de préparation (0-10) |
|
|
303
|
+
| `originality` | `smallint` | — | Originalité (0-10) |
|
|
304
|
+
| `quantity` | `smallint` | — | Quantité (0-10) |
|
|
305
|
+
| `taste` | `smallint` | — | Goût (0-10) |
|
|
306
|
+
| `comment` | `text` | — | Commentaire libre |
|
|
307
|
+
| `created_at` | `timestamp` | — | Date de création |
|
|
308
|
+
| `updated_at` | `timestamp` | — | Date de mise à jour |
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
### Clients
|
|
313
|
+
|
|
314
|
+
#### `users` — Clients
|
|
315
|
+
|
|
316
|
+
| Colonne | Type | Rôle | Description |
|
|
317
|
+
|---|---|---|---|
|
|
318
|
+
| `id` | `bigint` | PK | ID client |
|
|
319
|
+
| `first_order_subscription` | `timestamp` | — | Date de première commande abonnement |
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## Règles métier
|
|
324
|
+
|
|
325
|
+
### Filtres standards
|
|
326
|
+
|
|
327
|
+
**Commandes valides** — exclure les annulations et remboursements
|
|
328
|
+
```sql
|
|
329
|
+
status NOT IN (31, 32)
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
**Jours ouvrés** — lundi à vendredi
|
|
333
|
+
```sql
|
|
334
|
+
extract(isodow from date) BETWEEN 1 AND 5
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
**Produits picking (shop)** — exclure les génériques
|
|
338
|
+
```sql
|
|
339
|
+
eorderd.artid NOT LIKE 'G%'
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
**Produits shop** — catalogue standard (excluant meals)
|
|
343
|
+
```sql
|
|
344
|
+
a_prodtype IN ('PRODUIT', 'PANIER', 'ADMIN')
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Validité abonnement
|
|
348
|
+
|
|
349
|
+
**Abonnement actif valide**
|
|
350
|
+
```sql
|
|
351
|
+
s.status = 1
|
|
352
|
+
AND s.deleted_at IS NULL
|
|
353
|
+
AND s.next_delivery_date::date > (CURRENT_DATE + interval '1 day')
|
|
354
|
+
AND NOT EXISTS (
|
|
355
|
+
SELECT 1 FROM efarmz_db.subscription_off so
|
|
356
|
+
WHERE so.subscription_id = s.id AND so.start_date = s.next_delivery_date
|
|
357
|
+
)
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
### Calculs
|
|
361
|
+
|
|
362
|
+
**Marge produit** — en %
|
|
363
|
+
```sql
|
|
364
|
+
((finalprice / NULLIF((1 + (vatid::float / 100)), 0))
|
|
365
|
+
- (buyprice - (buyprice * COALESCE(discount, 0) / 100)))
|
|
366
|
+
/ NULLIF(finalprice / NULLIF((1 + (vatid::float / 100)), 0), 0) * 100
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
**Stock disponible** — stock physique moins réservations
|
|
370
|
+
```sql
|
|
371
|
+
cp_stock - COALESCE(cp_res, 0)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
**Valeur stock** — au coût d'achat net
|
|
375
|
+
```sql
|
|
376
|
+
(cp_stock - COALESCE(cp_res, 0)) * (buyprice - (buyprice * COALESCE(discount, 0) / 100))
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
**Rotation stock** — à partir des ventes du mois précédent
|
|
380
|
+
```sql
|
|
381
|
+
sells_last_month / NULLIF(stock_disponible, 0)
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
**Montant hors TVA d'une ligne** — à partir de `eorderd`
|
|
385
|
+
```sql
|
|
386
|
+
ROUND(CAST((eorderd.unitprice - eorderd.unitprice * eorderd.vatpc / 100) AS numeric), 2)
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
**Seuil panier picking** — montant minimum pour déclarer une commande viable
|
|
390
|
+
```
|
|
391
|
+
total_add_items + total_items + total_catalogue1 > 40
|
|
392
|
+
```
|
|
393
|
+
Calculé avec `catalogue1` (prix abonnés × qty) depuis `subscription_items` + `subscription_add_items` + `meals_selected`.
|
|
394
|
+
|
|
395
|
+
### Cutoffs abonnement
|
|
396
|
+
|
|
397
|
+
Une commande abonnement génère **deux entrées** dans `subscription_generation_status` pour une même `delivery_date` :
|
|
398
|
+
|
|
399
|
+
| `type` | Contenu |
|
|
400
|
+
|---|---|
|
|
401
|
+
| `'long'` | Plats (meals) |
|
|
402
|
+
| `'short'` | Produits du shop |
|
|
403
|
+
|
|
404
|
+
⚠️ Ne pas agréger sans filtrer sur `type` sous peine de doublon-compter une livraison.
|
|
405
|
+
|
|
406
|
+
### Frais de port
|
|
407
|
+
|
|
408
|
+
**Identifier les frais de port**
|
|
409
|
+
```sql
|
|
410
|
+
eorderd.artid = 'COSTPORT'
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
⚠️ Toujours vérifier `SUM(price) > 0` pour confirmer qu'ils sont effectivement facturés (ils peuvent être gratuits).
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## Patterns SQL
|
|
418
|
+
|
|
419
|
+
### Meals vs Produits simples {#pattern-meals-vs-simples}
|
|
420
|
+
|
|
421
|
+
**Quand** : distinguer un meal kit (à décomposer) d'un produit simple
|
|
422
|
+
|
|
423
|
+
```sql
|
|
424
|
+
WHERE product_id LIKE '%-%' -- meal kit (2 portions, 3 portions, etc.)
|
|
425
|
+
WHERE product_id NOT LIKE '%-%' -- produit simple (1 ligne picking)
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
### ID plat — type & portion {#pattern-plat-id}
|
|
429
|
+
|
|
430
|
+
**Quand** : extraire le type de plat et le nombre de portions
|
|
431
|
+
|
|
432
|
+
```sql
|
|
433
|
+
-- Type de plat (1ère lettre de artid)
|
|
434
|
+
CASE LEFT(d.artid, 1)
|
|
435
|
+
WHEN 'L' THEN 'Légume'
|
|
436
|
+
WHEN 'V' THEN 'Viande'
|
|
437
|
+
WHEN 'P' THEN 'Poisson'
|
|
438
|
+
ELSE 'Autre'
|
|
439
|
+
END AS plat_type
|
|
440
|
+
|
|
441
|
+
-- Nombre de portions (chiffre avant le P final : V810-2P → 2)
|
|
442
|
+
-- ⚠️ RTRIM obligatoire — artid est char(n)
|
|
443
|
+
SUBSTRING(RTRIM(d.artid) FROM '-(\d+)P$') || ' portion(s)' AS portion
|
|
444
|
+
|
|
445
|
+
-- Filtrer les plats portion (⚠️ RTRIM obligatoire)
|
|
446
|
+
AND RTRIM(d.artid) LIKE '%-%P'
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### Tags produit {#pattern-tags}
|
|
450
|
+
|
|
451
|
+
**Quand** : récupérer les tags (features) associés à un produit
|
|
452
|
+
|
|
453
|
+
```sql
|
|
454
|
+
SELECT f.name1
|
|
455
|
+
FROM efarmz_db.art
|
|
456
|
+
LEFT JOIN efarmz_db.artfeature af ON af.artid = art.artid
|
|
457
|
+
LEFT JOIN efarmz_db.feature f ON f.featureid = af.featureid
|
|
458
|
+
WHERE art.artid = 'V810'
|
|
459
|
+
|
|
460
|
+
-- Pour les plats : supprimer la portion de l'artid avant le join
|
|
461
|
+
LEFT JOIN efarmz_db.artfeature af
|
|
462
|
+
ON af.artid = REGEXP_REPLACE(RTRIM(d.artid), '-\d+P$', '')
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### Décomposition ARTPART {#pattern-artpart}
|
|
466
|
+
|
|
467
|
+
**Quand** : lister les ingrédients d'un meal kit
|
|
468
|
+
|
|
469
|
+
**Dépend de** : Conventions globales / RTRIM, Conventions globales / deduplication
|
|
470
|
+
|
|
471
|
+
```sql
|
|
472
|
+
WITH ranked_artpart AS (
|
|
473
|
+
SELECT ap.ARTID AS meal_id, ap.ARTPARTID AS ingredient_id,
|
|
474
|
+
art.NAME1, art.UNIT, art.a_zone, ap.QTY, ap.ORDER,
|
|
475
|
+
ROW_NUMBER() OVER (
|
|
476
|
+
PARTITION BY ap.ARTID, ap.ORDER
|
|
477
|
+
ORDER BY ap.S_MODDATE DESC
|
|
478
|
+
) AS rn
|
|
479
|
+
FROM efarmz_db.ARTPART ap
|
|
480
|
+
JOIN efarmz_db.ART art ON art.ARTID = ap.ARTPARTID
|
|
481
|
+
WHERE ap.ARTID IN (...)
|
|
482
|
+
)
|
|
483
|
+
SELECT * FROM ranked_artpart WHERE rn = 1
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
⚠️ Toujours utiliser `WHERE rn = 1` pour éviter les doublons en cas de mise à jour.
|
|
487
|
+
|
|
488
|
+
### Alignement N-1 même jour de semaine {#pattern-ly-align}
|
|
489
|
+
|
|
490
|
+
**Quand** : calculer la date homologue de l'année précédente (ou N-2), même jour de la semaine
|
|
491
|
+
|
|
492
|
+
```sql
|
|
493
|
+
-- N-1
|
|
494
|
+
(
|
|
495
|
+
(delivery_date - interval '1 year')::date
|
|
496
|
+
+ (((extract(isodow from delivery_date)::int
|
|
497
|
+
- extract(isodow from (delivery_date - interval '1 year'))::int
|
|
498
|
+
+ 10) % 7) - 3) * interval '1 day'
|
|
499
|
+
)::date AS delivery_date_ly
|
|
500
|
+
|
|
501
|
+
-- N-2 (même formule, 2 ans en arrière)
|
|
502
|
+
(
|
|
503
|
+
(delivery_date - interval '2 year')::date
|
|
504
|
+
+ (((extract(isodow from delivery_date)::int
|
|
505
|
+
- extract(isodow from (delivery_date - interval '2 year'))::int
|
|
506
|
+
+ 10) % 7) - 3) * interval '1 day'
|
|
507
|
+
)::date AS delivery_date_2y
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### Semaine ISO pour les meals {#pattern-iso-week}
|
|
511
|
+
|
|
512
|
+
**Quand** : aligner les sélections de meals avec la semaine de livraison
|
|
513
|
+
|
|
514
|
+
```sql
|
|
515
|
+
date_trunc('week', ms.week) = date_trunc('week', s.next_delivery_date)
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### Zones picking {#pattern-zones}
|
|
519
|
+
|
|
520
|
+
**Quand** : compter les lignes par zone (SEC, FRAIS, UFRAIS)
|
|
521
|
+
|
|
522
|
+
```sql
|
|
523
|
+
COUNT(*) FILTER (WHERE a_zone = 'SEC') AS sec_lines,
|
|
524
|
+
COUNT(*) FILTER (WHERE a_zone = 'FRAIS') AS frais_lines,
|
|
525
|
+
COUNT(*) FILTER (WHERE a_zone = 'UFRAIS') AS ufrais_lines
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
### Génération dates ouvrées {#pattern-workdays}
|
|
529
|
+
|
|
530
|
+
**Quand** : générer une liste de dates de livraison futures (lun-ven)
|
|
531
|
+
|
|
532
|
+
```sql
|
|
533
|
+
SELECT gs::date AS delivery_date
|
|
534
|
+
FROM generate_series(
|
|
535
|
+
(current_date + interval '1 day')::date,
|
|
536
|
+
(current_date + interval '13 days')::date,
|
|
537
|
+
interval '1 day'
|
|
538
|
+
) gs
|
|
539
|
+
WHERE extract(isodow from gs) BETWEEN 1 AND 5
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
### Commandes 100% shop {#pattern-shop-only}
|
|
543
|
+
|
|
544
|
+
**Quand** : identifier les commandes contenant exclusivement des produits shop (pas de meals)
|
|
545
|
+
|
|
546
|
+
```sql
|
|
547
|
+
WHERE NOT EXISTS (
|
|
548
|
+
SELECT 1
|
|
549
|
+
FROM jsonb_array_elements(items::jsonb) AS item
|
|
550
|
+
WHERE item->>'a_prodtype' NOT IN ('PRODUIT', 'PANIER', 'ADMIN')
|
|
551
|
+
)
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
### Rang des commandes par client {#pattern-order-rank}
|
|
555
|
+
|
|
556
|
+
**Quand** : numéroter les commandes d'un client (1ère, 2e, 3e…)
|
|
557
|
+
|
|
558
|
+
**Dépend de** : Conventions globales / tie-breaker
|
|
559
|
+
|
|
560
|
+
```sql
|
|
561
|
+
ROW_NUMBER() OVER (
|
|
562
|
+
PARTITION BY h.c_uuid
|
|
563
|
+
ORDER BY h.dlvdate ASC, h.w_orderid ASC
|
|
564
|
+
) AS order_rank
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### Timing de sélection des meals {#pattern-meal-timing}
|
|
568
|
+
|
|
569
|
+
**Quand** : mesurer l'avance avec laquelle un abonné sélectionne ses meals
|
|
570
|
+
|
|
571
|
+
```sql
|
|
572
|
+
CASE
|
|
573
|
+
WHEN date_trunc('week', selection_date) = date_trunc('week', dlvdate)
|
|
574
|
+
THEN 'Semaine même'
|
|
575
|
+
WHEN date_trunc('week', selection_date) = date_trunc('week', dlvdate - interval '1 week')
|
|
576
|
+
THEN '1 semaine en avance'
|
|
577
|
+
WHEN date_trunc('week', selection_date) = date_trunc('week', dlvdate - interval '2 weeks')
|
|
578
|
+
THEN '2 semaines en avance'
|
|
579
|
+
WHEN date_trunc('week', selection_date) = date_trunc('week', dlvdate - interval '3 weeks')
|
|
580
|
+
THEN '3 semaines en avance'
|
|
581
|
+
ELSE '+ de 3 semaines en avance'
|
|
582
|
+
END AS timing
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
⚠️ Utiliser `MIN(ms.created_at)` pour la première sélection faite par le client.
|
|
586
|
+
|
|
587
|
+
### Pourcentage avec total global {#pattern-pct-window}
|
|
588
|
+
|
|
589
|
+
**Quand** : calculer un % par rapport au total de tous les groupes
|
|
590
|
+
|
|
591
|
+
**Dépend de** : Conventions globales / ::numeric avant ROUND
|
|
592
|
+
|
|
593
|
+
```sql
|
|
594
|
+
ROUND(
|
|
595
|
+
COUNT(*)::numeric
|
|
596
|
+
/ NULLIF(SUM(COUNT(*)) OVER ()::numeric, 0) * 100
|
|
597
|
+
, 2) AS pct
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
### Ventes mois précédent {#pattern-last-month}
|
|
601
|
+
|
|
602
|
+
**Quand** : agréger les quantités vendues au mois précédent (pour rotation, top produits, etc.)
|
|
603
|
+
|
|
604
|
+
**Dépend de** : Règles métier / Filtres / Commandes valides
|
|
605
|
+
|
|
606
|
+
```sql
|
|
607
|
+
WITH last_month_sales AS (
|
|
608
|
+
SELECT d.artid, SUM(d.qty) AS qty
|
|
609
|
+
FROM efarmz_db.eorderd d
|
|
610
|
+
INNER JOIN efarmz_db.eorderh h ON h.w_orderid = d.w_orderid
|
|
611
|
+
WHERE h.status NOT IN (31, 32)
|
|
612
|
+
AND date_trunc('month', h.dlvdate) = date_trunc('month', CURRENT_DATE - interval '1 month')
|
|
613
|
+
GROUP BY d.artid
|
|
614
|
+
)
|
|
615
|
+
-- Utiliser cette CTE dans la requête principale
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
### Forecast commandes par date {#pattern-forecast}
|
|
619
|
+
|
|
620
|
+
**Quand** : projeter le nombre de commandes attendues sur les 2 prochaines semaines ouvrées (abonnements + one-shot)
|
|
621
|
+
|
|
622
|
+
**Dépend de** : Patterns SQL / Génération dates ouvrées, Patterns SQL / Alignement N-1 même jour de semaine, Règles métier / Validité abonnement, Règles métier / Seuil panier picking
|
|
623
|
+
|
|
624
|
+
```sql
|
|
625
|
+
WITH base_dates AS (
|
|
626
|
+
SELECT gs::date AS delivery_date
|
|
627
|
+
FROM generate_series(
|
|
628
|
+
(current_date + interval '1 day')::date,
|
|
629
|
+
(date_trunc('week', current_date)::date + interval '1 week' + interval '13 days')::date,
|
|
630
|
+
interval '1 day'
|
|
631
|
+
) gs
|
|
632
|
+
WHERE extract(isodow from gs) BETWEEN 1 AND 5
|
|
633
|
+
),
|
|
634
|
+
items AS (
|
|
635
|
+
SELECT i.subscription_id, SUM(i.quantity * a.finalprice) AS total_items
|
|
636
|
+
FROM efarmz_db.subscription_items i
|
|
637
|
+
JOIN efarmz_db.art a ON a.artid = i.product_id
|
|
638
|
+
GROUP BY i.subscription_id
|
|
639
|
+
),
|
|
640
|
+
add_items AS (
|
|
641
|
+
SELECT ai.subscription_id, ai.delivery_date, SUM(ai.quantity * a.finalprice) AS total_add_items
|
|
642
|
+
FROM efarmz_db.subscription_add_items ai
|
|
643
|
+
JOIN efarmz_db.art a ON a.artid = ai.product_id
|
|
644
|
+
GROUP BY ai.subscription_id, ai.delivery_date
|
|
645
|
+
),
|
|
646
|
+
meals AS (
|
|
647
|
+
SELECT ms.subscription_id,
|
|
648
|
+
date_trunc('week', ms.week) AS week_start,
|
|
649
|
+
SUM(a.catalogue1) AS total_catalogue1
|
|
650
|
+
FROM efarmz_db.meals_selected ms
|
|
651
|
+
JOIN efarmz_db.art a ON a.artid = ms.product_id
|
|
652
|
+
GROUP BY ms.subscription_id, date_trunc('week', ms.week)
|
|
653
|
+
),
|
|
654
|
+
-- Abonnements actifs avec panier viable (voir Règles métier / Validité abonnement + Seuil panier picking)
|
|
655
|
+
next_subscriptions AS (
|
|
656
|
+
SELECT s.id, s.next_delivery_date,
|
|
657
|
+
COALESCE(i.total_items, 0) AS total_items,
|
|
658
|
+
COALESCE(ai.total_add_items, 0) AS total_add_items,
|
|
659
|
+
COALESCE(m.total_catalogue1, 0) AS total_catalogue1
|
|
660
|
+
FROM efarmz_db.subscriptions s
|
|
661
|
+
LEFT JOIN items i ON i.subscription_id = s.id
|
|
662
|
+
LEFT JOIN add_items ai ON ai.subscription_id = s.id AND ai.delivery_date = s.next_delivery_date
|
|
663
|
+
LEFT JOIN meals m ON m.subscription_id = s.id AND m.week_start = date_trunc('week', s.next_delivery_date)
|
|
664
|
+
WHERE s.status = 1 AND s.deleted_at IS NULL
|
|
665
|
+
AND s.next_delivery_date::date > (CURRENT_DATE + interval '1 day')
|
|
666
|
+
AND NOT EXISTS (SELECT 1 FROM efarmz_db.subscription_off so
|
|
667
|
+
WHERE so.subscription_id = s.id AND so.start_date = s.next_delivery_date)
|
|
668
|
+
),
|
|
669
|
+
forecast_subscriptions AS (
|
|
670
|
+
SELECT DATE(next_delivery_date) AS delivery_date, COUNT(DISTINCT id) AS subscription_forecast
|
|
671
|
+
FROM next_subscriptions
|
|
672
|
+
WHERE total_add_items + total_items + total_catalogue1 > 40
|
|
673
|
+
AND extract(isodow from next_delivery_date) NOT IN (6, 7)
|
|
674
|
+
GROUP BY DATE(next_delivery_date)
|
|
675
|
+
),
|
|
676
|
+
forecast_by_date AS (
|
|
677
|
+
SELECT b.delivery_date, COALESCE(fs.subscription_forecast, 0) AS subscription_forecast
|
|
678
|
+
FROM base_dates b
|
|
679
|
+
LEFT JOIN forecast_subscriptions fs USING (delivery_date)
|
|
680
|
+
),
|
|
681
|
+
-- Dates homologues N-1 et N-2 (voir #pattern-ly-align)
|
|
682
|
+
aligned AS (
|
|
683
|
+
SELECT f.delivery_date, f.subscription_forecast,
|
|
684
|
+
((f.delivery_date - interval '1 year')::date
|
|
685
|
+
+ (((extract(isodow from f.delivery_date)::int
|
|
686
|
+
- extract(isodow from (f.delivery_date - interval '1 year'))::int + 10) % 7) - 3
|
|
687
|
+
) * interval '1 day')::date AS delivery_date_ly,
|
|
688
|
+
((f.delivery_date - interval '2 year')::date
|
|
689
|
+
+ (((extract(isodow from f.delivery_date)::int
|
|
690
|
+
- extract(isodow from (f.delivery_date - interval '2 year'))::int + 10) % 7) - 3
|
|
691
|
+
) * interval '1 day')::date AS delivery_date_2y
|
|
692
|
+
FROM forecast_by_date f
|
|
693
|
+
),
|
|
694
|
+
-- One-shot N-1 : seulement les commandes passées avec un lead time ≤ au lead time restant actuel
|
|
695
|
+
forecast_oneshot_ly AS (
|
|
696
|
+
SELECT a.delivery_date_ly, COUNT(DISTINCT h.w_orderid) AS count
|
|
697
|
+
FROM efarmz_db.eorderh h
|
|
698
|
+
INNER JOIN aligned a ON DATE(a.delivery_date_ly) = DATE(h.dlvdate)
|
|
699
|
+
WHERE h.status NOT IN (31, 32) AND h.ordertype = 1
|
|
700
|
+
AND h.s_credate::timestamp >= h.dlvdate::timestamp - (DATE(a.delivery_date)::timestamp - now())
|
|
701
|
+
GROUP BY a.delivery_date_ly
|
|
702
|
+
),
|
|
703
|
+
hist_oneshot AS (
|
|
704
|
+
SELECT dlvdate::date AS delivery_date, COUNT(DISTINCT w_orderid) AS oneshot
|
|
705
|
+
FROM efarmz_db.eorderh WHERE status NOT IN (31, 32) AND ordertype = 1
|
|
706
|
+
GROUP BY dlvdate::date
|
|
707
|
+
),
|
|
708
|
+
hist_subscription AS (
|
|
709
|
+
SELECT dlvdate::date AS delivery_date, COUNT(DISTINCT w_orderid) AS subscription
|
|
710
|
+
FROM efarmz_db.eorderh WHERE status NOT IN (31, 32) AND ordertype = 2
|
|
711
|
+
GROUP BY dlvdate::date
|
|
712
|
+
)
|
|
713
|
+
SELECT
|
|
714
|
+
TO_CHAR(a.delivery_date, 'YYYY/MM/DD') AS delivery_date,
|
|
715
|
+
COALESCE(h0.oneshot, 0) AS oneshot,
|
|
716
|
+
COALESCE(forecast_oneshot_ly.count, 0) AS oneshot_forecast,
|
|
717
|
+
COALESCE(s0.subscription, 0) AS subscription,
|
|
718
|
+
CASE WHEN a.subscription_forecast > 0
|
|
719
|
+
THEN a.subscription_forecast - COALESCE(s0.subscription, 0) ELSE 0 END AS subscription_forecast,
|
|
720
|
+
COALESCE(h0.oneshot, 0) + COALESCE(forecast_oneshot_ly.count, 0)
|
|
721
|
+
+ COALESCE(s0.subscription, 0)
|
|
722
|
+
+ CASE WHEN a.subscription_forecast > 0
|
|
723
|
+
THEN a.subscription_forecast - COALESCE(s0.subscription, 0) ELSE 0 END AS total_forecast,
|
|
724
|
+
TO_CHAR(a.delivery_date_ly, 'YYYY/MM/DD') AS delivery_date_ly,
|
|
725
|
+
COALESCE(h1.oneshot + s1.subscription, 0) AS orders_ly,
|
|
726
|
+
TO_CHAR(a.delivery_date_2y, 'YYYY/MM/DD') AS delivery_date_2y,
|
|
727
|
+
COALESCE(h2.oneshot + s2.subscription, 0) AS orders_2y
|
|
728
|
+
FROM aligned a
|
|
729
|
+
LEFT JOIN hist_oneshot h0 ON h0.delivery_date = a.delivery_date
|
|
730
|
+
LEFT JOIN hist_oneshot h1 ON h1.delivery_date = a.delivery_date_ly
|
|
731
|
+
LEFT JOIN hist_oneshot h2 ON h2.delivery_date = a.delivery_date_2y
|
|
732
|
+
LEFT JOIN hist_subscription s0 ON s0.delivery_date = a.delivery_date
|
|
733
|
+
LEFT JOIN hist_subscription s1 ON s1.delivery_date = a.delivery_date_ly
|
|
734
|
+
LEFT JOIN hist_subscription s2 ON s2.delivery_date = a.delivery_date_2y
|
|
735
|
+
LEFT JOIN forecast_oneshot_ly ON forecast_oneshot_ly.delivery_date_ly = a.delivery_date_ly
|
|
736
|
+
ORDER BY a.delivery_date;
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
⚠️ Le forecast one-shot (`forecast_oneshot_ly`) filtre les commandes N-1 par lead time restant : `s_credate >= dlvdate - (delivery_date - now())`. Cela évite de compter des commandes N-1 passées très tôt que les clients n'auraient pas encore passées côté J actuel — c'est un prorata temporis, pas un simple comptage brut N-1.
|
|
740
|
+
|
|
741
|
+
⚠️ `subscription_forecast` représente le *delta* (abonnements prévisionnels moins ceux déjà confirmés dans `eorderh`). Il peut être 0 si tous les abonnements ont déjà généré une commande.
|
|
742
|
+
|
|
743
|
+
---
|
|
744
|
+
|
|
745
|
+
## Glossaire
|
|
746
|
+
|
|
747
|
+
| Terme | Alias | Contexte | Notes |
|
|
748
|
+
|---|---|---|---|
|
|
749
|
+
| `artid` | `product_id` | Selon le contexte d'écriture — synonymes dans les queries | Type `char(n)`, toujours `RTRIM` pour patterns de fin |
|
|
750
|
+
| `c_uuid` | `users.id` (jointure via `::bigint`) | `eorderh.c_uuid` ← → `users.id` | Identifiant client unifié |
|
|
751
|
+
| `catalogue1` | — | Prix **abonnés** | Vs `finalprice` (prix non-abonnés) |
|
|
752
|
+
| `finalprice` | — | Prix **non-abonnés** | Vs `catalogue1` (prix abonnés) |
|
|
753
|
+
| Zone picking | `a_zone` | Localisation physique en entrepôt | Valeurs : `SEC`, `FRAIS`, `UFRAIS` |
|
|
754
|
+
| Type produit | `a_prodtype` | Classification en catalogue | Valeurs : `PRODUIT`, `PANIER`, `ADMIN`, `PLAT` |
|
|
755
|
+
| Préfixes artid plats | — | Codes de la 1ère lettre | `L` = Légume, `V` = Viande, `P` = Poisson |
|
|
756
|
+
| Statuts commande | `status` | Filtre standard | `31` = annulé, `32` = remboursé (à exclure) |
|
|
757
|
+
| Statut abonnement | `subscriptions.status` | Activité | `1` = actif |
|
|
758
|
+
|
|
759
|
+
---
|