Compare commits
48 Commits
1.0.0-rc.1
...
1.0.0-rc.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ec13e802e | ||
|
|
ebdf0c0d8e | ||
|
|
22ebcb102f | ||
|
|
cc3d4d3e32 | ||
|
|
ef1964d472 | ||
|
|
abb32e32b9 | ||
|
|
8c29459489 | ||
|
|
accb052f5c | ||
|
|
d5f095b5e5 | ||
|
|
fd628f3f95 | ||
|
|
a10f0b35d9 | ||
|
|
858421a9eb | ||
|
|
158bc4fd57 | ||
|
|
b7e3eefed6 | ||
|
|
67e4346c68 | ||
|
|
9ce62e82e5 | ||
|
|
7e93219774 | ||
|
|
29f46b7287 | ||
|
|
1f02c932a0 | ||
|
|
7d8bd8c372 | ||
|
|
f4bd4ddf24 | ||
|
|
1f7098d55b | ||
|
|
1867e75177 | ||
|
|
d502882052 | ||
|
|
a5b2196b32 | ||
|
|
cc8dfa29b4 | ||
|
|
62bce92d6d | ||
|
|
8d130b9741 | ||
|
|
2d7436a12b | ||
|
|
f25a62ed26 | ||
|
|
36764f2647 | ||
|
|
e37cad6699 | ||
|
|
ae7f8ed2c9 | ||
|
|
a3a86f4b2f | ||
|
|
9c081574c8 | ||
|
|
11edccad02 | ||
|
|
7c796802be | ||
|
|
5cc43bc889 | ||
|
|
f094411f10 | ||
|
|
ccc477f291 | ||
|
|
7217d945a3 | ||
|
|
9185269874 | ||
|
|
e42e3b4c0d | ||
|
|
a7ae946797 | ||
|
|
7a189abf0b | ||
|
|
34fc1c33bf | ||
|
|
61e34b4f4e | ||
|
|
7fe2770d45 |
74
AGENTS.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
OpenSem builds on Laravel 9.
|
||||
|
||||
Core application code lives in `app/`, while HTTP routes reside in
|
||||
`routes/` and Blade views in `resources/views/`. Reusable front-end
|
||||
assets (JS, SCSS, images) sit under `resources/` and are compiled into
|
||||
`public/` via Laravel Mix.
|
||||
|
||||
Database blueprints are versioned in `database/migrations/` with seeds
|
||||
in `database/seeders/`.
|
||||
|
||||
Tests are organised in `tests/Unit/` and `tests/Feature/`; keep large
|
||||
fixtures in `tests/Fixtures/` to avoid polluting source directories.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `composer install` — install PHP dependencies defined in
|
||||
`composer.json`.
|
||||
|
||||
- `php artisan serve` — start a local HTTP server on port 8000.
|
||||
|
||||
- `npm install && npm run dev` — install Node tooling and build UI
|
||||
assets for development.
|
||||
|
||||
- `npm run prod` — generate minified production assets in `public/`.
|
||||
|
||||
- `php artisan migrate --seed` — apply database schema and load
|
||||
default data for demo instances.
|
||||
|
||||
- `./build.sh` — builds a `.tar.xz` that contains the production and
|
||||
deployement ready source to be deployed.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Follow PSR-12 with four-space indentation and `snake_case` database
|
||||
columns. Controllers, models, and Livewire components use StudlyCase
|
||||
class names; private methods remain `camelCase`. Run `composer run
|
||||
inspect` before opening a PR to execute `phpcs` and `phpstan`. For
|
||||
front-end changes, keep Blade sections in lowercase kebab IDs (for
|
||||
example, `@section('order-summary')`).
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Use PHPUnit via `php artisan test`; target deterministic tests with
|
||||
clear Arrange/Act/Assert blocks. Feature tests should mirror top-level
|
||||
route names (e.g., `OrdersTest.php`). Unit tests belong in
|
||||
`tests/Unit/` and should stub external services. When adding
|
||||
migrations or service integrations, include coverage that exercises
|
||||
failure paths. For granular checks, `./vendor/bin/phpunit --filter
|
||||
FooTest` is acceptable, but always run the full suite before pushing.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
Commits in this repo mix Conventional Commit prefixes (`new:`, `fix:`,
|
||||
`chg:`); `fix: prevent null totals`. Keep messages in the imperative
|
||||
mood and reference ticket IDs when available.
|
||||
|
||||
Pull requests must describe scope, list schema or configuration
|
||||
changes, and note any manual follow-up (cron, storage links,
|
||||
queues).
|
||||
|
||||
Attach screenshots or terminal logs when touching UI or console
|
||||
output, and ensure CI scripts (when available) pass.
|
||||
|
||||
## Environment & Security Notes
|
||||
|
||||
Copy `.env.example` to `.env` and run `php artisan key:generate`
|
||||
before local work. Never commit `.env`, `storage/`, or database dumps
|
||||
containing sensitive data. Use the Docker resources in `docker/` only
|
||||
for reproducible environments; keep secrets in your host overrides,
|
||||
not in version control.
|
||||
@@ -43,6 +43,7 @@ COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
RUN mkdir -p /app/bootstrap/cache \
|
||||
/app/storage/media-library/temp \
|
||||
/app/storage/framework/cache \
|
||||
/app/storage/framework/views \
|
||||
/app/storage/framework/sessions \
|
||||
@@ -55,6 +56,8 @@ RUN chmod +x artisan
|
||||
|
||||
RUN ./artisan vendor:publish --tag=public --force ## creates public/vendor/jsvalidation
|
||||
RUN ./artisan vendor:publish --tag=boilerplate-public --force --ansi ## creates public/vendor/boilerplate
|
||||
RUN ./artisan vendor:publish --tag=datatables-buttons --force --ansi ## creates public/vendor/datatables/buttons
|
||||
RUN ./artisan vendor:publish --tag=lfm_public --force --ansi
|
||||
|
||||
## XXXvlab: 2025-09-25 these migration files are breaking first
|
||||
## install, but we had to resolve to not install from scratch and use
|
||||
@@ -83,8 +86,6 @@ RUN apk add --no-cache xz
|
||||
# bring PHP app with vendor
|
||||
COPY --from=phpdeps /app /app
|
||||
|
||||
# ensure required runtime dirs exist (empty is fine)
|
||||
RUN mkdir -p storage/framework/{cache,views,sessions} bootstrap/cache
|
||||
# create artifact (use tar + xz so we don't depend on GNU tar -J)
|
||||
RUN mkdir -p /out \
|
||||
&& tar -C /app -cf /out/app.tar \
|
||||
@@ -93,7 +94,8 @@ RUN mkdir -p /out \
|
||||
--exclude=.editorconfig --exclude=phpunit.xml \
|
||||
--exclude=.travis.yml --exclude=composer.lock --exclude=.styleci.yml \
|
||||
--exclude=Makefile --exclude=.gitkeep --exclude=test \
|
||||
artisan app build config database vendor public resources routes stubs bootstrap storage composer.json \
|
||||
--exclude=resources/shop \
|
||||
artisan app config database vendor public resources routes stubs bootstrap storage composer.json \
|
||||
&& xz -T0 -9e /out/app.tar \
|
||||
&& mv /out/app.tar.xz /out/opensem-prod.tar.xz
|
||||
|
||||
|
||||
48
Gruntfile.js
@@ -19,8 +19,8 @@ var jsSite = [
|
||||
jsBootstrap,
|
||||
'node_modules/jquery-serializejson/jquery.serializejson.min.js',
|
||||
'node_modules/currency.js/dist/currency.min.js',
|
||||
'build/js/plugins/smooth_products/js/smoothproducts.min.js',
|
||||
'build/js/site.js',
|
||||
'resources/shop/js/plugins/smooth_products/js/smoothproducts.min.js',
|
||||
'resources/shop/js/site.js',
|
||||
]
|
||||
|
||||
var cssSite = [
|
||||
@@ -28,8 +28,8 @@ var cssSite = [
|
||||
'node_modules/@fortawesome/fontawesome-free/css/all.min.css',
|
||||
'node_modules/animate.css/animate.min.css',
|
||||
'node_modules/icheck-bootstrap/icheck-bootstrap.min.css',
|
||||
'build/js/plugins/smooth_products/css/smoothproducts.css',
|
||||
'build/css/site.css',
|
||||
'resources/shop/js/plugins/smooth_products/css/smoothproducts.css',
|
||||
'resources/shop/css/site.css',
|
||||
]
|
||||
|
||||
var jsAdminLTE = [
|
||||
@@ -41,15 +41,15 @@ var jsAdminLTE = [
|
||||
]
|
||||
|
||||
var jsCoreInclude = [
|
||||
'build/js/include/core/objectLength.js',
|
||||
'build/js/include/core/url.js',
|
||||
'build/js/include/core/user.js',
|
||||
'build/js/include/form/radio.js',
|
||||
'build/js/include/form/upload.js',
|
||||
'build/js/include/form/validator.js',
|
||||
'build/js/include/layout/animate.js',
|
||||
'build/js/include/layout/scroll.js',
|
||||
'build/js/include/layout/tooltip.js',
|
||||
'resources/shop/js/include/core/objectLength.js',
|
||||
'resources/shop/js/include/core/url.js',
|
||||
'resources/shop/js/include/core/user.js',
|
||||
'resources/shop/js/include/form/radio.js',
|
||||
'resources/shop/js/include/form/upload.js',
|
||||
'resources/shop/js/include/form/validator.js',
|
||||
'resources/shop/js/include/layout/animate.js',
|
||||
'resources/shop/js/include/layout/scroll.js',
|
||||
'resources/shop/js/include/layout/tooltip.js',
|
||||
]
|
||||
|
||||
var jsBundle = [
|
||||
@@ -84,7 +84,7 @@ var jsMain = [
|
||||
var cssPrint = [
|
||||
// 'node_modules/bootstrap/dist/css/bootstrap.min.css',
|
||||
'cssIcons',
|
||||
'build/print.css'
|
||||
'resources/shop/print.css'
|
||||
]
|
||||
|
||||
var cssBundle = [
|
||||
@@ -109,7 +109,7 @@ var cssIcons = [
|
||||
var cssMain = [
|
||||
cssBundle,
|
||||
cssIcons,
|
||||
'build/css/main.css',
|
||||
'resources/shop/css/main.css',
|
||||
]
|
||||
|
||||
var jsDataTables = [
|
||||
@@ -251,31 +251,31 @@ module.exports = function(grunt) {
|
||||
},
|
||||
{
|
||||
expand: true,
|
||||
cwd: 'build/fonts',
|
||||
cwd: 'resources/shop/fonts',
|
||||
src: ['**'],
|
||||
dest: 'public/fonts/'
|
||||
},
|
||||
{
|
||||
expand: true,
|
||||
cwd: 'build/img',
|
||||
cwd: 'resources/shop/img',
|
||||
src: ['**'],
|
||||
dest: 'public/img/'
|
||||
},
|
||||
{
|
||||
expand: true,
|
||||
cwd: 'build/lang',
|
||||
cwd: 'resources/shop/lang',
|
||||
src: ['**'],
|
||||
dest: 'public/assets/lang/'
|
||||
},
|
||||
{
|
||||
expand: true,
|
||||
cwd: 'build/plugins',
|
||||
cwd: 'resources/shop/plugins',
|
||||
src: ['**'],
|
||||
dest: 'public/assets/plugins/'
|
||||
},
|
||||
{
|
||||
expand: true,
|
||||
cwd: 'build/assets/tpl',
|
||||
cwd: 'resources/shop/assets/tpl',
|
||||
src: ['**'],
|
||||
dest: 'public/assets/tpl/'
|
||||
},
|
||||
@@ -395,7 +395,7 @@ module.exports = function(grunt) {
|
||||
},
|
||||
{
|
||||
expand: true,
|
||||
cwd: 'build/plugins/pdfjs/',
|
||||
cwd: 'resources/shop/plugins/pdfjs/',
|
||||
src: ['**'],
|
||||
dest: 'public/assets/plugins/pdfjs',
|
||||
},
|
||||
@@ -461,7 +461,7 @@ module.exports = function(grunt) {
|
||||
},
|
||||
{
|
||||
expand: true,
|
||||
cwd: 'build/js/include/plugins/datatables_lang/',
|
||||
cwd: 'resources/shop/js/include/plugins/datatables_lang/',
|
||||
src: ['*.json'],
|
||||
dest: 'public/assets/plugins/datatables_lang',
|
||||
},
|
||||
@@ -539,7 +539,7 @@ module.exports = function(grunt) {
|
||||
},
|
||||
{
|
||||
expand: true,
|
||||
cwd: 'build/js/include/',
|
||||
cwd: 'resources/shop/js/include/',
|
||||
src: ['boilerplate.js'],
|
||||
dest: 'public/assets/plugins',
|
||||
},
|
||||
@@ -548,7 +548,7 @@ module.exports = function(grunt) {
|
||||
},
|
||||
watch: {
|
||||
dist: {
|
||||
files: ['build/js/*', 'build/css/*'],
|
||||
files: ['resources/shop/js/*', 'resources/shop/css/*'],
|
||||
// tasks: ['concat', 'copy']
|
||||
tasks: ['concat']
|
||||
}
|
||||
|
||||
30
README.md
@@ -1,10 +1,19 @@
|
||||
## A propos de OpenSem
|
||||
|
||||
OpenSem est une solution de commerce électronique et un ERP développé pour les besoins exprimés.
|
||||
OpenSem est une solution de commerce électronique et un ERP développé
|
||||
pour les besoins exprimés.
|
||||
|
||||
Développée par Ludovic Candellier en étroite relation avec
|
||||
Jardin'Envie.
|
||||
|
||||
L'application est écrite en PHP et est basée sur Laravel.
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
Laravel is a web application framework with expressive, elegant
|
||||
syntax. We believe development must be an enjoyable and creative
|
||||
experience to be truly fulfilling. Laravel takes the pain out of
|
||||
development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
@@ -14,12 +23,21 @@ Laravel is a web application framework with expressive, elegant syntax. We belie
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
Laravel is accessible, powerful, and provides tools required for
|
||||
large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||
Laravel has the most extensive and thorough
|
||||
[documentation](https://laravel.com/docs) and video tutorial library
|
||||
of all modern web application frameworks, making it a breeze to get
|
||||
started with the framework.
|
||||
|
||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains over 1500 video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can
|
||||
help. Laracasts contains over 1500 video tutorials on a range of
|
||||
topics including Laravel, modern PHP, unit testing, and
|
||||
JavaScript. Boost your skills by digging into our comprehensive video
|
||||
library.
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
The Laravel framework is open-sourced software licensed under the [MIT
|
||||
license](https://opensource.org/licenses/MIT).
|
||||
|
||||
@@ -11,7 +11,7 @@ use Yajra\DataTables\Html\Column;
|
||||
|
||||
class CustomerInvoicesDataTable extends DataTable
|
||||
{
|
||||
public $model_name = 'invoices';
|
||||
public $model_name = 'customer_invoices';
|
||||
|
||||
public $sortedColumn = 1;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Controllers\Admin\Botanic;
|
||||
|
||||
use App\Datatables\Botanic\SpeciesDataTable;
|
||||
use App\Repositories\Botanic\Genres;
|
||||
use App\Repositories\Botanic\Species;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -21,7 +20,7 @@ class SpecieController extends Controller
|
||||
|
||||
public function create()
|
||||
{
|
||||
$data = Genres::init();
|
||||
$data = Species::init();
|
||||
|
||||
return view('Admin.Botanic.Species.create', $data);
|
||||
}
|
||||
@@ -36,7 +35,7 @@ class SpecieController extends Controller
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$data = Genres::init();
|
||||
$data = Species::init();
|
||||
$data['specie'] = Species::getFull($id);
|
||||
|
||||
return view('Admin.Botanic.Species.edit', $data);
|
||||
|
||||
@@ -12,15 +12,6 @@ class CustomerInvoiceController extends Controller
|
||||
return $dataTable->render('Admin.Shop.CustomerInvoices.list');
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$data = [
|
||||
'invoice' => Invoices::get($id),
|
||||
];
|
||||
|
||||
return view('Admin.Shop.CustomerInvoices.view', $data);
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
return Invoices::destroy($id);
|
||||
|
||||
@@ -71,7 +71,10 @@ class PriceListValueController extends Controller
|
||||
|
||||
public function addPrice($index)
|
||||
{
|
||||
$data['index'] = $index;
|
||||
$data = [
|
||||
'index' => $index,
|
||||
'taxes' => Taxes::getOptions(),
|
||||
];
|
||||
|
||||
return view('Admin.Shop.PriceListValues.partials.row_price', $data);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Shop\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Repositories\Core\User\ShopCart;
|
||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -31,6 +32,7 @@ class LoginController extends Controller
|
||||
]);
|
||||
|
||||
if ($this->guard()->attempt($credentials, $request->get('remember'))) {
|
||||
ShopCart::migrateGuestCartToUser();
|
||||
$request->session()->regenerate();
|
||||
if (back()->getTargetUrl() === route('Shop.Orders.store')) {
|
||||
$route = 'Shop.Orders.order';
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Shop\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Shop\RegisterCustomer;
|
||||
use App\Repositories\Core\User\ShopCart;
|
||||
use App\Repositories\Shop\CustomerSaleChannels;
|
||||
use App\Repositories\Shop\CustomerAddresses;
|
||||
use App\Repositories\Shop\Customers;
|
||||
@@ -33,6 +34,7 @@ class RegisterController extends Controller
|
||||
$user = $this->create($request->all());
|
||||
|
||||
$this->guard()->login($user);
|
||||
ShopCart::migrateGuestCartToUser();
|
||||
|
||||
return $request->wantsJson()
|
||||
? new JsonResponse([], 201)
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
|
||||
namespace App\Http\Controllers\Shop;
|
||||
|
||||
use App\Repositories\Core\User\ShopCart;
|
||||
use App\Repositories\Shop\Baskets;
|
||||
use App\Repositories\Shop\CustomerAddresses;
|
||||
use App\Repositories\Shop\Customers;
|
||||
use App\Repositories\Shop\Offers;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class CustomerController extends Controller
|
||||
{
|
||||
@@ -43,8 +48,69 @@ class CustomerController extends Controller
|
||||
public function storeProfileAjax(Request $request)
|
||||
{
|
||||
$data = $request->all();
|
||||
|
||||
if (array_key_exists('default_sale_channel_id', $data)) {
|
||||
$customer = Customers::get(Customers::getId());
|
||||
$newSaleChannelId = (int) $data['default_sale_channel_id'];
|
||||
$currentSaleChannelId = (int) ($customer->default_sale_channel_id ?? 0);
|
||||
|
||||
if ($newSaleChannelId && $newSaleChannelId !== $currentSaleChannelId && ShopCart::count() > 0) {
|
||||
$cartItems = ShopCart::getContent();
|
||||
$unavailable = [];
|
||||
|
||||
foreach ($cartItems as $item) {
|
||||
$offerId = (int) $item->id;
|
||||
|
||||
if (! Offers::getPrice($offerId, 1, $newSaleChannelId)) {
|
||||
$offer = Offers::get($offerId, ['article']);
|
||||
$unavailable[] = $offer->article->name ?? $item->name;
|
||||
|
||||
if (count($unavailable) >= 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($unavailable)) {
|
||||
$list = implode(', ', $unavailable);
|
||||
|
||||
return response()->json([
|
||||
'error' => 1,
|
||||
'message' => __('Certains articles de votre panier ne sont pas disponibles dans ce canal : :products. Merci de finaliser votre commande ou de retirer ces articles avant de changer de canal.', ['products' => $list]),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$customerId = $data['id'] ?? Customers::getId();
|
||||
$requestedDefaultSaleChannelId = $data['default_sale_channel_id'] ?? null;
|
||||
$hasDefaultSaleChannelColumn = Schema::hasColumn('shop_customers', 'default_sale_channel_id');
|
||||
|
||||
if (! $hasDefaultSaleChannelColumn) {
|
||||
unset($data['default_sale_channel_id']);
|
||||
}
|
||||
|
||||
$customer = Customers::store($data);
|
||||
|
||||
if (! $customer) {
|
||||
return response()->json([
|
||||
'error' => 1,
|
||||
'message' => __('Impossible de mettre à jour votre profil pour le moment.'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($hasDefaultSaleChannelColumn && $requestedDefaultSaleChannelId !== null) {
|
||||
Customers::setDefaultSaleChannel($customerId, $requestedDefaultSaleChannelId);
|
||||
}
|
||||
|
||||
$freshCustomer = Customers::get($customerId, ['sale_channels']);
|
||||
Customers::guard()->setUser($freshCustomer);
|
||||
|
||||
if ($requestedDefaultSaleChannelId !== null) {
|
||||
session(['shop.default_sale_channel_id' => $requestedDefaultSaleChannelId]);
|
||||
Baskets::refreshPrices((int) $requestedDefaultSaleChannelId);
|
||||
}
|
||||
|
||||
return response()->json(['error' => 0]);
|
||||
}
|
||||
|
||||
@@ -56,10 +122,89 @@ class CustomerController extends Controller
|
||||
return redirect()->route('Shop.Customers.edit');
|
||||
}
|
||||
|
||||
public function storeAddress(Request $request)
|
||||
{
|
||||
if (Customers::isNotConnected()) {
|
||||
return response()->json(['message' => __('Authentification requise.')], 403);
|
||||
}
|
||||
|
||||
$prefix = $request->input('prefix');
|
||||
$types = ['deliveries' => 1, 'invoices' => 2];
|
||||
|
||||
if (! array_key_exists($prefix, $types)) {
|
||||
return response()->json(['message' => __('Type d\'adresse inconnu.')], 422);
|
||||
}
|
||||
|
||||
$addressData = $request->input($prefix, []);
|
||||
|
||||
$validator = Validator::make($addressData, [
|
||||
'name' => ['nullable', 'string', 'max:150'],
|
||||
'address' => ['required', 'string', 'max:255'],
|
||||
'address2' => ['nullable', 'string', 'max:255'],
|
||||
'zipcode' => ['required', 'string', 'max:30'],
|
||||
'city' => ['required', 'string', 'max:255'],
|
||||
], [
|
||||
'address.required' => __('Merci de renseigner l\'adresse.'),
|
||||
'zipcode.required' => __('Merci de renseigner le code postal.'),
|
||||
'city.required' => __('Merci de renseigner la ville.'),
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => __('Merci de vérifier les informations saisies.'),
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$data = $validator->validated();
|
||||
$customerId = Customers::getId();
|
||||
$data['customer_id'] = $customerId;
|
||||
$data['type'] = $types[$prefix];
|
||||
|
||||
if (empty($data['name'])) {
|
||||
$data['name'] = Customers::getName($customerId);
|
||||
}
|
||||
|
||||
$address = CustomerAddresses::store($data);
|
||||
CustomerAddresses::setDefault($customerId, $address->id, $types[$prefix]);
|
||||
|
||||
$html = view('Shop.Customers.partials.address_item', [
|
||||
'address' => $address->toArray(),
|
||||
'prefix' => $prefix,
|
||||
'with_name' => true,
|
||||
'selected' => $address->id,
|
||||
])->render();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'html' => $html,
|
||||
'message' => __('Adresse enregistrée.'),
|
||||
'id' => $address->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete_address($id)
|
||||
{
|
||||
$ret = CustomerAddresses::destroy($id);
|
||||
$address = CustomerAddresses::get($id);
|
||||
|
||||
return redirect()->route('Shop.Customers.edit');
|
||||
if (! $address || (int) $address->customer_id !== (int) Customers::getId()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$remaining = CustomerAddresses::getModel()
|
||||
->byCustomer($address->customer_id)
|
||||
->byType($address->type)
|
||||
->count();
|
||||
|
||||
if ($remaining <= 1) {
|
||||
return redirect()->route('Shop.Customers.edit')
|
||||
->with('growl', [__('Vous devez conserver au moins une adresse par type.'), 'warning']);
|
||||
}
|
||||
|
||||
CustomerAddresses::destroy($id);
|
||||
CustomerAddresses::ensureDefault($address->customer_id, $address->type);
|
||||
|
||||
return redirect()->route('Shop.Customers.edit')
|
||||
->with('growl', [__('Adresse supprimée.'), 'success']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,14 @@ class InvoiceController extends Controller
|
||||
|
||||
public function view($uuid)
|
||||
{
|
||||
$invoice = Invoices::view($uuid);
|
||||
|
||||
if (! $invoice) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'invoice' => Invoices::view($uuid),
|
||||
'invoice' => $invoice,
|
||||
];
|
||||
|
||||
return view('Shop.Invoices.view', $data);
|
||||
@@ -24,7 +30,9 @@ class InvoiceController extends Controller
|
||||
|
||||
public function pdf($uuid)
|
||||
{
|
||||
\Debugbar::disable();
|
||||
if (app()->bound('debugbar')) {
|
||||
app('debugbar')->disable();
|
||||
}
|
||||
|
||||
return InvoicePDF::getByUUID($uuid);
|
||||
}
|
||||
|
||||
@@ -49,12 +49,22 @@ class OrderController extends Controller
|
||||
{
|
||||
if (ShopCart::count()) {
|
||||
$customer = Customers::getWithAddresses();
|
||||
$deliveries = Deliveries::getByCustomer();
|
||||
$customerId = $customer ? $customer->id : false;
|
||||
$defaultSaleChannelId = SaleChannels::getDefaultID($customerId);
|
||||
$deliveries = $defaultSaleChannelId
|
||||
? Deliveries::getBySaleChannels([$defaultSaleChannelId])
|
||||
: Deliveries::getByCustomer($customerId);
|
||||
$deliveries = $deliveries ? $deliveries->values() : collect();
|
||||
|
||||
$customerData = $customer ? $customer->toArray() : false;
|
||||
if ($customerData && $defaultSaleChannelId) {
|
||||
$customerData['default_sale_channel_id'] = $defaultSaleChannelId;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'customer' => $customer ? $customer->toArray() : false,
|
||||
'customer' => $customerData,
|
||||
'basket' => Baskets::getBasketTotal(),
|
||||
'deliveries' => $deliveries ? $deliveries->toArray() : [],
|
||||
'deliveries' => $deliveries->toArray(),
|
||||
'delivery_types' => DeliveryTypes::getWithPrice(Baskets::getWeight()),
|
||||
];
|
||||
|
||||
|
||||
@@ -3,13 +3,19 @@
|
||||
namespace App\Http\Controllers\Shop;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Repositories\Core\User\ShopCart;
|
||||
use App\Repositories\Shop\Paybox as PayboxGateway;
|
||||
use App\Repositories\Shop\Contents;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
|
||||
class PayboxController extends Controller
|
||||
{
|
||||
public function accepted()
|
||||
{
|
||||
ShopCart::clear();
|
||||
|
||||
return view('paybox.paybox', ['content' => Contents::getPayboxConfirmedContent()]);
|
||||
}
|
||||
|
||||
@@ -30,8 +36,20 @@ class PayboxController extends Controller
|
||||
|
||||
public function process(Request $request)
|
||||
{
|
||||
$data = $request->all();
|
||||
$invoiceId = $request->input('order_number');
|
||||
|
||||
return view('paybox.send', $data);
|
||||
if (! $invoiceId) {
|
||||
Log::warning('Paybox callback missing order_number', ['payload' => $request->all()]);
|
||||
|
||||
return response('Missing order_number', 400);
|
||||
}
|
||||
|
||||
$success = PayboxGateway::verifyPayment($invoiceId);
|
||||
|
||||
if (! $success) {
|
||||
return response('KO', 400);
|
||||
}
|
||||
|
||||
return response('OK');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests\Admin\Shop;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreArticlePost extends FormRequest
|
||||
{
|
||||
@@ -13,8 +14,13 @@ class StoreArticlePost extends FormRequest
|
||||
|
||||
public function rules()
|
||||
{
|
||||
$articleId = $this->input('id');
|
||||
|
||||
return [
|
||||
'ref' => 'required|unique:shop_articles',
|
||||
'ref' => [
|
||||
'required',
|
||||
Rule::unique('shop_articles', 'ref')->ignore($articleId)->whereNull('deleted_at'),
|
||||
],
|
||||
'product_type' => 'required',
|
||||
'product_id' => 'required',
|
||||
'article_nature_id' => 'required',
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace App\Models\Shop;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class CustomerAddress extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $table = 'shop_customer_addresses';
|
||||
|
||||
@@ -39,7 +39,7 @@ class Invoice extends Model
|
||||
|
||||
public function address(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerAddress::class, 'invoice_address_id');
|
||||
return $this->belongsTo(CustomerAddress::class, 'invoice_address_id')->withTrashed();
|
||||
}
|
||||
|
||||
public function scopeByCustomer($query, $customerId)
|
||||
|
||||
@@ -29,7 +29,7 @@ class Order extends Model
|
||||
|
||||
public function delivery_address(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerAddress::class, 'delivery_address_id');
|
||||
return $this->belongsTo(CustomerAddress::class, 'delivery_address_id')->withTrashed();
|
||||
}
|
||||
|
||||
public function delivery(): BelongsTo
|
||||
|
||||
@@ -11,9 +11,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot()
|
||||
{
|
||||
if (config('app.env') === 'production') {
|
||||
\URL::forceScheme('https');
|
||||
}
|
||||
Schema::defaultStringLength(191);
|
||||
View::composer('Shop.layout.layout', LayoutComposer::class);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ class Varieties
|
||||
{
|
||||
$images = $data['images'] ?? false;
|
||||
$tags = $data['tags'] ?? false;
|
||||
if (! array_key_exists('plus', $data) || $data['plus'] === null) {
|
||||
$data['plus'] = '';
|
||||
}
|
||||
unset($data['images']);
|
||||
unset($data['tags']);
|
||||
$variety = self::store($data);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Repositories\Core;
|
||||
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media as MediaModel;
|
||||
|
||||
class Medias
|
||||
{
|
||||
public static function getImage($model, $conversion = 'normal', $collection = 'images')
|
||||
@@ -79,13 +81,9 @@ class Medias
|
||||
|
||||
public static function getImageSrc($image)
|
||||
{
|
||||
if (! $image) {
|
||||
return null;
|
||||
}
|
||||
$id = $image['id'];
|
||||
$filename = self::getFilename($image);
|
||||
$media = self::resolveMedia($image);
|
||||
|
||||
return "/storage/{$id}/{$filename}";
|
||||
return $media ? $media->getUrl() : null;
|
||||
}
|
||||
|
||||
public static function getThumbSrc($image)
|
||||
@@ -110,6 +108,12 @@ class Medias
|
||||
|
||||
public static function getSrcByType($image, $type)
|
||||
{
|
||||
$media = self::resolveMedia($image);
|
||||
|
||||
if ($media) {
|
||||
return $type ? $media->getUrl($type) : $media->getUrl();
|
||||
}
|
||||
|
||||
return $image ? '/storage/'.$image['id'].'/conversions/'.self::getFilename($image, $type) : false;
|
||||
}
|
||||
|
||||
@@ -124,4 +128,48 @@ class Medias
|
||||
{
|
||||
return str_replace(['#', '/', '\\', ' '], '-', $name);
|
||||
}
|
||||
|
||||
protected static function resolveMedia($image): ?MediaModel
|
||||
{
|
||||
if ($image instanceof MediaModel) {
|
||||
return $image;
|
||||
}
|
||||
|
||||
if (is_null($image)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($image)) {
|
||||
return self::hydrateMedia($image);
|
||||
}
|
||||
|
||||
if (is_object($image)) {
|
||||
if ($image instanceof \ArrayAccess) {
|
||||
return self::hydrateMedia((array) $image);
|
||||
}
|
||||
|
||||
$array = method_exists($image, 'toArray') ? $image->toArray() : (array) $image;
|
||||
|
||||
return self::hydrateMedia($array);
|
||||
}
|
||||
|
||||
$id = data_get($image, 'id');
|
||||
|
||||
return $id ? MediaModel::query()->withoutGlobalScopes()->find($id) : null;
|
||||
}
|
||||
|
||||
protected static function hydrateMedia(array $attributes): ?MediaModel
|
||||
{
|
||||
$id = data_get($attributes, 'id');
|
||||
|
||||
if (! $id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$media = new MediaModel();
|
||||
$media->forceFill($attributes);
|
||||
$media->exists = true;
|
||||
|
||||
return $media;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,11 +94,106 @@ class ShopCart
|
||||
return self::get()->getContent();
|
||||
}
|
||||
|
||||
public static function get()
|
||||
public static function migrateGuestCartToUser($userId = null)
|
||||
{
|
||||
$userId = Auth::guard('customer')->id();
|
||||
$sessionKey = 'cart_'.sha1(static::class . ($userId ?? 'guest'));
|
||||
$userId = self::resolveUserId($userId);
|
||||
|
||||
return Cart::session($sessionKey);
|
||||
if ($userId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$guestSessionKey = self::sessionKey();
|
||||
$guestItems = Cart::session($guestSessionKey)->getContent();
|
||||
|
||||
if ($guestItems->count() === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userSessionKey = self::sessionKey($userId);
|
||||
|
||||
foreach ($guestItems as $item) {
|
||||
$existing = Cart::session($userSessionKey)->get($item->id);
|
||||
|
||||
if ($existing) {
|
||||
Cart::session($userSessionKey)->update($item->id, [
|
||||
'quantity' => [
|
||||
'relative' => false,
|
||||
'value' => $existing->quantity + $item->quantity,
|
||||
],
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$itemData = [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'price' => $item->price,
|
||||
'quantity' => $item->quantity,
|
||||
'attributes' => self::extractAttributes($item),
|
||||
];
|
||||
|
||||
if (isset($item->associatedModel)) {
|
||||
$itemData['associatedModel'] = $item->associatedModel;
|
||||
}
|
||||
|
||||
$conditions = self::extractConditions($item);
|
||||
if (! empty($conditions)) {
|
||||
$itemData['conditions'] = $conditions;
|
||||
}
|
||||
|
||||
Cart::session($userSessionKey)->add($itemData);
|
||||
}
|
||||
|
||||
Cart::session($guestSessionKey)->clear();
|
||||
Cart::session($userSessionKey);
|
||||
}
|
||||
|
||||
protected static function extractAttributes($item)
|
||||
{
|
||||
if (! isset($item->attributes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_object($item->attributes) && method_exists($item->attributes, 'toArray')) {
|
||||
return $item->attributes->toArray();
|
||||
}
|
||||
|
||||
return (array) $item->attributes;
|
||||
}
|
||||
|
||||
protected static function extractConditions($item)
|
||||
{
|
||||
if (! isset($item->conditions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_object($item->conditions) && method_exists($item->conditions, 'toArray')) {
|
||||
return $item->conditions->toArray();
|
||||
}
|
||||
|
||||
return (array) $item->conditions;
|
||||
}
|
||||
|
||||
protected static function resolveUserId($userId = null)
|
||||
{
|
||||
return $userId ?? Auth::guard('customer')->id();
|
||||
}
|
||||
|
||||
protected static function sessionKey($userId = null)
|
||||
{
|
||||
$key = $userId ?? 'guest';
|
||||
|
||||
return 'cart_'.sha1(static::class.$key);
|
||||
}
|
||||
|
||||
protected static function session($userId = null)
|
||||
{
|
||||
return Cart::session(self::sessionKey($userId));
|
||||
}
|
||||
|
||||
public static function get($userId = null)
|
||||
{
|
||||
return self::session(self::resolveUserId($userId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,21 +27,34 @@ class ArticleTags
|
||||
|
||||
switch ($article->product_type) {
|
||||
case 'App\Models\Botanic\Variety':
|
||||
$data += $article->product->tags->toArray();
|
||||
if ($article->product->specie ?? false) {
|
||||
$data += $article->product->specie->tags->toArray();
|
||||
$variety = $article->product;
|
||||
if ($variety && $variety->tags) {
|
||||
$data = array_merge($data, $variety->tags->toArray());
|
||||
}
|
||||
if ($variety && $variety->specie && $variety->specie->tags) {
|
||||
$data = array_merge($data, $variety->specie->tags->toArray());
|
||||
}
|
||||
break;
|
||||
case 'App\Models\Botanic\Specie':
|
||||
$data += $article->product->tags->toArray();
|
||||
$specie = $article->product;
|
||||
if ($specie && $specie->tags) {
|
||||
$data = array_merge($data, $specie->tags->toArray());
|
||||
}
|
||||
break;
|
||||
case 'App\Models\Shop\Merchandise':
|
||||
$data += $article->product->tags->toArray();
|
||||
$data += $article->product->producer->tags->toArray();
|
||||
$merchandise = $article->product;
|
||||
if ($merchandise && $merchandise->tags) {
|
||||
$data = array_merge($data, $merchandise->tags->toArray());
|
||||
}
|
||||
if ($merchandise && $merchandise->producer && $merchandise->producer->tags) {
|
||||
$data = array_merge($data, $merchandise->producer->tags->toArray());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
$data += $article->tags->toArray();
|
||||
if ($article->tags) {
|
||||
$data = array_merge($data, $article->tags->toArray());
|
||||
}
|
||||
|
||||
foreach ($data as $tag) {
|
||||
if (! isset($tags[$tag['group']][$tag['name']])) {
|
||||
|
||||
@@ -5,6 +5,9 @@ namespace App\Repositories\Shop;
|
||||
use App\Models\Shop\Article;
|
||||
use App\Repositories\Botanic\Species;
|
||||
use App\Repositories\Botanic\Varieties;
|
||||
use App\Repositories\Shop\SaleChannels;
|
||||
use App\Repositories\Shop\Customers;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use App\Repositories\Core\Comments;
|
||||
use App\Traits\Model\Basic;
|
||||
use App\Traits\Repository\Imageable;
|
||||
@@ -16,7 +19,7 @@ class Articles
|
||||
|
||||
public static function autocomplete($str)
|
||||
{
|
||||
$data = Article::byAutocomplete($str)->orderBy('name')->limit(20)->pluck('name', 'id');
|
||||
$data = Article::byAutocomplete($str)->visible()->orderBy('name')->limit(20)->pluck('name', 'id');
|
||||
$export = [];
|
||||
foreach ($data as $key => $name) {
|
||||
$export[] = ['value' => $key, 'text' => $name];
|
||||
@@ -70,9 +73,33 @@ class Articles
|
||||
|
||||
public static function getArticleToSell($id, $saleChannelId = false)
|
||||
{
|
||||
$saleChannelId = $saleChannelId ?: SaleChannels::getDefaultID();
|
||||
$sessionSaleChannelId = session('shop.default_sale_channel_id');
|
||||
$customer = Customers::getAuth();
|
||||
$hasDefaultSaleChannelColumn = Schema::hasColumn('shop_customers', 'default_sale_channel_id');
|
||||
$customerDefaultSaleChannelId = ($customer && $hasDefaultSaleChannelColumn)
|
||||
? $customer->default_sale_channel_id
|
||||
: null;
|
||||
$customerSaleChannelIds = [];
|
||||
|
||||
if ($customer) {
|
||||
$customer->loadMissing('sale_channels:id');
|
||||
$customerSaleChannelIds = $customer->sale_channels->pluck('id')->toArray();
|
||||
}
|
||||
$data = self::getArticle($id);
|
||||
$data['offers'] = self::getOffersGroupedByNature($id, $saleChannelId);
|
||||
|
||||
$currentSaleChannel = $saleChannelId ? SaleChannels::get($saleChannelId) : null;
|
||||
$data['current_sale_channel'] = $currentSaleChannel ? $currentSaleChannel->toArray() : null;
|
||||
$data['available_sale_channels'] = Offers::getSaleChannelsForArticle($id);
|
||||
$data['debug_sale_channel'] = [
|
||||
'session_default_sale_channel_id' => $sessionSaleChannelId,
|
||||
'customer_default_sale_channel_id' => $customerDefaultSaleChannelId,
|
||||
'customer_linked_sale_channel_ids' => $customerSaleChannelIds,
|
||||
'resolved_sale_channel_id' => $saleChannelId,
|
||||
'has_default_sale_channel_column' => $hasDefaultSaleChannelColumn,
|
||||
];
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -108,8 +135,11 @@ class Articles
|
||||
$data['specie'] = $article->product ? $article->product->description : '';
|
||||
break;
|
||||
case 'App\Models\Shop\Merchandise':
|
||||
$data['merchandise'] = $article->product ? $article->product->description : '';
|
||||
$data['producer'] = $article->product->producer->description;
|
||||
$merchandise = $article->product;
|
||||
$data['merchandise'] = $merchandise ? ($merchandise->description ?? '') : '';
|
||||
if ($merchandise && $merchandise->producer) {
|
||||
$data['producer'] = $merchandise->producer->description ?? '';
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
@@ -100,12 +100,19 @@ class Baskets
|
||||
$offers = Offers::getWithPricesByIds(self::getIds(), $saleChannelId);
|
||||
foreach ($basket as $item) {
|
||||
$offer = $offers->where('id', $item->id)->first();
|
||||
if (! $offer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$priceValue = Offers::getPrice($item->id, $item->quantity, $saleChannelId);
|
||||
$unitPrice = $priceValue ? (float) $priceValue->price_taxed : (float) $item->price;
|
||||
|
||||
$article_nature = strtolower($offer->article->article_nature->name);
|
||||
$data[$article_nature][] = [
|
||||
'id' => (int) $item->id,
|
||||
'name' => $item->name,
|
||||
'quantity' => (int) $item->quantity,
|
||||
'price' => $item->price,
|
||||
'price' => $unitPrice,
|
||||
'variation' => $offer->variation->name,
|
||||
'image' => Articles::getPreviewSrc(ArticleImages::getFullImageByArticle($offer->article)),
|
||||
'latin' => $offer->article->product->specie->latin ?? false,
|
||||
@@ -115,6 +122,24 @@ class Baskets
|
||||
return $data ?? false;
|
||||
}
|
||||
|
||||
public static function refreshPrices($saleChannelId = false)
|
||||
{
|
||||
$saleChannelId = $saleChannelId ? $saleChannelId : SaleChannels::getDefaultID();
|
||||
$basket = ShopCart::getContent();
|
||||
|
||||
foreach ($basket as $item) {
|
||||
$priceValue = Offers::getPrice($item->id, $item->quantity, $saleChannelId);
|
||||
|
||||
if (! $priceValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ShopCart::get()->update($item->id, [
|
||||
'price' => $priceValue->price_taxed,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function getBasketData($id, $quantity = 1)
|
||||
{
|
||||
$offer = Offers::get($id, ['article', 'variation']);
|
||||
|
||||
@@ -12,17 +12,35 @@ class CustomerAddresses
|
||||
public static function storeByCustomer($customer, $data)
|
||||
{
|
||||
$deliveries = $data['deliveries'] ?? false;
|
||||
if ($deliveries && $deliveries['zipcode'] && $deliveries['city']) {
|
||||
$deliveries['customer_id'] = $customer->id;
|
||||
$deliveries['type'] = 1;
|
||||
self::store($deliveries);
|
||||
if ($deliveries) {
|
||||
if (! empty($deliveries['address_id'])) {
|
||||
self::setDefault($customer->id, (int) $deliveries['address_id'], 1);
|
||||
}
|
||||
|
||||
if (! empty($deliveries['zipcode']) && ! empty($deliveries['city'])) {
|
||||
$payload = $deliveries;
|
||||
unset($payload['address_id']);
|
||||
$payload['customer_id'] = $customer->id;
|
||||
$payload['type'] = 1;
|
||||
$newAddress = self::store($payload);
|
||||
self::setDefault($customer->id, $newAddress->id, 1);
|
||||
}
|
||||
}
|
||||
|
||||
$invoices = $data['invoices'] ?? false;
|
||||
if ($invoices && $invoices['zipcode'] && $invoices['city']) {
|
||||
$invoices['customer_id'] = $customer->id;
|
||||
$invoices['type'] = 2;
|
||||
self::store($invoices);
|
||||
if ($invoices) {
|
||||
if (! empty($invoices['address_id'])) {
|
||||
self::setDefault($customer->id, (int) $invoices['address_id'], 2);
|
||||
}
|
||||
|
||||
if (! empty($invoices['zipcode']) && ! empty($invoices['city'])) {
|
||||
$payload = $invoices;
|
||||
unset($payload['address_id']);
|
||||
$payload['customer_id'] = $customer->id;
|
||||
$payload['type'] = 2;
|
||||
$newAddress = self::store($payload);
|
||||
self::setDefault($customer->id, $newAddress->id, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,14 +88,24 @@ class CustomerAddresses
|
||||
|
||||
public static function getInvoiceAddress($customerId)
|
||||
{
|
||||
$addresses = CustomerAddress::byCustomer($customerId)->byInvoicing()->get();
|
||||
return count($addresses) ? $addresses->first() : self::getByCustomer($customerId);
|
||||
$address = CustomerAddress::byCustomer($customerId)
|
||||
->byInvoicing()
|
||||
->orderByDesc('priority')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
return $address ?? self::getByCustomer($customerId);
|
||||
}
|
||||
|
||||
public static function getDeliveryAddress($customerId)
|
||||
{
|
||||
$addresses = CustomerAddress::byCustomer($customerId)->byDelivery()->get();
|
||||
return count($addresses) ? $addresses->first() : self::getByCustomer($customerId);
|
||||
$address = CustomerAddress::byCustomer($customerId)
|
||||
->byDelivery()
|
||||
->orderByDesc('priority')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
return $address ?? self::getByCustomer($customerId);
|
||||
}
|
||||
|
||||
public static function getByCustomer($customerId = false)
|
||||
@@ -92,6 +120,40 @@ class CustomerAddresses
|
||||
return ((int) $type === 1) ? '<i class="fa fa-fw fa-truck"></i>' : '<i class="fa fa-fw fa-file-invoice"></i>';
|
||||
}
|
||||
|
||||
public static function setDefault($customerId, $addressId, $type)
|
||||
{
|
||||
if (! $addressId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$address = self::get($addressId);
|
||||
|
||||
if (! $address || (int) $address->customer_id !== (int) $customerId || (int) $address->type !== (int) $type) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::getModel()->byCustomer($customerId)->byType($type)->update(['priority' => null]);
|
||||
|
||||
$address->priority = 1;
|
||||
$address->save();
|
||||
}
|
||||
|
||||
public static function ensureDefault($customerId, $type)
|
||||
{
|
||||
$hasDefault = self::getModel()->byCustomer($customerId)->byType($type)->where('priority', 1)->exists();
|
||||
|
||||
if ($hasDefault) {
|
||||
return;
|
||||
}
|
||||
|
||||
$address = self::getModel()->byCustomer($customerId)->byType($type)->orderBy('id')->first();
|
||||
|
||||
if ($address) {
|
||||
$address->priority = 1;
|
||||
$address->save();
|
||||
}
|
||||
}
|
||||
|
||||
public static function toggleActive($id, $active)
|
||||
{
|
||||
return self::update(['active' => $active], $id);
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Datatables\Shop\CustomerInvoicesDataTable;
|
||||
use App\Datatables\Shop\CustomerOrdersDataTable;
|
||||
use App\Models\Shop\Customer;
|
||||
use App\Traits\Model\Basic;
|
||||
use App\Repositories\Shop\CustomerAddresses;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -30,16 +31,31 @@ class Customers
|
||||
public static function getSaleChannels($customerId = false)
|
||||
{
|
||||
$customer = $customerId ? self::get($customerId) : self::getAuth();
|
||||
$saleChannels = $customer ? $customer->sale_channels : false;
|
||||
$saleChannels = collect();
|
||||
|
||||
return $saleChannels ? $saleChannels : SaleChannels::getDefault();
|
||||
if ($customer) {
|
||||
$customer->loadMissing('sale_channels');
|
||||
$saleChannels = $customer->sale_channels ?? collect();
|
||||
|
||||
if ($saleChannels instanceof \Illuminate\Support\Collection && $saleChannels->isNotEmpty()) {
|
||||
return $saleChannels;
|
||||
}
|
||||
}
|
||||
|
||||
$default = SaleChannels::getDefault($customerId);
|
||||
|
||||
return $default ? collect([$default]) : collect();
|
||||
}
|
||||
|
||||
public static function getSaleChannel($customerId = false)
|
||||
{
|
||||
$saleChannels = self::getSaleChannels($customerId);
|
||||
|
||||
return $saleChannels->first();
|
||||
if ($saleChannels instanceof \Illuminate\Support\Collection) {
|
||||
return $saleChannels->first();
|
||||
}
|
||||
|
||||
return $saleChannels;
|
||||
}
|
||||
|
||||
public static function getDeliveries()
|
||||
@@ -57,12 +73,22 @@ class Customers
|
||||
|
||||
public static function editProfile($id = false)
|
||||
{
|
||||
return $id ? [
|
||||
'customer' => self::get($id, ['addresses', 'deliveries'])->toArray(),
|
||||
'deliveries' => Deliveries::getAllWithSaleChannel()->toArray(),
|
||||
if (! $id) {
|
||||
abort('403');
|
||||
}
|
||||
|
||||
$customer = self::get($id, ['addresses', 'deliveries', 'sale_channels']);
|
||||
|
||||
$saleChannels = self::getSaleChannels($id);
|
||||
|
||||
return [
|
||||
'customer' => $customer->toArray(),
|
||||
'sale_channels' => $saleChannels->toArray(),
|
||||
'deliveries' => Deliveries::getByCustomer($id)->toArray(),
|
||||
'sale_channel_checks' => Shop::getSaleChannelAvailabilitySummary($saleChannels->pluck('id')->toArray()),
|
||||
'orders' => (new CustomerOrdersDataTable())->html(),
|
||||
'invoices' => (new CustomerInvoicesDataTable())->html(),
|
||||
] : abort('403');
|
||||
];
|
||||
}
|
||||
|
||||
public static function getAddresses($id = false)
|
||||
@@ -95,6 +121,16 @@ class Customers
|
||||
$data = $customer->toArray();
|
||||
$data['sale_channels'] = $customer->sale_channels->pluck('id')->toArray();
|
||||
$data['deliveries'] = Deliveries::getBySaleChannels($data['sale_channels'])->toArray();
|
||||
$data['delivery_address_id'] = optional(CustomerAddresses::getDeliveryAddress($id))->id;
|
||||
$data['invoice_address_id'] = optional(CustomerAddresses::getInvoiceAddress($id))->id;
|
||||
|
||||
if (! $data['delivery_address_id'] && ! empty($data['delivery_addresses'])) {
|
||||
$data['delivery_address_id'] = $data['delivery_addresses'][0]['id'] ?? null;
|
||||
}
|
||||
|
||||
if (! $data['invoice_address_id'] && ! empty($data['invoice_addresses'])) {
|
||||
$data['invoice_address_id'] = $data['invoice_addresses'][0]['id'] ?? null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
@@ -102,8 +138,8 @@ class Customers
|
||||
public static function storeFull($data)
|
||||
{
|
||||
$data2 = $data;
|
||||
if ($data['sale_channels'] ?? false) {
|
||||
$saleChannels = $data['sale_channels'] ?? false;
|
||||
$saleChannels = array_key_exists('sale_channels', $data) ? $data['sale_channels'] : null;
|
||||
if ($saleChannels !== null) {
|
||||
unset($data['sale_channels']);
|
||||
}
|
||||
if ($data['deliveries'] ?? false) {
|
||||
@@ -113,7 +149,9 @@ class Customers
|
||||
unset($data['invoices']);
|
||||
}
|
||||
$customer = self::store($data);
|
||||
$customer->sale_channels()->sync($saleChannels);
|
||||
if ($saleChannels !== null) {
|
||||
$customer->sale_channels()->sync($saleChannels);
|
||||
}
|
||||
CustomerAddresses::storeByCustomer($customer, $data2);
|
||||
|
||||
return $customer->id;
|
||||
@@ -141,6 +179,24 @@ class Customers
|
||||
return $customer->sale_channels()->sync($saleChannels);
|
||||
}
|
||||
|
||||
public static function setDefaultSaleChannel($customerId, $saleChannelId)
|
||||
{
|
||||
if (! $customerId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$customer = self::get($customerId);
|
||||
|
||||
if (! $customer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$customer->default_sale_channel_id = $saleChannelId ?: null;
|
||||
$customer->save();
|
||||
|
||||
return $customer->fresh(['sale_channels']);
|
||||
}
|
||||
|
||||
public static function create($data)
|
||||
{
|
||||
$data['uuid'] = Str::uuid();
|
||||
|
||||
@@ -21,12 +21,12 @@ class Deliveries
|
||||
$customer = $customerId ? Customers::get($customerId) : Customers::getAuth();
|
||||
$saleChannels = $customer ? $customer->sale_channels->pluck('id')->toArray() : [SaleChannels::getDefaultID()];
|
||||
|
||||
return $saleChannels ? self::getBySaleChannels($saleChannels) : false;
|
||||
return $saleChannels ? self::getBySaleChannels($saleChannels) : collect();
|
||||
}
|
||||
|
||||
public static function getBySaleChannels($saleChannels)
|
||||
{
|
||||
return Delivery::bySaleChannels($saleChannels)->with('sale_channel')->get();
|
||||
return Delivery::bySaleChannels($saleChannels)->active()->with('sale_channel')->get();
|
||||
}
|
||||
|
||||
public static function getSaleChannelId($deliveryId)
|
||||
@@ -41,7 +41,7 @@ class Deliveries
|
||||
|
||||
public static function getAllWithSaleChannel()
|
||||
{
|
||||
return Delivery::orderBy('name', 'asc')->active()->public()->with('sale_channel')->get();
|
||||
return Delivery::orderBy('name', 'asc')->active()->with('sale_channel')->get();
|
||||
}
|
||||
|
||||
public static function toggleActive($id, $active)
|
||||
|
||||
@@ -15,9 +15,15 @@ class DeliveryTypes
|
||||
$types = self::getAll();
|
||||
|
||||
foreach ($types as $type) {
|
||||
$price = self::getPrice($type->id, $weight);
|
||||
|
||||
if ($price === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data[$type->id] = [
|
||||
'name' => $type->name,
|
||||
'price' => self::getPrice($type->id, $weight),
|
||||
'price' => $price,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,15 @@ class InvoicePDF
|
||||
public static function get($id)
|
||||
{
|
||||
$invoice = Invoices::getFull($id);
|
||||
$customFields = [];
|
||||
if ($orderRef = optional($invoice->order)->ref) {
|
||||
$customFields['order number'] = $orderRef;
|
||||
}
|
||||
|
||||
$customer = new Party([
|
||||
'name' => $invoice->customer->name,
|
||||
'name' => optional($invoice->customer)->name ?? __('Client inconnu'),
|
||||
'address' => self::makeAddress($invoice->address),
|
||||
'custom_fields' => [
|
||||
'order number' => $invoice->order->ref,
|
||||
],
|
||||
'custom_fields' => $customFields,
|
||||
]);
|
||||
|
||||
$items = self::makeItems($invoice->order->detail);
|
||||
@@ -48,7 +51,17 @@ class InvoicePDF
|
||||
|
||||
public static function makeAddress($address)
|
||||
{
|
||||
return $address->address.'<br>'.$address->zipcode.' '.$address->city;
|
||||
if (! $address) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$lines = array_filter([
|
||||
$address->address ?? '',
|
||||
$address->address2 ?? '',
|
||||
trim(($address->zipcode ?? '').' '.($address->city ?? '')),
|
||||
]);
|
||||
|
||||
return implode('<br>', $lines);
|
||||
}
|
||||
|
||||
public static function makeItems($details)
|
||||
|
||||
@@ -36,7 +36,13 @@ class Invoices
|
||||
|
||||
public static function view($uuid)
|
||||
{
|
||||
$data = self::getFullByUUID($uuid)->toArray();
|
||||
$invoice = self::getFullByUUID($uuid);
|
||||
|
||||
if (! $invoice) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = $invoice->toArray();
|
||||
$data['payment_type'] = InvoicePayments::getPaymentType($data['payment_type']);
|
||||
$data['status'] = self::getStatus($data['status']);
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
namespace App\Repositories\Shop;
|
||||
|
||||
use App\Models\Shop\Offer;
|
||||
use App\Models\Shop\PriceList;
|
||||
use App\Models\Shop\PriceListValue;
|
||||
use App\Models\Shop\SaleChannel;
|
||||
use App\Traits\Model\Basic;
|
||||
|
||||
class Offers
|
||||
@@ -166,4 +169,87 @@ class Offers
|
||||
{
|
||||
return Offer::query();
|
||||
}
|
||||
|
||||
public static function getSaleChannelsForArticle($articleId)
|
||||
{
|
||||
$channels = SaleChannel::query()
|
||||
->whereHas('price_lists', function ($query) use ($articleId) {
|
||||
$query->whereHas('tariff.offers', function ($subQuery) use ($articleId) {
|
||||
$subQuery->byArticle($articleId);
|
||||
})->whereHas('price_list_values');
|
||||
})
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$offers = Offer::query()
|
||||
->byArticle($articleId)
|
||||
->with([
|
||||
'article',
|
||||
'tariff:id,status_id',
|
||||
'variation',
|
||||
])
|
||||
->get();
|
||||
|
||||
return $channels->map(function ($channel) use ($offers) {
|
||||
$priceValue = null;
|
||||
$candidateOffer = null;
|
||||
$allOffersForChannel = [];
|
||||
|
||||
foreach ($offers as $offer) {
|
||||
$priceCandidate = self::getPrice($offer->id, 1, $channel->id);
|
||||
|
||||
if ($priceCandidate && (float) $priceCandidate->price_taxed > 0) {
|
||||
// Get price list name
|
||||
$priceListName = null;
|
||||
if ($priceCandidate) {
|
||||
$priceListModel = PriceList::find($priceCandidate->price_list_id);
|
||||
$priceListName = $priceListModel ? $priceListModel->name : null;
|
||||
}
|
||||
|
||||
// Collect all offers with their details
|
||||
$allOffersForChannel[] = [
|
||||
'id' => $offer->id,
|
||||
'variation_name' => $offer->variation ? $offer->variation->name : null,
|
||||
'stock_current' => (int) $offer->stock_current,
|
||||
'status_id' => (int) $offer->status_id,
|
||||
'is_active' => (int) $offer->status_id === 1,
|
||||
'tariff_id' => $offer->tariff_id ? (int) $offer->tariff_id : null,
|
||||
'price_taxed' => (float) $priceCandidate->price_taxed,
|
||||
'quantity' => (int) $priceCandidate->quantity,
|
||||
'price_list_name' => $priceListName,
|
||||
];
|
||||
|
||||
// Keep first valid offer as the main candidate
|
||||
if (!$candidateOffer) {
|
||||
$priceValue = $priceCandidate;
|
||||
$candidateOffer = $offer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$offerId = $candidateOffer ? $candidateOffer->id : null;
|
||||
$offerStock = $candidateOffer ? (int) $candidateOffer->stock_current : null;
|
||||
$offerIsActive = $candidateOffer ? (int) $candidateOffer->status_id === 1 : false;
|
||||
$offerTariffStatus = $candidateOffer && $candidateOffer->tariff ? (int) $candidateOffer->tariff->status_id : null;
|
||||
$offerHasStock = $candidateOffer && $candidateOffer->stock_current !== null
|
||||
? (float) $candidateOffer->stock_current > 0
|
||||
: null;
|
||||
$offerTariffId = $candidateOffer && $candidateOffer->tariff_id ? (int) $candidateOffer->tariff_id : null;
|
||||
|
||||
return [
|
||||
'id' => $channel->id,
|
||||
'name' => $channel->name,
|
||||
'code' => $channel->code,
|
||||
'price_taxed' => $priceValue ? (float) $priceValue->price_taxed : null,
|
||||
'quantity' => $priceValue ? (int) $priceValue->quantity : null,
|
||||
'offer_id' => $offerId,
|
||||
'offer_is_active' => $offerIsActive,
|
||||
'offer_stock_current' => $offerStock,
|
||||
'offer_has_stock' => $offerHasStock,
|
||||
'tariff_status_id' => $offerTariffStatus,
|
||||
'tariff_id' => $offerTariffId,
|
||||
'all_offers' => $allOffersForChannel,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
namespace App\Repositories\Shop;
|
||||
|
||||
use App\Models\Shop\Invoice;
|
||||
use App\Models\Shop\InvoicePayment;
|
||||
use App\Repositories\Core\DateTime;
|
||||
use Bnb\PayboxGateway\Requests\Paybox\AuthorizationWithCapture;
|
||||
use Bnb\PayboxGateway\Requests\PayboxDirect\Capture;
|
||||
use Bnb\PayboxGateway\Responses\Exceptions\InvalidSignature;
|
||||
use Bnb\PayboxGateway\Responses\Paybox\Verify;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class Paybox
|
||||
@@ -23,17 +26,141 @@ class Paybox
|
||||
|
||||
public static function verifyPayment($invoiceId)
|
||||
{
|
||||
$invoice = Invoices::get($invoiceId);
|
||||
$payboxVerify = App::make(Verify::class);
|
||||
try {
|
||||
$success = $payboxVerify->isSuccess($invoice->total_shipped);
|
||||
if ($success) {
|
||||
// process order here after making sure it was real payment
|
||||
}
|
||||
echo 'OK';
|
||||
} catch (InvalidSignature $e) {
|
||||
Log::alert('Invalid payment signature detected');
|
||||
$invoice = Invoices::get($invoiceId, ['order']);
|
||||
|
||||
if (! $invoice) {
|
||||
Log::warning('Paybox callback received for unknown invoice', [
|
||||
'invoice_id' => $invoiceId,
|
||||
'payload' => request()->all(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$payboxVerify = App::make(Verify::class);
|
||||
|
||||
try {
|
||||
$isSuccessful = $payboxVerify->isSuccess($invoice->total_shipped);
|
||||
} catch (InvalidSignature $e) {
|
||||
Log::alert('Invalid payment signature detected', [
|
||||
'invoice_id' => $invoiceId,
|
||||
'payload' => request()->except('signature'),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $isSuccessful) {
|
||||
Log::warning('Paybox payment verification failed', [
|
||||
'invoice_id' => $invoiceId,
|
||||
'response_code' => $payboxVerify->getResponseCode(),
|
||||
'payload' => request()->except('signature'),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::finalizeInvoicePayment($invoice);
|
||||
}
|
||||
|
||||
protected static function finalizeInvoicePayment(Invoice $invoice)
|
||||
{
|
||||
$order = $invoice->order;
|
||||
|
||||
if (! $order) {
|
||||
Log::error('Paybox payment cannot be finalized: missing related order', [
|
||||
'invoice_id' => $invoice->id,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$request = request();
|
||||
$referenceParts = array_filter([
|
||||
$request->input('call_number'),
|
||||
$request->input('transaction_number'),
|
||||
]);
|
||||
$reference = $referenceParts ? implode('-', $referenceParts) : $request->input('authorization_number');
|
||||
if (! $reference) {
|
||||
$reference = 'paybox-'.$invoice->id;
|
||||
}
|
||||
|
||||
$payload = $request->except('signature');
|
||||
$existingPayment = InvoicePayment::where('invoice_id', $invoice->id)
|
||||
->where('reference', $reference)
|
||||
->first();
|
||||
$shouldNotify = false;
|
||||
$validatedTotal = InvoicePayment::where('invoice_id', $invoice->id)
|
||||
->validated()
|
||||
->sum('amount');
|
||||
|
||||
if (! $existingPayment && (float) $validatedTotal >= (float) $invoice->total_shipped) {
|
||||
Log::info('Paybox payment ignored: invoice already fully settled', [
|
||||
'invoice_id' => $invoice->id,
|
||||
'order_id' => $order->id,
|
||||
'reference' => $reference,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($invoice, $order, $reference, $payload, $existingPayment, &$shouldNotify) {
|
||||
$attributes = [
|
||||
'payment_type' => 1,
|
||||
'amount' => $invoice->total_shipped,
|
||||
'date' => DateTime::getDate(),
|
||||
'data' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
'validated' => 1,
|
||||
];
|
||||
|
||||
if ($existingPayment) {
|
||||
$previousValidationState = (int) ($existingPayment->validated ?? 0);
|
||||
$existingPayment->fill($attributes);
|
||||
|
||||
if ($existingPayment->isDirty()) {
|
||||
$existingPayment->save();
|
||||
}
|
||||
|
||||
if ($previousValidationState !== 1 && (int) $existingPayment->validated === 1) {
|
||||
$shouldNotify = true;
|
||||
}
|
||||
} else {
|
||||
InvoicePayment::create($attributes + [
|
||||
'invoice_id' => $invoice->id,
|
||||
'reference' => $reference,
|
||||
]);
|
||||
$shouldNotify = true;
|
||||
}
|
||||
|
||||
Invoices::checkPayments($invoice->id);
|
||||
|
||||
$paidStatus = Orders::getStatusByName('Préparation');
|
||||
if ($paidStatus !== '' && (int) $order->status !== (int) $paidStatus) {
|
||||
$order->status = $paidStatus;
|
||||
$order->save();
|
||||
}
|
||||
});
|
||||
|
||||
if ($shouldNotify) {
|
||||
try {
|
||||
OrderMails::sendOrderConfirmed($order->id);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::error('Unable to send order confirmation email after Paybox payment', [
|
||||
'order_id' => $order->id,
|
||||
'invoice_id' => $invoice->id,
|
||||
'exception' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info('Paybox payment finalized successfully', [
|
||||
'invoice_id' => $invoice->id,
|
||||
'order_id' => $order->id,
|
||||
'reference' => $reference,
|
||||
'notified' => $shouldNotify,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function getPreviousAuthorizedRequest($request)
|
||||
|
||||
@@ -46,12 +46,34 @@ class PriceListValues
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
$value['price_list_id'] = $price_list_id;
|
||||
if ($value['price']) {
|
||||
if (self::hasPrice($value)) {
|
||||
self::store($value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function purgeRemovedValues($price_list_id, array $ids)
|
||||
{
|
||||
if (! count($ids)) {
|
||||
return;
|
||||
}
|
||||
|
||||
PriceListValue::byPriceList($price_list_id)
|
||||
->whereIn('id', $ids)
|
||||
->delete();
|
||||
}
|
||||
|
||||
protected static function hasPrice($value): bool
|
||||
{
|
||||
if (! array_key_exists('price', $value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$price = $value['price'];
|
||||
|
||||
return $price !== null && $price !== '';
|
||||
}
|
||||
|
||||
public static function getModel()
|
||||
{
|
||||
return PriceListValue::query();
|
||||
|
||||
@@ -17,7 +17,7 @@ class PriceLists
|
||||
'taxes' => Taxes::getOptions(),
|
||||
'price_list' => [
|
||||
'tariff_id' => $tariffId,
|
||||
'price_list_values' => array_fill(0, 3, ''),
|
||||
'price_list_values' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -50,9 +50,8 @@ class PriceLists
|
||||
public static function edit($id)
|
||||
{
|
||||
$price_list = self::getFull($id)->toArray();
|
||||
$n = count($price_list['price_list_values']);
|
||||
if ($n <= 3) {
|
||||
$price_list['price_list_values'] += array_fill($n, 3 - $n, '');
|
||||
if (count($price_list['price_list_values']) === 0) {
|
||||
$price_list['price_list_values'][] = [];
|
||||
}
|
||||
|
||||
return $price_list;
|
||||
@@ -71,9 +70,14 @@ class PriceLists
|
||||
public static function store($data)
|
||||
{
|
||||
$id = $data['id'] ?? false;
|
||||
$price_list_values = $data['price_list_values'] ?? false;
|
||||
$price_list_values = $data['price_list_values'] ?? [];
|
||||
$deleted_values = array_map('intval', array_filter($data['deleted_price_list_value_ids'] ?? [], function ($value) {
|
||||
return $value !== null && $value !== '';
|
||||
}));
|
||||
unset($data['price_list_values']);
|
||||
unset($data['deleted_price_list_value_ids']);
|
||||
$price_list = $id ? self::update($data) : self::create($data);
|
||||
PriceListValues::purgeRemovedValues($price_list->id, $deleted_values);
|
||||
PriceListValues::storePrices($price_list->id, $price_list_values);
|
||||
|
||||
return $price_list;
|
||||
|
||||
@@ -3,22 +3,53 @@
|
||||
namespace App\Repositories\Shop;
|
||||
|
||||
use App\Models\Shop\SaleChannel;
|
||||
use App\Repositories\Shop\Customers;
|
||||
use App\Traits\Model\Basic;
|
||||
|
||||
class SaleChannels
|
||||
{
|
||||
use Basic;
|
||||
|
||||
public static function getDefaultID()
|
||||
public static function getDefaultID($customerId = false)
|
||||
{
|
||||
$default = self::getDefault();
|
||||
$default = self::getDefault($customerId);
|
||||
|
||||
return $default ? self::getDefault()->id : false;
|
||||
return $default ? $default->id : false;
|
||||
}
|
||||
|
||||
public static function getDefault()
|
||||
public static function getDefault($customerId = false)
|
||||
{
|
||||
return self::getByCode('EXP');
|
||||
$sessionChannelId = session('shop.default_sale_channel_id');
|
||||
if ($sessionChannelId) {
|
||||
$sessionChannel = SaleChannel::find($sessionChannelId);
|
||||
if ($sessionChannel) {
|
||||
return $sessionChannel;
|
||||
}
|
||||
}
|
||||
|
||||
$customer = $customerId ? Customers::get($customerId) : Customers::getAuth();
|
||||
|
||||
if ($customer) {
|
||||
$customer->loadMissing('sale_channels');
|
||||
|
||||
if ($customer->default_sale_channel_id) {
|
||||
$preferred = $customer->sale_channels->firstWhere('id', $customer->default_sale_channel_id);
|
||||
if (! $preferred) {
|
||||
$preferred = SaleChannel::find($customer->default_sale_channel_id);
|
||||
}
|
||||
if ($preferred) {
|
||||
session(['shop.default_sale_channel_id' => $preferred->id]);
|
||||
return $preferred;
|
||||
}
|
||||
}
|
||||
|
||||
if ($customer->sale_channels->isNotEmpty()) {
|
||||
session(['shop.default_sale_channel_id' => $customer->sale_channels->first()->id]);
|
||||
return $customer->sale_channels->first();
|
||||
}
|
||||
}
|
||||
|
||||
return self::getByCode('POSTE');
|
||||
}
|
||||
|
||||
public static function getByCode($code)
|
||||
|
||||
@@ -8,8 +8,14 @@ class Searches
|
||||
{
|
||||
public static function search($options)
|
||||
{
|
||||
// Get article IDs from Scout search
|
||||
$searchResults = Article::search($options['search_name'])->get()->pluck('id');
|
||||
|
||||
// Filter to only include visible articles
|
||||
$visibleArticleIds = Article::whereIn('id', $searchResults)->visible()->pluck('id');
|
||||
|
||||
return collect(Articles::getArticlesToSell([
|
||||
'ids' => Article::search($options['search_name'])->get()->pluck('id'),
|
||||
'ids' => $visibleArticleIds,
|
||||
]))->sortBy('searchOrder')->toArray();
|
||||
}
|
||||
|
||||
|
||||
47
app/Repositories/Shop/Shop.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories\Shop;
|
||||
|
||||
use App\Repositories\Core\User\ShopCart;
|
||||
|
||||
class Shop
|
||||
{
|
||||
public static function getSaleChannelAvailabilitySummary(array $saleChannelIds): array
|
||||
{
|
||||
if (empty($saleChannelIds) || ShopCart::count() === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$cartItems = ShopCart::getContent();
|
||||
$summary = [];
|
||||
|
||||
foreach ($saleChannelIds as $saleChannelId) {
|
||||
$saleChannelId = (int) $saleChannelId;
|
||||
$issues = [];
|
||||
$issueCount = 0;
|
||||
|
||||
foreach ($cartItems as $item) {
|
||||
$offerId = (int) $item->id;
|
||||
|
||||
if (! Offers::getPrice($offerId, 1, $saleChannelId)) {
|
||||
$offer = Offers::get($offerId, ['article']);
|
||||
$issues[] = $offer->article->name ?? $item->name;
|
||||
$issueCount++;
|
||||
|
||||
if (count($issues) >= 3) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($issues)) {
|
||||
$summary[$saleChannelId] = [
|
||||
'full_count' => $issueCount,
|
||||
'names' => array_slice($issues, 0, 3),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Prevent closing from click inside dropdown
|
||||
$(document).on('click', '.dropdown-menu', function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// make it as accordion for smaller screens
|
||||
if ($(window).width() < 992) {
|
||||
$('.dropdown-menu a').click(function(e) {
|
||||
e.preventDefault();
|
||||
if ($(this).next('.submenu').length) {
|
||||
$(this).next('.submenu').toggle();
|
||||
}
|
||||
$('.dropdown').on('hide.bs.dropdown', function () {
|
||||
$(this).find('.submenu').hide();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,23 +1,35 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// --- The default avatar size
|
||||
'size' => 80,
|
||||
// Default configuration group for Gravatar
|
||||
'default' => [
|
||||
// --- The default avatar size
|
||||
'size' => 80,
|
||||
|
||||
// --- The default avatar to display if we have no results
|
||||
// (bool) false
|
||||
// (string) 404
|
||||
// (string) mm: (mystery-man) a simple, cartoon-style silhouetted outline of a person (does not vary by email hash).
|
||||
// (string) identicon: a geometric pattern based on an email hash.
|
||||
// (string) monsterid: a generated 'monster' with different colors, faces, etc.
|
||||
// (string) wavatar: generated faces with differing features and backgrounds.
|
||||
// (string) retro: awesome generated, 8-bit arcade-style pixelated faces.
|
||||
'default' => 'identicon',
|
||||
// --- The default avatar to display if we have no results
|
||||
// (bool) false
|
||||
// (string) 404
|
||||
// (string) mm: (mystery-man) a simple, cartoon-style silhouetted outline of a person (does not vary by email hash).
|
||||
// (string) identicon: a geometric pattern based on an email hash.
|
||||
// (string) monsterid: a generated 'monster' with different colors, faces, etc.
|
||||
// (string) wavatar: generated faces with differing features and backgrounds.
|
||||
// (string) retro: awesome generated, 8-bit arcade-style pixelated faces.
|
||||
'fallback' => 'identicon',
|
||||
|
||||
// --- Set the type of avatars we allow to show
|
||||
// - g: suitable for display on all websites with any audience type.
|
||||
// - pg: may contain rude gestures, provocatively dressed individuals, the lesser swear words, or mild violence.
|
||||
// - r: may contain such things as harsh profanity, intense violence, nudity, or hard drug use.
|
||||
// - x: may contain hardcore sexual imagery or extremely disturbing violence.
|
||||
'maxRating' => 'g',
|
||||
// --- Whether to use HTTPS protocol
|
||||
'secure' => false,
|
||||
|
||||
// --- Set the type of avatars we allow to show
|
||||
// - g: suitable for display on all websites with any audience type.
|
||||
// - pg: may contain rude gestures, provocatively dressed individuals, the lesser swear words, or mild violence.
|
||||
// - r: may contain such things as harsh profanity, intense violence, nudity, or hard drug use.
|
||||
// - x: may contain hardcore sexual imagery or extremely disturbing violence.
|
||||
'maximumRating' => 'g',
|
||||
|
||||
// --- Force default image to always display
|
||||
'forceDefault' => false,
|
||||
|
||||
// --- Optional file extension appended to URL
|
||||
'forceExtension' => 'jpg',
|
||||
]
|
||||
];
|
||||
|
||||
@@ -126,7 +126,7 @@ return [
|
||||
* The path where to store temporary files while performing image conversions.
|
||||
* If set to null, storage_path('media-library/temp') will be used.
|
||||
*/
|
||||
'temporary_directory_path' => null,
|
||||
'temporary_directory_path' => env('MEDIA_LIBRARY_TEMP_PATH', storage_path('media-library/temp')),
|
||||
|
||||
/*
|
||||
* The engine that should perform the image conversions.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('shop_deliveries')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$columns = ['created_by', 'updated_by', 'deleted_by'];
|
||||
$columnsToDrop = [];
|
||||
|
||||
foreach ($columns as $column) {
|
||||
if (Schema::hasColumn('shop_deliveries', $column)) {
|
||||
$columnsToDrop[] = $column;
|
||||
}
|
||||
}
|
||||
|
||||
if ($columnsToDrop) {
|
||||
Schema::table('shop_deliveries', function (Blueprint $table) use ($columnsToDrop) {
|
||||
$table->dropColumn($columnsToDrop);
|
||||
});
|
||||
}
|
||||
|
||||
Schema::table('shop_deliveries', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('created_by')->nullable()->after('event_date_end');
|
||||
$table->unsignedBigInteger('updated_by')->nullable()->after('created_by');
|
||||
$table->unsignedBigInteger('deleted_by')->nullable()->after('updated_by');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('shop_deliveries')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('shop_deliveries', function (Blueprint $table) {
|
||||
$table->dropColumn(['created_by', 'updated_by', 'deleted_by']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('shop_customers', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('shop_customers', 'default_sale_channel_id')) {
|
||||
$table->unsignedInteger('default_sale_channel_id')->nullable()->after('settings');
|
||||
$table->index('default_sale_channel_id', 'shop_customers_default_sale_channel_id_index');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('shop_customers', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('shop_customers', 'default_sale_channel_id')) {
|
||||
$table->dropIndex('shop_customers_default_sale_channel_id_index');
|
||||
$table->dropColumn('default_sale_channel_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('shop_articles', function (Blueprint $table) {
|
||||
$table->dropUnique('ref');
|
||||
$table->unique(['ref', 'deleted_at'], 'shop_articles_ref_deleted_at_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('shop_articles', function (Blueprint $table) {
|
||||
$table->dropUnique('shop_articles_ref_deleted_at_unique');
|
||||
$table->unique('ref');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -223,6 +223,9 @@ return [
|
||||
'successmod' => 'Le canal de vente a été correctement modifié',
|
||||
'successdel' => 'Le canal de vente a été correctement effacé',
|
||||
'confirmdelete' => 'Confirmez-vous la suppression du canal de vente ?',
|
||||
'missing_offers' => '{1} Ce canal de vente n\'a pas d\'offre pour :count produit.|[2,*] Ce canal de vente n\'a pas d\'offre pour :count produits.',
|
||||
'missing_offers_all' => 'Ce canal de vente n\'a aucune offre pour tous les produits de votre panier.',
|
||||
'cannot_select_with_cart' => 'Vous ne pouvez pas sélectionner ce mode d\'achat tant que votre panier contient des produits non disponibles dans ce mode.',
|
||||
],
|
||||
'shelves' => [
|
||||
'title' => 'Rayons',
|
||||
|
||||
0
resources/shop/assets/tpl/.gitkeep
Normal file
@@ -348,4 +348,31 @@ div.megamenu ul.megamenu li.megamenu.level1
|
||||
.dropdown-menu > li:hover > .submenu{
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px){
|
||||
#navbarContentMobile .navbar-nav {
|
||||
flex-direction: column;
|
||||
}
|
||||
#navbarContentMobile .navbar-nav .col {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
#navbarContentMobile .dropdown-menu {
|
||||
display: block;
|
||||
position: static;
|
||||
float: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
#navbarContentMobile .dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
#navbarContentMobile .dropdown-menu .container,
|
||||
#navbarContentMobile .dropdown-menu .row {
|
||||
margin: 0;
|
||||
}
|
||||
#navbarContentMobile .dropdown-menu .shadow {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
BIN
resources/shop/img/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.8 KiB |