Compare commits

..

No commits in common. "develop" and "issue-58" have entirely different histories.

1801 changed files with 59955 additions and 107112 deletions

View file

@ -1,6 +0,0 @@
# Browsers that we support
last 3 version
> 0.3%
not dead # no browsers without security updates
not Firefox < 100

View file

@ -1,11 +1,2 @@
.git/
.gitlab/
.gitlab-ci.yml
.git
*.md
.vscode/
auth.json
Dockerfile
manifest.yaml
node_modules/
production-resources.yaml
.env.example

View file

@ -3,33 +3,13 @@ root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{php,json}]
indent_size = 4
[*.blade.php]
indent_size = 2
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 2
[*.scss]
indent_size = 2
quote_type = single
[*.css]
indent_size = 2
quote_type = single
[*.vue]
indent_size = 2

View file

@ -3,7 +3,6 @@ APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_CURRENCY=EUR
LOG_CHANNEL=stack
LOG_LEVEL=debug
@ -16,24 +15,22 @@ DB_USERNAME=root
DB_PASSWORD=
BROADCAST_DRIVER=log
CACHE_STORE=file
CACHE_DRIVER=file
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
FILESYSTEM_DISK=local
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=
MAIL_FROM_ADDRESS=
MAIL_FROM_NAME="${APP_NAME}"
MAIL_TO_CONTACT=
MAIL_TO_REGATE=
MAIL_LIST_SERVICEWEB=
MAIL_LIST_INVALIDINCO=
# CRM
CRM_ENDPOINT=
@ -45,21 +42,3 @@ SENTRY_LARAVEL_DSN=
# Matomo
MATOMO_SITE_ID=
# UWIL ADMIN SECURE
# - LIST SEPARATED WITH ',' (Required)
UWIL_ADMIN_SECURE_CIDR_LIST=""
# - LIST SEPARATED WITH ',' (Optional)
UWIL_ADMIN_SECURE_CIDR_PROJECT_ALLOWED_LIST=""
# Arcadia API
ARCADIA_API_ENDPOINT=
ARCADIA_API_APIKEY=
ARCADIA_API_SECRET=
ARCADIA_API_IGNORE_SELF_SIGNED_CERT=false
# Serial Caster
SERIAL_CASTER_SEED=12345
# LDC
LDC_HEADQUARTERS_PHONE_RECEPTION="02 43 62 70 00"

14
.gitattributes vendored
View file

@ -1,11 +1,5 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
* text=auto
*.css linguist-vendored
*.scss linguist-vendored
*.js linguist-vendored
CHANGELOG.md export-ignore
.styleci.yml export-ignore

53
.gitignore vendored
View file

@ -1,51 +1,22 @@
.env
.env.testing
.env.backup
.env.production
.phpunit.result.cache
.stylelintcache
/.fleet
/.idea
/.phpunit.cache
/.vscode
/node_modules
/bootstrap/cache
/public/build
/public/cache
/public/hot
/public/sitemap.xml
/public/storage
/public/images/content
/public/images/gammes
/public/images/produits
/public/images/slides
/public/b-o/
/public/mix-manifest.json
/storage/*.key
/vendor
coverage/
auth.json
docker-compose.override.yml
.env
.env.backup
.env.dev
.env.prod
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
auth.json
.npmrc
js.save
phpstan-report.xml
# Ignore public favicon
/public/android-chrome-192x192.png
/public/android-chrome-512x512.png
/public/apple-touch-icon.png
/public/browserconfig.xml
/public/browserconfig.xml
/public/favicon-16x16.png
/public/favicon-32x32.png
/public/favicon.ico
/public/mstile-150x150.png
/public/safari-pinned-tab.svg
/public/site.webmanifest
/public/files
develop
_ide_helper.php
.phpstorm.meta.php
phpstan-report.xml
// Ignore PHPUnit cache files
cache

View file

@ -1,328 +1,23 @@
stages:
- install
- lint
- test
- security
- build
- deploy
include:
- project: "ldc-web/shared-ci"
ref: "main"
file: "security/composer.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "build/composer.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "build/docker-multistage.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "test/test.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "deploy/deploy.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "lint/lint7.3.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "security/security7.3.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "build/build7.3.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "test/test.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "deploy/deploy.yml"
# * LINT --------------------------------------------------------------------
.php-lint-base: &php-lint-base
image: $CI_REGISTRY/ldc-web/projets/ldc-web-images-docker/uwil-ci-8.5:latest
variables:
COMPOSER_CACHE_DIR: ".composer-cache"
cache:
key:
files:
- composer.lock
prefix: "composer"
policy: pull
paths:
- .composer-cache/
- vendor/
before_script:
- composer config gitlab-token.lab.s-web.fr $CI_DEPLOY_USER $CI_DEPLOY_PASSWORD
- composer install --no-scripts --no-interaction --no-ansi --no-progress --prefer-dist
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "develop"
parallel-lint:
<<: *php-lint-base
stage: lint
script:
- vendor/bin/parallel-lint --exclude vendor --exclude storage --exclude bootstrap --exclude node_modules .
allow_failure: false
pint:
<<: *php-lint-base
stage: lint
script:
- vendor/bin/pint --test
allow_failure: false
phpcs:
<<: *php-lint-base
stage: lint
script:
- vendor/bin/phpcs --standard=phpcs.xml --extensions=php --ignore=vendor/*,storage/*,bootstrap/*,node_modules/* --report=junit --report-file=phpcs-report.xml
artifacts:
when: on_failure
expose_as: 'phpcs'
paths:
- phpcs-report.xml
reports:
junit: phpcs-report.xml
allow_failure: false
phpstan:
<<: *php-lint-base
stage: lint
variables:
PHP_MEMORY_LIMIT: "512M"
COMPOSER_CACHE_DIR: ".composer-cache"
script:
- php -d memory_limit=512M vendor/bin/phpstan --error-format=junit > phpstan-report.xml
artifacts:
when: on_failure
expose_as: 'phpstan'
paths:
- phpstan-report.xml
reports:
junit: phpstan-report.xml
allow_failure: false
eslint:
stage: lint
image: oven/bun:1
variables:
BUN_INSTALL_CACHE_DIR: ".bun-cache"
cache:
key:
files:
- bun.lock
prefix: "bun"
policy: pull
paths:
- .bun-cache/
before_script:
- echo "@ldc-web:registry=https://${CI_SERVER_HOST}/api/v4/packages/npm/" >> .npmrc
- echo "//${CI_SERVER_HOST}/api/v4/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc
- echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- bun install --no-save
script:
- bun run eslint-ci --quiet --output-file=eslint-junit.xml
artifacts:
when: on_failure
expose_as: 'eslint'
paths:
- eslint-junit.xml
reports:
junit: eslint-junit.xml
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "develop"
stylelint:
stage: lint
image: oven/bun:1
variables:
BUN_INSTALL_CACHE_DIR: ".bun-cache"
cache:
key:
files:
- bun.lock
prefix: "bun"
policy: pull
paths:
- .bun-cache/
before_script:
- echo "@ldc-web:registry=https://${CI_SERVER_HOST}/api/v4/packages/npm/" >> .npmrc
- echo "//${CI_SERVER_HOST}/api/v4/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc
- echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- bun install --no-save
script:
- bun run stylelint-ci --quiet --output-file=stylelint-junit.xml
artifacts:
when: on_failure
expose_as: 'stylelint'
paths:
- stylelint-junit.xml
reports:
junit: stylelint-junit.xml
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "develop"
# * TESTS -------------------------------------------------------------------
bun-build-test:
stage: test
image: oven/bun:1
variables:
BUN_INSTALL_CACHE_DIR: ".bun-cache"
cache:
key:
files:
- bun.lock
prefix: "bun"
policy: pull
paths:
- .bun-cache/
before_script:
- echo "@ldc-web:registry=https://${CI_SERVER_HOST}/api/v4/packages/npm/" >> .npmrc
- echo "//${CI_SERVER_HOST}/api/v4/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc
- echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- bun install --no-save
script:
- timeout 300 bun run prod
artifacts:
expose_as: 'front-build-test'
paths:
- public/build/manifest.json
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "trunk" || $CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG != null
tests_front:
stage: test
image: oven/bun:1
variables:
BUN_INSTALL_CACHE_DIR: ".bun-cache"
cache:
key:
files:
- bun.lock
prefix: "bun"
policy: pull
paths:
- .bun-cache/
before_script:
- echo "@ldc-web:registry=https://${CI_SERVER_HOST}/api/v4/packages/npm/" >> .npmrc
- echo "//${CI_SERVER_HOST}/api/v4/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc
- echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- bun install --no-save
script:
- bun run test:unit
artifacts:
when: always
expose_as: 'vitest'
paths:
- coverage/junit-vitest.xml
reports:
junit: coverage/junit-vitest.xml
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "trunk" || $CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG != null
tests_laravel:
stage: test
image: $CI_REGISTRY/ldc-web/projets/ldc-web-images-docker/uwil-ci-8.5:latest
allow_failure: false
variables:
COMPOSER_CACHE_DIR: ".composer-cache"
# MYSQL SERVICE VARS
MYSQL_DATABASE: testing_db
MYSQL_ROOT_PASSWORD: password
# LARAVEL VARS
DB_CONNECTION: mysql
DB_HOST: mysql
DB_PORT: 3306
DB_DATABASE: testing_db
DB_USERNAME: root
DB_PASSWORD: password
cache:
- key:
files:
- composer.lock
prefix: "composer"
policy: pull
paths:
- .composer-cache/
- vendor/
services:
- name: mysql:8.0
alias: mysql
before_script:
- composer config gitlab-token.lab.s-web.fr $CI_DEPLOY_USER $CI_DEPLOY_PASSWORD
- composer install --no-scripts --no-interaction --no-ansi --no-progress --prefer-dist
script:
- cp .env.example .env
- php artisan key:generate
- php -r "while(true){try{new PDO('mysql:host=mysql;dbname=testing_db','root','password');break;}catch(Exception){sleep(1);}}"
- php artisan config:clear
- php artisan test --coverage --colors=never --log-junit=coverage/junit.xml --coverage-cobertura=coverage/cobertura.xml --coverage-html=coverage/html
after_script:
- if [ "$CI_JOB_STATUS" == "failed" ]; then cat storage/logs/laravel.log; fi
artifacts:
when: always
expose_as: 'coverage'
paths:
- coverage/html/
reports:
junit: coverage/junit.xml
coverage_report:
coverage_format: cobertura
path: coverage/cobertura.xml
coverage: '/^\s*Total:\s*([\d\.]+)%/'
needs:
- job: bun-build-test
optional: false
artifacts: true
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "trunk" || $CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG != null
# * SECURITY ----------------------------------------------------------------
bun-security:
stage: security
image: oven/bun:1
before_script:
- echo "@ldc-web:registry=https://${CI_SERVER_HOST}/api/v4/packages/npm/" >> .npmrc
- echo "//${CI_SERVER_HOST}/api/v4/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc
- echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
script:
- bun audit --json > bun-security-checker-report.json
artifacts:
when: on_failure
expose_as: 'bun-security-checker'
paths:
- bun-security-checker-report.json
allow_failure: true
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "trunk" || $CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG != null
# * BUILD -------------------------------------------------------------------
npm-build:
stage: build
script:
- echo "Frontend is built directly within Docker multi-stage build"
npm-security:
stage: security
script:
- echo "Npm security would run bun security"
dependencies:
- bun-security
needs:
- job: bun-security
optional: true
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "trunk" || $CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG != null
stages:
- lint
- security
- build
- test
- deploy

View file

@ -8,4 +8,4 @@
## Étapes pour la reproduction du bug
*
*

File diff suppressed because it is too large Load diff

View file

@ -5,5 +5,9 @@ php:
finder:
not-name:
- index.php
js: true
- server.php
js:
finder:
not-name:
- webpack.mix.js
css: true

View file

@ -1,43 +0,0 @@
{
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-sass-guidelines",
"stylelint-config-idiomatic-order"
],
"plugins": [
"stylelint-scss"
],
"files": [
"**/*.css",
"**/*.scss"
],
"ignoreFiles": [
"**/vendor/*.css",
"**/vendor/**/*.css",
"**/vendor/*.scss",
"**/vendor/**/*.scss",
"**/vendor/*.sass",
"**/vendor/**/*.sass",
"**/vendor/*.less",
"**/vendor/**/*.less"
],
"customSyntax": "postcss-scss",
"rules": {
"declaration-property-value-no-unknown": true,
"selector-max-id": null,
"color-no-invalid-hex": true,
"order/properties-alphabetical-order": null,
"max-nesting-depth": 5,
"selector-max-compound-selectors": 5,
"scss/double-slash-comment-empty-line-before": [
"always",
{
"ignore": [
"between-comments",
"stylelint-commands",
"inside-block"
]
}
]
}
}

