Compare commits
52
Commits
5ac824be81
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9688acbc2 | ||
|
|
d7d7496a66 | ||
|
|
f2f537a194 | ||
|
|
91745636ec | ||
|
|
181147e3bf | ||
|
|
624df79974 | ||
|
|
42ed11bc22 | ||
|
|
6fca16a58c | ||
|
|
a35031b2a1 | ||
|
|
96e7d33739 | ||
|
|
01ac0626be | ||
|
|
6c88ec0cc7 | ||
|
|
62b4ac1866 | ||
|
|
6cc303c59c | ||
|
|
40cbb41dec | ||
|
|
149040391d | ||
|
|
b8ccb55653 | ||
|
|
3949701dd2 | ||
|
|
d8073bdc46 | ||
|
|
5ee979fcc3 | ||
|
|
30eebd12d2 | ||
|
|
f469e52c32 | ||
|
|
de075a8fe3 | ||
|
|
53f6a1bb1a | ||
|
|
bf718c5b13 | ||
|
|
6f150710d1 | ||
|
|
8d34f531a5 | ||
|
|
5d2f9aa05d | ||
|
|
22c7a7b8d8 | ||
|
|
632c851f9c | ||
|
|
b0fbd5e5fc | ||
|
|
62ad698ea6 | ||
|
|
c99d913d94 | ||
|
|
46652bb925 | ||
|
|
b9073954f1 | ||
|
|
7b11e138d2 | ||
|
|
e82ad3ae4d | ||
|
|
ef7166c2dd | ||
|
|
4916e74d1c | ||
|
|
3023227a29 | ||
|
|
3149989286 | ||
|
|
b8fddc51c2 | ||
|
|
32a60b4476 | ||
|
|
c6830873ba | ||
|
|
5c84dfed66 | ||
|
|
7618954a86 | ||
|
|
7313063cd9 | ||
|
|
190a845383 | ||
|
|
3a87eff949 | ||
|
|
22a8d5026c | ||
|
|
c034088bee | ||
|
|
536bce4625 |
@@ -82,14 +82,3 @@ jobs:
|
|||||||
target: waf
|
target: waf
|
||||||
push: true
|
push: true
|
||||||
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'waf' || 'waf-pre-prod' }}
|
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'waf' || 'waf-pre-prod' }}
|
||||||
|
|
||||||
- name: SSH Deploy
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
uses: appleboy/ssh-action@v1
|
|
||||||
with:
|
|
||||||
host: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_HOST_PROD || secrets.SERVER_HOST_PRE_PROD }}
|
|
||||||
username: ${{ secrets.SERVER_USER }}
|
|
||||||
key: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_SSH_KEY_PROD || secrets.SERVER_SSH_KEY_PRE_PROD }}
|
|
||||||
script: |
|
|
||||||
docker compose -f ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.COMPOSE_PATH_PROD || secrets.COMPOSE_PATH_PRE_PROD }} pull backend waf
|
|
||||||
docker compose -f ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.COMPOSE_PATH_PROD || secrets.COMPOSE_PATH_PRE_PROD }} up -d --no-deps backend waf
|
|
||||||
|
|||||||
@@ -34,7 +34,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Install EAS CLI & tooling
|
- name: Install EAS CLI & tooling
|
||||||
run: |
|
run: |
|
||||||
npm install -g eas-cli
|
n=0
|
||||||
|
until [ $n -ge 3 ]; do
|
||||||
|
npm install -g eas-cli && break
|
||||||
|
n=$((n + 1))
|
||||||
|
echo "npm install -g eas-cli failed (attempt $n/3), cleaning up and retrying in 5s..."
|
||||||
|
npm uninstall -g eas-cli >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$(npm root -g)/eas-cli" "$(npm root -g)"/.eas-cli-* 2>/dev/null || true
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
command -v eas >/dev/null || { echo "::error::eas-cli installation failed after 3 attempts"; exit 1; }
|
||||||
pip install -r scripts/requirements.txt awscli --quiet
|
pip install -r scripts/requirements.txt awscli --quiet
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -53,6 +62,8 @@ jobs:
|
|||||||
echo "channel=production-admin" >> $GITHUB_OUTPUT
|
echo "channel=production-admin" >> $GITHUB_OUTPUT
|
||||||
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||||
echo "ota_api_url=https://mln-uber.club" >> $GITHUB_OUTPUT
|
echo "ota_api_url=https://mln-uber.club" >> $GITHUB_OUTPUT
|
||||||
|
echo "xavia_url=https://ota-prod.uber-stup.club" >> $GITHUB_OUTPUT
|
||||||
|
echo "xavia_key=${{ secrets.XAVIA_KEY_ADMIN_PROD }}" >> $GITHUB_OUTPUT
|
||||||
echo "apk_name=admin-panel-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
echo "apk_name=admin-panel-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
||||||
echo "message=Production update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
echo "message=Production update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
@@ -60,6 +71,8 @@ jobs:
|
|||||||
echo "channel=pre-prod-admin" >> $GITHUB_OUTPUT
|
echo "channel=pre-prod-admin" >> $GITHUB_OUTPUT
|
||||||
echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||||
echo "ota_api_url=https://5.181.0.112.nip.io" >> $GITHUB_OUTPUT
|
echo "ota_api_url=https://5.181.0.112.nip.io" >> $GITHUB_OUTPUT
|
||||||
|
echo "xavia_url=https://ota-preprod.uber-stup.club" >> $GITHUB_OUTPUT
|
||||||
|
echo "xavia_key=${{ secrets.XAVIA_KEY_ADMIN_PREPROD }}" >> $GITHUB_OUTPUT
|
||||||
echo "apk_name=admin-panel-pre-prod-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
echo "apk_name=admin-panel-pre-prod-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
||||||
echo "message=Pre-prod update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
echo "message=Pre-prod update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
@@ -70,6 +83,19 @@ jobs:
|
|||||||
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID }}"' app.json > app.tmp.json
|
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID }}"' app.json > app.tmp.json
|
||||||
mv app.tmp.json app.json
|
mv app.tmp.json app.json
|
||||||
|
|
||||||
|
- name: Select code signing certificate
|
||||||
|
# certs/certificate.pem (committé) correspond à la clé de signature
|
||||||
|
# du serveur OTA de production ; le serveur pre-prod signe avec une
|
||||||
|
# clé différente (PRIVATE_KEY_PREPROD côté ota-uber), donc les builds
|
||||||
|
# pre-prod doivent embarquer certs/certificate-preprod.pem à la place,
|
||||||
|
# sous peine de voir toute MAJ OTA rejetée silencieusement (signature
|
||||||
|
# invalide) sur ce canal.
|
||||||
|
working-directory: frontend-admin
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
|
||||||
|
cp certs/certificate-preprod.pem certs/certificate.pem
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Restore Gradle cache (RustFS)
|
- name: Restore Gradle cache (RustFS)
|
||||||
env:
|
env:
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||||
@@ -83,7 +109,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.api_url }}
|
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.api_url }}
|
||||||
EXPO_PUBLIC_UPDATE_URL: ${{ secrets.XAVIA_API_URL }}
|
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
|
||||||
EAS_BUILD_NO_EXPO_GO_WARNING: true
|
EAS_BUILD_NO_EXPO_GO_WARNING: true
|
||||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=2"
|
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=2"
|
||||||
@@ -118,14 +144,15 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.ota_api_url }}
|
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.ota_api_url }}
|
||||||
EXPO_PUBLIC_UPDATE_URL: ${{ secrets.XAVIA_API_URL }}
|
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
|
||||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||||
run: |
|
run: |
|
||||||
RUNTIME_VERSION=$(jq -r '.expo.version' app.json)
|
RUNTIME_VERSION=$(jq -r '.expo.runtimeVersion' app.json)
|
||||||
npx expo export --platform android --output-dir dist
|
npx expo export --platform android --output-dir dist
|
||||||
|
npx expo config --json > dist/expoconfig.json
|
||||||
cd dist && zip -r ../bundle.zip . && cd ..
|
cd dist && zip -r ../bundle.zip . && cd ..
|
||||||
curl -X POST "${{ secrets.XAVIA_API_URL }}/api/upload" \
|
curl -X POST "${{ steps.config.outputs.xavia_url }}/api/upload" \
|
||||||
-H "Authorization: Bearer ${{ secrets.XAVIA_API_KEY }}" \
|
-H "Authorization: Bearer ${{ steps.config.outputs.xavia_key }}" \
|
||||||
-F "file=@bundle.zip" \
|
-F "file=@bundle.zip" \
|
||||||
-F "runtimeVersion=$RUNTIME_VERSION" \
|
-F "runtimeVersion=$RUNTIME_VERSION" \
|
||||||
-F "channel=${{ steps.config.outputs.channel }}" \
|
-F "channel=${{ steps.config.outputs.channel }}" \
|
||||||
|
|||||||
@@ -34,7 +34,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Install EAS CLI & tooling
|
- name: Install EAS CLI & tooling
|
||||||
run: |
|
run: |
|
||||||
npm install -g eas-cli
|
n=0
|
||||||
|
until [ $n -ge 3 ]; do
|
||||||
|
npm install -g eas-cli && break
|
||||||
|
n=$((n + 1))
|
||||||
|
echo "npm install -g eas-cli failed (attempt $n/3), cleaning up and retrying in 5s..."
|
||||||
|
npm uninstall -g eas-cli >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$(npm root -g)/eas-cli" "$(npm root -g)"/.eas-cli-* 2>/dev/null || true
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
command -v eas >/dev/null || { echo "::error::eas-cli installation failed after 3 attempts"; exit 1; }
|
||||||
pip install -r scripts/requirements.txt awscli --quiet
|
pip install -r scripts/requirements.txt awscli --quiet
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -53,6 +62,8 @@ jobs:
|
|||||||
echo "channel=production-client" >> $GITHUB_OUTPUT
|
echo "channel=production-client" >> $GITHUB_OUTPUT
|
||||||
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||||
echo "ota_api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
echo "ota_api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||||
|
echo "xavia_url=https://ota-mobile-prod.uber-stup.club" >> $GITHUB_OUTPUT
|
||||||
|
echo "xavia_key=${{ secrets.XAVIA_KEY_MOBILE_PROD }}" >> $GITHUB_OUTPUT
|
||||||
echo "apk_name=mobile-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
echo "apk_name=mobile-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
||||||
echo "message=Production update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
echo "message=Production update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
@@ -60,6 +71,8 @@ jobs:
|
|||||||
echo "channel=pre-prod-client" >> $GITHUB_OUTPUT
|
echo "channel=pre-prod-client" >> $GITHUB_OUTPUT
|
||||||
echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||||
echo "ota_api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
echo "ota_api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||||
|
echo "xavia_url=https://ota-mobile-preprod.uber-stup.club" >> $GITHUB_OUTPUT
|
||||||
|
echo "xavia_key=${{ secrets.XAVIA_KEY_MOBILE_PREPROD }}" >> $GITHUB_OUTPUT
|
||||||
echo "apk_name=mobile-pre-prod-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
echo "apk_name=mobile-pre-prod-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
||||||
echo "message=Pre-prod update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
echo "message=Pre-prod update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
@@ -70,6 +83,19 @@ jobs:
|
|||||||
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID_CLIENT }}"' app.json > app.tmp.json
|
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID_CLIENT }}"' app.json > app.tmp.json
|
||||||
mv app.tmp.json app.json
|
mv app.tmp.json app.json
|
||||||
|
|
||||||
|
- name: Select code signing certificate
|
||||||
|
# certs/certificate.pem (committé) correspond à la clé de signature
|
||||||
|
# du serveur OTA mobile de production ; le serveur pre-prod signe
|
||||||
|
# avec une clé différente (PRIVATE_KEY_MOBILE_PREPROD côté ota-uber),
|
||||||
|
# donc les builds pre-prod doivent embarquer
|
||||||
|
# certs/certificate-preprod.pem à la place, sous peine de voir toute
|
||||||
|
# MAJ OTA rejetée silencieusement (signature invalide) sur ce canal.
|
||||||
|
working-directory: mobile
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
|
||||||
|
cp certs/certificate-preprod.pem certs/certificate.pem
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Restore Gradle cache (RustFS)
|
- name: Restore Gradle cache (RustFS)
|
||||||
env:
|
env:
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||||
@@ -83,10 +109,10 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.api_url }}
|
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.api_url }}
|
||||||
EXPO_PUBLIC_UPDATE_URL: ${{ secrets.XAVIA_API_URL }}
|
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
|
||||||
EAS_BUILD_NO_EXPO_GO_WARNING: true
|
EAS_BUILD_NO_EXPO_GO_WARNING: true
|
||||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=2"
|
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=2 -Dorg.gradle.internal.repository.max.retries=10 -Dorg.gradle.internal.repository.initial.backoff.ms=1000"
|
||||||
JAVA_TOOL_OPTIONS: "-Xmx3g"
|
JAVA_TOOL_OPTIONS: "-Xmx3g"
|
||||||
run: eas build --platform android --profile ${{ steps.config.outputs.profile }} --local --non-interactive
|
run: eas build --platform android --profile ${{ steps.config.outputs.profile }} --local --non-interactive
|
||||||
|
|
||||||
@@ -118,14 +144,15 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.ota_api_url }}
|
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.ota_api_url }}
|
||||||
EXPO_PUBLIC_UPDATE_URL: ${{ secrets.XAVIA_API_URL }}
|
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
|
||||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||||
run: |
|
run: |
|
||||||
RUNTIME_VERSION=$(jq -r '.expo.version' app.json)
|
RUNTIME_VERSION=$(jq -r '.expo.runtimeVersion' app.json)
|
||||||
npx expo export --platform android --output-dir dist
|
npx expo export --platform android --output-dir dist
|
||||||
|
npx expo config --json > dist/expoconfig.json
|
||||||
cd dist && zip -r ../bundle.zip . && cd ..
|
cd dist && zip -r ../bundle.zip . && cd ..
|
||||||
curl -X POST "${{ secrets.XAVIA_API_URL }}/api/upload" \
|
curl -X POST "${{ steps.config.outputs.xavia_url }}/api/upload" \
|
||||||
-H "Authorization: Bearer ${{ secrets.XAVIA_API_KEY }}" \
|
-H "Authorization: Bearer ${{ steps.config.outputs.xavia_key }}" \
|
||||||
-F "file=@bundle.zip" \
|
-F "file=@bundle.zip" \
|
||||||
-F "runtimeVersion=$RUNTIME_VERSION" \
|
-F "runtimeVersion=$RUNTIME_VERSION" \
|
||||||
-F "channel=${{ steps.config.outputs.channel }}" \
|
-F "channel=${{ steps.config.outputs.channel }}" \
|
||||||
|
|||||||
@@ -62,14 +62,3 @@ jobs:
|
|||||||
tags: xor1234/frontend-mln:${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'latest' || 'pre-prod' }}
|
tags: xor1234/frontend-mln:${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'latest' || 'pre-prod' }}
|
||||||
build-args: |
|
build-args: |
|
||||||
VITE_TOMTOM_API_KEY=${{ secrets.VITE_TOMTOM_API_KEY }}
|
VITE_TOMTOM_API_KEY=${{ secrets.VITE_TOMTOM_API_KEY }}
|
||||||
|
|
||||||
- name: SSH Deploy
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
uses: appleboy/ssh-action@v1
|
|
||||||
with:
|
|
||||||
host: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_HOST_PROD || secrets.SERVER_HOST_PRE_PROD }}
|
|
||||||
username: ${{ secrets.SERVER_USER }}
|
|
||||||
key: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_SSH_KEY_PROD || secrets.SERVER_SSH_KEY_PRE_PROD }}
|
|
||||||
script: |
|
|
||||||
docker compose -f ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.COMPOSE_PATH_PROD || secrets.COMPOSE_PATH_PRE_PROD }} pull frontend
|
|
||||||
docker compose -f ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.COMPOSE_PATH_PROD || secrets.COMPOSE_PATH_PRE_PROD }} up -d --no-deps frontend
|
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ graph TD
|
|||||||
P3["GET /api/v1/products/category/:category"]
|
P3["GET /api/v1/products/category/:category"]
|
||||||
P4["GET /api/v1/categories"]
|
P4["GET /api/v1/categories"]
|
||||||
P5["GET /api/v1/app-settings"]
|
P5["GET /api/v1/app-settings"]
|
||||||
P6["POST /api/v1/webhooks/nowpayments"]
|
P6["POST /api/v1/webhook/nowpayments"]
|
||||||
P7["POST /webhook/telegram"]
|
P7["POST /webhook/telegram"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -3,19 +3,35 @@ package db
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"gestion/models"
|
"gestion/models"
|
||||||
|
"gestion/utils"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (d *Database) CheckAddress(addressByUser *models.Command) error {
|
func (d *Database) CheckAddress(addressByUser *models.Command) error {
|
||||||
var correction models.Address
|
var correction models.Address
|
||||||
result := d.GDB.Where("invalid_address = ?", addressByUser.DeliveryAddress).First(&correction)
|
result := d.GDB.Where("invalid_address = ?", addressByUser.DeliveryAddress).First(&correction)
|
||||||
if result.Error != nil {
|
if result.Error == nil {
|
||||||
if isNotFound(result.Error) {
|
addressByUser.DeliveryAddress = correction.CorrectAddress
|
||||||
return nil
|
return fmt.Errorf("adresse invalide %s", correction.CorrectAddress)
|
||||||
}
|
}
|
||||||
|
if !isNotFound(result.Error) {
|
||||||
return fmt.Errorf("checkAddress: %w", result.Error)
|
return fmt.Errorf("checkAddress: %w", result.Error)
|
||||||
}
|
}
|
||||||
addressByUser.DeliveryAddress = correction.CorrectAddress
|
|
||||||
return fmt.Errorf("Adresse invalide %s", correction.CorrectAddress)
|
// Pas de correspondance exacte — fallback sur une comparaison normalisée
|
||||||
|
// (accents/casse/espaces) pour rattraper les variantes mineures de saisie.
|
||||||
|
corrections, err := d.AllAddress()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
normalizedInput := utils.NormalizeAddress(addressByUser.DeliveryAddress)
|
||||||
|
for _, c := range corrections {
|
||||||
|
if strings.EqualFold(utils.NormalizeAddress(c.InvalidAddress), normalizedInput) {
|
||||||
|
addressByUser.DeliveryAddress = c.CorrectAddress
|
||||||
|
return fmt.Errorf("adresse invalide %s", c.CorrectAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) AddAddress(CorrectAddressByAdmin string, InvalidAddressByAdmin string) error {
|
func (d *Database) AddAddress(CorrectAddressByAdmin string, InvalidAddressByAdmin string) error {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
|
|||||||
|
|
||||||
func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
|
func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
|
||||||
var alerts []models.AlertPolicy
|
var alerts []models.AlertPolicy
|
||||||
if err := d.GDB.Find(&alerts).Error; err != nil {
|
if err := d.GDB.Order("created_at DESC").Limit(500).Find(&alerts).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return alerts, nil
|
return alerts, nil
|
||||||
@@ -65,7 +65,7 @@ func (d *Database) ActivateAlert(id int) error {
|
|||||||
|
|
||||||
func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
|
func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
|
||||||
var alerts []models.AlertPolicy
|
var alerts []models.AlertPolicy
|
||||||
if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Find(&alerts).Error; err != nil {
|
if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Limit(100).Find(&alerts).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return alerts, nil
|
return alerts, nil
|
||||||
@@ -73,7 +73,7 @@ func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
|
|||||||
|
|
||||||
func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) {
|
func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) {
|
||||||
var alerts []models.AlertPolicy
|
var alerts []models.AlertPolicy
|
||||||
if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Find(&alerts).Error; err != nil {
|
if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Limit(200).Find(&alerts).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return alerts, nil
|
return alerts, nil
|
||||||
|
|||||||
@@ -7,7 +7,25 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetProductPrice récupère le prix réel d'un produit pour une quantité donnée (legacy)
|
// GetActiveProductPrice retourne le prix catalogue actif pour un produit et
|
||||||
|
// une quantité donnés (palier le plus proche ≤ quantity, cf. même requête que
|
||||||
|
// AddToBasket) — utilisé pour calculer le prix effectif d'une récompense
|
||||||
|
// "half_price_product" (50% de ce prix).
|
||||||
|
func (d *Database) GetActiveProductPrice(productID int, quantity float64) (float64, error) {
|
||||||
|
var result struct {
|
||||||
|
Price float64 `gorm:"column:price"`
|
||||||
|
}
|
||||||
|
err := d.GDB.Raw(`
|
||||||
|
SELECT price FROM product_prices
|
||||||
|
WHERE product_id = ? AND quantity <= ? AND active_price = true
|
||||||
|
ORDER BY quantity DESC LIMIT 1`,
|
||||||
|
productID, quantity).Scan(&result).Error
|
||||||
|
if err != nil || result.Price == 0 {
|
||||||
|
return 0, fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
|
||||||
|
}
|
||||||
|
return result.Price, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
|
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
|
||||||
var result struct {
|
var result struct {
|
||||||
Price float64 `gorm:"column:price"`
|
Price float64 `gorm:"column:price"`
|
||||||
@@ -43,37 +61,51 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err
|
|||||||
return baskets, nil
|
return baskets, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddRewardsToBasket ajoute plusieurs produits récompense au panier (prix = 0, is_reward = true).
|
// AddRewardsToBasket ajoute plusieurs produits récompense au panier (is_reward = true),
|
||||||
|
// au prix fourni par l'appelant dans chaque RewardItem.Price (0 pour un produit offert,
|
||||||
|
// ou le prix effectif déjà calculé pour une remise — voir handlers/points.go).
|
||||||
// Supprime les anciens items récompense avant d'insérer les nouveaux.
|
// Supprime les anciens items récompense avant d'insérer les nouveaux.
|
||||||
// Pas de vérification de stock — les récompenses sont gérées par l'admin.
|
// Pas de vérification de stock — les récompenses sont gérées par l'admin.
|
||||||
func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
|
func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
|
||||||
var baskets []models.Panier
|
var baskets []models.Panier
|
||||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var err error
|
||||||
|
baskets, err = addRewardsToBasketTx(tx, username, items, poolKey)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return baskets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addRewardsToBasketTx contient la logique de remplacement des articles
|
||||||
|
// récompense, factorisée pour être appelée soit seule (AddRewardsToBasket),
|
||||||
|
// soit dans la même transaction qu'une autre opération (voir
|
||||||
|
// ClaimPoolRewardAndAddToBasket) afin de garantir qu'une récompense n'est
|
||||||
|
// jamais consommée sans que son produit soit effectivement livré.
|
||||||
|
func addRewardsToBasketTx(tx *gorm.DB, username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
|
||||||
// Supprimer tout article récompense existant (remplacement)
|
// Supprimer tout article récompense existant (remplacement)
|
||||||
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username)
|
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username)
|
||||||
|
var baskets []models.Panier
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if item.ProductID <= 0 || item.Quantity <= 0 {
|
if item.ProductID <= 0 || item.Quantity <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var productName string
|
var productName string
|
||||||
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" {
|
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" {
|
||||||
return fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID)
|
return nil, fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID)
|
||||||
}
|
}
|
||||||
var basket models.Panier
|
var basket models.Panier
|
||||||
if err := tx.Raw(`
|
if err := tx.Raw(`
|
||||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
|
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
|
||||||
VALUES (?, ?, ?, 0, true, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, true, ?, CURRENT_TIMESTAMP)
|
||||||
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
|
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
|
||||||
username, item.ProductID, item.Quantity, poolKey).Scan(&basket).Error; err != nil {
|
username, item.ProductID, item.Quantity, item.Price, poolKey).Scan(&basket).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
baskets = append(baskets, basket)
|
baskets = append(baskets, basket)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return baskets, nil
|
return baskets, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,12 +130,12 @@ func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
|
|||||||
func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) {
|
func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) {
|
||||||
var basket models.Panier
|
var basket models.Panier
|
||||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
var currentStock float64
|
var productInfo struct {
|
||||||
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, productID).Scan(¤tStock).Error; err != nil {
|
Stock float64 `gorm:"column:stock"`
|
||||||
return fmt.Errorf("erreur lecture stock: %w", err)
|
Category string `gorm:"column:category"`
|
||||||
}
|
}
|
||||||
if currentStock < quantity {
|
if err := tx.Raw(`SELECT stock, category FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&productInfo).Error; err != nil {
|
||||||
return fmt.Errorf("stock insuffisant")
|
return fmt.Errorf("erreur lecture stock: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var priceResult struct {
|
var priceResult struct {
|
||||||
@@ -116,28 +148,51 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
|
|||||||
productID, quantity).Scan(&priceResult).Error; err != nil || priceResult.Price == 0 {
|
productID, quantity).Scan(&priceResult).Error; err != nil || priceResult.Price == 0 {
|
||||||
return fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
|
return fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
|
||||||
}
|
}
|
||||||
|
// Une promotion active pour ce produit/quantité/catégorie s'applique
|
||||||
|
// automatiquement au prix facturé — indépendamment des points de
|
||||||
|
// fidélité (contrairement aux récompenses par palier). Le montant
|
||||||
|
// économisé est conservé (promoDiscount) pour les statistiques
|
||||||
|
// admin, indépendamment de la config de promo courante au moment où
|
||||||
|
// ces stats seront consultées.
|
||||||
|
var promoDiscount float64
|
||||||
|
if discounted, ok := d.ApplyPromotionToPrice(productID, productInfo.Category, quantity, priceResult.Price); ok {
|
||||||
|
promoDiscount = priceResult.Price - discounted
|
||||||
|
priceResult.Price = discounted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offre "achetez X, Y offert" : le client reçoit une quantité
|
||||||
|
// supplémentaire du même produit, gratuite, sans changer le prix déjà
|
||||||
|
// calculé sur la quantité demandée — la quantité livrée/décomptée du
|
||||||
|
// stock est donc supérieure à la quantité facturée.
|
||||||
|
freeQuantity := d.ResolveFreeGiftQuantity(productID, productInfo.Category, quantity)
|
||||||
|
deliveredQuantity := quantity + freeQuantity
|
||||||
|
|
||||||
|
if productInfo.Stock < deliveredQuantity {
|
||||||
|
return fmt.Errorf("stock insuffisant")
|
||||||
|
}
|
||||||
|
|
||||||
var existing struct {
|
var existing struct {
|
||||||
ID int `gorm:"column:id"`
|
ID int `gorm:"column:id"`
|
||||||
Quantity float64 `gorm:"column:quantity"`
|
Quantity float64 `gorm:"column:quantity"`
|
||||||
Price float64 `gorm:"column:price"`
|
Price float64 `gorm:"column:price"`
|
||||||
|
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||||
}
|
}
|
||||||
// Chercher uniquement un item normal (non-récompense) pour ce produit
|
// Chercher uniquement un item normal (non-récompense) pour ce produit
|
||||||
tx.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
|
tx.Raw(`SELECT id, quantity, price, promo_discount FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
|
||||||
username, productID).Scan(&existing)
|
username, productID).Scan(&existing)
|
||||||
|
|
||||||
if existing.ID != 0 {
|
if existing.ID != 0 {
|
||||||
return tx.Raw(`
|
return tx.Raw(`
|
||||||
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
|
UPDATE baskets SET quantity = ?, price = ?, promo_discount = ?, created_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
|
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
|
||||||
existing.Quantity+quantity, existing.Price+priceResult.Price,
|
existing.Quantity+deliveredQuantity, existing.Price+priceResult.Price,
|
||||||
existing.ID).Scan(&basket).Error
|
existing.PromoDiscount+promoDiscount, existing.ID).Scan(&basket).Error
|
||||||
}
|
}
|
||||||
return tx.Raw(`
|
return tx.Raw(`
|
||||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
|
INSERT INTO baskets (username, product_id, quantity, price, is_reward, promo_discount, created_at)
|
||||||
VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, false, ?, CURRENT_TIMESTAMP)
|
||||||
RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
|
RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
|
||||||
username, productID, quantity, priceResult.Price).Scan(&basket).Error
|
username, productID, deliveredQuantity, priceResult.Price, promoDiscount).Scan(&basket).Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -164,35 +219,6 @@ func (d *Database) ClearBasket(username string) error {
|
|||||||
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
|
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearBasketOnCheckout décrémente le stock pour chaque article du panier puis vide le panier.
|
|
||||||
// C'est ici que le stock est effectivement consommé, au moment de la validation de la commande.
|
|
||||||
func (d *Database) ClearBasketOnCheckout(username string) error {
|
|
||||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
|
||||||
var items []struct {
|
|
||||||
ProductID int `gorm:"column:product_id"`
|
|
||||||
Quantity float64 `gorm:"column:quantity"`
|
|
||||||
}
|
|
||||||
if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&items).Error; err != nil {
|
|
||||||
return fmt.Errorf("erreur lecture panier: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range items {
|
|
||||||
var currentStock float64
|
|
||||||
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(¤tStock).Error; err != nil {
|
|
||||||
return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err)
|
|
||||||
}
|
|
||||||
if currentStock < item.Quantity {
|
|
||||||
return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID)
|
|
||||||
}
|
|
||||||
if err := tx.Exec(`UPDATE products SET stock = stock - ? WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
|
|
||||||
return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
|
func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
|
||||||
var username string
|
var username string
|
||||||
err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error
|
err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error
|
||||||
|
|||||||
@@ -78,9 +78,14 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
|
|||||||
|
|
||||||
if err := tx.Exec(`
|
if err := tx.Exec(`
|
||||||
UPDATE products p
|
UPDATE products p
|
||||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
|
||||||
FROM command_items ci
|
FROM (
|
||||||
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
|
SELECT product_id, SUM(quantite) AS total_qty
|
||||||
|
FROM command_items
|
||||||
|
WHERE command_id = ?
|
||||||
|
GROUP BY product_id
|
||||||
|
) agg
|
||||||
|
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
|
||||||
return fmt.Errorf("erreur remboursement stock: %w", err)
|
return fmt.Errorf("erreur remboursement stock: %w", err)
|
||||||
}
|
}
|
||||||
log.Printf("✅ [CancelAtomic] Stock remboursé")
|
log.Printf("✅ [CancelAtomic] Stock remboursé")
|
||||||
@@ -145,20 +150,18 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
|
|||||||
return penalty, nil
|
return penalty, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckCommandETAExistsAndValid vérifie si une ETA RÉELLE existe (> 0 minutes, non expirée)
|
|
||||||
func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
|
func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
|
||||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||||
|
|
||||||
etaMinutesStr, err := Redis.Get(RedisCtx, etaKey).Result()
|
etaData, err := Redis.HGetAll(RedisCtx, etaKey).Result()
|
||||||
if err != nil {
|
if err != nil || len(etaData) == 0 {
|
||||||
log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID)
|
log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
var etaMinutes int
|
var etaMinutes int
|
||||||
_, err = fmt.Sscanf(etaMinutesStr, "%d", &etaMinutes)
|
if _, err := fmt.Sscanf(etaData["eta_minutes"], "%d", &etaMinutes); err != nil || etaMinutes <= 0 {
|
||||||
if err != nil || etaMinutes <= 0 {
|
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaData["eta_minutes"])
|
||||||
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaMinutesStr)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,13 +195,18 @@ func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) er
|
|||||||
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username)
|
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username)
|
||||||
|
|
||||||
// ✅ Ne restitue le stock QUE si pas déjà fait
|
// ✅ Ne restitue le stock QUE si pas déjà fait
|
||||||
stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved"
|
stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved" || cmdResult.Status == "livre"
|
||||||
if !stockAlreadyRestored {
|
if !stockAlreadyRestored {
|
||||||
if err := tx.Exec(`
|
if err := tx.Exec(`
|
||||||
UPDATE products p
|
UPDATE products p
|
||||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
|
||||||
FROM command_items ci
|
FROM (
|
||||||
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
|
SELECT product_id, SUM(quantite) AS total_qty
|
||||||
|
FROM command_items
|
||||||
|
WHERE command_id = ?
|
||||||
|
GROUP BY product_id
|
||||||
|
) agg
|
||||||
|
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
|
||||||
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
|
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("✅ [DeleteAtomic] Stock remboursé (statut: %s)", cmdResult.Status)
|
log.Printf("✅ [DeleteAtomic] Stock remboursé (statut: %s)", cmdResult.Status)
|
||||||
@@ -316,12 +324,89 @@ func (d *Database) AddClientPenalty(username string, points int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) RestoreCommandStock(commandID int) error {
|
// CancelCommandByAdminAtomic transitionne une commande vers 'cancelled' depuis le
|
||||||
|
// panel admin/cabine de façon atomique (verrou FOR UPDATE sur la commande) : le
|
||||||
|
// remboursement de stock et le changement de statut se font dans la même
|
||||||
|
// transaction, conditionnés à une lecture du statut précédent faite sous verrou.
|
||||||
|
// Corrige un double remboursement possible sur double-tap/appel concurrent —
|
||||||
|
// l'ancien code (RestoreCommandStock + UpdateCommandStatus appelés séparément
|
||||||
|
// par le handler) lisait le statut puis restaurait le stock hors transaction,
|
||||||
|
// laissant une fenêtre où deux requêtes concurrentes lisaient toutes les deux
|
||||||
|
// "pas encore annulée" et remboursaient chacune le stock.
|
||||||
|
func (d *Database) CancelCommandByAdminAtomic(commandID int) error {
|
||||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
return tx.Exec(`
|
var prevStatus string
|
||||||
|
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if prevStatus == "" {
|
||||||
|
return fmt.Errorf("commande non trouvée")
|
||||||
|
}
|
||||||
|
|
||||||
|
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
|
||||||
|
if !slices.Contains(noRestoreStatuses, prevStatus) {
|
||||||
|
if err := tx.Exec(`
|
||||||
UPDATE products p
|
UPDATE products p
|
||||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
|
||||||
FROM command_items ci
|
FROM (
|
||||||
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error
|
SELECT product_id, SUM(quantite) AS total_qty
|
||||||
|
FROM command_items
|
||||||
|
WHERE command_id = ?
|
||||||
|
GROUP BY product_id
|
||||||
|
) agg
|
||||||
|
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
|
||||||
|
return fmt.Errorf("erreur remboursement stock: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP WHERE id = ?`, commandID).Error; err != nil {
|
||||||
|
return fmt.Errorf("erreur mise à jour statut: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CancelDeliveryByLivreurAtomic annule une commande côté livreur et restaure le stock
|
||||||
|
// de manière atomique (verrou FOR UPDATE + transition conditionnée à l'ancien statut).
|
||||||
|
// Idempotent : si la commande est déjà annulée, ne touche pas au stock et renvoie
|
||||||
|
// alreadyCancelled=true — évite un remboursement en double en cas de double appel
|
||||||
|
// (double-tap, retry réseau, ou commande déjà annulée par un autre canal).
|
||||||
|
func (d *Database) CancelDeliveryByLivreurAtomic(commandID int) (alreadyCancelled bool, prevStatus string, err error) {
|
||||||
|
err = d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if e := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if prevStatus == "" {
|
||||||
|
return fmt.Errorf("commande non trouvée")
|
||||||
|
}
|
||||||
|
if prevStatus == "cancelled" {
|
||||||
|
alreadyCancelled = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tx.Exec(`
|
||||||
|
UPDATE commandes SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND status = ?`, commandID, prevStatus)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("commande déjà modifiée par une autre requête")
|
||||||
|
}
|
||||||
|
|
||||||
|
if e := tx.Exec(`
|
||||||
|
UPDATE products p
|
||||||
|
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
|
||||||
|
FROM (
|
||||||
|
SELECT product_id, SUM(quantite) AS total_qty
|
||||||
|
FROM command_items
|
||||||
|
WHERE command_id = ?
|
||||||
|
GROUP BY product_id
|
||||||
|
) agg
|
||||||
|
WHERE agg.product_id = p.id`, commandID).Error; e != nil {
|
||||||
|
return fmt.Errorf("erreur remboursement stock: %w", e)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Category struct {
|
|||||||
Name string `json:"name" gorm:"column:name"`
|
Name string `json:"name" gorm:"column:name"`
|
||||||
Color string `json:"color" gorm:"column:color"`
|
Color string `json:"color" gorm:"column:color"`
|
||||||
IsComingSoon bool `json:"is_coming_soon" gorm:"column:is_coming_soon"`
|
IsComingSoon bool `json:"is_coming_soon" gorm:"column:is_coming_soon"`
|
||||||
|
Position int `json:"position" gorm:"column:position"`
|
||||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ func ValidateCategoryColor(color string) error {
|
|||||||
|
|
||||||
func (d *Database) GetAllCategories() ([]Category, error) {
|
func (d *Database) GetAllCategories() ([]Category, error) {
|
||||||
var categories []Category
|
var categories []Category
|
||||||
if err := d.GDB.Order("name ASC").Find(&categories).Error; err != nil {
|
if err := d.GDB.Order("position ASC, name ASC").Find(&categories).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if categories == nil {
|
if categories == nil {
|
||||||
@@ -43,7 +44,9 @@ func (d *Database) CreateCategory(name, color string, isComingSoon bool) (*Categ
|
|||||||
if color == "" {
|
if color == "" {
|
||||||
color = "#7c3aed"
|
color = "#7c3aed"
|
||||||
}
|
}
|
||||||
c := Category{Name: name, Color: color, IsComingSoon: isComingSoon}
|
var maxPos int
|
||||||
|
d.GDB.Model(&Category{}).Select("COALESCE(MAX(position), 0)").Scan(&maxPos)
|
||||||
|
c := Category{Name: name, Color: color, IsComingSoon: isComingSoon, Position: maxPos + 1}
|
||||||
if err := d.GDB.Create(&c).Error; err != nil {
|
if err := d.GDB.Create(&c).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -82,6 +85,18 @@ func (d *Database) DeleteCategory(id int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReorderCategories met à jour les positions selon l'ordre du tableau d'IDs fourni.
|
||||||
|
func (d *Database) ReorderCategories(ids []int) error {
|
||||||
|
tx := d.GDB.Begin()
|
||||||
|
for i, id := range ids {
|
||||||
|
if err := tx.Model(&Category{}).Where("id = ?", id).Update("position", i+1).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit().Error
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Database) CategoryExists(name string) (bool, error) {
|
func (d *Database) CategoryExists(name string) (bool, error) {
|
||||||
var count int64
|
var count int64
|
||||||
err := d.GDB.Model(&Category{}).Where("name = ?", name).Count(&count).Error
|
err := d.GDB.Model(&Category{}).Where("name = ?", name).Count(&count).Error
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func (d *Database) CreateClient(client *models.Client) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetClientByID récupère un client par son ID
|
||||||
func (d *Database) GetClientByID(id int) (*models.Client, error) {
|
func (d *Database) GetClientByID(id int) (*models.Client, error) {
|
||||||
var row struct {
|
var row struct {
|
||||||
ID int `gorm:"column:id"`
|
ID int `gorm:"column:id"`
|
||||||
@@ -75,6 +76,7 @@ func (d *Database) GetClientByID(id int) (*models.Client, error) {
|
|||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllClients récupère tous les clients
|
||||||
func (d *Database) GetAllClients() ([]*models.Client, error) {
|
func (d *Database) GetAllClients() ([]*models.Client, error) {
|
||||||
var rows []struct {
|
var rows []struct {
|
||||||
ID int `gorm:"column:id"`
|
ID int `gorm:"column:id"`
|
||||||
@@ -290,6 +292,28 @@ func (d *Database) GetClientByTelephone(telephone string) (*models.Client, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetClientByUsername récupère un client par son username
|
// GetClientByUsername récupère un client par son username
|
||||||
|
// GetClientsByUsernames charge plusieurs clients en une seule requête.
|
||||||
|
// Retourne map[username]*Client ; les usernames sans correspondance sont absents de la map.
|
||||||
|
func (d *Database) GetClientsByUsernames(usernames []string) (map[string]*models.Client, error) {
|
||||||
|
result := make(map[string]*models.Client, len(usernames))
|
||||||
|
if len(usernames) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
ID int `gorm:"column:id"`
|
||||||
|
Username string `gorm:"column:username"`
|
||||||
|
Nom string `gorm:"column:nom"`
|
||||||
|
Prenom string `gorm:"column:prenom"`
|
||||||
|
}
|
||||||
|
if err := d.GDB.Raw(`SELECT id, username, nom, prenom FROM clients WHERE username IN ?`, usernames).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
result[r.Username] = &models.Client{ID: r.ID, Username: r.Username, Nom: r.Nom, Prenom: r.Prenom}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Database) GetClientByUsername(username string) (*models.Client, error) {
|
func (d *Database) GetClientByUsername(username string) (*models.Client, error) {
|
||||||
var row struct {
|
var row struct {
|
||||||
ID int `gorm:"column:id"`
|
ID int `gorm:"column:id"`
|
||||||
@@ -713,12 +737,12 @@ func (d *Database) GetClientPointsAndRewards(username string) (pointsExtra map[s
|
|||||||
return pointsExtra, pointsRedeemed, nil
|
return pointsExtra, pointsRedeemed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaimPoolReward réclame une récompense pour un pool donné si le client a assez de points.
|
// claimPoolRewardTx vérifie l'éligibilité et consomme une récompense pour un
|
||||||
// Retourne le nombre de récompenses disponibles restantes après la réclamation.
|
// pool donné, dans la transaction fournie — factorisée pour être appelée
|
||||||
func (d *Database) ClaimPoolReward(username, poolKey string, threshold int) (remainingAvailable int, err error) {
|
// seule (ClaimPoolReward) ou combinée avec la livraison du produit dans la
|
||||||
var points, redeemed int
|
// même transaction (ClaimPoolRewardAndAddToBasket), afin qu'une récompense
|
||||||
|
// ne soit jamais consommée sans que son produit soit effectivement livré.
|
||||||
err = d.GDB.Transaction(func(tx *gorm.DB) error {
|
func claimPoolRewardTx(tx *gorm.DB, username, poolKey string, threshold int) (remainingAvailable int, err error) {
|
||||||
var row struct {
|
var row struct {
|
||||||
Points int `gorm:"column:pts"`
|
Points int `gorm:"column:pts"`
|
||||||
Redeemed int `gorm:"column:redeemed"`
|
Redeemed int `gorm:"column:redeemed"`
|
||||||
@@ -729,18 +753,16 @@ func (d *Database) ClaimPoolReward(username, poolKey string, threshold int) (rem
|
|||||||
COALESCE((points_redeemed->>?)::int, 0) as redeemed
|
COALESCE((points_redeemed->>?)::int, 0) as redeemed
|
||||||
FROM clients WHERE username = ? FOR UPDATE`,
|
FROM clients WHERE username = ? FOR UPDATE`,
|
||||||
poolKey, poolKey, username).Scan(&row).Error; err != nil {
|
poolKey, poolKey, username).Scan(&row).Error; err != nil {
|
||||||
return fmt.Errorf("erreur lecture: %w", err)
|
return 0, fmt.Errorf("erreur lecture: %w", err)
|
||||||
}
|
}
|
||||||
points = row.Points
|
|
||||||
redeemed = row.Redeemed
|
|
||||||
|
|
||||||
earned := points / threshold
|
earned := row.Points / threshold
|
||||||
available := earned - redeemed
|
available := earned - row.Redeemed
|
||||||
if available <= 0 {
|
if available <= 0 {
|
||||||
return fmt.Errorf("pas de récompense disponible pour ce pool")
|
return 0, fmt.Errorf("pas de récompense disponible pour ce pool")
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Exec(`
|
if err := tx.Exec(`
|
||||||
UPDATE clients
|
UPDATE clients
|
||||||
SET points_redeemed = jsonb_set(
|
SET points_redeemed = jsonb_set(
|
||||||
COALESCE(points_redeemed, '{}'::jsonb),
|
COALESCE(points_redeemed, '{}'::jsonb),
|
||||||
@@ -748,17 +770,48 @@ func (d *Database) ClaimPoolReward(username, poolKey string, threshold int) (rem
|
|||||||
to_jsonb(COALESCE((points_redeemed->>?)::int, 0) + 1)
|
to_jsonb(COALESCE((points_redeemed->>?)::int, 0) + 1)
|
||||||
), updated_at = CURRENT_TIMESTAMP
|
), updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE username = ?`,
|
WHERE username = ?`,
|
||||||
poolKey, poolKey, username).Error
|
poolKey, poolKey, username).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return earned - (row.Redeemed + 1), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimPoolReward réclame une récompense pour un pool donné si le client a assez de points.
|
||||||
|
// Retourne le nombre de récompenses disponibles restantes après la réclamation.
|
||||||
|
func (d *Database) ClaimPoolReward(username, poolKey string, threshold int) (remainingAvailable int, err error) {
|
||||||
|
err = d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var err error
|
||||||
|
remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold)
|
||||||
|
return err
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
earned := points / threshold
|
|
||||||
remainingAvailable = earned - (redeemed + 1)
|
|
||||||
return remainingAvailable, nil
|
return remainingAvailable, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *Database) ClaimPoolRewardAndAddToBasket(username, poolKey string, threshold int, items []models.RewardItem) (remainingAvailable int, added []models.Panier, err error) {
|
||||||
|
err = d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var err error
|
||||||
|
remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(items) > 0 {
|
||||||
|
added, err = addRewardsToBasketTx(tx, username, items, poolKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
return remainingAvailable, added, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ResetClientRedeemed remet à zéro les récompenses réclamées (admin).
|
// ResetClientRedeemed remet à zéro les récompenses réclamées (admin).
|
||||||
func (d *Database) ResetClientRedeemed(username, poolKey string) error {
|
func (d *Database) ResetClientRedeemed(username, poolKey string) error {
|
||||||
if poolKey != "" {
|
if poolKey != "" {
|
||||||
|
|||||||
@@ -6,8 +6,38 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// commandItemFull mappe toutes les colonnes de command_items pour les insertions batch avec infos client.
|
||||||
|
type commandItemFull struct {
|
||||||
|
CommandID int `gorm:"column:command_id"`
|
||||||
|
Produit string `gorm:"column:produit"`
|
||||||
|
ProductID int `gorm:"column:product_id"`
|
||||||
|
Quantite float64 `gorm:"column:quantite"`
|
||||||
|
Prix float64 `gorm:"column:prix"`
|
||||||
|
IsReward bool `gorm:"column:is_reward"`
|
||||||
|
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||||
|
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||||
|
ClientUsername string `gorm:"column:client_username"`
|
||||||
|
ClientNom string `gorm:"column:client_nom"`
|
||||||
|
ClientPrenom string `gorm:"column:client_prenom"`
|
||||||
|
ClientTelephone string `gorm:"column:client_telephone"`
|
||||||
|
DeliveryAddress string `gorm:"column:delivery_address"`
|
||||||
|
Status string `gorm:"column:status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (commandItemFull) TableName() string { return "command_items" }
|
||||||
|
|
||||||
|
// InsertCommandItemsBatch insère plusieurs items en une seule requête.
|
||||||
|
func (d *Database) InsertCommandItemsBatch(items []commandItemFull) error {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return d.GDB.Create(&items).Error
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// VALIDATION HELPERS
|
// VALIDATION HELPERS
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -203,6 +233,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
|||||||
ProductID *int64 `gorm:"column:product_id"`
|
ProductID *int64 `gorm:"column:product_id"`
|
||||||
Quantite float64 `gorm:"column:quantite"`
|
Quantite float64 `gorm:"column:quantite"`
|
||||||
Prix float64 `gorm:"column:prix"`
|
Prix float64 `gorm:"column:prix"`
|
||||||
|
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||||
IsReward bool `gorm:"column:is_reward"`
|
IsReward bool `gorm:"column:is_reward"`
|
||||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||||
ClientUsername string `gorm:"column:client_username"`
|
ClientUsername string `gorm:"column:client_username"`
|
||||||
@@ -232,6 +263,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
|||||||
ci.product_id,
|
ci.product_id,
|
||||||
ci.quantite,
|
ci.quantite,
|
||||||
ci.prix,
|
ci.prix,
|
||||||
|
ci.promo_discount,
|
||||||
ci.is_reward,
|
ci.is_reward,
|
||||||
ci.reward_pool_key,
|
ci.reward_pool_key,
|
||||||
ci.client_username,
|
ci.client_username,
|
||||||
@@ -280,6 +312,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
|||||||
"product_id": productIDValue,
|
"product_id": productIDValue,
|
||||||
"quantite": row.Quantite,
|
"quantite": row.Quantite,
|
||||||
"prix": row.Prix,
|
"prix": row.Prix,
|
||||||
|
"promo_discount": row.PromoDiscount,
|
||||||
"is_reward": row.IsReward,
|
"is_reward": row.IsReward,
|
||||||
"reward_pool_key": row.RewardPoolKey,
|
"reward_pool_key": row.RewardPoolKey,
|
||||||
"client_username": row.ClientUsername,
|
"client_username": row.ClientUsername,
|
||||||
@@ -308,6 +341,93 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCommandItemsBatch charge les items de plusieurs commandes en une seule requête.
|
||||||
|
// Retourne map[commandID][]items, même structure que GetCommandItems.
|
||||||
|
func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]any, error) {
|
||||||
|
result := make(map[int][]map[string]any, len(commandIDs))
|
||||||
|
if len(commandIDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []struct {
|
||||||
|
ID int `gorm:"column:id"`
|
||||||
|
CommandID int `gorm:"column:command_id"`
|
||||||
|
Produit string `gorm:"column:produit"`
|
||||||
|
ProductID *int64 `gorm:"column:product_id"`
|
||||||
|
Quantite float64 `gorm:"column:quantite"`
|
||||||
|
Prix float64 `gorm:"column:prix"`
|
||||||
|
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||||
|
IsReward bool `gorm:"column:is_reward"`
|
||||||
|
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||||
|
ClientUsername string `gorm:"column:client_username"`
|
||||||
|
ClientNom string `gorm:"column:client_nom"`
|
||||||
|
ClientPrenom string `gorm:"column:client_prenom"`
|
||||||
|
ClientTelephone string `gorm:"column:client_telephone"`
|
||||||
|
DeliveryAddress *string `gorm:"column:delivery_address"`
|
||||||
|
Status *string `gorm:"column:status"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
|
CommandStatus *string `gorm:"column:command_status"`
|
||||||
|
CommandAddress *string `gorm:"column:command_address"`
|
||||||
|
TotalPrix float64 `gorm:"column:total_prix"`
|
||||||
|
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||||
|
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||||
|
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
|
||||||
|
Category string `gorm:"column:category"`
|
||||||
|
Unit string `gorm:"column:unit"`
|
||||||
|
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := d.GDB.Raw(`
|
||||||
|
SELECT
|
||||||
|
ci.id, ci.command_id, ci.produit, ci.product_id,
|
||||||
|
ci.quantite, ci.prix, ci.promo_discount, ci.is_reward, ci.reward_pool_key,
|
||||||
|
ci.client_username, ci.client_nom, ci.client_prenom, ci.client_telephone,
|
||||||
|
ci.delivery_address, ci.status, ci.created_at, ci.updated_at,
|
||||||
|
c.status as command_status, c.adresse as command_address,
|
||||||
|
c.total_prix, c.referral_used, c.livreur_assign,
|
||||||
|
c.created_at as command_created_at,
|
||||||
|
COALESCE(p.category, '') as category,
|
||||||
|
COALESCE(p.unit, '') as unit,
|
||||||
|
c.client_order_id as client_order_number
|
||||||
|
FROM command_items ci
|
||||||
|
LEFT JOIN commandes c ON ci.command_id = c.id
|
||||||
|
LEFT JOIN products p ON ci.product_id = p.id
|
||||||
|
WHERE ci.command_id IN ?
|
||||||
|
ORDER BY ci.command_id ASC, ci.id ASC`, commandIDs).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("erreur récupération items batch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
productIDValue := 0
|
||||||
|
if row.ProductID != nil {
|
||||||
|
productIDValue = int(*row.ProductID)
|
||||||
|
}
|
||||||
|
var commandCreatedAt any
|
||||||
|
if row.CommandCreatedAt != nil {
|
||||||
|
commandCreatedAt = *row.CommandCreatedAt
|
||||||
|
}
|
||||||
|
item := map[string]any{
|
||||||
|
"id": row.ID, "command_id": row.CommandID,
|
||||||
|
"produit": row.Produit, "product_id": productIDValue,
|
||||||
|
"quantite": row.Quantite, "prix": row.Prix, "promo_discount": row.PromoDiscount,
|
||||||
|
"is_reward": row.IsReward, "reward_pool_key": row.RewardPoolKey,
|
||||||
|
"client_username": row.ClientUsername, "client_nom": row.ClientNom,
|
||||||
|
"client_prenom": row.ClientPrenom, "client_telephone": row.ClientTelephone,
|
||||||
|
"delivery_address": ptrStr(row.DeliveryAddress), "status": ptrStr(row.Status),
|
||||||
|
"created_at": row.CreatedAt, "updated_at": row.UpdatedAt,
|
||||||
|
"command_status": ptrStr(row.CommandStatus), "command_address": ptrStr(row.CommandAddress),
|
||||||
|
"total_prix": row.TotalPrix, "referral_used": row.ReferralUsed,
|
||||||
|
"livreur_assign": ptrStr(row.LivreurAssign), "command_created_at": commandCreatedAt,
|
||||||
|
"category": row.Category, "unit": row.Unit,
|
||||||
|
"client_order_number": row.ClientOrderNumber,
|
||||||
|
}
|
||||||
|
result[row.CommandID] = append(result[row.CommandID], item)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ptrStr retourne la valeur d'un *string ou "" si nil
|
// ptrStr retourne la valeur d'un *string ou "" si nil
|
||||||
func ptrStr(s *string) string {
|
func ptrStr(s *string) string {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@@ -316,6 +436,12 @@ func ptrStr(s *string) string {
|
|||||||
return *s
|
return *s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteCommandItem supprime un item d'une commande et restaure son stock si
|
||||||
|
// la commande n'est pas déjà dans un état terminal. Le statut de la commande
|
||||||
|
// est verrouillé (FOR UPDATE) avant toute décision, dans la même transaction
|
||||||
|
// que la suppression et le remboursement, pour éviter une course avec une
|
||||||
|
// annulation concurrente de la commande entière (qui rembourserait déjà cet
|
||||||
|
// item) — même classe de bug que celle corrigée sur UpdateCommandStatusAdmin.
|
||||||
func (d *Database) DeleteCommandItem(commandID, itemID int) error {
|
func (d *Database) DeleteCommandItem(commandID, itemID int) error {
|
||||||
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
|
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
|
||||||
|
|
||||||
@@ -326,31 +452,28 @@ func (d *Database) DeleteCommandItem(commandID, itemID int) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var cmdStatus string
|
||||||
|
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdStatus).Error; err != nil {
|
||||||
|
return fmt.Errorf("erreur vérification commande: %w", err)
|
||||||
|
}
|
||||||
|
if cmdStatus == "" {
|
||||||
|
return fmt.Errorf("commande %d non trouvée", commandID)
|
||||||
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
Prix float64 `gorm:"column:prix"`
|
Prix float64 `gorm:"column:prix"`
|
||||||
Quantite float64 `gorm:"column:quantite"`
|
Quantite float64 `gorm:"column:quantite"`
|
||||||
ProductID int `gorm:"column:product_id"`
|
ProductID int `gorm:"column:product_id"`
|
||||||
}
|
}
|
||||||
if err := d.GDB.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil {
|
if err := tx.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil {
|
||||||
return fmt.Errorf("erreur vérification item: %w", err)
|
return fmt.Errorf("erreur vérification item: %w", err)
|
||||||
}
|
}
|
||||||
if result.Prix == 0 && result.Quantite == 0 {
|
if result.Prix == 0 && result.Quantite == 0 {
|
||||||
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
|
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmdStatus string
|
|
||||||
d.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, commandID).Scan(&cmdStatus)
|
|
||||||
|
|
||||||
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
|
|
||||||
restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus)
|
|
||||||
|
|
||||||
tx := d.GDB.Begin()
|
|
||||||
if tx.Error != nil {
|
|
||||||
return fmt.Errorf("erreur démarrage transaction: %w", tx.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
|
if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
|
||||||
tx.Rollback()
|
|
||||||
log.Printf("❌ Erreur DELETE command_items: %v", err)
|
log.Printf("❌ Erreur DELETE command_items: %v", err)
|
||||||
return fmt.Errorf("erreur suppression item: %w", err)
|
return fmt.Errorf("erreur suppression item: %w", err)
|
||||||
}
|
}
|
||||||
@@ -359,28 +482,25 @@ func (d *Database) DeleteCommandItem(commandID, itemID int) error {
|
|||||||
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
|
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
|
||||||
result.Prix*result.Quantite, commandID,
|
result.Prix*result.Quantite, commandID,
|
||||||
).Error; err != nil {
|
).Error; err != nil {
|
||||||
tx.Rollback()
|
|
||||||
log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err)
|
log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err)
|
||||||
return fmt.Errorf("erreur mise à jour total commande: %w", err)
|
return fmt.Errorf("erreur mise à jour total commande: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
|
||||||
|
restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus)
|
||||||
if restoreStock {
|
if restoreStock {
|
||||||
if err := tx.Exec(
|
if err := tx.Exec(
|
||||||
`UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
`UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
result.Quantite, result.ProductID,
|
result.Quantite, result.ProductID,
|
||||||
).Error; err != nil {
|
).Error; err != nil {
|
||||||
tx.Rollback()
|
|
||||||
log.Printf("❌ [DeleteCommandItem] Erreur restauration stock: %v", err)
|
log.Printf("❌ [DeleteCommandItem] Erreur restauration stock: %v", err)
|
||||||
return fmt.Errorf("erreur restauration stock: %w", err)
|
return fmt.Errorf("erreur restauration stock: %w", err)
|
||||||
}
|
}
|
||||||
log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID)
|
log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit().Error; err != nil {
|
|
||||||
return fmt.Errorf("erreur commit transaction: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) UpdateCommandItemStatus(itemID int, status string) error {
|
func (d *Database) UpdateCommandItemStatus(itemID int, status string) error {
|
||||||
|
|||||||
+160
-130
@@ -1,6 +1,8 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gestion/models"
|
"gestion/models"
|
||||||
"log"
|
"log"
|
||||||
@@ -11,6 +13,9 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errAlreadyApproved est retournée quand le client tente d'approuver une commande déjà approuvée.
|
||||||
|
var errAlreadyApproved = errors.New("already_approved")
|
||||||
|
|
||||||
func sanitizeString(s string) string {
|
func sanitizeString(s string) string {
|
||||||
sanitized := strings.Map(func(r rune) rune {
|
sanitized := strings.Map(func(r rune) rune {
|
||||||
if r < 32 || r == 127 {
|
if r < 32 || r == 127 {
|
||||||
@@ -50,18 +55,7 @@ type basketItem struct {
|
|||||||
Price float64 `gorm:"column:price"`
|
Price float64 `gorm:"column:price"`
|
||||||
IsReward bool `gorm:"column:is_reward"`
|
IsReward bool `gorm:"column:is_reward"`
|
||||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||||
}
|
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||||
|
|
||||||
func (d *Database) fetchBasketItems(username string) ([]basketItem, float64, error) {
|
|
||||||
var items []basketItem
|
|
||||||
if err := d.GDB.Table("baskets").Select("product_id, quantity, price, is_reward, reward_pool_key").Where("username = ?", username).Scan(&items).Error; err != nil {
|
|
||||||
return nil, 0, fmt.Errorf("erreur récupération panier: %w", err)
|
|
||||||
}
|
|
||||||
total := 0.0
|
|
||||||
for _, item := range items {
|
|
||||||
total += item.Price
|
|
||||||
}
|
|
||||||
return items, total, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateCommandStatus vérifie si le statut est valide
|
// validateCommandStatus vérifie si le statut est valide
|
||||||
@@ -84,74 +78,6 @@ func validateCommandStatus(status string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) CreateCommand(username string) (*models.Command, error) {
|
|
||||||
adresse := "Adresse non spécifiée"
|
|
||||||
var clientCheck models.Client
|
|
||||||
if err := d.GDB.Select("username").Where("username = ?", username).First(&clientCheck).Error; err == nil && clientCheck.Username != "" {
|
|
||||||
adresse = clientCheck.Username
|
|
||||||
}
|
|
||||||
|
|
||||||
basketItems, totalPrix, err := d.fetchBasketItems(username)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(basketItems) == 0 {
|
|
||||||
return nil, fmt.Errorf("le panier est vide")
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmdResult struct {
|
|
||||||
ID int `gorm:"column:id"`
|
|
||||||
ClientOrderID int `gorm:"column:client_order_id"`
|
|
||||||
CreatedAt time.Time `gorm:"column:created_at"`
|
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
|
||||||
}
|
|
||||||
err = d.GDB.Raw(`
|
|
||||||
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
||||||
RETURNING id, client_order_id, created_at, updated_at`,
|
|
||||||
username, "pending", adresse, totalPrix, username).Scan(&cmdResult).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("erreur lors de la création de la commande: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
commandID := cmdResult.ID
|
|
||||||
|
|
||||||
for _, item := range basketItems {
|
|
||||||
productName, err := d.GetProductNameByID(item.ProductID)
|
|
||||||
if err != nil {
|
|
||||||
productName = "Produit inconnu"
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdItem := models.CommandItem{
|
|
||||||
CommandID: commandID,
|
|
||||||
Produit: productName,
|
|
||||||
ProductID: item.ProductID,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
Price: item.Price,
|
|
||||||
IsReward: item.IsReward,
|
|
||||||
RewardPoolKey: item.RewardPoolKey,
|
|
||||||
}
|
|
||||||
if err := d.GDB.Create(&cmdItem).Error; err != nil {
|
|
||||||
return nil, fmt.Errorf("erreur lors de l'insertion des items: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
|
|
||||||
return nil, fmt.Errorf("erreur lors du vidage du panier: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
command := &models.Command{
|
|
||||||
ID: commandID,
|
|
||||||
ClientOrderID: cmdResult.ClientOrderID,
|
|
||||||
Status: "pending",
|
|
||||||
Total: totalPrix,
|
|
||||||
}
|
|
||||||
|
|
||||||
return command, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) {
|
func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) {
|
||||||
if err := validateUsername(username); err != nil {
|
if err := validateUsername(username); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -175,24 +101,31 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
|
|||||||
clientTelephone = sanitizeString(client.Telephone)
|
clientTelephone = sanitizeString(client.Telephone)
|
||||||
}
|
}
|
||||||
|
|
||||||
basketItems, totalPrix, err := d.fetchBasketItems(username)
|
var (
|
||||||
if err != nil {
|
command *models.Command
|
||||||
log.Printf("❌ Erreur query basket: %v", err)
|
totalPrix float64
|
||||||
return nil, err
|
)
|
||||||
|
err = d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// Verrou sur le panier : un double-submit concurrent du même client se
|
||||||
|
// bloque ici puis échoue proprement ("panier vide") une fois le premier
|
||||||
|
// passage terminé, au lieu de créer une commande fantôme.
|
||||||
|
var basketItems []basketItem
|
||||||
|
if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key, promo_discount FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil {
|
||||||
|
return fmt.Errorf("erreur récupération panier: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(basketItems) == 0 {
|
if len(basketItems) == 0 {
|
||||||
return nil, fmt.Errorf("le panier est vide")
|
return fmt.Errorf("le panier est vide")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range basketItems {
|
for _, item := range basketItems {
|
||||||
if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 {
|
if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 {
|
||||||
return nil, fmt.Errorf("données panier invalides")
|
return fmt.Errorf("données panier invalides")
|
||||||
}
|
}
|
||||||
|
totalPrix += item.Price
|
||||||
}
|
}
|
||||||
|
|
||||||
if totalPrix <= 0 || totalPrix > 100000 {
|
if totalPrix <= 0 || totalPrix > 100000 {
|
||||||
return nil, fmt.Errorf("montant de commande invalide: %.2f€", totalPrix)
|
return fmt.Errorf("montant de commande invalide: %.2f€", totalPrix)
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmdResult struct {
|
var cmdResult struct {
|
||||||
@@ -201,56 +134,68 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
|
|||||||
CreatedAt time.Time `gorm:"column:created_at"`
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
}
|
}
|
||||||
err = d.GDB.Raw(`
|
if err := tx.Raw(`
|
||||||
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
|
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
RETURNING id, client_order_id, created_at, updated_at`,
|
RETURNING id, client_order_id, created_at, updated_at`,
|
||||||
username, "pending", deliveryAddress, totalPrix, username).Scan(&cmdResult).Error
|
username, "pending", deliveryAddress, totalPrix, username).Scan(&cmdResult).Error; err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("erreur création commande: %w", err)
|
||||||
return nil, fmt.Errorf("erreur création commande: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
commandID := cmdResult.ID
|
commandID := cmdResult.ID
|
||||||
|
|
||||||
|
productIDs2 := make([]int, 0, len(basketItems))
|
||||||
for _, item := range basketItems {
|
for _, item := range basketItems {
|
||||||
productName, err := d.GetProductNameByID(item.ProductID)
|
productIDs2 = append(productIDs2, item.ProductID)
|
||||||
if err != nil || productName == "" {
|
}
|
||||||
|
productNames2, _ := d.GetProductNamesByIDs(productIDs2)
|
||||||
|
|
||||||
|
batchItems := make([]commandItemFull, 0, len(basketItems))
|
||||||
|
for _, item := range basketItems {
|
||||||
|
productName := productNames2[item.ProductID]
|
||||||
|
if productName == "" {
|
||||||
productName = fmt.Sprintf("Produit #%d", item.ProductID)
|
productName = fmt.Sprintf("Produit #%d", item.ProductID)
|
||||||
}
|
}
|
||||||
|
batchItems = append(batchItems, commandItemFull{
|
||||||
err = d.InsertCommandItemWithClientInfo(
|
CommandID: commandID,
|
||||||
commandID,
|
Produit: productName,
|
||||||
productName,
|
ProductID: item.ProductID,
|
||||||
item.ProductID,
|
Quantite: item.Quantity,
|
||||||
item.Quantity,
|
Prix: item.Price,
|
||||||
item.Price,
|
IsReward: item.IsReward,
|
||||||
item.IsReward,
|
RewardPoolKey: item.RewardPoolKey,
|
||||||
item.RewardPoolKey,
|
PromoDiscount: item.PromoDiscount,
|
||||||
username,
|
ClientUsername: username,
|
||||||
clientNom,
|
ClientNom: clientNom,
|
||||||
clientPrenom,
|
ClientPrenom: clientPrenom,
|
||||||
clientTelephone,
|
ClientTelephone: clientTelephone,
|
||||||
deliveryAddress,
|
DeliveryAddress: deliveryAddress,
|
||||||
)
|
Status: "pending",
|
||||||
if err != nil {
|
})
|
||||||
log.Printf("❌ Erreur INSERT command_items: %v", err)
|
}
|
||||||
return nil, fmt.Errorf("erreur insertion items: %w", err)
|
if err := tx.Create(&batchItems).Error; err != nil {
|
||||||
|
return fmt.Errorf("erreur insertion items: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stock déjà déduit à l'ajout au panier — ne pas déduire une seconde fois ici.
|
// Les articles récompense (payés en points) restent des produits physiques
|
||||||
|
// réellement distribués : le stock doit être décrémenté comme pour un
|
||||||
|
// article payant.
|
||||||
|
for _, item := range basketItems {
|
||||||
|
var currentStock float64
|
||||||
|
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(¤tStock).Error; err != nil {
|
||||||
|
return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err)
|
||||||
|
}
|
||||||
|
if currentStock < item.Quantity {
|
||||||
|
return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID)
|
||||||
|
}
|
||||||
|
if err := tx.Exec(`UPDATE products SET stock = stock - ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
|
||||||
|
return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
|
command = &models.Command{
|
||||||
log.Printf("⚠️ Erreur vidage panier: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sanitizedAddress := sanitizeLogMessage(deliveryAddress)
|
|
||||||
d.AddCommandLog(commandID, "created",
|
|
||||||
fmt.Sprintf("Commande créée - Adresse: %s - Total: %.2f€ - Client: %s %s",
|
|
||||||
sanitizedAddress, totalPrix, sanitizeLogMessage(clientNom), sanitizeLogMessage(clientPrenom)),
|
|
||||||
username)
|
|
||||||
|
|
||||||
command := &models.Command{
|
|
||||||
ID: commandID,
|
ID: commandID,
|
||||||
ClientOrderID: cmdResult.ClientOrderID,
|
ClientOrderID: cmdResult.ClientOrderID,
|
||||||
Username: username,
|
Username: username,
|
||||||
@@ -260,6 +205,18 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
|
|||||||
CreatedAt: cmdResult.CreatedAt,
|
CreatedAt: cmdResult.CreatedAt,
|
||||||
UpdatedAt: cmdResult.UpdatedAt,
|
UpdatedAt: cmdResult.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Erreur création commande: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizedAddress := sanitizeLogMessage(deliveryAddress)
|
||||||
|
d.AddCommandLog(command.ID, "created",
|
||||||
|
fmt.Sprintf("Commande créée - Adresse: %s - Total: %.2f€ - Client: %s %s",
|
||||||
|
sanitizedAddress, totalPrix, sanitizeLogMessage(clientNom), sanitizeLogMessage(clientPrenom)),
|
||||||
|
username)
|
||||||
|
|
||||||
return command, nil
|
return command, nil
|
||||||
}
|
}
|
||||||
@@ -424,9 +381,28 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) {
|
|||||||
return command, nil
|
return command, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastDeliveryCoordsCacheTTL = 5 * time.Minute
|
||||||
|
|
||||||
|
func lastDeliveryCoordsCacheKey(livreurUsername string) string {
|
||||||
|
return fmt.Sprintf("livreur:last_delivery_coords:%s", livreurUsername)
|
||||||
|
}
|
||||||
|
|
||||||
// GetLastDeliveryCoords retourne les coordonnées GPS de la dernière livraison terminée d'un livreur.
|
// GetLastDeliveryCoords retourne les coordonnées GPS de la dernière livraison terminée d'un livreur.
|
||||||
// Utilisé comme fallback quand le GPS temps réel est indisponible.
|
// Utilisé comme fallback quand le GPS temps réel est indisponible. Mis en cache quelques minutes
|
||||||
|
// car appelé à chaque calcul d'ETA et la dernière livraison ne change pas souvent.
|
||||||
func (d *Database) GetLastDeliveryCoords(livreurUsername string) (float64, float64, error) {
|
func (d *Database) GetLastDeliveryCoords(livreurUsername string) (float64, float64, error) {
|
||||||
|
cacheKey := lastDeliveryCoordsCacheKey(livreurUsername)
|
||||||
|
|
||||||
|
if cached, err := Redis.Get(RedisCtx, cacheKey).Result(); err == nil {
|
||||||
|
var coords struct {
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
}
|
||||||
|
if jsonErr := json.Unmarshal([]byte(cached), &coords); jsonErr == nil {
|
||||||
|
return coords.Lat, coords.Lon, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
DestLatitude float64 `gorm:"column:dest_latitude"`
|
DestLatitude float64 `gorm:"column:dest_latitude"`
|
||||||
DestLongitude float64 `gorm:"column:dest_longitude"`
|
DestLongitude float64 `gorm:"column:dest_longitude"`
|
||||||
@@ -446,6 +422,10 @@ func (d *Database) GetLastDeliveryCoords(livreurUsername string) (float64, float
|
|||||||
return 0, 0, fmt.Errorf("coordonnées introuvables pour dernière livraison de %s", livreurUsername)
|
return 0, 0, fmt.Errorf("coordonnées introuvables pour dernière livraison de %s", livreurUsername)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if coordsJSON, err := json.Marshal(map[string]float64{"lat": result.DestLatitude, "lon": result.DestLongitude}); err == nil {
|
||||||
|
Redis.Set(RedisCtx, cacheKey, coordsJSON, lastDeliveryCoordsCacheTTL)
|
||||||
|
}
|
||||||
|
|
||||||
return result.DestLatitude, result.DestLongitude, nil
|
return result.DestLatitude, result.DestLongitude, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,6 +464,36 @@ func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateOwnCommandAddress permet à un client de corriger l'adresse de SA
|
||||||
|
// PROPRE commande, tant qu'elle n'est pas encore prise en charge par un
|
||||||
|
// livreur (statut "en_route") ni terminée. La vérification d'appartenance et
|
||||||
|
// de statut se fait dans la clause WHERE, atomiquement : impossible de
|
||||||
|
// modifier la commande d'un autre client ou une commande déjà en route.
|
||||||
|
func (d *Database) UpdateOwnCommandAddress(commandID int, clientUsername, deliveryAddress string) error {
|
||||||
|
if err := validateAddress(deliveryAddress); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := d.GDB.Exec(`
|
||||||
|
UPDATE commandes
|
||||||
|
SET adresse = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND username = ? AND status IN ('pending', 'assigned')`,
|
||||||
|
deliveryAddress, commandID, clientUsername)
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("erreur lors de la mise à jour de l'adresse: %w", result.Error)
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("commande introuvable, non modifiable (déjà en livraison ou terminée), ou n'appartenant pas à ce client")
|
||||||
|
}
|
||||||
|
|
||||||
|
d.AddCommandLog(commandID, "address_updated",
|
||||||
|
fmt.Sprintf("Adresse corrigée par le client %s", clientUsername),
|
||||||
|
clientUsername)
|
||||||
|
|
||||||
|
log.Printf("✅ [UPD_OWN_ADDR] Adresse commande %d corrigée par %s", commandID, clientUsername)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client
|
// ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client
|
||||||
func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error {
|
func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error {
|
||||||
if err := validateAddress(proposedAddress); err != nil {
|
if err := validateAddress(proposedAddress); err != nil {
|
||||||
@@ -647,7 +657,7 @@ func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (
|
|||||||
log.Printf("📋 [ValidateAtomic] Commande trouvée - status=%s, client=%s, livreur=%s",
|
log.Printf("📋 [ValidateAtomic] Commande trouvée - status=%s, client=%s, livreur=%s",
|
||||||
cmd.Status, cmd.Username, cmd.LivreurAssign)
|
cmd.Status, cmd.Username, cmd.LivreurAssign)
|
||||||
|
|
||||||
validStatuses := []string{"assigned", "en_route", "pending", "livre"}
|
validStatuses := []string{"assigned", "en_route", "arrived", "pending", "livre"}
|
||||||
if !slices.Contains(validStatuses, cmd.Status) {
|
if !slices.Contains(validStatuses, cmd.Status) {
|
||||||
log.Printf("❌ [ValidateAtomic] Statut invalide pour validation: %s", cmd.Status)
|
log.Printf("❌ [ValidateAtomic] Statut invalide pour validation: %s", cmd.Status)
|
||||||
return fmt.Errorf("statut invalide pour validation: %s", cmd.Status)
|
return fmt.Errorf("statut invalide pour validation: %s", cmd.Status)
|
||||||
@@ -772,6 +782,11 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, s
|
|||||||
return fmt.Errorf("cette commande ne vous appartient pas")
|
return fmt.Errorf("cette commande ne vous appartient pas")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd.Status == "approved" {
|
||||||
|
log.Printf("ℹ️ [ApproveAtomic] Commande %d déjà approuvée — réponse idempotente", commandID)
|
||||||
|
return errAlreadyApproved
|
||||||
|
}
|
||||||
|
|
||||||
if cmd.Status != "livre" {
|
if cmd.Status != "livre" {
|
||||||
log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", cmd.Status)
|
log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", cmd.Status)
|
||||||
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
|
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
|
||||||
@@ -821,6 +836,9 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, s
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, errAlreadyApproved) {
|
||||||
|
return 0, "", nil
|
||||||
|
}
|
||||||
return 0, "", err
|
return 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,13 +894,25 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
|
|||||||
return fmt.Errorf("commande non trouvée")
|
return fmt.Errorf("commande non trouvée")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cmd.Status != "livre" {
|
// Historiquement restreint à "livre" seul (cf. commentaire de
|
||||||
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
|
// TestApproveDeliveryAtomicByStaff dans les tests) — élargi après un
|
||||||
|
// incident réel où une vérification GPS en amont (coordonnées de
|
||||||
|
// destination périmées après un changement d'adresse, cf.
|
||||||
|
// updateCommandDestinationCoords) a bloqué la transition du livreur
|
||||||
|
// vers "livre" : la commande restait alors coincée, sans qu'admin ni
|
||||||
|
// cabine ne puissent confirmer la réception. On accepte désormais tout
|
||||||
|
// statut non terminal ("arrived" inclus), à l'image de
|
||||||
|
// ValidateDeliveryAtomic (qui accepte déjà pending/assigned/en_route),
|
||||||
|
// pour que le staff garde toujours un moyen de débloquer une commande
|
||||||
|
// légitime indépendamment d'un blocage en amont côté livreur.
|
||||||
|
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre"}
|
||||||
|
if !slices.Contains(validStatuses, cmd.Status) {
|
||||||
|
return fmt.Errorf("statut invalide pour confirmation de réception: %s", cmd.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := tx.Exec(`
|
result := tx.Exec(`
|
||||||
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ? AND status = 'livre'`, commandID)
|
WHERE id = ? AND status = ?`, commandID, cmd.Status)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) e
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDeliveryPersonCommands récupère les commandes assignées à un livreur
|
|
||||||
func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status string) ([]map[string]any, error) {
|
func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status string) ([]map[string]any, error) {
|
||||||
query := `SELECT id, username, status, adresse, total_prix::float8 as total_prix, livreur_assign, created_at, updated_at
|
query := `SELECT id, username, status, adresse, total_prix::float8 as total_prix, livreur_assign, created_at, updated_at
|
||||||
FROM commandes
|
FROM commandes
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "gestion/models"
|
||||||
|
|
||||||
|
// ResolveFreeGift retourne la quantité offerte (du même produit) pour un
|
||||||
|
// produit, sa catégorie catalogue et une quantité commandée donnés — le seuil
|
||||||
|
// le plus élevé (BuyQuantity) atteint par la quantité commandée est retenu,
|
||||||
|
// tous seuils confondus pour ce produit (ex: seuils 10g→+1g et 20g→+3g, une
|
||||||
|
// commande de 25g retient +3g, pas +1g).
|
||||||
|
func ResolveFreeGift(settings *models.AppSettings, productID int, category string, quantity float64) float64 {
|
||||||
|
if settings == nil || !settings.FreeGiftsEnabled {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var bestBuy, bestFree float64
|
||||||
|
found := false
|
||||||
|
consider := func(tiers []models.FreeGiftTier) {
|
||||||
|
for _, t := range tiers {
|
||||||
|
if t.BuyQuantity <= 0 || t.FreeQuantity <= 0 || quantity < t.BuyQuantity {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !found || t.BuyQuantity > bestBuy {
|
||||||
|
bestBuy, bestFree = t.BuyQuantity, t.FreeQuantity
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, g := range settings.FreeGifts {
|
||||||
|
if g.Category != category {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if g.AllProducts {
|
||||||
|
consider(g.Tiers)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, pq := range g.Products {
|
||||||
|
if pq.ProductID == productID {
|
||||||
|
consider(pq.Tiers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return bestFree
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFreeGiftQuantity lit les settings courants et applique
|
||||||
|
// ResolveFreeGift — wrapper pratique pour les appelants qui n'ont pas déjà
|
||||||
|
// les settings sous la main (même style que ApplyPromotionToPrice).
|
||||||
|
func (d *Database) ResolveFreeGiftQuantity(productID int, category string, quantity float64) float64 {
|
||||||
|
settings, err := d.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return ResolveFreeGift(&settings, productID, category, quantity)
|
||||||
|
}
|
||||||
@@ -25,20 +25,16 @@ func wazeAppLink(lat, lon float64) string {
|
|||||||
return fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", lat, lon)
|
return fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", lat, lon)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateMapLinks génère tous les liens de cartes pour une position GPS
|
|
||||||
func (d *Database) GenerateMapLinks(lat, lon float64, label string) MapLinks {
|
func (d *Database) GenerateMapLinks(lat, lon float64, label string) MapLinks {
|
||||||
return MapLinks{
|
return MapLinks{
|
||||||
WazeApp: wazeAppLink(lat, lon),
|
WazeApp: wazeAppLink(lat, lon),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateNavigationLink génère un lien de navigation vers une destination
|
|
||||||
// fromLat/fromLon sont ignorés : Waze part toujours de la position GPS courante
|
|
||||||
func (d *Database) GenerateNavigationLink(fromLat, fromLon, toLat, toLon float64, platform string) string {
|
func (d *Database) GenerateNavigationLink(fromLat, fromLon, toLat, toLon float64, platform string) string {
|
||||||
return wazeAppLink(toLat, toLon)
|
return wazeAppLink(toLat, toLon)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateMapLinksForCommand génère les liens de navigation pour une commande
|
|
||||||
func (d *Database) GenerateMapLinksForCommand(commandID int, deliverymanUsername string) (map[string]string, error) {
|
func (d *Database) GenerateMapLinksForCommand(commandID int, deliverymanUsername string) (map[string]string, error) {
|
||||||
_, _, err := d.GetDeliveryPersonLocation(deliverymanUsername)
|
_, _, err := d.GetDeliveryPersonLocation(deliverymanUsername)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func InitDB() *Database {
|
|||||||
// Configuration du pool de connexions
|
// Configuration du pool de connexions
|
||||||
db.SetMaxOpenConns(50)
|
db.SetMaxOpenConns(50)
|
||||||
db.SetMaxIdleConns(10)
|
db.SetMaxIdleConns(10)
|
||||||
db.SetConnMaxLifetime(5 * time.Minute)
|
db.SetConnMaxLifetime(30 * time.Minute)
|
||||||
|
|
||||||
// Tester la connexion
|
// Tester la connexion
|
||||||
if err = db.Ping(); err != nil {
|
if err = db.Ping(); err != nil {
|
||||||
@@ -66,6 +66,8 @@ func InitDB() *Database {
|
|||||||
gormDB, err := gorm.Open(postgres.New(postgres.Config{
|
gormDB, err := gorm.Open(postgres.New(postgres.Config{
|
||||||
Conn: db,
|
Conn: db,
|
||||||
}), &gorm.Config{
|
}), &gorm.Config{
|
||||||
|
SkipDefaultTransaction: true,
|
||||||
|
PrepareStmt: true,
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -138,6 +140,20 @@ func InitDB() *Database {
|
|||||||
log.Fatalf("❌ Erreur migration command_items.reward_pool_key: %v", err)
|
log.Fatalf("❌ Erreur migration command_items.reward_pool_key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: baskets.promo_discount + command_items.promo_discount —
|
||||||
|
// montant (en €) économisé par une promotion de prix sur cette ligne,
|
||||||
|
// capturé une fois pour toutes au moment de AddToBasket (voir
|
||||||
|
// db_basket.go) puis copié tel quel au checkout, pour permettre des
|
||||||
|
// statistiques historiques fiables même si la config de promo change
|
||||||
|
// ensuite (contrairement à un recalcul a posteriori sur les settings
|
||||||
|
// courants, qui donnerait un résultat faux pour les anciennes commandes).
|
||||||
|
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil {
|
||||||
|
log.Fatalf("❌ Erreur migration baskets.promo_discount: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil {
|
||||||
|
log.Fatalf("❌ Erreur migration command_items.promo_discount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
|
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
|
||||||
if _, err = database.Exec(`
|
if _, err = database.Exec(`
|
||||||
DO $$
|
DO $$
|
||||||
@@ -165,6 +181,23 @@ func InitDB() *Database {
|
|||||||
log.Fatalf("❌ Erreur migration categories.is_coming_soon: %v", err)
|
log.Fatalf("❌ Erreur migration categories.is_coming_soon: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: position d'affichage des catégories
|
||||||
|
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||||
|
log.Fatalf("❌ Erreur migration categories.position: %v", err)
|
||||||
|
}
|
||||||
|
// Backfill: attribuer des positions aux catégories existantes (ordre alphabétique)
|
||||||
|
if _, err = database.Exec(`
|
||||||
|
UPDATE categories c
|
||||||
|
SET position = sub.rn
|
||||||
|
FROM (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (ORDER BY name ASC) AS rn
|
||||||
|
FROM categories
|
||||||
|
) sub
|
||||||
|
WHERE c.id = sub.id AND c.position = 0
|
||||||
|
`); err != nil {
|
||||||
|
log.Fatalf("❌ Erreur backfill categories.position: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Migration: table paramètres globaux de l'application
|
// Migration: table paramètres globaux de l'application
|
||||||
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS app_settings (
|
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
key VARCHAR(100) PRIMARY KEY,
|
key VARCHAR(100) PRIMARY KEY,
|
||||||
@@ -277,6 +310,21 @@ func InitDB() *Database {
|
|||||||
log.Fatalf("❌ Erreur migration contacts: %v", err)
|
log.Fatalf("❌ Erreur migration contacts: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: clé RustFS pour les médias (stockage objet)
|
||||||
|
if _, err = database.Exec(`ALTER TABLE media ADD COLUMN IF NOT EXISTS key TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||||
|
log.Fatalf("❌ Erreur migration media.key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: colonne parrain sur les clients (système de parrainage)
|
||||||
|
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS parrain VARCHAR(255) DEFAULT NULL`); err != nil {
|
||||||
|
log.Fatalf("❌ Erreur migration clients.parrain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: index sur clients.parrain (lookups filleuls + stats parrainage)
|
||||||
|
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_clients_parrain ON clients(parrain) WHERE parrain IS NOT NULL`); err != nil {
|
||||||
|
log.Fatalf("❌ Erreur migration idx_clients_parrain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Lancer le nettoyage périodique des tokens expirés
|
// Lancer le nettoyage périodique des tokens expirés
|
||||||
go database.cleanExpiredTokensPeriodically()
|
go database.cleanExpiredTokensPeriodically()
|
||||||
|
|
||||||
@@ -321,6 +369,7 @@ func (db *Database) createTables() error {
|
|||||||
cancellations_count INTEGER DEFAULT 0 NOT NULL,
|
cancellations_count INTEGER DEFAULT 0 NOT NULL,
|
||||||
last_penalty_reason TEXT DEFAULT NULL,
|
last_penalty_reason TEXT DEFAULT NULL,
|
||||||
referral_balance NUMERIC(10,2) DEFAULT 0.0,
|
referral_balance NUMERIC(10,2) DEFAULT 0.0,
|
||||||
|
parrain VARCHAR(255) DEFAULT NULL,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);`,
|
);`,
|
||||||
@@ -382,6 +431,7 @@ func (db *Database) createTables() error {
|
|||||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||||
url TEXT NOT NULL,
|
url TEXT NOT NULL,
|
||||||
type VARCHAR(50) NOT NULL,
|
type VARCHAR(50) NOT NULL,
|
||||||
|
key TEXT NOT NULL DEFAULT '',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);`,
|
);`,
|
||||||
|
|
||||||
@@ -513,6 +563,30 @@ func (db *Database) createTables() error {
|
|||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL
|
name VARCHAR(255) NOT NULL
|
||||||
);`,
|
);`,
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// TABLE livreur_ratings
|
||||||
|
// ============================
|
||||||
|
`CREATE TABLE IF NOT EXISTS livreur_ratings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
order_id INTEGER NOT NULL UNIQUE REFERENCES commandes(id) ON DELETE CASCADE,
|
||||||
|
livreur_username VARCHAR(255) NOT NULL,
|
||||||
|
client_username VARCHAR(255) NOT NULL,
|
||||||
|
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_ratings_livreur ON livreur_ratings(livreur_username);`,
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// TABLE login_history (livreur uniquement)
|
||||||
|
// ============================
|
||||||
|
`CREATE TABLE IF NOT EXISTS login_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_login_history_username ON login_history(username);`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, query := range queries {
|
for _, query := range queries {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LivreurRating struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
OrderID int `json:"order_id"`
|
||||||
|
LivreurUsername string `json:"livreur_username"`
|
||||||
|
ClientUsername string `json:"client_username"`
|
||||||
|
Rating int `json:"rating"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) SubmitLivreurRating(orderID int, livreurUsername, clientUsername string, rating int, comment string) error {
|
||||||
|
return d.GDB.Exec(`
|
||||||
|
INSERT INTO livreur_ratings (order_id, livreur_username, client_username, rating, comment, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NOW())
|
||||||
|
`, orderID, livreurUsername, clientUsername, rating, comment).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetOrderRating(orderID int) (*LivreurRating, error) {
|
||||||
|
var r LivreurRating
|
||||||
|
err := d.GDB.Raw(`SELECT * FROM livreur_ratings WHERE order_id = ? LIMIT 1`, orderID).Scan(&r).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if r.ID == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetLivreurRatings(livreurUsername string) ([]LivreurRating, float64, error) {
|
||||||
|
var ratings []LivreurRating
|
||||||
|
if err := d.GDB.Raw(`
|
||||||
|
SELECT * FROM livreur_ratings WHERE livreur_username = ? ORDER BY created_at DESC LIMIT 200
|
||||||
|
`, livreurUsername).Scan(&ratings).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var avg float64
|
||||||
|
if len(ratings) > 0 {
|
||||||
|
d.GDB.Raw(`SELECT COALESCE(AVG(rating), 0) FROM livreur_ratings WHERE livreur_username = ?`, livreurUsername).Scan(&avg)
|
||||||
|
}
|
||||||
|
return ratings, avg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrderForRating retourne l'username client et le livreur d'une commande approuvée
|
||||||
|
func (d *Database) GetOrderForRating(orderID int) (clientUsername, livreurUsername string, err error) {
|
||||||
|
var row struct {
|
||||||
|
Username string `gorm:"column:username"`
|
||||||
|
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||||
|
}
|
||||||
|
err = d.GDB.Raw(`
|
||||||
|
SELECT username, COALESCE(livreur_assign, '') as livreur_assign
|
||||||
|
FROM commandes WHERE id = ? AND status = 'approved' LIMIT 1
|
||||||
|
`, orderID).Scan(&row).Error
|
||||||
|
return row.Username, row.LivreurAssign, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoginHistoryEntry struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordLivreurLogin enregistre une connexion réussie d'un livreur (best-effort, non bloquant).
|
||||||
|
func (d *Database) RecordLivreurLogin(username string) error {
|
||||||
|
return d.GDB.Exec(`
|
||||||
|
INSERT INTO login_history (username, created_at)
|
||||||
|
VALUES (?, NOW())
|
||||||
|
`, username).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLivreurLoginHistoryByMonth retourne le détail des connexions d'un livreur pour un mois donné,
|
||||||
|
// triées du plus récent au plus ancien (max 50 entrées).
|
||||||
|
func (d *Database) GetLivreurLoginHistoryByMonth(username string, year, month int) ([]LoginHistoryEntry, error) {
|
||||||
|
var entries []LoginHistoryEntry
|
||||||
|
err := d.GDB.Raw(`
|
||||||
|
SELECT id, username, created_at FROM login_history
|
||||||
|
WHERE username = ?
|
||||||
|
AND EXTRACT(YEAR FROM created_at) = ?
|
||||||
|
AND EXTRACT(MONTH FROM created_at) = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`, username, year, month).Scan(&entries).Error
|
||||||
|
return entries, err
|
||||||
|
}
|
||||||
@@ -59,8 +59,8 @@ func validateMediaURL(url string) error {
|
|||||||
if strings.Contains(url, "..") || strings.Contains(url, "...") || strings.Contains(url, "..//") {
|
if strings.Contains(url, "..") || strings.Contains(url, "...") || strings.Contains(url, "..//") {
|
||||||
return fmt.Errorf("path traversal détecté dans l'URL")
|
return fmt.Errorf("path traversal détecté dans l'URL")
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(url, "/uploads/") {
|
if !strings.HasPrefix(url, "/uploads/") && !strings.HasPrefix(url, "/media/") {
|
||||||
return fmt.Errorf("URL doit commencer par /uploads/")
|
return fmt.Errorf("URL doit commencer par /uploads/ ou /media/")
|
||||||
}
|
}
|
||||||
dangerousChars := []string{"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\"}
|
dangerousChars := []string{"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\"}
|
||||||
for _, char := range dangerousChars {
|
for _, char := range dangerousChars {
|
||||||
@@ -93,6 +93,11 @@ func (d *Database) CreateMedia(media any) error {
|
|||||||
mediaType := m.GetType()
|
mediaType := m.GetType()
|
||||||
mediaURL := m.GetURL()
|
mediaURL := m.GetURL()
|
||||||
|
|
||||||
|
mediaKey := ""
|
||||||
|
if mediaPtr, isPtr := media.(*models.Media); isPtr {
|
||||||
|
mediaKey = mediaPtr.Key
|
||||||
|
}
|
||||||
|
|
||||||
if err := validateProductID(productID); err != nil {
|
if err := validateProductID(productID); err != nil {
|
||||||
log.Printf("❌ [CreateMedia] %v", err)
|
log.Printf("❌ [CreateMedia] %v", err)
|
||||||
return err
|
return err
|
||||||
@@ -106,14 +111,14 @@ func (d *Database) CreateMedia(media any) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := d.InsertMedia(m, productID, mediaURL, mediaType); err != nil {
|
if err := d.InsertMedia(m, productID, mediaURL, mediaType, mediaKey); err != nil {
|
||||||
log.Printf("❌ [InsertMedia] %v", err)
|
log.Printf("❌ [InsertMedia] %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType string) error {
|
func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType string, key string) error {
|
||||||
var exists bool
|
var exists bool
|
||||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; err != nil {
|
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; err != nil {
|
||||||
log.Printf("❌ [CreateMedia] Erreur vérification produit: %v", err)
|
log.Printf("❌ [CreateMedia] Erreur vérification produit: %v", err)
|
||||||
@@ -128,9 +133,9 @@ func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType str
|
|||||||
ID int `gorm:"column:id"`
|
ID int `gorm:"column:id"`
|
||||||
}
|
}
|
||||||
err := d.GDB.Raw(`
|
err := d.GDB.Raw(`
|
||||||
INSERT INTO media (product_id, url, type, created_at)
|
INSERT INTO media (product_id, url, type, key, created_at)
|
||||||
VALUES (?, ?, ?, ?) RETURNING id`,
|
VALUES (?, ?, ?, ?, ?) RETURNING id`,
|
||||||
productID, mediaURL, mediaType, time.Now(),
|
productID, mediaURL, mediaType, key, time.Now(),
|
||||||
).Scan(&result).Error
|
).Scan(&result).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err)
|
log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err)
|
||||||
@@ -154,7 +159,7 @@ func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
|
|||||||
|
|
||||||
var media models.Media
|
var media models.Media
|
||||||
err := d.GDB.Raw(`
|
err := d.GDB.Raw(`
|
||||||
SELECT id, product_id, url, type, created_at
|
SELECT id, product_id, url, type, key, created_at
|
||||||
FROM media WHERE id = ?`, mediaID).Scan(&media).Error
|
FROM media WHERE id = ?`, mediaID).Scan(&media).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [GetMediaByID] Erreur query: %v", err)
|
log.Printf("❌ [GetMediaByID] Erreur query: %v", err)
|
||||||
@@ -169,6 +174,20 @@ func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
|
|||||||
return &media, nil
|
return &media, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMediaBatch charge les médias de plusieurs produits en une seule requête.
|
||||||
|
func (d *Database) GetMediaBatch(productIDs []int) map[int][]models.Media {
|
||||||
|
result := make(map[int][]models.Media, len(productIDs))
|
||||||
|
if len(productIDs) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var mediaList []models.Media
|
||||||
|
d.GDB.Raw(`SELECT id, product_id, url, type, key, created_at FROM media WHERE product_id IN ? ORDER BY product_id ASC, id ASC`, productIDs).Scan(&mediaList)
|
||||||
|
for _, m := range mediaList {
|
||||||
|
result[m.ProductID] = append(result[m.ProductID], m)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
|
func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
|
||||||
log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID)
|
log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID)
|
||||||
|
|
||||||
@@ -179,7 +198,7 @@ func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
|
|||||||
|
|
||||||
var mediaList []models.Media
|
var mediaList []models.Media
|
||||||
err := d.GDB.Raw(`
|
err := d.GDB.Raw(`
|
||||||
SELECT id, product_id, url, type, created_at
|
SELECT id, product_id, url, type, key, created_at
|
||||||
FROM media WHERE product_id = ?
|
FROM media WHERE product_id = ?
|
||||||
ORDER BY id ASC`, productID).Scan(&mediaList).Error
|
ORDER BY id ASC`, productID).Scan(&mediaList).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
|
|||||||
pipe := Redis.Pipeline()
|
pipe := Redis.Pipeline()
|
||||||
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
||||||
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
||||||
pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
pipe.Expire(RedisCtx, notifKey, time.Hour)
|
||||||
pipe.Exec(RedisCtx) //nolint
|
pipe.Exec(RedisCtx) //nolint
|
||||||
|
|
||||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||||
@@ -54,7 +54,6 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotifyLivreur envoie une notification in-app (Redis) à un livreur
|
|
||||||
func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error {
|
func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error {
|
||||||
notifKey := fmt.Sprintf("notifications:%s", username)
|
notifKey := fmt.Sprintf("notifications:%s", username)
|
||||||
|
|
||||||
@@ -70,7 +69,7 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
|
|||||||
pipe2 := Redis.Pipeline()
|
pipe2 := Redis.Pipeline()
|
||||||
pipe2.LPush(RedisCtx, notifKey, notifJSON)
|
pipe2.LPush(RedisCtx, notifKey, notifJSON)
|
||||||
pipe2.LTrim(RedisCtx, notifKey, 0, 199)
|
pipe2.LTrim(RedisCtx, notifKey, 0, 199)
|
||||||
pipe2.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
pipe2.Expire(RedisCtx, notifKey, time.Hour)
|
||||||
pipe2.Exec(RedisCtx) //nolint
|
pipe2.Exec(RedisCtx) //nolint
|
||||||
|
|
||||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||||
@@ -88,7 +87,6 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotifyAllAdminCabine stocke une notification Redis pour tous les admins/cabines
|
|
||||||
func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) {
|
func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) {
|
||||||
var users []struct {
|
var users []struct {
|
||||||
Username string `gorm:"column:username"`
|
Username string `gorm:"column:username"`
|
||||||
@@ -114,7 +112,7 @@ func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryA
|
|||||||
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
||||||
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
||||||
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
||||||
pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
pipe.Expire(RedisCtx, notifKey, time.Hour)
|
||||||
}
|
}
|
||||||
pipe.Exec(RedisCtx) //nolint
|
pipe.Exec(RedisCtx) //nolint
|
||||||
|
|
||||||
@@ -130,7 +128,6 @@ func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryA
|
|||||||
log.Printf("📬 [ADMIN_NOTIF] Notif Redis (%d users) pour commande #%d", count, commandID)
|
log.Printf("📬 [ADMIN_NOTIF] Notif Redis (%d users) pour commande #%d", count, commandID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotifyAllAdminCabineAlert envoie une notification Redis à tous les admins/cabines lors d'une alerte
|
|
||||||
func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alertMessage string) {
|
func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alertMessage string) {
|
||||||
var users []struct {
|
var users []struct {
|
||||||
Username string `gorm:"column:username"`
|
Username string `gorm:"column:username"`
|
||||||
@@ -156,7 +153,7 @@ func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alert
|
|||||||
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
||||||
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
||||||
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
||||||
pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
pipe.Expire(RedisCtx, notifKey, time.Hour)
|
||||||
}
|
}
|
||||||
pipe.Exec(RedisCtx) //nolint
|
pipe.Exec(RedisCtx) //nolint
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gestion/models"
|
"gestion/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (d *Database) SetClientParrain(clientUsername, parrainUsername string) error {
|
func (d *Database) SetClientParrain(clientUsername, parrainUsername string) error {
|
||||||
@@ -18,13 +21,44 @@ func (d *Database) SetClientParrain(clientUsername, parrainUsername string) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetClientParrainAndCredit assigne un parrain à un client et crédite le parrain
|
||||||
|
// dans une seule transaction, pour éviter un lien parrain enregistré sans le crédit associé.
|
||||||
|
func (d *Database) SetClientParrainAndCredit(clientUsername, parrainUsername string, creditAmount float64) error {
|
||||||
|
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
result := tx.Model(&models.Client{}).
|
||||||
|
Where("username = ? AND (parrain IS NULL OR parrain = '')", clientUsername).
|
||||||
|
Update("parrain", parrainUsername)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("client introuvable ou parrain déjà défini")
|
||||||
|
}
|
||||||
|
|
||||||
|
if creditAmount > 0 {
|
||||||
|
result = tx.Model(&models.Client{}).Where("username = ?", parrainUsername).
|
||||||
|
Updates(map[string]any{"referral_balance": gorm.Expr("referral_balance + ?", creditAmount)})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("parrain non trouvé")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Database) GetClientParrain(clientUsername string) (string, error) {
|
func (d *Database) GetClientParrain(clientUsername string) (string, error) {
|
||||||
var parrain string
|
var parrain sql.NullString
|
||||||
err := d.GDB.Table("clients").
|
err := d.GDB.Table("clients").
|
||||||
Select("parrain").
|
Select("parrain").
|
||||||
Where("username = ?", clientUsername).
|
Where("username = ?", clientUsername).
|
||||||
Scan(&parrain).Error
|
Scan(&parrain).Error
|
||||||
return parrain, err
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return parrain.String, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) GetClientsByParrain(parrainUsername string) ([]models.Client, error) {
|
func (d *Database) GetClientsByParrain(parrainUsername string) ([]models.Client, error) {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"gestion/models"
|
"gestion/models"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateProduct crée un nouveau produit avec ses prix
|
// CreateProduct crée un nouveau produit avec ses prix
|
||||||
@@ -73,14 +75,21 @@ func (d *Database) CreateProduct(product any) error {
|
|||||||
p.SetCreatedAt(result.CreatedAt)
|
p.SetCreatedAt(result.CreatedAt)
|
||||||
p.SetUpdatedAt(result.UpdatedAt)
|
p.SetUpdatedAt(result.UpdatedAt)
|
||||||
|
|
||||||
for i, price := range p.GetPrices() {
|
if rawPrices := p.GetPrices(); len(rawPrices) > 0 {
|
||||||
err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, ?, ?, ?)`,
|
priceRows := make([]models.ProductPrice, len(rawPrices))
|
||||||
result.ID, price.Quantity, price.Price, price.ActivePrice).Error
|
for i, price := range rawPrices {
|
||||||
if err != nil {
|
priceRows[i] = models.ProductPrice{
|
||||||
log.Printf("❌ [DB CreateProduct] Erreur insertion prix[%d]: %v", i, err)
|
ProductID: result.ID,
|
||||||
|
Quantity: price.Quantity,
|
||||||
|
Price: price.Price,
|
||||||
|
ActivePrice: price.ActivePrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := d.GDB.Create(&priceRows).Error; err != nil {
|
||||||
|
log.Printf("❌ [DB CreateProduct] Erreur insertion prix batch: %v", err)
|
||||||
return fmt.Errorf("erreur insertion prix: %v", err)
|
return fmt.Errorf("erreur insertion prix: %v", err)
|
||||||
}
|
}
|
||||||
log.Printf("✅ [DB CreateProduct] Prix[%d] inséré: quantity=%g, price=%.2f", i, price.Quantity, price.Price)
|
log.Printf("✅ [DB CreateProduct] %d prix insérés", len(priceRows))
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", result.ID)
|
log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", result.ID)
|
||||||
@@ -136,6 +145,27 @@ func (d *Database) GetProductNamesByIDs(ids []int) (map[int]string, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProductCategoriesByIDs retourne un map id→category pour une liste d'IDs.
|
||||||
|
func (d *Database) GetProductCategoriesByIDs(ids []int) (map[int]string, error) {
|
||||||
|
result := make(map[int]string, len(ids))
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
rows, err := d.GDB.Raw(`SELECT id, category FROM products WHERE id IN ?`, ids).Rows()
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id int
|
||||||
|
var category string
|
||||||
|
if err := rows.Scan(&id, &category); err == nil {
|
||||||
|
result[id] = category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Database) GetAllProducts() ([]models.Product, error) {
|
func (d *Database) GetAllProducts() ([]models.Product, error) {
|
||||||
log.Println("📦 [GetAllProducts] START")
|
log.Println("📦 [GetAllProducts] START")
|
||||||
|
|
||||||
@@ -149,23 +179,22 @@ func (d *Database) GetAllProducts() ([]models.Product, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range products {
|
productIDs := make([]int, len(products))
|
||||||
prices, err := d.GetProductPrices(products[i].ID)
|
for i, p := range products {
|
||||||
if err != nil {
|
productIDs[i] = p.ID
|
||||||
log.Printf("⚠️ [GetAllProducts] Erreur loading prices for product %d: %v", products[i].ID, err)
|
|
||||||
products[i].Prices = []models.ProductPrice{}
|
|
||||||
} else {
|
|
||||||
products[i].Prices = prices
|
|
||||||
log.Printf("✅ [GetAllProducts] Loaded %d prices for product %d", len(prices), products[i].ID)
|
|
||||||
}
|
}
|
||||||
|
allPrices := d.GetProductPricesBatch(productIDs)
|
||||||
media, err := d.GetMediaByProductID(products[i].ID)
|
allMedia := d.GetMediaBatch(productIDs)
|
||||||
if err != nil {
|
for i := range products {
|
||||||
log.Printf("⚠️ [GetAllProducts] Erreur loading media for product %d: %v", products[i].ID, err)
|
if prices, ok := allPrices[products[i].ID]; ok {
|
||||||
products[i].Media = []models.Media{}
|
products[i].Prices = prices
|
||||||
} else {
|
} else {
|
||||||
|
products[i].Prices = []models.ProductPrice{}
|
||||||
|
}
|
||||||
|
if media, ok := allMedia[products[i].ID]; ok {
|
||||||
products[i].Media = media
|
products[i].Media = media
|
||||||
log.Printf("✅ [GetAllProducts] Loaded %d media for product %d", len(media), products[i].ID)
|
} else {
|
||||||
|
products[i].Media = []models.Media{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,14 +217,16 @@ func (d *Database) GetProductsByCategory(category string) ([]models.Product, err
|
|||||||
return nil, fmt.Errorf("erreur lors de la récupération des produits: %w", err)
|
return nil, fmt.Errorf("erreur lors de la récupération des produits: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
catProductIDs := make([]int, len(products))
|
||||||
|
for i, p := range products {
|
||||||
|
catProductIDs[i] = p.ID
|
||||||
|
}
|
||||||
|
catPrices := d.GetProductPricesBatch(catProductIDs)
|
||||||
for i := range products {
|
for i := range products {
|
||||||
prices, err := d.GetProductPrices(products[i].ID)
|
if prices, ok := catPrices[products[i].ID]; ok {
|
||||||
if err != nil {
|
|
||||||
log.Printf("⚠️ [GetProductsByCategory] Erreur loading prices for product %d: %v", products[i].ID, err)
|
|
||||||
products[i].Prices = []models.ProductPrice{}
|
|
||||||
} else {
|
|
||||||
products[i].Prices = prices
|
products[i].Prices = prices
|
||||||
log.Printf("✅ [GetProductsByCategory] Loaded %d prices for product %d", len(prices), products[i].ID)
|
} else {
|
||||||
|
products[i].Prices = []models.ProductPrice{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,9 +246,17 @@ func (d *Database) UpdateProduct(productID int, name, category, description, uni
|
|||||||
|
|
||||||
d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
|
d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
|
||||||
|
|
||||||
for _, price := range prices {
|
if len(prices) > 0 {
|
||||||
if err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, ?, ?, ?)`,
|
priceRows := make([]models.ProductPrice, len(prices))
|
||||||
productID, price.Quantity, price.Price, price.ActivePrice).Error; err != nil {
|
for i, price := range prices {
|
||||||
|
priceRows[i] = models.ProductPrice{
|
||||||
|
ProductID: productID,
|
||||||
|
Quantity: price.Quantity,
|
||||||
|
Price: price.Price,
|
||||||
|
ActivePrice: price.ActivePrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := d.GDB.Create(&priceRows).Error; err != nil {
|
||||||
return fmt.Errorf("erreur insertion prix: %w", err)
|
return fmt.Errorf("erreur insertion prix: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,15 +264,28 @@ func (d *Database) UpdateProduct(productID int, name, category, description, uni
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetProductStock fixe le stock à une valeur absolue. Le verrou FOR UPDATE
|
||||||
|
// sérialise cette écriture avec les décréments du checkout (db_commands.go) :
|
||||||
|
// sans lui, une modification admin pourrait écraser silencieusement le
|
||||||
|
// décrément d'une commande passée au même instant sur le même produit.
|
||||||
func (d *Database) SetProductStock(productID int, stock float64) error {
|
func (d *Database) SetProductStock(productID int, stock float64) error {
|
||||||
result := d.GDB.Exec(`UPDATE products SET stock = ?, updated_at = ? WHERE id = ?`,
|
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
stock, time.Now(), productID)
|
var exists int
|
||||||
if result.Error != nil {
|
if err := tx.Raw(`SELECT 1 FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&exists).Error; err != nil {
|
||||||
return fmt.Errorf("erreur mise à jour stock: %w", result.Error)
|
return fmt.Errorf("erreur verrouillage produit: %w", err)
|
||||||
}
|
}
|
||||||
if result.RowsAffected == 0 {
|
if exists == 0 {
|
||||||
return fmt.Errorf("produit non trouvé")
|
return fmt.Errorf("produit non trouvé")
|
||||||
}
|
}
|
||||||
|
if err := tx.Exec(`UPDATE products SET stock = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
stock, time.Now(), productID).Error; err != nil {
|
||||||
|
return fmt.Errorf("erreur mise à jour stock: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ func (d *Database) GetProductPrices(productID int) ([]models.ProductPrice, error
|
|||||||
return prices, nil
|
return prices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProductPricesBatch charge les prix de plusieurs produits en une seule requête.
|
||||||
|
func (d *Database) GetProductPricesBatch(productIDs []int) map[int][]models.ProductPrice {
|
||||||
|
result := make(map[int][]models.ProductPrice, len(productIDs))
|
||||||
|
if len(productIDs) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var prices []models.ProductPrice
|
||||||
|
d.GDB.Where("product_id IN ?", productIDs).Order("product_id ASC, quantity ASC").Find(&prices)
|
||||||
|
for _, p := range prices {
|
||||||
|
result[p.ProductID] = append(result[p.ProductID], p)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Database) AddActivePrice(priceID int) error {
|
func (d *Database) AddActivePrice(priceID int) error {
|
||||||
result := d.GDB.Model(&models.ProductPrice{}).
|
result := d.GDB.Model(&models.ProductPrice{}).
|
||||||
Where("id = ?", priceID).
|
Where("id = ?", priceID).
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gestion/models"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolvePromotionDiscount retourne le pourcentage de réduction actif pour un
|
||||||
|
// produit, sa catégorie catalogue et une quantité donnés, si une promotion
|
||||||
|
// configurée dans les settings couvre exactement ce couple (produit,
|
||||||
|
// quantité) — contrairement aux récompenses, aucun seuil de points n'entre
|
||||||
|
// en jeu : la promotion s'applique à toute commande de cette quantité.
|
||||||
|
func ResolvePromotionDiscount(settings *models.AppSettings, productID int, category string, quantity float64) (float64, bool) {
|
||||||
|
if settings == nil || !settings.PromotionsEnabled {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
for _, promo := range settings.Promotions {
|
||||||
|
if promo.Category != category || promo.DiscountPercent <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if promo.AllProducts {
|
||||||
|
if promo.Quantity == quantity {
|
||||||
|
return promo.DiscountPercent, true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, pq := range promo.Products {
|
||||||
|
if pq.ProductID == productID && pq.Quantity == quantity {
|
||||||
|
return promo.DiscountPercent, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyPromotionToPrice applique la réduction (si une promotion couvre ce
|
||||||
|
// produit/quantité/catégorie) au prix catalogue donné, arrondi au centime.
|
||||||
|
func (d *Database) ApplyPromotionToPrice(productID int, category string, quantity, price float64) (float64, bool) {
|
||||||
|
settings, err := d.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
return price, false
|
||||||
|
}
|
||||||
|
discount, ok := ResolvePromotionDiscount(&settings, productID, category, quantity)
|
||||||
|
if !ok {
|
||||||
|
return price, false
|
||||||
|
}
|
||||||
|
return math.Round(price*(1-discount/100)*100) / 100, true
|
||||||
|
}
|
||||||
@@ -189,7 +189,7 @@ func (d *Database) RemoveCommandFromAllQueues(commandID int, deliveryman string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Vérifier toutes les autres queues de livreurs (au cas où)
|
// 4. Vérifier toutes les autres queues de livreurs (au cas où)
|
||||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
keys, _ := scanRedisKeys("queue:deliveryman:*")
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -27,25 +27,6 @@ func (d *Database) GetClientCancellationsCount(username string) (int, error) {
|
|||||||
return result.Count, nil
|
return result.Count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncrementClientCancellationsCount incrémente le compteur d'annulations
|
|
||||||
func (d *Database) IncrementClientCancellationsCount(username string) error {
|
|
||||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Updates(map[string]any{
|
|
||||||
"cancellations_count": gorm.Expr("COALESCE(cancellations_count, 0) + 1"),
|
|
||||||
})
|
|
||||||
if result.Error != nil {
|
|
||||||
log.Printf("❌ [IncrementCancellations] Erreur: %v", result.Error)
|
|
||||||
return fmt.Errorf("erreur incrémentation: %w", result.Error)
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return fmt.Errorf("client non trouvé")
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("client:%s", username)
|
|
||||||
Redis.Del(RedisCtx, cacheKey)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// penaltyForCount retourne le montant du palier applicable pour un nombre d'annulations donné
|
// penaltyForCount retourne le montant du palier applicable pour un nombre d'annulations donné
|
||||||
func penaltyForCount(count int, tiers []models.PenaltyTier) int {
|
func penaltyForCount(count int, tiers []models.PenaltyTier) int {
|
||||||
if len(tiers) == 0 {
|
if len(tiers) == 0 {
|
||||||
@@ -64,6 +45,16 @@ func penaltyForCount(count int, tiers []models.PenaltyTier) int {
|
|||||||
return sorted[len(sorted)-1].Amount
|
return sorted[len(sorted)-1].Amount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// penaltyTiers charge le barème de pénalités configuré, avec repli sur le barème par défaut si les settings sont indisponibles
|
||||||
|
func (d *Database) penaltyTiers(logCtx string) []models.PenaltyTier {
|
||||||
|
settings, err := d.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ [%s] Impossible de charger les settings, barème par défaut: %v", logCtx, err)
|
||||||
|
settings = DefaultSettings()
|
||||||
|
}
|
||||||
|
return settings.PenaltyTiers
|
||||||
|
}
|
||||||
|
|
||||||
// CalculateCancellationPenalty calcule la pénalité selon l'historique et le barème configuré
|
// CalculateCancellationPenalty calcule la pénalité selon l'historique et le barème configuré
|
||||||
func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
|
func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
|
||||||
count, err := d.GetClientCancellationsCount(username)
|
count, err := d.GetClientCancellationsCount(username)
|
||||||
@@ -71,13 +62,7 @@ func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := d.GetSettings()
|
penalty := penaltyForCount(count, d.penaltyTiers("CalculatePenalty"))
|
||||||
if err != nil {
|
|
||||||
log.Printf("⚠️ [CalculatePenalty] Impossible de charger les settings, barème par défaut: %v", err)
|
|
||||||
settings = DefaultSettings()
|
|
||||||
}
|
|
||||||
|
|
||||||
penalty := penaltyForCount(count, settings.PenaltyTiers)
|
|
||||||
|
|
||||||
log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points",
|
log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points",
|
||||||
username, count, penalty)
|
username, count, penalty)
|
||||||
@@ -85,29 +70,47 @@ func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
|
|||||||
return penalty, nil
|
return penalty, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyCancellationPenalty applique une pénalité et incrémente le compteur d'annulations
|
// ApplyCancellationPenalty applique une pénalité (cumulative) et incrémente le compteur d'annulations.
|
||||||
|
// Verrouillée via FOR UPDATE pour éviter qu'un appel concurrent (même client, deux livraisons en parallèle)
|
||||||
|
// calcule la pénalité sur un compteur pas encore à jour, et l'amende s'additionne au lieu d'écraser
|
||||||
|
// le solde existant (cohérent avec CancelCommandAtomic pour l'annulation côté client).
|
||||||
func (d *Database) ApplyCancellationPenalty(username string) (int, error) {
|
func (d *Database) ApplyCancellationPenalty(username string) (int, error) {
|
||||||
penalty, err := d.CalculateCancellationPenalty(username)
|
tiers := d.penaltyTiers("ApplyCancellationPenalty")
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
var penalty int
|
||||||
|
|
||||||
|
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var count int
|
||||||
|
if err := tx.Raw(`
|
||||||
|
SELECT COALESCE(cancellations_count, 0) FROM clients
|
||||||
|
WHERE username = ? FOR UPDATE`, username).Scan(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("erreur récupération compteur: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
penalty = penaltyForCount(count, tiers)
|
||||||
|
|
||||||
log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty)
|
log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty)
|
||||||
|
|
||||||
if err := d.IncrementClientCancellationsCount(username); err != nil {
|
result := tx.Exec(`
|
||||||
return 0, err
|
UPDATE clients
|
||||||
}
|
SET cancellations_count = COALESCE(cancellations_count, 0) + 1,
|
||||||
|
amende = amende + ?,
|
||||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("amende", float64(penalty))
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE username = ?`, penalty, username)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", result.Error)
|
log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", result.Error)
|
||||||
return 0, fmt.Errorf("erreur application pénalité: %w", result.Error)
|
return fmt.Errorf("erreur application pénalité: %w", result.Error)
|
||||||
}
|
}
|
||||||
if result.RowsAffected == 0 {
|
if result.RowsAffected == 0 {
|
||||||
return 0, fmt.Errorf("client non trouvé")
|
return fmt.Errorf("client non trouvé")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("✅ [ApplyCancellationPenalty] Amende %d appliquée à %s", penalty, username)
|
log.Printf("✅ [ApplyCancellationPenalty] Amende %d appliquée à %s", penalty, username)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("client:%s", username)
|
cacheKey := fmt.Sprintf("client:%s", username)
|
||||||
Redis.Del(RedisCtx, cacheKey)
|
Redis.Del(RedisCtx, cacheKey)
|
||||||
|
|||||||
@@ -72,6 +72,18 @@ func DefaultSettings() models.AppSettings {
|
|||||||
Mode: "single",
|
Mode: "single",
|
||||||
CategoryRoutes: []models.CategoryRoute{},
|
CategoryRoutes: []models.CategoryRoute{},
|
||||||
},
|
},
|
||||||
|
AdminColorPrimary: "#7c3aed",
|
||||||
|
AdminColorSecondary: "#000000",
|
||||||
|
AdminColorSuccess: "#4ade80",
|
||||||
|
AdminColorDanger: "#ef4444",
|
||||||
|
AdminColorWarning: "#f59e0b",
|
||||||
|
ClientColorPrimary: "#7c3aed",
|
||||||
|
ClientColorSecondary: "#000000",
|
||||||
|
ClientColorSuccess: "#4ade80",
|
||||||
|
ClientColorDanger: "#ef4444",
|
||||||
|
ClientColorWarning: "#f59e0b",
|
||||||
|
ClientTitleGradientFrom: "#a78bfa",
|
||||||
|
ClientTitleGradientTo: "#22d3ee",
|
||||||
DeliverySchedule: DefaultDeliverySchedule(),
|
DeliverySchedule: DefaultDeliverySchedule(),
|
||||||
PostalZones: []models.PostalZone{
|
PostalZones: []models.PostalZone{
|
||||||
{Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}},
|
{Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}},
|
||||||
@@ -103,6 +115,11 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
|||||||
switch row.Key {
|
switch row.Key {
|
||||||
case "penalties_enabled":
|
case "penalties_enabled":
|
||||||
settings.PenaltiesEnabled = row.Value == "true"
|
settings.PenaltiesEnabled = row.Value == "true"
|
||||||
|
case "penalty_tiers":
|
||||||
|
var tiers []models.PenaltyTier
|
||||||
|
if err := json.Unmarshal([]byte(row.Value), &tiers); err == nil {
|
||||||
|
settings.PenaltyTiers = tiers
|
||||||
|
}
|
||||||
case "show_amende_score":
|
case "show_amende_score":
|
||||||
settings.ShowAmendeScore = row.Value == "true"
|
settings.ShowAmendeScore = row.Value == "true"
|
||||||
case "points_enabled":
|
case "points_enabled":
|
||||||
@@ -113,10 +130,32 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
|||||||
settings.PointsPools = pools
|
settings.PointsPools = pools
|
||||||
}
|
}
|
||||||
case "points_reward":
|
case "points_reward":
|
||||||
|
// row.Value peut valoir la chaîne littérale "null" (récompense
|
||||||
|
// désactivée puis sauvegardée : json.Marshal(nil *PointsReward)
|
||||||
|
// produit "null"). json.Unmarshal d'un null JSON dans une valeur
|
||||||
|
// non-pointeur est un no-op sans erreur (voir doc encoding/json),
|
||||||
|
// donc sans ce garde-fou &reward pointerait vers une struct vide
|
||||||
|
// mais non-nil, et la récompense réapparaîtrait activée.
|
||||||
|
if row.Value != "null" && row.Value != "" {
|
||||||
var reward models.PointsReward
|
var reward models.PointsReward
|
||||||
if err := json.Unmarshal([]byte(row.Value), &reward); err == nil {
|
if err := json.Unmarshal([]byte(row.Value), &reward); err == nil {
|
||||||
settings.PointsReward = &reward
|
settings.PointsReward = &reward
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
case "promotions_enabled":
|
||||||
|
settings.PromotionsEnabled = row.Value == "true"
|
||||||
|
case "promotions":
|
||||||
|
var promotions []models.CategoryPromotionConfig
|
||||||
|
if err := json.Unmarshal([]byte(row.Value), &promotions); err == nil {
|
||||||
|
settings.Promotions = promotions
|
||||||
|
}
|
||||||
|
case "free_gifts_enabled":
|
||||||
|
settings.FreeGiftsEnabled = row.Value == "true"
|
||||||
|
case "free_gifts":
|
||||||
|
var freeGifts []models.CategoryFreeGiftConfig
|
||||||
|
if err := json.Unmarshal([]byte(row.Value), &freeGifts); err == nil {
|
||||||
|
settings.FreeGifts = freeGifts
|
||||||
|
}
|
||||||
case "referral_enabled":
|
case "referral_enabled":
|
||||||
settings.ReferralEnabled = row.Value == "true"
|
settings.ReferralEnabled = row.Value == "true"
|
||||||
case "referral_amount":
|
case "referral_amount":
|
||||||
@@ -163,6 +202,30 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
|||||||
settings.Telegram2FAEnabled = row.Value == "true"
|
settings.Telegram2FAEnabled = row.Value == "true"
|
||||||
case "shop_name":
|
case "shop_name":
|
||||||
settings.ShopName = row.Value
|
settings.ShopName = row.Value
|
||||||
|
case "admin_color_primary":
|
||||||
|
settings.AdminColorPrimary = row.Value
|
||||||
|
case "admin_color_secondary":
|
||||||
|
settings.AdminColorSecondary = row.Value
|
||||||
|
case "admin_color_success":
|
||||||
|
settings.AdminColorSuccess = row.Value
|
||||||
|
case "admin_color_danger":
|
||||||
|
settings.AdminColorDanger = row.Value
|
||||||
|
case "admin_color_warning":
|
||||||
|
settings.AdminColorWarning = row.Value
|
||||||
|
case "client_color_primary":
|
||||||
|
settings.ClientColorPrimary = row.Value
|
||||||
|
case "client_color_secondary":
|
||||||
|
settings.ClientColorSecondary = row.Value
|
||||||
|
case "client_color_success":
|
||||||
|
settings.ClientColorSuccess = row.Value
|
||||||
|
case "client_color_danger":
|
||||||
|
settings.ClientColorDanger = row.Value
|
||||||
|
case "client_color_warning":
|
||||||
|
settings.ClientColorWarning = row.Value
|
||||||
|
case "client_title_gradient_from":
|
||||||
|
settings.ClientTitleGradientFrom = row.Value
|
||||||
|
case "client_title_gradient_to":
|
||||||
|
settings.ClientTitleGradientTo = row.Value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return settings, nil
|
return settings, nil
|
||||||
@@ -177,6 +240,14 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
|||||||
return "false"
|
return "false"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.PenaltyTiers == nil {
|
||||||
|
s.PenaltyTiers = []models.PenaltyTier{}
|
||||||
|
}
|
||||||
|
tiersJSON, err := json.Marshal(s.PenaltyTiers)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("erreur sérialisation penalty_tiers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if s.PointsPools == nil {
|
if s.PointsPools == nil {
|
||||||
s.PointsPools = []models.PointsPool{}
|
s.PointsPools = []models.PointsPool{}
|
||||||
}
|
}
|
||||||
@@ -194,11 +265,52 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
|||||||
return fmt.Errorf("erreur sérialisation pools: %w", err)
|
return fmt.Errorf("erreur sérialisation pools: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.PointsReward != nil {
|
||||||
|
for i := range s.PointsReward.CategoryConfigs {
|
||||||
|
if s.PointsReward.CategoryConfigs[i].Products == nil {
|
||||||
|
s.PointsReward.CategoryConfigs[i].Products = []models.RewardProductQuantity{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
rewardJSON, err := json.Marshal(s.PointsReward)
|
rewardJSON, err := json.Marshal(s.PointsReward)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("erreur sérialisation points_reward: %w", err)
|
return fmt.Errorf("erreur sérialisation points_reward: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.Promotions == nil {
|
||||||
|
s.Promotions = []models.CategoryPromotionConfig{}
|
||||||
|
}
|
||||||
|
for i := range s.Promotions {
|
||||||
|
if s.Promotions[i].Products == nil {
|
||||||
|
s.Promotions[i].Products = []models.PromotionProductQuantity{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promotionsJSON, err := json.Marshal(s.Promotions)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("erreur sérialisation promotions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.FreeGifts == nil {
|
||||||
|
s.FreeGifts = []models.CategoryFreeGiftConfig{}
|
||||||
|
}
|
||||||
|
for i := range s.FreeGifts {
|
||||||
|
if s.FreeGifts[i].Tiers == nil {
|
||||||
|
s.FreeGifts[i].Tiers = []models.FreeGiftTier{}
|
||||||
|
}
|
||||||
|
if s.FreeGifts[i].Products == nil {
|
||||||
|
s.FreeGifts[i].Products = []models.FreeGiftProductQuantity{}
|
||||||
|
}
|
||||||
|
for j := range s.FreeGifts[i].Products {
|
||||||
|
if s.FreeGifts[i].Products[j].Tiers == nil {
|
||||||
|
s.FreeGifts[i].Products[j].Tiers = []models.FreeGiftTier{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
freeGiftsJSON, err := json.Marshal(s.FreeGifts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("erreur sérialisation free_gifts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if s.NowPaymentsCurrencies == nil {
|
if s.NowPaymentsCurrencies == nil {
|
||||||
s.NowPaymentsCurrencies = []string{}
|
s.NowPaymentsCurrencies = []string{}
|
||||||
}
|
}
|
||||||
@@ -233,10 +345,15 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
|||||||
}
|
}
|
||||||
pairs := [][2]string{
|
pairs := [][2]string{
|
||||||
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
|
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
|
||||||
|
{"penalty_tiers", string(tiersJSON)},
|
||||||
{"show_amende_score", boolStr(s.ShowAmendeScore)},
|
{"show_amende_score", boolStr(s.ShowAmendeScore)},
|
||||||
{"points_enabled", boolStr(s.PointsEnabled)},
|
{"points_enabled", boolStr(s.PointsEnabled)},
|
||||||
{"points_pools", string(poolsJSON)},
|
{"points_pools", string(poolsJSON)},
|
||||||
{"points_reward", string(rewardJSON)},
|
{"points_reward", string(rewardJSON)},
|
||||||
|
{"promotions_enabled", boolStr(s.PromotionsEnabled)},
|
||||||
|
{"promotions", string(promotionsJSON)},
|
||||||
|
{"free_gifts_enabled", boolStr(s.FreeGiftsEnabled)},
|
||||||
|
{"free_gifts", string(freeGiftsJSON)},
|
||||||
{"referral_enabled", boolStr(s.ReferralEnabled)},
|
{"referral_enabled", boolStr(s.ReferralEnabled)},
|
||||||
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
|
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
|
||||||
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
|
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
|
||||||
@@ -253,6 +370,18 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
|||||||
{"delivery_mode", string(deliveryModeJSON)},
|
{"delivery_mode", string(deliveryModeJSON)},
|
||||||
{"shop_name", s.ShopName},
|
{"shop_name", s.ShopName},
|
||||||
{"contact_telegram", s.ContactTelegram},
|
{"contact_telegram", s.ContactTelegram},
|
||||||
|
{"admin_color_primary", s.AdminColorPrimary},
|
||||||
|
{"admin_color_secondary", s.AdminColorSecondary},
|
||||||
|
{"admin_color_success", s.AdminColorSuccess},
|
||||||
|
{"admin_color_danger", s.AdminColorDanger},
|
||||||
|
{"admin_color_warning", s.AdminColorWarning},
|
||||||
|
{"client_color_primary", s.ClientColorPrimary},
|
||||||
|
{"client_color_secondary", s.ClientColorSecondary},
|
||||||
|
{"client_color_success", s.ClientColorSuccess},
|
||||||
|
{"client_color_danger", s.ClientColorDanger},
|
||||||
|
{"client_color_warning", s.ClientColorWarning},
|
||||||
|
{"client_title_gradient_from", s.ClientTitleGradientFrom},
|
||||||
|
{"client_title_gradient_to", s.ClientTitleGradientTo},
|
||||||
}
|
}
|
||||||
|
|
||||||
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
|
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||||
|
|||||||
@@ -0,0 +1,495 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gestion/models"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Reset des sections de stats ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ResetAdminStat enregistre (ou met à jour) la date de reset pour une section.
|
||||||
|
func (d *Database) ResetAdminStat(section string) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`
|
||||||
|
return d.GDB.Exec(upsert, section, now).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadResetAt lit la date de reset stockée pour une clé donnée (zero value si absente).
|
||||||
|
func (d *Database) ReadResetAt(key string) time.Time {
|
||||||
|
var row struct {
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
err := d.GDB.Table("app_settings").
|
||||||
|
Select("value").
|
||||||
|
Where("key = ?", key).
|
||||||
|
Scan(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
if row.Value != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, row.Value); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Construction des clauses WHERE (filtrage par reset) ────────────────────
|
||||||
|
|
||||||
|
// statusFilterClause construit "<baseStatus> [AND <dateColumn> >= ?]" et
|
||||||
|
// renvoie la clause ainsi que les arguments à binder, dans l'ordre.
|
||||||
|
// dateColumn doit être qualifié par l'alias de table (ex: "c.created_at") dès
|
||||||
|
// que la requête appelante fait une jointure où plusieurs tables possèdent une
|
||||||
|
// colonne created_at, sous peine d'erreur Postgres "ambiguous column".
|
||||||
|
func statusFilterClause(baseStatus string, resetAt time.Time, dateColumn string) (string, []interface{}) {
|
||||||
|
if !resetAt.IsZero() {
|
||||||
|
return baseStatus + " AND " + dateColumn + " >= ?", []interface{}{resetAt.Format(time.RFC3339)}
|
||||||
|
}
|
||||||
|
return baseStatus, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminStatsFilters regroupe les dates de reset pour chaque section, lues une
|
||||||
|
// seule fois puis transmises aux différentes requêtes.
|
||||||
|
type AdminStatsFilters struct {
|
||||||
|
ResetCommandes time.Time
|
||||||
|
ResetRevenus time.Time
|
||||||
|
ResetProduits time.Time
|
||||||
|
ResetHeures time.Time
|
||||||
|
ResetJours time.Time
|
||||||
|
ResetDoses time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAdminStatsFilters lit toutes les dates de reset en une seule requête.
|
||||||
|
func (d *Database) LoadAdminStatsFilters() AdminStatsFilters {
|
||||||
|
keys := []string{
|
||||||
|
"stats_reset_commandes_at",
|
||||||
|
"stats_reset_revenus_at",
|
||||||
|
"stats_reset_produits_at",
|
||||||
|
"stats_reset_heures_at",
|
||||||
|
"stats_reset_jours_at",
|
||||||
|
"stats_reset_doses_at",
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
Key string `gorm:"column:key"`
|
||||||
|
Value string `gorm:"column:value"`
|
||||||
|
}
|
||||||
|
d.GDB.Table("app_settings").Select("key, value").Where("key IN ?", keys).Scan(&rows)
|
||||||
|
|
||||||
|
m := make(map[string]time.Time, len(keys))
|
||||||
|
for _, r := range rows {
|
||||||
|
if t, err := time.Parse(time.RFC3339, r.Value); err == nil {
|
||||||
|
m[r.Key] = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AdminStatsFilters{
|
||||||
|
ResetCommandes: m["stats_reset_commandes_at"],
|
||||||
|
ResetRevenus: m["stats_reset_revenus_at"],
|
||||||
|
ResetProduits: m["stats_reset_produits_at"],
|
||||||
|
ResetHeures: m["stats_reset_heures_at"],
|
||||||
|
ResetJours: m["stats_reset_jours_at"],
|
||||||
|
ResetDoses: m["stats_reset_doses_at"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commandes par jour de la semaine (non annulées) ─────────────────────────
|
||||||
|
|
||||||
|
func (d *Database) OrderPerDaysPerWeeks(wdRows *[]models.WeekdayRow, resetAt time.Time) error {
|
||||||
|
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||||
|
query := `
|
||||||
|
SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count
|
||||||
|
FROM commandes
|
||||||
|
WHERE ` + where + `
|
||||||
|
GROUP BY dow
|
||||||
|
ORDER BY dow
|
||||||
|
`
|
||||||
|
return d.GDB.Raw(query, args...).Scan(wdRows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commandes par jour sur 30 jours ──────────────────────────────────────────
|
||||||
|
|
||||||
|
func (d *Database) OrdersByDayLast30(dayRows *[]models.DayRow, resetAt time.Time) error {
|
||||||
|
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||||
|
query := `
|
||||||
|
SELECT DATE(created_at) AS day, COUNT(*) AS count
|
||||||
|
FROM commandes
|
||||||
|
WHERE created_at >= NOW() - INTERVAL '30 days'
|
||||||
|
AND ` + where + `
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY day
|
||||||
|
`
|
||||||
|
return d.GDB.Raw(query, args...).Scan(dayRows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Revenus par jour sur 30 jours (commandes approuvées) ─────────────────────
|
||||||
|
|
||||||
|
func (d *Database) RevenueByDayLast30(dayRevRows *[]models.DayRevenueRow, resetAt time.Time) error {
|
||||||
|
where, args := statusFilterClause("status = 'approved'", resetAt, "created_at")
|
||||||
|
query := `
|
||||||
|
SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||||
|
FROM commandes
|
||||||
|
WHERE created_at >= NOW() - INTERVAL '30 days'
|
||||||
|
AND ` + where + `
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY day
|
||||||
|
`
|
||||||
|
return d.GDB.Raw(query, args...).Scan(dayRevRows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commandes par jour sur un mois calendaire complet ────────────────────────
|
||||||
|
|
||||||
|
type DailyMonthStatRow struct {
|
||||||
|
Day time.Time
|
||||||
|
Count int
|
||||||
|
Revenue float64
|
||||||
|
Quantity float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsByDayForMonth applique resetCommandes au comptage (count) et à la
|
||||||
|
// quantité (quantity, qui reflète le volume de commandes comme count), et
|
||||||
|
// resetRevenus au revenu (revenue) — chaque métrique doit respecter la même
|
||||||
|
// section de reset que son équivalent dans le résumé global (TotalOrders /
|
||||||
|
// TotalRevenue), sous peine d'afficher des chiffres incohérents entre eux
|
||||||
|
// après une réinitialisation partielle.
|
||||||
|
func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time.Time, resetCommandes time.Time, resetRevenus time.Time) error {
|
||||||
|
start := time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
|
||||||
|
end := start.AddDate(0, 1, 0)
|
||||||
|
|
||||||
|
whereCount, argsCount := statusFilterClause("status != 'cancelled'", resetCommandes, "created_at")
|
||||||
|
whereRevenue, argsRevenue := statusFilterClause("status = 'approved'", resetRevenus, "created_at")
|
||||||
|
whereQuantity, argsQuantity := statusFilterClause("c.status != 'cancelled'", resetCommandes, "c.created_at")
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
d.day,
|
||||||
|
COALESCE(d.count, 0) AS count,
|
||||||
|
COALESCE(rv.revenue, 0) AS revenue,
|
||||||
|
COALESCE(qt.quantity, 0) AS quantity
|
||||||
|
FROM (
|
||||||
|
SELECT DATE(created_at) AS day, COUNT(*) AS count
|
||||||
|
FROM commandes
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
AND ` + whereCount + `
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
) d
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DATE(created_at) AS day,
|
||||||
|
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||||
|
FROM commandes
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
AND ` + whereRevenue + `
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
) rv ON rv.day = d.day
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DATE(c.created_at) AS day, SUM(ci.quantite) AS quantity
|
||||||
|
FROM commandes c
|
||||||
|
JOIN command_items ci ON ci.command_id = c.id
|
||||||
|
WHERE c.created_at >= ? AND c.created_at < ?
|
||||||
|
AND ` + whereQuantity + `
|
||||||
|
GROUP BY DATE(c.created_at)
|
||||||
|
) qt ON qt.day = d.day
|
||||||
|
ORDER BY d.day
|
||||||
|
`
|
||||||
|
|
||||||
|
// Ordre des "?" dans la requête : (start, end, [resetCommandes]) pour "d",
|
||||||
|
// puis (start, end, [resetRevenus]) pour "rv", puis (start, end, [resetCommandes]) pour "qt".
|
||||||
|
args := []interface{}{start, end}
|
||||||
|
args = append(args, argsCount...)
|
||||||
|
args = append(args, start, end)
|
||||||
|
args = append(args, argsRevenue...)
|
||||||
|
args = append(args, start, end)
|
||||||
|
args = append(args, argsQuantity...)
|
||||||
|
|
||||||
|
return d.GDB.Raw(query, args...).Scan(rows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrdersAndRevenueByHour renvoie, par heure, le nombre de commandes non annulées
|
||||||
|
// (volume d'activité) et le revenu confirmé (commandes approuvées uniquement —
|
||||||
|
// cohérent avec TotalRevenue/RevenueByDayLast30, pour ne pas compter comme
|
||||||
|
// "revenu" une commande encore en cours qui pourrait être annulée).
|
||||||
|
func (d *Database) OrdersAndRevenueByHour(hourRows *[]models.HourRow, resetAt time.Time) error {
|
||||||
|
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
EXTRACT(HOUR FROM created_at)::int AS hour,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'approved' THEN total_prix - COALESCE(referral_used, 0) ELSE 0 END), 0) AS revenue
|
||||||
|
FROM commandes
|
||||||
|
WHERE ` + where + `
|
||||||
|
GROUP BY hour
|
||||||
|
ORDER BY hour
|
||||||
|
`
|
||||||
|
return d.GDB.Raw(query, args...).Scan(hourRows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Top produits (quantité vendue) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
// TopProducts renvoie les produits les plus commandés. La quantité/le nombre de
|
||||||
|
// commandes reflètent l'activité (non annulées), le revenu ne compte que les
|
||||||
|
// commandes approuvées (revenu confirmé, cohérent avec le résumé global).
|
||||||
|
func (d *Database) TopProducts(prodRows *[]models.ProductRow, resetAt time.Time, limit int) error {
|
||||||
|
where, args := statusFilterClause("c.status != 'cancelled'", resetAt, "c.created_at")
|
||||||
|
args = append(args, limit)
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
ci.product_id,
|
||||||
|
ci.produit AS name,
|
||||||
|
SUM(ci.quantite) AS total_quantity,
|
||||||
|
COUNT(DISTINCT ci.command_id) AS order_count,
|
||||||
|
SUM(CASE WHEN c.status = 'approved'
|
||||||
|
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
|
||||||
|
ELSE 0 END) AS revenue,
|
||||||
|
COALESCE(p.category, '') AS category,
|
||||||
|
COALESCE(cat.color, '#7c3aed') AS category_color
|
||||||
|
FROM command_items ci
|
||||||
|
JOIN commandes c ON c.id = ci.command_id
|
||||||
|
LEFT JOIN products p ON p.id = ci.product_id
|
||||||
|
LEFT JOIN categories cat ON cat.name = p.category
|
||||||
|
WHERE ` + where + `
|
||||||
|
GROUP BY ci.product_id, ci.produit, p.category, cat.color
|
||||||
|
ORDER BY total_quantity DESC
|
||||||
|
LIMIT ?
|
||||||
|
`
|
||||||
|
return d.GDB.Raw(query, args...).Scan(prodRows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Répartition des doses/quantités par produit ──────────────────────────────
|
||||||
|
|
||||||
|
// QuantityBreakdown : quantité/nombre de commandes reflètent l'activité (non
|
||||||
|
// annulées), le revenu ne compte que les commandes approuvées (revenu confirmé).
|
||||||
|
func (d *Database) QuantityBreakdown(qtyRows *[]models.QuantityBreakdownRow, resetAt time.Time) error {
|
||||||
|
where, args := statusFilterClause("c.status != 'cancelled'", resetAt, "c.created_at")
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
ci.product_id,
|
||||||
|
ci.produit AS product_name,
|
||||||
|
ci.quantite AS quantity,
|
||||||
|
COUNT(DISTINCT ci.command_id) AS order_count,
|
||||||
|
SUM(ci.quantite) AS total_sold,
|
||||||
|
SUM(CASE WHEN c.status = 'approved'
|
||||||
|
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
|
||||||
|
ELSE 0 END) AS revenue,
|
||||||
|
COALESCE(cat.color, '#7c3aed') AS category_color
|
||||||
|
FROM command_items ci
|
||||||
|
JOIN commandes c ON c.id = ci.command_id
|
||||||
|
LEFT JOIN products p ON p.id = ci.product_id
|
||||||
|
LEFT JOIN categories cat ON cat.name = p.category
|
||||||
|
WHERE ` + where + `
|
||||||
|
GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color
|
||||||
|
ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC
|
||||||
|
`
|
||||||
|
return d.GDB.Raw(query, args...).Scan(qtyRows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Détail du jour (catégorie → produits) ────────────────────────────────────
|
||||||
|
|
||||||
|
// DailyProductDetail : quantité/nombre de commandes reflètent l'activité (non
|
||||||
|
// annulées), le revenu ne compte que les commandes approuvées (revenu confirmé).
|
||||||
|
func (d *Database) DailyProductDetail(dailyRows *[]models.DailyProductRow) error {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
ci.product_id,
|
||||||
|
ci.produit AS product_name,
|
||||||
|
COALESCE(p.category, 'Sans catégorie') AS category,
|
||||||
|
COALESCE(cat.color, '#7c3aed') AS category_color,
|
||||||
|
SUM(ci.quantite) AS total_quantity,
|
||||||
|
COUNT(DISTINCT ci.command_id) AS order_count,
|
||||||
|
SUM(CASE WHEN c.status = 'approved'
|
||||||
|
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
|
||||||
|
ELSE 0 END) AS revenue
|
||||||
|
FROM command_items ci
|
||||||
|
JOIN commandes c ON c.id = ci.command_id
|
||||||
|
LEFT JOIN products p ON p.id = ci.product_id
|
||||||
|
LEFT JOIN categories cat ON cat.name = p.category
|
||||||
|
WHERE DATE(c.created_at) = CURRENT_DATE
|
||||||
|
AND c.status != 'cancelled'
|
||||||
|
GROUP BY ci.product_id, ci.produit, p.category, cat.color
|
||||||
|
ORDER BY p.category, SUM(ci.quantite) DESC
|
||||||
|
`
|
||||||
|
return d.GDB.Raw(query).Scan(dailyRows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) DailyProductDetailForDate(dailyRows *[]models.DailyProductRow, date time.Time) error {
|
||||||
|
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||||
|
end := start.AddDate(0, 0, 1)
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
ci.product_id,
|
||||||
|
ci.produit AS product_name,
|
||||||
|
COALESCE(p.category, 'Sans catégorie') AS category,
|
||||||
|
COALESCE(cat.color, '#7c3aed') AS category_color,
|
||||||
|
SUM(ci.quantite) AS total_quantity,
|
||||||
|
COUNT(DISTINCT ci.command_id) AS order_count,
|
||||||
|
SUM(CASE WHEN c.status = 'approved'
|
||||||
|
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
|
||||||
|
ELSE 0 END) AS revenue
|
||||||
|
FROM command_items ci
|
||||||
|
JOIN commandes c ON c.id = ci.command_id
|
||||||
|
LEFT JOIN products p ON p.id = ci.product_id
|
||||||
|
LEFT JOIN categories cat ON cat.name = p.category
|
||||||
|
WHERE c.created_at >= ? AND c.created_at < ?
|
||||||
|
AND c.status != 'cancelled'
|
||||||
|
GROUP BY ci.product_id, ci.produit, p.category, cat.color
|
||||||
|
ORDER BY p.category, SUM(ci.quantite) DESC
|
||||||
|
`
|
||||||
|
return d.GDB.Raw(query, start, end).Scan(dailyRows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DailyOrdersCountForDate renvoie le nombre de commandes (non annulées) pour
|
||||||
|
// une date précise.
|
||||||
|
func (d *Database) DailyOrdersCountForDate(date time.Time) (int64, error) {
|
||||||
|
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||||
|
end := start.AddDate(0, 0, 1)
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := d.GDB.Raw(`
|
||||||
|
SELECT COUNT(DISTINCT id) FROM commandes
|
||||||
|
WHERE created_at >= ? AND created_at < ? AND status != 'cancelled'
|
||||||
|
`, start, end).Scan(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DailyOrdersCount renvoie le nombre de commandes (non annulées) du jour.
|
||||||
|
func (d *Database) DailyOrdersCount() (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := d.GDB.Raw(`
|
||||||
|
SELECT COUNT(DISTINCT id) FROM commandes
|
||||||
|
WHERE DATE(created_at) = CURRENT_DATE AND status != 'cancelled'
|
||||||
|
`).Scan(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Résumé global ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TotalOrders renvoie le nombre total de commandes filtré par le reset "commandes".
|
||||||
|
func (d *Database) TotalOrders(resetAt time.Time) (int64, error) {
|
||||||
|
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||||
|
var total int64
|
||||||
|
err := d.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE `+where, args...).Scan(&total).Error
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalRevenue renvoie le revenu total (commandes approuvées) filtré par le reset "revenus".
|
||||||
|
func (d *Database) TotalRevenue(resetAt time.Time) (float64, error) {
|
||||||
|
where, args := statusFilterClause("status = 'approved'", resetAt, "created_at")
|
||||||
|
var total float64
|
||||||
|
err := d.GDB.Raw(`SELECT COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) FROM commandes WHERE `+where, args...).
|
||||||
|
Scan(&total).Error
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalPromoDiscount renvoie le montant total (€) des réductions de prix
|
||||||
|
// accordées par des promotions sur les commandes approuvées, filtré par le
|
||||||
|
// reset "revenus" (même périmètre que TotalRevenue, dont c'est un
|
||||||
|
// sous-indicateur). Basé sur command_items.promo_discount, capturé au moment
|
||||||
|
// de AddToBasket — reflète donc les promos réellement appliquées à l'époque,
|
||||||
|
// pas la config de promotions courante.
|
||||||
|
func (d *Database) TotalPromoDiscount(resetAt time.Time) (float64, error) {
|
||||||
|
where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at")
|
||||||
|
var total float64
|
||||||
|
query := `
|
||||||
|
SELECT COALESCE(SUM(ci.promo_discount), 0)
|
||||||
|
FROM command_items ci
|
||||||
|
JOIN commandes c ON c.id = ci.command_id
|
||||||
|
WHERE ` + where
|
||||||
|
err := d.GDB.Raw(query, args...).Scan(&total).Error
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromoOrdersCount renvoie le nombre de commandes distinctes (approuvées)
|
||||||
|
// ayant bénéficié d'au moins une réduction de prix promo, filtré par le
|
||||||
|
// reset "revenus".
|
||||||
|
func (d *Database) PromoOrdersCount(resetAt time.Time) (int64, error) {
|
||||||
|
where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at")
|
||||||
|
var count int64
|
||||||
|
query := `
|
||||||
|
SELECT COUNT(DISTINCT ci.command_id)
|
||||||
|
FROM command_items ci
|
||||||
|
JOIN commandes c ON c.id = ci.command_id
|
||||||
|
WHERE ci.promo_discount > 0 AND ` + where
|
||||||
|
err := d.GDB.Raw(query, args...).Scan(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveDaysLast30 renvoie le nombre de jours distincts ayant eu au moins une commande sur 30 jours.
|
||||||
|
func (d *Database) ActiveDaysLast30(resetAt time.Time) (int64, error) {
|
||||||
|
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||||
|
var activeDays int64
|
||||||
|
query := `
|
||||||
|
SELECT COUNT(DISTINCT DATE(created_at))
|
||||||
|
FROM commandes
|
||||||
|
WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + where
|
||||||
|
err := d.GDB.Raw(query, args...).Scan(&activeDays).Error
|
||||||
|
return activeDays, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrdersCountLast30 renvoie le nombre de commandes sur les 30 derniers jours.
|
||||||
|
func (d *Database) OrdersCountLast30(resetAt time.Time) (int64, error) {
|
||||||
|
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||||
|
var count int64
|
||||||
|
query := `
|
||||||
|
SELECT COUNT(*) FROM commandes
|
||||||
|
WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + where
|
||||||
|
err := d.GDB.Raw(query, args...).Scan(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetMyDeliveryStatsPerDay(statsRows *[]models.DayRowWithResult, username string) error {
|
||||||
|
query := `
|
||||||
|
SELECT DATE(updated_at) AS day,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||||
|
FROM commandes
|
||||||
|
WHERE livreur_assign = ?
|
||||||
|
AND status IN ('livre', 'approved')
|
||||||
|
AND updated_at >= NOW() - INTERVAL '30 days'
|
||||||
|
GROUP BY DATE(updated_at)
|
||||||
|
ORDER BY day
|
||||||
|
`
|
||||||
|
return d.GDB.Raw(query, username).Scan(statsRows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetMyDeliveryStatsPerWeek(statsRow *[]models.WeekRow, username string) error {
|
||||||
|
query := `
|
||||||
|
SELECT EXTRACT(WEEK FROM updated_at)::int AS week_num,
|
||||||
|
EXTRACT(YEAR FROM updated_at)::int AS year,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||||
|
FROM commandes
|
||||||
|
WHERE livreur_assign = ?
|
||||||
|
AND status IN ('livre', 'approved')
|
||||||
|
AND updated_at >= NOW() - INTERVAL '12 weeks'
|
||||||
|
GROUP BY week_num, year
|
||||||
|
ORDER BY year, week_num
|
||||||
|
`
|
||||||
|
return d.GDB.Raw(query, username).Scan(statsRow).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetMyDeliveryStatsPerMonth(statsRow *[]models.MonthRow, username string) error {
|
||||||
|
query := `
|
||||||
|
SELECT EXTRACT(MONTH FROM updated_at)::int AS month_num,
|
||||||
|
EXTRACT(YEAR FROM updated_at)::int AS year,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||||
|
FROM commandes
|
||||||
|
WHERE livreur_assign = ?
|
||||||
|
AND status IN ('livre', 'approved')
|
||||||
|
AND updated_at >= NOW() - INTERVAL '12 months'
|
||||||
|
GROUP BY month_num, year
|
||||||
|
ORDER BY year, month_num
|
||||||
|
`
|
||||||
|
return d.GDB.Raw(query, username).Scan(statsRow).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetMyDeliveryStatsToday(statsRow *models.TodayRow, username string) error {
|
||||||
|
query := `
|
||||||
|
SELECT COUNT(*) AS count,
|
||||||
|
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||||
|
FROM commandes
|
||||||
|
WHERE livreur_assign = ?
|
||||||
|
AND status IN ('livre', 'approved')
|
||||||
|
AND DATE(updated_at) = CURRENT_DATE
|
||||||
|
`
|
||||||
|
return d.GDB.Raw(query, username).Scan(statsRow).Error
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ func GenerateLinkToken(username, role string) (string, error) {
|
|||||||
|
|
||||||
key := fmt.Sprintf("telegram:link:%s", token)
|
key := fmt.Sprintf("telegram:link:%s", token)
|
||||||
if err := Redis.Set(RedisCtx, key, val, linkTokenTTL).Err(); err != nil {
|
if err := Redis.Set(RedisCtx, key, val, linkTokenTTL).Err(); err != nil {
|
||||||
return "", fmt.Errorf("Redis SET: %w", err)
|
return "", fmt.Errorf("redis set: %w", err)
|
||||||
}
|
}
|
||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func (d *Database) CreateUser(user *models.User) error {
|
|||||||
|
|
||||||
func (d *Database) GetAllUsers() ([]*models.User, error) {
|
func (d *Database) GetAllUsers() ([]*models.User, error) {
|
||||||
var users []*models.User
|
var users []*models.User
|
||||||
if err := d.GDB.Order("created_at DESC").Find(&users).Error; err != nil {
|
if err := d.GDB.Order("created_at DESC").Limit(500).Find(&users).Error; err != nil {
|
||||||
return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err)
|
return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err)
|
||||||
}
|
}
|
||||||
return users, nil
|
return users, nil
|
||||||
@@ -23,7 +23,7 @@ func (d *Database) GetAllUsers() ([]*models.User, error) {
|
|||||||
|
|
||||||
func (d *Database) GetAllDeliveryMen() ([]*models.User, error) {
|
func (d *Database) GetAllDeliveryMen() ([]*models.User, error) {
|
||||||
var users []*models.User
|
var users []*models.User
|
||||||
if err := d.GDB.Where("role = ?", "livreur").Find(&users).Error; err != nil {
|
if err := d.GDB.Where("role = ?", "livreur").Limit(100).Find(&users).Error; err != nil {
|
||||||
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
|
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
|
||||||
}
|
}
|
||||||
return users, nil
|
return users, nil
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
// FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter
|
// FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter
|
||||||
func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
|
func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
|
||||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
keys, err := scanRedisKeys("delivery:status:*")
|
||||||
if err != nil || len(keys) == 0 {
|
if err != nil || len(keys) == 0 {
|
||||||
return "", fmt.Errorf("aucun livreur trouvé")
|
return "", fmt.Errorf("aucun livreur trouvé")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition i
|
|||||||
eta := map[string]any{
|
eta := map[string]any{
|
||||||
"command_id": commandID,
|
"command_id": commandID,
|
||||||
"total_eta_minutes": totalETA,
|
"total_eta_minutes": totalETA,
|
||||||
|
"eta_minutes": totalETA,
|
||||||
"queue_position": queuePosition,
|
"queue_position": queuePosition,
|
||||||
"updated_at": now.Unix(),
|
"updated_at": now.Unix(),
|
||||||
"arrival_time": arrivalTime.Unix(),
|
"arrival_time": arrivalTime.Unix(),
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CleanupInvalidQueueCommands supprime toutes les commandes avec des données manquantes
|
|
||||||
func (d *Database) CleanupInvalidQueueCommands() (int, error) {
|
func (d *Database) CleanupInvalidQueueCommands() (int, error) {
|
||||||
log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...")
|
log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...")
|
||||||
|
|
||||||
@@ -82,7 +81,6 @@ func (d *Database) CleanupInvalidQueueCommands() (int, error) {
|
|||||||
return removedCount, nil
|
return removedCount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeInvalidCommand supprime une commande invalide de toutes les queues
|
|
||||||
func (d *Database) removeInvalidCommand(key string, commandID int, reason string) {
|
func (d *Database) removeInvalidCommand(key string, commandID int, reason string) {
|
||||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ func (d *Database) GetAllQueuesOverview() (map[string]any, error) {
|
|||||||
return overview, nil
|
return overview, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetQueueStats - Statistiques détaillées
|
|
||||||
func (d *Database) GetQueueStats() (map[string]any, error) {
|
func (d *Database) GetQueueStats() (map[string]any, error) {
|
||||||
normalCount, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
|
normalCount, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
|
||||||
priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result()
|
priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result()
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveCommandFromQueue - VERSION AMÉLIORÉE avec auto-update du statut
|
|
||||||
func (d *Database) RemoveCommandFromQueue(commandID int) error {
|
func (d *Database) RemoveCommandFromQueue(commandID int) error {
|
||||||
key := fmt.Sprintf("queue:pending:%d", commandID)
|
key := fmt.Sprintf("queue:pending:%d", commandID)
|
||||||
commandIDStr := strconv.Itoa(commandID)
|
commandIDStr := strconv.Itoa(commandID)
|
||||||
@@ -321,7 +320,6 @@ func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanRedisKeys remplace KEYS * par SCAN pour ne pas bloquer Redis.
|
|
||||||
func scanRedisKeys(pattern string) ([]string, error) {
|
func scanRedisKeys(pattern string) ([]string, error) {
|
||||||
var all []string
|
var all []string
|
||||||
cursor := uint64(0)
|
cursor := uint64(0)
|
||||||
|
|||||||
@@ -19,6 +19,24 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.25 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
|
||||||
|
github.com/aws/smithy-go v1.27.1 // indirect
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,39 @@
|
|||||||
|
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
|
||||||
|
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
|
||||||
|
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ func AlertPolice(c *gin.Context) {
|
|||||||
var req struct {
|
var req struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
// message optionnel — on ignore l'erreur de bind
|
|
||||||
_ = c.ShouldBindJSON(&req)
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
usernameStr := username.(string)
|
usernameStr := username.(string)
|
||||||
@@ -61,6 +60,25 @@ func DeleteAlert(c *gin.Context) {
|
|||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Un livreur ne peut supprimer que ses propres alertes — admin garde l'accès complet.
|
||||||
|
if userRole == "livreur" {
|
||||||
|
username, exists := c.Get("username")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
alert, err := database.GetAlertPolicy(alertID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if alert.Username != username.(string) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err = database.DeleteAlertPolicy(alertID); err != nil {
|
if err = database.DeleteAlertPolicy(alertID); err != nil {
|
||||||
utils.ServerErr(c, "Impossible de supprimer l'alerte", err)
|
utils.ServerErr(c, "Impossible de supprimer l'alerte", err)
|
||||||
return
|
return
|
||||||
@@ -92,6 +110,15 @@ func GetAlert(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Un livreur ne peut consulter que ses propres alertes — admin/cabine gardent l'accès complet pour le dispatch
|
||||||
|
if userRole == "livreur" {
|
||||||
|
username, exists := c.Get("username")
|
||||||
|
if !exists || alert.Username != username.(string) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"alert": alert,
|
"alert": alert,
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ func generateAdminToken(user *models.User) (string, error) {
|
|||||||
claims := models.AdminClaims{
|
claims := models.AdminClaims{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Role: user.Role, // ← "admin" ou "cabine" ou "livreur"
|
Role: user.Role,
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||||
Issuer: "api-admin", // Même issuer pour tous les admins
|
Issuer: "api-admin",
|
||||||
Subject: strconv.Itoa(user.ID),
|
Subject: strconv.Itoa(user.ID),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -73,112 +73,6 @@ func generateAdminToken(user *models.User) (string, error) {
|
|||||||
return tokenString, nil
|
return tokenString, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterClient crée un nouveau compte client
|
|
||||||
func RegisterClient(c *gin.Context) {
|
|
||||||
var req models.RegisterClientRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
log.Printf("❌ [REGISTER_CLIENT] Erreur binding: %v", err)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "Données invalides",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize text inputs
|
|
||||||
req.Username = utils.StripHTML(req.Username)
|
|
||||||
req.Nom = utils.StripHTML(req.Nom)
|
|
||||||
req.Prenom = utils.StripHTML(req.Prenom)
|
|
||||||
|
|
||||||
// Validation téléphone
|
|
||||||
if !utils.ValidatePhoneNumber(req.Telephone) {
|
|
||||||
log.Printf("❌ [REGISTER_CLIENT] Téléphone invalide: %s", req.Telephone)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "Numéro de téléphone invalide",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
|
|
||||||
database := c.MustGet("database").(*db.Database)
|
|
||||||
|
|
||||||
// Vérifier username unique
|
|
||||||
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
|
|
||||||
log.Printf("❌ [REGISTER_CLIENT] Username déjà utilisé: %s", req.Username)
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier téléphone unique
|
|
||||||
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil {
|
|
||||||
log.Printf("❌ [REGISTER_CLIENT] Téléphone déjà utilisé: %s", normalizedPhone)
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hasher le mot de passe
|
|
||||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ [REGISTER_CLIENT] Erreur bcrypt: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Créer le client
|
|
||||||
client := &models.Client{
|
|
||||||
Username: req.Username,
|
|
||||||
Password: string(hashed),
|
|
||||||
Nom: strings.TrimSpace(req.Nom),
|
|
||||||
Prenom: strings.TrimSpace(req.Prenom),
|
|
||||||
Telephone: normalizedPhone,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := database.CreateClient(client); err != nil {
|
|
||||||
log.Printf("❌ [REGISTER_CLIENT] Erreur création: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création client"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Générer le token
|
|
||||||
token, err := generateClientToken(client)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ [REGISTER_CLIENT] Erreur génération token: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sauvegarder le token
|
|
||||||
expiresAt := time.Now().Add(clientTokenDuration)
|
|
||||||
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
|
|
||||||
log.Printf("❌ [REGISTER_CLIENT] Erreur SaveToken: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Créer la session Redis
|
|
||||||
sessionID := uuid.New().String()
|
|
||||||
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
|
|
||||||
log.Printf("⚠️ [REGISTER_CLIENT] Erreur session Redis: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client.Password = ""
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, models.LoginResponse{
|
|
||||||
AccessToken: token,
|
|
||||||
TokenType: "Bearer",
|
|
||||||
ExpiresIn: int(clientTokenDuration.Seconds()),
|
|
||||||
User: gin.H{
|
|
||||||
"id": client.ID,
|
|
||||||
"username": client.Username,
|
|
||||||
"nom": client.Nom,
|
|
||||||
"prenom": client.Prenom,
|
|
||||||
"telephone": client.Telephone,
|
|
||||||
"role": "client",
|
|
||||||
"session_id": sessionID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminCreateClient crée un client depuis l'interface admin (sans session ni token)
|
// AdminCreateClient crée un client depuis l'interface admin (sans session ni token)
|
||||||
func AdminCreateClient(c *gin.Context) {
|
func AdminCreateClient(c *gin.Context) {
|
||||||
if userRole := c.GetString("role"); userRole != "admin" {
|
if userRole := c.GetString("role"); userRole != "admin" {
|
||||||
@@ -561,6 +455,12 @@ func LoginAdmin(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user.Role == "livreur" {
|
||||||
|
if err := database.RecordLivreurLogin(user.Username); err != nil {
|
||||||
|
log.Printf("⚠️ [LOGIN_ADMIN] Erreur enregistrement historique connexion livreur: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
token, _ := generateAdminToken(user)
|
token, _ := generateAdminToken(user)
|
||||||
|
|
||||||
expiresAt := time.Now().Add(adminTokenDuration)
|
expiresAt := time.Now().Add(adminTokenDuration)
|
||||||
@@ -602,62 +502,6 @@ func LogoutAdmin(c *gin.Context) {
|
|||||||
// HELPERS
|
// HELPERS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// GetCurrentClient récupère le client actuel
|
|
||||||
// GET /api/v1/profile/client
|
|
||||||
func GetCurrentClient(c *gin.Context) {
|
|
||||||
clientID := c.GetInt("client_id")
|
|
||||||
database := c.MustGet("database").(*db.Database)
|
|
||||||
|
|
||||||
client, err := database.GetClientByID(clientID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ [GET_CURRENT_CLIENT] Client non trouvé: ID=%d", clientID)
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client.Password = ""
|
|
||||||
|
|
||||||
log.Printf("✅ [GET_CURRENT_CLIENT] Client récupéré: %s", client.Username)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"client": gin.H{
|
|
||||||
"id": client.ID,
|
|
||||||
"username": client.Username,
|
|
||||||
"nom": client.Nom,
|
|
||||||
"prenom": client.Prenom,
|
|
||||||
"telephone": client.Telephone,
|
|
||||||
"command": client.Command,
|
|
||||||
"amende": client.Amende,
|
|
||||||
"points_extra": client.PointsExtra,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentAdmin récupère l'admin/user actuel
|
|
||||||
// GET /api/v1/profile/admin
|
|
||||||
func GetCurrentAdmin(c *gin.Context) {
|
|
||||||
userID := c.GetInt("user_id")
|
|
||||||
database := c.MustGet("database").(*db.Database)
|
|
||||||
|
|
||||||
user, err := database.GetUserByID(userID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ [GET_CURRENT_ADMIN] User non trouvé: ID=%d", userID)
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur non trouvé"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user.Password = ""
|
|
||||||
|
|
||||||
log.Printf("✅ [GET_CURRENT_ADMIN] User récupéré: %s", user.Username)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"user": models.ProfileResponse{
|
|
||||||
Username: user.Username,
|
|
||||||
Role: user.Role,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllUsers récupère tous les utilisateurs (Admin only)
|
// GetAllUsers récupère tous les utilisateurs (Admin only)
|
||||||
func GetAllUsers(c *gin.Context) {
|
func GetAllUsers(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
@@ -855,3 +699,9 @@ func CreateUser(c *gin.Context) {
|
|||||||
log.Printf("✅ [CREATE_USER] Utilisateur %s (%s) créé", user.Username, user.Role)
|
log.Printf("✅ [CREATE_USER] Utilisateur %s (%s) créé", user.Username, user.Role)
|
||||||
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
|
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Health(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -195,7 +195,6 @@ func CancelCommandByClient(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ AUTRES ERREURS
|
|
||||||
switch err.Error() {
|
switch err.Error() {
|
||||||
case "commande non trouvée":
|
case "commande non trouvée":
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||||
@@ -312,7 +311,6 @@ func GetAllCancelledOrders(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ ENRICHIR les données (sans exposer d'infos sensibles inutiles)
|
|
||||||
var enrichedOrders []map[string]any
|
var enrichedOrders []map[string]any
|
||||||
for _, order := range cancelledOrders {
|
for _, order := range cancelledOrders {
|
||||||
orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"]))
|
orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"]))
|
||||||
|
|||||||
@@ -109,6 +109,26 @@ func UpdateCategory(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ReorderCategories(c *gin.Context) {
|
||||||
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
IDs []int `json:"ids" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Liste d'IDs requise"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.ReorderCategories(req.IDs); err != nil {
|
||||||
|
log.Printf("❌ [CATEGORIES] Reorder erreur: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du réordonnancement"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
func DeleteCategory(c *gin.Context) {
|
func DeleteCategory(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ func buildTimeline(logs []map[string]any) []gin.H {
|
|||||||
for _, logEntry := range logs {
|
for _, logEntry := range logs {
|
||||||
status, _ := logEntry["status"].(string)
|
status, _ := logEntry["status"].(string)
|
||||||
message, _ := logEntry["message"].(string)
|
message, _ := logEntry["message"].(string)
|
||||||
createdAt, _ := logEntry["created_at"]
|
createdAt := logEntry["created_at"]
|
||||||
|
|
||||||
timeline = append(timeline, gin.H{
|
timeline = append(timeline, gin.H{
|
||||||
"status": status,
|
"status": status,
|
||||||
|
|||||||
@@ -73,8 +73,47 @@ func validateAddress(address string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateCommandDestinationCoords regéocode l'adresse et met à jour
|
||||||
|
// dest_latitude/dest_longitude après tout changement d'adresse de livraison.
|
||||||
|
// Sans cet appel, ces coordonnées restent celles de l'ANCIENNE adresse
|
||||||
|
// (géocodées une seule fois à l'assignation) : la vérification GPS de
|
||||||
|
// handlers/deleviry.go compare alors la position réelle du livreur à un point
|
||||||
|
// périmé et peut refuser à tort une validation "trop loin de la destination"
|
||||||
|
// alors que le livreur est bien arrivé à la nouvelle adresse. En cas d'échec
|
||||||
|
// de géocodage, on réinitialise les coordonnées plutôt que de laisser
|
||||||
|
// l'ancienne valeur périmée : le contrôle GPS est alors ignoré (comportement
|
||||||
|
// déjà prévu quand dest_latitude/dest_longitude sont absentes) au lieu de
|
||||||
|
// bloquer sur un point qui ne correspond plus à l'adresse réelle.
|
||||||
|
func updateCommandDestinationCoords(database *db.Database, geoService *services.GeoService, commandID int, address string) {
|
||||||
|
if geoService == nil || strings.TrimSpace(address) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
location, err := geoService.GeocodeAddress(address)
|
||||||
|
if err != nil || location == nil {
|
||||||
|
log.Printf("⚠️ [ADDR_GEOCODE] Échec géocodage cmd %d (%q): %v — coordonnées de destination réinitialisées", commandID, address, err)
|
||||||
|
if err := database.GDB.Exec(
|
||||||
|
`UPDATE commandes SET dest_latitude = NULL, dest_longitude = NULL WHERE id = ?`,
|
||||||
|
commandID,
|
||||||
|
).Error; err != nil {
|
||||||
|
log.Printf("⚠️ [ADDR_GEOCODE] Erreur reset coordonnées cmd %d: %v", commandID, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.GDB.Exec(
|
||||||
|
`UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`,
|
||||||
|
location.Latitude, location.Longitude, commandID,
|
||||||
|
).Error; err != nil {
|
||||||
|
log.Printf("⚠️ [ADDR_GEOCODE] Erreur mise à jour coordonnées cmd %d: %v", commandID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("✅ [ADDR_GEOCODE] Coordonnées de destination mises à jour pour cmd %d", commandID)
|
||||||
|
}
|
||||||
|
|
||||||
func UpdateCommandAddress(c *gin.Context) {
|
func UpdateCommandAddress(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||||
|
|
||||||
userRole := c.GetString("role")
|
userRole := c.GetString("role")
|
||||||
if !utils.CheckRoleAdmin(c, userRole) {
|
if !utils.CheckRoleAdmin(c, userRole) {
|
||||||
@@ -138,6 +177,8 @@ func UpdateCommandAddress(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateCommandDestinationCoords(database, geoService, commandID, req.DeliveryAddress)
|
||||||
|
|
||||||
database.AddCommandLog(commandID, "address_updated",
|
database.AddCommandLog(commandID, "address_updated",
|
||||||
fmt.Sprintf("Adresse mise à jour par admin %s", adminUsername),
|
fmt.Sprintf("Adresse mise à jour par admin %s", adminUsername),
|
||||||
adminUsername)
|
adminUsername)
|
||||||
@@ -220,6 +261,7 @@ func ProposeAddressChange(c *gin.Context) {
|
|||||||
// POST /api/v1/commands/:id/address/respond
|
// POST /api/v1/commands/:id/address/respond
|
||||||
func RespondToAddressProposal(c *gin.Context) {
|
func RespondToAddressProposal(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||||
|
|
||||||
userRole := c.GetString("role")
|
userRole := c.GetString("role")
|
||||||
if !utils.CheckRoleClient(c, userRole) {
|
if !utils.CheckRoleClient(c, userRole) {
|
||||||
@@ -246,11 +288,25 @@ func RespondToAddressProposal(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// La colonne proposed_address est vidée par RespondToAddressProposal dès
|
||||||
|
// qu'elle est traitée : on la lit avant l'appel pour pouvoir regéocoder la
|
||||||
|
// nouvelle adresse en cas d'acceptation.
|
||||||
|
var proposedAddress string
|
||||||
|
if req.Accepted {
|
||||||
|
if command, err := database.GetCommandByID(commandID); err == nil {
|
||||||
|
proposedAddress, _ = command["proposed_address"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := database.RespondToAddressProposal(commandID, clientUsername, req.Accepted); err != nil {
|
if err := database.RespondToAddressProposal(commandID, clientUsername, req.Accepted); err != nil {
|
||||||
utils.ServerErr(c, "Impossible de traiter la réponse", err)
|
utils.ServerErr(c, "Impossible de traiter la réponse", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Accepted && proposedAddress != "" {
|
||||||
|
updateCommandDestinationCoords(database, geoService, commandID, proposedAddress)
|
||||||
|
}
|
||||||
|
|
||||||
action := "refusée"
|
action := "refusée"
|
||||||
if req.Accepted {
|
if req.Accepted {
|
||||||
action = "acceptée"
|
action = "acceptée"
|
||||||
@@ -263,6 +319,64 @@ func RespondToAddressProposal(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateOwnCommandAddress permet à un client de corriger l'adresse de sa
|
||||||
|
// propre commande (ex: suite à un échec de géocodage bloquant l'assignation
|
||||||
|
// auto). Refusé si la commande est déjà en_route ou terminée (voir requête
|
||||||
|
// SQL dans db.UpdateOwnCommandAddress).
|
||||||
|
func UpdateOwnCommandAddress(c *gin.Context) {
|
||||||
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||||
|
|
||||||
|
userRole := c.GetString("role")
|
||||||
|
if !utils.CheckRoleClient(c, userRole) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clientUsername, err := safeGetUsername(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimitKey := fmt.Sprintf("update_own_addr:%s", clientUsername)
|
||||||
|
if !checkRateLimit(rateLimitKey) {
|
||||||
|
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Trop de requêtes, réessayez plus tard"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
commandID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || commandID <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
DeliveryAddress string `json:"delivery_address" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !geoService.IsValidAddress(req.DeliveryAddress) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse introuvable, vérifiez l'orthographe ou le code postal"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.UpdateOwnCommandAddress(commandID, clientUsername, req.DeliveryAddress); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCommandDestinationCoords(database, geoService, commandID, req.DeliveryAddress)
|
||||||
|
|
||||||
|
log.Printf("✅ [UPD_OWN_ADDR] Commande %d mise à jour par %s", commandID, clientUsername)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "Adresse mise à jour",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func ExportApprovedCommandsCSV(c *gin.Context) {
|
func ExportApprovedCommandsCSV(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -482,10 +596,6 @@ func StaffApproveDelivery(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// APPROBATION PAR ADMIN
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
func ValidateDelivery(c *gin.Context) {
|
func ValidateDelivery(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -560,7 +670,7 @@ func ValidateDelivery(c *gin.Context) {
|
|||||||
|
|
||||||
currentStatus, _ := command["status"].(string)
|
currentStatus, _ := command["status"].(string)
|
||||||
|
|
||||||
validStatuses := []string{"assigned", "en_route", "pending", "livre"}
|
validStatuses := []string{"assigned", "en_route", "arrived", "pending", "livre"}
|
||||||
if !slices.Contains(validStatuses, currentStatus) {
|
if !slices.Contains(validStatuses, currentStatus) {
|
||||||
failed = append(failed, gin.H{
|
failed = append(failed, gin.H{
|
||||||
"command_id": commandID,
|
"command_id": commandID,
|
||||||
@@ -1145,19 +1255,11 @@ func UpdateCommandStatusAdmin(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.Status == "cancelled" {
|
if req.Status == "cancelled" {
|
||||||
current, errCmd := database.GetCommandByID(commandID)
|
if err := database.CancelCommandByAdminAtomic(commandID); err != nil {
|
||||||
if errCmd == nil {
|
utils.ServerErr(c, "Impossible d'annuler la commande", err)
|
||||||
currentStatus, _ := current["status"].(string)
|
return
|
||||||
alreadyDone := currentStatus == "cancelled" || currentStatus == "approved" || currentStatus == "livre"
|
|
||||||
if !alreadyDone {
|
|
||||||
if err := database.RestoreCommandStock(commandID); err != nil {
|
|
||||||
log.Printf("⚠️ [STATUS_ADMIN] Erreur restauration stock cmd %d: %v", commandID, err)
|
|
||||||
}
|
}
|
||||||
}
|
} else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
|
||||||
utils.ServerErr(c, "Impossible de mettre à jour le statut", err)
|
utils.ServerErr(c, "Impossible de mettre à jour le statut", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import (
|
|||||||
// IPNWebhook - POST /api/v1/webhooks/nowpayments
|
// IPNWebhook - POST /api/v1/webhooks/nowpayments
|
||||||
func IPNWebhook(c *gin.Context) {
|
func IPNWebhook(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
np, ok := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
npRaw, npExists := c.Get("nowpayments")
|
||||||
if !ok || np == nil {
|
np, ok := npRaw.(*services.NowPaymentsClient)
|
||||||
|
if !npExists || !ok || np == nil {
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"})
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gestion/db"
|
"gestion/db"
|
||||||
|
"gestion/models"
|
||||||
"gestion/services"
|
"gestion/services"
|
||||||
"gestion/utils"
|
"gestion/utils"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -40,14 +40,27 @@ func GetMyDeliveries(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collecter tous les IDs et usernames en une passe pour éviter les N+1
|
||||||
|
commandIDs := make([]int, 0, len(commands))
|
||||||
|
clientUsernames := make([]string, 0, len(commands))
|
||||||
|
for _, cmd := range commands {
|
||||||
|
if cid, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"])); cid > 0 {
|
||||||
|
commandIDs = append(commandIDs, cid)
|
||||||
|
}
|
||||||
|
if u, _ := cmd["username"].(string); u != "" {
|
||||||
|
clientUsernames = append(clientUsernames, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allItems, _ := database.GetCommandItemsBatch(commandIDs)
|
||||||
|
allClients, _ := database.GetClientsByUsernames(clientUsernames)
|
||||||
|
|
||||||
filteredCommands := make([]gin.H, len(commands))
|
filteredCommands := make([]gin.H, len(commands))
|
||||||
for i, cmd := range commands {
|
for i, cmd := range commands {
|
||||||
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"]))
|
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"]))
|
||||||
items, _ := database.GetCommandItems(commandID)
|
items := allItems[commandID]
|
||||||
|
|
||||||
// Client info SANS téléphone
|
|
||||||
clientUsername, _ := cmd["username"].(string)
|
clientUsername, _ := cmd["username"].(string)
|
||||||
client, _ := database.GetClientByUsername(clientUsername)
|
client := allClients[clientUsername]
|
||||||
|
|
||||||
clientInfo := gin.H{"nom": "Client", "prenom": ""}
|
clientInfo := gin.H{"nom": "Client", "prenom": ""}
|
||||||
if client != nil {
|
if client != nil {
|
||||||
@@ -63,6 +76,7 @@ func GetMyDeliveries(c *gin.Context) {
|
|||||||
"produit": item["produit"],
|
"produit": item["produit"],
|
||||||
"quantite": item["quantite"],
|
"quantite": item["quantite"],
|
||||||
"prix": item["prix"],
|
"prix": item["prix"],
|
||||||
|
"promo_discount": item["promo_discount"],
|
||||||
"is_reward": item["is_reward"],
|
"is_reward": item["is_reward"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,6 +159,8 @@ func GetDeliveryDetails(c *gin.Context) {
|
|||||||
"produit": item["produit"],
|
"produit": item["produit"],
|
||||||
"quantite": item["quantite"],
|
"quantite": item["quantite"],
|
||||||
"prix": item["prix"],
|
"prix": item["prix"],
|
||||||
|
"promo_discount": item["promo_discount"],
|
||||||
|
"is_reward": item["is_reward"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,37 +260,40 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
|||||||
distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon)
|
distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon)
|
||||||
log.Printf("📍 [GPS] Distance: %.2f m", distance)
|
log.Printf("📍 [GPS] Distance: %.2f m", distance)
|
||||||
|
|
||||||
if distance > 100 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "Vous êtes trop loin de la destination",
|
|
||||||
"current_distance": fmt.Sprintf("%.2f", distance),
|
|
||||||
"unit": "meters",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("✅ [GPS] Validation OK")
|
log.Printf("✅ [GPS] Validation OK")
|
||||||
} else {
|
} else {
|
||||||
log.Printf("⚠️ [GPS] Coordonnées de destination non disponibles, validation ignorée")
|
log.Printf("⚠️ [GPS] Coordonnées de destination non disponibles, validation ignorée")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mettre à jour le statut
|
// Mettre à jour le statut.
|
||||||
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
// Le cas "cancelled" passe par une transaction atomique dédiée (transition +
|
||||||
|
// remboursement stock), pour empêcher tout double remboursement en cas de
|
||||||
|
// double appel (double-tap, retry réseau, commande déjà annulée ailleurs).
|
||||||
|
if req.Status == "cancelled" {
|
||||||
|
alreadyCancelled, prevStatus, cancelErr := database.CancelDeliveryByLivreurAtomic(commandID)
|
||||||
|
if cancelErr != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Erreur mise à jour",
|
"error": "Erreur mise à jour",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if alreadyCancelled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "Commande déjà annulée",
|
||||||
|
"command_id": commandID,
|
||||||
|
"status": "cancelled",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if req.Status == "cancelled" {
|
|
||||||
cancelMsg := req.Notes
|
cancelMsg := req.Notes
|
||||||
if cancelMsg == "" {
|
if cancelMsg == "" {
|
||||||
cancelMsg = "Annulé par le livreur"
|
cancelMsg = "Annulé par le livreur"
|
||||||
}
|
}
|
||||||
database.SetCommandCancelReason(commandID, fmt.Sprintf("[Livreur: %s] %s", usernameStr, cancelMsg))
|
database.SetCommandCancelReason(commandID, fmt.Sprintf("[Livreur: %s] %s", usernameStr, cancelMsg))
|
||||||
|
|
||||||
prevStatus, _ := command["status"].(string)
|
|
||||||
if prevStatus == "arrived" || prevStatus == "livre" {
|
if prevStatus == "arrived" || prevStatus == "livre" {
|
||||||
clientUsername, _ := command["username"].(string)
|
clientUsername, _ := command["username"].(string)
|
||||||
if clientUsername != "" {
|
if clientUsername != "" {
|
||||||
@@ -285,6 +304,11 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "Erreur mise à jour",
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
|
// ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
|
||||||
@@ -441,12 +465,8 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
|||||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||||
|
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
|
// Transition + remboursement stock déjà effectués atomiquement plus haut.
|
||||||
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
|
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
|
||||||
if err := database.RestoreCommandStock(commandID); err != nil {
|
|
||||||
log.Printf("⚠️ [STATUS_LIVREUR] Erreur restauration stock cmd %d: %v", commandID, err)
|
|
||||||
} else {
|
|
||||||
log.Printf("✅ [STATUS_LIVREUR] Stock restauré pour cmd %d", commandID)
|
|
||||||
}
|
|
||||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||||
|
|
||||||
case "arrived":
|
case "arrived":
|
||||||
@@ -526,10 +546,8 @@ func ReportDeliveryIssue(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
|
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/v1/livreur/stats
|
|
||||||
func GetMyDeliveryStats(c *gin.Context) {
|
func GetMyDeliveryStats(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
username, exists := c.Get("username")
|
username, exists := c.Get("username")
|
||||||
if !exists {
|
if !exists {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||||
@@ -540,66 +558,30 @@ func GetMyDeliveryStats(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
usernameStr := username.(string)
|
usernameStr := username.(string)
|
||||||
gdb := database.GDB
|
|
||||||
|
|
||||||
type DayRow struct {
|
var dayRows []models.DayRowWithResult
|
||||||
Day time.Time `gorm:"column:day"`
|
if err := database.GetMyDeliveryStatsPerDay(&dayRows, usernameStr); err != nil {
|
||||||
Count int `gorm:"column:count"`
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats jour"})
|
||||||
Revenue float64 `gorm:"column:revenue"`
|
return
|
||||||
}
|
|
||||||
type WeekRow struct {
|
|
||||||
WeekNum int `gorm:"column:week_num"`
|
|
||||||
Year int `gorm:"column:year"`
|
|
||||||
Count int `gorm:"column:count"`
|
|
||||||
Revenue float64 `gorm:"column:revenue"`
|
|
||||||
}
|
|
||||||
type MonthRow struct {
|
|
||||||
MonthNum int `gorm:"column:month_num"`
|
|
||||||
Year int `gorm:"column:year"`
|
|
||||||
Count int `gorm:"column:count"`
|
|
||||||
Revenue float64 `gorm:"column:revenue"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var dayRows []DayRow
|
var weekRows []models.WeekRow
|
||||||
gdb.Raw(`
|
if err := database.GetMyDeliveryStatsPerWeek(&weekRows, usernameStr); err != nil {
|
||||||
SELECT DATE(updated_at) AS day,
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats semaine"})
|
||||||
COUNT(*) AS count,
|
return
|
||||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
}
|
||||||
FROM commandes
|
|
||||||
WHERE livreur_assign = ?
|
|
||||||
AND status IN ('livre', 'approved')
|
|
||||||
AND updated_at >= NOW() - INTERVAL '30 days'
|
|
||||||
GROUP BY DATE(updated_at)
|
|
||||||
ORDER BY day
|
|
||||||
`, usernameStr).Scan(&dayRows)
|
|
||||||
|
|
||||||
var weekRows []WeekRow
|
var monthRows []models.MonthRow
|
||||||
gdb.Raw(`
|
if err := database.GetMyDeliveryStatsPerMonth(&monthRows, usernameStr); err != nil {
|
||||||
SELECT EXTRACT(WEEK FROM updated_at)::int AS week_num,
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats mois"})
|
||||||
EXTRACT(YEAR FROM updated_at)::int AS year,
|
return
|
||||||
COUNT(*) AS count,
|
}
|
||||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
|
||||||
FROM commandes
|
|
||||||
WHERE livreur_assign = ?
|
|
||||||
AND status IN ('livre', 'approved')
|
|
||||||
AND updated_at >= NOW() - INTERVAL '12 weeks'
|
|
||||||
GROUP BY week_num, year
|
|
||||||
ORDER BY year, week_num
|
|
||||||
`, usernameStr).Scan(&weekRows)
|
|
||||||
|
|
||||||
var monthRows []MonthRow
|
var todayRow models.TodayRow
|
||||||
gdb.Raw(`
|
if err := database.GetMyDeliveryStatsToday(&todayRow, usernameStr); err != nil {
|
||||||
SELECT EXTRACT(MONTH FROM updated_at)::int AS month_num,
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats du jour"})
|
||||||
EXTRACT(YEAR FROM updated_at)::int AS year,
|
return
|
||||||
COUNT(*) AS count,
|
}
|
||||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
|
||||||
FROM commandes
|
|
||||||
WHERE livreur_assign = ?
|
|
||||||
AND status IN ('livre', 'approved')
|
|
||||||
AND updated_at >= NOW() - INTERVAL '12 months'
|
|
||||||
GROUP BY month_num, year
|
|
||||||
ORDER BY year, month_num
|
|
||||||
`, usernameStr).Scan(&monthRows)
|
|
||||||
|
|
||||||
monthNames := [13]string{"", "Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"}
|
monthNames := [13]string{"", "Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"}
|
||||||
|
|
||||||
@@ -639,5 +621,7 @@ func GetMyDeliveryStats(c *gin.Context) {
|
|||||||
"by_day": byDay,
|
"by_day": byDay,
|
||||||
"by_week": byWeek,
|
"by_week": byWeek,
|
||||||
"by_month": byMonth,
|
"by_month": byMonth,
|
||||||
|
"today_count": todayRow.Count,
|
||||||
|
"today_revenue": todayRow.Revenue,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import (
|
|||||||
func GetDeliveryPersonDetails(c *gin.Context) {
|
func GetDeliveryPersonDetails(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
// ✅ SÉCURITÉ: Admin seulement
|
|
||||||
userRole := c.GetString("role")
|
userRole := c.GetString("role")
|
||||||
if userRole != "admin" && userRole != "cabine" && userRole != "livreur" {
|
if userRole != "admin" && userRole != "cabine" && userRole != "livreur" {
|
||||||
log.Printf("❌ [GET_DELIVERY_DETAILS] Accès refusé - role=%s", userRole)
|
log.Printf("❌ [GET_DELIVERY_DETAILS] Accès refusé - role=%s", userRole)
|
||||||
@@ -63,9 +62,9 @@ func GetDeliveryPersonDetails(c *gin.Context) {
|
|||||||
|
|
||||||
// Utiliser la fonction GPS existante
|
// Utiliser la fonction GPS existante
|
||||||
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
||||||
var locationInfo map[string]interface{}
|
var locationInfo map[string]any
|
||||||
if err == nil {
|
if err == nil {
|
||||||
locationInfo = map[string]interface{}{
|
locationInfo = map[string]any{
|
||||||
"latitude": lat,
|
"latitude": lat,
|
||||||
"longitude": lon,
|
"longitude": lon,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ func GetOrderETA(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4️⃣ VÉRIFIER LES DROITS D'ACCÈS
|
|
||||||
cmdUsername, _ := command["username"].(string)
|
cmdUsername, _ := command["username"].(string)
|
||||||
userRole := c.GetString("role")
|
userRole := c.GetString("role")
|
||||||
|
|
||||||
@@ -112,10 +111,8 @@ func GetOrderETA(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5️⃣ VÉRIFIER LE STATUT DE LA COMMANDE
|
|
||||||
cmdStatus, _ := command["status"].(string)
|
cmdStatus, _ := command["status"].(string)
|
||||||
|
|
||||||
// ✅ CORRECTION: Vérifier si commande terminée
|
|
||||||
if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" {
|
if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" {
|
||||||
log.Printf("ℹ️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus)
|
log.Printf("ℹ️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -129,7 +126,6 @@ func GetOrderETA(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pour pending/assigned: pas encore de position livreur disponible
|
|
||||||
if cmdStatus == "pending" || cmdStatus == "assigned" {
|
if cmdStatus == "pending" || cmdStatus == "assigned" {
|
||||||
log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus)
|
log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -142,7 +138,6 @@ func GetOrderETA(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pour arrived: livreur sur place, ETA non pertinent
|
|
||||||
if cmdStatus == "arrived" {
|
if cmdStatus == "arrived" {
|
||||||
log.Printf("ℹ️ [ETA] Commande arrived - livreur déjà sur place")
|
log.Printf("ℹ️ [ETA] Commande arrived - livreur déjà sur place")
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -154,9 +149,7 @@ func GetOrderETA(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Pour en_route: calcul ETA réel via position du livreur
|
|
||||||
|
|
||||||
// 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA
|
|
||||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||||
etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
|
etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
|
||||||
|
|
||||||
@@ -197,10 +190,8 @@ func GetOrderETA(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7️⃣ Pas de cache valide - Recalculer l'ETA
|
|
||||||
log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...")
|
log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...")
|
||||||
|
|
||||||
// Récupérer coordonnées destination
|
|
||||||
var destLat, destLon float64
|
var destLat, destLon float64
|
||||||
|
|
||||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||||
@@ -248,13 +239,10 @@ func GetOrderETA(c *gin.Context) {
|
|||||||
|
|
||||||
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
|
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
|
||||||
|
|
||||||
// Cas 1 : GPS livreur disponible
|
|
||||||
livreurLocation, gpsErr := geoService.GetDeliveryPersonLocation(livreurAssign)
|
livreurLocation, gpsErr := geoService.GetDeliveryPersonLocation(livreurAssign)
|
||||||
if gpsErr != nil {
|
if gpsErr != nil {
|
||||||
// Cas 2 : GPS absent → dernière adresse de livraison
|
|
||||||
lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(livreurAssign)
|
lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(livreurAssign)
|
||||||
if lastErr != nil || lastLat == 0 {
|
if lastErr != nil || lastLat == 0 {
|
||||||
// Cas 3 : Aucune position → cache périmé ou message
|
|
||||||
log.Printf("⚠️ [ETA] Aucune position disponible pour %s", livreurAssign)
|
log.Printf("⚠️ [ETA] Aucune position disponible pour %s", livreurAssign)
|
||||||
c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData))
|
c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData))
|
||||||
return
|
return
|
||||||
@@ -263,7 +251,6 @@ func GetOrderETA(c *gin.Context) {
|
|||||||
log.Printf("📍 [ETA] Position depuis dernière livraison: (%.6f, %.6f)", lastLat, lastLon)
|
log.Printf("📍 [ETA] Position depuis dernière livraison: (%.6f, %.6f)", lastLat, lastLon)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculer ETA avec TomTom
|
|
||||||
log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)",
|
log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)",
|
||||||
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
|
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
|
||||||
|
|
||||||
@@ -274,11 +261,10 @@ func GetOrderETA(c *gin.Context) {
|
|||||||
etaMinutes = services.CalculateETA(distanceKm)
|
etaMinutes = services.CalculateETA(distanceKm)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sauvegarder en cache
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
|
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
|
||||||
|
|
||||||
etaCache := map[string]interface{}{
|
etaCache := map[string]any{
|
||||||
"command_id": commandID,
|
"command_id": commandID,
|
||||||
"eta_minutes": etaMinutes,
|
"eta_minutes": etaMinutes,
|
||||||
"updated_at": now.Unix(),
|
"updated_at": now.Unix(),
|
||||||
|
|||||||
@@ -169,10 +169,6 @@ func FindNearestDeliveryPerson(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// LISTE TOUS LES LIVREURS TRIÉS PAR DISTANCE
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// GetAllDeliveryDistances retourne tous les livreurs triés par distance
|
// GetAllDeliveryDistances retourne tous les livreurs triés par distance
|
||||||
func GetAllDeliveryDistances(c *gin.Context) {
|
func GetAllDeliveryDistances(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
@@ -258,9 +254,6 @@ func GetAllDeliveryDistances(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// AUTO-ASSIGNATION INTELLIGENTE AVEC QUEUE MULTI-COMMANDES
|
|
||||||
// ============================================
|
|
||||||
func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||||
@@ -347,12 +340,9 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("🚗 %d livreur(s) actif(s)", activeCount)
|
log.Printf("🚗 %d livreur(s) actif(s)", activeCount)
|
||||||
|
|
||||||
// Récupérer les livreurs actifs avec capacité disponible
|
|
||||||
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
||||||
|
|
||||||
// Si aucun livreur avec capacité disponible
|
|
||||||
if err != nil || len(activeLivreurs) == 0 {
|
if err != nil || len(activeLivreurs) == 0 {
|
||||||
// Cas 1: Un seul livreur actif -> pas de limite
|
|
||||||
if activeCount == 1 {
|
if activeCount == 1 {
|
||||||
singleDeliveryman, err := database.GetSingleActiveDeliveryman()
|
singleDeliveryman, err := database.GetSingleActiveDeliveryman()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -371,7 +361,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Passer les coordonnées à la fonction d'assignation
|
|
||||||
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address)
|
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
@@ -386,7 +375,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance)
|
log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance)
|
||||||
|
|
||||||
// ✅ CORRECTION: Utiliser etaData directement sans accès aux clés
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Commande assignée au seul livreur actif (sans limite)",
|
"message": "Commande assignée au seul livreur actif (sans limite)",
|
||||||
@@ -399,7 +387,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
"single_driver": true,
|
"single_driver": true,
|
||||||
"traffic_aware": true,
|
"traffic_aware": true,
|
||||||
},
|
},
|
||||||
"eta": etaData, // ✅ Directement l'objet complet
|
"eta": etaData,
|
||||||
"delivery_address": address,
|
"delivery_address": address,
|
||||||
"coordinates": gin.H{
|
"coordinates": gin.H{
|
||||||
"latitude": location.Latitude,
|
"latitude": location.Latitude,
|
||||||
@@ -410,13 +398,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cas 2: Plusieurs livreurs mais tous à capacité max -> Distribution forcée
|
|
||||||
allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity()
|
allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity()
|
||||||
|
|
||||||
if allAtCapacity && numActive > 1 {
|
if allAtCapacity && numActive > 1 {
|
||||||
log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive)
|
log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive)
|
||||||
|
|
||||||
// Trouver le livreur le moins chargé (même s'il dépasse 10)
|
|
||||||
leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced()
|
leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
@@ -424,8 +410,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculer le temps de trajet avec TomTom
|
|
||||||
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude)
|
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
@@ -433,8 +417,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Assigner de force avec coordonnées
|
|
||||||
err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address)
|
err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
@@ -449,7 +431,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance)
|
log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance)
|
||||||
|
|
||||||
// ✅ CORRECTION: Utiliser etaData directement
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Commande assignée par distribution forcée (capacité max dépassée)",
|
"message": "Commande assignée par distribution forcée (capacité max dépassée)",
|
||||||
@@ -463,7 +444,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
"over_capacity": true,
|
"over_capacity": true,
|
||||||
"traffic_aware": true,
|
"traffic_aware": true,
|
||||||
},
|
},
|
||||||
"eta": etaData, // ✅ Directement l'objet complet
|
"eta": etaData,
|
||||||
"delivery_address": address,
|
"delivery_address": address,
|
||||||
"coordinates": gin.H{
|
"coordinates": gin.H{
|
||||||
"latitude": location.Latitude,
|
"latitude": location.Latitude,
|
||||||
@@ -474,7 +455,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cas 3: Erreur générique
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
"error": "Aucun livreur actif avec capacité disponible",
|
"error": "Aucun livreur actif avec capacité disponible",
|
||||||
"active_count": activeCount,
|
"active_count": activeCount,
|
||||||
@@ -483,13 +463,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cas normal: Au moins un livreur avec capacité disponible
|
|
||||||
usernames := make([]string, len(activeLivreurs))
|
usernames := make([]string, len(activeLivreurs))
|
||||||
for i, livreur := range activeLivreurs {
|
for i, livreur := range activeLivreurs {
|
||||||
usernames[i] = livreur.Username
|
usernames[i] = livreur.Username
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trouver le livreur le plus proche (calcul rapide)
|
|
||||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
@@ -498,7 +476,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recalculer l'ETA avec TomTom pour plus de précision
|
|
||||||
travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
|
travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fallback sur le calcul initial
|
// Fallback sur le calcul initial
|
||||||
@@ -509,7 +486,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime)
|
log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime)
|
||||||
|
|
||||||
// ✅ Assigner à la queue du livreur avec coordonnées
|
|
||||||
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address)
|
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
@@ -524,7 +500,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username)
|
log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username)
|
||||||
|
|
||||||
// ✅ CORRECTION: Utiliser etaData directement
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Commande assignée à la queue du livreur",
|
"message": "Commande assignée à la queue du livreur",
|
||||||
@@ -537,7 +512,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
"single_driver": activeCount == 1,
|
"single_driver": activeCount == 1,
|
||||||
"traffic_aware": err == nil,
|
"traffic_aware": err == nil,
|
||||||
},
|
},
|
||||||
"eta": etaData, // ✅ Directement l'objet complet
|
"eta": etaData,
|
||||||
"delivery_address": address,
|
"delivery_address": address,
|
||||||
"coordinates": gin.H{
|
"coordinates": gin.H{
|
||||||
"latitude": location.Latitude,
|
"latitude": location.Latitude,
|
||||||
@@ -547,8 +522,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutoAssignAllPendingCommands assigne toutes les commandes en attente
|
|
||||||
// POST /api/v2/admin/protected/commands/auto-assign-all
|
|
||||||
func AutoAssignAllPendingCommands(c *gin.Context) {
|
func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||||
@@ -559,7 +532,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer toutes les commandes pending
|
|
||||||
commands, err := database.GetAllCommands("pending", "")
|
commands, err := database.GetAllCommands("pending", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
@@ -585,7 +557,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
|||||||
for _, cmd := range commands {
|
for _, cmd := range commands {
|
||||||
commandID, ok := cmd["id"].(int)
|
commandID, ok := cmd["id"].(int)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Essayer avec float64
|
|
||||||
if idFloat, ok := cmd["id"].(float64); ok {
|
if idFloat, ok := cmd["id"].(float64); ok {
|
||||||
commandID = int(idFloat)
|
commandID = int(idFloat)
|
||||||
} else {
|
} else {
|
||||||
@@ -593,7 +564,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer l'adresse
|
|
||||||
address, ok := cmd["adresse"].(string)
|
address, ok := cmd["adresse"].(string)
|
||||||
if !ok || address == "" || address == "Adresse non spécifiée" {
|
if !ok || address == "" || address == "Adresse non spécifiée" {
|
||||||
failed = append(failed, gin.H{
|
failed = append(failed, gin.H{
|
||||||
@@ -603,7 +573,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Géocoder l'adresse
|
|
||||||
location, err := geoService.GeocodeAddress(address)
|
location, err := geoService.GeocodeAddress(address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failed = append(failed, gin.H{
|
failed = append(failed, gin.H{
|
||||||
@@ -618,7 +587,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
|||||||
Longitude: location.Longitude,
|
Longitude: location.Longitude,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer les livreurs actifs
|
|
||||||
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
||||||
if err != nil || len(activeLivreurs) == 0 {
|
if err != nil || len(activeLivreurs) == 0 {
|
||||||
failed = append(failed, gin.H{
|
failed = append(failed, gin.H{
|
||||||
@@ -633,7 +601,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
|||||||
usernames[i] = livreur.Username
|
usernames[i] = livreur.Username
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trouver le livreur le plus proche (version rapide pour assignation masse)
|
|
||||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failed = append(failed, gin.H{
|
failed = append(failed, gin.H{
|
||||||
@@ -643,7 +610,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pour l'assignation en masse, on utilise le calcul rapide
|
|
||||||
travelTime := nearest.EstimatedTime
|
travelTime := nearest.EstimatedTime
|
||||||
distance := nearest.Distance
|
distance := nearest.Distance
|
||||||
|
|
||||||
@@ -657,13 +623,10 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mettre à jour le statut du livreur
|
|
||||||
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||||
|
|
||||||
// Récupérer l'ETA - ✅ CORRECTION: Gérer les types correctement
|
|
||||||
etaData, _ := database.GetCommandETA(commandID)
|
etaData, _ := database.GetCommandETA(commandID)
|
||||||
|
|
||||||
var totalETA, waitTime interface{}
|
var totalETA, waitTime any
|
||||||
totalETA = "N/A"
|
totalETA = "N/A"
|
||||||
waitTime = "N/A"
|
waitTime = "N/A"
|
||||||
|
|
||||||
@@ -688,7 +651,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
|||||||
log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA)
|
log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer l'overview des queues
|
|
||||||
queuesOverview, _ := database.GetAllQueuesOverview()
|
queuesOverview, _ := database.GetAllQueuesOverview()
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -705,7 +667,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs
|
// GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs
|
||||||
// GET /api/v2/admin/protected/delivery/queues
|
|
||||||
func GetAllDeliveryQueues(c *gin.Context) {
|
func GetAllDeliveryQueues(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -723,7 +684,6 @@ func GetAllDeliveryQueues(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer les détails de chaque livreur
|
|
||||||
var deliverymenDetails []gin.H
|
var deliverymenDetails []gin.H
|
||||||
|
|
||||||
keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result()
|
keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result()
|
||||||
@@ -734,7 +694,7 @@ func GetAllDeliveryQueues(c *gin.Context) {
|
|||||||
|
|
||||||
// Récupérer le statut
|
// Récupérer le statut
|
||||||
statusData, _ := db.Redis.Get(db.RedisCtx, key).Result()
|
statusData, _ := db.Redis.Get(db.RedisCtx, key).Result()
|
||||||
var status map[string]interface{}
|
var status map[string]any
|
||||||
if statusData != "" {
|
if statusData != "" {
|
||||||
json.Unmarshal([]byte(statusData), &status)
|
json.Unmarshal([]byte(statusData), &status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username)
|
log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username)
|
||||||
|
|
||||||
// Récupérer la position GPS du livreur
|
|
||||||
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [MAP_LINKS] Erreur position: %v", err)
|
log.Printf("❌ [MAP_LINKS] Erreur position: %v", err)
|
||||||
@@ -46,7 +45,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation des coordonnées
|
|
||||||
if lat == 0 && lon == 0 {
|
if lat == 0 && lon == 0 {
|
||||||
log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username)
|
log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username)
|
||||||
c.JSON(http.StatusNotFound, gin.H{
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
@@ -57,7 +55,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Générer les liens de cartes
|
|
||||||
mapLinks := database.GenerateMapLinks(lat, lon, username)
|
mapLinks := database.GenerateMapLinks(lat, lon, username)
|
||||||
|
|
||||||
log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon)
|
log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon)
|
||||||
@@ -76,8 +73,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLivreurNavLink retourne le lien Waze App pour une livraison assignée au livreur connecté
|
|
||||||
// GET /api/v1/livreur/deliveries/:id/nav-link
|
|
||||||
func GetLivreurNavLink(c *gin.Context) {
|
func GetLivreurNavLink(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
username := c.GetString("username")
|
username := c.GetString("username")
|
||||||
@@ -100,7 +95,6 @@ func GetLivreurNavLink(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priorité : coordonnées GPS de la destination
|
|
||||||
var wazeLink string
|
var wazeLink string
|
||||||
destLat, hasLat := command["dest_latitude"].(float64)
|
destLat, hasLat := command["dest_latitude"].(float64)
|
||||||
destLon, hasLon := command["dest_longitude"].(float64)
|
destLon, hasLon := command["dest_longitude"].(float64)
|
||||||
|
|||||||
@@ -4,92 +4,18 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"gestion/db"
|
"gestion/db"
|
||||||
"log"
|
"log"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetMyCompletedOrders(c *gin.Context) {
|
|
||||||
database := c.MustGet("database").(*db.Database)
|
|
||||||
|
|
||||||
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
|
|
||||||
username, exists := c.Get("username")
|
|
||||||
if !exists {
|
|
||||||
log.Printf("❌ [HISTORY] Utilisateur non authentifié")
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"error": "Authentification requise",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
usernameStr := username.(string)
|
|
||||||
log.Printf("📚 [HISTORY] Récupération historique pour: %s", usernameStr)
|
|
||||||
|
|
||||||
// ✅ Récupérer les commandes terminées (approved)
|
|
||||||
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ [HISTORY] Erreur: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "Erreur lors de la récupération de l'historique",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("✅ [HISTORY] %d commandes terminées trouvées", len(commands))
|
|
||||||
|
|
||||||
// ✅ Récupérer les infos client pour statistiques
|
|
||||||
client, err := database.GetClientByUsername(usernameStr)
|
|
||||||
|
|
||||||
// ✅ Récupérer les noms et clés des pools de points
|
|
||||||
poolNames := []string{"Pool 1", "Pool 2"}
|
|
||||||
var poolKeys []string
|
|
||||||
if settings, sErr := database.GetSettings(); sErr == nil && len(settings.PointsPools) > 0 {
|
|
||||||
poolNames = make([]string, len(settings.PointsPools))
|
|
||||||
poolKeys = make([]string, len(settings.PointsPools))
|
|
||||||
for i, p := range settings.PointsPools {
|
|
||||||
poolNames[i] = p.Name
|
|
||||||
poolKeys[i] = p.Key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response := gin.H{
|
|
||||||
"success": true,
|
|
||||||
"commands": commands,
|
|
||||||
"count": len(commands),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == nil && client != nil {
|
|
||||||
// Construire le tableau depuis points_extra[poolKey] pour tous les pools (stockage dynamique)
|
|
||||||
poolPoints := make([]int, len(poolKeys))
|
|
||||||
for i, key := range poolKeys {
|
|
||||||
if key != "" {
|
|
||||||
poolPoints[i] = client.PointsExtra[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("📊 [HISTORY] pool_names=%v pool_keys=%v pool_points=%v extra=%v",
|
|
||||||
poolNames, poolKeys, poolPoints, client.PointsExtra)
|
|
||||||
|
|
||||||
response["client_stats"] = gin.H{
|
|
||||||
"username": client.Username,
|
|
||||||
"total_commands": client.Command,
|
|
||||||
"points_extra": client.PointsExtra,
|
|
||||||
"pool_points": poolPoints,
|
|
||||||
"pool_names": poolNames,
|
|
||||||
"penalties": client.Amende,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
|
// GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
|
||||||
// GET /api/v1/my-commands/history/detailed
|
// GET /api/v1/my-commands/history/detailed
|
||||||
func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
|
|
||||||
username, exists := c.Get("username")
|
username, exists := c.Get("username")
|
||||||
if !exists {
|
if !exists {
|
||||||
log.Printf("❌ [HISTORY_DETAILED] Utilisateur non authentifié")
|
log.Printf("❌ [HISTORY_DETAILED] Utilisateur non authentifié")
|
||||||
@@ -102,7 +28,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
|||||||
usernameStr := username.(string)
|
usernameStr := username.(string)
|
||||||
log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr)
|
log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr)
|
||||||
|
|
||||||
// ✅ Récupérer les commandes terminées
|
|
||||||
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
|
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
|
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
|
||||||
@@ -112,7 +37,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Enrichir chaque commande avec ses items
|
|
||||||
var enrichedCommands []map[string]any
|
var enrichedCommands []map[string]any
|
||||||
|
|
||||||
for _, command := range commands {
|
for _, command := range commands {
|
||||||
@@ -121,7 +45,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer les items de cette commande
|
|
||||||
items, err := database.GetCommandItems(commandID)
|
items, err := database.GetCommandItems(commandID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err)
|
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err)
|
||||||
@@ -130,9 +53,7 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
|||||||
|
|
||||||
// Ajouter les items à la commande
|
// Ajouter les items à la commande
|
||||||
enrichedCommand := make(map[string]any)
|
enrichedCommand := make(map[string]any)
|
||||||
for k, v := range command {
|
maps.Copy(enrichedCommand, command)
|
||||||
enrichedCommand[k] = v
|
|
||||||
}
|
|
||||||
enrichedCommand["items"] = items
|
enrichedCommand["items"] = items
|
||||||
enrichedCommand["items_count"] = len(items)
|
enrichedCommand["items_count"] = len(items)
|
||||||
|
|
||||||
@@ -141,7 +62,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands))
|
log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands))
|
||||||
|
|
||||||
// ✅ Récupérer les infos client
|
|
||||||
client, err := database.GetClientByUsername(usernameStr)
|
client, err := database.GetClientByUsername(usernameStr)
|
||||||
|
|
||||||
response := gin.H{
|
response := gin.H{
|
||||||
@@ -168,7 +88,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
|||||||
func GetOrderHistory(c *gin.Context) {
|
func GetOrderHistory(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
|
|
||||||
username, exists := c.Get("username")
|
username, exists := c.Get("username")
|
||||||
if !exists {
|
if !exists {
|
||||||
log.Printf("❌ [ORDER_HISTORY] Utilisateur non authentifié")
|
log.Printf("❌ [ORDER_HISTORY] Utilisateur non authentifié")
|
||||||
@@ -180,7 +99,6 @@ func GetOrderHistory(c *gin.Context) {
|
|||||||
|
|
||||||
usernameStr := username.(string)
|
usernameStr := username.(string)
|
||||||
|
|
||||||
// Récupérer l'ID de la commande
|
|
||||||
var commandID int
|
var commandID int
|
||||||
if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil {
|
if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
@@ -191,7 +109,6 @@ func GetOrderHistory(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr)
|
log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr)
|
||||||
|
|
||||||
// ✅ Vérifier que la commande existe
|
|
||||||
command, err := database.GetCommandByID(commandID)
|
command, err := database.GetCommandByID(commandID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [ORDER_HISTORY] Commande non trouvée")
|
log.Printf("❌ [ORDER_HISTORY] Commande non trouvée")
|
||||||
@@ -201,7 +118,6 @@ func GetOrderHistory(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Vérifier que la commande appartient au client
|
|
||||||
cmdUsername, ok := command["username"].(string)
|
cmdUsername, ok := command["username"].(string)
|
||||||
if !ok || cmdUsername != usernameStr {
|
if !ok || cmdUsername != usernameStr {
|
||||||
log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr)
|
log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr)
|
||||||
@@ -211,18 +127,16 @@ func GetOrderHistory(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Récupérer les logs de la commande
|
|
||||||
logs, err := database.GetCommandLogs(commandID)
|
logs, err := database.GetCommandLogs(commandID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err)
|
log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err)
|
||||||
logs = []map[string]interface{}{}
|
logs = []map[string]any{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Récupérer les items
|
|
||||||
items, err := database.GetCommandItems(commandID)
|
items, err := database.GetCommandItems(commandID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err)
|
log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err)
|
||||||
items = []map[string]interface{}{}
|
items = []map[string]any{}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items))
|
log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items))
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gestion/db"
|
||||||
|
"gestion/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loginHistoryWeek struct {
|
||||||
|
Week int `json:"week"`
|
||||||
|
Entries []db.LoginHistoryEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLivreurLoginHistory retourne l'historique de connexion d'un livreur pour un mois donné,
|
||||||
|
// regroupé par semaine ISO (détail complet, pas d'agrégation par compteur).
|
||||||
|
func GetLivreurLoginHistory(c *gin.Context) {
|
||||||
|
username := c.Param("username")
|
||||||
|
if username == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
year := now.Year()
|
||||||
|
month := int(now.Month())
|
||||||
|
|
||||||
|
if y := c.Query("year"); y != "" {
|
||||||
|
parsed, err := strconv.Atoi(y)
|
||||||
|
if err != nil || parsed < 2000 || parsed > 2100 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Année invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
year = parsed
|
||||||
|
}
|
||||||
|
if m := c.Query("month"); m != "" {
|
||||||
|
parsed, err := strconv.Atoi(m)
|
||||||
|
if err != nil || parsed < 1 || parsed > 12 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Mois invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
month = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
entries, err := database.GetLivreurLoginHistoryByMonth(username, year, month)
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerErr(c, "Erreur récupération historique de connexion", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
weekOrder := make([]int, 0)
|
||||||
|
weekMap := make(map[int]*loginHistoryWeek)
|
||||||
|
for _, e := range entries {
|
||||||
|
_, isoWeek := e.CreatedAt.ISOWeek()
|
||||||
|
w, ok := weekMap[isoWeek]
|
||||||
|
if !ok {
|
||||||
|
w = &loginHistoryWeek{Week: isoWeek}
|
||||||
|
weekMap[isoWeek] = w
|
||||||
|
weekOrder = append(weekOrder, isoWeek)
|
||||||
|
}
|
||||||
|
w.Entries = append(w.Entries, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
weeks := make([]*loginHistoryWeek, 0, len(weekOrder))
|
||||||
|
for _, wk := range weekOrder {
|
||||||
|
weeks = append(weeks, weekMap[wk])
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"username": username,
|
||||||
|
"year": year,
|
||||||
|
"month": month,
|
||||||
|
"weeks": weeks,
|
||||||
|
"count": len(entries),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -20,7 +20,6 @@ func GetClientNotifications(c *gin.Context) {
|
|||||||
|
|
||||||
notifKey := "notifications:" + username
|
notifKey := "notifications:" + username
|
||||||
|
|
||||||
// Récupérer toutes les notifications (max 50)
|
|
||||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
|
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err)
|
log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err)
|
||||||
@@ -128,7 +127,7 @@ func MarkLivreurNotificationsRead(c *gin.Context) {
|
|||||||
|
|
||||||
markedCount := 0
|
markedCount := 0
|
||||||
for i, raw := range results {
|
for i, raw := range results {
|
||||||
var n map[string]interface{}
|
var n map[string]any
|
||||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -171,7 +170,7 @@ func MarkNotificationsRead(c *gin.Context) {
|
|||||||
// Réécrire chaque notification avec read=true
|
// Réécrire chaque notification avec read=true
|
||||||
markedCount := 0
|
markedCount := 0
|
||||||
for i, raw := range results {
|
for i, raw := range results {
|
||||||
var n map[string]interface{}
|
var n map[string]any
|
||||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ type BasketsRequest struct {
|
|||||||
Quantity float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// ✅ SÉCURISÉ: AddProductsBasket
|
|
||||||
// ============================================
|
|
||||||
// POST /api/v1/panier/add
|
// POST /api/v1/panier/add
|
||||||
func AddProductsBasket(c *gin.Context) {
|
func AddProductsBasket(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
@@ -90,7 +87,6 @@ func GetAllBaskets(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
|
|
||||||
authUsername, hasAuth := c.Get("username")
|
authUsername, hasAuth := c.Get("username")
|
||||||
if !hasAuth {
|
if !hasAuth {
|
||||||
log.Printf("❌ [GET_PANIER] Username manquant dans JWT")
|
log.Printf("❌ [GET_PANIER] Username manquant dans JWT")
|
||||||
@@ -100,7 +96,6 @@ func GetAllBaskets(c *gin.Context) {
|
|||||||
|
|
||||||
authUsernameStr := authUsername.(string)
|
authUsernameStr := authUsername.(string)
|
||||||
|
|
||||||
// ✅ SÉCURITÉ 2: Vérifier que c'est bien l'utilisateur de la session
|
|
||||||
if username != authUsernameStr {
|
if username != authUsernameStr {
|
||||||
log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!")
|
log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!")
|
||||||
log.Printf(" Username du JWT: %s", authUsernameStr)
|
log.Printf(" Username du JWT: %s", authUsernameStr)
|
||||||
@@ -111,10 +106,8 @@ func GetAllBaskets(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ SÉCURITÉ 3: Forcer l'utilisation du username du JWT
|
|
||||||
username = authUsernameStr
|
username = authUsernameStr
|
||||||
|
|
||||||
// ✅ SÉCURITÉ 4: Vérifier que c'est un CLIENT
|
|
||||||
_, err := database.GetClientByUsername(username)
|
_, err := database.GetClientByUsername(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [GET_PANIER] Client inexistant: %s", username)
|
log.Printf("❌ [GET_PANIER] Client inexistant: %s", username)
|
||||||
@@ -130,7 +123,7 @@ func GetAllBaskets(c *gin.Context) {
|
|||||||
|
|
||||||
var totalAmount float64
|
var totalAmount float64
|
||||||
for _, item := range baskets {
|
for _, item := range baskets {
|
||||||
totalAmount += item.Price // price = prix total de la ligne (cumul des ajouts)
|
totalAmount += item.Price
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount)
|
log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount)
|
||||||
@@ -156,7 +149,6 @@ func DeleteProductFromBasket(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
|
|
||||||
authUsername, hasAuth := c.Get("username")
|
authUsername, hasAuth := c.Get("username")
|
||||||
if !hasAuth {
|
if !hasAuth {
|
||||||
log.Printf("❌ [DEL_PANIER] Username manquant dans JWT")
|
log.Printf("❌ [DEL_PANIER] Username manquant dans JWT")
|
||||||
@@ -168,7 +160,6 @@ func DeleteProductFromBasket(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr)
|
log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr)
|
||||||
|
|
||||||
// ✅ SÉCURITÉ 2: Vérifier que l'article appartient à ce client
|
|
||||||
itemUsername, err := database.GetBasketItemOwner(req.ID)
|
itemUsername, err := database.GetBasketItemOwner(req.ID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -187,7 +178,6 @@ func DeleteProductFromBasket(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supprimer l'article
|
|
||||||
err = database.DeleteProductFromBasket(req.ID)
|
err = database.DeleteProductFromBasket(req.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ServerErr(c, "Erreur lors de la suppression", err)
|
utils.ServerErr(c, "Erreur lors de la suppression", err)
|
||||||
@@ -205,7 +195,6 @@ func DeleteProductFromBasket(c *gin.Context) {
|
|||||||
func ClearBasket(c *gin.Context) {
|
func ClearBasket(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
|
|
||||||
authUsername, hasAuth := c.Get("username")
|
authUsername, hasAuth := c.Get("username")
|
||||||
if !hasAuth {
|
if !hasAuth {
|
||||||
log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT")
|
log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT")
|
||||||
@@ -262,8 +251,8 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
var req struct {
|
var req struct {
|
||||||
DeliveryAddress string `json:"delivery_address" binding:"required"`
|
DeliveryAddress string `json:"delivery_address" binding:"required"`
|
||||||
UseReferralBalance bool `json:"use_referral_balance"`
|
UseReferralBalance bool `json:"use_referral_balance"`
|
||||||
PaymentMethod string `json:"payment_method"` // "cash" (défaut) ou "crypto"
|
PaymentMethod string `json:"payment_method"`
|
||||||
PayCurrency string `json:"pay_currency"` // ex: "btc", "eth", "ltc" (requis si crypto)
|
PayCurrency string `json:"pay_currency"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
|
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
|
||||||
@@ -277,7 +266,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
req.DeliveryAddress = cmd.DeliveryAddress
|
req.DeliveryAddress = cmd.DeliveryAddress
|
||||||
|
|
||||||
// Vérifier que le client a lié son compte Telegram (seulement si les notifications sont activées)
|
|
||||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||||
if _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked {
|
if _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Vous devez lier votre compte Telegram avant de commander"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Vous devez lier votre compte Telegram avant de commander"})
|
||||||
@@ -287,9 +275,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr)
|
log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr)
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 1️⃣ Vérifier que le panier n'est pas vide
|
|
||||||
// ============================================
|
|
||||||
items, err := database.GetBasketItems(usernameStr)
|
items, err := database.GetBasketItems(usernameStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ServerErr(c, "Impossible de récupérer le panier", err)
|
utils.ServerErr(c, "Impossible de récupérer le panier", err)
|
||||||
@@ -304,9 +289,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
|
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 1️⃣b Calculer le total et vérifier la présence d'au moins un article payant
|
|
||||||
// ============================================
|
|
||||||
var cartTotal float64
|
var cartTotal float64
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if price, ok := item["price"].(float64); ok {
|
if price, ok := item["price"].(float64); ok {
|
||||||
@@ -314,7 +296,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Détecter si le panier contient un article récompense (prix 0)
|
|
||||||
hasRewardItem := false
|
hasRewardItem := false
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if price, ok := item["price"].(float64); ok && price == 0 {
|
if price, ok := item["price"].(float64); ok && price == 0 {
|
||||||
@@ -322,17 +303,13 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Si récompense présente mais aucun produit payant → refuser
|
|
||||||
if hasRewardItem && cartTotal <= 0 {
|
if hasRewardItem && cartTotal <= 0 {
|
||||||
log.Printf("❌ [CHECKOUT] Panier contient uniquement des récompenses pour %s", usernameStr)
|
log.Printf("❌ [CHECKOUT] Panier contient uniquement des récompenses pour %s", usernameStr)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Vous devez commander au moins un produit de la boutique pour bénéficier de votre récompense"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Vous devez commander au moins un produit de la boutique pour bénéficier de votre récompense"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer les paramètres globaux (zones + parrainage)
|
|
||||||
appSettings, _ := database.GetSettings()
|
appSettings, _ := database.GetSettings()
|
||||||
|
|
||||||
// Récupérer le solde parrainage disponible (seulement si le système est activé)
|
|
||||||
var referralBalance float64
|
var referralBalance float64
|
||||||
if req.UseReferralBalance && appSettings.ReferralEnabled {
|
if req.UseReferralBalance && appSettings.ReferralEnabled {
|
||||||
referralBalance, _ = database.GetClientReferralBalance(usernameStr)
|
referralBalance, _ = database.GetClientReferralBalance(usernameStr)
|
||||||
@@ -366,8 +343,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Règle parrainage : après déduction du crédit, le client doit toujours payer au minimum le seuil de zone.
|
|
||||||
// Ex : zone 50€, crédit 50€ → panier doit être >= 100€
|
|
||||||
var referralUsed float64
|
var referralUsed float64
|
||||||
if req.UseReferralBalance && referralBalance > 0 {
|
if req.UseReferralBalance && referralBalance > 0 {
|
||||||
effectivePayment := cartTotal - referralBalance
|
effectivePayment := cartTotal - referralBalance
|
||||||
@@ -399,7 +374,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
|
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier que tous les produits du panier ont encore un prix actif
|
|
||||||
unavailable, err := database.GetUnavailableBasketItems(usernameStr)
|
unavailable, err := database.GetUnavailableBasketItems(usernameStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ServerErr(c, "Erreur vérification produits", err)
|
utils.ServerErr(c, "Erreur vérification produits", err)
|
||||||
@@ -414,11 +388,11 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérification option crypto
|
|
||||||
isCrypto := req.PaymentMethod == "crypto"
|
isCrypto := req.PaymentMethod == "crypto"
|
||||||
if isCrypto {
|
if isCrypto {
|
||||||
np, npOk := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
npRaw, npExists := c.Get("nowpayments")
|
||||||
if !npOk || np == nil {
|
np, npOk := npRaw.(*services.NowPaymentsClient)
|
||||||
|
if !npExists || !npOk || np == nil {
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"})
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -434,6 +408,10 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||||
}
|
}
|
||||||
log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err)
|
log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err)
|
||||||
|
if strings.Contains(err.Error(), "stock insuffisant") {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Le stock ou le produit n'est plus disponible, repasse commande"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -447,7 +425,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
|
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
|
||||||
|
|
||||||
// Pour le paiement crypto, le panier/stock sera décrémenté à la confirmation du webhook NowPayments.
|
|
||||||
if isCrypto {
|
if isCrypto {
|
||||||
np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||||
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
|
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
|
||||||
@@ -460,7 +437,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
payResp, err := np.CreatePayment(payReq)
|
payResp, err := np.CreatePayment(payReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Annuler la commande et restaurer le panier / parrainage
|
|
||||||
_ = database.CancelCryptoCommand(commandID)
|
_ = database.CancelCryptoCommand(commandID)
|
||||||
if referralUsed > 0 {
|
if referralUsed > 0 {
|
||||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||||
@@ -470,14 +446,21 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passer la commande en 'pending_payment' (attente confirmation)
|
|
||||||
if _, err := database.DB.Exec(`UPDATE commandes SET status = 'pending_payment', payment_method = 'crypto', updated_at = NOW() WHERE id = $1`, commandID); err != nil {
|
if _, err := database.DB.Exec(`UPDATE commandes SET status = 'pending_payment', payment_method = 'crypto', updated_at = NOW() WHERE id = $1`, commandID); err != nil {
|
||||||
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err)
|
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
priceAmt, _ := payResp.PriceAmount.Float64()
|
priceAmt, _ := payResp.PriceAmount.Float64()
|
||||||
payAmt, _ := payResp.PayAmount.Float64()
|
payAmt, _ := payResp.PayAmount.Float64()
|
||||||
_, _ = database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt)
|
if _, err := database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt); err != nil {
|
||||||
|
log.Printf("❌ [CHECKOUT] Erreur enregistrement paiement crypto (commande %d, nowpayment %s): %v", commandID, payResp.PaymentID.String(), err)
|
||||||
|
_ = database.CancelCryptoCommand(commandID)
|
||||||
|
if referralUsed > 0 {
|
||||||
|
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne lors de l'enregistrement du paiement"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("✅ [CHECKOUT] Commande %d en attente paiement crypto (%s)", commandID, req.PayCurrency)
|
log.Printf("✅ [CHECKOUT] Commande %d en attente paiement crypto (%s)", commandID, req.PayCurrency)
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
@@ -495,32 +478,8 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifier immédiatement tous les admins et agents cabine
|
|
||||||
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
|
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 3️⃣ Décrémenter le stock et vider le panier
|
|
||||||
// ============================================
|
|
||||||
err = database.ClearBasketOnCheckout(usernameStr)
|
|
||||||
if err != nil {
|
|
||||||
// Stock insuffisant au moment du checkout (concurrent) → annuler la commande
|
|
||||||
if strings.Contains(err.Error(), "stock insuffisant") {
|
|
||||||
_ = database.CancelCryptoCommand(commandID)
|
|
||||||
if referralUsed > 0 {
|
|
||||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
|
||||||
}
|
|
||||||
log.Printf("❌ [CHECKOUT] Stock insuffisant au moment de la validation: %v", err)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Tu as trop attendu pour passer commande! Le stock ou le produit n'est plus disponible, repasse commande"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
utils.ServerErr(c, "Impossible de valider le panier", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("🧹 [CHECKOUT] Stock décrémenté et panier vidé")
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 4️⃣ Auto-assignation livreur (optionnel)
|
|
||||||
// ============================================
|
|
||||||
var assigned bool
|
var assigned bool
|
||||||
var assignInfo gin.H
|
var assignInfo gin.H
|
||||||
|
|
||||||
@@ -540,7 +499,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance)
|
log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance)
|
||||||
|
|
||||||
// ✅ CORRECTION: Utiliser CalculateETAWithTomTom au lieu de GetETAWithTraffic
|
|
||||||
travelTime, distance, err := services.CalculateETAWithTomTom(
|
travelTime, distance, err := services.CalculateETAWithTomTom(
|
||||||
nearest.Location,
|
nearest.Location,
|
||||||
services.Coordinates{
|
services.Coordinates{
|
||||||
@@ -558,7 +516,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance)
|
log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance)
|
||||||
|
|
||||||
// Assigner la commande au livreur
|
|
||||||
err = database.AssignCommandToDeliverymanQueueWithCoords(
|
err = database.AssignCommandToDeliverymanQueueWithCoords(
|
||||||
commandID,
|
commandID,
|
||||||
nearest.Username,
|
nearest.Username,
|
||||||
@@ -571,13 +528,10 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err)
|
log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err)
|
||||||
} else {
|
} else {
|
||||||
// Mettre à jour le statut du livreur
|
|
||||||
err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err)
|
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifier le livreur de la nouvelle commande
|
|
||||||
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
|
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
|
||||||
if referralUsed > 0 {
|
if referralUsed > 0 {
|
||||||
notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed)
|
notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed)
|
||||||
@@ -585,8 +539,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
|
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
|
||||||
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
|
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifier le client
|
|
||||||
clientOrderID := database.GetClientOrderID(commandID)
|
clientOrderID := database.GetClientOrderID(commandID)
|
||||||
clientMsg := fmt.Sprintf("Ta commande #%d est prise en compte ! Merci de rester branché et vigilant sur les notifs à venir.", clientOrderID)
|
clientMsg := fmt.Sprintf("Ta commande #%d est prise en compte ! Merci de rester branché et vigilant sur les notifs à venir.", clientOrderID)
|
||||||
database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
|
database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
|
||||||
@@ -609,9 +561,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err)
|
log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 5️⃣ Réponse
|
|
||||||
// ============================================
|
|
||||||
newBalance, _ := database.GetClientReferralBalance(usernameStr)
|
newBalance, _ := database.GetClientReferralBalance(usernameStr)
|
||||||
resp := gin.H{
|
resp := gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -638,7 +587,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, resp)
|
c.JSON(http.StatusCreated, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBaseURL construit l'URL de base depuis la requête en cours
|
|
||||||
func getBaseURL(c *gin.Context) string {
|
func getBaseURL(c *gin.Context) string {
|
||||||
scheme := "https"
|
scheme := "https"
|
||||||
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetClientParrainAdmin — POST /api/v2/admin/protected/client/:username/parrain/set (admin)
|
|
||||||
// Assigne un parrain à un client. Le parrain reçoit settings.ReferralAmount sur son solde.
|
|
||||||
func SetClientParrainAdmin(c *gin.Context) {
|
func SetClientParrainAdmin(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
targetUsername := c.Param("username")
|
targetUsername := c.Param("username")
|
||||||
@@ -28,14 +26,12 @@ func SetClientParrainAdmin(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier que le parrain existe
|
|
||||||
parrain, err := database.GetClientByUsername(req.Parrain)
|
parrain, err := database.GetClientByUsername(req.Parrain)
|
||||||
if err != nil || parrain == nil {
|
if err != nil || parrain == nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier que le client n'a pas déjà un parrain
|
|
||||||
existing, err := database.GetClientParrain(targetUsername)
|
existing, err := database.GetClientParrain(targetUsername)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ServerErr(c, "Erreur vérification parrain", err)
|
utils.ServerErr(c, "Erreur vérification parrain", err)
|
||||||
@@ -46,25 +42,23 @@ func SetClientParrainAdmin(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := database.SetClientParrain(targetUsername, req.Parrain); err != nil {
|
settings, _ := database.GetSettings()
|
||||||
|
creditAmount := 0.0
|
||||||
|
if settings.ReferralEnabled && settings.ReferralAmount > 0 {
|
||||||
|
creditAmount = settings.ReferralAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.SetClientParrainAndCredit(targetUsername, req.Parrain, creditAmount); err != nil {
|
||||||
utils.ServerErr(c, "Erreur enregistrement parrain", err)
|
utils.ServerErr(c, "Erreur enregistrement parrain", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, creditAmount)
|
||||||
settings, _ := database.GetSettings()
|
|
||||||
if settings.ReferralEnabled && settings.ReferralAmount > 0 {
|
|
||||||
if err := database.CreditClientReferral(req.Parrain, settings.ReferralAmount); err != nil {
|
|
||||||
log.Printf("⚠️ [PARRAIN] Impossible de créditer %s: %v", req.Parrain, err)
|
|
||||||
} else {
|
|
||||||
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, settings.ReferralAmount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "Parrain enregistré",
|
"message": "Parrain enregistré",
|
||||||
"client": targetUsername,
|
"client": targetUsername,
|
||||||
"parrain": req.Parrain,
|
"parrain": req.Parrain,
|
||||||
"amount_credited": settings.ReferralAmount,
|
"amount_credited": creditAmount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,105 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"gestion/db"
|
"gestion/db"
|
||||||
"gestion/models"
|
"gestion/models"
|
||||||
"gestion/utils"
|
"gestion/utils"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// normalizeRewardCategoryType retombe sur "free_product" pour toute valeur
|
||||||
|
// vide ou inconnue — rétrocompatibilité avec les configurations enregistrées
|
||||||
|
// avant l'introduction du type par catégorie (RewardCategoryConfig.Type).
|
||||||
|
func normalizeRewardCategoryType(t string) string {
|
||||||
|
if t == "half_price_product" {
|
||||||
|
return "half_price_product"
|
||||||
|
}
|
||||||
|
return "free_product"
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryRewardCandidate représente un produit éligible à la récompense pour
|
||||||
|
// une config de catégorie donnée : son type ("free_product" |
|
||||||
|
// "half_price_product") et la quantité configurée pour cette catégorie.
|
||||||
|
type categoryRewardCandidate struct {
|
||||||
|
Category string
|
||||||
|
Type string
|
||||||
|
ProductID int
|
||||||
|
Name string
|
||||||
|
Quantity float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCategoryRewardCandidates dérive, pour chaque config de catégorie de
|
||||||
|
// la récompense, la liste des produits éligibles — tous ceux du catalogue si
|
||||||
|
// AllProducts, sinon la sélection explicite — avec le type et la quantité
|
||||||
|
// configurés directement dans le bloc catégorie (RewardCategoryConfig).
|
||||||
|
// Il n'existe plus de liste "reward_items" saisie à part : la catégorie est
|
||||||
|
// l'unique source de vérité (type + produits + quantité).
|
||||||
|
func resolveCategoryRewardCandidates(database *db.Database, reward *models.PointsReward) ([]categoryRewardCandidate, error) {
|
||||||
|
candidates := make([]categoryRewardCandidate, 0)
|
||||||
|
if reward == nil {
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogCache := make(map[string][]models.Product)
|
||||||
|
for _, cfg := range reward.CategoryConfigs {
|
||||||
|
rewardType := normalizeRewardCategoryType(cfg.Type)
|
||||||
|
if cfg.AllProducts {
|
||||||
|
products, ok := catalogCache[cfg.Category]
|
||||||
|
if !ok {
|
||||||
|
var err error
|
||||||
|
products, err = database.GetProductsByCategory(cfg.Category)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("produits catégorie %q: %w", cfg.Category, err)
|
||||||
|
}
|
||||||
|
catalogCache[cfg.Category] = products
|
||||||
|
}
|
||||||
|
for _, p := range products {
|
||||||
|
candidates = append(candidates, categoryRewardCandidate{
|
||||||
|
Category: cfg.Category, Type: rewardType, ProductID: p.ID, Name: p.Name, Quantity: cfg.Quantity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if len(cfg.Products) > 0 {
|
||||||
|
ids := make([]int, len(cfg.Products))
|
||||||
|
for i, pq := range cfg.Products {
|
||||||
|
ids[i] = pq.ProductID
|
||||||
|
}
|
||||||
|
names, err := database.GetProductNamesByIDs(ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("noms produits catégorie %q: %w", cfg.Category, err)
|
||||||
|
}
|
||||||
|
for _, pq := range cfg.Products {
|
||||||
|
candidates = append(candidates, categoryRewardCandidate{
|
||||||
|
Category: cfg.Category, Type: rewardType, ProductID: pq.ProductID, Name: names[pq.ProductID], Quantity: pq.Quantity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveRewardPrice calcule le prix réellement facturé pour une quantité
|
||||||
|
// donnée d'un produit récompense, selon le type de sa catégorie : 0€ pour
|
||||||
|
// "free_product", 50% du prix catalogue actif (palier ≤ quantity) pour
|
||||||
|
// "half_price_product". Erreur si le prix catalogue est introuvable (produit
|
||||||
|
// désactivé, aucun palier actif ≤ quantity) — la récompense ne doit alors pas
|
||||||
|
// être proposée/réclamée plutôt que de facturer un montant incorrect.
|
||||||
|
func effectiveRewardPrice(database *db.Database, productID int, quantity float64, rewardType string) (float64, error) {
|
||||||
|
if rewardType != "half_price_product" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
catalogPrice, err := database.GetActiveProductPrice(productID, quantity)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", productID, err)
|
||||||
|
}
|
||||||
|
return math.Round(catalogPrice/2*100) / 100, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetMyPointsRewards retourne les points et les récompenses disponibles du client connecté.
|
// GetMyPointsRewards retourne les points et les récompenses disponibles du client connecté.
|
||||||
// La récompense est globale : son seuil s'applique indépendamment à chaque pool.
|
// La récompense est globale : son seuil s'applique indépendamment à chaque pool.
|
||||||
func GetMyPointsRewards(c *gin.Context) {
|
func GetMyPointsRewards(c *gin.Context) {
|
||||||
@@ -41,11 +130,26 @@ func GetMyPointsRewards(c *gin.Context) {
|
|||||||
|
|
||||||
reward := settings.PointsReward
|
reward := settings.PointsReward
|
||||||
|
|
||||||
|
type ConfigProductResponse struct {
|
||||||
|
ProductID int `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
type EligibleConfigResponse struct {
|
type EligibleConfigResponse struct {
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
|
Type string `json:"type"`
|
||||||
AllProducts bool `json:"all_products"`
|
AllProducts bool `json:"all_products"`
|
||||||
ProductIDs []int `json:"product_ids"`
|
Products []ConfigProductResponse `json:"products"`
|
||||||
ProductNames []string `json:"product_names"`
|
Quantity float64 `json:"quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RewardItemResponse struct {
|
||||||
|
ProductID int `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Type string `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PoolInfo struct {
|
type PoolInfo struct {
|
||||||
@@ -56,23 +160,13 @@ func GetMyPointsRewards(c *gin.Context) {
|
|||||||
RewardsClaimed int `json:"rewards_claimed"`
|
RewardsClaimed int `json:"rewards_claimed"`
|
||||||
RewardsAvailable int `json:"rewards_available"`
|
RewardsAvailable int `json:"rewards_available"`
|
||||||
EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"`
|
EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"`
|
||||||
|
EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collecter tous les product_ids nécessaires en un seul passage
|
candidates, err := resolveCategoryRewardCandidates(database, reward)
|
||||||
allProductIDs := make([]int, 0)
|
if err != nil {
|
||||||
if reward != nil {
|
log.Printf("⚠️ [POINTS] Résolution candidats récompense: %v", err)
|
||||||
for _, cfg := range reward.CategoryConfigs {
|
|
||||||
if !cfg.AllProducts {
|
|
||||||
allProductIDs = append(allProductIDs, cfg.ProductIDs...)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for _, item := range reward.RewardItems {
|
|
||||||
if item.ProductID > 0 {
|
|
||||||
allProductIDs = append(allProductIDs, item.ProductID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
productNames, _ := database.GetProductNamesByIDs(allProductIDs)
|
|
||||||
|
|
||||||
pools := make([]PoolInfo, 0, len(settings.PointsPools))
|
pools := make([]PoolInfo, 0, len(settings.PointsPools))
|
||||||
for _, pool := range settings.PointsPools {
|
for _, pool := range settings.PointsPools {
|
||||||
@@ -83,12 +177,9 @@ func GetMyPointsRewards(c *gin.Context) {
|
|||||||
if reward != nil && reward.Threshold > 0 {
|
if reward != nil && reward.Threshold > 0 {
|
||||||
earned = pts / reward.Threshold
|
earned = pts / reward.Threshold
|
||||||
available = earned - redeemed
|
available = earned - redeemed
|
||||||
if available < 0 {
|
available = max(earned-redeemed, 0)
|
||||||
available = 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtrer les category_configs aux seules catégories du pool
|
|
||||||
poolCats := make(map[string]bool, len(pool.Categories))
|
poolCats := make(map[string]bool, len(pool.Categories))
|
||||||
for _, c := range pool.Categories {
|
for _, c := range pool.Categories {
|
||||||
poolCats[c] = true
|
poolCats[c] = true
|
||||||
@@ -99,21 +190,50 @@ func GetMyPointsRewards(c *gin.Context) {
|
|||||||
if !poolCats[cfg.Category] {
|
if !poolCats[cfg.Category] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
names := make([]string, 0, len(cfg.ProductIDs))
|
products := make([]ConfigProductResponse, 0, len(cfg.Products))
|
||||||
for _, pid := range cfg.ProductIDs {
|
for _, pq := range cfg.Products {
|
||||||
if n, ok := productNames[pid]; ok {
|
name := ""
|
||||||
names = append(names, n)
|
for _, cand := range candidates {
|
||||||
|
if cand.ProductID == pq.ProductID && cand.Category == cfg.Category {
|
||||||
|
name = cand.Name
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
products = append(products, ConfigProductResponse{
|
||||||
|
ProductID: pq.ProductID,
|
||||||
|
ProductName: name,
|
||||||
|
Quantity: pq.Quantity,
|
||||||
|
})
|
||||||
|
}
|
||||||
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
|
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
|
||||||
Category: cfg.Category,
|
Category: cfg.Category,
|
||||||
|
Type: normalizeRewardCategoryType(cfg.Type),
|
||||||
AllProducts: cfg.AllProducts,
|
AllProducts: cfg.AllProducts,
|
||||||
ProductIDs: cfg.ProductIDs,
|
Products: products,
|
||||||
ProductNames: names,
|
Quantity: cfg.Quantity,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eligibleRewardItems := make([]RewardItemResponse, 0)
|
||||||
|
for _, cand := range candidates {
|
||||||
|
if !poolCats[cand.Category] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
|
||||||
|
ProductID: cand.ProductID,
|
||||||
|
ProductName: cand.Name,
|
||||||
|
Quantity: cand.Quantity,
|
||||||
|
Price: price,
|
||||||
|
Type: cand.Type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pools = append(pools, PoolInfo{
|
pools = append(pools, PoolInfo{
|
||||||
Key: pool.Key,
|
Key: pool.Key,
|
||||||
Name: pool.Name,
|
Name: pool.Name,
|
||||||
@@ -122,34 +242,30 @@ func GetMyPointsRewards(c *gin.Context) {
|
|||||||
RewardsClaimed: redeemed,
|
RewardsClaimed: redeemed,
|
||||||
RewardsAvailable: available,
|
RewardsAvailable: available,
|
||||||
EligibleConfigs: eligibleConfigs,
|
EligibleConfigs: eligibleConfigs,
|
||||||
|
EligibleRewardItems: eligibleRewardItems,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construire la liste des produits récompense avec leurs noms
|
// Aperçu global des produits récompense, indépendant d'un pool précis — le
|
||||||
type RewardItemResponse struct {
|
// type/prix effectif par pool est celui exposé dans pools[].eligible_reward_items.
|
||||||
ProductID int `json:"product_id"`
|
|
||||||
ProductName string `json:"product_name"`
|
|
||||||
Quantity float64 `json:"quantity"`
|
|
||||||
Price float64 `json:"price"`
|
|
||||||
}
|
|
||||||
var rewardMeta gin.H
|
var rewardMeta gin.H
|
||||||
if reward != nil {
|
if reward != nil {
|
||||||
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems))
|
rewardItems := make([]RewardItemResponse, 0, len(candidates))
|
||||||
for _, item := range reward.RewardItems {
|
for _, cand := range candidates {
|
||||||
if item.ProductID <= 0 {
|
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
|
||||||
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := productNames[item.ProductID]
|
|
||||||
rewardItems = append(rewardItems, RewardItemResponse{
|
rewardItems = append(rewardItems, RewardItemResponse{
|
||||||
ProductID: item.ProductID,
|
ProductID: cand.ProductID,
|
||||||
ProductName: name,
|
ProductName: cand.Name,
|
||||||
Quantity: item.Quantity,
|
Quantity: cand.Quantity,
|
||||||
Price: item.Price,
|
Price: price,
|
||||||
|
Type: cand.Type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
rewardMeta = gin.H{
|
rewardMeta = gin.H{
|
||||||
"threshold": reward.Threshold,
|
"threshold": reward.Threshold,
|
||||||
"type": reward.Type,
|
|
||||||
"description": reward.Description,
|
"description": reward.Description,
|
||||||
"reward_items": rewardItems,
|
"reward_items": rewardItems,
|
||||||
}
|
}
|
||||||
@@ -168,7 +284,7 @@ func ClaimMyReward(c *gin.Context) {
|
|||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
PoolKey string `json:"pool_key" binding:"required"`
|
PoolKey string `json:"pool_key" binding:"required"`
|
||||||
ProductID int `json:"product_id"` // optionnel : 0 = automatique (1 seul item)
|
ProductID int `json:"product_id"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pool_key requis"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "pool_key requis"})
|
||||||
@@ -194,53 +310,103 @@ func ClaimMyReward(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier que le pool existe
|
// Vérifier que le pool existe et récupérer ses catégories
|
||||||
poolExists := false
|
var selectedPool *models.PointsPool
|
||||||
for _, p := range settings.PointsPools {
|
for i := range settings.PointsPools {
|
||||||
if p.Key == req.PoolKey {
|
if settings.PointsPools[i].Key == req.PoolKey {
|
||||||
poolExists = true
|
selectedPool = &settings.PointsPools[i]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !poolExists {
|
if selectedPool == nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Pool introuvable"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Pool introuvable"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
remaining, err := database.ClaimPoolReward(username, req.PoolKey, reward.Threshold)
|
// Un produit récompense n'est éligible pour ce pool que si sa catégorie
|
||||||
|
// fait partie des catégories du pool — sans ce filtre, un client pourrait
|
||||||
|
// réclamer n'importe quel produit récompense (toutes catégories
|
||||||
|
// confondues) avec les points d'un pool quelconque.
|
||||||
|
poolCategories := make(map[string]bool, len(selectedPool.Categories))
|
||||||
|
for _, cat := range selectedPool.Categories {
|
||||||
|
poolCategories[cat] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, err := resolveCategoryRewardCandidates(database, reward)
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerErr(c, "Erreur résolution produits récompense", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le prix effectif (0€ ou -50% du prix catalogue courant) est résolu ici,
|
||||||
|
// avant toute écriture — si un item ne peut pas être tarifé (produit sans
|
||||||
|
// palier de prix actif), la réclamation entière échoue proprement, avant
|
||||||
|
// même de démarrer la transaction de consommation de points.
|
||||||
|
eligibleItems := make([]models.RewardItem, 0, len(candidates))
|
||||||
|
for _, cand := range candidates {
|
||||||
|
if !poolCategories[cand.Category] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ [CLAIM] %s: %v", username, err)
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
eligibleItems = append(eligibleItems, models.RewardItem{
|
||||||
|
ProductID: cand.ProductID,
|
||||||
|
Quantity: cand.Quantity,
|
||||||
|
Price: price,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsToAdd := eligibleItems
|
||||||
|
if req.ProductID > 0 {
|
||||||
|
itemsToAdd = nil
|
||||||
|
for _, item := range eligibleItems {
|
||||||
|
if item.ProductID == req.ProductID {
|
||||||
|
itemsToAdd = []models.RewardItem{item}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if itemsToAdd == nil {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Ce produit n'est pas éligible pour cette récompense"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sans produit éligible pour ce pool (ex: catégories de la récompense mal
|
||||||
|
// alignées avec celles du pool), on refuse avant de consommer un point —
|
||||||
|
// sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit
|
||||||
|
// jamais ajouté au panier (récompense perdue silencieusement).
|
||||||
|
if len(itemsToAdd) == 0 {
|
||||||
|
log.Printf("❌ [CLAIM] Aucun produit éligible pour %s (pool=%s)", username, req.PoolKey)
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining, added, err := database.ClaimPoolRewardAndAddToBasket(username, req.PoolKey, reward.Threshold, itemsToAdd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "pas de récompense disponible") {
|
if strings.Contains(err.Error(), "pas de récompense disponible") {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "Pas assez de points pour réclamer une récompense"})
|
c.JSON(http.StatusConflict, gin.H{"error": "Pas assez de points pour réclamer une récompense"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.Contains(err.Error(), "produit récompense introuvable") {
|
||||||
|
log.Printf("❌ [CLAIM] Configuration récompense invalide pour %s: %v", username, err)
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
|
||||||
|
return
|
||||||
|
}
|
||||||
utils.ServerErr(c, "Erreur réclamation récompense", err)
|
utils.ServerErr(c, "Erreur réclamation récompense", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si le client a sélectionné un produit spécifique parmi plusieurs, ne donner que celui-là
|
productAdded := len(added) > 0
|
||||||
itemsToAdd := reward.RewardItems
|
|
||||||
if req.ProductID > 0 && len(reward.RewardItems) > 1 {
|
|
||||||
for _, item := range reward.RewardItems {
|
|
||||||
if item.ProductID == req.ProductID {
|
|
||||||
itemsToAdd = []models.RewardItem{item}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ajouter les produits récompense au panier si configurés
|
|
||||||
productAdded := false
|
|
||||||
var productNames []string
|
var productNames []string
|
||||||
if len(itemsToAdd) > 0 {
|
|
||||||
if added, addErr := database.AddRewardsToBasket(username, itemsToAdd, req.PoolKey); addErr == nil && len(added) > 0 {
|
|
||||||
productAdded = true
|
|
||||||
for _, item := range added {
|
for _, item := range added {
|
||||||
productNames = append(productNames, item.ProductName)
|
productNames = append(productNames, item.ProductName)
|
||||||
}
|
}
|
||||||
|
if productAdded {
|
||||||
log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
|
log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
|
||||||
} else if addErr != nil {
|
|
||||||
log.Printf("⚠️ [CLAIM] Impossible d'ajouter produits récompense: %v", addErr)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -252,7 +418,6 @@ func ClaimMyReward(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminResetClientRedeemed remet à zéro les récompenses réclamées d'un client (admin).
|
|
||||||
func AdminResetClientRedeemed(c *gin.Context) {
|
func AdminResetClientRedeemed(c *gin.Context) {
|
||||||
username := c.Param("username")
|
username := c.Param("username")
|
||||||
poolKey := c.Query("pool_key")
|
poolKey := c.Query("pool_key")
|
||||||
|
|||||||
+112
-150
@@ -4,12 +4,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"gestion/db"
|
"gestion/db"
|
||||||
"gestion/models"
|
"gestion/models"
|
||||||
|
"gestion/services"
|
||||||
"gestion/utils"
|
"gestion/utils"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -116,7 +117,6 @@ func validateCategory(database *db.Database, category string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension)
|
|
||||||
func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
|
func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
|
||||||
file, err := fileHeader.Open()
|
file, err := fileHeader.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -130,7 +130,6 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mimeType := mtype.String()
|
mimeType := mtype.String()
|
||||||
// Normaliser : couper les paramètres éventuels (ex: "video/mp4; codecs=...")
|
|
||||||
if idx := strings.Index(mimeType, ";"); idx != -1 {
|
if idx := strings.Index(mimeType, ";"); idx != -1 {
|
||||||
mimeType = strings.TrimSpace(mimeType[:idx])
|
mimeType = strings.TrimSpace(mimeType[:idx])
|
||||||
}
|
}
|
||||||
@@ -142,28 +141,9 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
|
|||||||
return mimeType, nil
|
return mimeType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ PROTECTION CONTRE PATH TRAVERSAL
|
|
||||||
func sanitizeFilePath(path string) (string, error) {
|
|
||||||
// Nettoyer le chemin
|
|
||||||
cleaned := filepath.Clean(path)
|
|
||||||
|
|
||||||
// Vérifier qu'il ne contient pas de ".."
|
|
||||||
if strings.Contains(cleaned, "..") {
|
|
||||||
return "", fmt.Errorf("path traversal détecté")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier qu'il commence par "uploads/"
|
|
||||||
if !strings.HasPrefix(cleaned, "uploads/") && !strings.HasPrefix(cleaned, "uploads\\") {
|
|
||||||
return "", fmt.Errorf("chemin invalide")
|
|
||||||
}
|
|
||||||
|
|
||||||
return cleaned, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateProduct(c *gin.Context) {
|
func CreateProduct(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
// ✅ VÉRIFIER LE RÔLE (déjà fait par middleware, double-check)
|
|
||||||
role := c.GetString("role")
|
role := c.GetString("role")
|
||||||
if role != "admin" && role != "cabine" {
|
if role != "admin" && role != "cabine" {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||||
@@ -172,14 +152,12 @@ func CreateProduct(c *gin.Context) {
|
|||||||
|
|
||||||
username, _ := safeGetUsername(c)
|
username, _ := safeGetUsername(c)
|
||||||
|
|
||||||
// ✅ PARSER AVEC LIMITE DE TAILLE
|
|
||||||
if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil {
|
if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil {
|
||||||
log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err)
|
log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ RÉCUPÉRER ET VALIDER LES DONNÉES
|
|
||||||
name := strings.TrimSpace(c.PostForm("name"))
|
name := strings.TrimSpace(c.PostForm("name"))
|
||||||
category := strings.TrimSpace(c.PostForm("category"))
|
category := strings.TrimSpace(c.PostForm("category"))
|
||||||
description := strings.TrimSpace(c.PostForm("description"))
|
description := strings.TrimSpace(c.PostForm("description"))
|
||||||
@@ -189,7 +167,6 @@ func CreateProduct(c *gin.Context) {
|
|||||||
unit = "u"
|
unit = "u"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ VALIDATION STRICTE
|
|
||||||
if err := validateProductName(name); err != nil {
|
if err := validateProductName(name); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -200,7 +177,6 @@ func CreateProduct(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ NETTOYER ET VALIDER LA CATÉGORIE
|
|
||||||
category = strings.ToLower(strings.TrimSpace(category))
|
category = strings.ToLower(strings.TrimSpace(category))
|
||||||
category = strings.Map(func(r rune) rune {
|
category = strings.Map(func(r rune) rune {
|
||||||
if r < 32 || r == 127 {
|
if r < 32 || r == 127 {
|
||||||
@@ -219,7 +195,6 @@ func CreateProduct(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ VALIDER LE STOCK
|
|
||||||
stock, err := strconv.ParseFloat(stockStr, 64)
|
stock, err := strconv.ParseFloat(stockStr, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
|
||||||
@@ -231,11 +206,10 @@ func CreateProduct(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ RÉCUPÉRER ET VALIDER LES PRIX
|
|
||||||
prices := []models.ProductPrice{}
|
prices := []models.ProductPrice{}
|
||||||
priceIndex := 0
|
priceIndex := 0
|
||||||
|
|
||||||
for priceIndex < 100 { // Limite anti-spam
|
for priceIndex < 100 {
|
||||||
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
|
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
|
||||||
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
|
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
|
||||||
activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex)
|
activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex)
|
||||||
@@ -323,7 +297,6 @@ func CreateProduct(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ LIMITER LE NOMBRE DE FICHIERS
|
|
||||||
if len(files) > MaxFilesPerProduct {
|
if len(files) > MaxFilesPerProduct {
|
||||||
database.DeleteProduct(product.ID)
|
database.DeleteProduct(product.ID)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
@@ -336,13 +309,13 @@ func CreateProduct(c *gin.Context) {
|
|||||||
|
|
||||||
cleanProductName := cleanFileName(product.Name)
|
cleanProductName := cleanFileName(product.Name)
|
||||||
uploadedMedia := []models.Media{}
|
uploadedMedia := []models.Media{}
|
||||||
savedFiles := []string{}
|
savedFiles := []models.Media{}
|
||||||
|
storage := c.MustGet("storage").(services.Storage)
|
||||||
var totalSize int64 = 0
|
var totalSize int64 = 0
|
||||||
|
|
||||||
for i, fileHeader := range files {
|
for i, fileHeader := range files {
|
||||||
// ✅ VÉRIFIER LA TAILLE INDIVIDUELLE
|
|
||||||
if fileHeader.Size > MaxFileSize {
|
if fileHeader.Size > MaxFileSize {
|
||||||
rollbackFiles(savedFiles)
|
rollbackFiles(storage, savedFiles)
|
||||||
database.DeleteProduct(product.ID)
|
database.DeleteProduct(product.ID)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"error": fmt.Sprintf("Fichier %s trop volumineux (max %dMB)", fileHeader.Filename, MaxFileSize/(1024*1024)),
|
"error": fmt.Sprintf("Fichier %s trop volumineux (max %dMB)", fileHeader.Filename, MaxFileSize/(1024*1024)),
|
||||||
@@ -351,10 +324,8 @@ func CreateProduct(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
totalSize += fileHeader.Size
|
totalSize += fileHeader.Size
|
||||||
|
|
||||||
// ✅ VÉRIFIER LA TAILLE TOTALE
|
|
||||||
if totalSize > MaxTotalUploadSize {
|
if totalSize > MaxTotalUploadSize {
|
||||||
rollbackFiles(savedFiles)
|
rollbackFiles(storage, savedFiles)
|
||||||
database.DeleteProduct(product.ID)
|
database.DeleteProduct(product.ID)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"error": fmt.Sprintf("Taille totale dépassée (max %dMB)", MaxTotalUploadSize/(1024*1024)),
|
"error": fmt.Sprintf("Taille totale dépassée (max %dMB)", MaxTotalUploadSize/(1024*1024)),
|
||||||
@@ -364,76 +335,50 @@ func CreateProduct(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("📄 [%d/%d] Traitement: %s", i+1, len(files), fileHeader.Filename)
|
log.Printf("📄 [%d/%d] Traitement: %s", i+1, len(files), fileHeader.Filename)
|
||||||
|
|
||||||
// ✅ VÉRIFIER LE TYPE MIME RÉEL (pas juste l'extension)
|
|
||||||
mimeType, err := validateFileMimeType(fileHeader)
|
mimeType, err := validateFileMimeType(fileHeader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err)
|
log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err)
|
||||||
rollbackFiles(savedFiles)
|
rollbackFiles(storage, savedFiles)
|
||||||
database.DeleteProduct(product.ID)
|
database.DeleteProduct(product.ID)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ DÉTERMINER LE TYPE DE MÉDIA
|
|
||||||
var mediaType string
|
var mediaType string
|
||||||
if strings.HasPrefix(mimeType, "image/") {
|
if strings.HasPrefix(mimeType, "image/") {
|
||||||
mediaType = "image"
|
mediaType = "image"
|
||||||
} else if strings.HasPrefix(mimeType, "video/") {
|
} else if strings.HasPrefix(mimeType, "video/") {
|
||||||
mediaType = "video"
|
mediaType = "video"
|
||||||
} else {
|
} else {
|
||||||
rollbackFiles(savedFiles)
|
rollbackFiles(storage, savedFiles)
|
||||||
database.DeleteProduct(product.ID)
|
database.DeleteProduct(product.ID)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ GÉNÉRER UN NOM UNIQUE ET SÉCURISÉ
|
|
||||||
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename)
|
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename)
|
||||||
|
|
||||||
// ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE
|
mediaURL, mediaKey, err := storage.Upload(fileHeader, mediaType+"s", uniqueFileName)
|
||||||
destFolder := filepath.Join("uploads", mediaType+"s")
|
|
||||||
if err := os.MkdirAll(destFolder, 0750); err != nil {
|
|
||||||
log.Printf("❌ [CreateProduct] Erreur création dossier: %v", err)
|
|
||||||
rollbackFiles(savedFiles)
|
|
||||||
database.DeleteProduct(product.ID)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur système"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
filePath := filepath.Join(destFolder, uniqueFileName)
|
|
||||||
|
|
||||||
// ✅ VALIDER LE CHEMIN (protection path traversal)
|
|
||||||
safeFilePath, err := sanitizeFilePath(filePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [CreateProduct] Path traversal détecté: %v", err)
|
|
||||||
rollbackFiles(savedFiles)
|
|
||||||
database.DeleteProduct(product.ID)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ SAUVEGARDER LE FICHIER
|
|
||||||
if err := c.SaveUploadedFile(fileHeader, safeFilePath); err != nil {
|
|
||||||
log.Printf("❌ [CreateProduct] Erreur sauvegarde: %v", err)
|
log.Printf("❌ [CreateProduct] Erreur sauvegarde: %v", err)
|
||||||
rollbackFiles(savedFiles)
|
rollbackFiles(storage, savedFiles)
|
||||||
database.DeleteProduct(product.ID)
|
database.DeleteProduct(product.ID)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
savedFiles = append(savedFiles, safeFilePath)
|
savedFiles = append(savedFiles, models.Media{URL: mediaURL, Key: mediaKey})
|
||||||
|
|
||||||
// ✅ CRÉER L'ENTRÉE MÉDIA
|
|
||||||
mediaURL := "/" + filepath.ToSlash(safeFilePath)
|
|
||||||
media := models.Media{
|
media := models.Media{
|
||||||
ProductID: product.ID,
|
ProductID: product.ID,
|
||||||
Type: mediaType,
|
Type: mediaType,
|
||||||
URL: mediaURL,
|
URL: mediaURL,
|
||||||
|
Key: mediaKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := database.CreateMedia(&media); err != nil {
|
if err := database.CreateMedia(&media); err != nil {
|
||||||
log.Printf("❌ [CreateProduct] Erreur DB média: %v", err)
|
log.Printf("❌ [CreateProduct] Erreur DB média: %v", err)
|
||||||
rollbackFiles(savedFiles)
|
rollbackFiles(storage, savedFiles)
|
||||||
database.DeleteProduct(product.ID)
|
database.DeleteProduct(product.ID)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
|
||||||
return
|
return
|
||||||
@@ -468,6 +413,7 @@ func GetAllProducts(c *gin.Context) {
|
|||||||
if role != "admin" && role != "cabine" {
|
if role != "admin" && role != "cabine" {
|
||||||
products = filterActivePrices(products)
|
products = filterActivePrices(products)
|
||||||
}
|
}
|
||||||
|
products = applyPromotions(products, database)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": products,
|
"data": products,
|
||||||
@@ -480,7 +426,6 @@ func GetProductsByCategory(c *gin.Context) {
|
|||||||
|
|
||||||
category := strings.ToLower(strings.TrimSpace(c.Param("category")))
|
category := strings.ToLower(strings.TrimSpace(c.Param("category")))
|
||||||
|
|
||||||
// ✅ VALIDATION
|
|
||||||
if err := validateCategory(database, category); err != nil {
|
if err := validateCategory(database, category); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -499,7 +444,6 @@ func GetProductsByCategory(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Charger les médias
|
|
||||||
for i := range products {
|
for i := range products {
|
||||||
media, _ := database.GetMediaByProductID(products[i].ID)
|
media, _ := database.GetMediaByProductID(products[i].ID)
|
||||||
products[i].Media = media
|
products[i].Media = media
|
||||||
@@ -508,6 +452,7 @@ func GetProductsByCategory(c *gin.Context) {
|
|||||||
if roleCtx != "admin" && roleCtx != "cabine" {
|
if roleCtx != "admin" && roleCtx != "cabine" {
|
||||||
products = filterActivePrices(products)
|
products = filterActivePrices(products)
|
||||||
}
|
}
|
||||||
|
products = applyPromotions(products, database)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": products,
|
"data": products,
|
||||||
@@ -533,15 +478,14 @@ func GetProductByID(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// ✅ Charger les médias
|
|
||||||
media, _ := database.GetMediaByProductID(product.ID)
|
media, _ := database.GetMediaByProductID(product.ID)
|
||||||
product.Media = media
|
product.Media = media
|
||||||
|
|
||||||
// ✅ Filtrer les prix désactivés (sauf pour admin/cabine)
|
|
||||||
role := c.GetString("role")
|
role := c.GetString("role")
|
||||||
if role != "admin" && role != "cabine" {
|
if role != "admin" && role != "cabine" {
|
||||||
filterActivepricesSingle(&product)
|
filterActivepricesSingle(&product)
|
||||||
}
|
}
|
||||||
|
applyPromotionsSingle(&product, database)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -552,7 +496,6 @@ func GetProductByID(c *gin.Context) {
|
|||||||
func UpdateProduct(c *gin.Context) {
|
func UpdateProduct(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
// ✅ VÉRIFIER LE RÔLE
|
|
||||||
role := c.GetString("role")
|
role := c.GetString("role")
|
||||||
if role != "admin" && role != "cabine" {
|
if role != "admin" && role != "cabine" {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||||
@@ -567,7 +510,6 @@ func UpdateProduct(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
|
|
||||||
_, err = database.GetProductByID(id)
|
_, err = database.GetProductByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
||||||
@@ -589,7 +531,6 @@ func UpdateProduct(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ VALIDATION COMPLÈTE
|
|
||||||
if err := validateProductName(updateData.Name); err != nil {
|
if err := validateProductName(updateData.Name); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -733,8 +674,8 @@ func UpdateStock(c *gin.Context) {
|
|||||||
|
|
||||||
func DeleteMedia(c *gin.Context) {
|
func DeleteMedia(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
s3Service := c.MustGet("s3Service").(*services.S3Service)
|
||||||
|
|
||||||
// ✅ VÉRIFIER LE RÔLE
|
|
||||||
role := c.GetString("role")
|
role := c.GetString("role")
|
||||||
if role != "admin" && role != "cabine" {
|
if role != "admin" && role != "cabine" {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||||
@@ -753,28 +694,27 @@ func DeleteMedia(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ SÉCURISER LE CHEMIN AVANT SUPPRESSION
|
if err := database.DeleteMedia(mediaID); err != nil {
|
||||||
filePath := strings.TrimPrefix(media.URL, "/")
|
log.Printf("❌ [DeleteMedia] Erreur suppression DB: %v", err)
|
||||||
|
|
||||||
safeFilePath, err := sanitizeFilePath(filePath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ [DeleteMedia] Path invalide: %v", err)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ SUPPRIMER LE FICHIER PHYSIQUE
|
|
||||||
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
|
|
||||||
log.Printf("⚠️ [DeleteMedia] Erreur suppression fichier: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ SUPPRIMER DE LA DB
|
|
||||||
err = database.DeleteMedia(mediaID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if media.Key != "" {
|
||||||
|
if err := s3Service.DeleteFile(media.Key); err != nil {
|
||||||
|
log.Printf("⚠️ [DeleteMedia] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("✅ [DeleteMedia] Fichier supprimé sur RustFS: %s", media.Key)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localStorage := services.NewLocalStorage("uploads")
|
||||||
|
if err := localStorage.Delete(media.URL, ""); err != nil {
|
||||||
|
log.Printf("⚠️ [DeleteMedia] Fichier local non supprimé (%s): %v", media.URL, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("✅ [DeleteMedia] Fichier local supprimé: %s", media.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Média supprimé",
|
"message": "Média supprimé",
|
||||||
@@ -784,7 +724,6 @@ func DeleteMedia(c *gin.Context) {
|
|||||||
func UploadMedia(c *gin.Context) {
|
func UploadMedia(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
// ✅ VÉRIFIER LE RÔLE
|
|
||||||
username, err := safeGetUsername(c)
|
username, err := safeGetUsername(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||||
@@ -792,19 +731,17 @@ func UploadMedia(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
role := c.GetString("role")
|
role := c.GetString("role")
|
||||||
if role != "admin" && role != "cabine" {
|
if role != "admin" {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ RÉCUPÉRER ET VALIDER L'ID PRODUIT
|
|
||||||
productID, err := strconv.Atoi(c.Param("id"))
|
productID, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil || productID <= 0 {
|
if err != nil || productID <= 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
|
|
||||||
productName, err := database.GetProductNameByID(productID)
|
productName, err := database.GetProductNameByID(productID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
||||||
@@ -813,7 +750,6 @@ func UploadMedia(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("📤 [UploadMedia] %s upload média pour produit #%d (%s)", username, productID, productName)
|
log.Printf("📤 [UploadMedia] %s upload média pour produit #%d (%s)", username, productID, productName)
|
||||||
|
|
||||||
// ✅ RÉCUPÉRER LE TYPE ET LE FICHIER
|
|
||||||
fileType := c.PostForm("type")
|
fileType := c.PostForm("type")
|
||||||
if fileType != "image" && fileType != "video" {
|
if fileType != "image" && fileType != "video" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"})
|
||||||
@@ -827,7 +763,6 @@ func UploadMedia(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ VÉRIFIER LA TAILLE
|
|
||||||
const MaxFileSize = 10 * 1024 * 1024 // 10MB
|
const MaxFileSize = 10 * 1024 * 1024 // 10MB
|
||||||
if file.Size > MaxFileSize {
|
if file.Size > MaxFileSize {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
@@ -836,7 +771,6 @@ func UploadMedia(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ VÉRIFIER LE TYPE MIME RÉEL
|
|
||||||
detectedMime, err := validateFileMimeType(file)
|
detectedMime, err := validateFileMimeType(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [UploadMedia] Type MIME invalide: %v", err)
|
log.Printf("❌ [UploadMedia] Type MIME invalide: %v", err)
|
||||||
@@ -846,7 +780,6 @@ func UploadMedia(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("📋 [UploadMedia] Type MIME détecté: %s", detectedMime)
|
log.Printf("📋 [UploadMedia] Type MIME détecté: %s", detectedMime)
|
||||||
|
|
||||||
// Vérifier que le MIME correspond au type déclaré
|
|
||||||
if fileType == "image" && !strings.HasPrefix(detectedMime, "image/") {
|
if fileType == "image" && !strings.HasPrefix(detectedMime, "image/") {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"})
|
||||||
return
|
return
|
||||||
@@ -856,40 +789,32 @@ func UploadMedia(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ GÉNÉRER UN NOM UNIQUE
|
|
||||||
cleanProductName := cleanFileName(productName)
|
cleanProductName := cleanFileName(productName)
|
||||||
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename)
|
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename)
|
||||||
|
|
||||||
// ✅ CRÉER LE DOSSIER
|
storage := c.MustGet("storage").(services.Storage)
|
||||||
destFolder := filepath.Join("uploads", fileType+"s")
|
folder := fileType + "s"
|
||||||
if err := os.MkdirAll(destFolder, 0750); err != nil {
|
mediaURL, mediaKey, err := storage.Upload(file, folder, uniqueFileName)
|
||||||
log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err)
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"})
|
log.Printf("❌ [UploadMedia] Erreur upload: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur upload fichier"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ SAUVEGARDER LE FICHIER
|
log.Printf("✅ [UploadMedia] Fichier uploadé: %s", mediaURL)
|
||||||
filePath := filepath.Join(destFolder, uniqueFileName)
|
|
||||||
if err := c.SaveUploadedFile(file, filePath); err != nil {
|
|
||||||
log.Printf("❌ [UploadMedia] Erreur sauvegarde: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("✅ [UploadMedia] Fichier sauvegardé: %s", filePath)
|
|
||||||
|
|
||||||
// ✅ CRÉER L'ENTRÉE EN BASE
|
|
||||||
mediaURL := "/" + filepath.ToSlash(filePath)
|
|
||||||
media := models.Media{
|
media := models.Media{
|
||||||
ProductID: productID,
|
ProductID: productID,
|
||||||
Type: fileType,
|
Type: fileType,
|
||||||
URL: mediaURL,
|
URL: mediaURL,
|
||||||
|
Key: mediaKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.CreateMedia(&media)
|
err = database.CreateMedia(&media)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Rollback: supprimer le fichier
|
if delErr := storage.Delete(mediaURL, mediaKey); delErr != nil {
|
||||||
os.Remove(filePath)
|
log.Printf("⚠️ [UploadMedia] Échec rollback (%s): %v", mediaURL, delErr)
|
||||||
|
}
|
||||||
log.Printf("❌ [UploadMedia] Erreur DB: %v", err)
|
log.Printf("❌ [UploadMedia] Erreur DB: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
|
||||||
return
|
return
|
||||||
@@ -908,6 +833,28 @@ func UploadMedia(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ServeMedia(c *gin.Context) {
|
||||||
|
s3Service := c.MustGet("s3Service").(*services.S3Service)
|
||||||
|
|
||||||
|
key := strings.TrimPrefix(c.Param("key"), "/")
|
||||||
|
if key == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Clé manquante"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, contentType, err := s3Service.GetFile(c.Request.Context(), key)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Média non trouvé"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer body.Close()
|
||||||
|
|
||||||
|
c.Header("Content-Type", contentType)
|
||||||
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
io.Copy(c.Writer, body)
|
||||||
|
}
|
||||||
|
|
||||||
func ActivePrice(c *gin.Context) {
|
func ActivePrice(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
role := c.GetString("role")
|
role := c.GetString("role")
|
||||||
@@ -952,10 +899,6 @@ func DesActivePrice(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "Prix désactivé avec succès"})
|
c.JSON(http.StatusOK, gin.H{"message": "Prix désactivé avec succès"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// DELETE PRODUCT - VERSION SÉCURISÉE
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
func DeleteProduct(c *gin.Context) {
|
func DeleteProduct(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -976,32 +919,28 @@ func DeleteProduct(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id)
|
log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id)
|
||||||
|
|
||||||
// ✅ RÉCUPÉRER LES MÉDIAS AVANT SUPPRESSION
|
|
||||||
mediaList, err := database.GetMediaByProductID(id)
|
mediaList, err := database.GetMediaByProductID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ SUPPRIMER LES FICHIERS AVEC SÉCURITÉ
|
s3Service := c.MustGet("s3Service").(*services.S3Service)
|
||||||
|
localStorage := services.NewLocalStorage("uploads")
|
||||||
for _, media := range mediaList {
|
for _, media := range mediaList {
|
||||||
filePath := strings.TrimPrefix(media.URL, "/")
|
if media.Key != "" {
|
||||||
|
if err := s3Service.DeleteFile(media.Key); err != nil {
|
||||||
safeFilePath, err := sanitizeFilePath(filePath)
|
log.Printf("⚠️ [DeleteProduct] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
|
||||||
if err != nil {
|
}
|
||||||
log.Printf("⚠️ [DeleteProduct] Path invalide: %v", err)
|
} else {
|
||||||
continue
|
if err := localStorage.Delete(media.URL, ""); err != nil {
|
||||||
|
log.Printf("⚠️ [DeleteProduct] Erreur suppression locale: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
|
|
||||||
log.Printf("⚠️ [DeleteProduct] Erreur suppression: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ SUPPRIMER LES MÉDIAS DE LA DB
|
|
||||||
database.DeleteMediaByProductID(id)
|
database.DeleteMediaByProductID(id)
|
||||||
|
|
||||||
// ✅ SUPPRIMER LE PRODUIT
|
|
||||||
err = database.DeleteProduct(id)
|
err = database.DeleteProduct(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression produit"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression produit"})
|
||||||
@@ -1016,17 +955,11 @@ func DeleteProduct(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
func rollbackFiles(storage services.Storage, files []models.Media) {
|
||||||
// HELPERS
|
for _, f := range files {
|
||||||
// ============================================
|
if err := storage.Delete(f.URL, f.Key); err != nil {
|
||||||
|
log.Printf("⚠️ [rollbackFiles] Erreur suppression %s: %v", f.URL, err)
|
||||||
func rollbackFiles(files []string) {
|
|
||||||
for _, file := range files {
|
|
||||||
safeFilePath, err := sanitizeFilePath(file)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
os.Remove(safeFilePath)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,3 +1006,32 @@ func filterActivepricesSingle(product *models.Product) {
|
|||||||
}
|
}
|
||||||
product.Prices = activePrices
|
product.Prices = activePrices
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyPromotions annote chaque palier de prix éligible avec le prix promo
|
||||||
|
// (PromoPrice/PromoPercent) si une promotion couvre ce produit/quantité —
|
||||||
|
// affichage seulement, le prix catalogue (Price) n'est jamais modifié ici ;
|
||||||
|
// le prix réellement facturé est recalculé indépendamment dans AddToBasket.
|
||||||
|
func applyPromotions(products []models.Product, database *db.Database) []models.Product {
|
||||||
|
settings, err := database.GetSettings()
|
||||||
|
if err != nil || !settings.PromotionsEnabled {
|
||||||
|
return products
|
||||||
|
}
|
||||||
|
for i := range products {
|
||||||
|
for j := range products[i].Prices {
|
||||||
|
pr := &products[i].Prices[j]
|
||||||
|
discount, ok := db.ResolvePromotionDiscount(&settings, products[i].ID, products[i].Category, pr.Quantity)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
promoPrice := math.Round(pr.Price*(1-discount/100)*100) / 100
|
||||||
|
pr.PromoPrice = &promoPrice
|
||||||
|
pr.PromoPercent = discount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return products
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPromotionsSingle(product *models.Product, database *db.Database) {
|
||||||
|
products := applyPromotions([]models.Product{*product}, database)
|
||||||
|
*product = products[0]
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gestion/db"
|
||||||
|
"gestion/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SubmitLivreurRating(c *gin.Context) {
|
||||||
|
clientUsername := c.GetString("username")
|
||||||
|
if clientUsername == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orderID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || orderID <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID commande invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Rating int `json:"rating" binding:"required,min=1,max=5"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Note invalide (1 à 5 requis)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
|
ownerUsername, livreurUsername, err := database.GetOrderForRating(orderID)
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerErr(c, "Erreur lecture commande", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ownerUsername == "" {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable ou non terminée"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ownerUsername != clientUsername {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Commande non autorisée"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if livreurUsername == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun livreur assigné à cette commande"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := database.GetOrderRating(orderID)
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerErr(c, "Erreur vérification avis", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Vous avez déjà noté ce livreur pour cette commande"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.SubmitLivreurRating(orderID, livreurUsername, clientUsername, req.Rating, req.Comment); err != nil {
|
||||||
|
utils.ServerErr(c, "Erreur enregistrement avis", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLivreurRatings(c *gin.Context) {
|
||||||
|
username := c.Param("username")
|
||||||
|
if username == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
ratings, avg, err := database.GetLivreurRatings(username)
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerErr(c, "Erreur récupération avis", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ratings": ratings,
|
||||||
|
"average": avg,
|
||||||
|
"count": len(ratings),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMyRatings retourne les avis reçus par le livreur connecté (uniquement les siens).
|
||||||
|
func GetMyRatings(c *gin.Context) {
|
||||||
|
username := c.GetString("username")
|
||||||
|
if username == "" || c.GetString("role") != "livreur" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
ratings, avg, err := database.GetLivreurRatings(username)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération avis"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ratings": ratings,
|
||||||
|
"average": avg,
|
||||||
|
"count": len(ratings),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOrderRatingStatus(c *gin.Context) {
|
||||||
|
clientUsername := c.GetString("username")
|
||||||
|
if clientUsername == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orderID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || orderID <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
rating, err := database.GetOrderRating(orderID)
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerErr(c, "Erreur", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rating == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"rated": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"rated": true, "rating": rating.Rating, "comment": rating.Comment})
|
||||||
|
}
|
||||||
@@ -307,19 +307,22 @@ func GetDeliverymanLocationForCommand(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ✅ 6. Récupérer l'ETA de la commande depuis Redis (si disponible)
|
// ✅ 6. Récupérer l'ETA de la commande depuis Redis (si disponible)
|
||||||
|
// La clé est un hash (HSet), jamais une simple valeur — Redis.Get renvoie
|
||||||
|
// une erreur WRONGTYPE dessus, silencieusement ignorée ici auparavant,
|
||||||
|
// ce qui faisait toujours renvoyer etaMinutes=0.
|
||||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||||
etaData, _ := db.Redis.Get(db.RedisCtx, etaKey).Result()
|
eta, _ := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
|
||||||
|
|
||||||
var etaMinutes int = 0
|
var etaMinutes int = 0
|
||||||
var etaSetAt int64 = 0
|
var etaSetAt int64 = 0
|
||||||
if etaData != "" {
|
if minutesStr, ok := eta["eta_minutes"]; ok {
|
||||||
var eta map[string]interface{}
|
if minutes, err := strconv.Atoi(minutesStr); err == nil {
|
||||||
json.Unmarshal([]byte(etaData), &eta)
|
etaMinutes = minutes
|
||||||
if minutes, ok := eta["minutes"].(float64); ok {
|
|
||||||
etaMinutes = int(minutes)
|
|
||||||
}
|
}
|
||||||
if timestamp, ok := eta["set_at"].(float64); ok {
|
}
|
||||||
etaSetAt = int64(timestamp)
|
if updatedAtStr, ok := eta["updated_at"]; ok {
|
||||||
|
if timestamp, err := strconv.ParseInt(updatedAtStr, 10, 64); err == nil {
|
||||||
|
etaSetAt = timestamp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -947,36 +950,6 @@ func SubtractClientPointsAdmin(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRealtimeStats(c *gin.Context) {
|
|
||||||
userRole := c.GetString("role")
|
|
||||||
if userRole != "admin" {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := db.Redis.HGetAll(db.RedisCtx, "stats:realtime").Result()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "Erreur lors de la récupération des statistiques",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(stats) == 0 {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": true,
|
|
||||||
"message": "Aucune statistique disponible pour le moment",
|
|
||||||
"stats": map[string]interface{}{},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": true,
|
|
||||||
"stats": stats,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshETAForActivDelivery(username string, lat, lon float64) {
|
func refreshETAForActivDelivery(username string, lat, lon float64) {
|
||||||
// 1. Récupérer le statut actuel du livreur
|
// 1. Récupérer le statut actuel du livreur
|
||||||
statusKey := fmt.Sprintf("delivery:status:%s", username)
|
statusKey := fmt.Sprintf("delivery:status:%s", username)
|
||||||
@@ -1048,9 +1021,14 @@ func refreshETAForActivDelivery(username string, lat, lon float64) {
|
|||||||
|
|
||||||
db.Redis.HSet(db.RedisCtx, etaKey, map[string]interface{}{
|
db.Redis.HSet(db.RedisCtx, etaKey, map[string]interface{}{
|
||||||
"command_id": commandID,
|
"command_id": commandID,
|
||||||
|
// eta_minutes ET total_eta_minutes doivent tous les deux être présents
|
||||||
|
// (voir le commentaire de SetCommandETAWithDetails) — sans quoi les
|
||||||
|
// lecteurs qui attendent l'un ou l'autre nom de champ ne trouvent rien.
|
||||||
"eta_minutes": etaMinutes,
|
"eta_minutes": etaMinutes,
|
||||||
|
"total_eta_minutes": etaMinutes,
|
||||||
"updated_at": now.Unix(),
|
"updated_at": now.Unix(),
|
||||||
"arrival_time": arrivalTime.Unix(),
|
"arrival_time": arrivalTime.Unix(),
|
||||||
|
"estimated_arrival": arrivalTime.Format(time.RFC3339),
|
||||||
"distance_km": distanceKm,
|
"distance_km": distanceKm,
|
||||||
"with_traffic": err == nil,
|
"with_traffic": err == nil,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ func GetPublicSettings(c *gin.Context) {
|
|||||||
"shop_name": settings.ShopName,
|
"shop_name": settings.ShopName,
|
||||||
"two_fa_enabled": settings.Telegram2FAEnabled,
|
"two_fa_enabled": settings.Telegram2FAEnabled,
|
||||||
"contact_telegram": settings.ContactTelegram,
|
"contact_telegram": settings.ContactTelegram,
|
||||||
|
"client_color_primary": settings.ClientColorPrimary,
|
||||||
|
"client_color_secondary": settings.ClientColorSecondary,
|
||||||
|
"client_color_success": settings.ClientColorSuccess,
|
||||||
|
"client_color_danger": settings.ClientColorDanger,
|
||||||
|
"client_color_warning": settings.ClientColorWarning,
|
||||||
|
"client_title_gradient_from": settings.ClientTitleGradientFrom,
|
||||||
|
"client_title_gradient_to": settings.ClientTitleGradientTo,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+327
-114
@@ -1,38 +1,282 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gestion/db"
|
"gestion/db"
|
||||||
"gestion/models"
|
"gestion/models"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
)
|
)
|
||||||
|
|
||||||
var weekdayNames = []string{"Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"}
|
var weekdayNames = []string{"Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"}
|
||||||
|
|
||||||
|
// sections valides pour le reset
|
||||||
|
var validStatsSections = map[string]string{
|
||||||
|
"commandes": "stats_reset_commandes_at",
|
||||||
|
"revenus": "stats_reset_revenus_at",
|
||||||
|
"produits": "stats_reset_produits_at",
|
||||||
|
"heures": "stats_reset_heures_at",
|
||||||
|
"jours": "stats_reset_jours_at",
|
||||||
|
"doses": "stats_reset_doses_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetAdminStats réinitialise une section précise des statistiques.
|
||||||
|
func ResetAdminStats(c *gin.Context) {
|
||||||
|
section := c.Param("section")
|
||||||
|
key, ok := validStatsSections[section]
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("section invalide : %s", section)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
if err := database.ResetAdminStat(key); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Erreur lors de la suppresion de la section statistique: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "section": section, "reset_at": now})
|
||||||
|
}
|
||||||
|
|
||||||
|
func dateFilter(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminStatsByMonth renvoie, pour chaque jour du mois demandé (paramètre
|
||||||
|
// de query "month" au format YYYY-MM, mois courant par défaut), le nombre de
|
||||||
|
// commandes, le revenu et la quantité vendue. Les jours sans commande sont
|
||||||
|
// inclus avec des valeurs à zéro.
|
||||||
|
func GetAdminStatsByMonth(c *gin.Context) {
|
||||||
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
|
monthParam := c.Query("month")
|
||||||
|
monthStart := time.Now()
|
||||||
|
if monthParam != "" {
|
||||||
|
parsed, err := time.Parse("2006-01", monthParam)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre month invalide (attendu YYYY-MM) : %s", monthParam)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
monthStart = parsed
|
||||||
|
}
|
||||||
|
monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
|
||||||
|
|
||||||
|
filters := database.LoadAdminStatsFilters()
|
||||||
|
|
||||||
|
var rows []db.DailyMonthStatRow
|
||||||
|
if err := database.StatsByDayForMonth(&rows, monthStart, filters.ResetCommandes, filters.ResetRevenus); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération des statistiques mensuelles: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rowByDay := make(map[string]db.DailyMonthStatRow, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
rowByDay[r.Day.Format("2006-01-02")] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
daysInMonth := monthStart.AddDate(0, 1, -1).Day()
|
||||||
|
byDay := make([]gin.H, daysInMonth)
|
||||||
|
var totalOrders int
|
||||||
|
var totalRevenue float64
|
||||||
|
var totalQuantity float64
|
||||||
|
|
||||||
|
for i := range daysInMonth {
|
||||||
|
day := monthStart.AddDate(0, 0, i)
|
||||||
|
key := day.Format("2006-01-02")
|
||||||
|
r, ok := rowByDay[key]
|
||||||
|
if !ok {
|
||||||
|
r = db.DailyMonthStatRow{Day: day}
|
||||||
|
}
|
||||||
|
byDay[i] = gin.H{
|
||||||
|
"day": key,
|
||||||
|
"label": day.Format("02/01"),
|
||||||
|
"count": r.Count,
|
||||||
|
"revenue": r.Revenue,
|
||||||
|
"quantity": r.Quantity,
|
||||||
|
}
|
||||||
|
totalOrders += r.Count
|
||||||
|
totalRevenue += r.Revenue
|
||||||
|
totalQuantity += r.Quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"month": monthStart.Format("2006-01"),
|
||||||
|
"summary": gin.H{
|
||||||
|
"total_orders": totalOrders,
|
||||||
|
"total_revenue": totalRevenue,
|
||||||
|
"total_quantity": totalQuantity,
|
||||||
|
},
|
||||||
|
"by_day": byDay,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAdminDailyDetail(c *gin.Context) {
|
||||||
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
|
dateParam := c.Query("date")
|
||||||
|
date := time.Now()
|
||||||
|
if dateParam != "" {
|
||||||
|
parsed, err := time.Parse("2006-01-02", dateParam)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre date invalide (attendu YYYY-MM-DD) : %s", dateParam)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
date = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
var dailyRows []models.DailyProductRow
|
||||||
|
if err := database.DailyProductDetailForDate(&dailyRows, date); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération du détail du jour: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type dailyCatGroup struct {
|
||||||
|
Category string
|
||||||
|
CategoryColor string
|
||||||
|
TotalQuantity float64
|
||||||
|
TotalRevenue float64
|
||||||
|
Products []gin.H
|
||||||
|
}
|
||||||
|
var dailyCats []dailyCatGroup
|
||||||
|
dailyCatIdx := map[string]int{}
|
||||||
|
dailyTotalRevenue := 0.0
|
||||||
|
dailyTotalQty := 0.0
|
||||||
|
|
||||||
|
for _, r := range dailyRows {
|
||||||
|
dailyTotalRevenue += r.Revenue
|
||||||
|
dailyTotalQty += r.TotalQuantity
|
||||||
|
idx, ok := dailyCatIdx[r.Category]
|
||||||
|
if !ok {
|
||||||
|
idx = len(dailyCats)
|
||||||
|
dailyCats = append(dailyCats, dailyCatGroup{
|
||||||
|
Category: r.Category,
|
||||||
|
CategoryColor: r.CategoryColor,
|
||||||
|
})
|
||||||
|
dailyCatIdx[r.Category] = idx
|
||||||
|
}
|
||||||
|
dailyCats[idx].TotalQuantity += r.TotalQuantity
|
||||||
|
dailyCats[idx].TotalRevenue += r.Revenue
|
||||||
|
dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{
|
||||||
|
"product_id": r.ProductID,
|
||||||
|
"name": r.ProductName,
|
||||||
|
"quantity": r.TotalQuantity,
|
||||||
|
"order_count": r.OrderCount,
|
||||||
|
"revenue": r.Revenue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyCatsJSON := make([]gin.H, len(dailyCats))
|
||||||
|
for i, g := range dailyCats {
|
||||||
|
dailyCatsJSON[i] = gin.H{
|
||||||
|
"category": g.Category,
|
||||||
|
"category_color": g.CategoryColor,
|
||||||
|
"total_quantity": g.TotalQuantity,
|
||||||
|
"total_revenue": g.TotalRevenue,
|
||||||
|
"products": g.Products,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyTotalOrders, _ := database.DailyOrdersCountForDate(date)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"date": date.Format("02/01/2006"),
|
||||||
|
"total_orders": dailyTotalOrders,
|
||||||
|
"total_quantity": dailyTotalQty,
|
||||||
|
"total_revenue": dailyTotalRevenue,
|
||||||
|
"categories": dailyCatsJSON,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetAdminStats returns aggregated order & product statistics for the admin dashboard.
|
// GetAdminStats returns aggregated order & product statistics for the admin dashboard.
|
||||||
func GetAdminStats(c *gin.Context) {
|
func GetAdminStats(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
gdb := database.GDB
|
|
||||||
|
|
||||||
// ── Commandes par jour de la semaine (all time, non annulées) ──────────────
|
filters := database.LoadAdminStatsFilters()
|
||||||
var wdRows []models.WeekdayRow
|
|
||||||
gdb.Raw(`
|
|
||||||
SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count
|
|
||||||
FROM commandes
|
|
||||||
WHERE status != 'cancelled'
|
|
||||||
GROUP BY dow
|
|
||||||
ORDER BY dow
|
|
||||||
`).Scan(&wdRows)
|
|
||||||
|
|
||||||
|
// Toutes les requêtes sont indépendantes — on les lance en parallèle.
|
||||||
|
var (
|
||||||
|
wdRows []models.WeekdayRow
|
||||||
|
dayRows []models.DayRow
|
||||||
|
dayRevRows []models.DayRevenueRow
|
||||||
|
hourRows []models.HourRow
|
||||||
|
prodRows []models.ProductRow
|
||||||
|
qtyRows []models.QuantityBreakdownRow
|
||||||
|
dailyRows []models.DailyProductRow
|
||||||
|
totalOrders int64
|
||||||
|
totalRevenue float64
|
||||||
|
totalPromoDiscount float64
|
||||||
|
promoOrdersCount int64
|
||||||
|
dailyTotalOrders int64
|
||||||
|
activeDays int64
|
||||||
|
last30Count int64
|
||||||
|
)
|
||||||
|
|
||||||
|
eg, _ := errgroup.WithContext(context.Background())
|
||||||
|
eg.Go(func() error { return database.OrderPerDaysPerWeeks(&wdRows, filters.ResetJours) })
|
||||||
|
eg.Go(func() error { return database.OrdersByDayLast30(&dayRows, filters.ResetCommandes) })
|
||||||
|
eg.Go(func() error { return database.RevenueByDayLast30(&dayRevRows, filters.ResetRevenus) })
|
||||||
|
eg.Go(func() error { return database.OrdersAndRevenueByHour(&hourRows, filters.ResetHeures) })
|
||||||
|
eg.Go(func() error { return database.TopProducts(&prodRows, filters.ResetProduits, 15) })
|
||||||
|
eg.Go(func() error { return database.QuantityBreakdown(&qtyRows, filters.ResetDoses) })
|
||||||
|
eg.Go(func() error { return database.DailyProductDetail(&dailyRows) })
|
||||||
|
eg.Go(func() error {
|
||||||
|
var err error
|
||||||
|
totalOrders, err = database.TotalOrders(filters.ResetCommandes)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
var err error
|
||||||
|
totalRevenue, err = database.TotalRevenue(filters.ResetRevenus)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
var err error
|
||||||
|
totalPromoDiscount, err = database.TotalPromoDiscount(filters.ResetRevenus)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
var err error
|
||||||
|
promoOrdersCount, err = database.PromoOrdersCount(filters.ResetRevenus)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
var err error
|
||||||
|
dailyTotalOrders, err = database.DailyOrdersCount()
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
var err error
|
||||||
|
activeDays, err = database.ActiveDaysLast30(filters.ResetCommandes)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
var err error
|
||||||
|
last30Count, err = database.OrdersCountLast30(filters.ResetCommandes)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := eg.Wait(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des statistiques"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commandes par jour de la semaine ──────────────────────────────────────
|
||||||
byWeekday := make([]gin.H, 7)
|
byWeekday := make([]gin.H, 7)
|
||||||
wdMap := make(map[int]int, len(wdRows))
|
wdMap := make(map[int]int, len(wdRows))
|
||||||
for _, r := range wdRows {
|
for _, r := range wdRows {
|
||||||
wdMap[r.DOW] = r.Count
|
wdMap[r.DOW] = r.Count
|
||||||
}
|
}
|
||||||
peakCount, peakWeekday := 0, ""
|
peakCount, peakWeekday := 0, ""
|
||||||
for i := 0; i < 7; i++ {
|
for i := range 7 {
|
||||||
cnt := wdMap[i]
|
cnt := wdMap[i]
|
||||||
byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt}
|
byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt}
|
||||||
if cnt > peakCount {
|
if cnt > peakCount {
|
||||||
@@ -42,16 +286,6 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Commandes par jour sur 30 jours ───────────────────────────────────────
|
// ── Commandes par jour sur 30 jours ───────────────────────────────────────
|
||||||
var dayRows []models.DayRow
|
|
||||||
gdb.Raw(`
|
|
||||||
SELECT DATE(created_at) AS day, COUNT(*) AS count
|
|
||||||
FROM commandes
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
|
||||||
AND status != 'cancelled'
|
|
||||||
GROUP BY DATE(created_at)
|
|
||||||
ORDER BY day
|
|
||||||
`).Scan(&dayRows)
|
|
||||||
|
|
||||||
byDay := make([]gin.H, len(dayRows))
|
byDay := make([]gin.H, len(dayRows))
|
||||||
for i, r := range dayRows {
|
for i, r := range dayRows {
|
||||||
byDay[i] = gin.H{
|
byDay[i] = gin.H{
|
||||||
@@ -61,17 +295,7 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Revenus par jour sur 30 jours (commandes approuvées) ─────────────────
|
// ── Revenus par jour sur 30 jours ─────────────────────────────────────────
|
||||||
var dayRevRows []models.DayRevenueRow
|
|
||||||
gdb.Raw(`
|
|
||||||
SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
|
||||||
FROM commandes
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
|
||||||
AND status = 'approved'
|
|
||||||
GROUP BY DATE(created_at)
|
|
||||||
ORDER BY day
|
|
||||||
`).Scan(&dayRevRows)
|
|
||||||
|
|
||||||
byDayRevenue := make([]gin.H, len(dayRevRows))
|
byDayRevenue := make([]gin.H, len(dayRevRows))
|
||||||
for i, r := range dayRevRows {
|
for i, r := range dayRevRows {
|
||||||
byDayRevenue[i] = gin.H{
|
byDayRevenue[i] = gin.H{
|
||||||
@@ -81,25 +305,13 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Commandes & revenus par heure (all time, non annulées) ───────────────
|
// ── Commandes & revenus par heure ─────────────────────────────────────────
|
||||||
var hourRows []models.HourRow
|
|
||||||
gdb.Raw(`
|
|
||||||
SELECT
|
|
||||||
EXTRACT(HOUR FROM created_at)::int AS hour,
|
|
||||||
COUNT(*) AS count,
|
|
||||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
|
||||||
FROM commandes
|
|
||||||
WHERE status != 'cancelled'
|
|
||||||
GROUP BY hour
|
|
||||||
ORDER BY hour
|
|
||||||
`).Scan(&hourRows)
|
|
||||||
|
|
||||||
hourMap := make(map[int]models.HourRow, len(hourRows))
|
hourMap := make(map[int]models.HourRow, len(hourRows))
|
||||||
for _, r := range hourRows {
|
for _, r := range hourRows {
|
||||||
hourMap[r.Hour] = r
|
hourMap[r.Hour] = r
|
||||||
}
|
}
|
||||||
byHour := make([]gin.H, 24)
|
byHour := make([]gin.H, 24)
|
||||||
for h := 0; h < 24; h++ {
|
for h := range 24 {
|
||||||
r := hourMap[h]
|
r := hourMap[h]
|
||||||
byHour[h] = gin.H{
|
byHour[h] = gin.H{
|
||||||
"hour": h,
|
"hour": h,
|
||||||
@@ -109,27 +321,7 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Top produits (quantité vendue, commandes terminées) ───────────────────
|
// ── Top produits ──────────────────────────────────────────────────────────
|
||||||
var prodRows []models.ProductRow
|
|
||||||
gdb.Raw(`
|
|
||||||
SELECT
|
|
||||||
ci.product_id,
|
|
||||||
ci.produit AS name,
|
|
||||||
SUM(ci.quantite) AS total_quantity,
|
|
||||||
COUNT(DISTINCT ci.command_id) AS order_count,
|
|
||||||
SUM(ci.prix) AS revenue,
|
|
||||||
COALESCE(p.category, '') AS category,
|
|
||||||
COALESCE(cat.color, '#7c3aed') AS category_color
|
|
||||||
FROM command_items ci
|
|
||||||
JOIN commandes c ON c.id = ci.command_id
|
|
||||||
LEFT JOIN products p ON p.id = ci.product_id
|
|
||||||
LEFT JOIN categories cat ON cat.name = p.category
|
|
||||||
WHERE c.status != 'cancelled'
|
|
||||||
GROUP BY ci.product_id, ci.produit, p.category, cat.color
|
|
||||||
ORDER BY total_quantity DESC
|
|
||||||
LIMIT 15
|
|
||||||
`).Scan(&prodRows)
|
|
||||||
|
|
||||||
topProducts := make([]gin.H, len(prodRows))
|
topProducts := make([]gin.H, len(prodRows))
|
||||||
topProductName := ""
|
topProductName := ""
|
||||||
for i, r := range prodRows {
|
for i, r := range prodRows {
|
||||||
@@ -147,26 +339,7 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Répartition des doses/quantités par produit ───────────────────────────
|
// ── Répartition des doses/quantités ───────────────────────────────────────
|
||||||
var qtyRows []models.QuantityBreakdownRow
|
|
||||||
gdb.Raw(`
|
|
||||||
SELECT
|
|
||||||
ci.product_id,
|
|
||||||
ci.produit AS product_name,
|
|
||||||
ci.quantite AS quantity,
|
|
||||||
COUNT(DISTINCT ci.command_id) AS order_count,
|
|
||||||
SUM(ci.quantite) AS total_sold,
|
|
||||||
SUM(ci.prix) AS revenue,
|
|
||||||
COALESCE(cat.color, '#7c3aed') AS category_color
|
|
||||||
FROM command_items ci
|
|
||||||
JOIN commandes c ON c.id = ci.command_id
|
|
||||||
LEFT JOIN products p ON p.id = ci.product_id
|
|
||||||
LEFT JOIN categories cat ON cat.name = p.category
|
|
||||||
WHERE c.status != 'cancelled'
|
|
||||||
GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color
|
|
||||||
ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC
|
|
||||||
`).Scan(&qtyRows)
|
|
||||||
|
|
||||||
type productGroup struct {
|
type productGroup struct {
|
||||||
ProductID int
|
ProductID int
|
||||||
Name string
|
Name string
|
||||||
@@ -195,7 +368,6 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
"revenue": r.Revenue,
|
"revenue": r.Revenue,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Trier par total de commandes décroissant, garder 15 max
|
|
||||||
for i := 0; i < len(groups)-1; i++ {
|
for i := 0; i < len(groups)-1; i++ {
|
||||||
for j := i + 1; j < len(groups); j++ {
|
for j := i + 1; j < len(groups); j++ {
|
||||||
if groups[j].TotalOrders > groups[i].TotalOrders {
|
if groups[j].TotalOrders > groups[i].TotalOrders {
|
||||||
@@ -207,54 +379,95 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
groups = groups[:15]
|
groups = groups[:15]
|
||||||
}
|
}
|
||||||
byQuantity := make([]gin.H, len(groups))
|
byQuantity := make([]gin.H, len(groups))
|
||||||
for i, g := range groups {
|
for i, grp := range groups {
|
||||||
byQuantity[i] = gin.H{
|
byQuantity[i] = gin.H{
|
||||||
"product_id": g.ProductID,
|
"product_id": grp.ProductID,
|
||||||
"name": g.Name,
|
"name": grp.Name,
|
||||||
"category_color": g.CategoryColor,
|
"category_color": grp.CategoryColor,
|
||||||
"total_orders": g.TotalOrders,
|
"total_orders": grp.TotalOrders,
|
||||||
"quantities": g.Quantities,
|
"quantities": grp.Quantities,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Détail du jour ────────────────────────────────────────────────────────
|
||||||
|
type dailyCatGroup struct {
|
||||||
|
Category string
|
||||||
|
CategoryColor string
|
||||||
|
TotalQuantity float64
|
||||||
|
TotalRevenue float64
|
||||||
|
Products []gin.H
|
||||||
|
}
|
||||||
|
var dailyCats []dailyCatGroup
|
||||||
|
dailyCatIdx := map[string]int{}
|
||||||
|
dailyTotalRevenue := 0.0
|
||||||
|
dailyTotalQty := 0.0
|
||||||
|
for _, r := range dailyRows {
|
||||||
|
dailyTotalRevenue += r.Revenue
|
||||||
|
dailyTotalQty += r.TotalQuantity
|
||||||
|
idx, ok := dailyCatIdx[r.Category]
|
||||||
|
if !ok {
|
||||||
|
idx = len(dailyCats)
|
||||||
|
dailyCats = append(dailyCats, dailyCatGroup{
|
||||||
|
Category: r.Category,
|
||||||
|
CategoryColor: r.CategoryColor,
|
||||||
|
})
|
||||||
|
dailyCatIdx[r.Category] = idx
|
||||||
|
}
|
||||||
|
dailyCats[idx].TotalQuantity += r.TotalQuantity
|
||||||
|
dailyCats[idx].TotalRevenue += r.Revenue
|
||||||
|
dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{
|
||||||
|
"product_id": r.ProductID,
|
||||||
|
"name": r.ProductName,
|
||||||
|
"quantity": r.TotalQuantity,
|
||||||
|
"order_count": r.OrderCount,
|
||||||
|
"revenue": r.Revenue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
dailyCatsJSON := make([]gin.H, len(dailyCats))
|
||||||
|
for i, grp := range dailyCats {
|
||||||
|
dailyCatsJSON[i] = gin.H{
|
||||||
|
"category": grp.Category,
|
||||||
|
"category_color": grp.CategoryColor,
|
||||||
|
"total_quantity": grp.TotalQuantity,
|
||||||
|
"total_revenue": grp.TotalRevenue,
|
||||||
|
"products": grp.Products,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Résumé global ─────────────────────────────────────────────────────────
|
// ── Résumé global ─────────────────────────────────────────────────────────
|
||||||
var totalOrders int64
|
|
||||||
var totalRevenue float64
|
|
||||||
gdb.Raw(`SELECT COUNT(*) FROM commandes WHERE status != 'cancelled'`).Scan(&totalOrders)
|
|
||||||
gdb.Raw(`SELECT COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) FROM commandes WHERE status = 'approved'`).Scan(&totalRevenue)
|
|
||||||
|
|
||||||
avgPerDay := 0.0
|
avgPerDay := 0.0
|
||||||
if totalOrders > 0 {
|
if totalOrders > 0 && activeDays > 0 {
|
||||||
// average over the last 30 days with data
|
|
||||||
var activeDays int64
|
|
||||||
gdb.Raw(`
|
|
||||||
SELECT COUNT(DISTINCT DATE(created_at))
|
|
||||||
FROM commandes
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled'
|
|
||||||
`).Scan(&activeDays)
|
|
||||||
if activeDays > 0 {
|
|
||||||
var last30Count int64
|
|
||||||
gdb.Raw(`
|
|
||||||
SELECT COUNT(*) FROM commandes
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled'
|
|
||||||
`).Scan(&last30Count)
|
|
||||||
avgPerDay = float64(last30Count) / float64(activeDays)
|
avgPerDay = float64(last30Count) / float64(activeDays)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"summary": gin.H{
|
"summary": gin.H{
|
||||||
"total_orders": totalOrders,
|
"total_orders": totalOrders,
|
||||||
"total_revenue": totalRevenue,
|
"total_revenue": totalRevenue,
|
||||||
|
"total_promo_discount": totalPromoDiscount,
|
||||||
|
"promo_orders_count": promoOrdersCount,
|
||||||
"peak_weekday": peakWeekday,
|
"peak_weekday": peakWeekday,
|
||||||
"top_product": topProductName,
|
"top_product": topProductName,
|
||||||
"avg_per_day": avgPerDay,
|
"avg_per_day": avgPerDay,
|
||||||
},
|
},
|
||||||
|
"reset_at_commandes": dateFilter(filters.ResetCommandes),
|
||||||
|
"reset_at_revenus": dateFilter(filters.ResetRevenus),
|
||||||
|
"reset_at_produits": dateFilter(filters.ResetProduits),
|
||||||
|
"reset_at_heures": dateFilter(filters.ResetHeures),
|
||||||
|
"reset_at_jours": dateFilter(filters.ResetJours),
|
||||||
|
"reset_at_doses": dateFilter(filters.ResetDoses),
|
||||||
"by_weekday": byWeekday,
|
"by_weekday": byWeekday,
|
||||||
"by_day_30": byDay,
|
"by_day_30": byDay,
|
||||||
"by_day_revenue": byDayRevenue,
|
"by_day_revenue": byDayRevenue,
|
||||||
"by_hour": byHour,
|
"by_hour": byHour,
|
||||||
"top_products": topProducts,
|
"top_products": topProducts,
|
||||||
"by_quantity": byQuantity,
|
"by_quantity": byQuantity,
|
||||||
|
"daily_detail": gin.H{
|
||||||
|
"date": time.Now().Format("02/01/2006"),
|
||||||
|
"total_orders": dailyTotalOrders,
|
||||||
|
"total_quantity": dailyTotalQty,
|
||||||
|
"total_revenue": dailyTotalRevenue,
|
||||||
|
"categories": dailyCatsJSON,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,9 +198,6 @@ func UpdateClientByAdmin(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ LOG DEBUG - Voir ce qui est reçu
|
|
||||||
log.Printf("📝 [UPDATE_CLIENT_ADMIN] Requête reçue: %+v", req)
|
|
||||||
|
|
||||||
// Récupérer le client actuel
|
// Récupérer le client actuel
|
||||||
client, err := database.GetClientByID(clientID)
|
client, err := database.GetClientByID(clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gestion/db"
|
"gestion/db"
|
||||||
|
"gestion/services"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -10,18 +12,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// CONSTANTES DE CONFIGURATION
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Distance maximale en mètres pour valider une livraison
|
|
||||||
MAX_DELIVERY_VALIDATION_DISTANCE_METERS = 100 // 100 mètres
|
|
||||||
|
|
||||||
// Distance maximale en kilomètres
|
|
||||||
MAX_DELIVERY_VALIDATION_DISTANCE_KM = 0.1 // 100 mètres = 0.1 km
|
|
||||||
)
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 3️⃣ DÉMARRER UNE LIVRAISON (PASSER EN IN_ROUTE)
|
// 3️⃣ DÉMARRER UNE LIVRAISON (PASSER EN IN_ROUTE)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -30,6 +20,7 @@ const (
|
|||||||
// POST /api/v1/deliveries/:id/start
|
// POST /api/v1/deliveries/:id/start
|
||||||
func StartDelivery(c *gin.Context) {
|
func StartDelivery(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||||
|
|
||||||
username, exists := c.Get("username")
|
username, exists := c.Get("username")
|
||||||
if !exists || c.GetString("role") != "livreur" {
|
if !exists || c.GetString("role") != "livreur" {
|
||||||
@@ -114,11 +105,47 @@ func StartDelivery(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if etaMinutes == 0 && req.Latitude != 0 && req.Longitude != 0 {
|
if etaMinutes == 0 {
|
||||||
destLat, _ := command["dest_latitude"].(float64)
|
destLat, _ := command["dest_latitude"].(float64)
|
||||||
destLon, _ := command["dest_longitude"].(float64)
|
destLon, _ := command["dest_longitude"].(float64)
|
||||||
|
|
||||||
|
// Fallback 1 : cache Redis (géocodage déjà fait à l'assignation
|
||||||
|
// mais pas encore persisté en DB — cf. goroutine async dans
|
||||||
|
// handlers/commands.go AssignCommandToDeliveryman).
|
||||||
|
if destLat == 0 || destLon == 0 {
|
||||||
|
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||||
|
if destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result(); err == nil && destData != "" {
|
||||||
|
var coords struct {
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
|
||||||
|
destLat, destLon = coords.Lat, coords.Lon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback 2 : géocodage synchrone de l'adresse. Couvre le cas où
|
||||||
|
// le livreur démarre la livraison avant que la goroutine async
|
||||||
|
// d'assignation ait fini de géocoder (race condition).
|
||||||
|
if (destLat == 0 || destLon == 0) && geoService != nil {
|
||||||
|
if adresse, _ := command["adresse"].(string); adresse != "" {
|
||||||
|
if location, err := geoService.GeocodeAddress(adresse); err == nil && location != nil {
|
||||||
|
destLat, destLon = location.Latitude, location.Longitude
|
||||||
|
database.GDB.Exec(
|
||||||
|
"UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?",
|
||||||
|
destLat, destLon, commandID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if destLat != 0 && destLon != 0 {
|
if destLat != 0 && destLon != 0 {
|
||||||
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
|
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
|
||||||
|
} else {
|
||||||
|
// Fallback 3 : aucune coordonnée exploitable — ETA par
|
||||||
|
// défaut plutôt que pas d'ETA du tout dans le message.
|
||||||
|
etaMinutes = 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if etaMinutes > 0 {
|
if etaMinutes > 0 {
|
||||||
|
|||||||
+26
-1
@@ -92,6 +92,29 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s3Service, err := services.NewS3Service(
|
||||||
|
os.Getenv("S3_REGION"),
|
||||||
|
os.Getenv("S3_BUCKET"),
|
||||||
|
os.Getenv("S3_ENDPOINT"),
|
||||||
|
services.S3Credentials{
|
||||||
|
S3KeyId: os.Getenv("RUSTFS_ACCESS_KEY"),
|
||||||
|
S3AccessKey: os.Getenv("RUSTFS_SECRET_KEY"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("erreur init S3: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var storage services.Storage
|
||||||
|
switch os.Getenv("STORAGE_DRIVER") {
|
||||||
|
case "s3":
|
||||||
|
storage = services.NewS3Storage(s3Service)
|
||||||
|
log.Println("✅ Storage driver: s3 (RustFS)")
|
||||||
|
default:
|
||||||
|
storage = services.NewLocalStorage("uploads")
|
||||||
|
log.Println("✅ Storage driver: local")
|
||||||
|
}
|
||||||
|
|
||||||
log.Println("")
|
log.Println("")
|
||||||
log.Println("🧹 Démarrage du nettoyage des commandes invalides...")
|
log.Println("🧹 Démarrage du nettoyage des commandes invalides...")
|
||||||
removed, err := database.CleanupInvalidQueueCommands()
|
removed, err := database.CleanupInvalidQueueCommands()
|
||||||
@@ -153,6 +176,8 @@ func main() {
|
|||||||
r.Use(func(c *gin.Context) {
|
r.Use(func(c *gin.Context) {
|
||||||
c.Set("database", database)
|
c.Set("database", database)
|
||||||
c.Set("geoService", geoService)
|
c.Set("geoService", geoService)
|
||||||
|
c.Set("s3Service", s3Service)
|
||||||
|
c.Set("storage", storage)
|
||||||
c.Next()
|
c.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -167,7 +192,7 @@ func main() {
|
|||||||
|
|
||||||
r.Static("/uploads", "./uploads")
|
r.Static("/uploads", "./uploads")
|
||||||
|
|
||||||
routes.SetupRoutes(r, database, geoService)
|
routes.SetupRoutes(r, database, geoService, s3Service)
|
||||||
|
|
||||||
if err := r.Run(":8080"); err != nil {
|
if err := r.Run(":8080"); err != nil {
|
||||||
log.Fatalf("❌ Erreur au lancement du serveur : %v", err)
|
log.Fatalf("❌ Erreur au lancement du serveur : %v", err)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type CommandItem struct {
|
|||||||
Price float64 `gorm:"column:prix" json:"price"`
|
Price float64 `gorm:"column:prix" json:"price"`
|
||||||
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
|
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
|
||||||
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
|
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
|
||||||
|
PromoDiscount float64 `gorm:"column:promo_discount" json:"promo_discount,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ package models
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Media struct {
|
type Media struct {
|
||||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int `json:"id"`
|
||||||
ProductID int `gorm:"column:product_id" json:"product_id"`
|
ProductID int `json:"product_id"`
|
||||||
Type string `gorm:"column:type" json:"type"`
|
Type string `json:"type"`
|
||||||
URL string `gorm:"column:url" json:"url"`
|
URL string `json:"url"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
Key string `json:"-"` // clé interne RustFS, jamais exposée
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Media) TableName() string { return "media" }
|
func (Media) TableName() string { return "media" }
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Panier struct {
|
|||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
IsReward bool `json:"is_reward"`
|
IsReward bool `json:"is_reward"`
|
||||||
RewardPoolKey string `json:"reward_pool_key,omitempty"`
|
RewardPoolKey string `json:"reward_pool_key,omitempty"`
|
||||||
|
PromoDiscount float64 `json:"promo_discount,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,21 @@ type ProductPrice struct {
|
|||||||
Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"`
|
Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"`
|
||||||
Price float64 `json:"price" gorm:"column:price" binding:"required"`
|
Price float64 `json:"price" gorm:"column:price" binding:"required"`
|
||||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||||
ActivePrice bool `json:"active_price" gorm:"column:active_price;default:true"`
|
// Pas de tag gorm "default:true" ici : GORM omet de l'INSERT tout champ
|
||||||
|
// dont la valeur Go est la valeur zéro (false) s'il porte un tag
|
||||||
|
// "default", laissant Postgres appliquer sa propre valeur par défaut
|
||||||
|
// (TRUE) à la place — un prix explicitement désactivé (false) revenait
|
||||||
|
// donc toujours actif après un Create(). La colonne a déjà son défaut
|
||||||
|
// TRUE posé au niveau SQL (db_init.go), ce tag Go était redondant et
|
||||||
|
// seulement source du bug.
|
||||||
|
ActivePrice bool `json:"active_price" gorm:"column:active_price"`
|
||||||
|
|
||||||
|
// Champs transitoires (non persistés, gorm:"-") : annotés à la volée sur
|
||||||
|
// les endpoints de lecture client si une promotion s'applique à ce palier
|
||||||
|
// précis (voir handlers.applyPromotions) — permet d'afficher le prix
|
||||||
|
// barré + le prix promo sans toucher au prix catalogue réel.
|
||||||
|
PromoPrice *float64 `json:"promo_price,omitempty" gorm:"-"`
|
||||||
|
PromoPercent float64 `json:"promo_percent,omitempty" gorm:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ProductPrice) TableName() string { return "product_prices" }
|
func (ProductPrice) TableName() string { return "product_prices" }
|
||||||
|
|||||||
@@ -19,11 +19,3 @@ type DeliveryPersonStatus struct {
|
|||||||
CurrentCommand int `json:"current_command,omitempty"`
|
CurrentCommand int `json:"current_command,omitempty"`
|
||||||
LastUpdate time.Time `json:"last_update"`
|
LastUpdate time.Time `json:"last_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StockReservation struct {
|
|
||||||
ProductID int `json:"product_id"`
|
|
||||||
Quantity int `json:"quantity"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
|
||||||
CommandID int `json:"command_id"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,27 +14,109 @@ type PointsTier struct {
|
|||||||
Points int `json:"points"`
|
Points int `json:"points"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense
|
// RewardProductQuantity associe un produit à sa propre quantité offerte / à
|
||||||
type RewardCategoryConfig struct {
|
// -50%, pour le cas où une catégorie n'est pas configurée en "tous les
|
||||||
Category string `json:"category"` // nom de la catégorie
|
// produits" — ex: produit A à 2g offerts, produit B à 1g offert, tous deux
|
||||||
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
// dans la même catégorie et le même type de récompense.
|
||||||
ProductIDs []int `json:"product_ids"` // IDs des produits éligibles si AllProducts = false
|
type RewardProductQuantity struct {
|
||||||
|
ProductID int `json:"product_id"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RewardItem représente un produit offert lors d'une récompense, avec sa quantité et son prix associé
|
// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense,
|
||||||
|
// le type de récompense appliqué pour cette catégorie précise, et la quantité
|
||||||
|
// concernée (ex: 1g offert, ou 2g à -50%) — la quantité correspond au palier
|
||||||
|
// de prix catalogue du produit (voir GetActiveProductPrice), pas une valeur
|
||||||
|
// libre : ex. "30€ offert = 1g" si le produit a un palier quantity=1 à 30€.
|
||||||
|
//
|
||||||
|
// Si AllProducts = true, Quantity s'applique uniformément à tous les produits
|
||||||
|
// de la catégorie. Si AllProducts = false, chaque produit sélectionné dans
|
||||||
|
// Products a sa propre quantité (Quantity au niveau catégorie est alors ignoré).
|
||||||
|
type RewardCategoryConfig struct {
|
||||||
|
Category string `json:"category"` // nom de la catégorie
|
||||||
|
Type string `json:"type"` // "free_product" (défaut) | "half_price_product"
|
||||||
|
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
||||||
|
Quantity float64 `json:"quantity"` // quantité uniforme si AllProducts = true
|
||||||
|
Products []RewardProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RewardItem représente un produit résolu à ajouter au panier lors d'un
|
||||||
|
// claim (ProductID + Quantity + Price effectif) — construit dynamiquement à
|
||||||
|
// partir des CategoryConfigs au moment du claim, plus une liste saisie à part.
|
||||||
type RewardItem struct {
|
type RewardItem struct {
|
||||||
ProductID int `json:"product_id"` // ID du produit ajouté au panier
|
ProductID int `json:"product_id"` // ID du produit ajouté au panier
|
||||||
Quantity float64 `json:"quantity"` // quantité offerte
|
Quantity float64 `json:"quantity"` // quantité offerte
|
||||||
Price float64 `json:"price"` // valeur indicative affichée au client
|
Price float64 `json:"price"` // prix effectif facturé (0 si offert, 50% du prix catalogue si -50%)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés
|
// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés.
|
||||||
|
// Le type de récompense (gratuit ou -50%) et la quantité concernée sont
|
||||||
|
// définis par catégorie dans CategoryConfigs (voir RewardCategoryConfig) —
|
||||||
|
// les produits éligibles et leur quantité ne sont plus saisis à part.
|
||||||
type PointsReward struct {
|
type PointsReward struct {
|
||||||
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20)
|
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20)
|
||||||
Type string `json:"type"` // "free_product" | "half_price_product" | "custom"
|
|
||||||
Description string `json:"description"` // description libre affichée au client
|
Description string `json:"description"` // description libre affichée au client
|
||||||
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles
|
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type + quantité par catégorie
|
||||||
RewardItems []RewardItem `json:"reward_items"` // produits ajoutés au panier lors du claim
|
}
|
||||||
|
|
||||||
|
// PromotionProductQuantity associe un produit à sa propre quantité en promo,
|
||||||
|
// pour le cas où une catégorie n'est pas configurée en "tous les produits" —
|
||||||
|
// même logique que RewardProductQuantity mais pour les promotions.
|
||||||
|
type PromotionProductQuantity struct {
|
||||||
|
ProductID int `json:"product_id"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryPromotionConfig définit une promotion (réduction en %) appliquée
|
||||||
|
// automatiquement au prix catalogue d'un produit pour une quantité donnée —
|
||||||
|
// contrairement à RewardCategoryConfig, ça ne dépend d'aucun seuil de points :
|
||||||
|
// le prix réduit s'applique à tout client qui commande ce produit à cette
|
||||||
|
// quantité, affiché directement sur le produit. La quantité correspond au
|
||||||
|
// palier de prix catalogue existant (voir GetActiveProductPrice), pas une
|
||||||
|
// valeur libre.
|
||||||
|
//
|
||||||
|
// Si AllProducts = true, Quantity s'applique uniformément à tous les produits
|
||||||
|
// de la catégorie. Si AllProducts = false, chaque produit sélectionné dans
|
||||||
|
// Products a sa propre quantité (Quantity au niveau catégorie est alors ignoré).
|
||||||
|
type CategoryPromotionConfig struct {
|
||||||
|
Category string `json:"category"` // nom de la catégorie
|
||||||
|
DiscountPercent float64 `json:"discount_percent"` // pourcentage de réduction libre (ex: 10, 20, 33.5)
|
||||||
|
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
||||||
|
Quantity float64 `json:"quantity"` // quantité uniforme si AllProducts = true
|
||||||
|
Products []PromotionProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FreeGiftTier définit un seuil d'achat et la quantité offerte associée, du
|
||||||
|
// même produit — plusieurs seuils peuvent coexister pour un même produit
|
||||||
|
// (ex: 10g achetés → 1g offert, 20g achetés → 3g offerts) ; le seuil le plus
|
||||||
|
// élevé atteint par la quantité commandée est retenu (voir ResolveFreeGift).
|
||||||
|
type FreeGiftTier struct {
|
||||||
|
BuyQuantity float64 `json:"buy_quantity"` // quantité à acheter pour déclencher l'offre
|
||||||
|
FreeQuantity float64 `json:"free_quantity"` // quantité offerte du même produit
|
||||||
|
}
|
||||||
|
|
||||||
|
// FreeGiftProductQuantity associe un produit à ses propres seuils
|
||||||
|
// d'achat/offre, pour le cas où une catégorie n'est pas configurée en "tous
|
||||||
|
// les produits" — même logique que PromotionProductQuantity mais pour les
|
||||||
|
// offres quantité achetée/offerte.
|
||||||
|
type FreeGiftProductQuantity struct {
|
||||||
|
ProductID int `json:"product_id"`
|
||||||
|
Tiers []FreeGiftTier `json:"tiers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryFreeGiftConfig définit une offre "achetez X, Y offert" (du même
|
||||||
|
// produit) appliquée automatiquement dès que la quantité ajoutée au panier
|
||||||
|
// atteint un seuil configuré — indépendant des points de fidélité et des
|
||||||
|
// promotions (cumulable avec elles).
|
||||||
|
//
|
||||||
|
// Si AllProducts = true, Tiers s'applique uniformément à tous les produits de
|
||||||
|
// la catégorie. Si AllProducts = false, chaque produit sélectionné dans
|
||||||
|
// Products a ses propres seuils (Tiers au niveau catégorie est alors ignoré).
|
||||||
|
type CategoryFreeGiftConfig struct {
|
||||||
|
Category string `json:"category"` // nom de la catégorie
|
||||||
|
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
||||||
|
Tiers []FreeGiftTier `json:"tiers"` // seuils uniformes si AllProducts = true
|
||||||
|
Products []FreeGiftProductQuantity `json:"products"` // produits + seuils individuels si AllProducts = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// DaySchedule représente les horaires de livraison pour un jour de la semaine
|
// DaySchedule représente les horaires de livraison pour un jour de la semaine
|
||||||
@@ -90,6 +172,10 @@ type AppSettings struct {
|
|||||||
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
|
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
|
||||||
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
|
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
|
||||||
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
|
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
|
||||||
|
PromotionsEnabled bool `json:"promotions_enabled"` // activer/désactiver les promotions
|
||||||
|
Promotions []CategoryPromotionConfig `json:"promotions"` // promotions (% de réduction) par catégorie
|
||||||
|
FreeGiftsEnabled bool `json:"free_gifts_enabled"` // activer/désactiver les offres "achetez X, Y offert"
|
||||||
|
FreeGifts []CategoryFreeGiftConfig `json:"free_gifts"` // offres quantité achetée/offerte par catégorie
|
||||||
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
|
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
|
||||||
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
|
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
|
||||||
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
|
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
|
||||||
@@ -106,4 +192,19 @@ type AppSettings struct {
|
|||||||
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
|
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
|
||||||
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs
|
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs
|
||||||
ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact
|
ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact
|
||||||
|
// Palette de couleurs — espace admin
|
||||||
|
AdminColorPrimary string `json:"admin_color_primary"`
|
||||||
|
AdminColorSecondary string `json:"admin_color_secondary"`
|
||||||
|
AdminColorSuccess string `json:"admin_color_success"`
|
||||||
|
AdminColorDanger string `json:"admin_color_danger"`
|
||||||
|
AdminColorWarning string `json:"admin_color_warning"`
|
||||||
|
// Palette de couleurs — app client + site web
|
||||||
|
ClientColorPrimary string `json:"client_color_primary"`
|
||||||
|
ClientColorSecondary string `json:"client_color_secondary"`
|
||||||
|
ClientColorSuccess string `json:"client_color_success"`
|
||||||
|
ClientColorDanger string `json:"client_color_danger"`
|
||||||
|
ClientColorWarning string `json:"client_color_warning"`
|
||||||
|
// Dégradé du titre boutique sur le site web client
|
||||||
|
ClientTitleGradientFrom string `json:"client_title_gradient_from"`
|
||||||
|
ClientTitleGradientTo string `json:"client_title_gradient_to"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,3 +42,35 @@ type DayRevenueRow struct {
|
|||||||
Day time.Time `gorm:"column:day"`
|
Day time.Time `gorm:"column:day"`
|
||||||
Revenue float64 `gorm:"column:revenue"`
|
Revenue float64 `gorm:"column:revenue"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DailyProductRow struct {
|
||||||
|
ProductID int `gorm:"column:product_id"`
|
||||||
|
ProductName string `gorm:"column:product_name"`
|
||||||
|
Category string `gorm:"column:category"`
|
||||||
|
CategoryColor string `gorm:"column:category_color"`
|
||||||
|
TotalQuantity float64 `gorm:"column:total_quantity"`
|
||||||
|
OrderCount int `gorm:"column:order_count"`
|
||||||
|
Revenue float64 `gorm:"column:revenue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DayRowWithResult struct {
|
||||||
|
Day time.Time `gorm:"column:day"`
|
||||||
|
Count int `gorm:"column:count"`
|
||||||
|
Revenue float64 `gorm:"column:revenue"`
|
||||||
|
}
|
||||||
|
type WeekRow struct {
|
||||||
|
WeekNum int `gorm:"column:week_num"`
|
||||||
|
Year int `gorm:"column:year"`
|
||||||
|
Count int `gorm:"column:count"`
|
||||||
|
Revenue float64 `gorm:"column:revenue"`
|
||||||
|
}
|
||||||
|
type MonthRow struct {
|
||||||
|
MonthNum int `gorm:"column:month_num"`
|
||||||
|
Year int `gorm:"column:year"`
|
||||||
|
Count int `gorm:"column:count"`
|
||||||
|
Revenue float64 `gorm:"column:revenue"`
|
||||||
|
}
|
||||||
|
type TodayRow struct {
|
||||||
|
Count int `gorm:"column:count"`
|
||||||
|
Revenue float64 `gorm:"column:revenue"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services.GeoService) {
|
func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services.GeoService, s3Service *services.S3Service) {
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 🔐 MIDDLEWARE GLOBAL
|
// 🔐 MIDDLEWARE GLOBAL
|
||||||
@@ -17,6 +17,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
router.Use(func(c *gin.Context) {
|
router.Use(func(c *gin.Context) {
|
||||||
c.Set("database", database)
|
c.Set("database", database)
|
||||||
c.Set("geoService", geoService)
|
c.Set("geoService", geoService)
|
||||||
|
c.Set("s3Service", s3Service)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -79,6 +80,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
// Approbation livraison
|
// Approbation livraison
|
||||||
cartGroupV1.POST("/commands/:id/approve", handlers.ApproveDelivery)
|
cartGroupV1.POST("/commands/:id/approve", handlers.ApproveDelivery)
|
||||||
cartGroupV1.POST("/commands/:id/address/respond", handlers.RespondToAddressProposal)
|
cartGroupV1.POST("/commands/:id/address/respond", handlers.RespondToAddressProposal)
|
||||||
|
cartGroupV1.PUT("/commands/:id/address", handlers.UpdateOwnCommandAddress)
|
||||||
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
|
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
|
||||||
cartGroupV1.GET("/my-commands/history/detailed", handlers.GetMyCompletedOrdersWithItems)
|
cartGroupV1.GET("/my-commands/history/detailed", handlers.GetMyCompletedOrdersWithItems)
|
||||||
cartGroupV1.GET("/commands/:id/history", handlers.GetOrderHistory)
|
cartGroupV1.GET("/commands/:id/history", handlers.GetOrderHistory)
|
||||||
@@ -88,6 +90,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
|
|
||||||
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
|
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
|
||||||
cartGroupV1.GET("/my-commands/history", handlers.GetClientCommandsHistory)
|
cartGroupV1.GET("/my-commands/history", handlers.GetClientCommandsHistory)
|
||||||
|
// NOTATION LIVREUR
|
||||||
|
cartGroupV1.POST("/orders/:id/rate", handlers.SubmitLivreurRating)
|
||||||
|
cartGroupV1.GET("/orders/:id/rating", handlers.GetOrderRatingStatus)
|
||||||
|
|
||||||
// ⭐⭐ PÉNALITÉS CLIENT
|
// ⭐⭐ PÉNALITÉS CLIENT
|
||||||
cartGroupV1.GET("/penalties", handlers.GetMyPenalties) // Voir mes pénalités
|
cartGroupV1.GET("/penalties", handlers.GetMyPenalties) // Voir mes pénalités
|
||||||
|
|
||||||
@@ -125,6 +131,11 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
// ============================================
|
// ============================================
|
||||||
router.POST("/api/v1/webhooks/nowpayments", handlers.IPNWebhook)
|
router.POST("/api/v1/webhooks/nowpayments", handlers.IPNWebhook)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🖼️ PROXY MÉDIAS (RustFS privé via VPN)
|
||||||
|
// ============================================
|
||||||
|
router.GET("/media/*key", handlers.ServeMedia)
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 🤖 WEBHOOK TELEGRAM - PUBLIC (sécurisé par secret header)
|
// 🤖 WEBHOOK TELEGRAM - PUBLIC (sécurisé par secret header)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -135,6 +146,11 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
// ============================================
|
// ============================================
|
||||||
router.POST("/api/internal/telegram/link", handlers.InternalTelegramLink)
|
router.POST("/api/internal/telegram/link", handlers.InternalTelegramLink)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📋 HEALTH CHECK
|
||||||
|
// ============================================
|
||||||
|
router.GET("/health", handlers.Health)
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 📋 PATTERN v2: ADMIN API
|
// 📋 PATTERN v2: ADMIN API
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -197,16 +213,18 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
// CATÉGORIES - GESTION ADMIN
|
// CATÉGORIES - GESTION ADMIN
|
||||||
// ============================================
|
// ============================================
|
||||||
adminGroupV2.POST("/categories", handlers.CreateCategory)
|
adminGroupV2.POST("/categories", handlers.CreateCategory)
|
||||||
|
adminGroupV2.PUT("/categories/reorder", handlers.ReorderCategories)
|
||||||
adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory)
|
adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory)
|
||||||
adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory)
|
adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory)
|
||||||
// ============================================
|
// ============================================
|
||||||
// STATISTIQUES ADMIN
|
// STATISTIQUES ADMIN
|
||||||
// ============================================
|
// ============================================
|
||||||
adminGroupV2.GET("/stats", handlers.GetAdminStats)
|
adminGroupV2.GET("/stats", handlers.GetAdminStats)
|
||||||
|
adminGroupV2.POST("/stats/reset/:section", handlers.ResetAdminStats)
|
||||||
|
adminGroupV2.GET("/stats/monthly", handlers.GetAdminStatsByMonth)
|
||||||
adminGroupV2.POST("/active/product/price/:id", handlers.ActivePrice)
|
adminGroupV2.POST("/active/product/price/:id", handlers.ActivePrice)
|
||||||
adminGroupV2.POST("/desactive/product/price/:id", handlers.DesActivePrice)
|
adminGroupV2.POST("/desactive/product/price/:id", handlers.DesActivePrice)
|
||||||
|
adminGroupV2.GET("/stats/daily", handlers.GetAdminDailyDetail)
|
||||||
// ============================================
|
// ============================================
|
||||||
// COMMANDES - GESTION DE BASE
|
// COMMANDES - GESTION DE BASE
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -260,6 +278,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
adminGroupV2.PUT("/delivery-persons/update/:username/location", handlers.UpdateDeliveryPersonLocationAdmin)
|
adminGroupV2.PUT("/delivery-persons/update/:username/location", handlers.UpdateDeliveryPersonLocationAdmin)
|
||||||
adminGroupV2.DELETE("/delivery-persons/:username/queue/:command_id", handlers.RemoveCommandFromQueue)
|
adminGroupV2.DELETE("/delivery-persons/:username/queue/:command_id", handlers.RemoveCommandFromQueue)
|
||||||
adminGroupV2.GET("/delivery-persons/:username/map-links", handlers.GetDeliveryPersonMapLinks)
|
adminGroupV2.GET("/delivery-persons/:username/map-links", handlers.GetDeliveryPersonMapLinks)
|
||||||
|
adminGroupV2.GET("/delivery-persons/:username/ratings", handlers.GetLivreurRatings)
|
||||||
|
adminGroupV2.GET("/delivery-persons/:username/login-history", handlers.GetLivreurLoginHistory)
|
||||||
// Commandes annulées
|
// Commandes annulées
|
||||||
adminGroupV2.GET("/orders/cancelled", handlers.GetAllCancelledOrders)
|
adminGroupV2.GET("/orders/cancelled", handlers.GetAllCancelledOrders)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -392,6 +412,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
// ============================================
|
// ============================================
|
||||||
// NOTIFICATIONS LIVREUR
|
// NOTIFICATIONS LIVREUR
|
||||||
// ============================================
|
// ============================================
|
||||||
|
livreurGroupV1.GET("/ratings", handlers.GetMyRatings)
|
||||||
|
|
||||||
livreurGroupV1.GET("/notifications", handlers.GetLivreurNotifications)
|
livreurGroupV1.GET("/notifications", handlers.GetLivreurNotifications)
|
||||||
livreurGroupV1.POST("/notifications/read", handlers.MarkLivreurNotificationsRead)
|
livreurGroupV1.POST("/notifications/read", handlers.MarkLivreurNotificationsRead)
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,13 @@ package services
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"gestion/utils"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"golang.org/x/text/runes"
|
|
||||||
"golang.org/x/text/transform"
|
|
||||||
"golang.org/x/text/unicode/norm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -64,25 +60,24 @@ func NewAddressCorrectionService(geoService *GeoService) *AddressCorrectionServi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// POINT D'ENTRÉE PRINCIPAL
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// ResolveAddress tente de géocoder une adresse avec correction automatique.
|
|
||||||
// Retourne toujours une suggestion, même approximative.
|
|
||||||
// Ordre de résolution :
|
|
||||||
// 1. Géocodage exact → succès immédiat
|
|
||||||
// 2. Nominatim fuzzy search (addressdetails + limit=5)
|
|
||||||
// 3. Décomposition structurée de l'adresse
|
|
||||||
// 4. Erreur explicite avec suggestions si dispo
|
|
||||||
func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) {
|
func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) {
|
||||||
rawAddress = strings.TrimSpace(rawAddress)
|
rawAddress = strings.TrimSpace(rawAddress)
|
||||||
if rawAddress == "" {
|
if rawAddress == "" {
|
||||||
return nil, fmt.Errorf("adresse vide")
|
return nil, fmt.Errorf("adresse vide")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Étape 1 : essai exact via GeoService (utilise le cache Redis) ──
|
if loc, err := acs.geoService.getFromCache(rawAddress); err == nil {
|
||||||
if loc, err := acs.geoService.GeocodeAddress(rawAddress); err == nil {
|
return &AddressSuggestion{
|
||||||
|
OriginalAddress: rawAddress,
|
||||||
|
CorrectedAddress: rawAddress,
|
||||||
|
Coordinates: Coordinates{Latitude: loc.Latitude, Longitude: loc.Longitude},
|
||||||
|
Confidence: 1.0,
|
||||||
|
CorrectionApplied: false,
|
||||||
|
Source: "exact",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if loc, err := acs.geoService.fetchFromNominatim(rawAddress); err == nil {
|
||||||
|
acs.geoService.saveToCache(rawAddress, loc)
|
||||||
return &AddressSuggestion{
|
return &AddressSuggestion{
|
||||||
OriginalAddress: rawAddress,
|
OriginalAddress: rawAddress,
|
||||||
CorrectedAddress: rawAddress,
|
CorrectedAddress: rawAddress,
|
||||||
@@ -106,11 +101,6 @@ func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*Address
|
|||||||
return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress)
|
return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// ÉTAPE 2 : FUZZY SEARCH NOMINATIM
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// nominatimFuzzySearch interroge Nominatim avec plusieurs variantes de l'adresse
|
|
||||||
func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) {
|
func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) {
|
||||||
variants := buildAddressVariants(address)
|
variants := buildAddressVariants(address)
|
||||||
|
|
||||||
@@ -131,7 +121,7 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr
|
|||||||
CorrectedAddress: corrected,
|
CorrectedAddress: corrected,
|
||||||
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
|
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
|
||||||
Confidence: confidence,
|
Confidence: confidence,
|
||||||
CorrectionApplied: !strings.EqualFold(normalize(address), normalize(corrected)),
|
CorrectionApplied: !strings.EqualFold(utils.NormalizeAddress(address), utils.NormalizeAddress(corrected)),
|
||||||
Source: "fuzzy",
|
Source: "fuzzy",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -140,7 +130,6 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr
|
|||||||
return nil, fmt.Errorf("aucune correspondance fuzzy trouvée")
|
return nil, fmt.Errorf("aucune correspondance fuzzy trouvée")
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryNominatim exécute une requête vers l'API Nominatim
|
|
||||||
func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) {
|
func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) {
|
||||||
query = strings.TrimSpace(query)
|
query = strings.TrimSpace(query)
|
||||||
if query == "" {
|
if query == "" {
|
||||||
@@ -172,7 +161,7 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("Nominatim status %d", resp.StatusCode)
|
return nil, fmt.Errorf("nominatim status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
@@ -196,7 +185,6 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]
|
|||||||
func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) {
|
func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) {
|
||||||
parts := parseAddressParts(address)
|
parts := parseAddressParts(address)
|
||||||
|
|
||||||
// Essai 1 : numéro + rue + ville (sans code postal)
|
|
||||||
if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" {
|
if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" {
|
||||||
q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city)
|
q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city)
|
||||||
if s, err := acs.nominatimFuzzySearch(q); err == nil {
|
if s, err := acs.nominatimFuzzySearch(q); err == nil {
|
||||||
@@ -206,7 +194,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Essai 2 : rue + code postal uniquement
|
|
||||||
if parts.streetName != "" && parts.postcode != "" {
|
if parts.streetName != "" && parts.postcode != "" {
|
||||||
q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode)
|
q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode)
|
||||||
if s, err := acs.nominatimFuzzySearch(q); err == nil {
|
if s, err := acs.nominatimFuzzySearch(q); err == nil {
|
||||||
@@ -216,7 +203,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Essai 3 : ville + code postal comme zone de repli
|
|
||||||
if parts.city != "" && parts.postcode != "" {
|
if parts.city != "" && parts.postcode != "" {
|
||||||
q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode)
|
q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode)
|
||||||
suggestions, err := acs.queryNominatim(q, 3)
|
suggestions, err := acs.queryNominatim(q, 3)
|
||||||
@@ -243,7 +229,7 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
|
|||||||
// buildAddressVariants génère plusieurs variantes d'une adresse pour maximiser les chances
|
// buildAddressVariants génère plusieurs variantes d'une adresse pour maximiser les chances
|
||||||
func buildAddressVariants(address string) []string {
|
func buildAddressVariants(address string) []string {
|
||||||
variants := []string{address}
|
variants := []string{address}
|
||||||
normalized := normalize(address)
|
normalized := utils.NormalizeAddress(address)
|
||||||
|
|
||||||
// Variante sans accents
|
// Variante sans accents
|
||||||
if normalized != address {
|
if normalized != address {
|
||||||
@@ -387,8 +373,8 @@ func parseAddressParts(address string) addressParts {
|
|||||||
|
|
||||||
// computeConfidence calcule un score de similarité entre l'adresse originale et la suggestion
|
// computeConfidence calcule un score de similarité entre l'adresse originale et la suggestion
|
||||||
func computeConfidence(original, suggested string, nominatimImportance float64) float64 {
|
func computeConfidence(original, suggested string, nominatimImportance float64) float64 {
|
||||||
origNorm := normalize(strings.ToLower(original))
|
origNorm := utils.NormalizeAddress(strings.ToLower(original))
|
||||||
suggNorm := normalize(strings.ToLower(suggested))
|
suggNorm := utils.NormalizeAddress(strings.ToLower(suggested))
|
||||||
|
|
||||||
// Score de similarité sur les mots communs
|
// Score de similarité sur les mots communs
|
||||||
origWords := strings.Fields(origNorm)
|
origWords := strings.Fields(origNorm)
|
||||||
@@ -449,13 +435,6 @@ func formatNominatimAddress(s NominatimSuggestion) string {
|
|||||||
return strings.Join(parts, ", ")
|
return strings.Join(parts, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalize supprime les accents et normalise les espaces
|
|
||||||
func normalize(s string) string {
|
|
||||||
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
|
|
||||||
result, _, _ := transform.String(t, s)
|
|
||||||
return strings.Join(strings.Fields(result), " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// isPostcode retourne true si le mot ressemble à un code postal français
|
// isPostcode retourne true si le mot ressemble à un code postal français
|
||||||
func isPostcode(s string) bool {
|
func isPostcode(s string) bool {
|
||||||
if len(s) != 5 {
|
if len(s) != 5 {
|
||||||
|
|||||||
@@ -72,19 +72,14 @@ func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) {
|
|||||||
return location, nil
|
return location, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. TomTom (primaire — plus fiable que Nominatim pour les adresses FR)
|
// 2. Tentative directe via Nominatim
|
||||||
if location, err := GeocodeWithTomTom(address); err == nil {
|
|
||||||
gs.saveToCache(address, location)
|
|
||||||
return location, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fallback Nominatim
|
|
||||||
if location, err := gs.fetchFromNominatim(address); err == nil {
|
if location, err := gs.fetchFromNominatim(address); err == nil {
|
||||||
gs.saveToCache(address, location)
|
gs.saveToCache(address, location)
|
||||||
return location, nil
|
return location, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. ── Correction automatique de l'adresse ────────────────────────────
|
// 3. ── NOUVEAU : correction automatique de l'adresse ──────────────────
|
||||||
|
// Déclenché uniquement si le géocodage direct a échoué.
|
||||||
log.Printf("🔍 [GEO] Géocodage direct échoué pour '%s', tentative de correction...", address)
|
log.Printf("🔍 [GEO] Géocodage direct échoué pour '%s', tentative de correction...", address)
|
||||||
|
|
||||||
suggestion, err := gs.correctionService.ResolveAddress(address)
|
suggestion, err := gs.correctionService.ResolveAddress(address)
|
||||||
@@ -216,10 +211,6 @@ func (gs *GeoService) getCacheKey(address string) string {
|
|||||||
return fmt.Sprintf("geocode:cache:%s", address)
|
return fmt.Sprintf("geocode:cache:%s", address)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// CALCULS GÉOGRAPHIQUES
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// CalculateDistance calcule la distance entre deux points (formule Haversine)
|
// CalculateDistance calcule la distance entre deux points (formule Haversine)
|
||||||
func CalculateDistance(from, to Coordinates) float64 {
|
func CalculateDistance(from, to Coordinates) float64 {
|
||||||
// Conversion en radians
|
// Conversion en radians
|
||||||
@@ -228,7 +219,6 @@ func CalculateDistance(from, to Coordinates) float64 {
|
|||||||
lat2Rad := toRadians(to.Latitude)
|
lat2Rad := toRadians(to.Latitude)
|
||||||
lon2Rad := toRadians(to.Longitude)
|
lon2Rad := toRadians(to.Longitude)
|
||||||
|
|
||||||
// Différences
|
|
||||||
dLat := lat2Rad - lat1Rad
|
dLat := lat2Rad - lat1Rad
|
||||||
dLon := lon2Rad - lon1Rad
|
dLon := lon2Rad - lon1Rad
|
||||||
|
|
||||||
@@ -244,13 +234,10 @@ func CalculateDistance(from, to Coordinates) float64 {
|
|||||||
|
|
||||||
// CalculateETA calcule le temps estimé d'arrivée en minutes (version locale/fallback)
|
// CalculateETA calcule le temps estimé d'arrivée en minutes (version locale/fallback)
|
||||||
func CalculateETA(distanceKm float64) int {
|
func CalculateETA(distanceKm float64) int {
|
||||||
// ⚡ AMÉLIORATION: Formule plus réaliste basée sur la distance
|
|
||||||
if distanceKm < 0.1 {
|
if distanceKm < 0.1 {
|
||||||
return MinETA // Très proche: minimum 3 minutes
|
return MinETA
|
||||||
}
|
}
|
||||||
|
|
||||||
// Temps de trajet basé sur vitesse moyenne en ville (25 km/h avec trafic)
|
|
||||||
// Plus réaliste que 30 km/h
|
|
||||||
travelTime := (distanceKm / 25.0) * 60.0
|
travelTime := (distanceKm / 25.0) * 60.0
|
||||||
|
|
||||||
// Ajouter une marge pour le trafic (environ 20%)
|
// Ajouter une marge pour le trafic (environ 20%)
|
||||||
@@ -267,8 +254,6 @@ func CalculateETA(distanceKm float64) int {
|
|||||||
return totalMinutes
|
return totalMinutes
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
|
|
||||||
// Retourne (etaMinutes, distanceKm, error)
|
|
||||||
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
|
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
|
||||||
if len(tomTomKeys.keys) == 0 {
|
if len(tomTomKeys.keys) == 0 {
|
||||||
distance := CalculateDistance(from, to)
|
distance := CalculateDistance(from, to)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func (s *LBTelegramService) IsConfigured() bool {
|
|||||||
// EnrollUser enrôle un utilisateur auprès de LBTelegram après liaison du compte.
|
// EnrollUser enrôle un utilisateur auprès de LBTelegram après liaison du compte.
|
||||||
// LBTelegram envoie lui-même le message de confirmation (chaîne Bot1→Bot2→Bot3).
|
// LBTelegram envoie lui-même le message de confirmation (chaîne Bot1→Bot2→Bot3).
|
||||||
func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error {
|
func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"user_id": chatID,
|
"user_id": chatID,
|
||||||
"username": username,
|
"username": username,
|
||||||
"role": role,
|
"role": role,
|
||||||
@@ -68,7 +68,7 @@ func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) erro
|
|||||||
// SendNotification envoie un message via la gateway LBTelegram.
|
// SendNotification envoie un message via la gateway LBTelegram.
|
||||||
// Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn).
|
// Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn).
|
||||||
func (s *LBTelegramService) SendNotification(userID int64, message string) error {
|
func (s *LBTelegramService) SendNotification(userID int64, message string) error {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"message": message,
|
"message": message,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type S3Service struct {
|
||||||
|
client *s3.Client
|
||||||
|
bucketName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type S3Credentials struct {
|
||||||
|
S3KeyId string
|
||||||
|
S3AccessKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewS3Service initialise le client S3 pointant vers RustFS (accessible via VPN).
|
||||||
|
func NewS3Service(region, bucketName, endpoint string, creds S3Credentials) (*S3Service, error) {
|
||||||
|
var cfg aws.Config
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if creds.S3KeyId != "" && creds.S3AccessKey != "" {
|
||||||
|
cfg, err = config.LoadDefaultConfig(context.TODO(),
|
||||||
|
config.WithRegion(region),
|
||||||
|
config.WithCredentialsProvider(
|
||||||
|
credentials.NewStaticCredentialsProvider(creds.S3KeyId, creds.S3AccessKey, ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cfg, err = config.LoadDefaultConfig(context.TODO(), config.WithRegion(region))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("erreur chargement config AWS: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||||
|
if endpoint != "" {
|
||||||
|
o.BaseEndpoint = aws.String(endpoint) // ex: http://10.x.x.x:9000 (IP interne VPN de RustFS)
|
||||||
|
o.UsePathStyle = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return &S3Service{
|
||||||
|
client: client,
|
||||||
|
bucketName: bucketName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadFile upload un fichier et renvoie sa clé S3 (pas d'URL publique, RustFS est privé).
|
||||||
|
func (s *S3Service) UploadFile(fileHeader *multipart.FileHeader, folder string) (key string, err error) {
|
||||||
|
ext := filepath.Ext(fileHeader.Filename)
|
||||||
|
fileName := fmt.Sprintf("%s%s", uuid.New().String(), ext)
|
||||||
|
return s.UploadFileWithName(fileHeader, folder, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadFileWithName upload un fichier avec un nom déjà déterminé et renvoie la clé S3.
|
||||||
|
func (s *S3Service) UploadFileWithName(fileHeader *multipart.FileHeader, folder, fileName string) (key string, err error) {
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("erreur ouverture fichier: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
buf := bytes.NewBuffer(nil)
|
||||||
|
if _, err := buf.ReadFrom(file); err != nil {
|
||||||
|
return "", fmt.Errorf("erreur lecture fichier: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key = fmt.Sprintf("%s/%s", folder, fileName)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
contentType := fileHeader.Header.Get("Content-Type")
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(s.bucketName),
|
||||||
|
Key: aws.String(key),
|
||||||
|
Body: bytes.NewReader(buf.Bytes()),
|
||||||
|
ContentType: aws.String(contentType),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("erreur upload RustFS: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFile récupère un objet depuis RustFS (stream + content-type) pour le proxy.
|
||||||
|
// Le contexte doit rester actif pendant toute la lecture du body par l'appelant.
|
||||||
|
func (s *S3Service) GetFile(ctx context.Context, key string) (io.ReadCloser, string, error) {
|
||||||
|
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s.bucketName),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("erreur lecture RustFS: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := "application/octet-stream"
|
||||||
|
if out.ContentType != nil {
|
||||||
|
contentType = *out.ContentType
|
||||||
|
}
|
||||||
|
return out.Body, contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFile supprime un fichier à partir de sa clé S3.
|
||||||
|
func (s *S3Service) DeleteFile(key string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||||
|
Bucket: aws.String(s.bucketName),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("erreur suppression RustFS: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"gestion/utils"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Storage abstrait l'emplacement de stockage des médias produits (local ou S3),
|
||||||
|
// pour que tous les points d'upload/suppression respectent le même driver.
|
||||||
|
type Storage interface {
|
||||||
|
// Upload sauvegarde le fichier et renvoie l'URL à persister en DB (models.Media.URL)
|
||||||
|
// et la clé interne (vide pour local, clé S3 sinon — models.Media.Key).
|
||||||
|
Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error)
|
||||||
|
// Delete supprime le fichier. url et key sont ceux stockés en DB pour ce média :
|
||||||
|
// chaque implémentation ignore celui qui ne la concerne pas.
|
||||||
|
Delete(url string, key string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalStorage stocke les fichiers sur le disque local, sous baseDir (ex: "uploads").
|
||||||
|
type LocalStorage struct {
|
||||||
|
baseDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLocalStorage(baseDir string) *LocalStorage {
|
||||||
|
return &LocalStorage{baseDir: baseDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalStorage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) {
|
||||||
|
destFolder := filepath.Join(s.baseDir, folder)
|
||||||
|
if err := os.MkdirAll(destFolder, 0750); err != nil {
|
||||||
|
return "", "", fmt.Errorf("erreur création dossier: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(destFolder, fileName)
|
||||||
|
safeFilePath, err := utils.SanitizeFilePath(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("chemin invalide: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("erreur ouverture fichier: %w", err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
dst, err := os.OpenFile(safeFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("erreur création fichier: %w", err)
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, src); err != nil {
|
||||||
|
os.Remove(safeFilePath)
|
||||||
|
return "", "", fmt.Errorf("erreur écriture fichier: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/" + filepath.ToSlash(safeFilePath), "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalStorage) Delete(url string, key string) error {
|
||||||
|
filePath := ""
|
||||||
|
if len(url) > 0 && url[0] == '/' {
|
||||||
|
filePath = url[1:]
|
||||||
|
} else {
|
||||||
|
filePath = url
|
||||||
|
}
|
||||||
|
|
||||||
|
safeFilePath, err := utils.SanitizeFilePath(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("chemin invalide: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("erreur suppression: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3Storage adapte le S3Service existant (RustFS) à l'interface Storage.
|
||||||
|
type S3Storage struct {
|
||||||
|
s3 *S3Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewS3Storage(s3 *S3Service) *S3Storage {
|
||||||
|
return &S3Storage{s3: s3}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *S3Storage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) {
|
||||||
|
key, err = s.s3.UploadFileWithName(fileHeader, folder, fileName)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return "/media/" + key, key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *S3Storage) Delete(url string, key string) error {
|
||||||
|
if key == "" {
|
||||||
|
return fmt.Errorf("clé S3 manquante pour suppression")
|
||||||
|
}
|
||||||
|
return s.s3.DeleteFile(key)
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error {
|
|||||||
return fmt.Errorf("telegram non configuré")
|
return fmt.Errorf("telegram non configuré")
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"chat_id": chatID,
|
"chat_id": chatID,
|
||||||
"text": text,
|
"text": text,
|
||||||
"parse_mode": "HTML",
|
"parse_mode": "HTML",
|
||||||
@@ -107,11 +107,11 @@ func (t *TelegramService) SendMessageWithButtons(chatID int64, text string, butt
|
|||||||
row = append(row, map[string]string{"text": b[0], "url": b[1]})
|
row = append(row, map[string]string{"text": b[0], "url": b[1]})
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"chat_id": chatID,
|
"chat_id": chatID,
|
||||||
"text": text,
|
"text": text,
|
||||||
"parse_mode": "HTML",
|
"parse_mode": "HTML",
|
||||||
"reply_markup": map[string]interface{}{
|
"reply_markup": map[string]any{
|
||||||
"inline_keyboard": [][]map[string]string{row},
|
"inline_keyboard": [][]map[string]string{row},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ func (t *TelegramService) SetWebhook(webhookURL string) error {
|
|||||||
return fmt.Errorf("telegram non configuré")
|
return fmt.Errorf("telegram non configuré")
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"url": webhookURL,
|
"url": webhookURL,
|
||||||
"allowed_updates": []string{"message"},
|
"allowed_updates": []string{"message"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,72 +15,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GeocodeWithTomTom géocode une adresse via l'API TomTom Search.
|
|
||||||
func GeocodeWithTomTom(address string) (*GeoLocation, error) {
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
|
|
||||||
buildReq := func(key string) (*http.Request, error) {
|
|
||||||
u := &url.URL{
|
|
||||||
Scheme: "https",
|
|
||||||
Host: "api.tomtom.com",
|
|
||||||
Path: fmt.Sprintf("/search/2/geocode/%s.json", url.PathEscape(address)),
|
|
||||||
}
|
|
||||||
q := url.Values{}
|
|
||||||
q.Set("key", key)
|
|
||||||
q.Set("countrySet", "FR")
|
|
||||||
q.Set("limit", "1")
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
return http.NewRequest(http.MethodGet, u.String(), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := tomTomKeys.Do(client, buildReq)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TomTom geocode: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("TomTom geocode %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TomTom geocode lecture: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsed struct {
|
|
||||||
Results []struct {
|
|
||||||
Position struct {
|
|
||||||
Lat float64 `json:"lat"`
|
|
||||||
Lon float64 `json:"lon"`
|
|
||||||
} `json:"position"`
|
|
||||||
Address struct {
|
|
||||||
FreeformAddress string `json:"freeformAddress"`
|
|
||||||
} `json:"address"`
|
|
||||||
MatchConfidence struct {
|
|
||||||
Score float64 `json:"score"`
|
|
||||||
} `json:"matchConfidence"`
|
|
||||||
} `json:"results"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
||||||
return nil, fmt.Errorf("TomTom geocode parsing: %w", err)
|
|
||||||
}
|
|
||||||
if len(parsed.Results) == 0 {
|
|
||||||
return nil, fmt.Errorf("TomTom geocode: aucun résultat pour '%s'", address)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := parsed.Results[0]
|
|
||||||
log.Printf("📍 [GEO] TomTom geocode '%s' → %s (%.6f, %.6f) conf=%.2f",
|
|
||||||
address, r.Address.FreeformAddress, r.Position.Lat, r.Position.Lon, r.MatchConfidence.Score)
|
|
||||||
|
|
||||||
return &GeoLocation{
|
|
||||||
Latitude: r.Position.Lat,
|
|
||||||
Longitude: r.Position.Lon,
|
|
||||||
DisplayName: r.Address.FreeformAddress,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
|
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ func (m *tomTomKeyManager) rotate(fromIdx int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Do exécute la requête en rotant automatiquement sur 403/429.
|
// Do exécute la requête en rotant automatiquement sur 403/429.
|
||||||
|
// buildReq doit construire une nouvelle *http.Request pour la clé donnée.
|
||||||
func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*http.Request, error)) (*http.Response, error) {
|
func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*http.Request, error)) (*http.Response, error) {
|
||||||
n := len(m.keys)
|
n := len(m.keys)
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
@@ -67,27 +68,23 @@ func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*h
|
|||||||
|
|
||||||
_, startIdx := m.currentKey()
|
_, startIdx := m.currentKey()
|
||||||
|
|
||||||
for attempt := 0; attempt < n; attempt++ {
|
for attempt := range n {
|
||||||
idx := (startIdx + attempt) % n
|
idx := (startIdx + attempt) % n
|
||||||
key := m.keys[idx]
|
key := m.keys[idx]
|
||||||
|
|
||||||
req, err := buildReq(key)
|
req, err := buildReq(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
|
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
|
||||||
io.Copy(io.Discard, resp.Body)
|
io.Copy(io.Discard, resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
m.rotate(idx)
|
m.rotate(idx)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gestion/db"
|
||||||
|
"gestion/services"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ces tests appellent le vrai service Nominatim (réseau réel, rate-limité à
|
||||||
|
// 1 req/s — voir services/adresses_correction.go). Contrairement aux tests
|
||||||
|
// purs de services/adresses_correction_test.go (normalisation, décomposition,
|
||||||
|
// scoring — sans réseau), ceux-ci vérifient le comportement de bout en bout
|
||||||
|
// de ResolveAddress sur de vraies adresses nantaises mal écrites.
|
||||||
|
//
|
||||||
|
// Chaque cas a été vérifié manuellement au préalable (curl vers l'API
|
||||||
|
// Nominatim) pour confirmer ce que la recherche directe résout déjà seule
|
||||||
|
// (Nominatim tolère nativement la casse, les accents et certaines
|
||||||
|
// abréviations sans point) et ce qui nécessite réellement la logique de
|
||||||
|
// correction (variantes, décomposition structurée, repli ville+code postal).
|
||||||
|
//
|
||||||
|
// Un délai explicite sépare chaque cas, en plus du throttle déjà appliqué à
|
||||||
|
// chaque requête HTTP interne (1.1s dans queryNominatim), par courtoisie
|
||||||
|
// envers le service public.
|
||||||
|
|
||||||
|
const (
|
||||||
|
nantesLatMin, nantesLatMax = 47.15, 47.28
|
||||||
|
nantesLonMin, nantesLonMax = -1.65, -1.45
|
||||||
|
)
|
||||||
|
|
||||||
|
func isWithinNantes(lat, lon float64) bool {
|
||||||
|
return lat >= nantesLatMin && lat <= nantesLatMax && lon >= nantesLonMin && lon <= nantesLonMax
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAddress_RealNantesAddresses(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
|
||||||
|
}
|
||||||
|
|
||||||
|
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
|
||||||
|
correction := services.NewAddressCorrectionService(geoService)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
minConfidence float64
|
||||||
|
maxConfidence float64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// Nominatim tolère nativement la casse et l'absence d'accent :
|
||||||
|
// résolution directe (étape 1 de ResolveAddress), confiance max.
|
||||||
|
name: "tout minuscule sans accent",
|
||||||
|
input: "12 rue crebillon 44000 nantes",
|
||||||
|
minConfidence: 0.90,
|
||||||
|
maxConfidence: 1.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Abréviation sans point ("Pl" au lieu de "Place") — également
|
||||||
|
// tolérée nativement par Nominatim, résolution directe.
|
||||||
|
name: "abréviation sans point",
|
||||||
|
input: "3 Pl Royale 44000 Nantes",
|
||||||
|
minConfidence: 0.90,
|
||||||
|
maxConfidence: 1.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Faute de frappe réaliste sur un nom de rue réel (Gambetta ->
|
||||||
|
// Gambeta) : vérifié que la recherche Nominatim directe ET
|
||||||
|
// toutes les variantes générées par l'algorithme (accents,
|
||||||
|
// abréviations, décomposition structurée essais 1 et 2)
|
||||||
|
// échouent — seul le repli ville+code postal (essai 3,
|
||||||
|
// confiance fixe 0.30) aboutit. Documente une vraie limite :
|
||||||
|
// l'algorithme ne corrige pas les fautes de frappe arbitraires
|
||||||
|
// dans un nom de rue, il retombe sur "quelque part dans la
|
||||||
|
// bonne ville".
|
||||||
|
name: "faute de frappe non corrigible sur le nom de rue",
|
||||||
|
input: "15 Rue Gambeta 44000 Nantes",
|
||||||
|
minConfidence: 0.25,
|
||||||
|
maxConfidence: 0.35,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if i > 0 {
|
||||||
|
time.Sleep(1200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestion, err := correction.ResolveAddress(c.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveAddress(%q): %v", c.input, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isWithinNantes(suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude) {
|
||||||
|
t.Errorf("coordonnées hors de Nantes pour %q: lat=%.4f lon=%.4f",
|
||||||
|
c.input, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
|
||||||
|
}
|
||||||
|
if suggestion.Confidence < c.minConfidence || suggestion.Confidence > c.maxConfidence {
|
||||||
|
t.Errorf("confiance hors intervalle attendu pour %q: got=%.2f want=[%.2f,%.2f]",
|
||||||
|
c.input, suggestion.Confidence, c.minConfidence, c.maxConfidence)
|
||||||
|
}
|
||||||
|
if suggestion.CorrectedAddress == "" {
|
||||||
|
t.Errorf("adresse corrigée vide pour %q", c.input)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("%q -> %q (confiance=%.2f, source=%s, correction_appliquée=%v, lat=%.4f lon=%.4f)",
|
||||||
|
c.input, suggestion.CorrectedAddress, suggestion.Confidence, suggestion.Source,
|
||||||
|
suggestion.CorrectionApplied, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une adresse totalement absurde (aucun rapport avec un lieu réel) doit
|
||||||
|
// échouer proprement plutôt que renvoyer une coordonnée aléatoire.
|
||||||
|
func TestResolveAddress_NonsenseAddressFailsCleanly(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
|
||||||
|
}
|
||||||
|
|
||||||
|
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
|
||||||
|
correction := services.NewAddressCorrectionService(geoService)
|
||||||
|
|
||||||
|
_, err := correction.ResolveAddress("Xyzzyplonk Zorbaxx 00000 Nullepart")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("attendu une erreur pour une adresse sans aucun rapport avec un lieu réel")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gestion/models"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// db_address.go gère une table de correspondances gérées par l'admin
|
||||||
|
// (adresse_correction) : à chaque checkout, CheckAddress vérifie si l'adresse
|
||||||
|
// saisie par le client correspond à une entrée connue comme invalide, et si
|
||||||
|
// oui, substitue l'adresse correcte tout en signalant une erreur pour forcer
|
||||||
|
// une nouvelle confirmation côté client (voir ValidateBasket).
|
||||||
|
|
||||||
|
func cleanupAddressCorrections(t *testing.T, ids ...int64) {
|
||||||
|
t.Helper()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
for _, id := range ids {
|
||||||
|
testDB.GDB.Exec(`DELETE FROM adresse_correction WHERE id = ?`, id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAddress_NoMatchReturnsNilAndLeavesAddressUnchanged(t *testing.T) {
|
||||||
|
cmd := &models.Command{DeliveryAddress: testUserPrefix + "adresse jamais enregistrée 44000 Nantes"}
|
||||||
|
original := cmd.DeliveryAddress
|
||||||
|
|
||||||
|
if err := testDB.CheckAddress(cmd); err != nil {
|
||||||
|
t.Fatalf("CheckAddress sans correspondance ne doit jamais échouer: %v", err)
|
||||||
|
}
|
||||||
|
if cmd.DeliveryAddress != original {
|
||||||
|
t.Errorf("adresse ne doit pas être modifiée sans correspondance: got=%q want=%q", cmd.DeliveryAddress, original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAddress_MatchSubstitutesCorrectAddressAndReturnsError(t *testing.T) {
|
||||||
|
invalid := testUserPrefix + "12 Rue Crebillon Nantes"
|
||||||
|
correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes"
|
||||||
|
if err := testDB.AddAddress(correct, invalid); err != nil {
|
||||||
|
t.Fatalf("AddAddress: %v", err)
|
||||||
|
}
|
||||||
|
var id int64
|
||||||
|
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id)
|
||||||
|
cleanupAddressCorrections(t, id)
|
||||||
|
|
||||||
|
cmd := &models.Command{DeliveryAddress: invalid}
|
||||||
|
err := testDB.CheckAddress(cmd)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("attendu une erreur signalant la correction (pour forcer une re-confirmation client)")
|
||||||
|
}
|
||||||
|
if cmd.DeliveryAddress != correct {
|
||||||
|
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addCorrectionForFallback enregistre une correction et retourne une fonction
|
||||||
|
// de nettoyage à appeler via t.Cleanup par l'appelant (évite de dépendre de
|
||||||
|
// l'ordre d'exécution entre plusieurs corrections ajoutées dans un même test).
|
||||||
|
func addCorrectionForFallback(t *testing.T, invalid, correct string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := testDB.AddAddress(correct, invalid); err != nil {
|
||||||
|
t.Fatalf("AddAddress(%q -> %q): %v", invalid, correct, err)
|
||||||
|
}
|
||||||
|
var id int64
|
||||||
|
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id)
|
||||||
|
cleanupAddressCorrections(t, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les quatre tests suivants couvrent le fallback normalisé de CheckAddress
|
||||||
|
// (utils.NormalizeAddress + strings.EqualFold) : une correction enregistrée
|
||||||
|
// par l'admin avec un texte exact donné doit continuer à s'appliquer même si
|
||||||
|
// le client tape une variante mineure (casse, accents, espaces), plutôt que
|
||||||
|
// d'échouer silencieusement et laisser passer une adresse non livrable.
|
||||||
|
|
||||||
|
func TestCheckAddress_NormalizedFallback_CaseVariantMatches(t *testing.T) {
|
||||||
|
invalid := testUserPrefix + "12 Rue Crebillon Nantes"
|
||||||
|
correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes"
|
||||||
|
addCorrectionForFallback(t, invalid, correct)
|
||||||
|
|
||||||
|
cmd := &models.Command{DeliveryAddress: testUserPrefix + "12 RUE CREBILLON NANTES"}
|
||||||
|
err := testDB.CheckAddress(cmd)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("attendu une erreur signalant la correction (variante de casse)")
|
||||||
|
}
|
||||||
|
if cmd.DeliveryAddress != correct {
|
||||||
|
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAddress_NormalizedFallback_AccentVariantMatches(t *testing.T) {
|
||||||
|
invalid := testUserPrefix + "10 Rue du Général Buat Nantes"
|
||||||
|
correct := testUserPrefix + "10 Rue du Général Buat, 44000 Nantes"
|
||||||
|
addCorrectionForFallback(t, invalid, correct)
|
||||||
|
|
||||||
|
// Saisie sans accent par le client, alors que la correction enregistrée
|
||||||
|
// par l'admin en contient un.
|
||||||
|
cmd := &models.Command{DeliveryAddress: testUserPrefix + "10 Rue du General Buat Nantes"}
|
||||||
|
err := testDB.CheckAddress(cmd)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("attendu une erreur signalant la correction (variante d'accent)")
|
||||||
|
}
|
||||||
|
if cmd.DeliveryAddress != correct {
|
||||||
|
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAddress_NormalizedFallback_WhitespaceVariantMatches(t *testing.T) {
|
||||||
|
invalid := testUserPrefix + "5 Cours des 50 Otages Nantes"
|
||||||
|
correct := testUserPrefix + "5 Cours des 50 Otages, 44000 Nantes"
|
||||||
|
addCorrectionForFallback(t, invalid, correct)
|
||||||
|
|
||||||
|
cmd := &models.Command{DeliveryAddress: testUserPrefix + "5 Cours des 50 Otages Nantes "}
|
||||||
|
err := testDB.CheckAddress(cmd)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("attendu une erreur signalant la correction (espaces multiples)")
|
||||||
|
}
|
||||||
|
if cmd.DeliveryAddress != correct {
|
||||||
|
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAddress_NormalizedFallback_CombinedCaseAccentWhitespaceMatches(t *testing.T) {
|
||||||
|
invalid := testUserPrefix + "8 Rue de Verdun Nantes"
|
||||||
|
correct := testUserPrefix + "8 Rue de Verdun, 44000 Nantes"
|
||||||
|
addCorrectionForFallback(t, invalid, correct)
|
||||||
|
|
||||||
|
cmd := &models.Command{DeliveryAddress: testUserPrefix + "8 RUE de verdun nantes "}
|
||||||
|
err := testDB.CheckAddress(cmd)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("attendu une erreur signalant la correction (casse + espaces combinés)")
|
||||||
|
}
|
||||||
|
if cmd.DeliveryAddress != correct {
|
||||||
|
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le fallback compare une égalité normalisée stricte, pas une similarité
|
||||||
|
// floue : une adresse réellement différente (même partiellement proche) ne
|
||||||
|
// doit jamais être substituée par erreur.
|
||||||
|
func TestCheckAddress_NormalizedFallback_DoesNotMatchDifferentAddress(t *testing.T) {
|
||||||
|
invalid := testUserPrefix + "12 Rue Crebillon Nantes"
|
||||||
|
correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes"
|
||||||
|
addCorrectionForFallback(t, invalid, correct)
|
||||||
|
|
||||||
|
cmd := &models.Command{DeliveryAddress: testUserPrefix + "14 Rue Crebillon Nantes"}
|
||||||
|
original := cmd.DeliveryAddress
|
||||||
|
if err := testDB.CheckAddress(cmd); err != nil {
|
||||||
|
t.Fatalf("une adresse différente ne doit pas déclencher de correction: %v", err)
|
||||||
|
}
|
||||||
|
if cmd.DeliveryAddress != original {
|
||||||
|
t.Errorf("adresse ne doit pas être modifiée: got=%q want=%q", cmd.DeliveryAddress, original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avec plusieurs corrections enregistrées, le fallback doit retrouver la
|
||||||
|
// bonne entrée (pas la première venue) même via une variante normalisée.
|
||||||
|
func TestCheckAddress_NormalizedFallback_FindsRightEntryAmongMultiple(t *testing.T) {
|
||||||
|
invalidA := testUserPrefix + "1 Rue A Nantes"
|
||||||
|
correctA := testUserPrefix + "1 Rue A, 44000 Nantes"
|
||||||
|
invalidB := testUserPrefix + "2 Rue B Nantes"
|
||||||
|
correctB := testUserPrefix + "2 Rue B, 44000 Nantes"
|
||||||
|
addCorrectionForFallback(t, invalidA, correctA)
|
||||||
|
addCorrectionForFallback(t, invalidB, correctB)
|
||||||
|
|
||||||
|
cmd := &models.Command{DeliveryAddress: testUserPrefix + "2 RUE b nantes"}
|
||||||
|
if err := testDB.CheckAddress(cmd); err == nil {
|
||||||
|
t.Fatal("attendu une erreur signalant la correction B")
|
||||||
|
}
|
||||||
|
if cmd.DeliveryAddress != correctB {
|
||||||
|
t.Errorf("adresse corrigée: got=%q want=%q (ne doit pas confondre avec A)", cmd.DeliveryAddress, correctB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddAddress_ThenAllAddressIncludesIt(t *testing.T) {
|
||||||
|
invalid := testUserPrefix + "adresse invalide test"
|
||||||
|
correct := testUserPrefix + "adresse correcte test"
|
||||||
|
if err := testDB.AddAddress(correct, invalid); err != nil {
|
||||||
|
t.Fatalf("AddAddress: %v", err)
|
||||||
|
}
|
||||||
|
var id int64
|
||||||
|
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id)
|
||||||
|
cleanupAddressCorrections(t, id)
|
||||||
|
|
||||||
|
all, err := testDB.AllAddress()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AllAddress: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, a := range all {
|
||||||
|
if a.InvalidAddress == invalid && a.CorrectAddress == correct {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("la correspondance ajoutée n'apparaît pas dans AllAddress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAddress doit cibler la correspondance exacte, sans affecter une autre
|
||||||
|
// correspondance non liée. Note : invalid_address a une contrainte UNIQUE en
|
||||||
|
// base (adresse_correction_invalid_address_key), donc deux corrections ne
|
||||||
|
// peuvent jamais partager la même adresse invalide — le risque réel est
|
||||||
|
// seulement qu'un DELETE mal ciblé touche une correspondance différente.
|
||||||
|
func TestDeleteAddress_RemovesOnlyTargetedPairNotUnrelatedOne(t *testing.T) {
|
||||||
|
invalidA := testUserPrefix + "adresse A"
|
||||||
|
correctA := testUserPrefix + "correction A"
|
||||||
|
invalidB := testUserPrefix + "adresse B"
|
||||||
|
correctB := testUserPrefix + "correction B"
|
||||||
|
if err := testDB.AddAddress(correctA, invalidA); err != nil {
|
||||||
|
t.Fatalf("AddAddress A: %v", err)
|
||||||
|
}
|
||||||
|
if err := testDB.AddAddress(correctB, invalidB); err != nil {
|
||||||
|
t.Fatalf("AddAddress B: %v", err)
|
||||||
|
}
|
||||||
|
var idA, idB int64
|
||||||
|
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalidA).Scan(&idA)
|
||||||
|
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalidB).Scan(&idB)
|
||||||
|
cleanupAddressCorrections(t, idA, idB)
|
||||||
|
|
||||||
|
if err := testDB.DeleteAddress(invalidA, correctA); err != nil {
|
||||||
|
t.Fatalf("DeleteAddress: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := testDB.AllAddress()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AllAddress: %v", err)
|
||||||
|
}
|
||||||
|
var stillHasA, stillHasB bool
|
||||||
|
for _, a := range all {
|
||||||
|
if a.InvalidAddress == invalidA && a.CorrectAddress == correctA {
|
||||||
|
stillHasA = true
|
||||||
|
}
|
||||||
|
if a.InvalidAddress == invalidB && a.CorrectAddress == correctB {
|
||||||
|
stillHasB = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stillHasA {
|
||||||
|
t.Error("la correspondance ciblée (A) doit être supprimée")
|
||||||
|
}
|
||||||
|
if !stillHasB {
|
||||||
|
t.Error("l'autre correspondance (B), non ciblée, ne doit pas être supprimée")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gestion/handlers"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func alertContext(username, role string, body []byte, alertID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/livreur/alert", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
if username != "" {
|
||||||
|
c.Set("username", username)
|
||||||
|
}
|
||||||
|
c.Set("role", role)
|
||||||
|
if alertID != 0 {
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", alertID)}}
|
||||||
|
}
|
||||||
|
return c, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestAlert(t *testing.T, username, message string) int {
|
||||||
|
t.Helper()
|
||||||
|
alert, err := testDB.CreateAlert(username, message)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAlert: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
testDB.GDB.Exec(`DELETE FROM alerte_policy WHERE id = ?`, alert.ID)
|
||||||
|
})
|
||||||
|
return alert.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AlertPolice ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAlertPolice_LivreurCreatesAlert(t *testing.T) {
|
||||||
|
livreur := testUserPrefix + "alert_create_livreur"
|
||||||
|
body, _ := json.Marshal(map[string]string{"message": "Contrôle en cours"})
|
||||||
|
c, rec := alertContext(livreur, "livreur", body, 0)
|
||||||
|
handlers.AlertPolice(c)
|
||||||
|
t.Cleanup(func() { testDB.GDB.Exec(`DELETE FROM alerte_policy WHERE username = ?`, livreur) })
|
||||||
|
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
AlertID int `json:"alert_id"`
|
||||||
|
User string `json:"user"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
if resp.User != livreur {
|
||||||
|
t.Errorf("user: got=%q want=%q", resp.User, livreur)
|
||||||
|
}
|
||||||
|
|
||||||
|
alert, err := testDB.GetAlertPolicy(resp.AlertID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAlertPolicy: %v", err)
|
||||||
|
}
|
||||||
|
if alert.Message != "Contrôle en cours" || alert.Status != "true" {
|
||||||
|
t.Errorf("alerte créée: message=%q status=%q", alert.Message, alert.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertPolice_NonLivreurForbidden(t *testing.T) {
|
||||||
|
for _, role := range []string{"client", "admin", "cabine"} {
|
||||||
|
t.Run(role, func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(map[string]string{"message": "test"})
|
||||||
|
c, rec := alertContext(testUserPrefix+"alert_forbidden_"+role, role, body, 0)
|
||||||
|
handlers.AlertPolice(c)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("le rôle %q ne doit pas pouvoir déclencher une alerte police: got=%d", role, rec.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetAlert ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestGetAlert_LivreurCanViewOwnAlert(t *testing.T) {
|
||||||
|
livreur := testUserPrefix + "alert_view_own"
|
||||||
|
alertID := createTestAlert(t, livreur, "test")
|
||||||
|
|
||||||
|
c, rec := alertContext(livreur, "livreur", nil, alertID)
|
||||||
|
handlers.GetAlert(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAlert_LivreurCannotViewOthersAlert(t *testing.T) {
|
||||||
|
owner := testUserPrefix + "alert_view_owner"
|
||||||
|
intruder := testUserPrefix + "alert_view_intruder"
|
||||||
|
alertID := createTestAlert(t, owner, "test")
|
||||||
|
|
||||||
|
c, rec := alertContext(intruder, "livreur", nil, alertID)
|
||||||
|
handlers.GetAlert(c)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("un livreur ne doit pas pouvoir consulter l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAlert_AdminCanViewAnyAlert(t *testing.T) {
|
||||||
|
owner := testUserPrefix + "alert_view_admin_owner"
|
||||||
|
alertID := createTestAlert(t, owner, "test")
|
||||||
|
|
||||||
|
c, rec := alertContext(testUserPrefix+"alert_view_admin", "admin", nil, alertID)
|
||||||
|
handlers.GetAlert(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("un admin doit pouvoir consulter n'importe quelle alerte: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EndAlert ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestEndAlert_OwnerCanEnd(t *testing.T) {
|
||||||
|
livreur := testUserPrefix + "alert_end_owner"
|
||||||
|
alertID := createTestAlert(t, livreur, "test")
|
||||||
|
|
||||||
|
c, rec := alertContext(livreur, "livreur", nil, alertID)
|
||||||
|
handlers.EndAlert(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
alert, _ := testDB.GetAlertPolicy(alertID)
|
||||||
|
if alert.Status != "false" {
|
||||||
|
t.Errorf("statut après EndAlert: got=%q want=false", alert.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndAlert_NonOwnerLivreurRejected(t *testing.T) {
|
||||||
|
owner := testUserPrefix + "alert_end_owner2"
|
||||||
|
intruder := testUserPrefix + "alert_end_intruder"
|
||||||
|
alertID := createTestAlert(t, owner, "test")
|
||||||
|
|
||||||
|
c, rec := alertContext(intruder, "livreur", nil, alertID)
|
||||||
|
handlers.EndAlert(c)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("un livreur tiers ne doit pas pouvoir terminer l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
alert, _ := testDB.GetAlertPolicy(alertID)
|
||||||
|
if alert.Status != "true" {
|
||||||
|
t.Errorf("l'alerte ne doit pas être terminée par un intrus: got=%q want=true", alert.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DeleteAlert ──────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Corrigé : un livreur ne peut supprimer que ses propres alertes (comme
|
||||||
|
// EndAlert) ; un admin garde l'accès complet sans restriction de propriétaire.
|
||||||
|
|
||||||
|
func TestDeleteAlert_OwnerLivreurCanDeleteOwnAlert(t *testing.T) {
|
||||||
|
owner := testUserPrefix + "alert_delete_owner_ok"
|
||||||
|
alertID := createTestAlert(t, owner, "test")
|
||||||
|
|
||||||
|
c, rec := alertContext(owner, "livreur", nil, alertID)
|
||||||
|
handlers.DeleteAlert(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("le propriétaire doit pouvoir supprimer sa propre alerte: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := testDB.GetAlertPolicy(alertID); err == nil {
|
||||||
|
t.Error("l'alerte doit être supprimée")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAlert_NonOwnerLivreurRejected(t *testing.T) {
|
||||||
|
owner := testUserPrefix + "alert_delete_owner"
|
||||||
|
intruder := testUserPrefix + "alert_delete_intruder"
|
||||||
|
alertID := createTestAlert(t, owner, "test")
|
||||||
|
|
||||||
|
c, rec := alertContext(intruder, "livreur", nil, alertID)
|
||||||
|
handlers.DeleteAlert(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("un livreur tiers ne doit pas pouvoir supprimer l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := testDB.GetAlertPolicy(alertID); err != nil {
|
||||||
|
t.Error("l'alerte ne doit pas être supprimée par un intrus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAlert_AdminCanDeleteAnyAlertRegardlessOfOwner(t *testing.T) {
|
||||||
|
owner := testUserPrefix + "alert_delete_admin_owner"
|
||||||
|
alertID := createTestAlert(t, owner, "test")
|
||||||
|
|
||||||
|
c, rec := alertContext(testUserPrefix+"alert_delete_admin", "admin", nil, alertID)
|
||||||
|
handlers.DeleteAlert(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("un admin doit pouvoir supprimer n'importe quelle alerte: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := testDB.GetAlertPolicy(alertID); err == nil {
|
||||||
|
t.Error("l'alerte doit être supprimée par l'admin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAlert_NonLivreurNonAdminForbidden(t *testing.T) {
|
||||||
|
owner := testUserPrefix + "alert_delete_forbidden_owner"
|
||||||
|
alertID := createTestAlert(t, owner, "test")
|
||||||
|
|
||||||
|
c, rec := alertContext(testUserPrefix+"alert_delete_forbidden_cabine", "cabine", nil, alertID)
|
||||||
|
handlers.DeleteAlert(c)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("le rôle cabine ne doit pas pouvoir supprimer une alerte: got=%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Listing ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestGetMyAlerts_ReturnsOnlyOwnAlerts(t *testing.T) {
|
||||||
|
mine := testUserPrefix + "alert_mine"
|
||||||
|
other := testUserPrefix + "alert_other"
|
||||||
|
createTestAlert(t, mine, "à moi 1")
|
||||||
|
createTestAlert(t, mine, "à moi 2")
|
||||||
|
createTestAlert(t, other, "pas à moi")
|
||||||
|
|
||||||
|
c, rec := alertContext(mine, "livreur", nil, 0)
|
||||||
|
handlers.GetMyAlerts(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
if resp.Count != 2 {
|
||||||
|
t.Errorf("nombre d'alertes du livreur: got=%d want=2", resp.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetActiveAlerts_ExcludesEndedAlerts(t *testing.T) {
|
||||||
|
livreur := testUserPrefix + "alert_active_filter"
|
||||||
|
activeID := createTestAlert(t, livreur, "active")
|
||||||
|
endedID := createTestAlert(t, livreur, "terminée")
|
||||||
|
if err := testDB.EndAlert(endedID); err != nil {
|
||||||
|
t.Fatalf("EndAlert (setup): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts, err := testDB.GetActiveAlerts()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetActiveAlerts: %v", err)
|
||||||
|
}
|
||||||
|
var foundActive, foundEnded bool
|
||||||
|
for _, a := range alerts {
|
||||||
|
if a.ID == activeID {
|
||||||
|
foundActive = true
|
||||||
|
}
|
||||||
|
if a.ID == endedID {
|
||||||
|
foundEnded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundActive {
|
||||||
|
t.Error("l'alerte active doit apparaître dans GetActiveAlerts")
|
||||||
|
}
|
||||||
|
if foundEnded {
|
||||||
|
t.Error("l'alerte terminée ne doit pas apparaître dans GetActiveAlerts")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gestion/handlers"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cmdContext(method, username, role string, body []byte, commandID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
|
var reader *bytes.Reader
|
||||||
|
if body != nil {
|
||||||
|
reader = bytes.NewReader(body)
|
||||||
|
} else {
|
||||||
|
reader = bytes.NewReader([]byte{})
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, "/api/v1/commands", reader)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
if username != "" {
|
||||||
|
c.Set("username", username)
|
||||||
|
}
|
||||||
|
c.Set("role", role)
|
||||||
|
if commandID != 0 {
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
|
||||||
|
}
|
||||||
|
return c, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CancelCommandByClient (HTTP layer) ───────────────────────────────────
|
||||||
|
|
||||||
|
func TestCancelCommandByClient_RejectsNonClientRole(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
c, rec := cmdContext(http.MethodPost, testUserPrefix+"cancel_role", "admin", nil, 1)
|
||||||
|
handlers.CancelCommandByClient(c)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("role admin doit être refusé: got=%d want=403", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelCommandByClient_InvalidCommandIDReturns400(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "cancel_badid")
|
||||||
|
c, rec := cmdContext(http.MethodPost, username, "client", nil, 0)
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: "not-a-number"}}
|
||||||
|
handlers.CancelCommandByClient(c)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("ID invalide doit retourner 400: got=%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelCommandByClient_UnknownCommandReturns404(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "cancel_404")
|
||||||
|
c, rec := cmdContext(http.MethodPost, username, "client", nil, 99999999)
|
||||||
|
handlers.CancelCommandByClient(c)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("commande inconnue doit retourner 404: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelCommandByClient_WrongOwnerReturns403(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
owner := newTestClient(t, "cancel_owner")
|
||||||
|
intruder := newTestClient(t, "cancel_intruder")
|
||||||
|
productID := newTestProduct(t, "CancelWrongOwner", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
c, rec := cmdContext(http.MethodPost, intruder, "client", nil, cmdID)
|
||||||
|
handlers.CancelCommandByClient(c)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("un autre client ne doit pas pouvoir annuler: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelCommandByClient_SuccessNoPenaltyWhenNoLivreur(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "cancel_ok_nopenalty")
|
||||||
|
productID := newTestProduct(t, "CancelOkNoPenalty", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID)
|
||||||
|
handlers.CancelCommandByClient(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("annulation sans livreur doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := commandStatus(t, cmdID); got != "cancelled" {
|
||||||
|
t.Errorf("statut après annulation: got=%s want=cancelled", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelCommandByClient_TerminalStatusReturns400(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "cancel_terminal")
|
||||||
|
productID := newTestProduct(t, "CancelTerminal", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
|
||||||
|
|
||||||
|
c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID)
|
||||||
|
handlers.CancelCommandByClient(c)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("annulation d'une commande livrée doit être rejetée: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelCommandByClient_ConfirmationRequiredReturns409WithPenaltyWarning(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "cancel_confirm")
|
||||||
|
livreur := "cancel_confirm_livreur"
|
||||||
|
productID := newTestProduct(t, "CancelConfirm", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "en_route", livreur, productID, 1, 10)
|
||||||
|
if err := testDB.SetCommandETA(cmdID, 14); err != nil {
|
||||||
|
t.Fatalf("SetCommandETA: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID)
|
||||||
|
handlers.CancelCommandByClient(c)
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("annulation tardive sans force doit demander confirmation: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte(`"will_apply":true`)) {
|
||||||
|
t.Errorf("la réponse doit avertir d'une pénalité à venir: body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statut inchangé tant que non confirmé.
|
||||||
|
if got := commandStatus(t, cmdID); got != "en_route" {
|
||||||
|
t.Errorf("statut ne doit pas changer avant confirmation: got=%s want=en_route", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetMyCancellationHistory ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestGetMyCancellationHistory_RejectsNonClientRole(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
c, rec := cmdContext(http.MethodGet, testUserPrefix+"hist_role", "livreur", nil, 0)
|
||||||
|
handlers.GetMyCancellationHistory(c)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMyCancellationHistory_ReturnsHistoryAndTotalPenalties(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "hist_ok")
|
||||||
|
c, rec := cmdContext(http.MethodGet, username, "client", nil, 0)
|
||||||
|
handlers.GetMyCancellationHistory(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("historique doit réussir pour un client: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte(`"total_penalties"`)) {
|
||||||
|
t.Errorf("la réponse doit inclure total_penalties: body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetAllCancelledOrders ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestGetAllCancelledOrders_RejectsNonAdminNonCabine(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
c, rec := cmdContext(http.MethodGet, testUserPrefix+"allcancel_role", "livreur", nil, 0)
|
||||||
|
handlers.GetAllCancelledOrders(c)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAllCancelledOrders_InvalidLimitFallsBackToDefault(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
c, rec := cmdContext(http.MethodGet, testUserPrefix+"allcancel_limit", "admin", nil, 0)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/cancelled?limit=not-a-number", nil)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("username", testUserPrefix+"allcancel_limit")
|
||||||
|
c.Set("role", "admin")
|
||||||
|
handlers.GetAllCancelledOrders(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("limit invalide doit quand même réussir avec un défaut: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAllCancelledOrders_LimitCapsAt500(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/cancelled?limit=99999", nil)
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("username", testUserPrefix+"allcancel_cap")
|
||||||
|
c.Set("role", "admin")
|
||||||
|
handlers.GetAllCancelledOrders(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("limit énorme doit quand même réussir (plafonné): got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DeleteCommandByCabine ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestDeleteCommandByCabine_RejectsNonCabineNonAdmin(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_role", "client", nil, 1)
|
||||||
|
handlers.DeleteCommandByCabine(c)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteCommandByCabine_UnknownCommandReturns404(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_404", "cabine", nil, 99999999)
|
||||||
|
handlers.DeleteCommandByCabine(c)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("commande inconnue doit retourner 404: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteCommandByCabine_SuccessDeletesCommand(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "delcab_ok")
|
||||||
|
productID := newTestProduct(t, "DelCabOk", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_ok_actor", "cabine", nil, cmdID)
|
||||||
|
handlers.DeleteCommandByCabine(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("suppression par cabine doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE id = ?`, cmdID).Scan(&count)
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("la commande doit être supprimée: got=%d lignes restantes", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── validateReason (testé indirectement via CancelCommandByClient) ───────
|
||||||
|
|
||||||
|
func TestCancelCommandByClient_BlankReasonDefaultsToStandardMessage(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "reason_blank")
|
||||||
|
productID := newTestProduct(t, "ReasonBlank", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
body := []byte(`{"reason":""}`)
|
||||||
|
c, rec := cmdContext(http.MethodPost, username, "client", body, cmdID)
|
||||||
|
handlers.CancelCommandByClient(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("annulation avec reason vide doit réussir (validateReason doit fournir un défaut, pas rejeter): got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := commandStatus(t, cmdID); got != "cancelled" {
|
||||||
|
t.Errorf("statut après annulation avec raison vide: got=%s want=cancelled", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// commandAddressState lit adresse/proposed_address/address_proposal_status
|
||||||
|
// directement pour vérifier le flux propose -> respond.
|
||||||
|
type commandAddressState struct {
|
||||||
|
Adresse string `gorm:"column:adresse"`
|
||||||
|
ProposedAddress string `gorm:"column:proposed_address"`
|
||||||
|
AddressProposalStatus string `gorm:"column:address_proposal_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCommandAddressState(t *testing.T, commandID int) commandAddressState {
|
||||||
|
t.Helper()
|
||||||
|
var s commandAddressState
|
||||||
|
if err := testDB.GDB.Raw(
|
||||||
|
`SELECT adresse, COALESCE(proposed_address, '') as proposed_address,
|
||||||
|
COALESCE(address_proposal_status, '') as address_proposal_status
|
||||||
|
FROM commandes WHERE id = ?`, commandID,
|
||||||
|
).Scan(&s).Error; err != nil {
|
||||||
|
t.Fatalf("getCommandAddressState: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UpdateCommandAddress (modification directe admin) ───────────────────────
|
||||||
|
|
||||||
|
func TestUpdateCommandAddress_UpdatesAddress(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "upd_addr_ok")
|
||||||
|
productID := newTestProduct(t, "UpdAddrOk", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
if err := testDB.UpdateCommandAddress(cmdID, "42 Nouvelle Adresse, 44000 Nantes"); err != nil {
|
||||||
|
t.Fatalf("UpdateCommandAddress: %v", err)
|
||||||
|
}
|
||||||
|
if got := getCommandAddressState(t, cmdID).Adresse; got != "42 Nouvelle Adresse, 44000 Nantes" {
|
||||||
|
t.Errorf("adresse après mise à jour: got=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateCommandAddress_RejectsEmptyAddress(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "upd_addr_empty")
|
||||||
|
productID := newTestProduct(t, "UpdAddrEmpty", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
before := getCommandAddressState(t, cmdID).Adresse
|
||||||
|
|
||||||
|
if err := testDB.UpdateCommandAddress(cmdID, " "); err == nil {
|
||||||
|
t.Fatal("attendu un rejet pour une adresse vide/blanche")
|
||||||
|
}
|
||||||
|
if got := getCommandAddressState(t, cmdID).Adresse; got != before {
|
||||||
|
t.Errorf("adresse ne doit pas changer sur un rejet: got=%q want=%q", got, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateCommandAddress_RejectsUnknownCommand(t *testing.T) {
|
||||||
|
if err := testDB.UpdateCommandAddress(999999999, "1 rue inexistante"); err == nil {
|
||||||
|
t.Fatal("attendu une erreur pour une commande inexistante")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note : db.UpdateCommandAddress lui-même n'interdit pas de modifier l'adresse
|
||||||
|
// d'une commande terminée — cette règle ("livre/approved/cancelled interdits")
|
||||||
|
// est uniquement appliquée par le handler HTTP (UpdateCommandAddress dans
|
||||||
|
// handlers/commands.go), pas par la fonction DB. Ce test documente ce fait
|
||||||
|
// explicitement pour qu'un futur appelant direct de la fonction DB (ex. un
|
||||||
|
// script, un worker) ne suppose pas à tort que la protection est là.
|
||||||
|
func TestUpdateCommandAddress_DBFunctionAloneDoesNotBlockTerminalStatuses(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "upd_addr_terminal")
|
||||||
|
productID := newTestProduct(t, "UpdAddrTerminal", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "approved", "", productID, 1, 10)
|
||||||
|
|
||||||
|
if err := testDB.UpdateCommandAddress(cmdID, "Adresse modifiée après coup"); err != nil {
|
||||||
|
t.Fatalf("la fonction DB seule n'impose pas la restriction de statut (attendu, voir commentaire): %v", err)
|
||||||
|
}
|
||||||
|
if got := getCommandAddressState(t, cmdID).Adresse; got != "Adresse modifiée après coup" {
|
||||||
|
t.Errorf("adresse: got=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ProposeAddressChange / RespondToAddressProposal ─────────────────────────
|
||||||
|
|
||||||
|
func TestProposeAddressChange_SetsProposedAddressAndPendingStatus(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "propose_addr_ok")
|
||||||
|
productID := newTestProduct(t, "ProposeAddrOk", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
|
||||||
|
|
||||||
|
if err := testDB.ProposeAddressChange(cmdID, "Nouvelle adresse proposée, 44000 Nantes", "admin_test"); err != nil {
|
||||||
|
t.Fatalf("ProposeAddressChange: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := getCommandAddressState(t, cmdID)
|
||||||
|
if s.ProposedAddress != "Nouvelle adresse proposée, 44000 Nantes" {
|
||||||
|
t.Errorf("proposed_address: got=%q", s.ProposedAddress)
|
||||||
|
}
|
||||||
|
if s.AddressProposalStatus != "pending" {
|
||||||
|
t.Errorf("address_proposal_status: got=%q want=pending", s.AddressProposalStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRespondToAddressProposal_AcceptedAppliesProposedAddress(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "respond_addr_accept")
|
||||||
|
productID := newTestProduct(t, "RespondAddrAccept", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
|
||||||
|
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée acceptée", "admin_test"); err != nil {
|
||||||
|
t.Fatalf("ProposeAddressChange: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := testDB.RespondToAddressProposal(cmdID, username, true); err != nil {
|
||||||
|
t.Fatalf("RespondToAddressProposal (accepté): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := getCommandAddressState(t, cmdID)
|
||||||
|
if s.Adresse != "Adresse proposée acceptée" {
|
||||||
|
t.Errorf("adresse de livraison après acceptation: got=%q want=%q", s.Adresse, "Adresse proposée acceptée")
|
||||||
|
}
|
||||||
|
if s.ProposedAddress != "" {
|
||||||
|
t.Errorf("proposed_address doit être vidé après réponse: got=%q", s.ProposedAddress)
|
||||||
|
}
|
||||||
|
if s.AddressProposalStatus != "accepted" {
|
||||||
|
t.Errorf("address_proposal_status: got=%q want=accepted", s.AddressProposalStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRespondToAddressProposal_RejectedKeepsOriginalAddress(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "respond_addr_reject")
|
||||||
|
productID := newTestProduct(t, "RespondAddrReject", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
|
||||||
|
original := getCommandAddressState(t, cmdID).Adresse
|
||||||
|
|
||||||
|
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée refusée", "admin_test"); err != nil {
|
||||||
|
t.Fatalf("ProposeAddressChange: %v", err)
|
||||||
|
}
|
||||||
|
if err := testDB.RespondToAddressProposal(cmdID, username, false); err != nil {
|
||||||
|
t.Fatalf("RespondToAddressProposal (refusé): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := getCommandAddressState(t, cmdID)
|
||||||
|
if s.Adresse != original {
|
||||||
|
t.Errorf("l'adresse de livraison ne doit pas changer sur un refus: got=%q want=%q", s.Adresse, original)
|
||||||
|
}
|
||||||
|
if s.ProposedAddress != "" {
|
||||||
|
t.Errorf("proposed_address doit être vidé même en cas de refus: got=%q", s.ProposedAddress)
|
||||||
|
}
|
||||||
|
if s.AddressProposalStatus != "rejected" {
|
||||||
|
t.Errorf("address_proposal_status: got=%q want=rejected", s.AddressProposalStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRespondToAddressProposal_FailsWhenNoProposalPending(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "respond_addr_none")
|
||||||
|
productID := newTestProduct(t, "RespondAddrNone", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
|
||||||
|
|
||||||
|
if err := testDB.RespondToAddressProposal(cmdID, username, true); err == nil {
|
||||||
|
t.Fatal("attendu une erreur : aucune proposition en attente")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// La proposition est liée au client propriétaire de la commande : un autre
|
||||||
|
// client ne doit pas pouvoir y répondre à sa place.
|
||||||
|
func TestRespondToAddressProposal_WrongClientCannotRespond(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
owner := newTestClient(t, "respond_addr_owner")
|
||||||
|
intruder := newTestClient(t, "respond_addr_intruder")
|
||||||
|
productID := newTestProduct(t, "RespondAddrIntruder", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, owner, "assigned", "", productID, 1, 10)
|
||||||
|
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée", "admin_test"); err != nil {
|
||||||
|
t.Fatalf("ProposeAddressChange: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := testDB.RespondToAddressProposal(cmdID, intruder, true); err == nil {
|
||||||
|
t.Fatal("un client tiers ne doit pas pouvoir répondre à la proposition d'un autre client")
|
||||||
|
}
|
||||||
|
|
||||||
|
s := getCommandAddressState(t, cmdID)
|
||||||
|
if s.AddressProposalStatus != "pending" {
|
||||||
|
t.Errorf("la proposition doit rester en attente après une tentative d'un intrus: got=%q want=pending", s.AddressProposalStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rejeu (double-tap) : une fois traitée, la même proposition ne doit pas
|
||||||
|
// pouvoir être acceptée/refusée une seconde fois.
|
||||||
|
func TestRespondToAddressProposal_DoubleRespondFails(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "respond_addr_double")
|
||||||
|
productID := newTestProduct(t, "RespondAddrDouble", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
|
||||||
|
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée", "admin_test"); err != nil {
|
||||||
|
t.Fatalf("ProposeAddressChange: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := testDB.RespondToAddressProposal(cmdID, username, true); err != nil {
|
||||||
|
t.Fatalf("1ère réponse: %v", err)
|
||||||
|
}
|
||||||
|
if err := testDB.RespondToAddressProposal(cmdID, username, false); err == nil {
|
||||||
|
t.Fatal("une 2e réponse sur une proposition déjà traitée doit échouer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// La 2e tentative (rejet) ne doit pas être appliquée par-dessus la 1ère (acceptation).
|
||||||
|
if got := getCommandAddressState(t, cmdID).AddressProposalStatus; got != "accepted" {
|
||||||
|
t.Errorf("le statut doit rester celui de la 1ère réponse: got=%q want=accepted", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Adresses réalistes de Nantes (44000) utilisées pour vérifier que l'adresse
|
||||||
|
// de livraison survit intacte à la création puis à toutes les voies de
|
||||||
|
// récupération d'une commande (client, admin, livreur, historique).
|
||||||
|
var nantesAddresses = []string{
|
||||||
|
"12 Rue Crébillon, 44000 Nantes",
|
||||||
|
"3 Place Royale, 44000 Nantes",
|
||||||
|
"5 Cours des 50 Otages, 44000 Nantes",
|
||||||
|
"8 Rue de Verdun, 44000 Nantes",
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestCommandWithAddress crée directement une commande avec une adresse et
|
||||||
|
// un statut contrôlés (en contournant le checkout), pour tester isolément la
|
||||||
|
// récupération de l'adresse par les différentes fonctions de listing.
|
||||||
|
func newTestCommandWithAddress(t *testing.T, username, status, address, livreurAssign string, productID int, quantite, prix float64) int {
|
||||||
|
t.Helper()
|
||||||
|
var cmdID int
|
||||||
|
if err := testDB.GDB.Raw(
|
||||||
|
`INSERT INTO commandes (username, status, adresse, livreur_assign, total_prix, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, NULLIF(?, ''), ?, NOW(), NOW()) RETURNING id`,
|
||||||
|
username, status, address, livreurAssign, prix,
|
||||||
|
).Scan(&cmdID).Error; err != nil {
|
||||||
|
t.Fatalf("création commande test avec adresse: %v", err)
|
||||||
|
}
|
||||||
|
if err := testDB.GDB.Exec(
|
||||||
|
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
|
||||||
|
VALUES (?, ?, 'item test', ?, ?, 'pending')`,
|
||||||
|
cmdID, productID, quantite, prix,
|
||||||
|
).Error; err != nil {
|
||||||
|
t.Fatalf("création item test: %v", err)
|
||||||
|
}
|
||||||
|
return cmdID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le checkout réel (panier -> CreateCommandWithAddress) doit stocker l'adresse
|
||||||
|
// telle quelle, et GetCommandByID doit la restituer à l'identique.
|
||||||
|
func TestCreateCommandWithAddress_RoundTripsRealNantesAddress(t *testing.T) {
|
||||||
|
for _, address := range nantesAddresses {
|
||||||
|
t.Run(address, func(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "addr_checkout")
|
||||||
|
productID := newTestProduct(t, "AddrCheckout", 10)
|
||||||
|
|
||||||
|
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
||||||
|
t.Fatalf("AddToBasket: %v", err)
|
||||||
|
}
|
||||||
|
cmd, err := testDB.CreateCommandWithAddress(username, address)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
command, err := testDB.GetCommandByID(cmd.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCommandByID: %v", err)
|
||||||
|
}
|
||||||
|
got, _ := command["adresse"].(string)
|
||||||
|
if got != address {
|
||||||
|
t.Errorf("adresse récupérée: got=%q want=%q", got, address)
|
||||||
|
}
|
||||||
|
if got == "" {
|
||||||
|
t.Error("l'adresse ne doit jamais être vide")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une adresse vide ou uniquement composée d'espaces doit être rejetée au
|
||||||
|
// checkout — pas de commande créée avec une adresse de livraison absente.
|
||||||
|
func TestCreateCommandWithAddress_RejectsEmptyOrBlankAddress(t *testing.T) {
|
||||||
|
for _, address := range []string{"", " ", "\t\n"} {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "addr_blank")
|
||||||
|
productID := newTestProduct(t, "AddrBlank", 10)
|
||||||
|
|
||||||
|
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
||||||
|
t.Fatalf("AddToBasket: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := testDB.CreateCommandWithAddress(username, address); err == nil {
|
||||||
|
t.Errorf("adresse %q aurait dû être rejetée", address)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&count)
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("aucune commande ne doit être créée avec une adresse %q: got=%d", address, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllCommands (vue admin) doit toujours renvoyer l'adresse de chaque
|
||||||
|
// commande, quel que soit son statut.
|
||||||
|
func TestGetAllCommands_AlwaysIncludesAddress(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "addr_admin_list")
|
||||||
|
productID := newTestProduct(t, "AddrAdminList", 10)
|
||||||
|
|
||||||
|
want := map[int]string{}
|
||||||
|
for i, address := range nantesAddresses {
|
||||||
|
status := []string{"pending", "assigned", "en_route", "livre"}[i%4]
|
||||||
|
cmdID := newTestCommandWithAddress(t, username, status, address, "", productID, 1, 10)
|
||||||
|
want[cmdID] = address
|
||||||
|
}
|
||||||
|
|
||||||
|
commands, err := testDB.GetAllCommands("", username)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAllCommands: %v", err)
|
||||||
|
}
|
||||||
|
if len(commands) != len(want) {
|
||||||
|
t.Fatalf("nombre de commandes: got=%d want=%d", len(commands), len(want))
|
||||||
|
}
|
||||||
|
for _, c := range commands {
|
||||||
|
id, _ := c["id"].(int)
|
||||||
|
address, _ := c["adresse"].(string)
|
||||||
|
if address == "" {
|
||||||
|
t.Errorf("commande %d: adresse vide", id)
|
||||||
|
}
|
||||||
|
if want[id] != address {
|
||||||
|
t.Errorf("commande %d: adresse=%q want=%q", id, address, want[id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeliveryPersonCommands (vue livreur) doit inclure l'adresse de chaque
|
||||||
|
// commande qui lui est assignée.
|
||||||
|
func TestGetDeliveryPersonCommands_IncludesAddress(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "addr_livreur_client")
|
||||||
|
productID := newTestProduct(t, "AddrLivreur", 10)
|
||||||
|
livreurUsername := testUserPrefix + "addr_livreur"
|
||||||
|
address := nantesAddresses[0]
|
||||||
|
|
||||||
|
cmdID := newTestCommandWithAddress(t, username, "assigned", address, livreurUsername, productID, 1, 10)
|
||||||
|
|
||||||
|
commands, err := testDB.GetDeliveryPersonCommands(livreurUsername, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDeliveryPersonCommands: %v", err)
|
||||||
|
}
|
||||||
|
if len(commands) != 1 {
|
||||||
|
t.Fatalf("nombre de commandes assignées: got=%d want=1", len(commands))
|
||||||
|
}
|
||||||
|
got, _ := commands[0]["adresse"].(string)
|
||||||
|
if got != address {
|
||||||
|
t.Errorf("adresse: got=%q want=%q", got, address)
|
||||||
|
}
|
||||||
|
// Le type Go concret de la colonne "id" issue d'un Raw(...).Scan(&[]map[string]any)
|
||||||
|
// n'est pas garanti (int64 selon le driver) — même précaution que le code
|
||||||
|
// de production (ex: handlers/deleviry.go, fmt.Sprintf + strconv.Atoi).
|
||||||
|
id, _ := strconv.Atoi(fmt.Sprintf("%v", commands[0]["id"]))
|
||||||
|
if id != cmdID {
|
||||||
|
t.Errorf("id de commande inattendu: got=%d want=%d", id, cmdID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCancelledCommands doit inclure l'adresse même pour une commande annulée.
|
||||||
|
func TestGetCancelledCommands_IncludesAddress(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "addr_cancelled")
|
||||||
|
productID := newTestProduct(t, "AddrCancelled", 10)
|
||||||
|
address := nantesAddresses[1]
|
||||||
|
|
||||||
|
newTestCommandWithAddress(t, username, "cancelled", address, "", productID, 1, 10)
|
||||||
|
|
||||||
|
commands, err := testDB.GetCancelledCommands(username, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCancelledCommands: %v", err)
|
||||||
|
}
|
||||||
|
if len(commands) != 1 {
|
||||||
|
t.Fatalf("nombre de commandes annulées: got=%d want=1", len(commands))
|
||||||
|
}
|
||||||
|
got, _ := commands[0]["adresse"].(string)
|
||||||
|
if got != address {
|
||||||
|
t.Errorf("adresse: got=%q want=%q", got, address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCompletedCommandsByUsername (historique client) doit inclure l'adresse
|
||||||
|
// des commandes terminées (approved).
|
||||||
|
func TestGetCompletedCommandsByUsername_IncludesAddress(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "addr_history")
|
||||||
|
productID := newTestProduct(t, "AddrHistory", 10)
|
||||||
|
address := nantesAddresses[2]
|
||||||
|
|
||||||
|
newTestCommandWithAddress(t, username, "approved", address, "", productID, 1, 10)
|
||||||
|
|
||||||
|
commands, err := testDB.GetCompletedCommandsByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCompletedCommandsByUsername: %v", err)
|
||||||
|
}
|
||||||
|
if len(commands) != 1 {
|
||||||
|
t.Fatalf("nombre de commandes terminées: got=%d want=1", len(commands))
|
||||||
|
}
|
||||||
|
got, _ := commands[0]["adresse"].(string)
|
||||||
|
if got != address {
|
||||||
|
t.Errorf("adresse: got=%q want=%q", got, address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllCommandsOldestFirst (vue cabine/admin triée) doit également inclure
|
||||||
|
// l'adresse de chaque commande.
|
||||||
|
func TestGetAllCommandsOldestFirst_IncludesAddress(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "addr_oldest_first")
|
||||||
|
productID := newTestProduct(t, "AddrOldestFirst", 10)
|
||||||
|
address := nantesAddresses[3]
|
||||||
|
|
||||||
|
newTestCommandWithAddress(t, username, "pending", address, "", productID, 1, 10)
|
||||||
|
|
||||||
|
commands, err := testDB.GetAllCommandsOldestFirst("", username)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAllCommandsOldestFirst: %v", err)
|
||||||
|
}
|
||||||
|
if len(commands) != 1 {
|
||||||
|
t.Fatalf("nombre de commandes: got=%d want=1", len(commands))
|
||||||
|
}
|
||||||
|
got, _ := commands[0]["adresse"].(string)
|
||||||
|
if got != address {
|
||||||
|
t.Errorf("adresse: got=%q want=%q", got, address)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"gestion/handlers"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ce fichier teste UpdateCommandStatusAdmin (annulation par admin/cabine).
|
||||||
|
// Ce chemin utilisait auparavant deux appels séparés (lecture du statut, puis
|
||||||
|
// restauration du stock hors transaction) — un double-tap ou appel concurrent
|
||||||
|
// pouvait alors rembourser le stock deux fois. Il délègue maintenant à
|
||||||
|
// db.CancelCommandByAdminAtomic, qui verrouille la commande (FOR UPDATE) et
|
||||||
|
// fait remboursement + changement de statut dans une seule transaction,
|
||||||
|
// comme CancelCommandAtomic (client) et CancelDeliveryByLivreurAtomic (livreur).
|
||||||
|
|
||||||
|
func adminCancelContext(commandID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
|
body, _ := json.Marshal(map[string]string{"status": "cancelled"})
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/v2/admin/protected/orders/x/status", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("role", "admin")
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}}
|
||||||
|
return c, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateCommandStatusAdmin_RefundsStockOnCancel(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "admincancel_single")
|
||||||
|
productID := newTestProduct(t, "AdminCancelSingle", 5)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
|
||||||
|
|
||||||
|
c, rec := adminCancelContext(cmdID)
|
||||||
|
handlers.UpdateCommandStatusAdmin(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := productStock(t, productID); got != 8 {
|
||||||
|
t.Errorf("stock après annulation admin (5 initial + 3 remboursés): got=%.2f want=8", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statut déjà terminal (livré) : la commande peut toujours être basculée en
|
||||||
|
// "cancelled" par l'admin (correction), mais le stock ne doit pas être
|
||||||
|
// remboursé une seconde fois puisqu'il a déjà quitté l'entrepôt.
|
||||||
|
func TestUpdateCommandStatusAdmin_DoesNotRefundAlreadyDeliveredOrder(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "admincancel_livre")
|
||||||
|
productID := newTestProduct(t, "AdminCancelLivre", 5)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 3, 30)
|
||||||
|
|
||||||
|
c, rec := adminCancelContext(cmdID)
|
||||||
|
handlers.UpdateCommandStatusAdmin(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := productStock(t, productID); got != 5 {
|
||||||
|
t.Errorf("stock ne doit pas être remboursé pour une commande déjà livrée: got=%.2f want=5", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-tap / retry réseau sur le bouton "annuler" côté admin : régression
|
||||||
|
// du bug corrigé (double remboursement). Barrière pour maximiser le
|
||||||
|
// recouvrement réel entre goroutines.
|
||||||
|
func TestUpdateCommandStatusAdmin_ConcurrentCancelDoesNotDoubleRefundStock(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "admincancel_concurrent")
|
||||||
|
productID := newTestProduct(t, "AdminCancelConcurrent", 5)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
|
||||||
|
|
||||||
|
n := 10
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
start := make(chan struct{})
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
c, _ := adminCancelContext(cmdID)
|
||||||
|
handlers.UpdateCommandStatusAdmin(c)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if got := productStock(t, productID); got != 8 {
|
||||||
|
t.Errorf("stock après %d annulations admin concurrentes de la même commande (5 initial + 3 remboursés une seule fois attendu): got=%.2f", n, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reproduction déterministe de l'ancienne fenêtre de course : deux appels
|
||||||
|
// annulent la même commande l'un juste après l'autre (simulation d'un
|
||||||
|
// double-tap sans dépendre du timing du scheduler). Avec CancelCommandByAdmin
|
||||||
|
// Atomic, le second appel voit la commande déjà 'cancelled' sous verrou et ne
|
||||||
|
// rembourse pas une seconde fois.
|
||||||
|
func TestUpdateCommandStatusAdmin_SequentialDoubleCancelDoesNotDoubleRefund(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "admincancel_sequential")
|
||||||
|
productID := newTestProduct(t, "AdminCancelSequential", 5)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
|
||||||
|
|
||||||
|
if err := testDB.CancelCommandByAdminAtomic(cmdID); err != nil {
|
||||||
|
t.Fatalf("1er appel: %v", err)
|
||||||
|
}
|
||||||
|
if err := testDB.CancelCommandByAdminAtomic(cmdID); err != nil {
|
||||||
|
t.Fatalf("2e appel (doit être idempotent, pas une erreur): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := productStock(t, productID); got != 8 {
|
||||||
|
t.Errorf("stock après double annulation admin: got=%.2f want=8 (un seul remboursement)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gestion/handlers"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestLivreur crée un utilisateur role=livreur réel (AssignDeliveryPerson
|
||||||
|
// vérifie son existence/rôle en base, pas juste le contexte gin) et
|
||||||
|
// programme son nettoyage.
|
||||||
|
func newTestLivreur(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
username := testUserPrefix + name
|
||||||
|
if err := testDB.GDB.Exec(
|
||||||
|
`INSERT INTO users (username, password, role) VALUES (?, 'x', 'livreur') ON CONFLICT (username) DO NOTHING`,
|
||||||
|
username,
|
||||||
|
).Error; err != nil {
|
||||||
|
t.Fatalf("création livreur test %q: %v", username, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
testDB.GDB.Exec(`DELETE FROM users WHERE username = ?`, username)
|
||||||
|
})
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAllCommandsContext(role string, query url.Values) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/commands?"+query.Encode(), nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("username", testUserPrefix+"gac_admin")
|
||||||
|
c.Set("role", role)
|
||||||
|
return c, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetAllCommands ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestGetAllCommands_RejectsNonAdminNonCabine(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
c, rec := getAllCommandsContext("livreur", url.Values{})
|
||||||
|
handlers.GetAllCommands(c)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAllCommands_FiltersByStatusAndUsername(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "gac_filter")
|
||||||
|
productID := newTestProduct(t, "GACFilter", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
otherUser := newTestClient(t, "gac_filter_other")
|
||||||
|
newTestCommandWithItem(t, otherUser, "cancelled", "", productID, 1, 10)
|
||||||
|
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("status", "pending")
|
||||||
|
q.Set("username", username)
|
||||||
|
c, rec := getAllCommandsContext("admin", q)
|
||||||
|
handlers.GetAllCommands(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("requête filtrée doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte(fmt.Sprintf(`"id":%d`, cmdID))) {
|
||||||
|
t.Errorf("la commande filtrée doit apparaître dans le résultat: body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAllCommands_AllSentinelReturnsEverything(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "gac_all")
|
||||||
|
productID := newTestProduct(t, "GACAll", 10)
|
||||||
|
newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/commands/all/all", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("username", testUserPrefix+"gac_admin")
|
||||||
|
c.Set("role", "admin")
|
||||||
|
c.Params = gin.Params{{Key: "status", Value: "all"}, {Key: "username", Value: "all"}}
|
||||||
|
handlers.GetAllCommands(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sentinel 'all' doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AssignDeliveryPerson ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func assignContext(role string, body []byte, commandID int, livreurUsername string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/assign", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("geoService", ensureTestGeoService())
|
||||||
|
c.Set("username", testUserPrefix+"assign_staff")
|
||||||
|
c.Set("role", role)
|
||||||
|
params := gin.Params{{Key: "command_id", Value: fmt.Sprintf("%d", commandID)}}
|
||||||
|
if livreurUsername != "" {
|
||||||
|
params = append(params, gin.Param{Key: "username", Value: livreurUsername})
|
||||||
|
}
|
||||||
|
c.Params = params
|
||||||
|
return c, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssignDeliveryPerson_RejectsNonAdminNonCabine(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
c, rec := assignContext("client", nil, 1, "someone")
|
||||||
|
handlers.AssignDeliveryPerson(c)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssignDeliveryPerson_SupportsCommandIDParam(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "assign_cmdid")
|
||||||
|
livreur := newTestLivreur(t, "assign_cmdid_livreur")
|
||||||
|
productID := newTestProduct(t, "AssignCmdID", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
c, rec := assignContext("admin", nil, cmdID, livreur)
|
||||||
|
handlers.AssignDeliveryPerson(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("assignation via :command_id doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssignDeliveryPerson_SupportsIDParamFallback(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "assign_id")
|
||||||
|
livreur := newTestLivreur(t, "assign_id_livreur")
|
||||||
|
productID := newTestProduct(t, "AssignID", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/cabine/assign", bytes.NewReader([]byte(fmt.Sprintf(`{"livreur_username":%q}`, livreur))))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("geoService", ensureTestGeoService())
|
||||||
|
c.Set("username", testUserPrefix+"assign_staff")
|
||||||
|
c.Set("role", "cabine")
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
|
||||||
|
handlers.AssignDeliveryPerson(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("assignation via :id (fallback cabine) doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetCommandItemsWithDetails ────────────────────────────────────────────
|
||||||
|
|
||||||
|
func itemsDetailedContext(username, role string, commandID int, setUsername bool) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/commands/items", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
if setUsername {
|
||||||
|
c.Set("username", username)
|
||||||
|
}
|
||||||
|
c.Set("role", role)
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
|
||||||
|
return c, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCommandItemsWithDetails_UnauthenticatedReturns401(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
c, rec := itemsDetailedContext("", "client", 1, false)
|
||||||
|
handlers.GetCommandItemsWithDetails(c)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("non authentifié doit retourner 401: got=%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCommandItemsWithDetails_IDORBlockedForOtherClient(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
owner := newTestClient(t, "items_owner")
|
||||||
|
intruder := newTestClient(t, "items_intruder")
|
||||||
|
productID := newTestProduct(t, "ItemsIDOR", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
c, rec := itemsDetailedContext(intruder, "client", cmdID, true)
|
||||||
|
handlers.GetCommandItemsWithDetails(c)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("un autre client ne doit pas accéder aux items: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCommandItemsWithDetails_OwnerCanAccess(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
owner := newTestClient(t, "items_owner_ok")
|
||||||
|
productID := newTestProduct(t, "ItemsOwnerOk", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 2, 20)
|
||||||
|
|
||||||
|
c, rec := itemsDetailedContext(owner, "client", cmdID, true)
|
||||||
|
handlers.GetCommandItemsWithDetails(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("le propriétaire doit accéder à ses items: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCommandItemsWithDetails_NoItemsReturns404(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
c, rec := itemsDetailedContext(testUserPrefix+"items_admin", "admin", 99999999, true)
|
||||||
|
handlers.GetCommandItemsWithDetails(c)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("commande sans item doit retourner 404: got=%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Régression : les changements d'adresse doivent regéocoder dest_latitude/
|
||||||
|
// dest_longitude ──────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Incident réel : ces coordonnées n'étaient géocodées qu'une seule fois, à
|
||||||
|
// l'assignation du livreur. Une correction d'adresse ultérieure ne les
|
||||||
|
// touchait pas, si bien que la vérification GPS de confirmation de livraison
|
||||||
|
// (handlers/deleviry.go) comparait la position réelle du livreur à un point
|
||||||
|
// périmé et pouvait refuser à tort une validation "trop loin de la
|
||||||
|
// destination" alors que le livreur était bien arrivé à la nouvelle adresse.
|
||||||
|
// Ces tests appellent le vrai service de géocodage (Nominatim) — sautés en
|
||||||
|
// mode -short, comme TestResolveAddress_RealNantesAddresses.
|
||||||
|
|
||||||
|
// staleDestCoords sont volontairement celles de Paris : n'importe quelle
|
||||||
|
// adresse de test à Nantes en est assez éloignée pour distinguer un vrai
|
||||||
|
// regéocodage d'une valeur restée périmée.
|
||||||
|
const (
|
||||||
|
staleDestLat = 48.8566
|
||||||
|
staleDestLon = 2.3522
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedStaleDestCoords(t *testing.T, commandID int) {
|
||||||
|
t.Helper()
|
||||||
|
if err := testDB.GDB.Exec(
|
||||||
|
`UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`,
|
||||||
|
staleDestLat, staleDestLon, commandID,
|
||||||
|
).Error; err != nil {
|
||||||
|
t.Fatalf("seedStaleDestCoords: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type destCoords struct {
|
||||||
|
DestLatitude float64 `gorm:"column:dest_latitude"`
|
||||||
|
DestLongitude float64 `gorm:"column:dest_longitude"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDestCoords(t *testing.T, commandID int) destCoords {
|
||||||
|
t.Helper()
|
||||||
|
var c destCoords
|
||||||
|
if err := testDB.GDB.Raw(
|
||||||
|
`SELECT COALESCE(dest_latitude, 0) AS dest_latitude, COALESCE(dest_longitude, 0) AS dest_longitude
|
||||||
|
FROM commandes WHERE id = ?`, commandID,
|
||||||
|
).Scan(&c).Error; err != nil {
|
||||||
|
t.Fatalf("getDestCoords: %v", err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateCommandAddress_Handler_RegeocodesStaleDestinationCoords(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
|
||||||
|
}
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "upd_addr_regeo")
|
||||||
|
productID := newTestProduct(t, "UpdAddrRegeo", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
seedStaleDestCoords(t, cmdID)
|
||||||
|
|
||||||
|
body := []byte(`{"delivery_address":"12 rue Crebillon, 44000 Nantes"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/commands/address", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("geoService", ensureTestGeoService())
|
||||||
|
c.Set("username", testUserPrefix+"upd_addr_regeo_admin")
|
||||||
|
c.Set("role", "admin")
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
|
||||||
|
|
||||||
|
handlers.UpdateCommandAddress(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("UpdateCommandAddress doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
coords := getDestCoords(t, cmdID)
|
||||||
|
if coords.DestLatitude == staleDestLat && coords.DestLongitude == staleDestLon {
|
||||||
|
t.Errorf("dest_latitude/dest_longitude doivent être regéocodées après changement d'adresse, pas rester sur l'ancien point: got=(%v, %v)", coords.DestLatitude, coords.DestLongitude)
|
||||||
|
}
|
||||||
|
if coords.DestLatitude == 0 || coords.DestLongitude == 0 {
|
||||||
|
t.Errorf("le géocodage de la nouvelle adresse a échoué (coordonnées à 0): got=%+v", coords)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRespondToAddressProposal_Handler_AcceptedRegeocodesStaleDestinationCoords(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
|
||||||
|
}
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "respond_addr_regeo")
|
||||||
|
productID := newTestProduct(t, "RespondAddrRegeo", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
|
||||||
|
seedStaleDestCoords(t, cmdID)
|
||||||
|
|
||||||
|
if err := testDB.ProposeAddressChange(cmdID, "12 rue Crebillon, 44000 Nantes", "admin_test"); err != nil {
|
||||||
|
t.Fatalf("ProposeAddressChange: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := []byte(`{"accepted":true}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/commands/address/respond", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("geoService", ensureTestGeoService())
|
||||||
|
c.Set("username", username)
|
||||||
|
c.Set("role", "client")
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
|
||||||
|
|
||||||
|
handlers.RespondToAddressProposal(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("RespondToAddressProposal doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
coords := getDestCoords(t, cmdID)
|
||||||
|
if coords.DestLatitude == staleDestLat && coords.DestLongitude == staleDestLon {
|
||||||
|
t.Errorf("dest_latitude/dest_longitude doivent être regéocodées après acceptation de la proposition, pas rester sur l'ancien point: got=(%v, %v)", coords.DestLatitude, coords.DestLongitude)
|
||||||
|
}
|
||||||
|
if coords.DestLatitude == 0 || coords.DestLongitude == 0 {
|
||||||
|
t.Errorf("le géocodage de l'adresse acceptée a échoué (coordonnées à 0): got=%+v", coords)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateOwnCommandAddress_Handler_RegeocodesStaleDestinationCoords(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
|
||||||
|
}
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "upd_own_addr_regeo")
|
||||||
|
productID := newTestProduct(t, "UpdOwnAddrRegeo", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
seedStaleDestCoords(t, cmdID)
|
||||||
|
|
||||||
|
body := []byte(`{"delivery_address":"12 rue Crebillon, 44000 Nantes"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/commands/address", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("geoService", ensureTestGeoService())
|
||||||
|
c.Set("username", username)
|
||||||
|
c.Set("role", "client")
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
|
||||||
|
|
||||||
|
handlers.UpdateOwnCommandAddress(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("UpdateOwnCommandAddress doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
coords := getDestCoords(t, cmdID)
|
||||||
|
if coords.DestLatitude == staleDestLat && coords.DestLongitude == staleDestLon {
|
||||||
|
t.Errorf("dest_latitude/dest_longitude doivent être regéocodées après auto-correction, pas rester sur l'ancien point: got=(%v, %v)", coords.DestLatitude, coords.DestLongitude)
|
||||||
|
}
|
||||||
|
if coords.DestLatitude == 0 || coords.DestLongitude == 0 {
|
||||||
|
t.Errorf("le géocodage de l'adresse corrigée a échoué (coordonnées à 0): got=%+v", coords)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gestion/handlers"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func deliveryDetailsContext(username string, commandID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/livreur/deliveries/%d", commandID), nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("username", username)
|
||||||
|
c.Set("role", "livreur")
|
||||||
|
return c, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeliveryDetails doit exposer is_reward par item, au même titre que
|
||||||
|
// GetMyDeliveries (la liste) — sans quoi le modal "détails" côté livreur ne
|
||||||
|
// peut pas signaler un article récompense (gratuit ou -50%), ni afficher son
|
||||||
|
// prix effectif correctement.
|
||||||
|
func TestGetDeliveryDetails_ExposesIsRewardPerItem(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
client := newTestClient(t, "delivdetails_client")
|
||||||
|
livreur := newTestClient(t, "delivdetails_livreur")
|
||||||
|
paidProductID := newTestProduct(t, "DelivDetailsPaid", 20)
|
||||||
|
rewardProductID := newTestProduct(t, "DelivDetailsReward", 5)
|
||||||
|
|
||||||
|
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, paidProductID, 1, 10)
|
||||||
|
insertRewardCommandItem(t, cmdID, rewardProductID, 1, 5, "pool_0")
|
||||||
|
|
||||||
|
c, rec := deliveryDetailsContext(livreur, cmdID)
|
||||||
|
handlers.GetDeliveryDetails(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Delivery struct {
|
||||||
|
Items []struct {
|
||||||
|
Produit string `json:"produit"`
|
||||||
|
IsReward bool `json:"is_reward"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"delivery"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("réponse non successful: body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if len(resp.Delivery.Items) != 2 {
|
||||||
|
t.Fatalf("nombre d'items: got=%d want=2", len(resp.Delivery.Items))
|
||||||
|
}
|
||||||
|
|
||||||
|
var sawReward, sawPaid bool
|
||||||
|
for _, it := range resp.Delivery.Items {
|
||||||
|
if it.IsReward {
|
||||||
|
sawReward = true
|
||||||
|
} else {
|
||||||
|
sawPaid = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawReward {
|
||||||
|
t.Errorf("l'item récompense doit avoir is_reward=true dans la réponse: %+v", resp.Delivery.Items)
|
||||||
|
}
|
||||||
|
if !sawPaid {
|
||||||
|
t.Errorf("l'item payant doit avoir is_reward=false dans la réponse: %+v", resp.Delivery.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gestion/handlers"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateDeliveryStatus (handlers/deleviry.go) n'impose plus aucune limite de
|
||||||
|
// distance entre le livreur et la destination pour valider une livraison
|
||||||
|
// (statut "livre") — la vérification GPS a été volontairement retirée pour ne
|
||||||
|
// pas bloquer le livreur (l'imprécision GPS réelle en zone urbaine/immeuble
|
||||||
|
// provoquait des rejets sur des livraisons pourtant légitimes). Les
|
||||||
|
// coordonnées GPS restent obligatoires et la distance est toujours calculée
|
||||||
|
// et loguée à des fins de suivi, mais elle n'entraîne plus de rejet.
|
||||||
|
|
||||||
|
const earthRadiusMeters = 6371000.0
|
||||||
|
|
||||||
|
// destinationPointNorthOf renvoie un point situé à "meters" au nord de
|
||||||
|
// (lat, lon) — même longitude, donc distance ≈ purement le delta de latitude
|
||||||
|
// (formule identique à utils.CalculateDistance pour ce cas particulier).
|
||||||
|
func destinationPointNorthOf(lat, lon, meters float64) (float64, float64) {
|
||||||
|
latOffsetRad := meters / earthRadiusMeters
|
||||||
|
latOffsetDeg := latOffsetRad * (180 / math.Pi)
|
||||||
|
return lat + latOffsetDeg, lon
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCommandDestination(t *testing.T, commandID int, lat, lon float64) {
|
||||||
|
t.Helper()
|
||||||
|
if err := testDB.GDB.Exec(
|
||||||
|
`UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`,
|
||||||
|
lat, lon, commandID,
|
||||||
|
).Error; err != nil {
|
||||||
|
t.Fatalf("setCommandDestination: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliveryStatusContextJSON est la variante de deliveryStatusContext (voir
|
||||||
|
// penalty_test.go) qui accepte un corps JSON arbitraire — nécessaire ici pour
|
||||||
|
// pouvoir passer latitude/longitude, que l'helper existant ne supporte pas.
|
||||||
|
func deliveryStatusContextJSON(username string, commandID int, body []byte) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/livreur/deliveries/%d/status", commandID), bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("username", username)
|
||||||
|
c.Set("role", "livreur")
|
||||||
|
return c, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
const nantesLat, nantesLon = 47.2184, -1.5536
|
||||||
|
|
||||||
|
func TestUpdateDeliveryStatus_GPS_ValidatesDeliveryAtModerateDistance(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
livreur := newTestClient(t, "gps_livreur_within")
|
||||||
|
client := newTestClient(t, "gps_client_within")
|
||||||
|
productID := newTestProduct(t, "GPSWithin", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
|
||||||
|
setCommandDestination(t, cmdID, nantesLat, nantesLon)
|
||||||
|
|
||||||
|
livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 200)
|
||||||
|
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon})
|
||||||
|
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
|
||||||
|
handlers.UpdateDeliveryStatus(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := commandStatus(t, cmdID); got != "livre" {
|
||||||
|
t.Errorf("statut après validation à 200m: got=%s want=livre", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aucune distance, aussi grande soit-elle, ne doit bloquer la validation : la
|
||||||
|
// vérification GPS a été retirée pour ne jamais empêcher un livreur de
|
||||||
|
// marquer une commande "livre".
|
||||||
|
func TestUpdateDeliveryStatus_GPS_FarBeyondOldThresholdStillValidatesDelivery(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
livreur := newTestClient(t, "gps_livreur_far")
|
||||||
|
client := newTestClient(t, "gps_client_far")
|
||||||
|
productID := newTestProduct(t, "GPSFar", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
|
||||||
|
setCommandDestination(t, cmdID, nantesLat, nantesLon)
|
||||||
|
|
||||||
|
livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 5000) // 5km : loin de toute ancienne limite
|
||||||
|
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon})
|
||||||
|
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
|
||||||
|
handlers.UpdateDeliveryStatus(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("aucune distance ne doit bloquer la validation: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := commandStatus(t, cmdID); got != "livre" {
|
||||||
|
t.Errorf("statut après validation à 5km: got=%s want=livre", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateDeliveryStatus_GPS_MissingCoordinatesRejected(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
livreur := newTestClient(t, "gps_livreur_missing_coords")
|
||||||
|
client := newTestClient(t, "gps_client_missing_coords")
|
||||||
|
productID := newTestProduct(t, "GPSMissingCoords", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
|
||||||
|
setCommandDestination(t, cmdID, nantesLat, nantesLon)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{"status": "livre"}) // latitude/longitude absents (zéro)
|
||||||
|
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
|
||||||
|
handlers.UpdateDeliveryStatus(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status HTTP sans coordonnées: got=%d want=%d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := commandStatus(t, cmdID); got != "en_route" {
|
||||||
|
t.Errorf("le statut ne doit pas changer sans coordonnées GPS: got=%s want=en_route", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si la commande n'a pas de coordonnées de destination enregistrées (adresse
|
||||||
|
// non géocodée), la validation GPS est ignorée plutôt que de bloquer le
|
||||||
|
// livreur indéfiniment.
|
||||||
|
func TestUpdateDeliveryStatus_GPS_MissingDestinationCoordinatesSkipsValidation(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
livreur := newTestClient(t, "gps_livreur_no_dest")
|
||||||
|
client := newTestClient(t, "gps_client_no_dest")
|
||||||
|
productID := newTestProduct(t, "GPSNoDest", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
|
||||||
|
// Pas d'appel à setCommandDestination : dest_latitude/dest_longitude restent à 0/NULL.
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": 48.8566, "longitude": 2.3522}) // Paris, sans rapport
|
||||||
|
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
|
||||||
|
handlers.UpdateDeliveryStatus(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sans destination enregistrée, la validation GPS doit être ignorée: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := commandStatus(t, cmdID); got != "livre" {
|
||||||
|
t.Errorf("statut: got=%s want=livre", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateDeliveryStatus_RejectsWhenNotAssignedToThisLivreur(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
assignedLivreur := newTestClient(t, "gps_assigned_livreur")
|
||||||
|
intruder := newTestClient(t, "gps_intruder_livreur")
|
||||||
|
client := newTestClient(t, "gps_client_wrong_livreur")
|
||||||
|
productID := newTestProduct(t, "GPSWrongLivreur", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, client, "en_route", assignedLivreur, productID, 1, 10)
|
||||||
|
setCommandDestination(t, cmdID, nantesLat, nantesLon)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": nantesLat, "longitude": nantesLon})
|
||||||
|
c, rec := deliveryStatusContextJSON(intruder, cmdID, body)
|
||||||
|
handlers.UpdateDeliveryStatus(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("un livreur non assigné doit être rejeté: got=%d want=%d body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := commandStatus(t, cmdID); got != "en_route" {
|
||||||
|
t.Errorf("le statut ne doit pas changer: got=%s want=en_route", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gestion/handlers"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func myDeliveriesContext(username, status string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
|
url := "/api/v1/livreur/deliveries"
|
||||||
|
if status != "" {
|
||||||
|
url += "?status=" + status
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("username", username)
|
||||||
|
c.Set("role", "livreur")
|
||||||
|
return c, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le livreur doit voir qu'un article a bénéficié d'une promotion de prix
|
||||||
|
// (promo_discount > 0), pour pouvoir justifier au client un montant total
|
||||||
|
// inférieur au prix catalogue — voir GetDeliveryDetails/GetMyDeliveries
|
||||||
|
// (backend/gestion/handlers/deleviry.go) et GetCommandItems/GetCommandItemsBatch
|
||||||
|
// (backend/gestion/db/db_command_items.go).
|
||||||
|
func TestGetDeliveryDetails_ExposesPromoDiscountPerItem(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
client := newTestClient(t, "delivpromo_client")
|
||||||
|
livreur := newTestClient(t, "delivpromo_livreur")
|
||||||
|
productID := newTestProduct(t, "DelivPromoDiscount", 20)
|
||||||
|
|
||||||
|
// 3g normalement à 50€, facturés 25€ (-50%) : promo_discount = 25€.
|
||||||
|
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 3, 25)
|
||||||
|
if err := testDB.GDB.Exec(
|
||||||
|
`UPDATE command_items SET promo_discount = 25 WHERE command_id = ? AND product_id = ?`,
|
||||||
|
cmdID, productID,
|
||||||
|
).Error; err != nil {
|
||||||
|
t.Fatalf("mise à jour promo_discount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c, rec := deliveryDetailsContext(livreur, cmdID)
|
||||||
|
handlers.GetDeliveryDetails(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Delivery struct {
|
||||||
|
Items []struct {
|
||||||
|
Produit string `json:"produit"`
|
||||||
|
Prix float64 `json:"prix"`
|
||||||
|
PromoDiscount float64 `json:"promo_discount"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"delivery"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !resp.Success || len(resp.Delivery.Items) != 1 {
|
||||||
|
t.Fatalf("réponse inattendue: body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
item := resp.Delivery.Items[0]
|
||||||
|
if item.PromoDiscount != 25 {
|
||||||
|
t.Errorf("promo_discount doit être exposé au livreur: got=%.2f want=25.00 (body=%s)", item.PromoDiscount, rec.Body.String())
|
||||||
|
}
|
||||||
|
if item.Prix != 25 {
|
||||||
|
t.Errorf("le prix affiché doit rester le prix déjà réduit facturé: got=%.2f want=25.00", item.Prix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Même vérification côté GetMyDeliveries (liste des livraisons), qui passe
|
||||||
|
// par un chemin de requête différent (GetCommandItemsBatch) que
|
||||||
|
// GetDeliveryDetails (GetCommandItems).
|
||||||
|
func TestGetMyDeliveries_ExposesPromoDiscountPerItem(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
client := newTestClient(t, "delivpromo_list_client")
|
||||||
|
livreur := newTestClient(t, "delivpromo_list_livreur")
|
||||||
|
productID := newTestProduct(t, "DelivPromoListDiscount", 20)
|
||||||
|
|
||||||
|
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 3, 25)
|
||||||
|
if err := testDB.GDB.Exec(
|
||||||
|
`UPDATE command_items SET promo_discount = 25 WHERE command_id = ? AND product_id = ?`,
|
||||||
|
cmdID, productID,
|
||||||
|
).Error; err != nil {
|
||||||
|
t.Fatalf("mise à jour promo_discount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c, rec := myDeliveriesContext(livreur, "")
|
||||||
|
handlers.GetMyDeliveries(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Deliveries []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Items []struct {
|
||||||
|
PromoDiscount float64 `json:"promo_discount"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"deliveries"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("réponse non successful: body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var found bool
|
||||||
|
for _, d := range resp.Deliveries {
|
||||||
|
if d.ID != cmdID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(d.Items) != 1 || d.Items[0].PromoDiscount != 25 {
|
||||||
|
t.Fatalf("promo_discount doit être exposé dans GetMyDeliveries: %+v", d.Items)
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("commande %d introuvable dans la réponse: body=%s", cmdID, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"gestion/db"
|
||||||
|
"gestion/models"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestProductWithCategory crée un produit de test avec une catégorie
|
||||||
|
// personnalisée (contrairement à newTestProduct qui pose toujours "test") —
|
||||||
|
// nécessaire ici pour distinguer les catégories dans le routage par livreur.
|
||||||
|
func newTestProductWithCategory(t *testing.T, name, category string, stock float64) int {
|
||||||
|
t.Helper()
|
||||||
|
fullName := testProductPrefix + name
|
||||||
|
var id int
|
||||||
|
if err := testDB.GDB.Raw(
|
||||||
|
`INSERT INTO products (name, category, description, stock) VALUES (?, ?, '', ?) RETURNING id`,
|
||||||
|
fullName, category, stock,
|
||||||
|
).Scan(&id).Error; err != nil {
|
||||||
|
t.Fatalf("création produit test %q: %v", fullName, err)
|
||||||
|
}
|
||||||
|
if err := testDB.GDB.Exec(
|
||||||
|
`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, 1, 10.00, true)`,
|
||||||
|
id,
|
||||||
|
).Error; err != nil {
|
||||||
|
t.Fatalf("création prix produit test %q: %v", fullName, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, id)
|
||||||
|
testDB.GDB.Exec(`DELETE FROM products WHERE id = ?`, id)
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// setLivreurStatus place directement en Redis le statut d'un livreur, comme
|
||||||
|
// le ferait l'app livreur en production (clé "delivery:status:{username}").
|
||||||
|
func setLivreurStatus(t *testing.T, username, status string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.Marshal(models.DeliveryPersonStatus{
|
||||||
|
Username: username,
|
||||||
|
Status: status,
|
||||||
|
LastUpdate: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal DeliveryPersonStatus: %v", err)
|
||||||
|
}
|
||||||
|
key := "delivery:status:" + username
|
||||||
|
if err := db.Redis.Set(db.RedisCtx, key, data, 0).Err(); err != nil {
|
||||||
|
t.Fatalf("setLivreurStatus: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Redis.Del(db.RedisCtx, key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDeliveryModeSettings(t *testing.T, mode models.DeliveryModeConfig) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.Marshal(mode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal delivery_mode: %v", err)
|
||||||
|
}
|
||||||
|
if err := testDB.GDB.Exec(
|
||||||
|
`INSERT INTO app_settings (key, value) VALUES ('delivery_mode', ?)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||||
|
string(data),
|
||||||
|
).Error; err != nil {
|
||||||
|
t.Fatalf("setDeliveryModeSettings: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'delivery_mode'`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsUsername(list []string, username string) bool {
|
||||||
|
for _, u := range list {
|
||||||
|
if u == username {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetCommandCategories ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestGetCommandCategories_ReturnsDistinctProductCategories(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "delivmode_categories")
|
||||||
|
productA := newTestProductWithCategory(t, "CatA", "cat_a", 10)
|
||||||
|
productB := newTestProductWithCategory(t, "CatB", "cat_b", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productA, 1, 10)
|
||||||
|
testDB.GDB.Exec(
|
||||||
|
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) VALUES (?, ?, 'item2', 1, 10, 'pending')`,
|
||||||
|
cmdID, productB,
|
||||||
|
)
|
||||||
|
|
||||||
|
categories, err := testDB.GetCommandCategories(cmdID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCommandCategories: %v", err)
|
||||||
|
}
|
||||||
|
if len(categories) != 2 || !containsUsername(categories, "cat_a") || !containsUsername(categories, "cat_b") {
|
||||||
|
t.Errorf("catégories: got=%v want=[cat_a cat_b]", categories)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetEligibleDeliverymenForCommand ─────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestGetEligibleDeliverymenForCommand_SingleModeReturnsAllActive(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "delivmode_single")
|
||||||
|
productID := newTestProductWithCategory(t, "Single", "cat_a", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
livreurA := testUserPrefix + "delivmode_single_a"
|
||||||
|
livreurB := testUserPrefix + "delivmode_single_b"
|
||||||
|
setLivreurStatus(t, livreurA, "available")
|
||||||
|
setLivreurStatus(t, livreurB, "available")
|
||||||
|
setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "single"})
|
||||||
|
|
||||||
|
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
|
||||||
|
}
|
||||||
|
if !containsUsername(eligible, livreurA) || !containsUsername(eligible, livreurB) {
|
||||||
|
t.Errorf("mode single doit renvoyer tous les livreurs actifs: got=%v", eligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEligibleDeliverymenForCommand_CategoryBasedFiltersToMatchingRoute(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "delivmode_filter")
|
||||||
|
productID := newTestProductWithCategory(t, "Filter", "cat_a", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
livreurA := testUserPrefix + "delivmode_filter_a"
|
||||||
|
livreurB := testUserPrefix + "delivmode_filter_b"
|
||||||
|
setLivreurStatus(t, livreurA, "available")
|
||||||
|
setLivreurStatus(t, livreurB, "available")
|
||||||
|
setDeliveryModeSettings(t, models.DeliveryModeConfig{
|
||||||
|
Mode: "category_based",
|
||||||
|
CategoryRoutes: []models.CategoryRoute{
|
||||||
|
{DeliverymanUsername: livreurA, Categories: []string{"cat_a"}},
|
||||||
|
{DeliverymanUsername: livreurB, Categories: []string{"cat_b"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
|
||||||
|
}
|
||||||
|
if !containsUsername(eligible, livreurA) {
|
||||||
|
t.Errorf("livreurA (cat_a) doit être éligible: got=%v", eligible)
|
||||||
|
}
|
||||||
|
if containsUsername(eligible, livreurB) {
|
||||||
|
t.Errorf("livreurB (cat_b, non commandée) ne doit pas être éligible: got=%v", eligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cas limite documenté explicitement dans le modèle métier : une commande
|
||||||
|
// mixte (catégories relevant de livreurs différents) doit renvoyer l'UNION
|
||||||
|
// des livreurs éligibles, pas une intersection (aucun livreur unique ne gère
|
||||||
|
// forcément toutes les catégories à la fois).
|
||||||
|
func TestGetEligibleDeliverymenForCommand_MixedCategoryCommand_ReturnsUnion(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "delivmode_mixed")
|
||||||
|
productA := newTestProductWithCategory(t, "MixedA", "cat_a", 10)
|
||||||
|
productB := newTestProductWithCategory(t, "MixedB", "cat_b", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productA, 1, 10)
|
||||||
|
testDB.GDB.Exec(
|
||||||
|
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) VALUES (?, ?, 'item2', 1, 10, 'pending')`,
|
||||||
|
cmdID, productB,
|
||||||
|
)
|
||||||
|
|
||||||
|
livreurA := testUserPrefix + "delivmode_mixed_a"
|
||||||
|
livreurB := testUserPrefix + "delivmode_mixed_b"
|
||||||
|
setLivreurStatus(t, livreurA, "available")
|
||||||
|
setLivreurStatus(t, livreurB, "available")
|
||||||
|
setDeliveryModeSettings(t, models.DeliveryModeConfig{
|
||||||
|
Mode: "category_based",
|
||||||
|
CategoryRoutes: []models.CategoryRoute{
|
||||||
|
{DeliverymanUsername: livreurA, Categories: []string{"cat_a"}},
|
||||||
|
{DeliverymanUsername: livreurB, Categories: []string{"cat_b"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
|
||||||
|
}
|
||||||
|
if !containsUsername(eligible, livreurA) || !containsUsername(eligible, livreurB) {
|
||||||
|
t.Errorf("commande mixte cat_a+cat_b doit renvoyer l'union des deux livreurs: got=%v", eligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEligibleDeliverymenForCommand_NoRouteMatchesFallsBackToAllActive(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "delivmode_nomatch")
|
||||||
|
productID := newTestProductWithCategory(t, "NoMatch", "cat_c", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
livreurA := testUserPrefix + "delivmode_nomatch_a"
|
||||||
|
setLivreurStatus(t, livreurA, "available")
|
||||||
|
setDeliveryModeSettings(t, models.DeliveryModeConfig{
|
||||||
|
Mode: "category_based",
|
||||||
|
CategoryRoutes: []models.CategoryRoute{
|
||||||
|
{DeliverymanUsername: livreurA, Categories: []string{"cat_a"}}, // ne couvre pas cat_c
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
|
||||||
|
}
|
||||||
|
if !containsUsername(eligible, livreurA) {
|
||||||
|
t.Errorf("aucune route ne couvre cat_c -> repli sur tous les livreurs actifs: got=%v", eligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEligibleDeliverymenForCommand_EmptyCategoryRoutesFallsBackToAllActive(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "delivmode_emptyroutes")
|
||||||
|
productID := newTestProductWithCategory(t, "EmptyRoutes", "cat_a", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
livreurA := testUserPrefix + "delivmode_emptyroutes_a"
|
||||||
|
setLivreurStatus(t, livreurA, "available")
|
||||||
|
setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "category_based", CategoryRoutes: []models.CategoryRoute{}})
|
||||||
|
|
||||||
|
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
|
||||||
|
}
|
||||||
|
if !containsUsername(eligible, livreurA) {
|
||||||
|
t.Errorf("category_based sans route configurée -> repli sur tous les livreurs actifs: got=%v", eligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEligibleDeliverymenForCommand_OfflineLivreurNeverEligible(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "delivmode_offline")
|
||||||
|
productID := newTestProductWithCategory(t, "Offline", "cat_a", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
onlineLivreur := testUserPrefix + "delivmode_offline_online"
|
||||||
|
offlineLivreur := testUserPrefix + "delivmode_offline_offline"
|
||||||
|
setLivreurStatus(t, onlineLivreur, "available")
|
||||||
|
setLivreurStatus(t, offlineLivreur, "offline")
|
||||||
|
setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "single"})
|
||||||
|
|
||||||
|
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
|
||||||
|
}
|
||||||
|
if containsUsername(eligible, offlineLivreur) {
|
||||||
|
t.Errorf("un livreur offline ne doit jamais être éligible: got=%v", eligible)
|
||||||
|
}
|
||||||
|
if !containsUsername(eligible, onlineLivreur) {
|
||||||
|
t.Errorf("le livreur en ligne doit être éligible: got=%v", eligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gestion/db"
|
||||||
|
"gestion/handlers"
|
||||||
|
"gestion/services"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// db.Redis n'est initialisé qu'après TestMain (db.InitRedis) — un var
|
||||||
|
// package-level serait construit trop tôt, d'où cette init paresseuse.
|
||||||
|
var testGeoService *services.GeoService
|
||||||
|
|
||||||
|
// ensureTestGeoService initialise paresseusement le GeoService partagé
|
||||||
|
// (db.Redis n'existe qu'après TestMain) — réutilisé par les autres fichiers
|
||||||
|
// de tests qui ont besoin de "geoService" dans le contexte gin.
|
||||||
|
func ensureTestGeoService() *services.GeoService {
|
||||||
|
if testGeoService == nil {
|
||||||
|
testGeoService = services.NewGeoService(db.Redis, db.RedisCtx)
|
||||||
|
}
|
||||||
|
return testGeoService
|
||||||
|
}
|
||||||
|
|
||||||
|
func etaContext(username, role string, commandID int, setUsername bool) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/eta", bytes.NewReader(nil))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("geoService", ensureTestGeoService())
|
||||||
|
if setUsername {
|
||||||
|
c.Set("username", username)
|
||||||
|
}
|
||||||
|
c.Set("role", role)
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
|
||||||
|
return c, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOrderETA_UnauthenticatedReturns401(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
c, rec := etaContext("", "client", 1, false)
|
||||||
|
handlers.GetOrderETA(c)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("non authentifié doit retourner 401: got=%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOrderETA_InvalidCommandIDReturns400(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
c, rec := etaContext(testUserPrefix+"eta_badid", "client", 0, true)
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: "not-a-number"}}
|
||||||
|
handlers.GetOrderETA(c)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("ID invalide doit retourner 400: got=%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOrderETA_UnknownCommandReturns404(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
c, rec := etaContext(testUserPrefix+"eta_404", "client", 99999999, true)
|
||||||
|
handlers.GetOrderETA(c)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("commande inconnue doit retourner 404: got=%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOrderETA_ClientAccessingAnotherClientCommandReturns403(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
owner := newTestClient(t, "eta_owner")
|
||||||
|
intruder := newTestClient(t, "eta_intruder")
|
||||||
|
productID := newTestProduct(t, "EtaOwner", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_livreur", productID, 1, 10)
|
||||||
|
|
||||||
|
c, rec := etaContext(intruder, "client", cmdID, true)
|
||||||
|
handlers.GetOrderETA(c)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("un autre client ne doit pas accéder à l'ETA: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOrderETA_LivreurNotAssignedReturns403(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
owner := newTestClient(t, "eta_lv_owner")
|
||||||
|
productID := newTestProduct(t, "EtaLvOwner", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_real_livreur", productID, 1, 10)
|
||||||
|
|
||||||
|
c, rec := etaContext("eta_other_livreur", "livreur", cmdID, true)
|
||||||
|
handlers.GetOrderETA(c)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("un livreur non assigné ne doit pas accéder à l'ETA: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOrderETA_AdminBypassesOwnershipChecks(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
owner := newTestClient(t, "eta_admin_owner")
|
||||||
|
productID := newTestProduct(t, "EtaAdminOwner", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
c, rec := etaContext(testUserPrefix+"eta_admin", "admin", cmdID, true)
|
||||||
|
handlers.GetOrderETA(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("admin doit pouvoir accéder à l'ETA de n'importe quelle commande: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOrderETA_DeliveredStatusReturnsEtaUnavailable(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
owner := newTestClient(t, "eta_delivered")
|
||||||
|
productID := newTestProduct(t, "EtaDelivered", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, owner, "livre", "eta_livreur_d", productID, 1, 10)
|
||||||
|
|
||||||
|
c, rec := etaContext(owner, "client", cmdID, true)
|
||||||
|
handlers.GetOrderETA(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("statut livre doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
|
||||||
|
t.Errorf("eta_available doit être false pour une commande livrée: body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOrderETA_PendingStatusReturnsEtaUnavailable(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
owner := newTestClient(t, "eta_pending")
|
||||||
|
productID := newTestProduct(t, "EtaPending", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
|
||||||
|
|
||||||
|
c, rec := etaContext(owner, "client", cmdID, true)
|
||||||
|
handlers.GetOrderETA(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("statut pending doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
|
||||||
|
t.Errorf("eta_available doit être false pour une commande pending: body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOrderETA_ArrivedStatusReturnsEtaUnavailable(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
owner := newTestClient(t, "eta_arrived")
|
||||||
|
productID := newTestProduct(t, "EtaArrived", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, owner, "arrived", "eta_livreur_a", productID, 1, 10)
|
||||||
|
|
||||||
|
c, rec := etaContext(owner, "client", cmdID, true)
|
||||||
|
handlers.GetOrderETA(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("statut arrived doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
|
||||||
|
t.Errorf("eta_available doit être false quand le livreur est arrivé: body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOrderETA_NoLivreurAssignedReturnsEtaUnavailable(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
owner := newTestClient(t, "eta_nolivreur")
|
||||||
|
productID := newTestProduct(t, "EtaNoLivreur", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, owner, "en_route", "", productID, 1, 10)
|
||||||
|
|
||||||
|
c, rec := etaContext(owner, "client", cmdID, true)
|
||||||
|
handlers.GetOrderETA(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sans livreur assigné doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
|
||||||
|
t.Errorf("eta_available doit être false sans livreur assigné: body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sans coordonnées de destination et sans cache Redis préalable, le
|
||||||
|
// handler doit tomber sur returnStaleOrUnavailable plutôt que planter.
|
||||||
|
func TestGetOrderETA_MissingDestinationCoordsFallsBackGracefully(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
owner := newTestClient(t, "eta_nodest")
|
||||||
|
productID := newTestProduct(t, "EtaNoDest", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_livreur_nodest", productID, 1, 10)
|
||||||
|
|
||||||
|
c, rec := etaContext(owner, "client", cmdID, true)
|
||||||
|
handlers.GetOrderETA(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sans coordonnées destination doit quand même retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
|
||||||
|
t.Errorf("eta_available doit être false sans coordonnées destination: body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gestion/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deux points à Nantes séparés d'environ 1.1km à vol d'oiseau (Haversine).
|
||||||
|
var (
|
||||||
|
nantesCentre = services.Coordinates{Latitude: 47.2184, Longitude: -1.5536}
|
||||||
|
nantesProche = services.Coordinates{Latitude: 47.2280, Longitude: -1.5536}
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCalculateDistance_HaversineCorrectness(t *testing.T) {
|
||||||
|
dist := services.CalculateDistance(nantesCentre, nantesProche)
|
||||||
|
// ~0.0096 rad de latitude ≈ 1.067km — tolérance large pour la formule.
|
||||||
|
if dist < 0.9 || dist > 1.3 {
|
||||||
|
t.Errorf("distance Haversine hors plage attendue: got=%.3fkm want≈1.07km", dist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateDistance_SamePointIsZero(t *testing.T) {
|
||||||
|
dist := services.CalculateDistance(nantesCentre, nantesCentre)
|
||||||
|
if dist != 0 {
|
||||||
|
t.Errorf("distance entre un point et lui-même doit être 0: got=%.4f", dist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateETA_UnderPointOneKmReturnsMinETA(t *testing.T) {
|
||||||
|
eta := services.CalculateETA(0.05)
|
||||||
|
if eta != services.MinETA {
|
||||||
|
t.Errorf("distance < 0.1km doit retourner MinETA: got=%d want=%d", eta, services.MinETA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateETA_AppliesTwentyPercentTrafficMargin(t *testing.T) {
|
||||||
|
// 25km à 25km/h = 60min ; +20% marge = 72min (dans les bornes [MinETA, MaxETA]).
|
||||||
|
eta := services.CalculateETA(25)
|
||||||
|
want := 72
|
||||||
|
if eta != want {
|
||||||
|
t.Errorf("ETA avec marge trafic 20%%: got=%d want=%d", eta, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateETA_ClampsToMaxETA(t *testing.T) {
|
||||||
|
eta := services.CalculateETA(1000)
|
||||||
|
if eta != services.MaxETA {
|
||||||
|
t.Errorf("très longue distance doit être plafonnée à MaxETA: got=%d want=%d", eta, services.MaxETA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateETA_ClampsToMinETA(t *testing.T) {
|
||||||
|
// distance faible mais non nulle, donnant un temps de trajet < MinETA
|
||||||
|
// après calcul (pas la branche <0.1km, une distance différente).
|
||||||
|
eta := services.CalculateETA(0.5)
|
||||||
|
if eta < services.MinETA {
|
||||||
|
t.Errorf("ETA ne doit jamais être inférieur à MinETA: got=%d want>=%d", eta, services.MinETA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sans clé API TomTom configurée (cas de cet environnement de test),
|
||||||
|
// CalculateETAWithTomTom doit retomber sur le calcul local identique à
|
||||||
|
// CalculateDistance+CalculateETA.
|
||||||
|
func TestCalculateETAWithTomTom_FallsBackToLocalCalcWithoutAPIKey(t *testing.T) {
|
||||||
|
etaMinutes, distanceKm, err := services.CalculateETAWithTomTom(nantesCentre, nantesProche)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fallback local ne doit pas retourner d'erreur: %v", err)
|
||||||
|
}
|
||||||
|
wantDistance := services.CalculateDistance(nantesCentre, nantesProche)
|
||||||
|
wantETA := services.CalculateETA(wantDistance)
|
||||||
|
if math.Abs(distanceKm-wantDistance) > 0.0001 {
|
||||||
|
t.Errorf("distance fallback: got=%.4f want=%.4f", distanceKm, wantDistance)
|
||||||
|
}
|
||||||
|
if etaMinutes != wantETA {
|
||||||
|
t.Errorf("eta fallback: got=%d want=%d", etaMinutes, wantETA)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestGetLastDeliveryCoords_NoPreviousDeliveryReturnsError(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
_, _, err := testDB.GetLastDeliveryCoords(testUserPrefix + "coords_nolivraison")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("sans livraison précédente, une erreur est attendue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLastDeliveryCoords_FallsBackToDBWhenNoCache(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "coords_db")
|
||||||
|
livreur := testUserPrefix + "coords_db_livreur"
|
||||||
|
productID := newTestProduct(t, "CoordsDB", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "livre", livreur, productID, 1, 10)
|
||||||
|
testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 47.2184, dest_longitude = -1.5536, updated_at = NOW() WHERE id = ?`, cmdID)
|
||||||
|
|
||||||
|
lat, lon, err := testDB.GetLastDeliveryCoords(livreur)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLastDeliveryCoords: %v", err)
|
||||||
|
}
|
||||||
|
if lat != 47.2184 || lon != -1.5536 {
|
||||||
|
t.Errorf("coordonnées depuis la DB: got=(%.4f,%.4f) want=(47.2184,-1.5536)", lat, lon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une fois les coordonnées lues depuis la DB, elles sont mises en cache
|
||||||
|
// Redis — un second appel doit renvoyer la valeur cachée même si la DB
|
||||||
|
// change entretemps (TTL non expiré).
|
||||||
|
func TestGetLastDeliveryCoords_CachesResultInRedis(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "coords_cache")
|
||||||
|
livreur := testUserPrefix + "coords_cache_livreur"
|
||||||
|
productID := newTestProduct(t, "CoordsCache", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "livre", livreur, productID, 1, 10)
|
||||||
|
testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 48.8566, dest_longitude = 2.3522, updated_at = NOW() WHERE id = ?`, cmdID)
|
||||||
|
|
||||||
|
lat1, _, err := testDB.GetLastDeliveryCoords(livreur)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("premier appel: %v", err)
|
||||||
|
}
|
||||||
|
if lat1 != 48.8566 {
|
||||||
|
t.Fatalf("premier appel doit lire la DB: got lat=%.4f want=48.8566", lat1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change la valeur en DB — un cache-hit doit ignorer ce changement.
|
||||||
|
testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 0, dest_longitude = 0 WHERE id = ?`, cmdID)
|
||||||
|
|
||||||
|
lat2, lon2, err := testDB.GetLastDeliveryCoords(livreur)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second appel (cache attendu): %v", err)
|
||||||
|
}
|
||||||
|
if lat2 != 48.8566 || lon2 != 2.3522 {
|
||||||
|
t.Errorf("second appel doit retourner la valeur cachée, pas la DB modifiée: got=(%.4f,%.4f) want=(48.8566,2.3522)", lat2, lon2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"gestion/db"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const notificationsScheduledKey = "notifications:scheduled"
|
||||||
|
|
||||||
|
// ScheduleETANotifications programme un rappel 5min et 3min avant l'arrivée
|
||||||
|
// estimée — mais seulement si l'ETA le justifie (voir bornes ci-dessous).
|
||||||
|
func TestScheduleETANotifications_SchedulesBothForLongETA(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "eta_notif_long")
|
||||||
|
productID := newTestProduct(t, "EtaNotifLong", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
if err := testDB.ScheduleETANotifications(cmdID, 10); err != nil {
|
||||||
|
t.Fatalf("ScheduleETANotifications: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
score5, err := getScheduledScore(t, cmdID, "5min")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("notification 5min absente: %v", err)
|
||||||
|
}
|
||||||
|
score3, err := getScheduledScore(t, cmdID, "3min")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("notification 3min absente: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantAt5 := before.Add(5 * time.Minute).Unix() // arrivée dans 10min - 5min = dans 5min
|
||||||
|
wantAt3 := before.Add(7 * time.Minute).Unix() // arrivée dans 10min - 3min = dans 7min
|
||||||
|
if diff := abs64(score5 - wantAt5); diff > 2 {
|
||||||
|
t.Errorf("score notification 5min: got=%d want≈%d (écart %ds)", score5, wantAt5, diff)
|
||||||
|
}
|
||||||
|
if diff := abs64(score3 - wantAt3); diff > 2 {
|
||||||
|
t.Errorf("score notification 3min: got=%d want≈%d (écart %ds)", score3, wantAt3, diff)
|
||||||
|
}
|
||||||
|
if score3 <= score5 {
|
||||||
|
t.Errorf("la notification 3min doit être programmée après la 5min (plus proche de l'arrivée): score5=%d score3=%d", score5, score3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// À la limite exacte (ETA = 5 min), un rappel "5 minutes avant l'arrivée"
|
||||||
|
// se déclencherait immédiatement (redondant) — il n'est donc volontairement
|
||||||
|
// pas programmé (condition stricte ">5", pas ">=5"). Seul le rappel 3min
|
||||||
|
// reste pertinent.
|
||||||
|
func TestScheduleETANotifications_ExactlyFiveMinutes_SkipsFiveMinReminder(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "eta_notif_five")
|
||||||
|
productID := newTestProduct(t, "EtaNotifFive", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||||
|
|
||||||
|
if err := testDB.ScheduleETANotifications(cmdID, 5); err != nil {
|
||||||
|
t.Fatalf("ScheduleETANotifications: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
|
||||||
|
t.Error("aucune notification 5min ne doit être programmée quand ETA=5min exactement")
|
||||||
|
}
|
||||||
|
if _, err := getScheduledScore(t, cmdID, "3min"); err != nil {
|
||||||
|
t.Errorf("la notification 3min doit être programmée quand ETA=5min: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// À ETA = 3 min, même le rappel "3 minutes avant" serait immédiat : aucune
|
||||||
|
// notification ne doit être programmée.
|
||||||
|
func TestScheduleETANotifications_ExactlyThreeMinutes_SchedulesNothing(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "eta_notif_three")
|
||||||
|
productID := newTestProduct(t, "EtaNotifThree", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||||
|
|
||||||
|
if err := testDB.ScheduleETANotifications(cmdID, 3); err != nil {
|
||||||
|
t.Fatalf("ScheduleETANotifications: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
|
||||||
|
t.Error("aucune notification 5min ne doit être programmée pour ETA=3min")
|
||||||
|
}
|
||||||
|
if _, err := getScheduledScore(t, cmdID, "3min"); err == nil {
|
||||||
|
t.Error("aucune notification 3min ne doit être programmée pour ETA=3min (serait immédiate)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une ETA très courte (MinETA=3min, plancher de tout le système) ne doit
|
||||||
|
// jamais programmer de notification de rappel — cohérent avec le cas ci-dessus.
|
||||||
|
func TestScheduleETANotifications_VeryShortETASchedulesNothing(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "eta_notif_short")
|
||||||
|
productID := newTestProduct(t, "EtaNotifShort", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||||
|
|
||||||
|
if err := testDB.ScheduleETANotifications(cmdID, 1); err != nil {
|
||||||
|
t.Fatalf("ScheduleETANotifications: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
|
||||||
|
t.Error("aucune notification ne doit être programmée pour une ETA d'1 minute")
|
||||||
|
}
|
||||||
|
if _, err := getScheduledScore(t, cmdID, "3min"); err == nil {
|
||||||
|
t.Error("aucune notification ne doit être programmée pour une ETA d'1 minute")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCommandETA (appelée par UpdateDeliveryStatus au passage en "en_route")
|
||||||
|
// déclenche automatiquement ScheduleETANotifications — vérifie l'intégration
|
||||||
|
// complète, pas seulement la fonction isolée.
|
||||||
|
func TestSetCommandETA_TriggersScheduledNotifications(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "eta_notif_integration")
|
||||||
|
productID := newTestProduct(t, "EtaNotifIntegration", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||||
|
|
||||||
|
if err := testDB.SetCommandETA(cmdID, 15); err != nil {
|
||||||
|
t.Fatalf("SetCommandETA: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := getScheduledScore(t, cmdID, "5min"); err != nil {
|
||||||
|
t.Errorf("SetCommandETA doit programmer une notification 5min: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := getScheduledScore(t, cmdID, "3min"); err != nil {
|
||||||
|
t.Errorf("SetCommandETA doit programmer une notification 3min: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le message envoyé au client doit contenir une indication de temps lisible,
|
||||||
|
// pas juste le statut brut.
|
||||||
|
func TestSendETANotification_MessageContainsReadableTime(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "eta_notif_message")
|
||||||
|
productID := newTestProduct(t, "EtaNotifMessage", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||||
|
|
||||||
|
channel := fmt.Sprintf("notifications:command:%d", cmdID)
|
||||||
|
pubsub := db.Redis.Subscribe(db.RedisCtx, channel)
|
||||||
|
defer pubsub.Close()
|
||||||
|
// Consommer le message de confirmation d'abonnement avant de publier.
|
||||||
|
if _, err := pubsub.Receive(db.RedisCtx); err != nil {
|
||||||
|
t.Fatalf("abonnement pubsub: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testDB.SendETANotification(cmdID, "5min")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case msg := <-pubsub.Channel():
|
||||||
|
if msg.Payload == "" {
|
||||||
|
t.Fatal("message de notification vide")
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.Payload, "5min") {
|
||||||
|
t.Errorf("le message doit indiquer le temps restant (%q): %q", "5min", msg.Payload)
|
||||||
|
}
|
||||||
|
t.Logf("message reçu: %q", msg.Payload)
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("aucun message reçu sur le canal de notification dans le délai imparti")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs64(n int64) int64 {
|
||||||
|
if n < 0 {
|
||||||
|
return -n
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// getScheduledScore lit le score (timestamp Unix) d'une notification
|
||||||
|
// programmée pour "{commandID}:{suffix}" dans le sorted set Redis.
|
||||||
|
func getScheduledScore(t *testing.T, commandID int, suffix string) (int64, error) {
|
||||||
|
t.Helper()
|
||||||
|
member := fmt.Sprintf("%d:%s", commandID, suffix)
|
||||||
|
score, err := db.Redis.ZScore(db.RedisCtx, notificationsScheduledKey, member).Result()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return int64(score), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"gestion/handlers"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Les clients ont signalé ne jamais voir le temps de livraison (notifications,
|
||||||
|
// app mobile, site web). Ces tests reproduisent le chemin réel : une commande
|
||||||
|
// est assignée automatiquement (le worker de queue appelle
|
||||||
|
// SetCommandETAWithDetails, PAS SetCommandETA), puis un client consulte le
|
||||||
|
// suivi de sa commande — exactement ce que fait l'app mobile / le site web.
|
||||||
|
|
||||||
|
func etaTestContext(username string, commandID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/commands/x/tracking", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("username", username)
|
||||||
|
c.Set("role", "client")
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}}
|
||||||
|
return c, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCommandETAWithDetails est le chemin utilisé par le worker
|
||||||
|
// d'auto-assignation/réoptimisation de queue (db/redis_queue_optimization.go,
|
||||||
|
// db/redis_queue_assignment.go) — de loin le plus emprunté en production.
|
||||||
|
// Il doit remplir "eta_minutes", pas seulement "total_eta_minutes", car
|
||||||
|
// c'est le champ que l'app mobile et le site web lisent (confirmé dans
|
||||||
|
// mobile/src/screens/client/OrderTrackingScreen.tsx et api.ts des deux
|
||||||
|
// frontends).
|
||||||
|
func TestSetCommandETAWithDetails_WritesEtaMinutesField(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "eta_details_field")
|
||||||
|
productID := newTestProduct(t, "EtaDetailsField", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA", productID, 1, 10)
|
||||||
|
|
||||||
|
if err := testDB.SetCommandETAWithDetails(cmdID, 22, 1); err != nil {
|
||||||
|
t.Fatalf("SetCommandETAWithDetails: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
etaData, err := testDB.GetCommandETA(cmdID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCommandETA: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := etaData["eta_minutes"]
|
||||||
|
if !ok || got == "" {
|
||||||
|
t.Errorf(`champ "eta_minutes" absent après SetCommandETAWithDetails (contenu: %v) — `+
|
||||||
|
`c'est le champ lu par le mobile et le site web, d'où l'absence de temps affiché`, etaData)
|
||||||
|
} else if got != "22" {
|
||||||
|
t.Errorf(`"eta_minutes" = %q, want "22"`, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reproduction bout-en-bout du symptôme signalé : après une assignation
|
||||||
|
// automatique (SetCommandETAWithDetails), le client consulte le suivi de sa
|
||||||
|
// commande (GetCommandTracking, l'endpoint utilisé par l'app mobile et le
|
||||||
|
// site web) — la réponse doit exposer eta.eta_minutes.
|
||||||
|
func TestGetCommandTracking_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "eta_tracking_client")
|
||||||
|
productID := newTestProduct(t, "EtaTrackingClient", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA2", productID, 1, 10)
|
||||||
|
|
||||||
|
if err := testDB.SetCommandETAWithDetails(cmdID, 17, 2); err != nil {
|
||||||
|
t.Fatalf("SetCommandETAWithDetails: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c, rec := etaTestContext(username, cmdID)
|
||||||
|
handlers.GetCommandTracking(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
ETA map[string]any `json:"eta"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
etaMinutesRaw, ok := resp.ETA["eta_minutes"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf(`la réponse de /tracking n'expose pas "eta.eta_minutes" (contenu eta: %v) — `+
|
||||||
|
`reproduit exactement le bug signalé par les clients`, resp.ETA)
|
||||||
|
}
|
||||||
|
etaMinutesStr, _ := etaMinutesRaw.(string)
|
||||||
|
if got, _ := strconv.Atoi(etaMinutesStr); got != 17 {
|
||||||
|
t.Errorf("eta.eta_minutes: got=%v want=17", etaMinutesRaw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Même reproduction via GetCommandStatus (autre endpoint de suivi, utilisé
|
||||||
|
// par l'app mobile pour le statut temps réel).
|
||||||
|
func TestGetCommandStatus_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "eta_status_client")
|
||||||
|
productID := newTestProduct(t, "EtaStatusClient", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA3", productID, 1, 10)
|
||||||
|
|
||||||
|
if err := testDB.SetCommandETAWithDetails(cmdID, 9, 1); err != nil {
|
||||||
|
t.Fatalf("SetCommandETAWithDetails: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c, rec := etaTestContext(username, cmdID)
|
||||||
|
handlers.GetCommandStatus(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
ETA map[string]any `json:"eta"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("décodage réponse: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := resp.ETA["eta_minutes"]; !ok {
|
||||||
|
t.Fatalf(`GetCommandStatus n'expose pas "eta.eta_minutes" (contenu: %v)`, resp.ETA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCommandETA (chemin utilisé par UpdateDeliveryStatus côté livreur) doit
|
||||||
|
// lui aussi rester lisible par les lecteurs qui attendent "total_eta_minutes"
|
||||||
|
// (handlers/deleviry.go, validation_deleviry.go, geoloca.go).
|
||||||
|
func TestSetCommandETA_AlsoWritesTotalEtaMinutesField(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "eta_simple_field")
|
||||||
|
productID := newTestProduct(t, "EtaSimpleField", 10)
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA4", productID, 1, 10)
|
||||||
|
|
||||||
|
if err := testDB.SetCommandETA(cmdID, 14); err != nil {
|
||||||
|
t.Fatalf("SetCommandETA: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
etaData, err := testDB.GetCommandETA(cmdID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCommandETA: %v", err)
|
||||||
|
}
|
||||||
|
if got, ok := etaData["total_eta_minutes"]; !ok || got != "14" {
|
||||||
|
t.Errorf(`"total_eta_minutes" = %q (présent=%v), want "14"`, got, ok)
|
||||||
|
}
|
||||||
|
if got, ok := etaData["eta_minutes"]; !ok || got != "14" {
|
||||||
|
t.Errorf(`"eta_minutes" = %q (présent=%v), want "14"`, got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeliverymanLocationForCommand (vue admin/cabine) lisait l'ETA via
|
||||||
|
// Redis.Get sur une clé qui est en réalité un hash (HSet) — l'erreur
|
||||||
|
// WRONGTYPE était silencieusement ignorée et etaMinutes restait toujours à 0.
|
||||||
|
func TestGetDeliverymanLocationForCommand_ExposesEtaMinutes(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "eta_admin_view_client")
|
||||||
|
productID := newTestProduct(t, "EtaAdminView", 10)
|
||||||
|
livreurUsername := testUserPrefix + "eta_admin_view_livreur"
|
||||||
|
cmdID := newTestCommandWithItem(t, username, "en_route", livreurUsername, productID, 1, 10)
|
||||||
|
|
||||||
|
if err := testDB.SetCommandETAWithDetails(cmdID, 12, 1); err != nil {
|
||||||
|
t.Fatalf("SetCommandETAWithDetails: %v", err)
|
||||||
|
}
|
||||||
|
// Position GPS du livreur, requise par le handler avant de lire l'ETA.
|
||||||
|
if err := testDB.UpdateDeliveryPersonLocation(livreurUsername, 47.2148, -1.5584); err != nil {
|
||||||
|
t.Fatalf("UpdateDeliveryPersonLocation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("username", "admin_test")
|
||||||
|
c.Set("role", "admin")
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(cmdID)}}
|
||||||
|
|
||||||
|
handlers.GetDeliverymanLocationForCommand(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Data struct {
|
||||||
|
ETA struct {
|
||||||
|
Minutes float64 `json:"minutes"`
|
||||||
|
HasETA bool `json:"has_eta"`
|
||||||
|
} `json:"eta"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !resp.Data.ETA.HasETA {
|
||||||
|
t.Errorf("has_eta devrait être true, ETA pourtant définie via SetCommandETAWithDetails")
|
||||||
|
}
|
||||||
|
if resp.Data.ETA.Minutes != 12 {
|
||||||
|
t.Errorf("data.eta.minutes: got=%.0f want=12", resp.Data.ETA.Minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gestion/db"
|
||||||
|
"gestion/models"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Persistance des settings (save→reload) ──────────────────────────────────
|
||||||
|
|
||||||
|
func TestUpdateSettings_FreeGiftsRoundTrip(t *testing.T) {
|
||||||
|
resetSettingsAfterTest(t)
|
||||||
|
|
||||||
|
s := db.DefaultSettings()
|
||||||
|
s.FreeGiftsEnabled = true
|
||||||
|
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||||
|
{
|
||||||
|
Category: "test",
|
||||||
|
AllProducts: false,
|
||||||
|
Products: []models.FreeGiftProductQuantity{
|
||||||
|
{ProductID: 111, Tiers: []models.FreeGiftTier{
|
||||||
|
{BuyQuantity: 10, FreeQuantity: 1},
|
||||||
|
{BuyQuantity: 20, FreeQuantity: 3},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := testDB.UpdateSettings(s); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := testDB.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSettings: %v", err)
|
||||||
|
}
|
||||||
|
if !loaded.FreeGiftsEnabled {
|
||||||
|
t.Fatal("free_gifts_enabled devrait être true après reload")
|
||||||
|
}
|
||||||
|
if len(loaded.FreeGifts) != 1 {
|
||||||
|
t.Fatalf("free_gifts: got=%d want=1: %+v", len(loaded.FreeGifts), loaded.FreeGifts)
|
||||||
|
}
|
||||||
|
gift := loaded.FreeGifts[0]
|
||||||
|
if gift.Category != "test" || len(gift.Products) != 1 {
|
||||||
|
t.Fatalf("free gift mal persistée: got=%+v", gift)
|
||||||
|
}
|
||||||
|
if len(gift.Products[0].Tiers) != 2 || gift.Products[0].Tiers[1].BuyQuantity != 20 || gift.Products[0].Tiers[1].FreeQuantity != 3 {
|
||||||
|
t.Errorf("tiers mal persistés: got=%+v", gift.Products[0].Tiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Désactivation : doit persister à false, pas de résurrection (même
|
||||||
|
// classe de bug que TestUpdateSettings_DisablingPointsRewardPersistsAsNil).
|
||||||
|
s.FreeGiftsEnabled = false
|
||||||
|
if err := testDB.UpdateSettings(s); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings (désactivation): %v", err)
|
||||||
|
}
|
||||||
|
loaded, err = testDB.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSettings (désactivation): %v", err)
|
||||||
|
}
|
||||||
|
if loaded.FreeGiftsEnabled {
|
||||||
|
t.Error("free_gifts_enabled devrait rester false après désactivation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Résolution de la quantité offerte (logique pure) ────────────────────────
|
||||||
|
|
||||||
|
func TestResolveFreeGift_AllProductsAtOrAboveThreshold(t *testing.T) {
|
||||||
|
settings := &models.AppSettings{
|
||||||
|
FreeGiftsEnabled: true,
|
||||||
|
FreeGifts: []models.CategoryFreeGiftConfig{
|
||||||
|
{Category: "fleurs", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||||
|
{BuyQuantity: 10, FreeQuantity: 1},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if got := db.ResolveFreeGift(settings, 42, "fleurs", 10); got != 1 {
|
||||||
|
t.Errorf("quantité offerte: got=%.2f want=1", got)
|
||||||
|
}
|
||||||
|
if got := db.ResolveFreeGift(settings, 42, "fleurs", 15); got != 1 {
|
||||||
|
t.Errorf("au-dessus du seuil, le cadeau reste dû: got=%.2f want=1", got)
|
||||||
|
}
|
||||||
|
if got := db.ResolveFreeGift(settings, 42, "fleurs", 9); got != 0 {
|
||||||
|
t.Errorf("sous le seuil, aucun cadeau: got=%.2f want=0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveFreeGift_DisabledReturnsZero(t *testing.T) {
|
||||||
|
settings := &models.AppSettings{
|
||||||
|
FreeGiftsEnabled: false,
|
||||||
|
FreeGifts: []models.CategoryFreeGiftConfig{
|
||||||
|
{Category: "fleurs", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||||
|
{BuyQuantity: 10, FreeQuantity: 1},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if got := db.ResolveFreeGift(settings, 42, "fleurs", 10); got != 0 {
|
||||||
|
t.Errorf("offres désactivées: aucun cadeau attendu: got=%.2f", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveFreeGift_PerProductHighestTierApplies(t *testing.T) {
|
||||||
|
settings := &models.AppSettings{
|
||||||
|
FreeGiftsEnabled: true,
|
||||||
|
FreeGifts: []models.CategoryFreeGiftConfig{
|
||||||
|
{
|
||||||
|
Category: "fleurs",
|
||||||
|
AllProducts: false,
|
||||||
|
Products: []models.FreeGiftProductQuantity{
|
||||||
|
{ProductID: 111, Tiers: []models.FreeGiftTier{
|
||||||
|
{BuyQuantity: 10, FreeQuantity: 1},
|
||||||
|
{BuyQuantity: 20, FreeQuantity: 3},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if got := db.ResolveFreeGift(settings, 111, "fleurs", 10); got != 1 {
|
||||||
|
t.Errorf("seuil 10g: got=%.2f want=1", got)
|
||||||
|
}
|
||||||
|
// 25g dépasse les deux seuils : le plus élevé (20g→3g) doit être retenu,
|
||||||
|
// pas le premier de la liste (10g→1g).
|
||||||
|
if got := db.ResolveFreeGift(settings, 111, "fleurs", 25); got != 3 {
|
||||||
|
t.Errorf("seuil le plus élevé atteint (20g→3g): got=%.2f want=3", got)
|
||||||
|
}
|
||||||
|
// Produit non listé dans cette config : aucun cadeau.
|
||||||
|
if got := db.ResolveFreeGift(settings, 222, "fleurs", 25); got != 0 {
|
||||||
|
t.Errorf("produit non couvert: got=%.2f want=0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Intégration AddToBasket : la quantité livrée inclut le cadeau, au même prix ──
|
||||||
|
|
||||||
|
func TestAddToBasket_AppliesFreeGiftQuantityAtSamePrice(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
resetSettingsAfterTest(t)
|
||||||
|
username := newTestClient(t, "freegift_basket_applies")
|
||||||
|
productID := newTestProduct(t, "FreeGiftBasketApplies", 50)
|
||||||
|
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
|
||||||
|
|
||||||
|
s := db.DefaultSettings()
|
||||||
|
s.FreeGiftsEnabled = true
|
||||||
|
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||||
|
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||||
|
{BuyQuantity: 10, FreeQuantity: 1},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := testDB.UpdateSettings(s); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
basket, err := testDB.AddToBasket(username, productID, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddToBasket: %v", err)
|
||||||
|
}
|
||||||
|
if basket.Quantity != 11 {
|
||||||
|
t.Errorf("quantité livrée attendue = 10 + 1 offert = 11: got=%.2f", basket.Quantity)
|
||||||
|
}
|
||||||
|
if basket.Price != 10.0 {
|
||||||
|
t.Errorf("le prix ne doit pas changer (facturé sur les 10g demandés): got=%.2f want=10.00", basket.Price)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddToBasket_NoFreeGiftBelowThreshold(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
resetSettingsAfterTest(t)
|
||||||
|
username := newTestClient(t, "freegift_basket_below")
|
||||||
|
productID := newTestProduct(t, "FreeGiftBasketBelow", 50)
|
||||||
|
|
||||||
|
s := db.DefaultSettings()
|
||||||
|
s.FreeGiftsEnabled = true
|
||||||
|
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||||
|
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||||
|
{BuyQuantity: 10, FreeQuantity: 1},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := testDB.UpdateSettings(s); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
basket, err := testDB.AddToBasket(username, productID, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddToBasket: %v", err)
|
||||||
|
}
|
||||||
|
if basket.Quantity != 5 {
|
||||||
|
t.Errorf("sous le seuil, aucune quantité offerte: got=%.2f want=5", basket.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// La quantité réellement décomptée du stock doit inclure le cadeau : un stock
|
||||||
|
// suffisant pour la quantité demandée mais pas pour demandée+offerte doit
|
||||||
|
// faire échouer l'ajout, pas livrer un cadeau partiel.
|
||||||
|
func TestAddToBasket_FreeGiftRejectedWhenStockInsufficientForBonus(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
resetSettingsAfterTest(t)
|
||||||
|
username := newTestClient(t, "freegift_basket_stock")
|
||||||
|
productID := newTestProduct(t, "FreeGiftBasketStock", 10) // stock = 10, pile la quantité demandée
|
||||||
|
|
||||||
|
s := db.DefaultSettings()
|
||||||
|
s.FreeGiftsEnabled = true
|
||||||
|
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||||
|
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||||
|
{BuyQuantity: 10, FreeQuantity: 1},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := testDB.UpdateSettings(s); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := testDB.AddToBasket(username, productID, 10); err == nil {
|
||||||
|
t.Fatal("stock=10 ne doit pas suffire pour livrer 10g + 1g offert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Intégration checkout : le bonus offert est bien décompté du stock ──────
|
||||||
|
//
|
||||||
|
// AddToBasket stocke déjà quantity = demandée + offerte (voir tests
|
||||||
|
// ci-dessus) ; CreateCommandWithAddress ne relit ni ne recalcule cette
|
||||||
|
// quantité — elle est copiée telle quelle dans command_items.quantite et
|
||||||
|
// utilisée telle quelle pour décrémenter products.stock (db_commands.go).
|
||||||
|
// Ces tests vérifient ce chemin de bout en bout, pas juste AddToBasket isolé.
|
||||||
|
|
||||||
|
func TestCheckout_FreeGiftBonusQuantityDecrementsStock(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
resetSettingsAfterTest(t)
|
||||||
|
username := newTestClient(t, "freegift_checkout_stock")
|
||||||
|
productID := newTestProduct(t, "FreeGiftCheckoutStock", 50)
|
||||||
|
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
|
||||||
|
|
||||||
|
s := db.DefaultSettings()
|
||||||
|
s.FreeGiftsEnabled = true
|
||||||
|
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||||
|
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||||
|
{BuyQuantity: 10, FreeQuantity: 1},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := testDB.UpdateSettings(s); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := testDB.AddToBasket(username, productID, 10); err != nil {
|
||||||
|
t.Fatalf("AddToBasket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 50 initial - (10 demandés + 1 offert) = 39, pas 40.
|
||||||
|
if got := productStock(t, productID); got != 39 {
|
||||||
|
t.Errorf("stock après checkout avec cadeau: got=%.2f want=39 (50 - 11)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var item struct {
|
||||||
|
Quantite float64 `gorm:"column:quantite"`
|
||||||
|
Prix float64 `gorm:"column:prix"`
|
||||||
|
}
|
||||||
|
if err := testDB.GDB.Raw(
|
||||||
|
`SELECT quantite, prix FROM command_items WHERE command_id = ? AND product_id = ?`,
|
||||||
|
cmd.ID, productID,
|
||||||
|
).Scan(&item).Error; err != nil {
|
||||||
|
t.Fatalf("lecture command_items: %v", err)
|
||||||
|
}
|
||||||
|
if item.Quantite != 11 {
|
||||||
|
t.Errorf("command_items.quantite doit inclure le cadeau: got=%.2f want=11", item.Quantite)
|
||||||
|
}
|
||||||
|
if item.Prix != 10.0 {
|
||||||
|
t.Errorf("command_items.prix ne doit pas changer (facturé sur les 10g demandés): got=%.2f want=10.00", item.Prix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckout_FreeGiftRollsBackWhenStockInsufficientForBonus(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
resetSettingsAfterTest(t)
|
||||||
|
username := newTestClient(t, "freegift_checkout_rollback")
|
||||||
|
productID := newTestProduct(t, "FreeGiftCheckoutRollback", 50)
|
||||||
|
|
||||||
|
s := db.DefaultSettings()
|
||||||
|
s.FreeGiftsEnabled = true
|
||||||
|
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||||
|
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||||
|
{BuyQuantity: 10, FreeQuantity: 1},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := testDB.UpdateSettings(s); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := testDB.AddToBasket(username, productID, 10); err != nil {
|
||||||
|
t.Fatalf("AddToBasket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le stock chute sous 11 (10 demandés + 1 offert) après l'ajout au panier,
|
||||||
|
// simulant une vente concurrente qui vide le stock entre AddToBasket et
|
||||||
|
// checkout — le checkout doit échouer et ne rien décrémenter.
|
||||||
|
if err := testDB.GDB.Exec(`UPDATE products SET stock = 10 WHERE id = ?`, productID).Error; err != nil {
|
||||||
|
t.Fatalf("réduction stock: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil {
|
||||||
|
t.Fatal("checkout attendu en échec: stock=10 insuffisant pour 10 demandés + 1 offert")
|
||||||
|
}
|
||||||
|
if got := productStock(t, productID); got != 10 {
|
||||||
|
t.Errorf("stock ne doit pas bouger si le checkout échoue: got=%.2f want=10", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Intégration annulation : le remboursement inclut le bonus offert ───────
|
||||||
|
|
||||||
|
func TestCancelCommandAtomic_RefundsFreeGiftBonusQuantity(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
resetSettingsAfterTest(t)
|
||||||
|
username := newTestClient(t, "freegift_cancel_refund")
|
||||||
|
productID := newTestProduct(t, "FreeGiftCancelRefund", 50)
|
||||||
|
|
||||||
|
s := db.DefaultSettings()
|
||||||
|
s.FreeGiftsEnabled = true
|
||||||
|
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||||
|
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||||
|
{BuyQuantity: 10, FreeQuantity: 1},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := testDB.UpdateSettings(s); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := testDB.AddToBasket(username, productID, 10); err != nil {
|
||||||
|
t.Fatalf("AddToBasket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||||
|
}
|
||||||
|
if got := productStock(t, productID); got != 39 {
|
||||||
|
t.Fatalf("précondition stock post-checkout: got=%.2f want=39", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err != nil {
|
||||||
|
t.Fatalf("CancelCommandAtomic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 39 + 11 (10 demandés + 1 offert) = 50, retour exact au stock initial.
|
||||||
|
if got := productStock(t, productID); got != 50 {
|
||||||
|
t.Errorf("stock après annulation (bonus offert inclus dans le remboursement): got=%.2f want=50", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rejeu : ne doit rembourser qu'une fois.
|
||||||
|
if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err == nil {
|
||||||
|
t.Fatal("le second appel sur une commande déjà annulée doit échouer, pas rembourser une seconde fois")
|
||||||
|
}
|
||||||
|
if got := productStock(t, productID); got != 50 {
|
||||||
|
t.Errorf("stock après double annulation: got=%.2f want=50 (un seul remboursement)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cumul avec les promotions de prix ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Une offre "achetez X, Y offert" et une promotion de réduction (%) sur le
|
||||||
|
// même produit doivent pouvoir s'appliquer ensemble : la promotion réduit le
|
||||||
|
// prix facturé sur la quantité demandée, le cadeau ajoute de la quantité
|
||||||
|
// livrée sans toucher au prix — les deux mécanismes sont indépendants dans
|
||||||
|
// AddToBasket (voir db_basket.go) mais rien ne garantissait jusqu'ici qu'ils
|
||||||
|
// ne s'écrasent pas mutuellement une fois combinés.
|
||||||
|
func TestAddToBasket_FreeGiftAndPromotionBothApplyTogether(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
resetSettingsAfterTest(t)
|
||||||
|
username := newTestClient(t, "freegift_promo_combo")
|
||||||
|
productID := newTestProduct(t, "FreeGiftPromoCombo", 50)
|
||||||
|
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
|
||||||
|
|
||||||
|
s := db.DefaultSettings()
|
||||||
|
s.PromotionsEnabled = true
|
||||||
|
s.Promotions = []models.CategoryPromotionConfig{
|
||||||
|
{Category: "test", AllProducts: true, Quantity: 10, DiscountPercent: 20},
|
||||||
|
}
|
||||||
|
s.FreeGiftsEnabled = true
|
||||||
|
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||||
|
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||||
|
{BuyQuantity: 10, FreeQuantity: 1},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := testDB.UpdateSettings(s); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
basket, err := testDB.AddToBasket(username, productID, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddToBasket: %v", err)
|
||||||
|
}
|
||||||
|
if basket.Quantity != 11 {
|
||||||
|
t.Errorf("le cadeau doit s'appliquer malgré la promo active: got quantity=%.2f want=11", basket.Quantity)
|
||||||
|
}
|
||||||
|
if basket.Price != 8.0 {
|
||||||
|
t.Errorf("la promo doit s'appliquer malgré le cadeau actif: got price=%.2f want=8.00 (10€ - 20%%)", basket.Price)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"gestion/handlers"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestDeliveredOrder crée une commande livrée/approuvée assignée à un
|
||||||
|
// livreur avec une date de mise à jour contrôlée (GetMyDeliveryStats groupe
|
||||||
|
// par updated_at, pas created_at).
|
||||||
|
func newTestDeliveredOrder(t *testing.T, livreurUsername, clientUsername, status string, productID int, quantite, prix, referralUsed float64, updatedAt time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
var cmdID int
|
||||||
|
if err := testDB.GDB.Raw(
|
||||||
|
`INSERT INTO commandes (username, status, livreur_assign, adresse, total_prix, referral_used, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 'Adresse test', ?, ?, ?, ?) RETURNING id`,
|
||||||
|
clientUsername, status, livreurUsername, prix, referralUsed, updatedAt, updatedAt,
|
||||||
|
).Scan(&cmdID).Error; err != nil {
|
||||||
|
t.Fatalf("création commande livrée test: %v", err)
|
||||||
|
}
|
||||||
|
if err := testDB.GDB.Exec(
|
||||||
|
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
|
||||||
|
VALUES (?, ?, 'item test', ?, ?, 'delivered')`,
|
||||||
|
cmdID, productID, quantite, prix,
|
||||||
|
).Error; err != nil {
|
||||||
|
t.Fatalf("création item commande livrée test: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func livreurStatsContext(username string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/livreur/stats", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("database", testDB)
|
||||||
|
c.Set("username", username)
|
||||||
|
c.Set("role", "livreur")
|
||||||
|
return c, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
type deliveryStatsResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
TodayCount int `json:"today_count"`
|
||||||
|
TodayRevenue float64 `json:"today_revenue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// today_count/today_revenue ne doivent compter que les livraisons du jour
|
||||||
|
// courant (livre/approved), nettes du crédit de parrainage — pas les jours
|
||||||
|
// précédents, même récents (voir by_day/by_week qui eux les agrègent).
|
||||||
|
func TestGetMyDeliveryStats_TodayCountAndRevenue(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
livreurUsername := newTestClient(t, "stats_livreur_today")
|
||||||
|
clientUsername := newTestClient(t, "stats_livreur_today_client")
|
||||||
|
productID := newTestProduct(t, "StatsLivreurToday", 100)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
yesterday := now.AddDate(0, 0, -1)
|
||||||
|
|
||||||
|
newTestDeliveredOrder(t, livreurUsername, clientUsername, "approved", productID, 2, 40, 10, now) // net 30, aujourd'hui
|
||||||
|
newTestDeliveredOrder(t, livreurUsername, clientUsername, "approved", productID, 1, 25, 0, yesterday) // hier, ne doit pas compter dans "today"
|
||||||
|
|
||||||
|
c, rec := livreurStatsContext(livreurUsername)
|
||||||
|
handlers.GetMyDeliveryStats(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp deliveryStatsResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("success=false, body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if resp.TodayCount != 1 {
|
||||||
|
t.Errorf("today_count: got=%d want=1 (la commande d'hier ne doit pas compter)", resp.TodayCount)
|
||||||
|
}
|
||||||
|
if resp.TodayRevenue != 30 {
|
||||||
|
t.Errorf("today_revenue: got=%.2f want=30 (40 - 10 de parrainage)", resp.TodayRevenue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aucune livraison aujourd'hui : today_count/today_revenue doivent être 0,
|
||||||
|
// pas une absence de champ (voir le bug initial où le seul indicateur de
|
||||||
|
// "livraisons du jour" était l'absence de ligne dans by_day).
|
||||||
|
func TestGetMyDeliveryStats_TodayCountZeroWhenNoDeliveryToday(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
livreurUsername := newTestClient(t, "stats_livreur_none")
|
||||||
|
|
||||||
|
c, rec := livreurStatsContext(livreurUsername)
|
||||||
|
handlers.GetMyDeliveryStats(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp deliveryStatsResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if resp.TodayCount != 0 {
|
||||||
|
t.Errorf("today_count: got=%d want=0", resp.TodayCount)
|
||||||
|
}
|
||||||
|
if resp.TodayRevenue != 0 {
|
||||||
|
t.Errorf("today_revenue: got=%.2f want=0", resp.TodayRevenue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seules les commandes assignées à CE livreur doivent être comptées.
|
||||||
|
func TestGetMyDeliveryStats_OnlyCountsOwnDeliveries(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
livreurA := newTestClient(t, "stats_livreur_a")
|
||||||
|
livreurB := newTestClient(t, "stats_livreur_b")
|
||||||
|
client := newTestClient(t, "stats_livreur_shared_client")
|
||||||
|
productID := newTestProduct(t, "StatsLivreurIsolation", 100)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
newTestDeliveredOrder(t, livreurA, client, "approved", productID, 1, 20, 0, now)
|
||||||
|
newTestDeliveredOrder(t, livreurB, client, "approved", productID, 1, 999, 0, now)
|
||||||
|
|
||||||
|
c, rec := livreurStatsContext(livreurA)
|
||||||
|
handlers.GetMyDeliveryStats(c)
|
||||||
|
|
||||||
|
var resp deliveryStatsResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("décodage réponse: %v", err)
|
||||||
|
}
|
||||||
|
if resp.TodayCount != 1 || resp.TodayRevenue != 20 {
|
||||||
|
t.Errorf("stats livreur A ne doivent refléter que ses propres livraisons: got count=%d revenue=%.2f want count=1 revenue=20", resp.TodayCount, resp.TodayRevenue)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Package tests regroupe les tests de bout en bout de la gestion de stock,
|
||||||
|
// écrits contre l'API publique des paquets db/ et handlers/ (aucun accès à
|
||||||
|
// leurs symboles non exportés) — voir docker-compose.yml pour la base de
|
||||||
|
// test locale nécessaire pour les exécuter (`go test ./tests/...`).
|
||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"gestion/db"
|
||||||
|
"os"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testDB est l'instance partagée par tous les tests de ce paquet. Toutes les
|
||||||
|
// données créées utilisent un préfixe dédié (testUserPrefix / testProductPrefix)
|
||||||
|
// et sont nettoyées avant et après chaque test, ce qui rend la suite sans
|
||||||
|
// danger même si elle tourne contre une base partagée.
|
||||||
|
var testDB *db.Database
|
||||||
|
|
||||||
|
const (
|
||||||
|
testUserPrefix = "stocktest_"
|
||||||
|
testProductPrefix = "TESTSTOCK_"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testPhoneCounter garantit un numéro de téléphone unique par client de test
|
||||||
|
// (colonne UNIQUE sur clients.telephone).
|
||||||
|
var testPhoneCounter int64
|
||||||
|
|
||||||
|
func nextTestPhone() string {
|
||||||
|
n := atomic.AddInt64(&testPhoneCounter, 1)
|
||||||
|
return fmt.Sprintf("+3361%09d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
testDB = db.InitDB()
|
||||||
|
db.InitRedis()
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupStockTestData supprime toutes les données créées par les tests de
|
||||||
|
// gestion de stock (identifiées par leur préfixe), dans le bon ordre pour
|
||||||
|
// respecter les contraintes de clé étrangère.
|
||||||
|
func cleanupStockTestData(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
testDB.GDB.Exec(`DELETE FROM command_items WHERE command_id IN (SELECT id FROM commandes WHERE username LIKE ?)`, testUserPrefix+"%")
|
||||||
|
testDB.GDB.Exec(`DELETE FROM commandes WHERE username LIKE ?`, testUserPrefix+"%")
|
||||||
|
testDB.GDB.Exec(`DELETE FROM baskets WHERE username LIKE ?`, testUserPrefix+"%")
|
||||||
|
testDB.GDB.Exec(`DELETE FROM clients WHERE username LIKE ?`, testUserPrefix+"%")
|
||||||
|
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id IN (SELECT id FROM products WHERE name LIKE ?)`, testProductPrefix+"%")
|
||||||
|
testDB.GDB.Exec(`DELETE FROM products WHERE name LIKE ?`, testProductPrefix+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestProduct crée un produit de test avec un stock initial donné et un
|
||||||
|
// prix actif pour quantity=1, et programme son nettoyage en fin de test.
|
||||||
|
func newTestProduct(t *testing.T, name string, stock float64) int {
|
||||||
|
t.Helper()
|
||||||
|
fullName := testProductPrefix + name
|
||||||
|
var id int
|
||||||
|
if err := testDB.GDB.Raw(
|
||||||
|
`INSERT INTO products (name, category, description, stock) VALUES (?, 'test', '', ?) RETURNING id`,
|
||||||
|
fullName, stock,
|
||||||
|
).Scan(&id).Error; err != nil {
|
||||||
|
t.Fatalf("création produit test %q: %v", fullName, err)
|
||||||
|
}
|
||||||
|
if err := testDB.GDB.Exec(
|
||||||
|
`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, 1, 10.00, true)`,
|
||||||
|
id,
|
||||||
|
).Error; err != nil {
|
||||||
|
t.Fatalf("création prix produit test %q: %v", fullName, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, id)
|
||||||
|
testDB.GDB.Exec(`DELETE FROM products WHERE id = ?`, id)
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// productStock relit le stock courant d'un produit directement en base.
|
||||||
|
func productStock(t *testing.T, productID int) float64 {
|
||||||
|
t.Helper()
|
||||||
|
var stock float64
|
||||||
|
if err := testDB.GDB.Raw(`SELECT stock FROM products WHERE id = ?`, productID).Scan(&stock).Error; err != nil {
|
||||||
|
t.Fatalf("lecture stock produit %d: %v", productID, err)
|
||||||
|
}
|
||||||
|
return stock
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestClient crée un client de test et programme son nettoyage en fin de test.
|
||||||
|
func newTestClient(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
username := testUserPrefix + name
|
||||||
|
if err := testDB.GDB.Exec(
|
||||||
|
`INSERT INTO clients (username, password, nom, prenom, telephone) VALUES (?, 'x', 'T', 'C', ?)
|
||||||
|
ON CONFLICT (username) DO NOTHING`,
|
||||||
|
username, nextTestPhone(),
|
||||||
|
).Error; err != nil {
|
||||||
|
t.Fatalf("création client test %q: %v", username, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username)
|
||||||
|
testDB.GDB.Exec(`DELETE FROM command_items WHERE command_id IN (SELECT id FROM commandes WHERE username = ?)`, username)
|
||||||
|
testDB.GDB.Exec(`DELETE FROM commandes WHERE username = ?`, username)
|
||||||
|
testDB.GDB.Exec(`DELETE FROM clients WHERE username = ?`, username)
|
||||||
|
})
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestCommandWithItem crée directement une commande avec un item (en
|
||||||
|
// contournant le checkout), pour tester isolément les chemins d'annulation
|
||||||
|
// et de remboursement de stock. Retourne l'ID de la commande créée.
|
||||||
|
func newTestCommandWithItem(t *testing.T, username, status, livreurAssign string, productID int, quantite float64, prix float64) int {
|
||||||
|
t.Helper()
|
||||||
|
var cmdID int
|
||||||
|
if err := testDB.GDB.Raw(
|
||||||
|
`INSERT INTO commandes (username, status, livreur_assign, adresse, total_prix, created_at, updated_at)
|
||||||
|
VALUES (?, ?, NULLIF(?, ''), 'Adresse test', ?, NOW(), NOW()) RETURNING id`,
|
||||||
|
username, status, livreurAssign, prix,
|
||||||
|
).Scan(&cmdID).Error; err != nil {
|
||||||
|
t.Fatalf("création commande test: %v", err)
|
||||||
|
}
|
||||||
|
if err := testDB.GDB.Exec(
|
||||||
|
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
|
||||||
|
VALUES (?, ?, 'item test', ?, ?, 'pending')`,
|
||||||
|
cmdID, productID, quantite, prix,
|
||||||
|
).Error; err != nil {
|
||||||
|
t.Fatalf("création item test: %v", err)
|
||||||
|
}
|
||||||
|
return cmdID
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandStatus(t *testing.T, commandID int) string {
|
||||||
|
t.Helper()
|
||||||
|
var status string
|
||||||
|
testDB.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, commandID).Scan(&status)
|
||||||
|
return status
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user