Compare commits
17 Commits
feat/entre
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc9b7855f | ||
| b61321c7b7 | |||
|
|
b130d44054 | ||
| dfa29ffc7a | |||
|
|
cde2c4fbb7 | ||
| 5552d98935 | |||
|
|
9e67a5e289 | ||
| 92289f9cb2 | |||
|
|
59418f2c66 | ||
| e1c9e25187 | |||
| 0b22574932 | |||
|
|
9115699f96 | ||
| 178b4e4eee | |||
|
|
fbc8365405 | ||
| 4561467532 | |||
| c441420522 | |||
| ba9ea2de71 |
@@ -4,7 +4,9 @@
|
|||||||
"Bash(npm run:*)",
|
"Bash(npm run:*)",
|
||||||
"WebFetch(domain:geo.api.gouv.fr)",
|
"WebFetch(domain:geo.api.gouv.fr)",
|
||||||
"Bash(pip3 install:*)",
|
"Bash(pip3 install:*)",
|
||||||
"Bash(python3 -c \":*)"
|
"Bash(python3 -c \":*)",
|
||||||
|
"Bash(make cache-clear *)",
|
||||||
|
"Bash(make test *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
.idea/db-forest-config.xml
generated
10
.idea/db-forest-config.xml
generated
@@ -1,5 +1,15 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
|
<component name="db-forest-configuration">
|
||||||
|
<data version="2">.
|
||||||
|
----------------------------------------
|
||||||
|
1:0:9cad43df-2147-4989-b7a4-443067034884
|
||||||
|
2:0:ae622167-c834-4e7b-87a5-c1721036f5dc
|
||||||
|
3:0:f407a514-c6b4-4b26-9555-445a85892502
|
||||||
|
4:0:09e221b8-067a-488b-9c1d-4e155a333079
|
||||||
|
5:0:9d8c1ad3-2491-4642-964a-666003c14128
|
||||||
|
.</data>
|
||||||
|
</component>
|
||||||
<component name="db-tree-configuration">
|
<component name="db-tree-configuration">
|
||||||
<option name="data" value="---------------------------------------- 1:0:f407a514-c6b4-4b26-9555-445a85892502 2:0:ae622167-c834-4e7b-87a5-c1721036f5dc 3:0:9cad43df-2147-4989-b7a4-443067034884 4:0:09e221b8-067a-488b-9c1d-4e155a333079 " />
|
<option name="data" value="---------------------------------------- 1:0:f407a514-c6b4-4b26-9555-445a85892502 2:0:ae622167-c834-4e7b-87a5-c1721036f5dc 3:0:9cad43df-2147-4989-b7a4-443067034884 4:0:09e221b8-067a-488b-9c1d-4e155a333079 " />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
5
.idea/ferme.iml
generated
5
.idea/ferme.iml
generated
@@ -155,6 +155,11 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/data-fixtures" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/data-fixtures" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/maennchen/zipstream-php" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/markbaker/complex" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/markbaker/matrix" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/phpoffice/phpspreadsheet" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/simple-cache" />
|
||||||
<excludePattern pattern="reference.php" />
|
<excludePattern pattern="reference.php" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
|
|||||||
5
.idea/php.xml
generated
5
.idea/php.xml
generated
@@ -174,6 +174,11 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
|
||||||
</include_path>
|
</include_path>
|
||||||
</component>
|
</component>
|
||||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
||||||
|
|||||||
145
.idea/workspace.xml
generated
145
.idea/workspace.xml
generated
@@ -4,12 +4,16 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente">
|
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : label age bovin">
|
||||||
|
<change beforePath="$PROJECT_DIR$/.claude/settings.local.json" beforeDir="false" afterPath="$PROJECT_DIR$/.claude/settings.local.json" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/db-forest-config.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/db-forest-config.xml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/ferme.iml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/ferme.iml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/php.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/php.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/CHANGELOG.md" beforeDir="false" afterPath="$PROJECT_DIR$/CHANGELOG.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/frontend/pages/bovine/[id].vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/bovine/[id].vue" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/Entity/BovineMovement.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/BovineMovement.php" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/State/Bovin/BovineMovementProcessor.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/State/Bovin/BovineMovementProcessor.php" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
@@ -41,7 +45,7 @@
|
|||||||
<component name="Git.Settings">
|
<component name="Git.Settings">
|
||||||
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
||||||
<map>
|
<map>
|
||||||
<entry key="$PROJECT_DIR$" value="feature/FER-13-faire-des-recherches-sur-le-scanner-des-betes" />
|
<entry key="$PROJECT_DIR$" value="feat/entree-sortie" />
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
@@ -213,6 +217,11 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
|
||||||
</include_path>
|
</include_path>
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectColorInfo">{
|
<component name="ProjectColorInfo">{
|
||||||
@@ -232,7 +241,9 @@
|
|||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"git-widget-placeholder": "fix/FER-15-fix-droit-de-suppression-reception-expedition-util",
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
|
"git-widget-placeholder": "feat/vie-du-bovin",
|
||||||
|
"git.auto.fetch.suggestion.counter": "3",
|
||||||
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
|
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.detected.package.tslint": "true",
|
"node.js.detected.package.tslint": "true",
|
||||||
@@ -274,7 +285,7 @@
|
|||||||
<component name="SharedIndexes">
|
<component name="SharedIndexes">
|
||||||
<attachedChunks>
|
<attachedChunks>
|
||||||
<set>
|
<set>
|
||||||
<option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.32098.40" />
|
<option value="bundled-php-predefined-a98d8de5180a-022fa7b8ab75-com.jetbrains.php.sharedIndexes-PS-261.23567.149" />
|
||||||
</set>
|
</set>
|
||||||
</attachedChunks>
|
</attachedChunks>
|
||||||
</component>
|
</component>
|
||||||
@@ -327,54 +338,16 @@
|
|||||||
<workItem from="1773824491213" duration="24805000" />
|
<workItem from="1773824491213" duration="24805000" />
|
||||||
<workItem from="1774275549972" duration="51000" />
|
<workItem from="1774275549972" duration="51000" />
|
||||||
<workItem from="1774276665015" duration="33750000" />
|
<workItem from="1774276665015" duration="33750000" />
|
||||||
</task>
|
<workItem from="1776755742205" duration="88521000" />
|
||||||
<task id="LOCAL-00037" summary="feat : finalisation de l'étape 1 "Réception" (formulaire)">
|
<workItem from="1777453284124" duration="86000" />
|
||||||
<option name="closed" value="true" />
|
<workItem from="1777453433907" duration="337000" />
|
||||||
<created>1769529522614</created>
|
<workItem from="1777454070632" duration="17254000" />
|
||||||
<option name="number" value="00037" />
|
<workItem from="1777540415843" duration="13205000" />
|
||||||
<option name="presentableId" value="LOCAL-00037" />
|
<workItem from="1777877316149" duration="29389000" />
|
||||||
<option name="project" value="LOCAL" />
|
<workItem from="1777982616362" duration="23909000" />
|
||||||
<updated>1769529522614</updated>
|
<workItem from="1778482021120" duration="1280000" />
|
||||||
</task>
|
<workItem from="1778656317630" duration="279000" />
|
||||||
<task id="LOCAL-00038" summary="feat : ajout du numéro identification des receptions et ajustement du bon de reception">
|
<workItem from="1778664396844" duration="2576000" />
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769676223697</created>
|
|
||||||
<option name="number" value="00038" />
|
|
||||||
<option name="presentableId" value="LOCAL-00038" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769676223697</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00039" summary="feat : ajout de la partie reception des marchandises (étape 3) et modification du bon de réception">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769700808988</created>
|
|
||||||
<option name="number" value="00039" />
|
|
||||||
<option name="presentableId" value="LOCAL-00039" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769700808988</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00040" summary="feat : mise en place de composant UI pour les select, checkbox, date, text">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769705141157</created>
|
|
||||||
<option name="number" value="00040" />
|
|
||||||
<option name="presentableId" value="LOCAL-00040" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769705141157</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00041" summary="feat : update CHANGELOG.md">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769705240487</created>
|
|
||||||
<option name="number" value="00041" />
|
|
||||||
<option name="presentableId" value="LOCAL-00041" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769705240487</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00042" summary="feat : ajout de commentaire">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769760766200</created>
|
|
||||||
<option name="number" value="00042" />
|
|
||||||
<option name="presentableId" value="LOCAL-00042" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769760766200</updated>
|
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00043" summary="fix : correction de l'affichage de l'immatriculation sur une réception en cours + correction css étape 3 d'une réception">
|
<task id="LOCAL-00043" summary="fix : correction de l'affichage de l'immatriculation sur une réception en cours + correction css étape 3 d'une réception">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
@@ -720,7 +693,55 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1774543840891</updated>
|
<updated>1774543840891</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="86" />
|
<task id="LOCAL-00086" summary="fix : update icon entrée/sortie">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1777896558092</created>
|
||||||
|
<option name="number" value="00086" />
|
||||||
|
<option name="presentableId" value="LOCAL-00086" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1777896558092</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00087" summary="fix : wording">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1777983048277</created>
|
||||||
|
<option name="number" value="00087" />
|
||||||
|
<option name="presentableId" value="LOCAL-00087" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1777983048278</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00088" summary="fix : wording">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1777983581324</created>
|
||||||
|
<option name="number" value="00088" />
|
||||||
|
<option name="presentableId" value="LOCAL-00088" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1777983581324</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00089" summary="feat : update CHANGELOG.md">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1778073247660</created>
|
||||||
|
<option name="number" value="00089" />
|
||||||
|
<option name="presentableId" value="LOCAL-00089" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1778073247660</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00090" summary="feat : amélioration du tableau bovin">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1778135981350</created>
|
||||||
|
<option name="number" value="00090" />
|
||||||
|
<option name="presentableId" value="LOCAL-00090" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1778135981350</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00091" summary="fix : label age bovin">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1778136373027</created>
|
||||||
|
<option name="number" value="00091" />
|
||||||
|
<option name="presentableId" value="LOCAL-00091" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1778136373027</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="92" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -770,10 +791,6 @@
|
|||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
<component name="VcsManagerConfiguration">
|
<component name="VcsManagerConfiguration">
|
||||||
<MESSAGE value="feat : changelog update" />
|
|
||||||
<MESSAGE value="fix : color tab" />
|
|
||||||
<MESSAGE value="feat : modification front de la page admin transporteur" />
|
|
||||||
<MESSAGE value="fix : espacement et changelog" />
|
|
||||||
<MESSAGE value="fix : espacement" />
|
<MESSAGE value="fix : espacement" />
|
||||||
<MESSAGE value="fix : text" />
|
<MESSAGE value="fix : text" />
|
||||||
<MESSAGE value="feat : front page admin bovin et changelog" />
|
<MESSAGE value="feat : front page admin bovin et changelog" />
|
||||||
@@ -792,10 +809,14 @@
|
|||||||
<MESSAGE value="feat : système de blocage utilisateur" />
|
<MESSAGE value="feat : système de blocage utilisateur" />
|
||||||
<MESSAGE value="feat : ajout d'un système de scanner bovin" />
|
<MESSAGE value="feat : ajout d'un système de scanner bovin" />
|
||||||
<MESSAGE value="feat : mise à jour du CLAUDE.md" />
|
<MESSAGE value="feat : mise à jour du CLAUDE.md" />
|
||||||
<MESSAGE value="feat : update CHANGELOG.md" />
|
|
||||||
<MESSAGE value="feat : la page de scanner est accessible que pour les admins" />
|
<MESSAGE value="feat : la page de scanner est accessible que pour les admins" />
|
||||||
<MESSAGE value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
|
<MESSAGE value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
|
<MESSAGE value="fix : update icon entrée/sortie" />
|
||||||
|
<MESSAGE value="fix : wording" />
|
||||||
|
<MESSAGE value="feat : update CHANGELOG.md" />
|
||||||
|
<MESSAGE value="feat : amélioration du tableau bovin" />
|
||||||
|
<MESSAGE value="fix : label age bovin" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="fix : label age bovin" />
|
||||||
</component>
|
</component>
|
||||||
<component name="XDebuggerManager">
|
<component name="XDebuggerManager">
|
||||||
<breakpoint-manager>
|
<breakpoint-manager>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ Ajouter dans le fichier .env du frontend
|
|||||||
* [#FER-15] Les non-admin ne peuvent plus supprimer de réception/expédition en attente
|
* [#FER-15] Les non-admin ne peuvent plus supprimer de réception/expédition en attente
|
||||||
* [#FER-17] Ecran d'ajout de bovin
|
* [#FER-17] Ecran d'ajout de bovin
|
||||||
* [#FER-18] Mise à jour du tableau d'arrivage
|
* [#FER-18] Mise à jour du tableau d'arrivage
|
||||||
|
* [#FER-26] Passeport du bovin
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.0.94'
|
app.version: '0.0.101'
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,598 +0,0 @@
|
|||||||
# Saisie information bovin (post-EDNOTIF) — Plan d'implémentation
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
>
|
|
||||||
> **Mode utilisateur :** L'utilisateur souhaite valider chaque étape avant exécution (cf. memory `feedback_step_by_step_validation`). Avant chaque task, présenter ce qui va être fait et attendre OK explicite.
|
|
||||||
|
|
||||||
**Goal:** Ajouter un écran de saisie post-EDNOTIF (poids, prix/kg, bâtiment, case) accessible depuis le tableau "Entrées validées", structuré en accordéons-par-bovin.
|
|
||||||
|
|
||||||
**Architecture:** Un nouveau composant `UiAccordion` réutilisable. Une nouvelle page Nuxt `entry-exit/bovine-info/[id].vue` qui charge la réception et ses bovins, instancie un accordéon par bovin et délègue la saisie à un sous-composant `bovine-info-form.vue`. Pas de nouvel endpoint, pas de migration : on PATCH les `Bovine` existants (`receivedWeight`, `pricePerKg`, `buildingCase`). Mini ajustement backend : exposer les ids de `BuildingCase` et `Building` dans le groupe de sérialisation `bovine:read`, sinon on n'a pas de quoi pré-remplir les selectors.
|
|
||||||
|
|
||||||
**Tech Stack:** Symfony 8 + API Platform 4 (annotations Groups) ; Nuxt 4 + Vue 3 + Tailwind ; pas de tests automatisés (cohérent avec le reste de la feature entry-exit, cf. spec).
|
|
||||||
|
|
||||||
**Spec source:** `docs/superpowers/specs/2026-05-04-bovine-info-saisie-design.md`
|
|
||||||
|
|
||||||
**Branche de travail:** `feat/entree-sortie` (déjà créée).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Synthèse du file-mapping
|
|
||||||
|
|
||||||
| Fichier | Type | Responsabilité |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `src/Entity/BuildingCase.php` | Modify | Ajouter `bovine:read` au groupe de `id` |
|
|
||||||
| `src/Entity/Building.php` | Modify | Ajouter `bovine:read` au groupe de `id` |
|
|
||||||
| `frontend/services/dto/bovine-data.ts` | Modify | Ajouter `id` à `BovineBuildingRef` et `BovineBuildingCaseRef` |
|
|
||||||
| `frontend/components/ui/UiAccordion.vue` | Create | Composant réutilisable, header en slot, body en slot, v-model boolean |
|
|
||||||
| `frontend/components/entry-exit/bovine-info-form.vue` | Create | Sous-composant : 4 champs + bouton Valider, émet `saved` |
|
|
||||||
| `frontend/pages/entry-exit/bovine-info/[id].vue` | Create | Page : header, fetch, tri, état d'ouverture, rendu liste d'accordéons |
|
|
||||||
| `frontend/pages/entry-exit/index.vue` | Modify | Ajouter `row-clickable` + `@row-click` au tableau "Entrées validées" |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1 : Exposer les ids `BuildingCase` et `Building` dans `bovine:read`
|
|
||||||
|
|
||||||
**Contexte :** Quand l'API normalise un `Bovine` avec le groupe `bovine:read`, l'embedded `buildingCase` ne contient que `caseNumber` et `building.label`. Pas d'ids → pas de pré-remplissage possible. On ajoute le groupe `bovine:read` aux deux propriétés `id` concernées (zéro changement de schéma, juste un attribut PHP).
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/Entity/BuildingCase.php:42`
|
|
||||||
- Modify: `src/Entity/Building.php:36`
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Patch `BuildingCase.id`**
|
|
||||||
|
|
||||||
```php
|
|
||||||
// src/Entity/BuildingCase.php — remplacer
|
|
||||||
#[Groups(['building:read', 'building_case:read'])]
|
|
||||||
private ?int $id = null;
|
|
||||||
|
|
||||||
// par
|
|
||||||
#[Groups(['building:read', 'building_case:read', 'bovine:read'])]
|
|
||||||
private ?int $id = null;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Patch `Building.id`**
|
|
||||||
|
|
||||||
```php
|
|
||||||
// src/Entity/Building.php — remplacer
|
|
||||||
#[Groups(['building:read', 'building:summary', 'reception:read'])]
|
|
||||||
private ?int $id = null;
|
|
||||||
|
|
||||||
// par
|
|
||||||
#[Groups(['building:read', 'building:summary', 'reception:read', 'bovine:read'])]
|
|
||||||
private ?int $id = null;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3 : Vider le cache (les groupes sont compilés)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make cache-clear
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4 : Vérifier que les tests existants passent**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test
|
|
||||||
```
|
|
||||||
|
|
||||||
Attendu : 9/9 tests OK (aucun changement de comportement, juste une exposition supplémentaire).
|
|
||||||
|
|
||||||
- [ ] **Step 5 : Vérification manuelle rapide**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
|
||||||
'http://localhost:8080/api/bovines/1' | jq '.buildingCase'
|
|
||||||
```
|
|
||||||
|
|
||||||
Attendu : la réponse contient `id` (numérique) en plus de `caseNumber`, et `buildingCase.building` contient `id` en plus de `label`. Si le bovin n'a pas de buildingCase, ce sera `null` — prendre un id de bovin qui en a un (sinon ignorer cette étape).
|
|
||||||
|
|
||||||
- [ ] **Step 6 : Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/Entity/BuildingCase.php src/Entity/Building.php
|
|
||||||
git commit -m "feat(api) : exposer BuildingCase.id et Building.id dans bovine:read"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2 : Compléter le DTO frontend `BovineData`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/services/dto/bovine-data.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Ajouter `id` aux deux interfaces de référence**
|
|
||||||
|
|
||||||
Remplacer le bloc en haut du fichier :
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export interface BovineBuildingRef {
|
|
||||||
id: number
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BovineBuildingCaseRef {
|
|
||||||
id: number
|
|
||||||
caseNumber: number | null
|
|
||||||
building: BovineBuildingRef | null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Vérifier que TypeScript ne casse pas**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npx vue-tsc --noEmit 2>&1 | head -40
|
|
||||||
```
|
|
||||||
|
|
||||||
Attendu : pas d'erreur (les autres pages qui consomment `BovineData` ne lisaient pas l'`id` depuis ces sous-objets ; ajouter un champ ne casse rien).
|
|
||||||
|
|
||||||
Si erreurs inattendues, les corriger en touchant seulement les call-sites pointés par tsc.
|
|
||||||
|
|
||||||
- [ ] **Step 3 : Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/services/dto/bovine-data.ts
|
|
||||||
git commit -m "feat(front) : id dans BovineBuildingRef et BovineBuildingCaseRef"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3 : Créer `UiAccordion`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `frontend/components/ui/UiAccordion.vue`
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Écrire le composant**
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div class="overflow-hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center justify-between gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide text-left"
|
|
||||||
@click="toggle"
|
|
||||||
>
|
|
||||||
<span class="flex-1">
|
|
||||||
<slot name="header" />
|
|
||||||
</span>
|
|
||||||
<Icon
|
|
||||||
name="mdi:chevron-down"
|
|
||||||
size="24"
|
|
||||||
class="shrink-0 transition-transform"
|
|
||||||
:class="{ 'rotate-180': modelValue }"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div v-if="modelValue" class="border border-t-0 border-slate-200 px-6 py-6">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: boolean]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const toggle = () => emit('update:modelValue', !props.modelValue)
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Vérifier l'auto-import**
|
|
||||||
|
|
||||||
Nuxt auto-importe les composants de `components/ui/` avec le préfixe `Ui` (cf. CLAUDE.md). Donc `<UiAccordion />` sera utilisable sans import explicite. Pas d'action ici, juste validation mentale.
|
|
||||||
|
|
||||||
- [ ] **Step 3 : Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/components/ui/UiAccordion.vue
|
|
||||||
git commit -m "feat(front) : composant UiAccordion réutilisable"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4 : Créer `bovine-info-form.vue` (sous-composant)
|
|
||||||
|
|
||||||
**Contexte :** Encapsule l'état local et le formulaire d'un bovin. Reçoit le bovin et la liste de bâtiments, émet `saved` avec le bovin mis à jour. Permet à la page parent de rester lisible.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `frontend/components/entry-exit/bovine-info-form.vue`
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Écrire le composant**
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<form class="space-y-6" :class="{ submitted }" @submit.prevent="submit">
|
|
||||||
<div class="grid grid-cols-2 gap-x-12 gap-y-6">
|
|
||||||
<UiNumberInput
|
|
||||||
v-model="form.receivedWeight"
|
|
||||||
label="Poids d'arrivée (kg)"
|
|
||||||
:min="0"
|
|
||||||
:step="1"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<UiNumberInput
|
|
||||||
v-model="form.pricePerKg"
|
|
||||||
label="Prix d'achat (kg)"
|
|
||||||
:min="0"
|
|
||||||
:step="0.01"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<UiSelect
|
|
||||||
v-model="form.buildingId"
|
|
||||||
label="Bâtiment"
|
|
||||||
:options="buildingOptions"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<UiSelect
|
|
||||||
v-model="form.buildingCaseId"
|
|
||||||
label="Case"
|
|
||||||
:options="caseOptions"
|
|
||||||
:disabled="form.buildingId === null"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<UiButton
|
|
||||||
type="submit"
|
|
||||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
|
||||||
:disabled="isSaving"
|
|
||||||
:loading="isSaving"
|
|
||||||
>
|
|
||||||
Valider
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { BovineData } from '~/services/dto/bovine-data'
|
|
||||||
import type { BuildingData } from '~/services/dto/building-data'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
bovine: BovineData
|
|
||||||
buildings: BuildingData[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
saved: [bovine: BovineData]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const api = useApi()
|
|
||||||
|
|
||||||
interface FormState {
|
|
||||||
receivedWeight: number | null
|
|
||||||
pricePerKg: number | null
|
|
||||||
buildingId: number | null
|
|
||||||
buildingCaseId: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = reactive<FormState>({
|
|
||||||
receivedWeight: props.bovine.receivedWeight,
|
|
||||||
pricePerKg: props.bovine.pricePerKg,
|
|
||||||
buildingId: props.bovine.buildingCase?.building?.id
|
|
||||||
?? props.bovine.effectiveBuilding?.id
|
|
||||||
?? null,
|
|
||||||
buildingCaseId: props.bovine.buildingCase?.id ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitted = ref(false)
|
|
||||||
const isSaving = ref(false)
|
|
||||||
|
|
||||||
const buildingOptions = computed(() =>
|
|
||||||
props.buildings.map(b => ({ value: b.id, label: b.label }))
|
|
||||||
)
|
|
||||||
|
|
||||||
const caseOptions = computed(() => {
|
|
||||||
if (form.buildingId === null) return []
|
|
||||||
const building = props.buildings.find(b => b.id === form.buildingId)
|
|
||||||
if (!building?.buildingCases) return []
|
|
||||||
return building.buildingCases.map(c => ({
|
|
||||||
value: c.id,
|
|
||||||
label: c.caseNumber !== null ? `Case ${c.caseNumber}` : (c.code ?? `#${c.id}`)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => form.buildingId, (newId) => {
|
|
||||||
if (form.buildingCaseId === null) return
|
|
||||||
const building = props.buildings.find(b => b.id === newId)
|
|
||||||
const caseStillValid = building?.buildingCases?.some(c => c.id === form.buildingCaseId)
|
|
||||||
if (!caseStillValid) {
|
|
||||||
form.buildingCaseId = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
submitted.value = true
|
|
||||||
if (
|
|
||||||
form.receivedWeight === null
|
|
||||||
|| form.pricePerKg === null
|
|
||||||
|| form.buildingId === null
|
|
||||||
|| form.buildingCaseId === null
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaving.value = true
|
|
||||||
try {
|
|
||||||
const updated = await api.patch<BovineData>(
|
|
||||||
`bovines/${props.bovine.id}`,
|
|
||||||
{
|
|
||||||
receivedWeight: form.receivedWeight,
|
|
||||||
pricePerKg: form.pricePerKg,
|
|
||||||
buildingCase: `/api/building_cases/${form.buildingCaseId}`
|
|
||||||
},
|
|
||||||
{ headers: { 'Content-Type': 'application/merge-patch+json' } }
|
|
||||||
)
|
|
||||||
emit('saved', updated)
|
|
||||||
} finally {
|
|
||||||
isSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
> Note : on utilise `application/merge-patch+json` comme content-type côté API Platform pour les PATCH (la convention par défaut). `useApi.patch` a déjà ce content-type par défaut — la ligne `headers` est ici **à supprimer** si `useApi.patch` le pose déjà. Vérifier dans `composables/useApi.ts` à l'étape suivante.
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Vérifier le content-type par défaut de `useApi.patch`**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n "patch" frontend/composables/useApi.ts | head -10
|
|
||||||
```
|
|
||||||
|
|
||||||
- Si `useApi.patch` injecte déjà `application/merge-patch+json`, **retirer** le bloc `headers` du composant ci-dessus.
|
|
||||||
- Sinon, le garder.
|
|
||||||
|
|
||||||
- [ ] **Step 3 : Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/components/entry-exit/bovine-info-form.vue
|
|
||||||
git commit -m "feat(front) : sous-composant bovine-info-form (4 champs + valider)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5 : Créer la page `bovine-info/[id].vue`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `frontend/pages/entry-exit/bovine-info/[id].vue`
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Écrire la page**
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div class="px-[86px]">
|
|
||||||
<div class="flex items-center justify-start gap-6 relative mb-8">
|
|
||||||
<Icon
|
|
||||||
@click="router.push('/entry-exit')"
|
|
||||||
name="gg:arrow-left-o"
|
|
||||||
size="44"
|
|
||||||
class="cursor-pointer text-primary-500 absolute -left-[60px]"
|
|
||||||
/>
|
|
||||||
<h1 class="font-bold text-3xl uppercase text-primary-500">
|
|
||||||
Saisie information bovin {{ reception?.identificationNumber ?? '' }}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading" class="text-center text-slate-500">Chargement…</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
|
||||||
<UiAccordion
|
|
||||||
v-for="bovine in sortedBovines"
|
|
||||||
:key="bovine.id"
|
|
||||||
:model-value="openId === bovine.id"
|
|
||||||
@update:model-value="onToggle(bovine.id, $event)"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<span class="flex items-center gap-3 normal-case">
|
|
||||||
<span class="font-bold text-base">{{ bovine.nationalNumber }}</span>
|
|
||||||
<span
|
|
||||||
v-if="isSaisi(bovine)"
|
|
||||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
|
|
||||||
>
|
|
||||||
Saisie
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
|
|
||||||
>
|
|
||||||
Attente saisie
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<BovineInfoForm
|
|
||||||
:bovine="bovine"
|
|
||||||
:buildings="buildings"
|
|
||||||
@saved="onSaved"
|
|
||||||
/>
|
|
||||||
</UiAccordion>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { BovineData } from '~/services/dto/bovine-data'
|
|
||||||
import type { BuildingData } from '~/services/dto/building-data'
|
|
||||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
|
||||||
import { getBuildingList } from '~/services/building'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const api = useApi()
|
|
||||||
|
|
||||||
const receptionId = computed(() => Number(route.params.id))
|
|
||||||
|
|
||||||
const reception = ref<ReceptionData | null>(null)
|
|
||||||
const bovines = ref<BovineData[]>([])
|
|
||||||
const buildings = ref<BuildingData[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const openId = ref<number | null>(null)
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: () => `Saisie information bovin ${reception.value?.identificationNumber ?? ''}`.trim()
|
|
||||||
})
|
|
||||||
|
|
||||||
const isSaisi = (bovine: BovineData) =>
|
|
||||||
bovine.receivedWeight !== null
|
|
||||||
&& bovine.pricePerKg !== null
|
|
||||||
&& bovine.buildingCase !== null
|
|
||||||
|
|
||||||
const sortedBovines = computed(() => {
|
|
||||||
const pending = bovines.value.filter(b => !isSaisi(b))
|
|
||||||
const done = bovines.value.filter(b => isSaisi(b))
|
|
||||||
return [...pending, ...done]
|
|
||||||
})
|
|
||||||
|
|
||||||
const onToggle = (bovineId: number, value: boolean) => {
|
|
||||||
openId.value = value ? bovineId : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSaved = (updated: BovineData) => {
|
|
||||||
const idx = bovines.value.findIndex(b => b.id === updated.id)
|
|
||||||
if (idx === -1) return
|
|
||||||
bovines.value[idx] = updated
|
|
||||||
|
|
||||||
// Ouvrir le prochain non-saisi dans la nouvelle liste triée
|
|
||||||
const next = sortedBovines.value.find(b => !isSaisi(b) && b.id !== updated.id)
|
|
||||||
openId.value = next?.id ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadBovines = async () => {
|
|
||||||
type Hydra = { 'hydra:member'?: BovineData[] }
|
|
||||||
const response = await api.get<BovineData[] | Hydra>(
|
|
||||||
'bovines',
|
|
||||||
{ reception: receptionId.value, itemsPerPage: 200 }
|
|
||||||
)
|
|
||||||
if (Array.isArray(response)) {
|
|
||||||
bovines.value = response
|
|
||||||
} else if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
|
|
||||||
bovines.value = response['hydra:member']
|
|
||||||
} else {
|
|
||||||
bovines.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const [r, , b] = await Promise.all([
|
|
||||||
api.get<ReceptionData>(`receptions/${receptionId.value}`),
|
|
||||||
loadBovines(),
|
|
||||||
getBuildingList()
|
|
||||||
])
|
|
||||||
reception.value = r
|
|
||||||
buildings.value = b
|
|
||||||
|
|
||||||
const firstPending = sortedBovines.value.find(bv => !isSaisi(bv))
|
|
||||||
openId.value = firstPending?.id ?? null
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
> Note de style : `BovineInfoForm` est référencé sans import — Nuxt auto-importe les composants `components/entry-exit/*.vue` avec un PascalCase basé sur le nom de fichier (à confirmer ; sinon, ajouter `import BovineInfoForm from '~/components/entry-exit/bovine-info-form.vue'`).
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Vérifier l'auto-import**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Aller sur `http://localhost:3000/entry-exit/bovine-info/<id>` (id d'une réception validée). Si erreur "BovineInfoForm is not defined", ajouter l'import explicite. Si rendu OK, continuer.
|
|
||||||
|
|
||||||
- [ ] **Step 3 : Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/pages/entry-exit/bovine-info/'[id].vue'
|
|
||||||
git commit -m "feat(front) : page saisie information bovin (accordéons)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6 : Câbler la navigation depuis le tableau "Entrées validées"
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/pages/entry-exit/index.vue`
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Ajouter la fonction de navigation**
|
|
||||||
|
|
||||||
Dans le `<script setup>`, sous le `goToEntry` existant, ajouter :
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const goToBovineInfo = (reception: ReceptionData) => {
|
|
||||||
router.push(`/entry-exit/bovine-info/${reception.id}`)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Activer le clic sur la table validée**
|
|
||||||
|
|
||||||
Dans le `<template>`, sur le `<UiDataTable>` du bloc "Entrées validées" (celui avec `v-model:page="validatedPage"`), ajouter les deux props :
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<UiDataTable
|
|
||||||
v-model:page="validatedPage"
|
|
||||||
v-model:per-page="validatedPerPage"
|
|
||||||
:columns="validatedColumns"
|
|
||||||
:items="validated"
|
|
||||||
:total-items="totalValidated"
|
|
||||||
:loading="validatedLoading"
|
|
||||||
row-clickable
|
|
||||||
@row-click="goToBovineInfo"
|
|
||||||
>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3 : Smoke test manuel**
|
|
||||||
|
|
||||||
Dev server toujours en marche. Sur `/entry-exit`, cliquer sur une ligne dans "Entrées validées" → la page `/entry-exit/bovine-info/{id}` s'ouvre, titre correct, premier accordéon non-saisi ouvert.
|
|
||||||
|
|
||||||
- [ ] **Step 4 : Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/pages/entry-exit/index.vue
|
|
||||||
git commit -m "feat(front) : clic sur entrée validée → page saisie info bovin"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 7 : Vérification fonctionnelle complète
|
|
||||||
|
|
||||||
Pas de code. Juste un parcours manuel en mode admin.
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Cas non-saisi → saisi**
|
|
||||||
- Aller sur `/entry-exit`, cliquer sur une entrée validée avec au moins un bovin non saisi.
|
|
||||||
- Vérifier : premier non-saisi ouvert, badge jaune pour les non-saisis, badge vert pour les déjà-saisis.
|
|
||||||
- Saisir les 4 champs, cliquer Valider.
|
|
||||||
- Vérifier : accordéon se ferme, badge passe vert, l'accordéon suivant non-saisi s'ouvre.
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Champs invalides**
|
|
||||||
- Ouvrir un accordéon vide, cliquer Valider sans rien remplir.
|
|
||||||
- Vérifier : bordures rouges, pas de requête réseau (DevTools).
|
|
||||||
|
|
||||||
- [ ] **Step 3 : Bâtiment change → case reset**
|
|
||||||
- Choisir un bâtiment, choisir une case, changer de bâtiment.
|
|
||||||
- Vérifier : la case repasse à vide.
|
|
||||||
|
|
||||||
- [ ] **Step 4 : Reload sur saisie partielle**
|
|
||||||
- Saisir les 4 champs d'un bovin, valider (badge vert).
|
|
||||||
- Recharger la page (F5).
|
|
||||||
- Vérifier : ce bovin a un badge vert au chargement, il est positionné en bas, fermé. Le premier non-saisi suivant est ouvert.
|
|
||||||
|
|
||||||
- [ ] **Step 5 : Tout saisi**
|
|
||||||
- Saisir tous les bovins.
|
|
||||||
- Vérifier : tous fermés, tous verts, pas d'accordéon ouvert. Pas de toast / pas de redirection.
|
|
||||||
|
|
||||||
- [ ] **Step 6 : Bouton retour**
|
|
||||||
- Cliquer la flèche retour : retour à `/entry-exit`.
|
|
||||||
|
|
||||||
Si un point échoue, debug puis corriger. Une fois OK, le boulot est fini — pas de commit supplémentaire (chaque task a déjà été commitée).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hors plan (à faire si bug remonté)
|
|
||||||
|
|
||||||
- Pagination des bovins si une réception en a > 200 (pour l'instant `itemsPerPage=200` couvre largement le besoin métier).
|
|
||||||
- Animation d'ouverture/fermeture de l'accordéon (pour l'instant `v-if` brut, sans transition).
|
|
||||||
- Tests unitaires Vitest (cohérent avec l'absence de tests frontend dans le repo).
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
# Entrée / Sortie des bovins — Design
|
|
||||||
|
|
||||||
## Contexte
|
|
||||||
|
|
||||||
Aujourd'hui, l'application gère les **réceptions** (arrivée d'un camion) qui déclarent un nombre de bovins par race (ex : 5 charolais + 3 limousine + 2 autres). Une fois la réception terminée, ces déclarations sont des indicateurs imprécis et il manque l'étape de saisie individuelle des bovins (numéro national, poids, prix…).
|
|
||||||
|
|
||||||
L'objectif est d'introduire un **workflow d'entrée** qui transforme une réception bovins finie en saisies individuelles enrichies via EDNOTIF, et de poser les fondations pour un futur workflow de sortie symétrique.
|
|
||||||
|
|
||||||
Pour ce lot, **les sorties sont hors scope** mais l'écran liste prévoit déjà leur emplacement.
|
|
||||||
|
|
||||||
## Décisions structurantes
|
|
||||||
|
|
||||||
| Décision | Choix |
|
|
||||||
| --- | --- |
|
|
||||||
| Distinction "en attente" vs "terminée" | Flag explicite `entryCompleted: bool` sur `Reception` |
|
|
||||||
| Lien Bovine → Reception | FK 1-N, `Bovine.reception` ManyToOne **nullable** |
|
|
||||||
| Rendu de l'écran de saisie | UN formulaire (2 lignes) + tableau récap dessous |
|
|
||||||
| Bâtiment + Case | Choisis **par bovin** dans le formulaire |
|
|
||||||
| Persistance | Save individuel à chaque "Ajouter" (POST /bovines) |
|
|
||||||
| Enrichissement EDNOTIF | Au backend via le `BovineProcessor` existant (pas de lookup live) |
|
|
||||||
|
|
||||||
## Modèle de données
|
|
||||||
|
|
||||||
### `Reception` — modification
|
|
||||||
|
|
||||||
Nouveau champ :
|
|
||||||
- `entryCompleted: bool`, default `false`, non nullable.
|
|
||||||
- Pertinent uniquement quand `receptionType.code === 'BOVINS'`. Pour les autres types, reste `false` et ignoré côté UI.
|
|
||||||
- Inclus dans les groupes `reception:read` et `reception:write`.
|
|
||||||
|
|
||||||
Migration : `ALTER TABLE reception ADD COLUMN entry_completed BOOLEAN NOT NULL DEFAULT false`.
|
|
||||||
|
|
||||||
Ajout d'un `BooleanFilter` sur `entryCompleted` dans `#[ApiFilter]`.
|
|
||||||
|
|
||||||
### `Bovine` — modification
|
|
||||||
|
|
||||||
Nouveau champ :
|
|
||||||
- `reception: Reception` (ManyToOne, **nullable**).
|
|
||||||
- Inclus dans `bovine:read` et `bovine:write`.
|
|
||||||
|
|
||||||
Migration : `ALTER TABLE bovine ADD COLUMN reception_id INTEGER NULL` + index + FK contrainte. Bovins existants restent à `NULL` — aucune migration de données.
|
|
||||||
|
|
||||||
Ajout d'un `SearchFilter` exact sur `reception` dans `#[ApiFilter]` pour permettre `GET /bovines?reception={id}`.
|
|
||||||
|
|
||||||
### `Reception` — relation inverse pour le compteur
|
|
||||||
|
|
||||||
Pour permettre l'affichage du compteur "bovins saisis" dans la liste sans N+1 :
|
|
||||||
|
|
||||||
- Ajouter `bovines: Collection<Bovine>` côté `Reception` (OneToMany inverse, `mappedBy: 'reception'`, fetch lazy).
|
|
||||||
- Exposer un getter calculé `getRegisteredBovineCount(): int` dans le groupe `reception:read`.
|
|
||||||
- L'implémentation côté provider/list peut utiliser un `addSelect('COUNT(b.id) AS bovineCount')` via un `QueryExtension` API Platform si le N+1 devient un problème (à mesurer).
|
|
||||||
|
|
||||||
### Aucune autre entité
|
|
||||||
|
|
||||||
Pas de table de jointure (un bovin entre une seule fois via une réception unique). Pas de nouvelle entité `Entry` (la `Reception` joue ce rôle). Pas d'entité `Exit` pour ce lot — la symétrie sera traitée plus tard.
|
|
||||||
|
|
||||||
## Endpoints API
|
|
||||||
|
|
||||||
Tous les endpoints réutilisent les ressources existantes ; **aucun endpoint custom n'est créé**.
|
|
||||||
|
|
||||||
### Liste des entrées en attente
|
|
||||||
|
|
||||||
`GET /api/receptions?receptionType.code=BOVINS&isValid=true&entryCompleted=false`
|
|
||||||
|
|
||||||
### Validation finale d'une entrée
|
|
||||||
|
|
||||||
`PATCH /api/receptions/{id}` avec `{ entryCompleted: true }`.
|
|
||||||
|
|
||||||
### Création d'un bovin lié
|
|
||||||
|
|
||||||
`POST /api/bovines` (Content-Type `application/ld+json`) avec :
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nationalNumber": "FR1234567890",
|
|
||||||
"receivedWeight": 368,
|
|
||||||
"pricePerKg": 5.7,
|
|
||||||
"arrivalDate": "2026-04-29",
|
|
||||||
"supplier": "/api/suppliers/12",
|
|
||||||
"reception": "/api/receptions/45",
|
|
||||||
"buildingCase": "/api/building_cases/8"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Le `BovineProcessor` enrichit automatiquement (workNumber, birthDate, race auto-créée via `BovineType`).
|
|
||||||
|
|
||||||
**Nettoyage en passant** : le `BovineProcessor` actuel appelle `setBreedCode()` qui n'existe plus (héritage avant la migration vers `BovineType` FK). À corriger pour qu'il fasse `setBovineType()` avec auto-create d'un `BovineType` si la race retournée par EDNOTIF n'existe pas en base.
|
|
||||||
|
|
||||||
### Suppression d'un bovin
|
|
||||||
|
|
||||||
`DELETE /api/bovines/{id}` — sécurité actuelle `ROLE_ADMIN` à abaisser à `ROLE_USER` pour permettre la correction immédiate depuis le tableau.
|
|
||||||
|
|
||||||
## Front-end
|
|
||||||
|
|
||||||
### Home (`pages/index.vue`)
|
|
||||||
|
|
||||||
- Card "CASES" → renommée "ENTRÉE / SORTIE" (multi-ligne `Entrée<br>Sortie`).
|
|
||||||
- Lien : `/entry-exit`.
|
|
||||||
- Icône : `mdi:swap-horizontal-bold` (à finaliser à l'implémentation).
|
|
||||||
|
|
||||||
### Page liste — `pages/entry-exit/index.vue`
|
|
||||||
|
|
||||||
Deux sections empilées :
|
|
||||||
|
|
||||||
**Entrées en attente**
|
|
||||||
- Composant : `UiDataTable`.
|
|
||||||
- Filtres serveur : `receptionType.code=BOVINS`, `isValid=true`, `entryCompleted=false`.
|
|
||||||
- Colonnes :
|
|
||||||
- Date réception
|
|
||||||
- Fournisseur (`supplier.name`)
|
|
||||||
- Total déclaré (calculé côté front : `sum(bovines_types.quantity) + parseInt(bovineDetail ?? '0')`)
|
|
||||||
- Bovins saisis (depuis `getRegisteredBovineCount` exposé sur Reception)
|
|
||||||
- Action (rangée cliquable)
|
|
||||||
- Click row → `/entry-exit/entry/{receptionId}`.
|
|
||||||
|
|
||||||
**Sorties en attente**
|
|
||||||
- Tableau placeholder vide avec message "À venir".
|
|
||||||
|
|
||||||
### Écran de saisie — `pages/entry-exit/entry/[id].vue`
|
|
||||||
|
|
||||||
**Header**
|
|
||||||
- Titre : "Entrée bovins #N-BR-XXXX — Fournisseur YYY"
|
|
||||||
- Sous-titre : "Bovins déclarés : 8 · Bovins saisis : 3"
|
|
||||||
- Icône retour à gauche.
|
|
||||||
|
|
||||||
**Formulaire (2 lignes)**
|
|
||||||
|
|
||||||
Ligne 1 : Numéro national · Poids à l'arrivée · Date d'arrivée · Vendeur (Supplier select)
|
|
||||||
Ligne 2 : Prix au kilo · Bâtiment (Building select) · Case (BuildingCase select dépendant du bâtiment) · Bouton **Ajouter**
|
|
||||||
|
|
||||||
**Pré-remplissage** (au chargement et après chaque add) :
|
|
||||||
- Date d'arrivée = `reception.receptionDate` (date seule, modifiable)
|
|
||||||
- Vendeur = `reception.supplier` (modifiable)
|
|
||||||
- Bâtiment = premier de `reception.buildings` si dispo, sinon vide
|
|
||||||
- Case = vide (à choisir explicitement)
|
|
||||||
- Numéro national, poids, prix : vides
|
|
||||||
|
|
||||||
**Comportement bouton "Ajouter"**
|
|
||||||
- Disabled si form invalide (n° national vide, poids ≤ 0, prix ≤ 0, building/case manquants).
|
|
||||||
- Click → `POST /api/bovines` avec `application/ld+json`.
|
|
||||||
- Succès → reload du tableau, reset form (en gardant les pré-remplissages), focus sur Numéro national.
|
|
||||||
- Erreur 409 (doublon n° national) → toast "Ce bovin existe déjà".
|
|
||||||
- Erreur EDNOTIF → bovin créé sans enrichissement (race/naissance vides), toast warning.
|
|
||||||
|
|
||||||
**Tableau récap (dessous)**
|
|
||||||
|
|
||||||
Colonnes : N° national · N° travail · Race · Sexe · Date naissance · Poids arrivée · Date arrivée · Prix/kg · Prix total · Bâtiment · Case · Action (icône poubelle).
|
|
||||||
|
|
||||||
Source : `GET /api/bovines?reception={id}` au mount + après chaque add/delete.
|
|
||||||
|
|
||||||
Suppression : `DELETE /api/bovines/{id}` avec `window.confirm`.
|
|
||||||
|
|
||||||
**Footer**
|
|
||||||
- Bouton **Valider l'entrée** (à droite).
|
|
||||||
- Si `bovins saisis < bovins déclarés` → `window.confirm("Vous n'avez saisi que X/Y bovins. Confirmer la fermeture ?")`.
|
|
||||||
- Disabled si 0 bovin saisi.
|
|
||||||
- Click → `PATCH /api/receptions/{id}` avec `{ entryCompleted: true }` → toast succès → redirection `/entry-exit`.
|
|
||||||
|
|
||||||
## Sécurité (rôles)
|
|
||||||
|
|
||||||
| Action | Rôle requis |
|
|
||||||
| --- | --- |
|
|
||||||
| Voir la page entrée/sortie | `ROLE_USER` |
|
|
||||||
| Ajouter un bovin (POST /bovines) | `ROLE_USER` (actuellement `ROLE_ADMIN` — à abaisser, ce flux est métier opérationnel) |
|
|
||||||
| Supprimer un bovin (DELETE /bovines) | `ROLE_USER` (idem, à abaisser) |
|
|
||||||
| Valider l'entrée (PATCH receptions) | `ROLE_USER` |
|
|
||||||
|
|
||||||
L'abaissement à `ROLE_USER` sur `Bovine::Post`, `Bovine::Patch` et `Bovine::Delete` est **délibéré** : ce flux fait partie des opérations métier quotidiennes, pas de l'administration. À confirmer pendant l'implémentation.
|
|
||||||
|
|
||||||
## Cas limites
|
|
||||||
|
|
||||||
- **Total saisi > déclaré** : autorisé (les déclarations en réception sont des indicateurs imprécis).
|
|
||||||
- **Doublon n° national** : la `UniqueConstraint` BDD le rejette → toast.
|
|
||||||
- **EDNOTIF indisponible** : bovin créé sans enrich, comportement actuel du processor.
|
|
||||||
- **Réception supprimée pendant la saisie** : impossible côté UI tant qu'on est dans l'écran. Si ça arrive (autre user), les `POST /bovines` suivants échoueront en 404 sur l'IRI reception → toast.
|
|
||||||
- **Sortie d'un bovin** : non géré dans ce lot. Le futur workflow de sortie viendra basculer `Bovine.exitedAt`.
|
|
||||||
|
|
||||||
## Critères d'acceptation
|
|
||||||
|
|
||||||
- [ ] Migration `entry_completed` sur Reception passe sans erreur.
|
|
||||||
- [ ] Migration `reception_id` sur Bovine passe sans erreur, bovins existants intacts.
|
|
||||||
- [ ] Card "CASES" sur home remplacée par "ENTRÉE / SORTIE".
|
|
||||||
- [ ] `/entry-exit` affiche les entrées en attente et un placeholder sorties.
|
|
||||||
- [ ] Click sur une entrée → écran saisie avec form pré-rempli.
|
|
||||||
- [ ] "Ajouter" → bovin créé, ligne au tableau, form reset (pré-remplissages restaurés).
|
|
||||||
- [ ] Suppression d'une ligne fonctionne avec confirmation.
|
|
||||||
- [ ] "Valider l'entrée" bascule `entryCompleted` et redirige.
|
|
||||||
- [ ] Une réception fermée disparaît de la liste.
|
|
||||||
- [ ] `BovineProcessor` corrigé pour utiliser `setBovineType()` avec auto-create.
|
|
||||||
- [ ] `make test` passe sans régression.
|
|
||||||
|
|
||||||
## Mode d'implémentation
|
|
||||||
|
|
||||||
Sur ce projet, l'utilisateur souhaite **valider chaque étape du plan** avant exécution. À chaque étape du plan d'implémentation, l'agent doit :
|
|
||||||
|
|
||||||
1. Présenter ce qu'il s'apprête à faire (fichiers, changements).
|
|
||||||
2. Attendre la validation explicite de l'utilisateur.
|
|
||||||
3. Exécuter, puis présenter l'étape suivante.
|
|
||||||
|
|
||||||
Cette discipline permet des retours en direct et des ajustements fins en cours de route.
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
# Saisie information bovin (post-EDNOTIF)
|
|
||||||
|
|
||||||
## Contexte
|
|
||||||
|
|
||||||
Sur la page `/entry-exit`, le tableau "Entrées validées" liste les receptions
|
|
||||||
dont les bovins sont tous confirmés EDNOTIF (`reception.validatedAt` non null).
|
|
||||||
Une fois cette validation acquise, l'utilisateur doit encore renseigner pour
|
|
||||||
chaque bovin quatre informations métier qui ne viennent pas d'EDNOTIF :
|
|
||||||
|
|
||||||
- poids d'arrivée
|
|
||||||
- prix d'achat au kg
|
|
||||||
- bâtiment
|
|
||||||
- case
|
|
||||||
|
|
||||||
Cette spec décrit l'écran de saisie et le composant accordéon qu'il introduit.
|
|
||||||
|
|
||||||
## Périmètre
|
|
||||||
|
|
||||||
- Nouvelle page accessible uniquement via clic sur une ligne d'"Entrées
|
|
||||||
validées" — pas d'entrée dans la nav globale.
|
|
||||||
- Aucun changement d'entité Doctrine, aucune migration : les quatre champs
|
|
||||||
existent déjà sur `Bovine` (`receivedWeight`, `pricePerKg`, `buildingCase`).
|
|
||||||
- Le champ `building` (legacy XLSX) n'est pas écrit. Côté affichage,
|
|
||||||
`getEffectiveBuilding()` continue de dériver le bâtiment effectif depuis
|
|
||||||
`buildingCase`.
|
|
||||||
- `pricePerKg` reste protégé par `ROLE_BUREAU` côté API. La page exige
|
|
||||||
`ROLE_ADMIN` ; la hiérarchie Symfony fait que `ROLE_ADMIN` hérite
|
|
||||||
`ROLE_BUREAU`, donc pas de cas particulier.
|
|
||||||
- Pas de gestion de "session interrompue" : chaque accordéon validé
|
|
||||||
individuellement est persisté immédiatement.
|
|
||||||
|
|
||||||
## Routing & navigation
|
|
||||||
|
|
||||||
- Page : `frontend/pages/entry-exit/bovine-info/[id].vue` où `[id]` est le
|
|
||||||
`receptionId`.
|
|
||||||
- Sur `frontend/pages/entry-exit/index.vue`, le tableau "Entrées validées"
|
|
||||||
reçoit `row-clickable` et `@row-click="goToBovineInfo"`. Le handler pousse
|
|
||||||
vers `/entry-exit/bovine-info/{reception.id}`.
|
|
||||||
- Le bouton flèche-retour de la page renvoie vers `/entry-exit`.
|
|
||||||
|
|
||||||
## Composant `UiAccordion`
|
|
||||||
|
|
||||||
Fichier : `frontend/components/ui/UiAccordion.vue`. Réutilisable, sans logique
|
|
||||||
métier.
|
|
||||||
|
|
||||||
**Props**
|
|
||||||
- `modelValue: boolean` — état ouvert/fermé, supporte `v-model`.
|
|
||||||
|
|
||||||
**Slots**
|
|
||||||
- `#header` — contenu libre du header (badge, titre, etc., aligné à gauche).
|
|
||||||
- default — corps de l'accordéon, rendu uniquement quand ouvert.
|
|
||||||
|
|
||||||
**Comportement**
|
|
||||||
- Click sur le header → emit `update:modelValue` avec la valeur inversée.
|
|
||||||
- Header : `bg-slate-100`, padding identique aux headers `UiDataTable`
|
|
||||||
(`px-4 py-3`), texte semi-bold uppercase.
|
|
||||||
- Chevron à droite (`mdi:chevron-down`), rotation 180° quand ouvert,
|
|
||||||
transition CSS courte.
|
|
||||||
- Pas d'animation de hauteur au déploiement (pour rester simple) — on rend ou
|
|
||||||
pas via `v-if`.
|
|
||||||
|
|
||||||
## Page `bovine-info/[id].vue`
|
|
||||||
|
|
||||||
**Layout** — copie du pattern `entry-exit/entry/[id].vue` :
|
|
||||||
`<div class="px-[86px]">` + bandeau titre avec flèche retour absolue, titre
|
|
||||||
`<h1>Saisie information bovin {{ reception.identificationNumber }}</h1>`.
|
|
||||||
Pas de sous-titre.
|
|
||||||
|
|
||||||
**Chargement (`onMounted`)**
|
|
||||||
|
|
||||||
1. `GET receptions/{id}` → alimente le titre.
|
|
||||||
2. `GET bovines?reception={id}&itemsPerPage=200` (pas de pagination — on
|
|
||||||
suppose qu'une réception a au plus quelques dizaines de bovins).
|
|
||||||
3. `GET buildings` — la réponse contient `buildingCases` imbriqués
|
|
||||||
(`BuildingData.buildingCases`). On dérive de là à la fois la liste de
|
|
||||||
bâtiments (selector "Bâtiment") et l'index des cases par bâtiment.
|
|
||||||
|
|
||||||
**État local par bovin** (`Map<bovineId, FormState>`) :
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type FormState = {
|
|
||||||
receivedWeight: number | null
|
|
||||||
pricePerKg: number | null
|
|
||||||
buildingId: number | null // UI-only, drive le filtre Case
|
|
||||||
buildingCaseId: number | null
|
|
||||||
submitted: boolean // pour les borders rouges au submit
|
|
||||||
isSaving: boolean
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Initialisé depuis l'API : `receivedWeight`, `pricePerKg` directement,
|
|
||||||
`buildingCaseId = bovine.buildingCase?.id`,
|
|
||||||
`buildingId = bovine.effectiveBuilding?.id`.
|
|
||||||
|
|
||||||
**Source de vérité du badge** : `bovine.receivedWeight != null
|
|
||||||
&& bovine.pricePerKg != null && bovine.buildingCase != null` — donc calculé
|
|
||||||
sur les valeurs *persistées*, pas sur le `FormState` en cours d'édition. Vert
|
|
||||||
"Saisie" si les trois sont non-null (le bâtiment est dérivé de la case),
|
|
||||||
sinon jaune "Attente saisie".
|
|
||||||
|
|
||||||
> Note : on garde 4 champs côté UI mais 3 conditions backend, parce que
|
|
||||||
> `building` n'est pas persisté indépendamment.
|
|
||||||
|
|
||||||
**Tri** — non-saisis (badge jaune) en haut puis saisis (badge vert) en bas,
|
|
||||||
ordre d'API préservé à l'intérieur de chaque groupe. Le tri est calculé
|
|
||||||
- au chargement initial,
|
|
||||||
- après chaque PATCH OK (le bovin qui vient d'être saisi descend dans le
|
|
||||||
groupe vert).
|
|
||||||
|
|
||||||
Il ne se recompute pas pendant qu'un accordéon est ouvert et en cours
|
|
||||||
d'édition — sinon les bovins sauteraient de position au moindre changement
|
|
||||||
de l'état "saisi/non-saisi", ce qui ne se produit ici que sur un PATCH
|
|
||||||
réussi.
|
|
||||||
|
|
||||||
**Open state** — `ref<number | null>` qui contient l'id du bovin
|
|
||||||
actuellement ouvert. Un seul accordéon ouvert à la fois.
|
|
||||||
|
|
||||||
- Initialisation : id du premier bovin non-saisi de la liste triée, ou
|
|
||||||
`null` si tout est déjà saisi.
|
|
||||||
- Click sur un header autre que celui ouvert → ferme l'ouvert, ouvre le
|
|
||||||
cliqué.
|
|
||||||
- Click sur le header ouvert → ferme (open = `null`).
|
|
||||||
- Validation OK d'un accordéon → ferme l'actuel, ouvre l'id du prochain
|
|
||||||
non-saisi de la liste (recalculée). Si plus de non-saisi → `null`.
|
|
||||||
|
|
||||||
## Formulaire par accordéon
|
|
||||||
|
|
||||||
Tous les champs `required`, validation au submit (pattern `submitted` flag +
|
|
||||||
`.submitted :invalid` du CSS global).
|
|
||||||
|
|
||||||
| Champ | Composant | Type / format |
|
|
||||||
| ------------------ | -------------------- | ------------------------ |
|
|
||||||
| Poids d'arrivée | `UiNumberInput` | entier kg |
|
|
||||||
| Prix d'achat (kg) | `UiNumberInput` | float, step 0.01 |
|
|
||||||
| Bâtiment | `UiSelect` | options = liste building |
|
|
||||||
| Case | `UiSelect` | options = cases du building sélectionné |
|
|
||||||
|
|
||||||
- Watch sur `buildingId` : si l'utilisateur change le bâtiment et que la case
|
|
||||||
actuellement sélectionnée n'appartient pas au nouveau, on remet
|
|
||||||
`buildingCaseId = null`.
|
|
||||||
- Bouton `Valider` centré, `bg-primary-500`, désactivé pendant `isSaving`.
|
|
||||||
|
|
||||||
**Soumission**
|
|
||||||
|
|
||||||
```
|
|
||||||
PATCH /bovines/{id}
|
|
||||||
{
|
|
||||||
receivedWeight,
|
|
||||||
pricePerKg,
|
|
||||||
buildingCase: `/api/building_cases/${buildingCaseId}`
|
|
||||||
}
|
|
||||||
Content-Type: application/ld+json
|
|
||||||
```
|
|
||||||
|
|
||||||
À la réponse OK, on remplace le bovin dans la liste locale par la version
|
|
||||||
retournée par l'API (qui contient `buildingCase` hydraté pour recomputer le
|
|
||||||
badge), puis on déclenche la transition d'état (resort + open suivant).
|
|
||||||
|
|
||||||
En cas d'erreur HTTP, le toast par défaut de `useApi` suffit ; on garde
|
|
||||||
l'accordéon ouvert et le `FormState` intact.
|
|
||||||
|
|
||||||
## Hors périmètre
|
|
||||||
|
|
||||||
- Pas de bulk-save (pas de "Tout valider").
|
|
||||||
- Pas de tracking "modifié non sauvé" / warning au unload — chaque accordéon
|
|
||||||
est validé explicitement, pas d'autosave.
|
|
||||||
- Pas de tests automatisés ajoutés dans ce lot (cohérent avec le reste de la
|
|
||||||
feature entry-exit).
|
|
||||||
- Pas d'exposition de cet écran ailleurs que via le tableau "Entrées
|
|
||||||
validées".
|
|
||||||
|
|
||||||
## Critères d'acceptation
|
|
||||||
|
|
||||||
- Cliquer sur une ligne du tableau "Entrées validées" ouvre la page
|
|
||||||
`/entry-exit/bovine-info/{id}`.
|
|
||||||
- La page liste tous les bovins de la réception, non-saisis en haut.
|
|
||||||
- Au chargement, un seul accordéon est ouvert : le premier non-saisi (ou
|
|
||||||
aucun si tout est déjà saisi).
|
|
||||||
- Cliquer sur un autre header ferme l'ouvert et ouvre le cliqué.
|
|
||||||
- Soumettre un accordéon avec un champ vide affiche les borders rouges
|
|
||||||
(`submitted` flag) et bloque la requête.
|
|
||||||
- Soumettre un accordéon valide PATCH le bovin et, après réponse OK, ferme
|
|
||||||
l'accordéon, met le badge en vert et ouvre le suivant non-saisi.
|
|
||||||
- Recharger la page après une saisie partielle réaffiche les valeurs
|
|
||||||
pré-remplies et le bon badge pour chaque bovin.
|
|
||||||
- `php-cs-fixer` et `make test` restent verts (pas de code backend modifié,
|
|
||||||
donc rien à régresser).
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
<template>
|
|
||||||
<form class="space-y-6" @submit.prevent="submit">
|
|
||||||
<div class="grid grid-cols-4 gap-x-12 gap-y-6">
|
|
||||||
<UiNumberInput
|
|
||||||
v-model="form.receivedWeight"
|
|
||||||
label="Poids d'arrivée (kg)"
|
|
||||||
wrapperClass="flex-col"
|
|
||||||
labelClass="font-bold uppercase text-xl text-primary-700"
|
|
||||||
:min="0"
|
|
||||||
:step="1"
|
|
||||||
/>
|
|
||||||
<UiNumberInput
|
|
||||||
v-model="form.pricePerKg"
|
|
||||||
label="Prix au kg"
|
|
||||||
wrapperClass="flex-col"
|
|
||||||
labelClass="font-bold uppercase text-xl text-primary-700"
|
|
||||||
:min="0"
|
|
||||||
:step="0.01"
|
|
||||||
/>
|
|
||||||
<UiSelect
|
|
||||||
v-model="form.buildingId"
|
|
||||||
label="Bâtiment"
|
|
||||||
:options="buildingOptions"
|
|
||||||
/>
|
|
||||||
<UiSelect
|
|
||||||
v-model="form.buildingCaseId"
|
|
||||||
label="Case"
|
|
||||||
:options="caseOptions"
|
|
||||||
:disabled="form.buildingId === null"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<UiButton
|
|
||||||
type="submit"
|
|
||||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
|
||||||
:disabled="isSaving"
|
|
||||||
:loading="isSaving"
|
|
||||||
>
|
|
||||||
Valider
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { BovineData } from '~/services/dto/bovine-data'
|
|
||||||
import type { BuildingData } from '~/services/dto/building-data'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
bovine: BovineData
|
|
||||||
buildings: BuildingData[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
saved: [bovine: BovineData]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const api = useApi()
|
|
||||||
|
|
||||||
interface FormState {
|
|
||||||
receivedWeight: number | null
|
|
||||||
pricePerKg: number | null
|
|
||||||
buildingId: number | null
|
|
||||||
buildingCaseId: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = reactive<FormState>({
|
|
||||||
receivedWeight: props.bovine.receivedWeight ?? null,
|
|
||||||
pricePerKg: props.bovine.pricePerKg ?? null,
|
|
||||||
buildingId: props.bovine.buildingCase?.building?.id
|
|
||||||
?? props.bovine.effectiveBuilding?.id
|
|
||||||
?? null,
|
|
||||||
buildingCaseId: props.bovine.buildingCase?.id ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const isSaving = ref(false)
|
|
||||||
|
|
||||||
const buildingOptions = computed(() =>
|
|
||||||
props.buildings.map(b => ({ value: b.id, label: b.label }))
|
|
||||||
)
|
|
||||||
|
|
||||||
const caseOptions = computed(() => {
|
|
||||||
if (form.buildingId === null) return []
|
|
||||||
const building = props.buildings.find(b => b.id === form.buildingId)
|
|
||||||
if (!building?.buildingCases) return []
|
|
||||||
return building.buildingCases.map(c => ({
|
|
||||||
value: c.id,
|
|
||||||
label: c.caseNumber !== null ? `Case ${c.caseNumber}` : (c.code ?? `#${c.id}`)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => form.buildingId, (newId) => {
|
|
||||||
if (form.buildingCaseId === null) return
|
|
||||||
const building = props.buildings.find(b => b.id === newId)
|
|
||||||
const caseStillValid = building?.buildingCases?.some(c => c.id === form.buildingCaseId)
|
|
||||||
if (!caseStillValid) {
|
|
||||||
form.buildingCaseId = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
const payload: Record<string, unknown> = {}
|
|
||||||
if (form.receivedWeight != null) payload.receivedWeight = form.receivedWeight
|
|
||||||
if (form.pricePerKg != null) payload.pricePerKg = form.pricePerKg
|
|
||||||
if (form.buildingCaseId != null) {
|
|
||||||
payload.buildingCase = `/api/building_cases/${form.buildingCaseId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(payload).length === 0) {
|
|
||||||
emit('saved', props.bovine)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaving.value = true
|
|
||||||
try {
|
|
||||||
const updated = await api.patch<BovineData>(
|
|
||||||
`bovines/${props.bovine.id}`,
|
|
||||||
payload,
|
|
||||||
{ toastSuccessMessage: `Bovin ${props.bovine.nationalNumber} enregistré.` }
|
|
||||||
)
|
|
||||||
emit('saved', updated)
|
|
||||||
} finally {
|
|
||||||
isSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="overflow-hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center justify-between gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide text-left"
|
|
||||||
@click="toggle"
|
|
||||||
>
|
|
||||||
<span class="flex-1">
|
|
||||||
<slot name="header" />
|
|
||||||
</span>
|
|
||||||
<Icon
|
|
||||||
name="mdi:chevron-down"
|
|
||||||
size="24"
|
|
||||||
class="shrink-0 transition-transform"
|
|
||||||
:class="{ 'rotate-180': modelValue }"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div v-if="modelValue" class="border border-t-0 border-slate-200 px-6 py-6">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: boolean]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const toggle = () => emit('update:modelValue', !props.modelValue)
|
|
||||||
</script>
|
|
||||||
@@ -153,7 +153,7 @@ const props = withDefaults(defineProps<{
|
|||||||
totalItems: undefined,
|
totalItems: undefined,
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 10,
|
perPage: 10,
|
||||||
perPageOptions: () => [5, 10, 25, 50],
|
perPageOptions: () => [10, 25, 50],
|
||||||
rowClickable: false,
|
rowClickable: false,
|
||||||
showActions: false,
|
showActions: false,
|
||||||
emptyMessage: 'Aucune donnée',
|
emptyMessage: 'Aucune donnée',
|
||||||
|
|||||||
@@ -108,9 +108,7 @@ const onInput = (event: Event) => {
|
|||||||
numeric = Math.min(max, numeric)
|
numeric = Math.min(max, numeric)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (numeric !== parsed) {
|
target.value = String(numeric)
|
||||||
target.value = String(numeric)
|
|
||||||
}
|
|
||||||
emit('update:modelValue', numeric)
|
emit('update:modelValue', numeric)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
frontend/components/ui/UiTabs.vue
Normal file
35
frontend/components/ui/UiTabs.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
|
||||||
|
<h1
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
class="font-bold text-3xl uppercase px-12 cursor-pointer"
|
||||||
|
:class="[
|
||||||
|
modelValue === tab.key
|
||||||
|
? 'border-b-[6px] border-primary-500 text-primary-500'
|
||||||
|
: 'text-primary-500/50',
|
||||||
|
tab.error ? '!text-red-500 !border-red-500' : ''
|
||||||
|
]"
|
||||||
|
@click="emit('update:modelValue', tab.key)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" generic="T extends string">
|
||||||
|
export interface UiTab<K extends string = string> {
|
||||||
|
key: K
|
||||||
|
label: string
|
||||||
|
error?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
modelValue: T
|
||||||
|
tabs: UiTab<T>[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: T): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
358
frontend/pages/bovine/[id].vue
Normal file
358
frontend/pages/bovine/[id].vue
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
<template>
|
||||||
|
<div class="px-[86px]">
|
||||||
|
<div class="flex items-center justify-between relative mb-10">
|
||||||
|
<div class="flex flex-row absolute -left-[60px]">
|
||||||
|
<Icon
|
||||||
|
@click="goBack"
|
||||||
|
name="gg:arrow-left-o"
|
||||||
|
size="44"
|
||||||
|
class="cursor-pointer text-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 class="font-bold text-3xl uppercase text-primary-500">Vie du bovin</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UiTabs
|
||||||
|
v-model="activeTab"
|
||||||
|
:tabs="tabs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="auth.isBureau" v-show="activeTab === 'mouvement'">
|
||||||
|
<form :class="{ submitted: movementSubmitted }" @submit.prevent="submitMovement">
|
||||||
|
<div class="flex flex-cols-3 justify-between mb-10">
|
||||||
|
<UiSelect
|
||||||
|
id="movement-building"
|
||||||
|
v-model="newMovementBuildingId"
|
||||||
|
label="Bâtiment"
|
||||||
|
:options="buildingOptions"
|
||||||
|
wrapper-class="w-[280px]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<UiSelect
|
||||||
|
id="movement-case"
|
||||||
|
v-model="newMovementCaseId"
|
||||||
|
label="Case"
|
||||||
|
:options="caseOptions"
|
||||||
|
:disabled="!newMovementBuildingId"
|
||||||
|
wrapper-class="w-[280px]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<UiDateInput
|
||||||
|
id="movement-date"
|
||||||
|
v-model="newMovementDate"
|
||||||
|
label="Date mouvement"
|
||||||
|
wrapper-class="w-[280px]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center mb-11">
|
||||||
|
<UiButton
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center gap-2 text-xl text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80"
|
||||||
|
:disabled="isSubmittingMovement"
|
||||||
|
:loading="isSubmittingMovement"
|
||||||
|
@click="movementSubmitted = true"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:plus" size="28" />
|
||||||
|
Ajouter
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<UiDataTable
|
||||||
|
:columns="movementColumns"
|
||||||
|
:items="filteredMovementRows"
|
||||||
|
:per-page="10"
|
||||||
|
>
|
||||||
|
<template #header-building>
|
||||||
|
<UiTextInput
|
||||||
|
v-model="movementFilters.building"
|
||||||
|
placeholder="Bâtiment"
|
||||||
|
size="compact"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #header-case>
|
||||||
|
<UiTextInput
|
||||||
|
v-model="movementFilters.case"
|
||||||
|
placeholder="Case"
|
||||||
|
size="compact"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #header-enteredAt>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Du" size="compact" disabled />
|
||||||
|
</template>
|
||||||
|
<template #header-leftAt>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Au" size="compact" disabled />
|
||||||
|
</template>
|
||||||
|
<template #header-duration>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Durée" size="compact" disabled />
|
||||||
|
</template>
|
||||||
|
<template #cell-leftAt="{ item }">
|
||||||
|
<span v-if="item.leftAt">{{ item.leftAt }}</span>
|
||||||
|
<span v-else class="italic text-slate-500">En cours</span>
|
||||||
|
</template>
|
||||||
|
</UiDataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="activeTab === 'passeport'">
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
|
||||||
|
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
|
||||||
|
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Veau</span>
|
||||||
|
</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Sexe</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
|
||||||
|
<div class="border-b border-black px-2 py-1 text-center font-semibold text-sm">Date de naissance</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.nationalNumber) }}</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.workNumber) }}</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.sex) }}</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.bovineType?.code) }}</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.bovineType?.label) }}</div>
|
||||||
|
<div class="px-2 py-1 text-center">{{ formatDate(bovine?.birthDate) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-9">
|
||||||
|
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
|
||||||
|
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
|
||||||
|
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Père</span>
|
||||||
|
</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
|
||||||
|
<div class="col-span-2 border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
|
||||||
|
<div class="col-span-2 border-b border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.fatherNationalNumber) }}</div>
|
||||||
|
<div class="col-span-2 border-r border-black px-2 py-1 text-center">{{ display(workNumberFromNational(bovine?.fatherNationalNumber)) }}</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.fatherBovineType?.code) }}</div>
|
||||||
|
<div class="col-span-2 px-2 py-1 text-center">{{ display(bovine?.fatherBovineType?.label) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-9">
|
||||||
|
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
|
||||||
|
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
|
||||||
|
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Mère</span>
|
||||||
|
</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
|
||||||
|
<div class="col-span-2 border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
|
||||||
|
<div class="col-span-2 border-b border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.motherNationalNumber) }}</div>
|
||||||
|
<div class="col-span-2 border-r border-black px-2 py-1 text-center">{{ display(workNumberFromNational(bovine?.motherNationalNumber)) }}</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.motherBovineType?.code) }}</div>
|
||||||
|
<div class="col-span-2 px-2 py-1 text-center">{{ display(bovine?.motherBovineType?.label) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="activeTab === 'sante'">
|
||||||
|
<div class="border-2 border-dashed border-primary-500 rounded-md py-16 text-center text-primary-500 font-bold uppercase text-2xl">
|
||||||
|
À venir
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getBuildingList } from '~/services/building'
|
||||||
|
import type { BuildingData } from '~/services/dto/building-data'
|
||||||
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
|
||||||
|
useHead({ title: 'Vie du bovin' })
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
type BovineTab = 'mouvement' | 'passeport' | 'sante'
|
||||||
|
const tabs = computed(() => [
|
||||||
|
...(auth.isBureau ? [{ key: 'mouvement' as const, label: 'Mouvement' }] : []),
|
||||||
|
{ key: 'passeport' as const, label: 'Passeport bovin' },
|
||||||
|
{ key: 'sante' as const, label: 'Santé' }
|
||||||
|
])
|
||||||
|
const activeTab = ref<BovineTab>(auth.isBureau ? 'mouvement' : 'passeport')
|
||||||
|
|
||||||
|
interface BovineTypeRef {
|
||||||
|
id: number
|
||||||
|
label: string | null
|
||||||
|
code: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildingRef {
|
||||||
|
label: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildingCaseRef {
|
||||||
|
caseNumber: number | null
|
||||||
|
building: BuildingRef | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BovineMovementData {
|
||||||
|
id: number
|
||||||
|
enteredAt: string
|
||||||
|
leftAt: string | null
|
||||||
|
buildingCase: BuildingCaseRef | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BovinePassportData {
|
||||||
|
id: number
|
||||||
|
nationalNumber: string
|
||||||
|
workNumber: string | null
|
||||||
|
sex: string | null
|
||||||
|
birthDate: string | null
|
||||||
|
exitedAt: string | null
|
||||||
|
exitDate: string | null
|
||||||
|
bovineType: BovineTypeRef | null
|
||||||
|
motherNationalNumber: string | null
|
||||||
|
motherBovineType: BovineTypeRef | null
|
||||||
|
fatherNationalNumber: string | null
|
||||||
|
fatherBovineType: BovineTypeRef | null
|
||||||
|
movements: BovineMovementData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
if (window.history.state?.back) {
|
||||||
|
router.back()
|
||||||
|
} else {
|
||||||
|
router.push('/inventory')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayIso = () => new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const bovine = ref<BovinePassportData | null>(null)
|
||||||
|
const buildings = ref<BuildingData[]>([])
|
||||||
|
const newMovementBuildingId = ref<string | number | null>(null)
|
||||||
|
const newMovementCaseId = ref<string | number | null>(null)
|
||||||
|
const newMovementDate = ref<string>(todayIso())
|
||||||
|
const isSubmittingMovement = ref(false)
|
||||||
|
const movementSubmitted = ref(false)
|
||||||
|
const movementFilters = ref({ building: '', case: '' })
|
||||||
|
|
||||||
|
const bovineId = computed(() => {
|
||||||
|
const raw = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
|
const n = Number(raw)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const display = (value: string | null | undefined) => (value && value !== '' ? value : '—')
|
||||||
|
|
||||||
|
const workNumberFromNational = (nationalNumber: string | null | undefined) => {
|
||||||
|
if (!nationalNumber) return null
|
||||||
|
return nationalNumber.slice(-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: string | null | undefined) => {
|
||||||
|
if (!date) return '—'
|
||||||
|
const d = new Date(date)
|
||||||
|
if (isNaN(d.getTime())) return date
|
||||||
|
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildingOptions = computed(() =>
|
||||||
|
buildings.value.map(b => ({ value: b.id, label: b.label }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const caseOptions = computed(() => {
|
||||||
|
const building = buildings.value.find(b => b.id === Number(newMovementBuildingId.value))
|
||||||
|
if (!building?.buildingCases) return []
|
||||||
|
return [...building.buildingCases]
|
||||||
|
.sort((a, b) => (a.caseNumber ?? 0) - (b.caseNumber ?? 0))
|
||||||
|
.map(c => ({
|
||||||
|
value: c.id,
|
||||||
|
label: `Case ${c.caseNumber ?? c.code ?? c.id}`
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(newMovementBuildingId, () => {
|
||||||
|
newMovementCaseId.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
const movementColumns = [
|
||||||
|
{ key: 'building', label: 'Bâtiment' },
|
||||||
|
{ key: 'case', label: 'Case' },
|
||||||
|
{ key: 'enteredAt', label: 'Du' },
|
||||||
|
{ key: 'leftAt', label: 'Au' },
|
||||||
|
{ key: 'duration', label: 'Durée' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const movementEndDate = (movement: BovineMovementData): string | null => {
|
||||||
|
return movement.leftAt ?? bovine.value?.exitedAt ?? bovine.value?.exitDate ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (movement: BovineMovementData): string => {
|
||||||
|
const start = new Date(movement.enteredAt)
|
||||||
|
if (isNaN(start.getTime())) return '—'
|
||||||
|
const endRaw = movementEndDate(movement)
|
||||||
|
const end = endRaw ? new Date(endRaw) : new Date()
|
||||||
|
if (isNaN(end.getTime())) return '—'
|
||||||
|
const days = Math.max(0, Math.floor((end.getTime() - start.getTime()) / 86_400_000))
|
||||||
|
return `${days} j`
|
||||||
|
}
|
||||||
|
|
||||||
|
const movementRows = computed(() => {
|
||||||
|
const list = bovine.value?.movements ?? []
|
||||||
|
return list.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
building: m.buildingCase?.building?.label ?? '—',
|
||||||
|
case: m.buildingCase?.caseNumber != null ? `Case ${m.buildingCase.caseNumber}` : '—',
|
||||||
|
enteredAt: formatDate(m.enteredAt),
|
||||||
|
leftAt: m.leftAt ? formatDate(m.leftAt) : null,
|
||||||
|
duration: formatDuration(m)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredMovementRows = computed(() => {
|
||||||
|
const buildingFilter = movementFilters.value.building.trim().toLowerCase()
|
||||||
|
const caseFilter = movementFilters.value.case.trim().toLowerCase()
|
||||||
|
return movementRows.value.filter(row => {
|
||||||
|
if (buildingFilter && !row.building.toLowerCase().includes(buildingFilter)) return false
|
||||||
|
if (caseFilter && !row.case.toLowerCase().includes(caseFilter)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitMovement = async () => {
|
||||||
|
if (!newMovementCaseId.value || !newMovementDate.value || bovineId.value === null) return
|
||||||
|
|
||||||
|
const buildingLabel = buildingOptions.value.find(o => o.value === Number(newMovementBuildingId.value))?.label ?? '—'
|
||||||
|
const caseLabel = caseOptions.value.find(o => o.value === Number(newMovementCaseId.value))?.label ?? '—'
|
||||||
|
const dateLabel = formatDate(newMovementDate.value)
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Confirmer la création du mouvement ?\n\nBâtiment : ${buildingLabel}\nCase : ${caseLabel}\nDate : ${dateLabel}`
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
isSubmittingMovement.value = true
|
||||||
|
try {
|
||||||
|
await api.post('bovine_movements', {
|
||||||
|
bovine: `/api/bovines/${bovineId.value}`,
|
||||||
|
buildingCase: `/api/building_cases/${newMovementCaseId.value}`,
|
||||||
|
enteredAt: newMovementDate.value
|
||||||
|
}, { toastSuccessMessage: 'Mouvement enregistré' })
|
||||||
|
bovine.value = await api.get<BovinePassportData>(`bovines/${bovineId.value}`)
|
||||||
|
newMovementBuildingId.value = null
|
||||||
|
newMovementCaseId.value = null
|
||||||
|
newMovementDate.value = todayIso()
|
||||||
|
movementSubmitted.value = false
|
||||||
|
} finally {
|
||||||
|
isSubmittingMovement.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (bovineId.value === null) return
|
||||||
|
const [bovineData, buildingList] = await Promise.all([
|
||||||
|
api.get<BovinePassportData>(`bovines/${bovineId.value}`),
|
||||||
|
getBuildingList()
|
||||||
|
])
|
||||||
|
bovine.value = bovineData
|
||||||
|
buildings.value = buildingList
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="px-[86px]">
|
|
||||||
<div class="flex items-center justify-start gap-6 relative mb-8">
|
|
||||||
<Icon
|
|
||||||
@click="router.push('/entry-exit')"
|
|
||||||
name="gg:arrow-left-o"
|
|
||||||
size="44"
|
|
||||||
class="cursor-pointer text-primary-500 absolute -left-[60px]"
|
|
||||||
/>
|
|
||||||
<h1 class="font-bold text-3xl uppercase text-primary-500">
|
|
||||||
Saisie information bovin {{ reception?.identificationNumber ?? '' }}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading" class="text-center text-slate-500">Chargement…</div>
|
|
||||||
|
|
||||||
<div v-else>
|
|
||||||
<div class="mb-4 max-w-[200px]">
|
|
||||||
<UiTextInput
|
|
||||||
v-model="searchQuery"
|
|
||||||
placeholder="N° national"
|
|
||||||
size="compact"
|
|
||||||
inputmode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
inputClass="text-xl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<UiAccordion
|
|
||||||
v-for="bovine in filteredBovines"
|
|
||||||
:key="bovine.id"
|
|
||||||
:model-value="openId === bovine.id"
|
|
||||||
@update:model-value="onToggle(bovine.id, $event)"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<span class="flex items-center gap-3 normal-case">
|
|
||||||
<span class="font-bold text-base">{{ bovine.nationalNumber }}</span>
|
|
||||||
<span
|
|
||||||
v-if="isSaisi(bovine)"
|
|
||||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
|
|
||||||
>
|
|
||||||
Saisie
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
|
|
||||||
>
|
|
||||||
Attente saisie
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<BovineInfoForm
|
|
||||||
:bovine="bovine"
|
|
||||||
:buildings="buildings"
|
|
||||||
@saved="onSaved"
|
|
||||||
/>
|
|
||||||
</UiAccordion>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { BovineData } from '~/services/dto/bovine-data'
|
|
||||||
import type { BuildingData } from '~/services/dto/building-data'
|
|
||||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
|
||||||
import { getBuildingList } from '~/services/building'
|
|
||||||
import BovineInfoForm from '~/components/entry-exit/bovine-info-form.vue'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const api = useApi()
|
|
||||||
|
|
||||||
const receptionId = computed(() => Number(route.params.id))
|
|
||||||
|
|
||||||
const reception = ref<ReceptionData | null>(null)
|
|
||||||
const bovines = ref<BovineData[]>([])
|
|
||||||
const buildings = ref<BuildingData[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const openId = ref<number | null>(null)
|
|
||||||
const searchQueryRaw = ref('')
|
|
||||||
const searchQuery = computed<string>({
|
|
||||||
get: () => searchQueryRaw.value,
|
|
||||||
set: (value) => {
|
|
||||||
searchQueryRaw.value = value.replace(/\D/g, '')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: () => `Saisie information bovin ${reception.value?.identificationNumber ?? ''}`.trim()
|
|
||||||
})
|
|
||||||
|
|
||||||
const isSaisi = (bovine: BovineData) =>
|
|
||||||
bovine.receivedWeight != null
|
|
||||||
&& bovine.pricePerKg != null
|
|
||||||
&& bovine.buildingCase != null
|
|
||||||
|
|
||||||
const sortedBovines = computed(() => {
|
|
||||||
const pending = bovines.value.filter(b => !isSaisi(b))
|
|
||||||
const done = bovines.value.filter(b => isSaisi(b))
|
|
||||||
return [...pending, ...done]
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredBovines = computed(() => {
|
|
||||||
const query = searchQuery.value.trim().toLowerCase()
|
|
||||||
if (!query) return sortedBovines.value
|
|
||||||
return sortedBovines.value.filter(b =>
|
|
||||||
b.nationalNumber.toLowerCase().includes(query)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const onToggle = (bovineId: number, value: boolean) => {
|
|
||||||
openId.value = value ? bovineId : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSaved = (updated: BovineData) => {
|
|
||||||
const idx = bovines.value.findIndex(b => b.id === updated.id)
|
|
||||||
if (idx === -1) return
|
|
||||||
bovines.value[idx] = updated
|
|
||||||
|
|
||||||
const next = sortedBovines.value.find(b => !isSaisi(b) && b.id !== updated.id)
|
|
||||||
openId.value = next?.id ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadBovines = async () => {
|
|
||||||
type Hydra = { 'hydra:member'?: BovineData[] }
|
|
||||||
const response = await api.get<BovineData[] | Hydra>(
|
|
||||||
'bovines',
|
|
||||||
{ reception: receptionId.value, itemsPerPage: 200 }
|
|
||||||
)
|
|
||||||
if (Array.isArray(response)) {
|
|
||||||
bovines.value = response
|
|
||||||
} else if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
|
|
||||||
bovines.value = response['hydra:member']
|
|
||||||
} else {
|
|
||||||
bovines.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const [r, , b] = await Promise.all([
|
|
||||||
api.get<ReceptionData>(`receptions/${receptionId.value}`),
|
|
||||||
loadBovines(),
|
|
||||||
getBuildingList()
|
|
||||||
])
|
|
||||||
reception.value = r
|
|
||||||
buildings.value = b
|
|
||||||
|
|
||||||
const firstPending = sortedBovines.value.find(bv => !isSaisi(bv))
|
|
||||||
openId.value = firstPending?.id ?? null
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="px-[86px]">
|
|
||||||
<div class="flex items-center justify-start gap-6 relative" :class="{ 'mb-8': isConsultationMode }">
|
|
||||||
<Icon
|
|
||||||
@click="router.push('/entry-exit')"
|
|
||||||
name="gg:arrow-left-o"
|
|
||||||
size="44"
|
|
||||||
class="cursor-pointer text-primary-500 absolute -left-[60px]"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<h1 class="font-bold text-3xl uppercase text-primary-500">
|
|
||||||
Entrée bovins {{ reception?.identificationNumber ?? '' }}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-if="!isConsultationMode" class="text-sm text-slate-600 mt-1 mb-8">
|
|
||||||
{{ reception?.supplier?.name ?? '—' }} · Bovins déclarés : {{ declaredCount }} · Bovins saisis : {{ savedBovinesTotal }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<template v-if="!isConsultationMode">
|
|
||||||
<form
|
|
||||||
class="grid grid-cols-4 gap-x-16 gap-y-8 mb-12 items-end"
|
|
||||||
:class="{ submitted }"
|
|
||||||
@submit.prevent="addBovine"
|
|
||||||
>
|
|
||||||
<UiTextInput
|
|
||||||
v-model="form.nationalNumber"
|
|
||||||
label="Numéro national"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<UiSelect
|
|
||||||
v-model="form.entryCause"
|
|
||||||
label="Cause d'entrée"
|
|
||||||
:options="entryCauseOptions"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<UiDateMaskedInput
|
|
||||||
v-model="form.arrivalDate"
|
|
||||||
label="Date d'entrée"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<UiSelect
|
|
||||||
v-model="form.supplierId"
|
|
||||||
label="Vendeur"
|
|
||||||
:options="supplierOptions"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="flex justify-center mb-12">
|
|
||||||
<UiButton
|
|
||||||
type="button"
|
|
||||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
|
||||||
:disabled="isAdding"
|
|
||||||
:loading="isAdding"
|
|
||||||
@click="addBovine"
|
|
||||||
>
|
|
||||||
Ajouter
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<UiDataTable
|
|
||||||
v-model:page="recapPage"
|
|
||||||
v-model:per-page="recapPerPage"
|
|
||||||
:columns="recapColumns"
|
|
||||||
:items="savedBovines"
|
|
||||||
:total-items="savedBovinesTotal"
|
|
||||||
:loading="savedBovinesLoading"
|
|
||||||
:show-actions="!isConsultationMode"
|
|
||||||
>
|
|
||||||
<template #header-nationalNumber>
|
|
||||||
<UiTextInput v-model="recapFilters.nationalNumber" placeholder="N° National" size="compact" />
|
|
||||||
</template>
|
|
||||||
<template #header-workNumber>
|
|
||||||
<UiTextInput v-model="recapFilters.workNumber" placeholder="N° Travail" size="compact" />
|
|
||||||
</template>
|
|
||||||
<template #header-bovineType.label>
|
|
||||||
<UiTextInput v-model="recapFilters['bovineType.label']" placeholder="Race" size="compact" />
|
|
||||||
</template>
|
|
||||||
<template #header-sex>
|
|
||||||
<UiTextInput v-model="recapFilters.sex" placeholder="Sexe" size="compact" />
|
|
||||||
</template>
|
|
||||||
<template #header-birthDate>
|
|
||||||
<UiDateMaskedInput v-model="birthDateFilter" placeholder="Né le" size="compact" />
|
|
||||||
</template>
|
|
||||||
<template #header-arrivalDate>
|
|
||||||
<UiDateMaskedInput v-model="arrivalDateFilter" placeholder="Entrée le" size="compact" />
|
|
||||||
</template>
|
|
||||||
<template #header-supplier.name>
|
|
||||||
<UiTextInput :model-value="''" placeholder="Vendeur" size="compact" disabled />
|
|
||||||
</template>
|
|
||||||
<template #header-entryCause>
|
|
||||||
<UiTextInput :model-value="''" placeholder="Cause" size="compact" disabled />
|
|
||||||
</template>
|
|
||||||
<template #header-ednotifConfirmedAt>
|
|
||||||
<UiTextInput :model-value="''" placeholder="EDNOTIF" size="compact" disabled />
|
|
||||||
</template>
|
|
||||||
<template #header-actions>
|
|
||||||
<UiTextInput :model-value="''" placeholder="Action" size="compact" disabled />
|
|
||||||
</template>
|
|
||||||
<template #cell-birthDate="{ item }">
|
|
||||||
{{ formatDate(item.birthDate) }}
|
|
||||||
</template>
|
|
||||||
<template #cell-arrivalDate="{ item }">
|
|
||||||
{{ formatDate(item.arrivalDate) }}
|
|
||||||
</template>
|
|
||||||
<template #cell-bovineType.label="{ item }">
|
|
||||||
{{ item.bovineType?.label ?? '—' }}
|
|
||||||
</template>
|
|
||||||
<template #cell-supplier.name="{ item }">
|
|
||||||
{{ supplierName(item.supplier) }}
|
|
||||||
</template>
|
|
||||||
<template #cell-entryCause="{ item }">
|
|
||||||
{{ entryCauseLabel(item.entryCause) }}
|
|
||||||
</template>
|
|
||||||
<template #cell-ednotifConfirmedAt="{ item }">
|
|
||||||
<span
|
|
||||||
v-if="item.ednotifConfirmedAt"
|
|
||||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
|
|
||||||
>
|
|
||||||
Validé
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700"
|
|
||||||
>
|
|
||||||
En attente
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template #actions="{ item }">
|
|
||||||
<Icon
|
|
||||||
name="mdi:delete-outline"
|
|
||||||
size="24"
|
|
||||||
class="cursor-pointer text-red-500 hover:text-red-700"
|
|
||||||
@click="confirmDeleteBovine(item)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</UiDataTable>
|
|
||||||
|
|
||||||
<div v-if="!isConsultationMode" class="flex justify-center mt-8">
|
|
||||||
<UiButton
|
|
||||||
type="button"
|
|
||||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
|
||||||
:disabled="savedBovinesTotal === 0 || isValidating"
|
|
||||||
:loading="isValidating"
|
|
||||||
@click="validateEntry"
|
|
||||||
>
|
|
||||||
Valider l'entrée
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
|
||||||
import type { BovineData } from '~/services/dto/bovine-data'
|
|
||||||
import type { SupplierData } from '~/services/dto/supplier-data'
|
|
||||||
import { getSupplierList } from '~/services/supplier'
|
|
||||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const api = useApi()
|
|
||||||
|
|
||||||
const receptionId = computed(() => Number(route.params.id))
|
|
||||||
|
|
||||||
const reception = ref<ReceptionData | null>(null)
|
|
||||||
const suppliers = ref<SupplierData[]>([])
|
|
||||||
|
|
||||||
const {
|
|
||||||
items: savedBovines,
|
|
||||||
totalItems: savedBovinesTotal,
|
|
||||||
page: recapPage,
|
|
||||||
perPage: recapPerPage,
|
|
||||||
filters: recapFilters,
|
|
||||||
loading: savedBovinesLoading,
|
|
||||||
reload: reloadSavedBovines
|
|
||||||
} = useDataTableServerState<BovineData>(
|
|
||||||
'bovines',
|
|
||||||
{
|
|
||||||
reception: receptionId.value,
|
|
||||||
nationalNumber: '',
|
|
||||||
workNumber: '',
|
|
||||||
'bovineType.label': '',
|
|
||||||
sex: '',
|
|
||||||
'birthDate[after]': '',
|
|
||||||
'birthDate[strictly_before]': '',
|
|
||||||
'arrivalDate[after]': '',
|
|
||||||
'arrivalDate[strictly_before]': ''
|
|
||||||
},
|
|
||||||
{ initialPerPage: 10 }
|
|
||||||
)
|
|
||||||
|
|
||||||
const addOneDay = (dateString: string): string => {
|
|
||||||
const [year, month, day] = dateString.split('-').map(Number)
|
|
||||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
|
||||||
return next.toISOString().slice(0, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
const birthDateFilter = computed<string>({
|
|
||||||
get: () => (recapFilters.value['birthDate[after]'] as string) ?? '',
|
|
||||||
set: (value: string) => {
|
|
||||||
if (!value) {
|
|
||||||
recapFilters.value['birthDate[after]'] = ''
|
|
||||||
recapFilters.value['birthDate[strictly_before]'] = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recapFilters.value['birthDate[after]'] = value
|
|
||||||
recapFilters.value['birthDate[strictly_before]'] = addOneDay(value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const arrivalDateFilter = computed<string>({
|
|
||||||
get: () => (recapFilters.value['arrivalDate[after]'] as string) ?? '',
|
|
||||||
set: (value: string) => {
|
|
||||||
if (!value) {
|
|
||||||
recapFilters.value['arrivalDate[after]'] = ''
|
|
||||||
recapFilters.value['arrivalDate[strictly_before]'] = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recapFilters.value['arrivalDate[after]'] = value
|
|
||||||
recapFilters.value['arrivalDate[strictly_before]'] = addOneDay(value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const isAdding = ref(false)
|
|
||||||
const isValidating = ref(false)
|
|
||||||
const submitted = ref(false)
|
|
||||||
|
|
||||||
const isConsultationMode = computed(() => reception.value?.entryCompleted === true)
|
|
||||||
|
|
||||||
const recapColumns = computed(() => {
|
|
||||||
const cols: Array<{ key: string; label: string; width: string }> = [
|
|
||||||
{ key: 'nationalNumber', label: 'N° National', width: '100px' },
|
|
||||||
{ key: 'workNumber', label: 'N° Travail', width: '110px' },
|
|
||||||
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
|
|
||||||
{ key: 'sex', label: 'Sexe', width: '70px' },
|
|
||||||
{ key: 'birthDate', label: 'Né le', width: '75px' },
|
|
||||||
{ key: 'arrivalDate', label: 'Entrée le', width: '75px' },
|
|
||||||
{ key: 'supplier.name', label: 'Vendeur', width: '150px' },
|
|
||||||
{ key: 'entryCause', label: 'Cause', width: '100px' }
|
|
||||||
]
|
|
||||||
if (isConsultationMode.value) {
|
|
||||||
cols.push({ key: 'ednotifConfirmedAt', label: 'EDNOTIF', width: '110px' })
|
|
||||||
}
|
|
||||||
return cols
|
|
||||||
})
|
|
||||||
|
|
||||||
const entryCauseLabel = (code: string | null | undefined) => {
|
|
||||||
if (!code) return '—'
|
|
||||||
return entryCauseOptions.find(o => o.value === code)?.label ?? code
|
|
||||||
}
|
|
||||||
|
|
||||||
const supplierName = (supplier: BovineData['supplier']) => {
|
|
||||||
if (supplier && typeof supplier === 'object') return supplier.name
|
|
||||||
return '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (date: string | null | undefined) => {
|
|
||||||
if (!date) return '—'
|
|
||||||
const d = new Date(date.replace(' ', 'T'))
|
|
||||||
if (isNaN(d.getTime())) return date
|
|
||||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmDeleteBovine = async (bovine: BovineData) => {
|
|
||||||
const confirmed = window.confirm(`Supprimer le bovin ${bovine.nationalNumber} ?`)
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
await api.delete(`bovines/${bovine.id}`)
|
|
||||||
reloadSavedBovines()
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateEntry = async () => {
|
|
||||||
if (savedBovinesTotal.value === 0 || isValidating.value) return
|
|
||||||
|
|
||||||
const message = savedBovinesTotal.value !== declaredCount.value
|
|
||||||
? `Attention : ${savedBovinesTotal.value} bovins saisis sur ${declaredCount.value} déclarés. Êtes-vous sûr de vouloir valider l'entrée ?`
|
|
||||||
: `Êtes-vous sûr de vouloir valider l'entrée ?`
|
|
||||||
|
|
||||||
if (!window.confirm(message)) return
|
|
||||||
|
|
||||||
isValidating.value = true
|
|
||||||
try {
|
|
||||||
await api.patch(`receptions/${receptionId.value}`, { entryCompleted: true })
|
|
||||||
router.push('/entry-exit')
|
|
||||||
} finally {
|
|
||||||
isValidating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type EntryCause = 'A' | 'N' | 'P'
|
|
||||||
|
|
||||||
interface FormState {
|
|
||||||
nationalNumber: string
|
|
||||||
entryCause: EntryCause
|
|
||||||
arrivalDate: string
|
|
||||||
supplierId: string | number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const entryCauseOptions = [
|
|
||||||
{ value: 'A', label: 'Achat' },
|
|
||||||
{ value: 'N', label: 'Naissance' },
|
|
||||||
{ value: 'P', label: 'Prêt ou pension' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const initialForm = (): FormState => ({
|
|
||||||
nationalNumber: '',
|
|
||||||
entryCause: 'A',
|
|
||||||
arrivalDate: reception.value?.receptionDate?.slice(0, 10) ?? '',
|
|
||||||
supplierId: reception.value?.supplier?.id ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const form = reactive<FormState>(initialForm())
|
|
||||||
|
|
||||||
const supplierOptions = computed(() =>
|
|
||||||
suppliers.value.map(s => ({ value: s.id, label: s.name }))
|
|
||||||
)
|
|
||||||
|
|
||||||
const declaredCount = computed(() => reception.value?.declaredBovineCount ?? 0)
|
|
||||||
|
|
||||||
const isFormValid = computed(() =>
|
|
||||||
form.nationalNumber.trim() !== ''
|
|
||||||
&& !!form.entryCause
|
|
||||||
&& !!form.arrivalDate
|
|
||||||
&& form.supplierId !== null
|
|
||||||
)
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
Object.assign(form, initialForm())
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadReception = async () => {
|
|
||||||
reception.value = await api.get<ReceptionData>(`receptions/${receptionId.value}`)
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusFirstField = () => {
|
|
||||||
const el = document.querySelector<HTMLInputElement>('form input[type="text"]')
|
|
||||||
el?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
const addBovine = async () => {
|
|
||||||
submitted.value = true
|
|
||||||
if (!isFormValid.value || isAdding.value) return
|
|
||||||
|
|
||||||
isAdding.value = true
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
nationalNumber: form.nationalNumber.trim(),
|
|
||||||
entryCause: form.entryCause,
|
|
||||||
arrivalDate: form.arrivalDate,
|
|
||||||
supplier: `/api/suppliers/${form.supplierId}`,
|
|
||||||
reception: `/api/receptions/${receptionId.value}`
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.post<BovineData>('bovines', payload, {
|
|
||||||
headers: { 'Content-Type': 'application/ld+json' }
|
|
||||||
})
|
|
||||||
|
|
||||||
reloadSavedBovines()
|
|
||||||
resetForm()
|
|
||||||
submitted.value = false
|
|
||||||
await nextTick()
|
|
||||||
focusFirstField()
|
|
||||||
} finally {
|
|
||||||
isAdding.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
suppliers.value = await getSupplierList()
|
|
||||||
await loadReception()
|
|
||||||
reloadSavedBovines()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="px-[86px]">
|
|
||||||
<div class="flex items-center justify-start gap-10 relative">
|
|
||||||
<Icon
|
|
||||||
@click="router.push('/')"
|
|
||||||
name="gg:arrow-left-o"
|
|
||||||
size="44"
|
|
||||||
class="cursor-pointer text-primary-500 absolute -left-[60px]"
|
|
||||||
/>
|
|
||||||
<h1 class="font-bold text-3xl uppercase text-primary-500">Entrée / Sortie</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 grid grid-cols-2 gap-8">
|
|
||||||
<section>
|
|
||||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Entrées en attente</h2>
|
|
||||||
<UiDataTable
|
|
||||||
v-model:page="entryPage"
|
|
||||||
v-model:per-page="entryPerPage"
|
|
||||||
:columns="entryColumns"
|
|
||||||
:items="entries"
|
|
||||||
:total-items="totalEntries"
|
|
||||||
:loading="entriesLoading"
|
|
||||||
row-clickable
|
|
||||||
@row-click="goToEntry"
|
|
||||||
>
|
|
||||||
<template #header-identificationNumber>
|
|
||||||
<UiTextInput
|
|
||||||
v-model="entryFilters.identificationNumber"
|
|
||||||
placeholder="Numéro"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template #header-receptionDate>
|
|
||||||
<UiDateMaskedInput v-model="entryDateFilter" placeholder="Date" size="compact" />
|
|
||||||
</template>
|
|
||||||
<template #header-declaredCount>
|
|
||||||
<UiTextInput :model-value="''" placeholder="Déclarés" size="compact" disabled />
|
|
||||||
</template>
|
|
||||||
<template #header-registeredBovineCount>
|
|
||||||
<UiTextInput :model-value="''" placeholder="Saisis" size="compact" disabled />
|
|
||||||
</template>
|
|
||||||
<template #header-status>
|
|
||||||
<UiTextInput :model-value="''" placeholder="Statut" size="compact" disabled />
|
|
||||||
</template>
|
|
||||||
<template #cell-identificationNumber="{ item }">
|
|
||||||
{{ item.identificationNumber }}
|
|
||||||
</template>
|
|
||||||
<template #cell-receptionDate="{ item }">
|
|
||||||
{{ formatDate(item.receptionDate) }}
|
|
||||||
</template>
|
|
||||||
<template #cell-declaredCount="{ item }">
|
|
||||||
{{ item.declaredBovineCount ?? 0 }}
|
|
||||||
</template>
|
|
||||||
<template #cell-registeredBovineCount="{ item }">
|
|
||||||
{{ item.registeredBovineCount ?? 0 }}
|
|
||||||
</template>
|
|
||||||
<template #cell-status="{ item }">
|
|
||||||
<span
|
|
||||||
v-if="!item.entryCompleted"
|
|
||||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
|
|
||||||
>
|
|
||||||
Attente saisie
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700"
|
|
||||||
>
|
|
||||||
Attente EDNOTIF
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</UiDataTable>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Entrées validées</h2>
|
|
||||||
<UiDataTable
|
|
||||||
v-model:page="validatedPage"
|
|
||||||
v-model:per-page="validatedPerPage"
|
|
||||||
:columns="validatedColumns"
|
|
||||||
:items="validated"
|
|
||||||
:total-items="totalValidated"
|
|
||||||
:loading="validatedLoading"
|
|
||||||
row-clickable
|
|
||||||
@row-click="goToBovineInfo"
|
|
||||||
>
|
|
||||||
<template #header-identificationNumber>
|
|
||||||
<UiTextInput
|
|
||||||
v-model="validatedFilters.identificationNumber"
|
|
||||||
placeholder="Numéro"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template #header-receptionDate>
|
|
||||||
<UiDateMaskedInput v-model="validatedDateFilter" placeholder="Date" size="compact" />
|
|
||||||
</template>
|
|
||||||
<template #header-registeredBovineCount>
|
|
||||||
<UiTextInput :model-value="''" placeholder="Saisis" size="compact" disabled />
|
|
||||||
</template>
|
|
||||||
<template #header-validatedAt>
|
|
||||||
<UiTextInput :model-value="''" placeholder="Validée le" size="compact" disabled />
|
|
||||||
</template>
|
|
||||||
<template #header-status>
|
|
||||||
<UiTextInput :model-value="''" placeholder="Statut" size="compact" disabled />
|
|
||||||
</template>
|
|
||||||
<template #cell-identificationNumber="{ item }">
|
|
||||||
{{ item.identificationNumber }}
|
|
||||||
</template>
|
|
||||||
<template #cell-receptionDate="{ item }">
|
|
||||||
{{ formatDate(item.receptionDate) }}
|
|
||||||
</template>
|
|
||||||
<template #cell-registeredBovineCount="{ item }">
|
|
||||||
{{ item.registeredBovineCount ?? 0 }}
|
|
||||||
</template>
|
|
||||||
<template #cell-validatedAt="{ item }">
|
|
||||||
{{ formatDate(item.validatedAt) }}
|
|
||||||
</template>
|
|
||||||
<template #cell-status>
|
|
||||||
<span
|
|
||||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
|
|
||||||
>
|
|
||||||
Validée
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</UiDataTable>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-12 mb-16 grid grid-cols-2 gap-8">
|
|
||||||
<section>
|
|
||||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Sorties en attente</h2>
|
|
||||||
<div class="rounded border border-dashed border-slate-300 p-8 text-center text-slate-500">
|
|
||||||
À venir
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Sorties validées</h2>
|
|
||||||
<div class="rounded border border-dashed border-slate-300 p-8 text-center text-slate-500">
|
|
||||||
À venir
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
|
||||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const {
|
|
||||||
items: entries,
|
|
||||||
totalItems: totalEntries,
|
|
||||||
page: entryPage,
|
|
||||||
perPage: entryPerPage,
|
|
||||||
filters: entryFilters,
|
|
||||||
loading: entriesLoading,
|
|
||||||
reload
|
|
||||||
} = useDataTableServerState<ReceptionData>(
|
|
||||||
'receptions',
|
|
||||||
{
|
|
||||||
'isValid': 'true',
|
|
||||||
'exists[validatedAt]': 'false',
|
|
||||||
'receptionType.code': 'BOVINS',
|
|
||||||
'identificationNumber': '',
|
|
||||||
'receptionDate[after]': '',
|
|
||||||
'receptionDate[strictly_before]': ''
|
|
||||||
},
|
|
||||||
{ initialPerPage: 5 }
|
|
||||||
)
|
|
||||||
|
|
||||||
const {
|
|
||||||
items: validated,
|
|
||||||
totalItems: totalValidated,
|
|
||||||
page: validatedPage,
|
|
||||||
perPage: validatedPerPage,
|
|
||||||
filters: validatedFilters,
|
|
||||||
loading: validatedLoading,
|
|
||||||
reload: reloadValidated
|
|
||||||
} = useDataTableServerState<ReceptionData>(
|
|
||||||
'receptions',
|
|
||||||
{
|
|
||||||
'isValid': 'true',
|
|
||||||
'exists[validatedAt]': 'true',
|
|
||||||
'receptionType.code': 'BOVINS',
|
|
||||||
'identificationNumber': '',
|
|
||||||
'receptionDate[after]': '',
|
|
||||||
'receptionDate[strictly_before]': ''
|
|
||||||
},
|
|
||||||
{ initialPerPage: 5 }
|
|
||||||
)
|
|
||||||
|
|
||||||
const addOneDay = (dateString: string): string => {
|
|
||||||
const [year, month, day] = dateString.split('-').map(Number)
|
|
||||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
|
||||||
return next.toISOString().slice(0, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
const entryDateFilter = computed<string>({
|
|
||||||
get: () => (entryFilters.value['receptionDate[after]'] as string) ?? '',
|
|
||||||
set: (value: string) => {
|
|
||||||
if (!value) {
|
|
||||||
entryFilters.value['receptionDate[after]'] = ''
|
|
||||||
entryFilters.value['receptionDate[strictly_before]'] = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entryFilters.value['receptionDate[after]'] = value
|
|
||||||
entryFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const validatedDateFilter = computed<string>({
|
|
||||||
get: () => (validatedFilters.value['receptionDate[after]'] as string) ?? '',
|
|
||||||
set: (value: string) => {
|
|
||||||
if (!value) {
|
|
||||||
validatedFilters.value['receptionDate[after]'] = ''
|
|
||||||
validatedFilters.value['receptionDate[strictly_before]'] = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
validatedFilters.value['receptionDate[after]'] = value
|
|
||||||
validatedFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const entryColumns = [
|
|
||||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
|
||||||
{ key: 'receptionDate', label: 'Date', width: '75px' },
|
|
||||||
{ key: 'declaredCount', label: 'Déclarés', width: '75px' },
|
|
||||||
{ key: 'registeredBovineCount', label: 'Saisis', width: '70px' },
|
|
||||||
{ key: 'status', label: 'Statut', width: '1fr' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const validatedColumns = [
|
|
||||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
|
||||||
{ key: 'receptionDate', label: 'Date', width: '75px' },
|
|
||||||
{ key: 'registeredBovineCount', label: 'Saisis', width: '50px' },
|
|
||||||
{ key: 'validatedAt', label: 'Validée le', width: '75px' },
|
|
||||||
{ key: 'status', label: 'Statut', width: '1fr' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const formatDate = (date: string | null | undefined) => {
|
|
||||||
if (!date) return '—'
|
|
||||||
const d = new Date(date.replace(' ', 'T'))
|
|
||||||
if (isNaN(d.getTime())) return date
|
|
||||||
return d.toLocaleDateString('fr-FR', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToEntry = (reception: ReceptionData) => {
|
|
||||||
router.push(`/entry-exit/entry/${reception.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToBovineInfo = (reception: ReceptionData) => {
|
|
||||||
router.push(`/entry-exit/bovine-info/${reception.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
reload()
|
|
||||||
reloadValidated()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -16,11 +16,7 @@ useHead({ title: 'Accueil' })
|
|||||||
EXPÉDITIONS<br>EN ATTENTE
|
EXPÉDITIONS<br>EN ATTENTE
|
||||||
</template>
|
</template>
|
||||||
</card-link>
|
</card-link>
|
||||||
<card-link link="/entry-exit" iconName="mdi:swap-horizontal-circle-outline">
|
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
|
||||||
<template #label>
|
|
||||||
Entrée<br>Sortie
|
|
||||||
</template>
|
|
||||||
</card-link>
|
|
||||||
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
|
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
|
||||||
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
|
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
|
||||||
<card-link link="/inventory" iconName="mdi:cow">
|
<card-link link="/inventory" iconName="mdi:cow">
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
<template>
|
|
||||||
<form :class="{ submitted }" @submit.prevent="validate">
|
|
||||||
<div class="flex items-center relative">
|
|
||||||
<div class="flex flex-row absolute -left-[60px]">
|
|
||||||
<Icon
|
|
||||||
@click="goBack"
|
|
||||||
name="gg:arrow-left-o"
|
|
||||||
size="40"
|
|
||||||
class="cursor-pointer text-primary-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-3xl text-primary-500 font-bold uppercase">
|
|
||||||
{{ isEdit ? 'Modification d\'un bovin' : 'Ajout d\'un bovin' }}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
|
|
||||||
<UiTextInput
|
|
||||||
id="bovine-national-number"
|
|
||||||
v-model="form.nationalNumber"
|
|
||||||
label="Numéro national"
|
|
||||||
:disabled="!auth.isAdmin || isLoading"
|
|
||||||
wrapper-class="w-[280px]"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<UiNumberInput
|
|
||||||
id="bovine-received-weight"
|
|
||||||
v-model="form.receivedWeight"
|
|
||||||
label="Poids à l'arrivée (kg)"
|
|
||||||
:min="0"
|
|
||||||
:disabled="!auth.isAdmin || isLoading"
|
|
||||||
wrapper-class="w-[280px] flex-col"
|
|
||||||
label-class="font-bold uppercase"
|
|
||||||
/>
|
|
||||||
<UiDateInput
|
|
||||||
id="bovine-arrival-date"
|
|
||||||
v-model="form.arrivalDate"
|
|
||||||
label="Date d'arrivée"
|
|
||||||
:disabled="!auth.isAdmin || isLoading"
|
|
||||||
wrapper-class="w-[280px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-cols-3 justify-between mb-11">
|
|
||||||
<UiSelect
|
|
||||||
id="bovine-supplier"
|
|
||||||
v-model="form.supplierId"
|
|
||||||
label="Vendeur"
|
|
||||||
:options="supplierOptions"
|
|
||||||
:loading="isLoadingSuppliers"
|
|
||||||
:disabled="!auth.isAdmin || isLoading"
|
|
||||||
wrapper-class="w-[280px]"
|
|
||||||
/>
|
|
||||||
<div class="w-[280px]" />
|
|
||||||
<div class="w-[280px]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-center">
|
|
||||||
<UiButton
|
|
||||||
type="submit"
|
|
||||||
:disabled="!auth.isAdmin || isLoading"
|
|
||||||
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] gap-2 text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
|
||||||
@click="submitted = true"
|
|
||||||
>
|
|
||||||
<Icon :name="isEdit ? '' : 'mdi:plus'" size="28" />
|
|
||||||
{{ isEdit ? 'Valider' : 'Ajouter' }}
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
useHead({ title: 'Bovins' })
|
|
||||||
|
|
||||||
import { createBovine, getBovine, updateBovine } from '~/services/bovine'
|
|
||||||
import type { BovinePayload } from '~/services/dto/bovine-data'
|
|
||||||
import type { SupplierData } from '~/services/dto/supplier-data'
|
|
||||||
import { getSupplierList } from '~/services/supplier'
|
|
||||||
import { useAuthStore } from '~/stores/auth'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
const caseId = computed(() => {
|
|
||||||
const raw = Number(route.query.caseId)
|
|
||||||
return Number.isFinite(raw) && raw > 0 ? raw : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const bovineId = computed(() => {
|
|
||||||
const raw = Number(route.query.id)
|
|
||||||
return Number.isFinite(raw) && raw > 0 ? raw : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const isEdit = computed(() => bovineId.value !== null)
|
|
||||||
|
|
||||||
const form = reactive<{
|
|
||||||
nationalNumber: string
|
|
||||||
receivedWeight: number | null
|
|
||||||
arrivalDate: string | null
|
|
||||||
supplierId: string
|
|
||||||
}>({
|
|
||||||
nationalNumber: '',
|
|
||||||
receivedWeight: null,
|
|
||||||
arrivalDate: null,
|
|
||||||
supplierId: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const submitted = ref(false)
|
|
||||||
const suppliers = ref<SupplierData[]>([])
|
|
||||||
const isLoadingSuppliers = ref(false)
|
|
||||||
|
|
||||||
const supplierOptions = computed(() =>
|
|
||||||
suppliers.value.map(s => ({ value: String(s.id), label: s.name }))
|
|
||||||
)
|
|
||||||
|
|
||||||
const backRoute = computed(() => ({
|
|
||||||
path: '/infrastructure/case',
|
|
||||||
query: caseId.value ? { id: String(caseId.value) } : {}
|
|
||||||
}))
|
|
||||||
|
|
||||||
const goBack = () => {
|
|
||||||
router.push(backRoute.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadSuppliers = async () => {
|
|
||||||
isLoadingSuppliers.value = true
|
|
||||||
try {
|
|
||||||
suppliers.value = await getSupplierList()
|
|
||||||
} finally {
|
|
||||||
isLoadingSuppliers.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hydrate = async () => {
|
|
||||||
if (!isEdit.value || bovineId.value === null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
const bovine = await getBovine(bovineId.value)
|
|
||||||
form.nationalNumber = bovine.nationalNumber ?? ''
|
|
||||||
form.receivedWeight = bovine.receivedWeight ?? null
|
|
||||||
form.arrivalDate = bovine.arrivalDate ?? null
|
|
||||||
if (bovine.supplier) {
|
|
||||||
const supplierId = bovine.supplier.replace(/.*\//, '')
|
|
||||||
form.supplierId = supplierId
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validate = async () => {
|
|
||||||
if (isLoading.value || !auth.isAdmin) return
|
|
||||||
if (!caseId.value) return
|
|
||||||
if (!form.nationalNumber.trim()) return
|
|
||||||
|
|
||||||
const payload: BovinePayload = {
|
|
||||||
nationalNumber: form.nationalNumber.trim(),
|
|
||||||
receivedWeight: form.receivedWeight,
|
|
||||||
arrivalDate: form.arrivalDate,
|
|
||||||
buildingCase: `/api/building_cases/${caseId.value}`,
|
|
||||||
supplier: form.supplierId ? `/api/suppliers/${form.supplierId}` : null
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
if (isEdit.value && bovineId.value !== null) {
|
|
||||||
await updateBovine(bovineId.value, payload)
|
|
||||||
} else {
|
|
||||||
await createBovine(payload)
|
|
||||||
}
|
|
||||||
router.push(backRoute.value)
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadSuppliers)
|
|
||||||
watch(bovineId, hydrate, { immediate: true })
|
|
||||||
</script>
|
|
||||||
@@ -23,14 +23,6 @@
|
|||||||
<Icon name="mdi:printer-outline" size="32" class="text-white" />
|
<Icon name="mdi:printer-outline" size="32" class="text-white" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink
|
|
||||||
v-if="hasCaseId && auth.isAdmin"
|
|
||||||
:to="addBovineRoute"
|
|
||||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:plus" size="28" />
|
|
||||||
Ajouter
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3 mt-4">
|
<div class="flex flex-wrap gap-3 mt-4">
|
||||||
@@ -56,7 +48,7 @@
|
|||||||
:items="items"
|
:items="items"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:row-clickable="auth.isAdmin"
|
row-clickable
|
||||||
empty-message="Aucun bovin dans cette case."
|
empty-message="Aucun bovin dans cette case."
|
||||||
@row-click="goToBovine"
|
@row-click="goToBovine"
|
||||||
>
|
>
|
||||||
@@ -134,7 +126,6 @@ useHead({ title: 'Cases' })
|
|||||||
|
|
||||||
import type { BuildingCaseData } from '~/services/dto/building-case-data'
|
import type { BuildingCaseData } from '~/services/dto/building-case-data'
|
||||||
import type { BovineData } from '~/services/dto/bovine-data'
|
import type { BovineData } from '~/services/dto/bovine-data'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
|
||||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||||
import { useBovineColumns } from '~/composables/useBovineColumns'
|
import { useBovineColumns } from '~/composables/useBovineColumns'
|
||||||
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
|
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
|
||||||
@@ -143,7 +134,6 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { printPdf } = usePdfPrinter()
|
const { printPdf } = usePdfPrinter()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
const caseId = computed(() => Number(route.query.id))
|
const caseId = computed(() => Number(route.query.id))
|
||||||
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
|
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
|
||||||
@@ -233,11 +223,6 @@ const title = computed(() => {
|
|||||||
return `${buildingLabel} case ${caseNumber}`.trim()
|
return `${buildingLabel} case ${caseNumber}`.trim()
|
||||||
})
|
})
|
||||||
|
|
||||||
const addBovineRoute = computed(() => ({
|
|
||||||
path: '/infrastructure/bovine',
|
|
||||||
query: { caseId: String(caseId.value) }
|
|
||||||
}))
|
|
||||||
|
|
||||||
const formatDate = (date: string | null) => {
|
const formatDate = (date: string | null) => {
|
||||||
if (!date) return '—'
|
if (!date) return '—'
|
||||||
const d = new Date(date)
|
const d = new Date(date)
|
||||||
@@ -270,11 +255,7 @@ const printCaseReport = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const goToBovine = (bovine: BovineData) => {
|
const goToBovine = (bovine: BovineData) => {
|
||||||
if (!auth.isAdmin) return
|
router.push(`/bovine/${bovine.id}`)
|
||||||
router.push({
|
|
||||||
path: '/infrastructure/bovine',
|
|
||||||
query: { id: String(bovine.id), caseId: String(caseId.value) }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(caseId, (id) => {
|
watch(caseId, (id) => {
|
||||||
|
|||||||
@@ -57,6 +57,8 @@
|
|||||||
:items="items"
|
:items="items"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
row-clickable
|
||||||
|
@row-click="(item: BovineData) => router.push(`/bovine/${item.id}`)"
|
||||||
>
|
>
|
||||||
<template #header-nationalNumber>
|
<template #header-nationalNumber>
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
@@ -123,7 +125,7 @@
|
|||||||
{{ formatDate(item.arrivalDate) }}
|
{{ formatDate(item.arrivalDate) }}
|
||||||
</template>
|
</template>
|
||||||
<template #cell-buildingCase.building.label="{ item }">
|
<template #cell-buildingCase.building.label="{ item }">
|
||||||
{{ item.effectiveBuilding?.label ?? '—' }}
|
{{ item.buildingCase?.building?.label ?? '—' }}
|
||||||
</template>
|
</template>
|
||||||
<template #cell-buildingCase.caseNumber="{ item }">
|
<template #cell-buildingCase.caseNumber="{ item }">
|
||||||
{{ item.buildingCase?.caseNumber ?? '—' }}
|
{{ item.buildingCase?.caseNumber ?? '—' }}
|
||||||
@@ -249,7 +251,6 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
|
|||||||
'bovines',
|
'bovines',
|
||||||
{
|
{
|
||||||
'exists[exitedAt]': 'false',
|
'exists[exitedAt]': 'false',
|
||||||
'exists[ednotifConfirmedAt]': 'true',
|
|
||||||
nationalNumber: '',
|
nationalNumber: '',
|
||||||
workNumber: '',
|
workNumber: '',
|
||||||
'bovineType.label': '',
|
'bovineType.label': '',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-start gap-10">
|
<div class="flex items-center justify-start gap-10">
|
||||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
||||||
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions finie</h1>
|
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des réceptions finies</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-[86px]">
|
<div class="px-[86px]">
|
||||||
|
|||||||
@@ -145,38 +145,14 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="formIsLoading">
|
<div v-if="formIsLoading">
|
||||||
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
|
<UiTabs
|
||||||
<h1
|
v-model="activeTab"
|
||||||
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
|
:tabs="[
|
||||||
:class="[
|
{ key: 'weights', label: 'pesée à plein', error: hasGrossWeightError },
|
||||||
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
|
{ key: 'weightsEmpty', label: 'pesée à vide', error: hasTareWeightError },
|
||||||
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
|
{ key: 'merchandise', label: isMerchandise ? 'Marchandise' : 'Bovins', error: hasMerchandiseTabError }
|
||||||
]"
|
]"
|
||||||
@click="activeTab = 'weights'"
|
/>
|
||||||
>
|
|
||||||
pesée à plein
|
|
||||||
</h1>
|
|
||||||
<h1
|
|
||||||
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
|
|
||||||
:class="[
|
|
||||||
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
|
|
||||||
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
|
|
||||||
]"
|
|
||||||
@click="activeTab = 'weightsEmpty'"
|
|
||||||
>
|
|
||||||
pesée à vide
|
|
||||||
</h1>
|
|
||||||
<h1
|
|
||||||
class="font-bold text-3xl uppercase px-12 col-start-2 row-start-1 cursor-pointer"
|
|
||||||
:class="[
|
|
||||||
activeTab === 'merchandise' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
|
|
||||||
hasMerchandiseTabError ? '!text-red-500 !border-red-500' : ''
|
|
||||||
]"
|
|
||||||
@click="activeTab = 'merchandise'"
|
|
||||||
>
|
|
||||||
{{ isMerchandise ? "Marchandise" : "Bovins" }}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div class="mb-12 ">
|
<div class="mb-12 ">
|
||||||
<update-weight
|
<update-weight
|
||||||
v-show="activeTab === 'weights'"
|
v-show="activeTab === 'weights'"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-start gap-10">
|
<div class="flex items-center justify-start gap-10">
|
||||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
||||||
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions en attente</h1>
|
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des réceptions en attente</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-[86px]">
|
<div class="px-[86px]">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-start gap-10">
|
<div class="flex items-center justify-start gap-10">
|
||||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
||||||
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions finie</h1>
|
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des expéditions finies</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-[86px]">
|
<div class="px-[86px]">
|
||||||
|
|||||||
@@ -146,28 +146,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="formIsLoading">
|
<div v-if="formIsLoading">
|
||||||
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
|
<UiTabs
|
||||||
<h1
|
v-model="activeTab"
|
||||||
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
|
:tabs="[
|
||||||
:class="[
|
{ key: 'weightsEmpty', label: 'pesée à vide', error: hasTareWeightError },
|
||||||
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
|
{ key: 'weights', label: 'pesée à plein', error: hasGrossWeightError }
|
||||||
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
|
]"
|
||||||
]"
|
/>
|
||||||
@click="activeTab = 'weightsEmpty'"
|
|
||||||
>
|
|
||||||
pesée à vide
|
|
||||||
</h1>
|
|
||||||
<h1
|
|
||||||
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
|
|
||||||
:class="[
|
|
||||||
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
|
|
||||||
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
|
|
||||||
]"
|
|
||||||
@click="activeTab = 'weights'"
|
|
||||||
>
|
|
||||||
pesée à plein
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div class="mb-12">
|
<div class="mb-12">
|
||||||
<update-weight
|
<update-weight
|
||||||
v-show="activeTab === 'weights'"
|
v-show="activeTab === 'weights'"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-start gap-10">
|
<div class="flex items-center justify-start gap-10">
|
||||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
||||||
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions en attente</h1>
|
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des expéditions en attente</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-[86px]">
|
<div class="px-[86px]">
|
||||||
|
|||||||
@@ -9,34 +9,3 @@ export async function createBovine(payload: BovinePayload) {
|
|||||||
toastSuccessKey: 'success.bovine.create'
|
toastSuccessKey: 'success.bovine.create'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createBovines(nationalNumbers: string[]): Promise<{ created: BovineData[]; errors: string[] }> {
|
|
||||||
const created: BovineData[] = []
|
|
||||||
const errors: string[] = []
|
|
||||||
|
|
||||||
for (const nationalNumber of nationalNumbers) {
|
|
||||||
try {
|
|
||||||
const bovine = await createBovine({ nationalNumber })
|
|
||||||
if (bovine) {
|
|
||||||
created.push(bovine)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
errors.push(nationalNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { created, errors }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getBovine(id: number) {
|
|
||||||
const api = useApi()
|
|
||||||
return api.get<BovineData>(`bovines/${id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateBovine(id: number, payload: BovinePayload) {
|
|
||||||
const api = useApi()
|
|
||||||
return api.patch<BovineData>(`bovines/${id}`, payload, {
|
|
||||||
toastErrorKey: 'errors.bovine.update',
|
|
||||||
toastSuccessKey: 'success.bovine.update'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
export interface BovineBuildingRef {
|
export interface BovineBuildingRef {
|
||||||
id: number
|
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BovineBuildingCaseRef {
|
export interface BovineBuildingCaseRef {
|
||||||
id: number
|
|
||||||
caseNumber: number | null
|
caseNumber: number | null
|
||||||
building: BovineBuildingRef | null
|
building: BovineBuildingRef | null
|
||||||
}
|
}
|
||||||
@@ -18,27 +16,16 @@ export interface BovineData {
|
|||||||
arrivalDate: string | null
|
arrivalDate: string | null
|
||||||
exitDate: string | null
|
exitDate: string | null
|
||||||
buildingCase: BovineBuildingCaseRef | null
|
buildingCase: BovineBuildingCaseRef | null
|
||||||
building: BovineBuildingRef | null
|
supplier: string | null
|
||||||
effectiveBuilding: BovineBuildingRef | null
|
|
||||||
supplier: { id: number; name: string } | string | null
|
|
||||||
workNumber: string | null
|
workNumber: string | null
|
||||||
birthDate: string | null
|
birthDate: string | null
|
||||||
bovineType: { id: number; label: string; code: string } | null
|
bovineType: { id: number; label: string; code: string } | null
|
||||||
sex: string | null
|
sex: string | null
|
||||||
ageMonths: number | null
|
ageMonths: number | null
|
||||||
exitedAt: string | null
|
exitedAt: string | null
|
||||||
reception?: string | null
|
|
||||||
entryCause?: 'A' | 'N' | 'P' | null
|
|
||||||
ednotifConfirmedAt?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BovinePayload = {
|
export type BovinePayload = {
|
||||||
nationalNumber?: string
|
nationalNumber?: string
|
||||||
receivedWeight?: number | null
|
|
||||||
pricePerKg?: number | null
|
|
||||||
arrivalDate?: string | null
|
|
||||||
buildingCase?: string | null
|
buildingCase?: string | null
|
||||||
supplier?: string | null
|
|
||||||
reception?: string | null
|
|
||||||
entryCause?: 'A' | 'N' | 'P' | null
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,6 @@ export interface ReceptionData {
|
|||||||
receptionDate: string
|
receptionDate: string
|
||||||
currentStep: number
|
currentStep: number
|
||||||
isValid: boolean
|
isValid: boolean
|
||||||
entryCompleted?: boolean
|
|
||||||
validatedAt?: string | null
|
|
||||||
registeredBovineCount?: number
|
|
||||||
confirmedBovineCount?: number
|
|
||||||
declaredBovineCount?: number
|
|
||||||
receptionType?: ReceptionTypeData | null
|
receptionType?: ReceptionTypeData | null
|
||||||
merchandiseType?: MerchandiseTypeData | null
|
merchandiseType?: MerchandiseTypeData | null
|
||||||
merchandiseDetail?: string | null
|
merchandiseDetail?: string | null
|
||||||
@@ -68,7 +63,6 @@ export type ReceptionPayload = {
|
|||||||
receptionDate?: string
|
receptionDate?: string
|
||||||
currentStep?: number
|
currentStep?: number
|
||||||
isValid?: boolean
|
isValid?: boolean
|
||||||
entryCompleted?: boolean
|
|
||||||
receptionType?: string | null
|
receptionType?: string | null
|
||||||
merchandiseType?: string | null
|
merchandiseType?: string | null
|
||||||
merchandiseDetail?: string | null
|
merchandiseDetail?: string | null
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const formatAgeLabel = (months: number | null | undefined): string => {
|
|||||||
const remaining = months % 12
|
const remaining = months % 12
|
||||||
let label = ''
|
let label = ''
|
||||||
if (years > 0) label = `${years} an${years > 1 ? 's' : ''}`
|
if (years > 0) label = `${years} an${years > 1 ? 's' : ''}`
|
||||||
if (remaining > 0) label += `${label ? ' ' : ''}${remaining} mois`
|
if (remaining > 0) label += `${label ? ' ' : ''}${remaining} m`
|
||||||
if (!label) label = '< 1 mois'
|
if (!label) label = '< 1 mois'
|
||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20260429073108 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Workflow entrée/sortie : ajout entry_completed sur reception et reception_id sur bovine.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
// Reception : flag de fermeture d'une entrée bovins.
|
|
||||||
$this->addSql('ALTER TABLE reception ADD entry_completed BOOLEAN NOT NULL DEFAULT FALSE');
|
|
||||||
|
|
||||||
// Bovine : FK nullable vers la réception qui a fait entrer le bovin.
|
|
||||||
$this->addSql('ALTER TABLE bovine ADD reception_id INT DEFAULT NULL');
|
|
||||||
$this->addSql('CREATE INDEX IDX_BOVINE_RECEPTION ON bovine (reception_id)');
|
|
||||||
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_BOVINE_RECEPTION FOREIGN KEY (reception_id) REFERENCES reception (id) ON DELETE SET NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_BOVINE_RECEPTION');
|
|
||||||
$this->addSql('DROP INDEX IDX_BOVINE_RECEPTION');
|
|
||||||
$this->addSql('ALTER TABLE bovine DROP reception_id');
|
|
||||||
$this->addSql('ALTER TABLE reception DROP entry_completed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20260429101011 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return "Ajout de la cause d'entrée (enum CauseEntree EDNOTIF) sur bovine.";
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE bovine ADD entry_cause VARCHAR(1) DEFAULT NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE bovine DROP entry_cause');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20260429143822 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Ajout de bovine.ednotif_confirmed_at (timestamp de confirmation EDNOTIF par le sync inventory).';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE bovine ADD ednotif_confirmed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
|
||||||
|
|
||||||
// Backfill : les bovins déjà en BDD ont été synchronisés depuis EDNOTIF
|
|
||||||
// historiquement (commande sync-inventory), on les considère confirmés.
|
|
||||||
// Les nouveaux bovins créés via le workflow entrée auront `NULL` par
|
|
||||||
// défaut et seront confirmés au prochain sync.
|
|
||||||
$this->addSql('UPDATE bovine SET ednotif_confirmed_at = NOW() WHERE ednotif_confirmed_at IS NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE bovine DROP ednotif_confirmed_at');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20260430090000 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Ajout de reception.validated_at (timestamp de validation complète : entrée terminée + tous bovins confirmés EDNOTIF).';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE reception ADD validated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE reception DROP validated_at');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
40
migrations/Version20260504125011.php
Normal file
40
migrations/Version20260504125011.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260504125011 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add mother/father national number and bovine type to bovine.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD mother_national_number VARCHAR(50) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD father_national_number VARCHAR(50) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD mother_bovine_type_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD father_bovine_type_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F14E5E9FB FOREIGN KEY (mother_bovine_type_id) REFERENCES bovine_type (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F53F12909 FOREIGN KEY (father_bovine_type_id) REFERENCES bovine_type (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('CREATE INDEX IDX_2068337F14E5E9FB ON bovine (mother_bovine_type_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_2068337F53F12909 ON bovine (father_bovine_type_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F14E5E9FB');
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F53F12909');
|
||||||
|
$this->addSql('DROP INDEX IDX_2068337F14E5E9FB');
|
||||||
|
$this->addSql('DROP INDEX IDX_2068337F53F12909');
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP mother_national_number');
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP father_national_number');
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP mother_bovine_type_id');
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP father_bovine_type_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
migrations/Version20260506141455.php
Normal file
33
migrations/Version20260506141455.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260506141455 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create bovine_movement table to track internal building/case history.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE bovine_movement (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, bovine_id INT NOT NULL, building_case_id INT DEFAULT NULL, building_id INT DEFAULT NULL, entered_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, left_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
|
||||||
|
$this->addSql('CREATE INDEX idx_bovine_movement_bovine ON bovine_movement (bovine_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_bovine_movement_timeline ON bovine_movement (bovine_id, entered_at)');
|
||||||
|
$this->addSql('CREATE INDEX idx_bovine_movement_case ON bovine_movement (building_case_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_bovine_movement_building ON bovine_movement (building_id)');
|
||||||
|
$this->addSql('ALTER TABLE bovine_movement ADD CONSTRAINT fk_bovine_movement_bovine FOREIGN KEY (bovine_id) REFERENCES bovine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('ALTER TABLE bovine_movement ADD CONSTRAINT fk_bovine_movement_case FOREIGN KEY (building_case_id) REFERENCES building_case (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('ALTER TABLE bovine_movement ADD CONSTRAINT fk_bovine_movement_building FOREIGN KEY (building_id) REFERENCES building (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE bovine_movement');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Command;
|
|
||||||
|
|
||||||
use App\Entity\Bovine;
|
|
||||||
use App\Entity\Building;
|
|
||||||
use App\Entity\Supplier;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
#[AsCommand(
|
|
||||||
name: 'app:feed-bovine-prices',
|
|
||||||
description: 'Met à jour le poids, le prix au kilo et le fournisseur des bovins existants depuis un fichier XLSX.'
|
|
||||||
)]
|
|
||||||
final class FeedBovinePricesCommand extends Command
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private EntityManagerInterface $em,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void
|
|
||||||
{
|
|
||||||
$this
|
|
||||||
->addArgument('file', InputArgument::REQUIRED, 'Chemin absolu vers le fichier XLSX')
|
|
||||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simule sans persister en BDD')
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
|
||||||
{
|
|
||||||
$io = new SymfonyStyle($input, $output);
|
|
||||||
$file = (string) $input->getArgument('file');
|
|
||||||
$dryRun = (bool) $input->getOption('dry-run');
|
|
||||||
|
|
||||||
if (!file_exists($file)) {
|
|
||||||
$io->error(sprintf('Fichier introuvable : %s', $file));
|
|
||||||
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$io->title('Feed bovins depuis '.basename($file));
|
|
||||||
if ($dryRun) {
|
|
||||||
$io->warning('Dry-run activé : aucune écriture en BDD.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$spreadsheet = IOFactory::load($file);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$io->error('Impossible de lire le fichier : '.$e->getMessage());
|
|
||||||
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sheet = $spreadsheet->getActiveSheet();
|
|
||||||
$highestRow = $sheet->getHighestRow();
|
|
||||||
|
|
||||||
// Pré-chargement des fournisseurs pour des lookups rapides (insensible casse).
|
|
||||||
$supplierByName = [];
|
|
||||||
foreach ($this->em->getRepository(Supplier::class)->findAll() as $supplier) {
|
|
||||||
$supplierByName[mb_strtoupper($supplier->getName())] = $supplier;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pré-chargement des bâtiments par code (insensible casse).
|
|
||||||
$buildingByCode = [];
|
|
||||||
foreach ($this->em->getRepository(Building::class)->findAll() as $building) {
|
|
||||||
$buildingByCode[mb_strtoupper($building->getCode())] = $building;
|
|
||||||
}
|
|
||||||
|
|
||||||
$bovineRepo = $this->em->getRepository(Bovine::class);
|
|
||||||
|
|
||||||
$stats = [
|
|
||||||
'total' => 0,
|
|
||||||
'updated' => 0,
|
|
||||||
'notFound' => 0,
|
|
||||||
'invalid' => 0,
|
|
||||||
'supplierMissing' => 0,
|
|
||||||
'buildingMissing' => 0,
|
|
||||||
];
|
|
||||||
$missingNationalNumbers = [];
|
|
||||||
$missingSuppliers = [];
|
|
||||||
$missingBuildings = [];
|
|
||||||
|
|
||||||
$io->progressStart($highestRow);
|
|
||||||
for ($row = 1; $row <= $highestRow; ++$row) {
|
|
||||||
++$stats['total'];
|
|
||||||
|
|
||||||
$rawNationalNumber = (string) ($sheet->getCell([1, $row])->getValue() ?? '');
|
|
||||||
$rawSupplier = (string) ($sheet->getCell([2, $row])->getValue() ?? '');
|
|
||||||
$rawWeight = $sheet->getCell([3, $row])->getValue();
|
|
||||||
$rawPrice = $sheet->getCell([4, $row])->getValue();
|
|
||||||
$rawBuilding = (string) ($sheet->getCell([5, $row])->getValue() ?? '');
|
|
||||||
|
|
||||||
$rawNationalNumber = trim($rawNationalNumber);
|
|
||||||
if ('' === $rawNationalNumber) {
|
|
||||||
++$stats['invalid'];
|
|
||||||
$io->progressAdvance();
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Garde : strip "FR" + espace optionnel uniquement s'il est présent.
|
|
||||||
$nationalNumber = preg_replace('/^FR\s*/i', '', $rawNationalNumber);
|
|
||||||
|
|
||||||
$bovine = $bovineRepo->findOneBy(['nationalNumber' => $nationalNumber]);
|
|
||||||
if (null === $bovine) {
|
|
||||||
++$stats['notFound'];
|
|
||||||
$missingNationalNumbers[] = $nationalNumber;
|
|
||||||
$io->progressAdvance();
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lookup supplier (peut être null si introuvable ou colonne vide).
|
|
||||||
$supplier = null;
|
|
||||||
$supplierName = mb_strtoupper(trim($rawSupplier));
|
|
||||||
if ('' !== $supplierName) {
|
|
||||||
$supplier = $supplierByName[$supplierName] ?? null;
|
|
||||||
if (null === $supplier) {
|
|
||||||
++$stats['supplierMissing'];
|
|
||||||
$missingSuppliers[$supplierName] = ($missingSuppliers[$supplierName] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$weight = is_numeric($rawWeight) ? (int) $rawWeight : null;
|
|
||||||
$price = is_numeric($rawPrice) ? (float) $rawPrice : null;
|
|
||||||
|
|
||||||
if (null !== $weight) {
|
|
||||||
$bovine->setReceivedWeight($weight);
|
|
||||||
}
|
|
||||||
if (null !== $price) {
|
|
||||||
$bovine->setPricePerKg($price);
|
|
||||||
}
|
|
||||||
$bovine->setSupplier($supplier);
|
|
||||||
|
|
||||||
// Bâtiment direct : on n'écrase pas une affectation à une case existante.
|
|
||||||
$buildingCode = mb_strtoupper(trim($rawBuilding));
|
|
||||||
if ('' !== $buildingCode && null === $bovine->getBuildingCase()) {
|
|
||||||
$building = $buildingByCode[$buildingCode] ?? null;
|
|
||||||
if (null !== $building) {
|
|
||||||
$bovine->setBuilding($building);
|
|
||||||
} else {
|
|
||||||
++$stats['buildingMissing'];
|
|
||||||
$missingBuildings[$buildingCode] = ($missingBuildings[$buildingCode] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
++$stats['updated'];
|
|
||||||
$io->progressAdvance();
|
|
||||||
}
|
|
||||||
$io->progressFinish();
|
|
||||||
|
|
||||||
if (!$dryRun) {
|
|
||||||
$this->em->flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
$io->section('Résultats');
|
|
||||||
$io->table(
|
|
||||||
['Métrique', 'Valeur'],
|
|
||||||
[
|
|
||||||
['Lignes totales', $stats['total']],
|
|
||||||
['Bovins mis à jour', $stats['updated']],
|
|
||||||
['Bovins introuvables', $stats['notFound']],
|
|
||||||
['Lignes invalides', $stats['invalid']],
|
|
||||||
['Fournisseurs introuvables (supplier=null)', $stats['supplierMissing']],
|
|
||||||
['Bâtiments introuvables (building non set)', $stats['buildingMissing']],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
if ([] !== $missingNationalNumbers) {
|
|
||||||
$preview = array_slice($missingNationalNumbers, 0, 10);
|
|
||||||
$io->warning(sprintf(
|
|
||||||
'%d bovin(s) introuvable(s). Aperçu : %s%s',
|
|
||||||
count($missingNationalNumbers),
|
|
||||||
implode(', ', $preview),
|
|
||||||
count($missingNationalNumbers) > 10 ? '…' : '',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([] !== $missingSuppliers) {
|
|
||||||
$list = [];
|
|
||||||
foreach ($missingSuppliers as $name => $count) {
|
|
||||||
$list[] = sprintf('%s (%d)', $name, $count);
|
|
||||||
}
|
|
||||||
$io->warning('Fournisseurs introuvables (bovins rattachés en null) : '.implode(', ', $list));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([] !== $missingBuildings) {
|
|
||||||
$list = [];
|
|
||||||
foreach ($missingBuildings as $code => $count) {
|
|
||||||
$list[] = sprintf('%s (%d)', $code, $count);
|
|
||||||
}
|
|
||||||
$io->warning('Bâtiments introuvables (champ non renseigné) : '.implode(', ', $list));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($dryRun) {
|
|
||||||
$io->success('Dry-run terminé. Relance sans --dry-run pour persister.');
|
|
||||||
} else {
|
|
||||||
$io->success('Feed terminé avec succès.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return Command::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,15 +10,15 @@ use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
|||||||
use ApiPlatform\Metadata\ApiFilter;
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
use ApiPlatform\Metadata\ApiProperty;
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Delete;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Enum\CauseEntree;
|
|
||||||
use App\Repository\BovineRepository;
|
use App\Repository\BovineRepository;
|
||||||
use App\State\Bovin\BovineProcessor;
|
use App\State\Bovin\BovineProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Context;
|
use Symfony\Component\Serializer\Attribute\Context;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
@@ -36,10 +36,9 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
|||||||
'sex' => 'exact',
|
'sex' => 'exact',
|
||||||
'buildingCase' => 'exact',
|
'buildingCase' => 'exact',
|
||||||
'receivedWeight' => 'exact',
|
'receivedWeight' => 'exact',
|
||||||
'reception' => 'exact',
|
|
||||||
])]
|
])]
|
||||||
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
|
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
|
||||||
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt', 'ednotifConfirmedAt'])]
|
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
order: ['birthDate' => 'ASC'],
|
order: ['birthDate' => 'ASC'],
|
||||||
operations: [
|
operations: [
|
||||||
@@ -53,20 +52,16 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
|||||||
new Post(
|
new Post(
|
||||||
normalizationContext: ['groups' => ['bovine:read']],
|
normalizationContext: ['groups' => ['bovine:read']],
|
||||||
denormalizationContext: ['groups' => ['bovine:write']],
|
denormalizationContext: ['groups' => ['bovine:write']],
|
||||||
security: "is_granted('ROLE_USER')",
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
processor: BovineProcessor::class,
|
processor: BovineProcessor::class,
|
||||||
),
|
),
|
||||||
new Patch(
|
new Patch(
|
||||||
requirements: ['id' => '\d+'],
|
requirements: ['id' => '\d+'],
|
||||||
normalizationContext: ['groups' => ['bovine:read']],
|
normalizationContext: ['groups' => ['bovine:read']],
|
||||||
denormalizationContext: ['groups' => ['bovine:write']],
|
denormalizationContext: ['groups' => ['bovine:write']],
|
||||||
security: "is_granted('ROLE_USER')",
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
processor: BovineProcessor::class,
|
processor: BovineProcessor::class,
|
||||||
),
|
),
|
||||||
new Delete(
|
|
||||||
requirements: ['id' => '\d+'],
|
|
||||||
security: "is_granted('ROLE_USER')",
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
security: "is_granted('ROLE_USER')",
|
security: "is_granted('ROLE_USER')",
|
||||||
)]
|
)]
|
||||||
@@ -101,24 +96,8 @@ class Bovine
|
|||||||
#[ApiProperty(readableLink: true)]
|
#[ApiProperty(readableLink: true)]
|
||||||
private ?BuildingCase $buildingCase = null;
|
private ?BuildingCase $buildingCase = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(inversedBy: 'bovines')]
|
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
|
||||||
#[Groups(['bovine:read', 'bovine:write'])]
|
|
||||||
#[ApiProperty(readableLink: false)]
|
|
||||||
private ?Reception $reception = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 1, nullable: true, enumType: CauseEntree::class)]
|
|
||||||
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
|
||||||
private ?CauseEntree $entryCause = null;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne]
|
|
||||||
#[Groups(['bovine:read'])]
|
|
||||||
#[ApiProperty(readableLink: true)]
|
|
||||||
private ?Building $building = null;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne]
|
#[ORM\ManyToOne]
|
||||||
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||||
#[ApiProperty(readableLink: true)]
|
|
||||||
private ?Supplier $supplier = null;
|
private ?Supplier $supplier = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 50, nullable: true)]
|
#[ORM\Column(length: 50, nullable: true)]
|
||||||
@@ -153,10 +132,36 @@ class Bovine
|
|||||||
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||||
private ?DateTimeImmutable $exitedAt = null;
|
private ?DateTimeImmutable $exitedAt = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
#[ORM\Column(length: 50, nullable: true)]
|
||||||
#[Groups(['bovine:read', 'building_case:read'])]
|
#[Groups(['bovine:read'])]
|
||||||
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i:s'])]
|
private ?string $motherNationalNumber = null;
|
||||||
private ?DateTimeImmutable $ednotifConfirmedAt = null;
|
|
||||||
|
#[ORM\ManyToOne]
|
||||||
|
#[Groups(['bovine:read'])]
|
||||||
|
#[ApiProperty(readableLink: true)]
|
||||||
|
private ?BovineType $motherBovineType = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50, nullable: true)]
|
||||||
|
#[Groups(['bovine:read'])]
|
||||||
|
private ?string $fatherNationalNumber = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne]
|
||||||
|
#[Groups(['bovine:read'])]
|
||||||
|
#[ApiProperty(readableLink: true)]
|
||||||
|
private ?BovineType $fatherBovineType = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Collection<int, BovineMovement>
|
||||||
|
*/
|
||||||
|
#[ORM\OneToMany(targetEntity: BovineMovement::class, mappedBy: 'bovine', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||||
|
#[ORM\OrderBy(['enteredAt' => 'DESC'])]
|
||||||
|
#[Groups(['bovine:read'])]
|
||||||
|
private Collection $movements;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->movements = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
@@ -234,52 +239,6 @@ class Bovine
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getReception(): ?Reception
|
|
||||||
{
|
|
||||||
return $this->reception;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setReception(?Reception $reception): static
|
|
||||||
{
|
|
||||||
$this->reception = $reception;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEntryCause(): ?CauseEntree
|
|
||||||
{
|
|
||||||
return $this->entryCause;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setEntryCause(?CauseEntree $entryCause): static
|
|
||||||
{
|
|
||||||
$this->entryCause = $entryCause;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getBuilding(): ?Building
|
|
||||||
{
|
|
||||||
return $this->building;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setBuilding(?Building $building): static
|
|
||||||
{
|
|
||||||
$this->building = $building;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bâtiment effectif d'un bovin : la case affectée si elle existe (logique
|
|
||||||
* historique), sinon le bâtiment direct (fed depuis l'XLSX initial).
|
|
||||||
*/
|
|
||||||
#[Groups(['bovine:read', 'building_case:read'])]
|
|
||||||
public function getEffectiveBuilding(): ?Building
|
|
||||||
{
|
|
||||||
return $this->buildingCase?->getIdBuilding() ?? $this->building;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSupplier(): ?Supplier
|
public function getSupplier(): ?Supplier
|
||||||
{
|
{
|
||||||
return $this->supplier;
|
return $this->supplier;
|
||||||
@@ -364,18 +323,6 @@ class Bovine
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEdnotifConfirmedAt(): ?DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->ednotifConfirmedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setEdnotifConfirmedAt(?DateTimeImmutable $ednotifConfirmedAt): static
|
|
||||||
{
|
|
||||||
$this->ednotifConfirmedAt = $ednotifConfirmedAt;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAgeMonths(): ?int
|
public function getAgeMonths(): ?int
|
||||||
{
|
{
|
||||||
return $this->ageMonths;
|
return $this->ageMonths;
|
||||||
@@ -388,6 +335,79 @@ class Bovine
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getMotherNationalNumber(): ?string
|
||||||
|
{
|
||||||
|
return $this->motherNationalNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMotherNationalNumber(?string $motherNationalNumber): static
|
||||||
|
{
|
||||||
|
$this->motherNationalNumber = $motherNationalNumber;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMotherBovineType(): ?BovineType
|
||||||
|
{
|
||||||
|
return $this->motherBovineType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMotherBovineType(?BovineType $motherBovineType): static
|
||||||
|
{
|
||||||
|
$this->motherBovineType = $motherBovineType;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFatherNationalNumber(): ?string
|
||||||
|
{
|
||||||
|
return $this->fatherNationalNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFatherNationalNumber(?string $fatherNationalNumber): static
|
||||||
|
{
|
||||||
|
$this->fatherNationalNumber = $fatherNationalNumber;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFatherBovineType(): ?BovineType
|
||||||
|
{
|
||||||
|
return $this->fatherBovineType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFatherBovineType(?BovineType $fatherBovineType): static
|
||||||
|
{
|
||||||
|
$this->fatherBovineType = $fatherBovineType;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, BovineMovement>
|
||||||
|
*/
|
||||||
|
public function getMovements(): Collection
|
||||||
|
{
|
||||||
|
return $this->movements;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addMovement(BovineMovement $movement): static
|
||||||
|
{
|
||||||
|
if (!$this->movements->contains($movement)) {
|
||||||
|
$this->movements->add($movement);
|
||||||
|
$movement->setBovine($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeMovement(BovineMovement $movement): static
|
||||||
|
{
|
||||||
|
$this->movements->removeElement($movement);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
#[ORM\PrePersist]
|
#[ORM\PrePersist]
|
||||||
#[ORM\PreUpdate]
|
#[ORM\PreUpdate]
|
||||||
public function refreshAgeMonths(): void
|
public function refreshAgeMonths(): void
|
||||||
|
|||||||
112
src/Entity/BovineMovement.php
Normal file
112
src/Entity/BovineMovement.php
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Repository\BovineMovementRepository;
|
||||||
|
use App\State\Bovin\BovineMovementProcessor;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: BovineMovementRepository::class)]
|
||||||
|
#[ORM\Table(name: 'bovine_movement')]
|
||||||
|
#[ORM\Index(name: 'idx_bovine_movement_timeline', columns: ['bovine_id', 'entered_at'])]
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Post(
|
||||||
|
denormalizationContext: ['groups' => ['bovine_movement:write']],
|
||||||
|
normalizationContext: ['groups' => ['bovine:read']],
|
||||||
|
processor: BovineMovementProcessor::class,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
security: "is_granted('ROLE_BUREAU')",
|
||||||
|
)]
|
||||||
|
class BovineMovement
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['bovine:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(inversedBy: 'movements')]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
#[Groups(['bovine_movement:write'])]
|
||||||
|
private Bovine $bovine;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne]
|
||||||
|
#[Groups(['bovine:read', 'bovine_movement:write'])]
|
||||||
|
#[ApiProperty(readableLink: true)]
|
||||||
|
private ?BuildingCase $buildingCase = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
#[Groups(['bovine:read', 'bovine_movement:write'])]
|
||||||
|
private DateTimeImmutable $enteredAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||||
|
#[Groups(['bovine:read'])]
|
||||||
|
private ?DateTimeImmutable $leftAt = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBovine(): Bovine
|
||||||
|
{
|
||||||
|
return $this->bovine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBovine(Bovine $bovine): static
|
||||||
|
{
|
||||||
|
$this->bovine = $bovine;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBuildingCase(): ?BuildingCase
|
||||||
|
{
|
||||||
|
return $this->buildingCase;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBuildingCase(?BuildingCase $buildingCase): static
|
||||||
|
{
|
||||||
|
$this->buildingCase = $buildingCase;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEnteredAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->enteredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasEnteredAt(): bool
|
||||||
|
{
|
||||||
|
return isset($this->enteredAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEnteredAt(DateTimeImmutable $enteredAt): static
|
||||||
|
{
|
||||||
|
$this->enteredAt = $enteredAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLeftAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->leftAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLeftAt(?DateTimeImmutable $leftAt): static
|
||||||
|
{
|
||||||
|
$this->leftAt = $leftAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ class Building
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['building:read', 'building:summary', 'reception:read', 'bovine:read'])]
|
#[Groups(['building:read', 'building:summary', 'reception:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class BuildingCase
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['building:read', 'building_case:read', 'bovine:read'])]
|
#[Groups(['building:read', 'building_case:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace App\Entity;
|
|||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
|
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
use ApiPlatform\Metadata\ApiFilter;
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
use ApiPlatform\Metadata\ApiProperty;
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
@@ -32,15 +31,13 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
|||||||
#[ORM\Entity]
|
#[ORM\Entity]
|
||||||
#[ORM\HasLifecycleCallbacks]
|
#[ORM\HasLifecycleCallbacks]
|
||||||
#[ORM\Table(name: 'reception')]
|
#[ORM\Table(name: 'reception')]
|
||||||
#[ApiFilter(BooleanFilter::class, properties: ['isValid', 'entryCompleted'])]
|
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
|
||||||
#[ApiFilter(ExistsFilter::class, properties: ['validatedAt'])]
|
|
||||||
#[ApiFilter(SearchFilter::class, properties: [
|
#[ApiFilter(SearchFilter::class, properties: [
|
||||||
'identificationNumber' => 'ipartial',
|
'identificationNumber' => 'ipartial',
|
||||||
'supplier.name' => 'ipartial',
|
'supplier.name' => 'ipartial',
|
||||||
'carrier.name' => 'ipartial',
|
'carrier.name' => 'ipartial',
|
||||||
'licensePlate' => 'ipartial',
|
'licensePlate' => 'ipartial',
|
||||||
'receptionType.id' => 'exact',
|
'receptionType.id' => 'exact',
|
||||||
'receptionType.code' => 'exact',
|
|
||||||
])]
|
])]
|
||||||
#[ApiFilter(DateFilter::class, properties: ['receptionDate'])]
|
#[ApiFilter(DateFilter::class, properties: ['receptionDate'])]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
@@ -113,14 +110,6 @@ class Reception
|
|||||||
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
|
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
|
||||||
private bool $isValid = false;
|
private bool $isValid = false;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => false])]
|
|
||||||
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
|
|
||||||
private bool $entryCompleted = false;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
|
||||||
#[Groups(['reception:read', 'reception-bovine:read'])]
|
|
||||||
private ?DateTimeImmutable $validatedAt = null;
|
|
||||||
|
|
||||||
#[ORM\Column(name: 'date_reception', type: 'datetime_immutable')]
|
#[ORM\Column(name: 'date_reception', type: 'datetime_immutable')]
|
||||||
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
|
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
|
||||||
#[Context(
|
#[Context(
|
||||||
@@ -215,12 +204,6 @@ class Reception
|
|||||||
#[Groups(['reception:read', 'reception:write'])]
|
#[Groups(['reception:read', 'reception:write'])]
|
||||||
private ?string $bovineDetail = null;
|
private ?string $bovineDetail = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var Collection<int, Bovine>
|
|
||||||
*/
|
|
||||||
#[ORM\OneToMany(targetEntity: Bovine::class, mappedBy: 'reception')]
|
|
||||||
private Collection $bovines;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
?DateTimeImmutable $receptionDate = null,
|
?DateTimeImmutable $receptionDate = null,
|
||||||
) {
|
) {
|
||||||
@@ -229,7 +212,6 @@ class Reception
|
|||||||
$this->buildings = new ArrayCollection();
|
$this->buildings = new ArrayCollection();
|
||||||
$this->pelletBuildings = new ArrayCollection();
|
$this->pelletBuildings = new ArrayCollection();
|
||||||
$this->bovines_types = new ArrayCollection();
|
$this->bovines_types = new ArrayCollection();
|
||||||
$this->bovines = new ArrayCollection();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
@@ -288,90 +270,6 @@ class Reception
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Groups(['reception:read'])]
|
|
||||||
public function isEntryCompleted(): bool
|
|
||||||
{
|
|
||||||
return $this->entryCompleted;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setEntryCompleted(bool $entryCompleted): self
|
|
||||||
{
|
|
||||||
$this->entryCompleted = $entryCompleted;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getValidatedAt(): ?DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->validatedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setValidatedAt(?DateTimeImmutable $validatedAt): self
|
|
||||||
{
|
|
||||||
$this->validatedAt = $validatedAt;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isFullyConfirmed(): bool
|
|
||||||
{
|
|
||||||
if ($this->bovines->isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
foreach ($this->bovines as $bovine) {
|
|
||||||
if (null === $bovine->getEdnotifConfirmedAt()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tryValidate(): void
|
|
||||||
{
|
|
||||||
if ($this->entryCompleted && null === $this->validatedAt && $this->isFullyConfirmed()) {
|
|
||||||
$this->validatedAt = new DateTimeImmutable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[ORM\PreUpdate]
|
|
||||||
public function onPreUpdate(): void
|
|
||||||
{
|
|
||||||
$this->tryValidate();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Groups(['reception:read'])]
|
|
||||||
public function getRegisteredBovineCount(): int
|
|
||||||
{
|
|
||||||
return $this->bovines->count();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Groups(['reception:read'])]
|
|
||||||
public function getConfirmedBovineCount(): int
|
|
||||||
{
|
|
||||||
$count = 0;
|
|
||||||
foreach ($this->bovines as $bovine) {
|
|
||||||
if (null !== $bovine->getEdnotifConfirmedAt()) {
|
|
||||||
++$count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $count;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Groups(['reception:read'])]
|
|
||||||
public function getDeclaredBovineCount(): int
|
|
||||||
{
|
|
||||||
$fromTypes = 0;
|
|
||||||
foreach ($this->bovines_types as $rb) {
|
|
||||||
$fromTypes += (int) ($rb->getQuantity() ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
$fromOther = is_numeric($this->bovineDetail) ? (int) $this->bovineDetail : 0;
|
|
||||||
|
|
||||||
return $fromTypes + $fromOther;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Groups(['reception:read'])]
|
#[Groups(['reception:read'])]
|
||||||
public function getReceptionDate(): ?DateTimeImmutable
|
public function getReceptionDate(): ?DateTimeImmutable
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ class Supplier
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['supplier:read', 'reception:read', 'bovine:read'])]
|
#[Groups(['supplier:read', 'reception:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 180)]
|
#[ORM\Column(length: 180)]
|
||||||
#[Groups(['supplier:read', 'reception:read', 'supplier:write', 'bovine:read'])]
|
#[Groups(['supplier:read', 'reception:read', 'supplier:write'])]
|
||||||
private string $name = '';
|
private string $name = '';
|
||||||
|
|
||||||
#[ORM\Column(length: 180, nullable: true)]
|
#[ORM\Column(length: 180, nullable: true)]
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enum;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cause d'une entrée de bovin sur l'exploitation (opération `IpBCreateEntree`).
|
|
||||||
*
|
|
||||||
* Source : `resources/ednotif-ws/CauseEntree.XSD` + doc IPG Table 9.
|
|
||||||
* Le `.value` est le code IPG transmis dans le payload SOAP.
|
|
||||||
*
|
|
||||||
* Note : duplique l'enum `Malio\EdnotifBundle\Bovin\Enum\CauseEntree` (pas
|
|
||||||
* encore présente dans la release installée v0.0.6). À remplacer par l'import
|
|
||||||
* bundle quand une version embarquant l'enum sera publiée.
|
|
||||||
*/
|
|
||||||
enum CauseEntree: string
|
|
||||||
{
|
|
||||||
/** Entrée par achat. */
|
|
||||||
case Achat = 'A';
|
|
||||||
|
|
||||||
/** Entrée par naissance. */
|
|
||||||
case Naissance = 'N';
|
|
||||||
|
|
||||||
/** Entrée par prêt ou pension. */
|
|
||||||
case PretOuPension = 'P';
|
|
||||||
}
|
|
||||||
34
src/Repository/BovineMovementRepository.php
Normal file
34
src/Repository/BovineMovementRepository.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Bovine;
|
||||||
|
use App\Entity\BovineMovement;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<BovineMovement>
|
||||||
|
*/
|
||||||
|
final class BovineMovementRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, BovineMovement::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOpenMovement(Bovine $bovine): ?BovineMovement
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('m')
|
||||||
|
->where('m.bovine = :bovine')
|
||||||
|
->andWhere('m.leftAt IS NULL')
|
||||||
|
->setParameter('bovine', $bovine)
|
||||||
|
->orderBy('m.enteredAt', 'DESC')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,6 @@ final class BovineRepository extends ServiceEntityRepository
|
|||||||
{
|
{
|
||||||
$qb = $this->createQueryBuilder('b')
|
$qb = $this->createQueryBuilder('b')
|
||||||
->where('b.exitedAt IS NULL')
|
->where('b.exitedAt IS NULL')
|
||||||
->andWhere('b.ednotifConfirmedAt IS NOT NULL')
|
|
||||||
->orderBy('b.birthDate', 'ASC')
|
->orderBy('b.birthDate', 'ASC')
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -82,7 +81,6 @@ final class BovineRepository extends ServiceEntityRepository
|
|||||||
'SUM(CASE WHEN b.ageMonths >= 20 AND b.ageMonths < 22 THEN 1 ELSE 0 END) AS between20And22',
|
'SUM(CASE WHEN b.ageMonths >= 20 AND b.ageMonths < 22 THEN 1 ELSE 0 END) AS between20And22',
|
||||||
)
|
)
|
||||||
->where('b.exitedAt IS NULL')
|
->where('b.exitedAt IS NULL')
|
||||||
->andWhere('b.ednotifConfirmedAt IS NOT NULL')
|
|
||||||
;
|
;
|
||||||
|
|
||||||
if (null !== $buildingCaseId) {
|
if (null !== $buildingCaseId) {
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ final class BovineInventoryExportProvider implements ProviderInterface
|
|||||||
$type = $bovine->getBovineType();
|
$type = $bovine->getBovineType();
|
||||||
$isLim = self::BREED_CODE_LIMOUSINE === $type?->getCode();
|
$isLim = self::BREED_CODE_LIMOUSINE === $type?->getCode();
|
||||||
$isCharo = self::BREED_CODE_CHAROLAISE === $type?->getCode();
|
$isCharo = self::BREED_CODE_CHAROLAISE === $type?->getCode();
|
||||||
$building = $bovine->getBuildingCase()?->getIdBuilding() ?? $bovine->getBuilding();
|
$building = $bovine->getBuildingCase()?->getIdBuilding();
|
||||||
$code = $building?->getCode();
|
$code = $building?->getCode();
|
||||||
|
|
||||||
$sheet->setCellValue('A'.$row, $isLim ? 'X' : '');
|
$sheet->setCellValue('A'.$row, $isLim ? 'X' : '');
|
||||||
|
|||||||
43
src/State/Bovin/BovineMovementProcessor.php
Normal file
43
src/State/Bovin/BovineMovementProcessor.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State\Bovin;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Entity\BovineMovement;
|
||||||
|
use App\Repository\BovineMovementRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
final class BovineMovementProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly BovineMovementRepository $movementRepository,
|
||||||
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
|
private readonly ProcessorInterface $persistProcessor,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
|
{
|
||||||
|
if (!$data instanceof BovineMovement) {
|
||||||
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
$enteredAt = $data->hasEnteredAt() ? $data->getEnteredAt() : new DateTimeImmutable();
|
||||||
|
$data->setEnteredAt($enteredAt);
|
||||||
|
$data->setLeftAt(null);
|
||||||
|
|
||||||
|
$bovine = $data->getBovine();
|
||||||
|
|
||||||
|
$openMovement = $this->movementRepository->findOpenMovement($bovine);
|
||||||
|
if (null !== $openMovement) {
|
||||||
|
$openMovement->setLeftAt($enteredAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bovine->setBuildingCase($data->getBuildingCase());
|
||||||
|
|
||||||
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,6 @@ namespace App\State\Bovin;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\Entity\Bovine;
|
use App\Entity\Bovine;
|
||||||
use App\Entity\BovineType;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
|
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
@@ -17,7 +15,6 @@ final class BovineProcessor implements ProcessorInterface
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BovinApiInterface $bovinApi,
|
private readonly BovinApiInterface $bovinApi,
|
||||||
private readonly EntityManagerInterface $em,
|
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
private readonly ProcessorInterface $persistProcessor,
|
private readonly ProcessorInterface $persistProcessor,
|
||||||
) {}
|
) {}
|
||||||
@@ -44,35 +41,28 @@ final class BovineProcessor implements ProcessorInterface
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$bovine->setSex($identification->sex);
|
|
||||||
$bovine->setWorkNumber($identification->workNumber);
|
$bovine->setWorkNumber($identification->workNumber);
|
||||||
$bovine->setBirthDate($identification->birthDate?->date);
|
$bovine->setBirthDate($identification->birthDate?->date);
|
||||||
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
|
$bovine->setBreedCode($this->normalizeBreedCode($identification->breedType));
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
// External service unavailable — persist bovine without enrichment.
|
// External service unavailable — persist bovine without enrichment.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function normalizeBreedCode(mixed $breedType): ?string
|
||||||
* Trouve un BovineType par code, sinon en crée un placeholder
|
|
||||||
* (l'admin pourra le renommer ensuite dans /admin/bovin/bovin-list).
|
|
||||||
*/
|
|
||||||
private function resolveBovineType(?string $code): ?BovineType
|
|
||||||
{
|
{
|
||||||
if (null === $code || '' === $code) {
|
if (null === $breedType) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$existing = $this->em->getRepository(BovineType::class)->findOneBy(['code' => $code]);
|
if (is_numeric($breedType)) {
|
||||||
if (null !== $existing) {
|
return (string) $breedType;
|
||||||
return $existing;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$bovineType = new BovineType();
|
if (is_string($breedType) && preg_match('/\d+/', $breedType, $matches)) {
|
||||||
$bovineType->setCode($code);
|
return $matches[0];
|
||||||
$bovineType->setLabel(sprintf('À renommer (%s)', $code));
|
}
|
||||||
$this->em->persist($bovineType);
|
|
||||||
|
|
||||||
return $bovineType;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,7 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
|
|||||||
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
|
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
|
||||||
}
|
}
|
||||||
|
|
||||||
$seen = [];
|
$seen = [];
|
||||||
$impactedReceptions = [];
|
|
||||||
foreach ($inventory->animals as $animal) {
|
foreach ($inventory->animals as $animal) {
|
||||||
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
|
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
|
||||||
if (null === $nationalNumber || '' === $nationalNumber) {
|
if (null === $nationalNumber || '' === $nationalNumber) {
|
||||||
@@ -73,20 +72,6 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
|
|||||||
|
|
||||||
$this->applyEdnotifData($bovine, $animal);
|
$this->applyEdnotifData($bovine, $animal);
|
||||||
$bovine->setExitedAt(null);
|
$bovine->setExitedAt(null);
|
||||||
|
|
||||||
// Marque la confirmation EDNOTIF si c'est la première fois qu'on
|
|
||||||
// voit ce bovin remonter dans l'inventaire.
|
|
||||||
if (null === $bovine->getEdnotifConfirmedAt()) {
|
|
||||||
$bovine->setEdnotifConfirmedAt(new DateTimeImmutable());
|
|
||||||
$reception = $bovine->getReception();
|
|
||||||
if (null !== $reception) {
|
|
||||||
$impactedReceptions[$reception->getId()] = $reception;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($impactedReceptions as $reception) {
|
|
||||||
$reception->tryValidate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$now = new DateTimeImmutable();
|
$now = new DateTimeImmutable();
|
||||||
@@ -114,6 +99,11 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
|
|||||||
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
|
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
|
||||||
$bovine->setWorkNumber($identification->workNumber);
|
$bovine->setWorkNumber($identification->workNumber);
|
||||||
$bovine->setBirthDate($identification->birthDate?->date);
|
$bovine->setBirthDate($identification->birthDate?->date);
|
||||||
|
|
||||||
|
$bovine->setMotherNationalNumber($identification->motherCarrier?->bovin?->nationalNumber);
|
||||||
|
$bovine->setMotherBovineType($this->resolveBovineType($identification->motherCarrier?->breedType));
|
||||||
|
$bovine->setFatherNationalNumber($identification->fatherIpg?->bovin?->nationalNumber);
|
||||||
|
$bovine->setFatherBovineType($this->resolveBovineType($identification->fatherIpg?->breedType));
|
||||||
}
|
}
|
||||||
|
|
||||||
$latestEntry = null;
|
$latestEntry = null;
|
||||||
|
|||||||
@@ -91,6 +91,22 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usort($rows, static function (array $a, array $b): int {
|
||||||
|
$aw = (string) ($a['workNumber'] ?? '');
|
||||||
|
$bw = (string) ($b['workNumber'] ?? '');
|
||||||
|
if ('' === $aw && '' === $bw) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ('' === $aw) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if ('' === $bw) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $aw <=> (int) $bw;
|
||||||
|
});
|
||||||
|
|
||||||
$monthHeaders = $this->buildMonthHeaders($firstArrivalDate, $headerBreedCode);
|
$monthHeaders = $this->buildMonthHeaders($firstArrivalDate, $headerBreedCode);
|
||||||
|
|
||||||
$dompdf = new Dompdf();
|
$dompdf = new Dompdf();
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
.sheet { width: auto; }
|
.sheet { width: auto; }
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 8px 0 16px 0;
|
margin: 0 0 8px 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -139,10 +139,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.main .sub-title {
|
.main .sub-title {
|
||||||
font-size: 16px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
padding: 8px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main .base {
|
.main .base {
|
||||||
@@ -203,61 +203,61 @@
|
|||||||
<h1 style="color: red; text-align: center; width: 100%; font-size: 36px">
|
<h1 style="color: red; text-align: center; width: 100%; font-size: 36px">
|
||||||
Arrivage du {{ firstArrivalDate ?? '-' }}
|
Arrivage du {{ firstArrivalDate ?? '-' }}
|
||||||
</h1>
|
</h1>
|
||||||
<table style="width:100%; border-collapse:collapse; table-layout:fixed; margin-bottom: 16px">
|
<table style="width:100%; border-collapse:collapse; table-layout:fixed; margin-bottom: 4px">
|
||||||
|
<colgroup>
|
||||||
|
{# 28 colonnes ≈ 3.571% chacune #}
|
||||||
|
{% for _ in 0..27 %}<col style="width:3.571%">{% endfor %}
|
||||||
|
</colgroup>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:40%; vertical-align:top; padding-right:2mm; border:0;">
|
<td style="border:0; text-align:left; font-weight:700; font-size: 18px;" colspan="4">PROVENANCE</td>
|
||||||
<table style="width:100%; border-collapse:collapse; table-layout:fixed;">
|
|
||||||
<tr>
|
|
||||||
<td style="border: 0; height: 20px"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="font-weight:700; text-align: left; border: none; font-size: 24px">CASE N° {{ buildingCase.caseNumber ?? '' }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td style="width:60%; vertical-align:top; padding-left:2mm; border:0;">
|
{# Paire 1 : chiffre + case vide #}
|
||||||
<table class="header-right-free" style="width:100%; border-collapse:collapse; table-layout:fixed;">
|
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">1</td>
|
||||||
<tr>
|
<td style="border:1px solid #2b2b2b;"></td>
|
||||||
<td style="border:0; text-align:center; font-weight:700; height: 20px;" colspan="5"></td>
|
<td style="border:0;"></td>
|
||||||
<td style="border:0;" colspan="2"></td>
|
{# Paire 2 #}
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">1</td>
|
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">2</td>
|
||||||
<td style="border:0; height: 20px;"></td>
|
<td style="border:1px solid #2b2b2b;"></td>
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">2</td>
|
<td style="border:0;"></td>
|
||||||
<td style="border:0; height: 20px;"></td>
|
{# Paire 3 #}
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">3</td>
|
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">3</td>
|
||||||
<td style="border:0; height: 20px;"></td>
|
<td style="border:1px solid #2b2b2b;"></td>
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; height: 20px;">4</td>
|
<td style="border:0;"></td>
|
||||||
<td style="border:0;" colspan="2"></td>
|
{# Paire 4 #}
|
||||||
</tr>
|
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700; font-size: 11px; padding:0;">4</td>
|
||||||
<tr>
|
<td style="border:1px solid #2b2b2b;"></td>
|
||||||
<td style="border:0; text-align:left; font-weight:700; font-size: 24px; width:40%; height: 20px;" colspan="5">PROVENANCE</td>
|
|
||||||
<td style="border:0;" colspan="2"></td>
|
{# Espacement entre PROVENANCE et RACE (1 col, RACE commence plus tôt) #}
|
||||||
<td style="border:1px solid #2b2b2b;"></td>
|
<td style="border:0;"></td>
|
||||||
<td style="border:0;"></td>
|
|
||||||
<td style="border:1px solid #2b2b2b;"></td>
|
{# Bloc RACE #}
|
||||||
<td style="border:0;"></td>
|
<td style="border:0; text-align:left; font-weight:700; font-size: 18px;" colspan="2">RACE</td>
|
||||||
<td style="border:1px solid #2b2b2b;"></td>
|
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="2">LIMOUSIN</td>
|
||||||
<td style="border:0;"></td>
|
<td style="border:1px solid #2b2b2b;"></td>
|
||||||
<td style="border:1px solid #2b2b2b;"></td>
|
<td style="border:0;"></td>
|
||||||
<td style="border: 0; width: 20%;" colspan="2"></td>
|
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="2">CHAROLAIS</td>
|
||||||
</tr>
|
<td style="border:1px solid #2b2b2b;"></td>
|
||||||
<tr>
|
<td style="border:0;"></td>
|
||||||
<td style="border: 0; height: 20px" colspan="16"></td>
|
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;">AUTRE</td>
|
||||||
</tr>
|
<td style="border:1px solid #2b2b2b;"></td>
|
||||||
<tr>
|
</tr>
|
||||||
<td style="border: 0; text-align:left; font-weight:700; font-size: 24px" colspan="3">RACE</td>
|
</table>
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="3">LIMOUSIN</td>
|
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="1"></td>
|
<table style="width:auto; border-collapse:collapse; margin-bottom: 8px; margin-top: 8px">
|
||||||
<td style="border: 0; text-align:center; font-weight:700;" colspan="1"></td>
|
<tr>
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="3">CHAROLAIS</td>
|
<td style="border:0; text-align:left; font-weight:700; font-size: 18px; padding-right: 8px;">BATIMENT N°</td>
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="1"></td>
|
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||||
<td style="border: 0; text-align:center; font-weight:700;" colspan="1"></td>
|
<td style="border:0; width: 22px;"></td>
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="2">Autre</td>
|
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||||
<td style="border:1px solid #2b2b2b; text-align:center; font-weight:700;" colspan="1"></td>
|
<td style="border:0; width: 22px;"></td>
|
||||||
</tr>
|
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||||
</table>
|
<td style="border:0; width: 32px;"></td>
|
||||||
</td>
|
<td style="border:0; text-align:left; font-weight:700; font-size: 18px; padding-right: 8px;">CASE N°</td>
|
||||||
|
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||||
|
<td style="border:0; width: 22px;"></td>
|
||||||
|
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||||
|
<td style="border:0; width: 22px;"></td>
|
||||||
|
<td style="border:1px solid #2b2b2b; width: 22px; height: 22px;"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -267,30 +267,29 @@
|
|||||||
<table class="main">
|
<table class="main">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
|
{% for month in monthHeaders|default([])|reverse %}
|
||||||
<th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
|
|
||||||
<th rowspan="4" class="head-big head-big-weight" style="width:4%">Poids<br>(kg)</th>
|
|
||||||
<th rowspan="4" class="head-big" style="width:7%">Date de<br>naissance</th>
|
|
||||||
|
|
||||||
{% for month in monthHeaders|default([]) %}
|
|
||||||
<th class="month" style="width:6.58%">{{ month.name }}</th>
|
<th class="month" style="width:6.58%">{{ month.name }}</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<th rowspan="4" class="head-big" style="width:7%">Date de<br>naissance</th>
|
||||||
|
<th rowspan="4" class="head-big head-big-weight" style="width:4%">Poids<br>(kg)</th>
|
||||||
|
<th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
|
||||||
|
<th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
{% for month in monthHeaders|default([]) %}
|
{% for month in monthHeaders|default([])|reverse %}
|
||||||
<th class="days">{{ month.days }}</th>
|
<th class="days">{{ month.days }}</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<th class="days">Foin</th>
|
|
||||||
<th class="days">Foin</th>
|
|
||||||
<th colspan="{{ monthHeaders|length -2 }}" class="sub-title">POIDS PAR MOIS</th>
|
<th colspan="{{ monthHeaders|length -2 }}" class="sub-title">POIDS PAR MOIS</th>
|
||||||
|
<th class="days">Foin</th>
|
||||||
|
<th class="days">Foin</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
{% for month in monthHeaders|default([]) %}
|
{% for month in monthHeaders|default([])|reverse %}
|
||||||
<th class="base">
|
<th class="base">
|
||||||
{% if month.baseValue is defined %}
|
{% if month.baseValue is defined %}
|
||||||
{{ month.baseValue|round(0, 'common') }} kg
|
{{ month.baseValue|round(0, 'common') }} kg
|
||||||
@@ -303,27 +302,28 @@
|
|||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{# 11 lignes comme dans ton code (0..10) #}
|
{# 13 lignes comme dans ton code (0..12) #}
|
||||||
{% for i in 0..12 %}
|
{% for i in 0..12 %}
|
||||||
{% set row = rows[i] ?? null %}
|
{% set row = rows[i] ?? null %}
|
||||||
{% set baseWeight = row ? (row.receivedWeight ?? null) : null %}
|
{% set baseWeight = row ? (row.receivedWeight ?? null) : null %}
|
||||||
|
|
||||||
<tr class="data-row">
|
<tr class="data-row">
|
||||||
<td class="row-work"></td>
|
{% for idx in 0..(monthCount > 0 ? monthCount - 1 : 0) %}
|
||||||
<td class="row-work">{{ row ? (row.workNumber ?? '') : '' }}</td>
|
{% set reversedIdx = (monthCount - 1) - idx %}
|
||||||
<td class="row-weight">{{ baseWeight ?? '' }}</td>
|
{% set projectedWeight = row and row.projectedWeights is defined ? (row.projectedWeights[reversedIdx] ?? null) : null %}
|
||||||
|
<td class="row-month"{% if reversedIdx < 4 %} style="background:#e0e0e0;"{% endif %}>
|
||||||
|
{{ projectedWeight is not null ? projectedWeight|round(0, 'common') : '' }}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
<td class="row-birth">
|
<td class="row-birth">
|
||||||
{% if row and row.birthDate %}
|
{% if row and row.birthDate %}
|
||||||
{% set birthParts = row.birthDate|split('/') %}
|
{% set birthParts = row.birthDate|split('/') %}
|
||||||
{{ birthParts|length == 3 ? birthParts[1] ~ '/' ~ birthParts[2] : row.birthDate }}
|
{{ birthParts|length == 3 ? birthParts[1] ~ '/' ~ birthParts[2] : row.birthDate }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% for idx in 0..(monthCount > 0 ? monthCount - 1 : 0) %}
|
<td class="row-weight">{{ baseWeight ?? '' }}</td>
|
||||||
{% set projectedWeight = row and row.projectedWeights is defined ? (row.projectedWeights[idx] ?? null) : null %}
|
<td class="row-work">{{ row ? (row.workNumber ?? '') : '' }}</td>
|
||||||
<td class="row-month"{% if loop.index0 < 4 %} style="background:#e0e0e0;"{% endif %}>
|
<td class="row-work"></td>
|
||||||
{{ projectedWeight is not null ? projectedWeight|round(0, 'common') : '' }}
|
|
||||||
</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -331,41 +331,89 @@
|
|||||||
<!-- =========================
|
<!-- =========================
|
||||||
FOOTER (traitements / vaccins)
|
FOOTER (traitements / vaccins)
|
||||||
========================= -->
|
========================= -->
|
||||||
<table class="footer" style="border-collapse:collapse; margin-top: 32px">
|
<table style="width:100%; border:0; border-collapse:collapse; table-layout:fixed; margin-top: 12px">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="height: 20px; border: 0" colspan="4"></td>
|
<td style="border:0; padding:0; width:49%; vertical-align:top;">
|
||||||
<td style="font-weight: 700" colspan="2">Grippe</td>
|
<table class="footer" style="border-collapse:collapse; width:100%; table-layout:fixed;">
|
||||||
<td style="font-weight: 700" colspan="2">Protivity</td>
|
<tr>
|
||||||
</tr>
|
<td style="font-weight: 700; height: 20px" colspan="10">Traitements</td>
|
||||||
<tr>
|
</tr>
|
||||||
<td style="height: 20px">Date</td>
|
<tr>
|
||||||
<td>Antibiotique</td>
|
<td style="height: 20px" colspan="2">Date</td>
|
||||||
<td>Date</td>
|
<td colspan="2"></td>
|
||||||
<td>Antero</td>
|
<td>Dose</td>
|
||||||
<td>Date</td>
|
<td colspan="5">Observation</td>
|
||||||
<td>Intranasale</td>
|
</tr>
|
||||||
<td>Date</td>
|
<tr>
|
||||||
<td>Rappel 30 jours</td>
|
<td style="height: 20px" colspan="2"></td>
|
||||||
</tr>
|
<td colspan="2">Grippe</td>
|
||||||
<tr>
|
<td></td>
|
||||||
<td style="height: 20px"></td>
|
<td colspan="5"></td>
|
||||||
<td></td>
|
</tr>
|
||||||
<td></td>
|
<tr>
|
||||||
<td></td>
|
<td style="height: 20px" colspan="2"></td>
|
||||||
<td></td>
|
<td colspan="2">Antéro</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td colspan="5"></td>
|
||||||
<td></td>
|
</tr>
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<td style="height: 20px" colspan="2"></td>
|
||||||
<td style="height: 20px"></td>
|
<td colspan="2">Antibiotiques</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td colspan="5"></td>
|
||||||
<td></td>
|
</tr>
|
||||||
<td></td>
|
<tr>
|
||||||
<td></td>
|
<td style="height: 20px" colspan="2"></td>
|
||||||
<td></td>
|
<td colspan="2">Déparasitage</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
<td colspan="5"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="height: 20px" colspan="2"></td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td></td>
|
||||||
|
<td colspan="5"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td style="border:0; padding:0; width:2%;"></td>
|
||||||
|
<td style="border:0; padding:0; width:49%; vertical-align:top;">
|
||||||
|
<table class="footer" style="border-collapse:collapse; width:100%; table-layout:fixed;">
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight: 700; height: 20px" colspan="10">Rappel</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="height: 20px" colspan="2">Date</td>
|
||||||
|
<td>Dose</td>
|
||||||
|
<td colspan="7">Observation</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="height: 20px" colspan="2"></td>
|
||||||
|
<td></td>
|
||||||
|
<td colspan="7"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="height: 20px" colspan="2"></td>
|
||||||
|
<td></td>
|
||||||
|
<td colspan="7"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="height: 20px" colspan="2"></td>
|
||||||
|
<td></td>
|
||||||
|
<td colspan="7"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="height: 20px" colspan="2"></td>
|
||||||
|
<td></td>
|
||||||
|
<td colspan="7"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="height: 20px" colspan="2"></td>
|
||||||
|
<td></td>
|
||||||
|
<td colspan="7"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user