95
.vscode/settings.json vendored
View file

@ -1,7 +1,7 @@
{
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#920c7f",
"activityBar.activeBorder": "#e7e7e7",
"activityBar.activeBorder": "#1a1d02",
"activityBar.background": "#920c7f",
"activityBar.foreground": "#e7e7e7",
"activityBar.inactiveForeground": "#e7e7e799",
@ -11,100 +11,13 @@
"statusBar.background": "#630856",
"statusBar.foreground": "#e7e7e7",
"statusBarItem.hoverBackground": "#920c7f",
"statusBarItem.remoteBackground": "#66760a",
"statusBarItem.remoteBackground": "#630856",
"statusBarItem.remoteForeground": "#e7e7e7",
"titleBar.activeBackground": "#630856",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveBackground": "#63085699",
"titleBar.inactiveForeground": "#e7e7e799",
"commandCenter.border": "#e7e7e799",
"activityBarTop.activeBackground": "#920c7f",
"activityBarTop.activeBorder": "#e7e7e7",
"activityBarTop.background": "#920c7f",
"activityBarTop.foreground": "#e7e7e7",
"activityBarTop.inactiveForeground": "#e7e7e799",
"commandCenter.foreground": "#e7e7e7",
"statusBar.debuggingBackground": "#630856",
"statusBar.debuggingForeground": "#e7e7e7",
"statusBar.inactiveBackground": "#630856"
"commandCenter.border": "#e7e7e799"
},
"peacock.color": "#630856",
"peacock.remoteColor": "#630856",
// "php.validate.executablePath": "/usr/local/bin/php85",
// "php.executablePath": "/usr/local/bin/php85",
"intelephense.environment.phpVersion": "8.5.0",
"intelephense.environment.includePaths": [
"./.intellipest",
"./vendor/pestphp",
"./vendor/phpunit/phpunit",
"./vendor/laravel"
],
"phpstan.binCommand": [
"${workspaceFolder}/vendor/bin/phpstan"
],
"laravel-pint.enable": true,
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "typescript", "html", "vue"],
"[html]": {
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
}
},
"[blade]": {
"editor.formatOnSave": true,
"editor.formatOnType": false
},
"[javascript]": {
"editor.autoIndent": "full",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.detectIndentation": false,
"editor.formatOnSave": true
},
"[scss]": {
"editor.defaultFormatter": "stylelint.vscode-stylelint",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.stylelint": "always"
}
},
"[typescript]": {
"editor.autoIndent": "full",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.detectIndentation": false,
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file"
},
"[php]": {
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file"
},
"[vue]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
}
},
"conventionalCommits.scopes": [
"migration"
],
"emeraldwalk.runonsave": {
"autoClearConsole": false,
"commands": [
{
"match": "\\.php$",
"isAsync": true,
"cmd": "${workspaceFolder}/vendor/bin/pint ${file}"
}
]
},
// "LaravelExtraIntellisense.phpCommand": "docker exec -w /app/srcs -t boutique-cnc-sable-$(whoami)-apache-php-1 php -r \"{code}\"",
// "LaravelExtraIntellisense.basePathForCode": "/app/srcs"
"peacock.remoteColor": "#630856"
}

View file

@ -1,71 +1,3 @@
# (2026-04-23)
### Bug Fixes
* :bug: delete ldcweb:statuschecker from the kernel 898104d
* :bug: error on removeFromCart dc8fa4d
* :bug: hide brands for unpublished products a5dbb9e
* :bug: set blade directives function in public #88 e7db2d4, closes #88
* :bug: update promotion filtering system 0692ec7
* :bug: use blade directives helper to prevent blade files from being cached #76 #77 ae54ee5, closes #76 #77
* :bug: user->id null on SalarieExportJob + add public holiday with cron + condition on public holiday's calendar 79d9061
* :chart_with_upwards_trend: update dashboard statistics #52 f454d35, closes #52
* addToCart policy was broken in product #86 + homepage #85 7150dc1, closes #86 #85
* Clean and fix cookie notice implementation 683e7fd
* Display the correct count of employee in the dashboard c07ff0d
* feedback on user basket a3b4a4b
* Fix check on salarie and customer relation 0366644
* fixed variable name for previous commit - #96 f91a7d0, closes #96
* hide prices in products.index <hen the user isn't logged in - #96 ab9fce5, closes #96
* prevent division by zero in membership rate calculation f27ed8f
* Remove piece of :poop: 2687f58
* rename route in sitemap 6328623
* reset view cache when updating settings f1bb958
* RM console.log e5e44bb
* Update matricule format 8ee5a94
### Features
* :poop: Update Matricule format on employees import and order export f156dd6
* :poop: Update SalarieSite Enum and Salarie Magasin model method 895843e
* :sparkles: add new product filter on food products no art in back-end #53 e900b85, closes #53
* :sparkles: adding multiple brands 79b9b9b
* :sparkles: Closes #46 3a3d993, closes #46
* :sparkles: re-add the stat before the date result on the dashbaord 8118d5a
* :sparkles: send an order reminder email b3d4d69
* 💬 add wording 31dac9b
* 🗃 add new field to show slide only for connected customers #87 fcd0a79, closes #87
* Add Laravel K8S 4fb9363
* Add missing newsletters folder ed5b2e0
* added admin secure fa3b38a
* added boolean to show old price on discounts - #95 06785a3, closes #95
* Authorize all BO roles for user import - ts-2165 e7baad6
* Clean and add CICD for K8S migration 9333185
* export file optimization e83b77d
* Issue #49 feedback ba6380f, closes #49
* Issue #52 feedback f6e06ec, closes #52
* Issue #55 feedback bab3bef, closes #55
* Make the fu**ing Salarie import works again c332696
* Make the fu**ing Salarie import works again 7f53f2a
* move uploads file in storage/app/public instead of public/ be69807
* re-order products with a select system like support-web - #94 f9e0448, closes #94
* Replace Regate EAI order transfert by email 184da02
* show only brands with min 1 product - #91 6e7bbe0, closes #91
* Update queue connection in manifest a93d191
* Update rules c49ca99
* update store link in fo navbar #92 2d9c55e, closes #92
* Update uploads files folder in controllers 43fc273
* updated slider to 25/75 ratio - #83 7c1c154, closes #83
### Reverts
* Revert "npm audit fix --force to solve all vulnerabilities" 02cfc19
* Revert "PHPCS correction 1" 3e9428d
## 07-10-2022
* Nouvelle UX (mise à jour et ajout de composants vue, inplémentation de l'intégration dans des blades)

View file

@ -1,52 +1,5 @@
ARG PHP_8_5_TAG=latest
FROM composer:2.9 AS vendor
ARG GITLAB_USER
ARG GITLAB_PASSWORD
COPY database/ database/
COPY composer.json composer.json
COPY composer.lock composer.lock
RUN composer config gitlab-token.lab.s-web.fr "${GITLAB_USER}" "${GITLAB_PASSWORD}"
RUN composer install \
--ignore-platform-reqs \
--no-interaction \
--no-plugins \
--no-scripts \
--no-dev \
--prefer-dist
FROM oven/bun:1.4.4-alpine AS frontend
ARG GITLAB_PASSWORD
RUN mkdir -p /app/public/build
WORKDIR /app
COPY package.json bun.lock* tsconfig.json vite.config.ts vite.config.js .stylelintrc.json /app/
COPY resources/ /app/resources/
COPY --from=vendor /app/vendor/ /app/vendor/
RUN echo "@ldc-web:registry=https://lab.s-web.fr/api/v4/packages/npm/" >> .npmrc && \
echo "//lab.s-web.fr/api/v4/packages/npm/:_authToken=${GITLAB_PASSWORD}" >> .npmrc && \
echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc && \
echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${GITLAB_PASSWORD}" >> .npmrc
RUN bun install --frozen-lockfile && bun run prod
ARG PHP_8_5_TAG
FROM registry.s-web.fr/ldc-web/projets/ldc-web-images-docker/uwil-production-8.5:${PHP_8_5_TAG}
ARG USER=www-data
COPY --chown=${USER}:${USER} . /app/srcs
COPY --chown=${USER}:${USER} ./resources/favicon/ /app/srcs/public/
COPY --from=vendor --chown=${USER}:${USER} /app/vendor/ /app/srcs/vendor/
COPY --from=frontend --chown=${USER}:${USER} /app/public/build/ /app/srcs/public/build/
FROM registry.s-web.fr/ldc-web/projets/ldc-web-images-docker/ldcweb-production-7.3:latest
COPY --chown=www-data:www-data . /app/srcs
USER root
RUN chmod -R 755 /app/srcs/storage
USER ${USER}
USER www-data

View file

@ -1,80 +1,26 @@
# Boutique Click N Collect Sablé
## Boutique Click N Collect Sablé
## Table of contents
- [Boutique Click N Collect Sablé](#boutique-click-n-collect-sablé)
- [Table of contents](#table-of-contents)
- [Frameworks, Platforms and Libraries](#frameworks-platforms-and-libraries)
- [Installation](#installation)
- [Tests](#tests)
- [Deployment](#deployment)
- [Available Commands](#available-commands)
- [Changelog](#changelog)
- [Architecture](#architecture)
- [Règles d'Or (Verrouillées par les tests)](#règles-dor-verrouillées-par-les-tests)
## Frameworks, Platforms and Libraries
<!-- https://github.com/Ileriayo/markdown-badges -->
[![SASS](https://img.shields.io/badge/SASS-hotpink.svg?style=for-the-badge&logo=SASS&logoColor=white)](https://sass-lang.com/)
[![TypeScript](https://img.shields.io/badge/TS-blue.svg?style=for-the-badge&logo=TYPESCRIPT&logoColor=white)](https://www.typescriptlang.org/)
[![Vite](https://img.shields.io/badge/vite-%23646CFF.svg?style=for-the-badge&logo=vite&logoColor=white)](https://vite.dev/)
[![PHP](https://img.shields.io/badge/php-%23777BB4.svg?style=for-the-badge&logo=php&logoColor=white)](https://www.php.net/)
[![Laravel](https://img.shields.io/badge/laravel-%23FF2D20.svg?style=for-the-badge&logo=laravel&logoColor=white)](https://laravel.com/docs/master)
[![Vue.js](https://img.shields.io/badge/vuejs-%234FC08D.svg?style=for-the-badge&logo=vue.js&logoColor=white)](https://vuejs.org/)
## Installation
```bash
cp .env.example .env
vi .env
```
composer install
php artisan storage:link
npm i
npm run prod
cd ./ressources/views/front/
mkdir <theme-folder>
cd <theme-folder>
git clone <theme-repo> .
cd ./../../../../
npm install && npm run prod
```
## Tests
## Back-Office
Création de compte
```bash
cp .env.example .env.testing
vi .env.testing
composer install
php artisan storage:link
composer run test
```
## Deployment
```bash
php artisan schedule:run
```
## Available Commands
```bash
php artisan ldcweb:admin-secure-sync
```
**Créer un utilisateur :**
```bash
php artisan create:admin
```
## Changelog
See the [CHANGELOG.md](CHANGELOG.md) file for details.
## Architecture
Ce projet suit les principes de **l'Architecture Hexagonale (Ports et Adapteurs)** et du **Domain-Driven Design (DDD)**.
L'objectif est d'isoler au maximum la logique métier de l'infrastructure (framework Laravel, base de données, requêtes réseau) pour avoir un code robuste, pérenne et facile à tester.
### Règles d'Or (Verrouillées par les tests)
Afin de faciliter l'onboarding et d'assurer que l'architecture ne dérive pas, **Pest Arch** et **PHPStan (Level 9)** garantissent les principes suivants à chaque commit :
1. **Isolation totale du Domaine (Le Cœur)** : Le code dans `domain/` ne doit **jamais** dépendre du Framework (`Illuminate\`), de `App\`, ou de `Infrastructure\`. Il ne dépend que de PHP natif.
2. **Inversion des Dépendances** : L'Application et l'Infrastructure dépendent du Domaine, et jamais l'inverse. Le domaine expose des `Ports` (Interfaces) que l'infrastructure implémente via ses `Adapters`.
3. **Entités et Objets-Valeurs Purs** : Le domaine manipule ses propres Entités et `ValueObjects` validés et typés fortement. L'utilisation directe des modèles Eloquent est strictement interdite dans le domaine.
4. **Conventions de Nommage** : Chaque composant a une place et un suffixe stricts : `*Service`, `*Repository`, `*Exception`, `*Dto`. Les tests échoueront si la convention n'est pas respectée.
URL : /bo

View file

@ -1,33 +0,0 @@
# Règles Métiers du projet
Les règles reposent sur le code de la partie **Domaine**.
## 1. Planification des Retraits
La sélection du retrait se fait désormais sur une journée complète et non plus par tranches horaires via `PickupSchedulingService`.
- **Jour de retrait standard** : Uniquement le **Vendredi** (`PICKUP_DAY = WeekDay::Friday`).
- **Heure de clôture (Cutoff)** : **Lundi à 14h00** de la même semaine (`CUTOFF_DAY = Monday`, `CUTOFF_HOUR = 14`).
- *Comportement* : Une commande validée avant Lundi 14h00 est préparée pour le vendredi en cours. Passé ce délai, le premier jour disponible bascule automatiquement sur le vendredi de la semaine suivante.
- **Gestion des Exceptions** :
- Les fermetures et jours fériés sont gérés via des `ShopPickupException`.
- Une exception peut **fermer le retrait**, ou proposer un **jour de remplacement**. Ce jour de remplacement peut posséder sa propre limite de validation (`orderDeadlineAt`).
- Une tâche planifiée `UpsertShopsPickupExceptionsUsingApiForYear` est lancée pour provisionner les jours fériés de l'année pour toutes les boutiques
## 2. Limites de Montants
Les montants pouvant être commandés sont gérés par un service dédié `CustomerOrderLimitPolicy`
- **Plafond par commande** : **200€ max** (`maxOrderAmountCents = 20000`). Lève une `OrderLimitExceededException`.
- **Plafond hebdomadaire** : **200€ max par client** sur l'ensemble de la semaine (`maxWeeklyAmountCents = 20000`). Lève une `WeeklyLimitExceededException`.
## 3. Disponibilité des Produits
Elle est géré par le service `ProductAvailabilityService`.
- **Conditions d'ajout au panier** :
- Le produit est actif (`status = true`).
- Il est éligible INCO (`hasInco = true`).
- La date actuelle est supérieure ou égale à `orderable_start` (si défini).
- La date actuelle est inférieure à `orderable_end` (si défini).

61
RGPD.md
View file

@ -1,61 +0,0 @@
# RGPD
## Site
### En base de données
| Table | Colonne | Contenu | Utilisation |
| ----------- | -------------------------- | ----------------------------------------------- | -------------------------------------------------------- |
| customers | email | Adresse email | Identifiant du client |
| customers | hashcode | Code unique de l'utilisateur en CRM | Identifiant du client en CRM |
| customers | created_at | Date | Suivi d'activité |
| customers | updated_at | Date | Suivi d'activité |
| customers | last_login_at | Date | Suivi de la dernière connexion au compte client |
| --- | --- | --- | --- |
| orders | employee.matricule | Matricule de l'employé | Identifiant unique de l'employé lié à la commande |
| orders | employee.firstname | Prénom de l'employé | Affichage et personnalisation de la commande |
| orders | employee.lastname | Nom de l'employé | Affichage et personnalisation de la commande |
| orders | employee.facility | Établissement de l'employé | Localisation de l'employé pour la gestion de la commande |
| orders | employee.site | Site de l'employé | Localisation du site de l'employé |
| orders | employee.service | Service de l'employé | Service d'appartenance de l'employé |
| orders | delivery_address.name | Nom du lieu de livraison | Identification du contact ou lieu de livraison |
| orders | delivery_address.address | Adresse de livraison | Expédition et livraison de la commande |
| orders | delivery_address.postcode | Code postal de livraison | Routage de la livraison |
| orders | delivery_address.city | Ville de livraison | Routage de la livraison |
| orders | delivery_address.phone | Téléphone de contact pour la livraison | Contact en cas de problème de livraison |
| orders | delivery_address.latitude | Latitude de l'adresse de livraison | Géolocalisation pour optimiser la livraison |
| orders | delivery_address.longitude | Longitude de l'adresse de livraison | Géolocalisation pour optimiser la livraison |
| orders | employee_matricule | Matricule de l'employé (colonne virtuelle) | Indexation et recherche rapide des commandes par employé |
| --- | --- | --- | --- |
| order_lines | product_id | Identifiant du produit présent dans la commande | Détail de la commande |
| order_lines | quantité | Quantité du produit présent dans la commande | Détail de la commande |
| --- | --- | --- | --- |
| employees | customer_id | Identifiant client | Permet de relier un salarié à un compte client du site |
| employees | matricule | Matricule | Matricule du salarié |
| employees | nom | Nom | Nom du salarié |
| employees | prenom | Prénom | Prénom du salarié |
| employees | start_date | Date de début du contrat | Activation du compte |
| employees | end_date | Date de fin du contrat | Désactivation du compte |
| employees | etablissement | Identifiant établissement | Localiser l'employé pour le dépôt de la commande |
| employees | site | Identifiant site | Localiser l'employé pour le dépôt de la commande |
| employees | service | Identifiant service | Localiser l'employé pour le dépôt de la commande |
| --- | --- | --- | --- |
| users | first_name | Prénom | Affichage à différents endroits du BO |
| users | last_name | Nom | Affichage à différents endroits du BO |
| users | email | Adresse mail | Identifiant, sert à la connexion au BO |
### Formulaire de contact (marketing)
| Donnée | Contenu |
| -------------- | -------------------------------- |
| Nom | Nom de la personne |
| Prénom | Prénom de la personne |
| Adresse E-mail | Adresse email de la personne |
| Pièce jointe | Photo renseignée par la personne |
## Cookies
| Service | Utilité |
| ------- | ----------------- |
| Matomo | Mesure d'audience |

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Basket;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Domain\Services\BasketService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Basket\AddToBasketInput, void>
*/
final class AddToBasket implements Action
{
public function __construct(
private readonly BasketService $basketService,
) {
}
#[\Override]
public function __invoke(ActionInput $input): void
{
Assert::isInstanceOf($input, AddToBasketInput::class);
$this->basketService->add(
$input->productId,
$input->qty
);
}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Basket;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\ProductId;
use Webmozart\Assert\Assert;
final readonly class AddToBasketInput implements ActionInput
{
public function __construct(
public ProductId $productId,
public int $qty,
) {
Assert::greaterThan($qty, 0);
}
}

View file

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Basket;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Contracts\ActionInputEmpty;
use Domain\Services\BasketService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Contracts\ActionInputEmpty, void>
*/
final class EmptyBasket implements Action
{
public function __construct(
private readonly BasketService $basketService,
) {
}
#[\Override]
public function __invoke(ActionInput $input): void
{
Assert::isInstanceOf(
$input,
ActionInputEmpty::class
);
$this->basketService->empty();
}
}

View file

@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Basket;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Domain\Services\BasketService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Basket\RemoveQuantityFromBasketInput|\App\Actions\Basket\RemoveProductFromBasketInput, void>
*/
final class RemoveFromBasket implements Action
{
public function __construct(
private readonly BasketService $basketService,
) {
}
#[\Override]
public function __invoke(ActionInput $input): void
{
Assert::isInstanceOfAny(
$input,
[
RemoveQuantityFromBasketInput::class,
RemoveProductFromBasketInput::class,
]
);
if ($input instanceof RemoveQuantityFromBasketInput) {
$this->basketService->remove(
$input->productId,
$input->qty
);
} elseif ($input instanceof RemoveProductFromBasketInput) {
$this->basketService->removeLine(
$input->productId
);
}
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Basket;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\ProductId;
final readonly class RemoveProductFromBasketInput implements ActionInput
{
public function __construct(
public ProductId $productId,
) {
}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Basket;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\ProductId;
use Webmozart\Assert\Assert;
final readonly class RemoveQuantityFromBasketInput implements ActionInput
{
public function __construct(
public ProductId $productId,
public int $qty,
) {
Assert::greaterThan($qty, 0);
}
}

View file

@ -1,77 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithOrder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use SortDirection;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Common\ChangeModelOrderInput, bool>
*/
final class ChangeModelOrder implements Action
{
private const int MAX_ORDER_VALUE = 4294967295;
/**
* @param \App\Actions\Common\ChangeModelOrderInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, ChangeModelOrderInput::class);
$model = $input->model;
$goUp = $input->direction === SortDirection::Ascending;
$currentOrder = $model->getOrder();
$usesSoftDeletes = in_array(SoftDeletes::class, class_uses_recursive($model), true)
|| method_exists($model, 'trashed');
/** @var Builder<Model&ModelWithOrder> $baseQuery */
$baseQuery = $usesSoftDeletes
// @phpstan-ignore method.notFound (withTrashed)
? $model::query()->withTrashed()
: $model::query();
/** @var (Model&ModelWithOrder)|null $adjacentModel */
$adjacentModel = $baseQuery
->where('order', $goUp ? '<' : '>', $currentOrder)
->orderBy('order', $goUp ? SortDirection::Descending : SortDirection::Ascending)
->first();
if ($adjacentModel === null) {
return false;
}
$this->permuteModelOrders($model, $adjacentModel);
return true;
}
private function permuteModelOrders(Model & ModelWithOrder $model1, Model & ModelWithOrder $model2): void
{
$order1 = $model1->getOrder();
$order2 = $model2->getOrder();
// Deactivate timestamps to avoid updating updated_at
$model1->timestamps = false;
$model2->timestamps = false;
$model1->setOrder(self::MAX_ORDER_VALUE);
$model1->saveQuietly();
$model2->setOrder($order1);
$model2->saveQuietly();
$model1->setOrder($order2);
$model1->saveQuietly();
}
}

View file

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithOrder;
use Illuminate\Database\Eloquent\Model;
use SortDirection;
use Webmozart\Assert\Assert;
final readonly class ChangeModelOrderInput implements ActionInput
{
public function __construct(
public Model & ModelWithOrder $model,
public SortDirection $direction,
) {
Assert::isInstanceOf($this->model, Model::class);
Assert::isInstanceOf($this->model, ModelWithOrder::class);
}
}

View file

@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithOrder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Common\ChangeModelOrderPositionInput, bool>
*/
final class ChangeModelOrderPosition implements Action
{
private const int MAX_ORDER_VALUE = 4294967295;
/**
* @param \App\Actions\Common\ChangeModelOrderPositionInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, ChangeModelOrderPositionInput::class);
$model = $input->model;
$currentOrder = $model->getOrder();
$targetOrder = $input->targetOrder;
if ($currentOrder === $targetOrder) {
return true;
}
$usesSoftDeletes = in_array(SoftDeletes::class, class_uses_recursive($model), true)
|| method_exists($model, 'trashed');
/** @var Builder<Model&ModelWithOrder> $baseQuery */
$baseQuery = $usesSoftDeletes
// @phpstan-ignore method.notFound (withTrashed)
? $model::query()->withTrashed()
: $model::query();
/** @var int|null $maxOrder */
$maxOrder = (clone $baseQuery)->max('order');
if ($maxOrder !== null && $targetOrder > $maxOrder) {
$targetOrder = (int) $maxOrder;
}
if ($currentOrder === $targetOrder) {
return true;
}
// Deactivate timestamps
$model->timestamps = false;
// Step 1: Temporarily park the moving model at MAX_ORDER_VALUE
$model->setOrder(self::MAX_ORDER_VALUE);
$model->saveQuietly();
if ($targetOrder < $currentOrder) {
// Moving UP
/** @var \Illuminate\Database\Eloquent\Collection<int, Model&ModelWithOrder> $itemsToShift */
$itemsToShift = (clone $baseQuery)
->where('order', '>=', $targetOrder)
->where('order', '<', $currentOrder)
->whereKeyNot($model->getKey())
->orderBy('order', 'desc')
->get();
foreach ($itemsToShift as $item) {
$item->timestamps = false;
$item->setOrder($item->getOrder() + 1);
$item->saveQuietly();
}
} else {
// Moving DOWN
/** @var \Illuminate\Database\Eloquent\Collection<int, Model&ModelWithOrder> $itemsToShift */
$itemsToShift = (clone $baseQuery)
->where('order', '>', $currentOrder)
->where('order', '<=', $targetOrder)
->whereKeyNot($model->getKey())
->orderBy('order', 'asc')
->get();
foreach ($itemsToShift as $item) {
$item->timestamps = false;
$item->setOrder($item->getOrder() - 1);
$item->saveQuietly();
}
}
// Step 2: Set the moving model to the target order
$model->setOrder($targetOrder);
$model->saveQuietly();
return true;
}
}

View file

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithOrder;
use Illuminate\Database\Eloquent\Model;
use Webmozart\Assert\Assert;
final readonly class ChangeModelOrderPositionInput implements ActionInput
{
public function __construct(
public Model & ModelWithOrder $model,
public int $targetOrder,
) {
Assert::isInstanceOf($this->model, Model::class);
Assert::isInstanceOf($this->model, ModelWithOrder::class);
Assert::greaterThanEq($this->targetOrder, 1);
}
}

View file

@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Enums\MassActionType;
use App\Interfaces\ModelWithStar;
use App\Interfaces\ModelWithStatus;
use Illuminate\Database\Eloquent\Model;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Common\ExecuteMassActionInput, int>
*/
final class ExecuteMassAction implements Action
{
/**
* @param \App\Actions\Common\ExecuteMassActionInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): int
{
Assert::isInstanceOf($input, ExecuteMassActionInput::class);
$this->assertModelMatchesInput($input);
return $this->updateModel($input);
}
private function assertModelMatchesInput(ExecuteMassActionInput $input): void
{
$modelClass = $input->modelClass;
switch ($input->action) {
case MassActionType::Star:
case MassActionType::Unstar:
Assert::isAOf($modelClass, ModelWithStar::class);
break;
case MassActionType::Publish:
case MassActionType::Unpublish:
Assert::isAOf($modelClass, ModelWithStatus::class);
break;
default:
throw new \UnexpectedValueException('Unsupported mass action type: ' . $input->action->value);
}
}
private function updateModel(ExecuteMassActionInput $input): int
{
$modelClass = $input->modelClass;
/** @var class-string<Model> $modelClass */
$model = new $modelClass();
$models = $modelClass::query()
->whereIn($model->getKeyName(), $input->modelKeyList)
->get();
$updated = 0;
foreach ($models as $foundModel) {
if (! $foundModel instanceof Model) {
continue;
}
switch ($input->action) {
case MassActionType::Star:
/** @var Model&ModelWithStar $foundModel */
$foundModel->setStar(true);
break;
case MassActionType::Unstar:
/** @var Model&ModelWithStar $foundModel */
$foundModel->setStar(false);
break;
case MassActionType::Publish:
/** @var Model&ModelWithStatus $foundModel */
$foundModel->setStatus(true);
break;
case MassActionType::Unpublish:
/** @var Model&ModelWithStatus $foundModel */
$foundModel->setStatus(false);
break;
default:
throw new \UnexpectedValueException('Unsupported mass action type: ' . $input->action->value);
}
$foundModel->saveOrFail();
$updated++;
}
return $updated;
}
}

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\ActionInput;
use App\Enums\MassActionType;
use Illuminate\Database\Eloquent\Model;
use Webmozart\Assert\Assert;
final readonly class ExecuteMassActionInput implements ActionInput
{
/**
* @param class-string<Model> $modelClass
* @param array<int|string> $modelKeyList
*/
public function __construct(
public string $modelClass,
public MassActionType $action,
public array $modelKeyList,
) {
Assert::classExists($this->modelClass);
Assert::isAOf($this->modelClass, Model::class);
Assert::notEmpty($this->modelKeyList);
Assert::allScalar($this->modelKeyList);
foreach ($this->modelKeyList as $modelKey) {
Assert::stringNotEmpty((string) $modelKey);
}
}
}

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithStar;
use Illuminate\Database\Eloquent\Model;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Common\ToggleModelStarInput, \Illuminate\Database\Eloquent\Model&\App\Interfaces\ModelWithStar>
*/
final class ToggleModelStar implements Action
{
/**
* @param \App\Actions\Common\ToggleModelStarInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Model & ModelWithStar
{
Assert::isInstanceOf($input, ToggleModelStarInput::class);
$model = $input->model;
$model->toggleStar();
$model->saveOrFail();
return $model;
}
}

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithStar;
use Illuminate\Database\Eloquent\Model;
use Webmozart\Assert\Assert;
final readonly class ToggleModelStarInput implements ActionInput
{
public function __construct(
public Model & ModelWithStar $model,
) {
Assert::isInstanceOf($this->model, Model::class);
Assert::isInstanceOf($this->model, ModelWithStar::class);
}
}

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithStatus;
use Illuminate\Database\Eloquent\Model;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Common\ToggleModelStatusInput, \Illuminate\Database\Eloquent\Model&\App\Interfaces\ModelWithStatus>
*/
final class ToggleModelStatus implements Action
{
/**
* @param \App\Actions\Common\ToggleModelStatusInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Model & ModelWithStatus
{
Assert::isInstanceOf($input, ToggleModelStatusInput::class);
$model = $input->model;
$model->toggleStatus();
$model->saveOrFail();
return $model;
}
}

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithStatus;
use Illuminate\Database\Eloquent\Model;
use Webmozart\Assert\Assert;
final readonly class ToggleModelStatusInput implements ActionInput
{
public function __construct(
public Model & ModelWithStatus $model,
) {
Assert::isInstanceOf($this->model, Model::class);
Assert::isInstanceOf($this->model, ModelWithStatus::class);
}
}

View file

@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Services\CrmService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Crm\UnsubscribeFromNewsletterInput, \App\Actions\Crm\UnsubscribeFromNewsletterOutput>
*/
final class UnsubscribeFromNewsletter implements Action
{
public function __construct(
private readonly CrmService $crmService
) {
}
#[\Override]
public function __invoke(ActionInput $input): UnsubscribeFromNewsletterOutput
{
Assert::isInstanceOf($input, UnsubscribeFromNewsletterInput::class);
try {
$isUnsubscribed = $this->crmService->unsubscribeCustomer(
$input->hashcode
);
return $isUnsubscribed ? UnsubscribeFromNewsletterOutput::SUCCESS : UnsubscribeFromNewsletterOutput::FAILURE;
} catch (\Throwable $e) {
report($e);
return UnsubscribeFromNewsletterOutput::FAILURE;
}
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use App\Contracts\ActionInput;
use Shared\ValueObjects\CrmHashcode;
final readonly class UnsubscribeFromNewsletterInput implements ActionInput
{
public function __construct(
public CrmHashcode $hashcode
) {
}
}

View file

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
enum UnsubscribeFromNewsletterOutput
{
case SUCCESS;
case INVALIDLINK;
case FAILURE;
}

View file

@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Services\CrmService;
use Infrastructure\Adapters\Services\AppCustomerAuthService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Crm\UpdateCustomerInput, \App\Actions\Crm\UpdateCustomerOutput>
*/
final class UpdateCustomer implements Action
{
public function __construct(
private readonly AppCustomerAuthService $authService,
private readonly CrmService $crmService
) {
}
#[\Override]
public function __invoke(ActionInput $input): UpdateCustomerOutput
{
Assert::isInstanceOf($input, UpdateCustomerInput::class);
$customer = $this->authService->getCustomerAsMandatory();
try {
/** @var array{firstname:string,lastname:string,birth_date:string,phone?:string,address?:string,postcode:string,city:string,opt_in?:bool} $data */
$data = [
'firstname' => $input->firstname,
'lastname' => $input->lastname,
'birth_date' => $input->birthDate,
'postcode' => $input->postcode,
'city' => $input->city,
'opt_in' => $input->optIn,
];
if ($input->phone !== null) {
$data['phone'] = $input->phone;
}
if ($input->address !== null) {
$data['address'] = $input->address;
}
$result = $this->crmService->updateCustomerInfos(
$customer,
$data
);
return new UpdateCustomerOutput($result);
} catch (\Throwable $e) {
report($e);
return new UpdateCustomerOutput(false);
}
}
}

View file

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use App\Contracts\ActionInput;
final readonly class UpdateCustomerInput implements ActionInput
{
public function __construct(
public string $firstname,
public string $lastname,
public string $birthDate,
public ?string $phone,
public ?string $address,
public string $postcode,
public string $city,
public bool $optIn,
) {
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use Domain\Models\ValueObjects\CrmCustomer;
use Illuminate\Validation\ValidationException;
final readonly class UpdateCustomerOutput
{
public function __construct(
public CrmCustomer | ValidationException | false $result
) {
}
}

View file

@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Services\CrmService;
use Domain\Models\ValueObjects\CrmCustomer;
use Infrastructure\Adapters\Services\AppCustomerAuthService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Crm\UpdateCustomerPasswordInput, \App\Actions\Crm\UpdateCustomerPasswordOutput>
*/
final class UpdateCustomerPassword implements Action
{
public function __construct(
private readonly AppCustomerAuthService $authService,
private readonly CrmService $crmService
) {
}
#[\Override]
public function __invoke(ActionInput $input): UpdateCustomerPasswordOutput
{
Assert::isInstanceOf($input, UpdateCustomerPasswordInput::class);
$hashcode = $this->authService->getCustomerAsMandatory()->hashcode;
$crmCustomer = $this->crmService->getCustomerFromHashCode(
$hashcode
);
if ($crmCustomer instanceof CrmCustomer === false) {
return new UpdateCustomerPasswordOutput(false);
}
try {
$result = $this->crmService->changePassword(
$crmCustomer,
$input->currentPassword,
$input->newPassword,
);
return new UpdateCustomerPasswordOutput($result);
} catch (\Throwable $e) {
report($e);
return new UpdateCustomerPasswordOutput(false);
}
}
}

View file

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
final readonly class UpdateCustomerPasswordInput implements ActionInput
{
public function __construct(
#[\SensitiveParameter]
public string $currentPassword,
#[\SensitiveParameter]
public string $newPassword,
) {
Assert::notEmpty($currentPassword);
Assert::notEmpty($newPassword);
Assert::notSame($currentPassword, $newPassword);
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use Domain\Models\ValueObjects\CrmCustomer;
use Illuminate\Validation\ValidationException;
final readonly class UpdateCustomerPasswordOutput
{
public function __construct(
public CrmCustomer | ValidationException | false $result
) {
}
}

View file

@ -1,113 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Customers;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Contracts\Repositories\EmployeeRepository;
use App\Exceptions\Crm\CrmCustomerExistsException;
use App\Exceptions\CrmServiceException;
use App\Models\Customer;
use App\Services\CrmService;
use Domain\Enums\Country;
use Domain\Models\ValueObjects\CrmCustomer;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Validation\ValidationException;
use Infrastructure\Adapters\Services\AppCustomerAuthService;
use Ramsey\Uuid\Uuid;
use Shared\ValueObjects\CrmHashcode;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Customers\RegisterCustomerInput, \App\Models\Customer>
*/
final class RegisterCustomer implements Action
{
public function __construct(
private readonly AppCustomerAuthService $authService,
private readonly CrmService $crmService,
private readonly EmployeeRepository $employeeRepository,
) {
}
/**
* @param \App\Actions\Customers\RegisterCustomerInput $input
*
* @throws ValidationException
* @throws \UnexpectedValueException
* @throws ModelNotFoundException
*/
#[\Override]
public function __invoke(ActionInput $input): Customer
{
Assert::isInstanceOf($input, RegisterCustomerInput::class);
// * Persist and associate Customer + Employee
$customer = new Customer([
'email' => $input->email,
]);
// * Temp hashcode to satisfy constraint
$customer->hashcode = new CrmHashcode(Uuid::uuid4());
$customer->saveOrFail();
$employee = $this->employeeRepository->findByMatriculeAndCode(
$input->matricule,
$input->code
);
if (! $employee) {
throw new ModelNotFoundException('Employee not found');
}
$employee->customer()->associate($customer);
$employee->saveOrFail();
$crmCustomer = new CrmCustomer(
hashcode: null,
email: $input->email->value,
password: $input->password,
firstname: $input->firstname,
lastname: $input->lastname,
birthDate: $input->birthDate->format('Y-m-d'),
phone: $input->phone?->value,
address: $input->address,
address2: null,
city: $input->city,
postcode: $input->postcode,
country: Country::france,
optIn: $input->optIn,
status: false,
lastConnection: null,
);
try {
$result = $this->crmService->persist($crmCustomer);
if (! $result->hashcode) {
throw new \UnexpectedValueException(
'CRM did not return a hashcode for the created customer'
);
}
$customer->hashcode = CrmHashcode::fromString($result->hashcode);
$customer->saveOrFail();
$customer->sendEmailVerificationNotification();
$this->authService->logIn($customer);
return $customer;
} catch (CrmCustomerExistsException) {
throw ValidationException::withMessages([
'error' => trans('An account is already associated with this email address'),
]);
} catch (CrmServiceException $e) {
report($e);
throw ValidationException::withMessages([
'error' => trans('An error has occurred. Please try again later'),
]);
}
}
}

View file

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Customers;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Code;
use Domain\Models\ValueObjects\Email;
use Domain\Models\ValueObjects\Matricule;
use Domain\Models\ValueObjects\PhoneNumber;
use Webmozart\Assert\Assert;
final readonly class RegisterCustomerInput implements ActionInput
{
public function __construct(
public Matricule $matricule,
public Code $code,
public string $firstname,
public string $lastname,
public Email $email,
public string $password,
public \DateTimeImmutable $birthDate,
public ?PhoneNumber $phone,
public ?string $address,
public string $postcode,
public string $city,
public bool $optIn = false,
) {
Assert::stringNotEmpty(trim($this->firstname));
Assert::maxLength($this->firstname, 255);
Assert::stringNotEmpty(trim($this->lastname));
Assert::maxLength($this->lastname, 255);
Assert::stringNotEmpty(trim($this->password));
Assert::stringNotEmpty(trim($this->postcode));
Assert::maxLength($this->postcode, 20);
Assert::stringNotEmpty(trim($this->city));
Assert::maxLength($this->city, 255);
if ($this->address !== null) {
Assert::maxLength($this->address, 255);
}
}
}

View file

@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Discounts;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Discount;
use App\Models\Product;
use Illuminate\Support\Carbon;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Discounts\CreateDiscountInput, \App\Models\Discount>
*/
final class CreateDiscount implements Action
{
/**
* @param \App\Actions\Discounts\CreateDiscountInput $input
*/
public function __invoke(ActionInput $input): Discount
{
Assert::isInstanceOf($input, CreateDiscountInput::class);
$discount = new Discount();
$discount->type = $input->type;
$discount->value = $input->value;
$discount->is_old_price_displayed = $input->is_old_price_displayed;
$discount->date_start = $input->period->start !== null ? Carbon::instance($input->period->start) : null;
$discount->date_end = $input->period->end !== null ? Carbon::instance($input->period->end) : null;
$discount->saveOrFail();
$this->syncAssociatedProducts($discount, $input->associatedProductsIds);
return $discount;
}
/**
* @param array<string> $productIdList
*/
private function syncAssociatedProducts(Discount $discount, array $productIdList): void
{
Product::query()
->where('discount_id', $discount->id)
->whereNotIn('id', $productIdList)
->update(['discount_id' => null]);
if (! empty($productIdList)) {
Product::query()
->whereIn('id', $productIdList)
->update(['discount_id' => $discount->id]);
}
}
}

View file

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Discounts;
use App\Contracts\ActionInput;
use Domain\Enums\DiscountType;
use Domain\Models\ValueObjects\DatePeriod;
use Domain\Models\ValueObjects\DiscountValue;
use Webmozart\Assert\Assert;
final readonly class CreateDiscountInput implements ActionInput
{
/**
* @param array<string> $associatedProductsIds
*/
public function __construct(
public DiscountType $type,
public DiscountValue $value,
public bool $is_old_price_displayed,
public DatePeriod $period = new DatePeriod(),
public array $associatedProductsIds = [],
) {
Assert::same($this->type, $this->value->type, 'The discount value type must match the discount type.');
Assert::allString($this->associatedProductsIds);
}
}

View file

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Discounts;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Discounts\DeleteDiscountInput, bool>
*/
final class DeleteDiscount implements Action
{
/**
* @param \App\Actions\Discounts\DeleteDiscountInput $input
*/
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, DeleteDiscountInput::class);
return (bool) $input->discount->deleteOrFail();
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Discounts;
use App\Contracts\ActionInput;
use App\Models\Discount;
final readonly class DeleteDiscountInput implements ActionInput
{
public function __construct(
public Discount $discount,
) {
}
}

View file

@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Discounts;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Discount;
use App\Models\Product;
use Illuminate\Support\Carbon;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Discounts\UpdateDiscountInput, \App\Models\Discount>
*/
final class UpdateDiscount implements Action
{
/**
* @param \App\Actions\Discounts\UpdateDiscountInput $input
*/
public function __invoke(ActionInput $input): Discount
{
Assert::isInstanceOf($input, UpdateDiscountInput::class);
$discount = $input->discount;
$discount->type = $input->type;
$discount->value = $input->value;
$discount->is_old_price_displayed = $input->is_old_price_displayed;
$discount->date_start = $input->period->start !== null ? Carbon::instance($input->period->start) : null;
$discount->date_end = $input->period->end !== null ? Carbon::instance($input->period->end) : null;
$discount->saveOrFail();
$this->syncAssociatedProducts($discount, $input->associatedProductsIds);
return $discount;
}
/**
* @param array<string> $productIdList
*/
private function syncAssociatedProducts(Discount $discount, array $productIdList): void
{
Product::query()
->where('discount_id', $discount->id)
->whereNotIn('id', $productIdList)
->update(['discount_id' => null]);
if (! empty($productIdList)) {
Product::query()
->whereIn('id', $productIdList)
->update(['discount_id' => $discount->id]);
}
}
}

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Discounts;
use App\Contracts\ActionInput;
use App\Models\Discount;
use Domain\Enums\DiscountType;
use Domain\Models\ValueObjects\DatePeriod;
use Domain\Models\ValueObjects\DiscountValue;
use Webmozart\Assert\Assert;
final readonly class UpdateDiscountInput implements ActionInput
{
/**
* @param array<string> $associatedProductsIds
*/
public function __construct(
public Discount $discount,
public DiscountType $type,
public DiscountValue $value,
public bool $is_old_price_displayed,
public DatePeriod $period = new DatePeriod(),
public array $associatedProductsIds = [],
) {
Assert::same($this->type, $this->value->type, 'The discount value type must match the discount type.');
Assert::allString($this->associatedProductsIds);
}
}

View file

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Employee;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Contracts\ActionInputEmpty;
use App\Contracts\Repositories\EmployeeRepository;
use App\Services\EmployeeSyncService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Contracts\ActionInputEmpty, void>
*/
final class SynchronizeEmployeesWithArcadiaApi implements Action
{
public function __construct(
private readonly EmployeeRepository $employeeRepository,
private readonly EmployeeSyncService $syncService
) {
}
/** @param \App\Contracts\ActionInputEmpty $input */
public function __invoke(ActionInput $input): void
{
Assert::isAOf($input, ActionInputEmpty::class);
$wasLastSyncedAt = $this->employeeRepository->getMostRecentSyncDate();
$this->syncService->synchronize($wasLastSyncedAt);
}
}

View file

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Faqs;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Faq;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Faqs\CreateFaqInput, \App\Models\Faq>
*/
final class CreateFaq implements Action
{
/**
* @param \App\Actions\Faqs\CreateFaqInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Faq
{
Assert::isInstanceOf($input, CreateFaqInput::class);
$faq = new Faq();
$faq->fill([
'question' => $input->question,
'answer' => $input->answer,
]);
$faq->saveOrFail();
return $faq;
}
}

View file

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Faqs;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
final readonly class CreateFaqInput implements ActionInput
{
public function __construct(
public string $question,
public string $answer,
) {
Assert::stringNotEmpty(trim($this->question), 'Question cannot be empty.');
Assert::minLength(trim($this->question), 4);
Assert::maxLength($this->question, 255);
Assert::stringNotEmpty(trim($this->answer), 'Answer cannot be empty.');
Assert::minLength(trim($this->answer), 4);
}
}

View file

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Faqs;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Faqs\DeleteFaqInput, void>
*/
final class DeleteFaq implements Action
{
/**
* @param \App\Actions\Faqs\DeleteFaqInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): void
{
Assert::isInstanceOf($input, DeleteFaqInput::class);
$input->faq->deleteOrFail();
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Faqs;
use App\Contracts\ActionInput;
use App\Models\Faq;
final readonly class DeleteFaqInput implements ActionInput
{
public function __construct(
public Faq $faq,
) {
}
}

View file

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Faqs;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Faq;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Faqs\UpdateFaqInput, \App\Models\Faq>
*/
final class UpdateFaq implements Action
{
/**
* @param \App\Actions\Faqs\UpdateFaqInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Faq
{
Assert::isInstanceOf($input, UpdateFaqInput::class);
$faq = $input->faq;
$faq->fill([
'question' => $input->question,
'answer' => $input->answer,
]);
$faq->saveOrFail();
return $faq;
}
}

View file

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Faqs;
use App\Contracts\ActionInput;
use App\Models\Faq;
use Webmozart\Assert\Assert;
final readonly class UpdateFaqInput implements ActionInput
{
public function __construct(
public Faq $faq,
public string $question,
public string $answer,
) {
Assert::stringNotEmpty(trim($this->question), 'Question cannot be empty.');
Assert::minLength(trim($this->question), 4);
Assert::maxLength($this->question, 255);
Assert::stringNotEmpty(trim($this->answer), 'Answer cannot be empty.');
Assert::minLength(trim($this->answer), 4);
}
}

View file

@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\PickupTimeSlot;
use Domain\Services\OrderTunnelService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Orders\ApplyPickTimeSlotInput, ?\Domain\Models\ValueObjects\PickupTimeSlot>
*/
final class ApplyPickTimeSlot implements Action
{
public function __construct(
private readonly OrderTunnelService $orderTunnelService,
) {
}
/**
* @param \App\Actions\Orders\ApplyPickTimeSlotInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): ?PickupTimeSlot
{
Assert::isInstanceOf($input, ApplyPickTimeSlotInput::class);
$builder = $this->orderTunnelService->getBuilderFromBasketOrFail();
$shop = $this->orderTunnelService->getShopForCustomer($input->shopperId);
$timeSlots = $this->orderTunnelService->getAvailableTimeSlots($shop);
return $this->orderTunnelService->applyTimeSlot(
$builder,
$shop,
$input->timeslot,
$timeSlots
);
}
}

View file

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\ActionInput;
use Carbon\CarbonInterface;
use Domain\Models\ValueObjects\Entities\ShopperId;
final readonly class ApplyPickTimeSlotInput implements ActionInput
{
public function __construct(
public ShopperId $shopperId,
public CarbonInterface $timeslot,
) {
}
}

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Domain\Services\OrderService as DomainOrderService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Orders\CancelOrderInput, void>
*/
final class CancelOrder implements Action
{
public function __construct(
private readonly DomainOrderService $domainOrderService,
) {
}
/**
* @param \App\Actions\Orders\CancelOrderInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): void
{
Assert::isInstanceOf($input, CancelOrderInput::class);
$this->domainOrderService->cancelOrder($input->order->getId());
}
}

View file

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\ActionInput;
use App\Models\Order;
use Webmozart\Assert\Assert;
final readonly class CancelOrderInput implements ActionInput
{
public function __construct(
public Order $order,
) {
Assert::stringNotEmpty(trim($this->order->id));
}
}

View file

@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\OrderReference;
use Domain\Services\OrderTunnelService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Orders\FinalizeOrderInput, \Domain\Models\ValueObjects\OrderReference>
*/
final class FinalizeOrder implements Action
{
public function __construct(
private readonly OrderTunnelService $orderTunnelService,
) {
}
/**
* @param \App\Actions\Orders\FinalizeOrderInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): OrderReference
{
Assert::isInstanceOf($input, FinalizeOrderInput::class);
$builder = $this->orderTunnelService->getBuilderCompletedOrFail();
$order = $this->orderTunnelService->finalizeOrder($builder);
return $order->reference;
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\ShopperId;
final readonly class FinalizeOrderInput implements ActionInput
{
public function __construct(
public ?ShopperId $shopperId = null,
) {
}
}

View file

@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Domain\Services\OrderTunnelService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Orders\InitializeOrderTunnelInput, void>
*/
final class InitializeOrderTunnel implements Action
{
public function __construct(
private readonly OrderTunnelService $orderTunnelService,
) {
}
/**
* @param \App\Actions\Orders\InitializeOrderTunnelInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): void
{
Assert::isInstanceOf($input, InitializeOrderTunnelInput::class);
$this->orderTunnelService->initializeFromBasket(
$input->basket,
$input->shopperId
);
}
}

View file

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\ActionInput;
use Domain\Models\Entities\Basket;
use Domain\Models\ValueObjects\Entities\ShopperId;
final readonly class InitializeOrderTunnelInput implements ActionInput
{
public function __construct(
public Basket $basket,
public ShopperId $shopperId,
) {
}
}

View file

@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Pages;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Page;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Pages\UpdatePageInput, \App\Models\Page>
*/
final class UpdatePage implements Action
{
/**
* @param \App\Actions\Pages\UpdatePageInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Page
{
Assert::isInstanceOf($input, UpdatePageInput::class);
$page = $input->page;
$page->fill([
'seo_title' => $input->seo->title,
'seo_description' => $input->seo->description,
'title' => $input->title,
'content' => $input->content,
]);
$page->saveOrFail();
return $page;
}
}

View file

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Pages;
use App\Contracts\ActionInput;
use App\Models\Page;
use Domain\Models\ValueObjects\SeoMetadata;
use Webmozart\Assert\Assert;
final readonly class UpdatePageInput implements ActionInput
{
public function __construct(
public Page $page,
public SeoMetadata $seo,
public ?string $title,
public string $content,
) {
Assert::stringNotEmpty(trim($this->content));
if ($this->title !== null) {
Assert::stringNotEmpty(trim($this->title));
Assert::maxLength($this->title, 255);
}
}
}

View file

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\PickupExceptions;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\PickupException;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\PickupExceptions\CreatePickupExceptionInput, \App\Models\PickupException>
*/
final class CreatePickupException implements Action
{
/**
* @param \App\Actions\PickupExceptions\CreatePickupExceptionInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): PickupException
{
Assert::isInstanceOf($input, CreatePickupExceptionInput::class);
$pickupException = new PickupException();
$pickupException->fill([
'date' => $input->date,
'label' => $input->label,
'repeatable' => $input->repeatable,
'repeat_frequency' => $input->repeat_frequency,
]);
$pickupException->setAsManuallySaved();
$pickupException->saveOrFail();
return $pickupException;
}
}

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\PickupExceptions;
use App\Contracts\ActionInput;
use Domain\Enums\RepeatFrequency;
use Webmozart\Assert\Assert;
final readonly class CreatePickupExceptionInput implements ActionInput
{
public function __construct(
public string $date,
public string $label,
public bool $repeatable,
public RepeatFrequency $repeat_frequency,
) {
Assert::regex($this->date, '/^\d{4}-\d{2}-\d{2}$/', 'Date must be in Y-m-d format.');
Assert::stringNotEmpty(trim($this->label), 'Label cannot be empty.');
Assert::maxLength(trim($this->label), 255, 'Label cannot exceed 255 characters.');
}
}

View file

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\PickupExceptions;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\PickupExceptions\DeletePickupExceptionInput, bool>
*/
final class DeletePickupException implements Action
{
/**
* @param \App\Actions\PickupExceptions\DeletePickupExceptionInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, DeletePickupExceptionInput::class);
return (bool) $input->pickupException->deleteOrFail();
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\PickupExceptions;
use App\Contracts\ActionInput;
use App\Models\PickupException;
final readonly class DeletePickupExceptionInput implements ActionInput
{
public function __construct(
public PickupException $pickupException,
) {
}
}

View file

@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\PickupExceptions;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\PickupException;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\PickupExceptions\UpdatePickupExceptionInput, \App\Models\PickupException>
*/
final class UpdatePickupException implements Action
{
/**
* @param \App\Actions\PickupExceptions\UpdatePickupExceptionInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): PickupException
{
Assert::isInstanceOf($input, UpdatePickupExceptionInput::class);
$input->pickupException->fill([
'date' => $input->date,
'label' => $input->label,
'repeatable' => $input->repeatable,
'repeat_frequency' => $input->repeat_frequency,
]);
$input->pickupException->setAsManuallySaved();
$input->pickupException->saveOrFail();
return $input->pickupException;
}
}

View file

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\PickupExceptions;
use App\Contracts\ActionInput;
use App\Models\PickupException;
use Domain\Enums\RepeatFrequency;
use Webmozart\Assert\Assert;
final readonly class UpdatePickupExceptionInput implements ActionInput
{
public function __construct(
public PickupException $pickupException,
public string $date,
public string $label,
public bool $repeatable,
public RepeatFrequency $repeat_frequency,
) {
Assert::regex($this->date, '/^\d{4}-\d{2}-\d{2}$/', 'Date must be in Y-m-d format.');
Assert::stringNotEmpty(trim($this->label), 'Label cannot be empty.');
Assert::maxLength(trim($this->label), 255, 'Label cannot exceed 255 characters.');
}
}

View file

@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\ProductIncos;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\ProductInco;
use App\Services\ArcadiaIncoService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\ProductIncos\AssociateProductIncoInput, \App\Models\ProductInco>
*/
final class AssociateProductInco implements Action
{
public function __construct(
private readonly ArcadiaIncoService $arcadiaIncoService,
) {
}
/**
* @param \App\Actions\ProductIncos\AssociateProductIncoInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): ProductInco
{
Assert::isInstanceOf($input, AssociateProductIncoInput::class);
$incoData = $this->arcadiaIncoService->fetchInco($input->no_art);
if ($incoData === null) {
throw new \RuntimeException(sprintf('INCO sheet #%d was not found on Arcadia API.', $input->no_art));
}
// * Remove existing product_inco for this product if present
$existing = ProductInco::query()->where('product_id', $input->product->id)->first();
if ($existing !== null) {
$existing->delete();
}
$inco = new ProductInco();
$inco->product_id = (string) $input->product->id;
$inco->no_art = $incoData->noArt;
$inco->ingredients = $incoData->ingredients;
$inco->preparation = $incoData->preparation;
$inco->servings = $incoData->servings;
$inco->allergens = $incoData->allergens;
$inco->selling_argument = $incoData->sellingArgument;
$inco->nutriscore = $incoData->nutriscore;
$inco->nutriscore_args = $incoData->nutriscoreArgs;
$inco->nutrition = $incoData->nutrition;
$inco->saveOrFail();
return $inco;
}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\ProductIncos;
use App\Contracts\ActionInput;
use App\Models\Product;
use Webmozart\Assert\Assert;
final readonly class AssociateProductIncoInput implements ActionInput
{
public function __construct(
public Product $product,
public int $no_art,
) {
Assert::greaterThanEq($this->no_art, 1, 'The INCO article number must be greater than or equal to 1.');
}
}

View file

@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\ProductIncos;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\ProductInco;
use App\Services\ArcadiaIncoService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\ProductIncos\SyncProductIncoInput, \App\Models\ProductInco>
*/
final class SyncProductInco implements Action
{
public function __construct(
private readonly ArcadiaIncoService $arcadiaIncoService,
) {
}
/**
* @param \App\Actions\ProductIncos\SyncProductIncoInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): ProductInco
{
Assert::isInstanceOf($input, SyncProductIncoInput::class);
$inco = $input->product->inco;
if ($inco === null) {
throw new \RuntimeException('Product does not have an associated INCO sheet to sync.');
}
$incoData = $this->arcadiaIncoService->fetchInco($inco->no_art);
if ($incoData === null) {
throw new \RuntimeException(sprintf('INCO sheet #%d was not found on Arcadia API.', $inco->no_art));
}
$inco->ingredients = $incoData->ingredients;
$inco->preparation = $incoData->preparation;
$inco->servings = $incoData->servings;
$inco->allergens = $incoData->allergens;
$inco->selling_argument = $incoData->sellingArgument;
$inco->nutriscore = $incoData->nutriscore;
$inco->nutriscore_args = $incoData->nutriscoreArgs;
$inco->nutrition = $incoData->nutrition;
$inco->saveOrFail();
return $inco;
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\ProductIncos;
use App\Contracts\ActionInput;
use App\Models\Product;
final readonly class SyncProductIncoInput implements ActionInput
{
public function __construct(
public Product $product,
) {
}
}

View file

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\ProductIncos;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Contracts\ActionInputEmpty;
use App\Models\ProductInco;
use App\Services\ArcadiaIncoService;
use Psr\Log\LoggerInterface;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Contracts\ActionInputEmpty, int>
*/
final class SynchronizeProductIncosWithArcadiaApi implements Action
{
public function __construct(
private readonly ArcadiaIncoService $arcadiaIncoService,
private readonly LoggerInterface $logger,
) {
}
/**
* @param \App\Contracts\ActionInputEmpty $input
*/
#[\Override]
public function __invoke(ActionInput $input): int
{
Assert::isInstanceOf($input, ActionInputEmpty::class);
$this->logger->info('Starting synchronization of all ProductIncos with Arcadia API.');
$updatedCount = 0;
/** @var \Illuminate\Database\Eloquent\Collection<int, ProductInco> $incos */
$incos = ProductInco::query()->get();
foreach ($incos as $inco) {
$incoData = $this->arcadiaIncoService->fetchInco($inco->no_art);
if ($incoData === null) {
$this->logger->warning("Could not sync ProductInco #{$inco->id} (no_art: {$inco->no_art}): not found on Arcadia.");
continue;
}
$inco->ingredients = $incoData->ingredients;
$inco->preparation = $incoData->preparation;
$inco->servings = $incoData->servings;
$inco->allergens = $incoData->allergens;
$inco->selling_argument = $incoData->sellingArgument;
$inco->nutriscore = $incoData->nutriscore;
$inco->nutriscore_args = $incoData->nutriscoreArgs;
$inco->nutrition = $incoData->nutrition;
$inco->saveOrFail();
$updatedCount++;
}
$this->logger->info("Completed synchronization of ProductIncos: {$updatedCount} updated.");
return $updatedCount;
}
}

View file

@ -1,109 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Products;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Product;
use App\Models\Tag;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Str;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Products\CreateProductInput, \App\Models\Product>
*/
final class CreateProduct implements Action
{
/**
* @param \App\Actions\Products\CreateProductInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Product
{
Assert::isInstanceOf($input, CreateProductInput::class);
$product = new Product();
$product->name = $input->name;
$product->short_description = $input->shortDescription;
$product->description = $input->description;
$product->price = $input->price;
$product->invoicing_unit = $input->invoicingUnit;
$product->stock = $input->stock;
$product->range_id = $input->rangeId;
$product->sell_unit = $input->sellUnit;
$product->order_unit = $input->orderUnit;
$product->tax_rate = $input->taxRate;
$product->brand = $input->brand;
$product->weight = $input->weight;
$product->weight_variable = $input->weightVariable;
$product->orderable_start = $input->orderableStart !== null ? Date::instance($input->orderableStart) : null;
$product->orderable_end = $input->orderableEnd !== null ? Date::instance($input->orderableEnd) : null;
$product->star = $input->star;
$product->status = $input->status;
if ($input->picture !== null) {
$product->setAttribute('picture', $input->picture);
}
$product->picture_alt = (string) $input->pictureAlt;
$product->picture_title = (string) $input->pictureTitle;
if ($input->pictureLarge !== null) {
$product->setAttribute('picture_large', $input->pictureLarge);
}
$product->picture_large_alt = (string) $input->pictureLargeAlt;
$product->picture_large_title = (string) $input->pictureLargeTitle;
$product->saveOrFail();
$this->syncTags($input->tags, $product);
$this->syncAssociatedProducts($input->associatedProductsIds, $product);
return $product;
}
/**
* @param array<array{id?: int|string, label: string}> $tags
*/
private function syncTags(array $tags, Product $product): void
{
$tagIdList = [];
foreach ($tags as $tag) {
$tagIdList[] = empty($tag['id'])
? $this->storeTag($tag)
: (int) $tag['id'];
}
$product->tags()->sync($tagIdList);
}
/**
* @param array{id?: int|string, label: string} $tag
*/
private function storeTag(array $tag): int
{
$slug = Str::slug($tag['label']);
$tagModel = Tag::query()
->where('slug', $slug)
->firstOrNew();
$tagModel->setAttribute('label', $tag['label']);
$tagModel->saveOrFail();
return (int) $tagModel->id;
}
/**
* @param list<string> $productIdList
*/
private function syncAssociatedProducts(array $productIdList, Product $product): void
{
$syncData = [];
foreach ($productIdList as $index => $id) {
$syncData[$id] = ['sort_order' => $index + 1];
}
$product->products()->sync($syncData);
}
}

View file

@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Products;
use App\Contracts\ActionInput;
use Carbon\CarbonInterface;
use Domain\Enums\ProductBrand;
use Domain\Enums\SellUnit;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\InvoicingUnit;
use Domain\Enums\Units\OrderUnit;
use Money\Money;
use Webmozart\Assert\Assert;
final readonly class CreateProductInput implements ActionInput
{
/**
* @param array<array{id?: int|string, label: string}> $tags
* @param list<string> $associatedProductsIds
*/
public function __construct(
public string $name,
public string $shortDescription,
public string $description,
public Money $price,
public InvoicingUnit $invoicingUnit,
public int $stock,
public int $rangeId,
public SellUnit $sellUnit,
public OrderUnit $orderUnit,
public TaxRate $taxRate,
public ProductBrand $brand,
public ?int $weight = null,
public bool $weightVariable = false,
public ?CarbonInterface $orderableStart = null,
public ?CarbonInterface $orderableEnd = null,
public bool $star = false,
public bool $status = false,
public mixed $picture = null,
public ?string $pictureAlt = null,
public ?string $pictureTitle = null,
public mixed $pictureLarge = null,
public ?string $pictureLargeAlt = null,
public ?string $pictureLargeTitle = null,
public array $tags = [],
public array $associatedProductsIds = [],
) {
Assert::stringNotEmpty(trim($this->name));
Assert::maxLength($this->name, 255);
Assert::greaterThanEq($this->stock, 0);
Assert::greaterThanEq($this->rangeId, 1);
}
}

View file

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Products;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Products\DeleteProductInput, bool>
*/
final class DeleteProduct implements Action
{
/**
* @param \App\Actions\Products\DeleteProductInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, DeleteProductInput::class);
return (bool) $input->product->deleteOrFail();
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Products;
use App\Contracts\ActionInput;
use App\Models\Product;
final readonly class DeleteProductInput implements ActionInput
{
public function __construct(
public Product $product,
) {
}
}

View file

@ -1,109 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Products;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Product;
use App\Models\Tag;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Str;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Products\UpdateProductInput, \App\Models\Product>
*/
final class UpdateProduct implements Action
{
/**
* @param \App\Actions\Products\UpdateProductInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Product
{
Assert::isInstanceOf($input, UpdateProductInput::class);
$product = $input->product;
$product->name = $input->name;
$product->short_description = $input->shortDescription;
$product->description = $input->description;
$product->price = $input->price;
$product->invoicing_unit = $input->invoicingUnit;
$product->stock = $input->stock;
$product->range_id = $input->rangeId;
$product->sell_unit = $input->sellUnit;
$product->order_unit = $input->orderUnit;
$product->tax_rate = $input->taxRate;
$product->brand = $input->brand;
$product->weight = $input->weight;
$product->weight_variable = $input->weightVariable;
$product->orderable_start = $input->orderableStart !== null ? Date::instance($input->orderableStart) : null;
$product->orderable_end = $input->orderableEnd !== null ? Date::instance($input->orderableEnd) : null;
$product->star = $input->star;
$product->status = $input->status;
if ($input->picture !== null) {
$product->setAttribute('picture', $input->picture);
}
$product->picture_alt = (string) $input->pictureAlt;
$product->picture_title = (string) $input->pictureTitle;
if ($input->pictureLarge !== null) {
$product->setAttribute('picture_large', $input->pictureLarge);
}
$product->picture_large_alt = (string) $input->pictureLargeAlt;
$product->picture_large_title = (string) $input->pictureLargeTitle;
$product->saveOrFail();
$this->syncTags($input->tags, $product);
$this->syncAssociatedProducts($input->associatedProductsIds, $product);
return $product;
}
/**
* @param array<array{id?: int|string, label: string}> $tags
*/
private function syncTags(array $tags, Product $product): void
{
$tagIdList = [];
foreach ($tags as $tag) {
$tagIdList[] = empty($tag['id'])
? $this->storeTag($tag)
: (int) $tag['id'];
}
$product->tags()->sync($tagIdList);
}
/**
* @param array{id?: int|string, label: string} $tag
*/
private function storeTag(array $tag): int
{
$slug = Str::slug($tag['label']);
$tagModel = Tag::query()
->where('slug', $slug)
->firstOrNew();
$tagModel->setAttribute('label', $tag['label']);
$tagModel->saveOrFail();
return (int) $tagModel->id;
}
/**
* @param list<string> $productIdList
*/
private function syncAssociatedProducts(array $productIdList, Product $product): void
{
$syncData = [];
foreach ($productIdList as $index => $id) {
$syncData[$id] = ['sort_order' => $index + 1];
}
$product->products()->sync($syncData);
}
}

View file

@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Products;
use App\Contracts\ActionInput;
use App\Models\Product;
use Carbon\CarbonInterface;
use Domain\Enums\ProductBrand;
use Domain\Enums\SellUnit;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\InvoicingUnit;
use Domain\Enums\Units\OrderUnit;
use Money\Money;
use Webmozart\Assert\Assert;
final readonly class UpdateProductInput implements ActionInput
{
/**
* @param array<array{id?: int|string, label: string}> $tags
* @param list<string> $associatedProductsIds
*/
public function __construct(
public Product $product,
public string $name,
public string $shortDescription,
public string $description,
public Money $price,
public InvoicingUnit $invoicingUnit,
public int $stock,
public int $rangeId,
public SellUnit $sellUnit,
public OrderUnit $orderUnit,
public TaxRate $taxRate,
public ProductBrand $brand,
public ?int $weight = null,
public bool $weightVariable = false,
public ?CarbonInterface $orderableStart = null,
public ?CarbonInterface $orderableEnd = null,
public bool $star = false,
public bool $status = false,
public mixed $picture = null,
public ?string $pictureAlt = null,
public ?string $pictureTitle = null,
public mixed $pictureLarge = null,
public ?string $pictureLargeAlt = null,
public ?string $pictureLargeTitle = null,
public array $tags = [],
public array $associatedProductsIds = [],
) {
Assert::stringNotEmpty(trim($this->name));
Assert::maxLength($this->name, 255);
Assert::greaterThanEq($this->stock, 0);
Assert::greaterThanEq($this->rangeId, 1);
}
}

View file

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Ranges;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Range;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Ranges\CreateRangeInput, \App\Models\Range>
*/
final class CreateRange implements Action
{
/**
* @param \App\Actions\Ranges\CreateRangeInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Range
{
Assert::isInstanceOf($input, CreateRangeInput::class);
$attributes = [
'name' => $input->name,
'description' => $input->description,
'picture_alt' => $input->pictureMetadata->alt ?? '',
'picture_title' => $input->pictureMetadata->title ?? '',
'status' => $input->status,
];
if ($input->picture !== null) {
$attributes['picture'] = $input->picture;
}
$range = new Range();
$range->fill($attributes);
$range->saveOrFail();
return $range;
}
}

View file

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Ranges;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\PictureMetadata;
use Illuminate\Http\UploadedFile;
use Webmozart\Assert\Assert;
final readonly class CreateRangeInput implements ActionInput
{
public function __construct(
public string $name,
public ?string $description,
public UploadedFile | string | null $picture,
public PictureMetadata $pictureMetadata,
public bool $status,
) {
$trimmedName = trim($this->name);
Assert::stringNotEmpty($trimmedName, 'The range name cannot be empty.');
Assert::minLength($trimmedName, 2, 'The range name must be at least 2 characters.');
Assert::maxLength($trimmedName, 45, 'The range name cannot exceed 45 characters.');
if ($this->picture !== null) {
Assert::notNull($this->pictureMetadata->alt, 'Picture alt is required when picture is provided.');
Assert::stringNotEmpty(trim($this->pictureMetadata->alt), 'Picture alt cannot be empty when picture is provided.');
Assert::notNull($this->pictureMetadata->title, 'Picture title is required when picture is provided.');
Assert::stringNotEmpty(trim($this->pictureMetadata->title), 'Picture title cannot be empty when picture is provided.');
}
}
}

View file

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Ranges;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Ranges\DeleteRangeInput, bool>
*/
final class DeleteRange implements Action
{
/**
* @param \App\Actions\Ranges\DeleteRangeInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, DeleteRangeInput::class);
return (bool) $input->range->deleteOrFail();
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Ranges;
use App\Contracts\ActionInput;
use App\Models\Range;
final readonly class DeleteRangeInput implements ActionInput
{
public function __construct(
public Range $range,
) {
}
}

View file

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Ranges;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Range;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Ranges\UpdateRangeInput, \App\Models\Range>
*/
final class UpdateRange implements Action
{
/**
* @param \App\Actions\Ranges\UpdateRangeInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Range
{
Assert::isInstanceOf($input, UpdateRangeInput::class);
$attributes = [
'name' => $input->name,
'description' => $input->description,
'picture_alt' => $input->pictureMetadata->alt ?? '',
'picture_title' => $input->pictureMetadata->title ?? '',
'status' => $input->status,
];
if ($input->picture !== null) {
$attributes['picture'] = $input->picture;
}
$range = $input->range;
$range->fill($attributes);
$range->saveOrFail();
return $range;
}
}

View file

@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Ranges;
use App\Contracts\ActionInput;
use App\Models\Range;
use Domain\Models\ValueObjects\PictureMetadata;
use Illuminate\Http\UploadedFile;
use Webmozart\Assert\Assert;
final readonly class UpdateRangeInput implements ActionInput
{
public function __construct(
public Range $range,
public string $name,
public ?string $description,
public UploadedFile | string | null $picture,
public PictureMetadata $pictureMetadata,
public bool $status,
) {
$trimmedName = trim($this->name);
Assert::stringNotEmpty($trimmedName, 'The range name cannot be empty.');
Assert::minLength($trimmedName, 2, 'The range name must be at least 2 characters.');
Assert::maxLength($trimmedName, 45, 'The range name cannot exceed 45 characters.');
if ($this->picture !== null) {
Assert::notNull($this->pictureMetadata->alt, 'Picture alt is required when picture is provided.');
Assert::stringNotEmpty(trim($this->pictureMetadata->alt), 'Picture alt cannot be empty when picture is provided.');
Assert::notNull($this->pictureMetadata->title, 'Picture title is required when picture is provided.');
Assert::stringNotEmpty(trim($this->pictureMetadata->title), 'Picture title cannot be empty when picture is provided.');
}
}
}

View file

@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Settings;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Setting;
use Domain\Models\ValueObjects\ShopperSiteCode;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Settings\UpdateSettingsInput, \App\Models\Setting>
*/
final class UpdateSettings implements Action
{
/**
* @param \App\Actions\Settings\UpdateSettingsInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Setting
{
Assert::isInstanceOf($input, UpdateSettingsInput::class);
$setting = Setting::getSingleton();
$setting->fill([
'employee_sites' => array_values(
array_map(
static fn (ShopperSiteCode $siteCode): string => $siteCode->value,
$input->employeeSites
)
),
]);
$setting->saveOrFail();
return $setting;
}
}

View file

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Settings;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\ShopperSiteCode;
use Webmozart\Assert\Assert;
final readonly class UpdateSettingsInput implements ActionInput
{
/**
* @param array<int, \Domain\Models\ValueObjects\ShopperSiteCode> $employeeSites
*/
public function __construct(
public array $employeeSites = [],
) {
Assert::allIsInstanceOf($this->employeeSites, ShopperSiteCode::class);
}
}

View file

@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Shop;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Shops\CreateShopInput, \App\Models\Shop>
*/
final class CreateShop implements Action
{
/**
* @param \App\Actions\Shops\CreateShopInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Shop
{
Assert::isInstanceOf($input, CreateShopInput::class);
$attributes = [
'name' => $input->name,
'address' => $input->address,
'postcode' => $input->postcode,
'city' => $input->city,
'phone' => $input->phone?->value,
'latitude' => $input->coordinates !== null ? (string) $input->coordinates->latitude : null,
'longitude' => $input->coordinates !== null ? (string) $input->coordinates->longitude : null,
];
$shop = new Shop();
$shop->fill($attributes);
$shop->default = $input->default;
$shop->saveOrFail();
return $shop;
}
}

View file

@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Coordinates;
use Domain\Models\ValueObjects\PhoneNumber;
use Webmozart\Assert\Assert;
final readonly class CreateShopInput implements ActionInput
{
public function __construct(
public string $name,
public string $address,
public string $postcode,
public string $city,
public ?PhoneNumber $phone,
public ?Coordinates $coordinates,
public bool $default,
) {
$trimmedName = trim($this->name);
Assert::stringNotEmpty($trimmedName, 'Shop name cannot be empty.');
Assert::maxLength($trimmedName, 255, 'Shop name cannot exceed 255 characters.');
$trimmedAddress = trim($this->address);
Assert::stringNotEmpty($trimmedAddress, 'Shop address cannot be empty.');
Assert::maxLength($trimmedAddress, 255, 'Shop address cannot exceed 255 characters.');
$trimmedPostcode = trim($this->postcode);
Assert::stringNotEmpty($trimmedPostcode, 'Shop postcode cannot be empty.');
Assert::maxLength($trimmedPostcode, 255, 'Shop postcode cannot exceed 255 characters.');
$trimmedCity = trim($this->city);
Assert::stringNotEmpty($trimmedCity, 'Shop city cannot be empty.');
Assert::maxLength($trimmedCity, 255, 'Shop city cannot exceed 255 characters.');
}
}

View file

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Shops\DeleteShopInput, bool>
*/
final class DeleteShop implements Action
{
/**
* @param \App\Actions\Shops\DeleteShopInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, DeleteShopInput::class);
return (bool) $input->shop->deleteOrFail();
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\ActionInput;
use App\Models\Shop;
final readonly class DeleteShopInput implements ActionInput
{
public function __construct(
public Shop $shop,
) {
}
}

View file

@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Shop;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Shops\UpdateShopInput, \App\Models\Shop>
*/
final class UpdateShop implements Action
{
/**
* @param \App\Actions\Shops\UpdateShopInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Shop
{
Assert::isInstanceOf($input, UpdateShopInput::class);
$attributes = [
'name' => $input->name,
'address' => $input->address,
'postcode' => $input->postcode,
'city' => $input->city,
'phone' => $input->phone?->value,
'latitude' => $input->coordinates !== null ? (string) $input->coordinates->latitude : null,
'longitude' => $input->coordinates !== null ? (string) $input->coordinates->longitude : null,
];
$shop = $input->shop;
$shop->fill($attributes);
$shop->default = $input->default;
$shop->saveOrFail();
return $shop;
}
}

View file

@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\ActionInput;
use App\Models\Shop;
use Domain\Models\ValueObjects\Coordinates;
use Domain\Models\ValueObjects\PhoneNumber;
use Webmozart\Assert\Assert;
final readonly class UpdateShopInput implements ActionInput
{
public function __construct(
public Shop $shop,
public string $name,
public string $address,
public string $postcode,
public string $city,
public ?PhoneNumber $phone,
public ?Coordinates $coordinates,
public bool $default,
) {
$trimmedName = trim($this->name);
Assert::stringNotEmpty($trimmedName, 'Shop name cannot be empty.');
Assert::maxLength($trimmedName, 255, 'Shop name cannot exceed 255 characters.');
$trimmedAddress = trim($this->address);
Assert::stringNotEmpty($trimmedAddress, 'Shop address cannot be empty.');
Assert::maxLength($trimmedAddress, 255, 'Shop address cannot exceed 255 characters.');
$trimmedPostcode = trim($this->postcode);
Assert::stringNotEmpty($trimmedPostcode, 'Shop postcode cannot be empty.');
Assert::maxLength($trimmedPostcode, 255, 'Shop postcode cannot exceed 255 characters.');
$trimmedCity = trim($this->city);
Assert::stringNotEmpty($trimmedCity, 'Shop city cannot be empty.');
Assert::maxLength($trimmedCity, 255, 'Shop city cannot exceed 255 characters.');
}
}

View file

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Shops\UpdateShopPickupExceptionsInput, bool>
*/
final class UpdateShopPickupExceptions implements Action
{
/**
* @param \App\Actions\Shops\UpdateShopPickupExceptionsInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, UpdateShopPickupExceptionsInput::class);
$input->shop->pickupExceptions()->sync($input->syncData);
return true;
}
}

View file

@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\ActionInput;
use App\Models\Shop;
use Webmozart\Assert\Assert;
final readonly class UpdateShopPickupExceptionsInput implements ActionInput
{
/**
* @param array<array-key, array{replacement_date: string|null, order_deadline_at: string|null}> $syncData
*/
public function __construct(
public Shop $shop,
public array $syncData,
) {
foreach ($this->syncData as $item) {
Assert::isArray($item, 'Each sync item must be an array.');
Assert::keyExists($item, 'replacement_date', 'Sync item must contain replacement_date key.');
Assert::keyExists($item, 'order_deadline_at', 'Sync item must contain order_deadline_at key.');
Assert::nullOrString($item['replacement_date'], 'replacement_date must be a string or null.');
Assert::nullOrString($item['order_deadline_at'], 'order_deadline_at must be a string or null.');
if ($item['order_deadline_at'] !== null) {
Assert::notNull(
$item['replacement_date'],
'A replacement date is required when an order deadline is set.'
);
}
if ($item['replacement_date'] !== null && $item['order_deadline_at'] !== null) {
$replacementDate = new \DateTimeImmutable($item['replacement_date']);
$orderDeadlineAt = new \DateTimeImmutable($item['order_deadline_at']);
Assert::true(
$orderDeadlineAt < $replacementDate,
'Order deadline must be strictly before the replacement date.'
);
}
}
}
}

Some files were not shown because too many files have changed in this diff Show more