Compare commits

..

134 Commits

Author SHA1 Message Date
96378a6c2e Merge branch 'refactor/inventory_stock' of https://gitea.quo.ar/tbianchini/shopit-back into refactor/inventory_stock 2026-09-29 08:23:09 -03:00
c5aeb19735 refactor(inventory): enhance error reporting for negative stock validation 2026-09-28 19:43:15 +00:00
9db955bdcf refactor(inventory): update stock validation rules and enforce stock_difference requirement 2026-09-28 19:43:15 +00:00
e988e55b77 feat(admin-stock): apply idempotent availability deltas 2026-09-28 19:43:15 +00:00
bc60f1caa1 refactor(inventory): record stock operations consistently 2026-09-28 19:43:15 +00:00
068d5d7f4f feat(inventory): add calculated availability and movement ledger 2026-09-28 19:43:15 +00:00
82c18e9e91 Merge branch 'tenant/smep' into homo 2026-09-28 19:27:39 +00:00
dfad6fb4b0 Merge pull request 'fix/events_integrations' (#11) from fix/events_integrations into homo
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/11
2026-09-28 19:15:30 +00:00
3f9b773367 feat(integrations): resolve payment context from events 2026-09-28 19:14:23 +00:00
7996d53e12 feat(events): associate purchases and integrations with events 2026-09-28 19:13:36 +00:00
a40a2065a9 Merge branch 'tenant/smep' of https://gitea.quo.ar/tbianchini/shopit-back into tenant/smep 2026-09-25 11:37:51 -03:00
57677f55e6 feat(category): include products from all descendant categories in paginated response 2026-09-25 11:37:32 -03:00
a8afd92f46 feat(migration): optimize product images size for improved loading speed 2026-09-25 11:37:32 -03:00
1df0d2e2ef optimized storefront page and images 2026-09-25 11:37:32 -03:00
d3cac7a590 feat(migration): add comprehensive migration report for Mutual SMEP WhatsApp catalog to online store 2026-09-25 11:37:31 -03:00
df13a636ba feat(migration): enhance Mutual SMEP catalog seeding with featured groups and highlighted products 2026-09-25 11:37:31 -03:00
d85841742e feat(carousel): add migration and test for Mutual SMEP carousel images 2026-09-25 11:37:31 -03:00
0e3fdc5460 Add new product images and implement SeedMutualSmepCatalogTest for catalog migration
- Added multiple new product images for smart TVs, audio devices, and accessories in WEBP format.
- Created a new test class SeedMutualSmepCatalogTest to validate the catalog migration process.
- Implemented setup and teardown methods to manage database schema and data during tests.
- Verified the integrity of catalog items, inventories, and associated attributes after migration.
2026-09-25 11:37:31 -03:00
0dc2675dae feat(seeder): implement Mutual SMEP attribute seeder and associated tests 2026-09-25 11:37:30 -03:00
28d1de072c feat(seeder): add Mutual SMEP category seeder and test cases 2026-09-25 11:37:30 -03:00
b09e8a4b96 feat(tenant): provision Mutual SMEP tenant with branding and menu setup 2026-09-25 11:37:30 -03:00
7f754037bb feat(ticket): enhance ticket details with entry reservation amount and product naming for desfile 2026-09-25 11:31:54 -03:00
f29c5692b9 feat(ticket): add entry reservation handling to ticket refund logic and tests 2026-09-25 11:20:12 -03:00
57d278336d feat(desfile): associate tickets menu with desfile tenant and add migration test 2026-09-25 11:15:52 -03:00
b309738222 Merge branch 'homo' 2026-09-25 10:03:21 -03:00
b3eb59983a Merge pull request 'homo' (#10) from homo into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/10
2026-09-24 19:55:03 +00:00
cf3f0ceb1b feat(category): include products from all descendant categories in paginated response 2026-09-24 16:28:15 -03:00
41c0902b83 feat(migration): optimize product images size for improved loading speed 2026-09-24 14:12:57 -03:00
a2439bf43b optimized storefront page and images 2026-09-24 14:12:49 -03:00
8d5a12f212 feat(migration): add comprehensive migration report for Mutual SMEP WhatsApp catalog to online store 2026-09-24 13:44:21 -03:00
92170b72f2 feat(migration): enhance Mutual SMEP catalog seeding with featured groups and highlighted products 2026-09-24 12:56:23 -03:00
c3def0678f feat(carousel): add migration and test for Mutual SMEP carousel images 2026-09-24 12:41:38 -03:00
74b72a7d4e Add new product images and implement SeedMutualSmepCatalogTest for catalog migration
- Added multiple new product images for smart TVs, audio devices, and accessories in WEBP format.
- Created a new test class SeedMutualSmepCatalogTest to validate the catalog migration process.
- Implemented setup and teardown methods to manage database schema and data during tests.
- Verified the integrity of catalog items, inventories, and associated attributes after migration.
2026-09-24 12:41:24 -03:00
16e2ca679c feat(seeder): implement Mutual SMEP attribute seeder and associated tests 2026-09-24 12:26:05 -03:00
6b0efbc6ee feat(seeder): add Mutual SMEP category seeder and test cases 2026-09-24 12:26:05 -03:00
2db83271fa feat(tenant): provision Mutual SMEP tenant with branding and menu setup 2026-09-24 12:26:05 -03:00
e259bba1d1 test(desfile): cover reservation cancellation 2026-09-24 11:09:43 -03:00
b3cd9f5de5 feat(desfile): cancel entry reservations 2026-09-24 11:09:38 -03:00
64820fe12b feat(desfile): soft delete entry reservations 2026-09-24 11:09:30 -03:00
e72b6cfdde feat(desfile): download reserved ticket PDF 2026-09-24 10:27:15 -03:00
0e870776c4 feat(desfile): export entry reservations 2026-09-24 10:14:56 -03:00
d8d8354070 feat(desfile): list paginated entry reservations 2026-09-24 10:02:26 -03:00
5bb61fc74c fix(reset): clear administrative entry reservations 2026-09-24 09:34:58 -03:00
9e9af70ba8 feat(desfile): add administrative entry reservations 2026-09-24 09:34:50 -03:00
884da2c89a feat(inventory): track administrative entry reservations 2026-09-24 09:34:42 -03:00
3671b95a83 feat(desfile): enhance entry reservation form and service with tenant-specific data 2026-09-23 16:55:35 -03:00
5e78f2bb1e feat(forms): add desfile entry reservation form 2026-09-23 16:28:35 -03:00
5296d595f1 feat(desfile): add entry reservation model 2026-09-23 16:28:24 -03:00
9901d449e1 feat(migration): add ticket reservation menu for admin app 2026-09-23 16:26:20 -03:00
57605a56c4 Merge pull request 'tennant/onticket' (#9) from tennant/onticket into homo
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/9
2026-09-23 18:43:47 +00:00
d9f19b42f1 refactor(storage): centralize temporary URL generation 2026-09-22 13:54:44 -03:00
90b2784859 feat(storage): cache temporary S3 URLs 2026-09-22 13:54:37 -03:00
6f191b27f9 feat(seeder): add ticket products to test events 2026-09-22 12:26:34 -03:00
3401c934b2 feat(tickets): group user tickets by event 2026-09-22 12:26:16 -03:00
6efe8439c7 feat: add slug field to events and implement tests for slug uniqueness per tenant 2026-09-22 10:46:34 -03:00
506f8a5176 feat(event): add slug field to events and update related resources and routes 2026-09-22 10:32:25 -03:00
03bb7709c4 feat(seeder): update FiestaTradicionArrufoSeeder to use AVIF image format and validate image existence 2026-09-22 10:12:45 -03:00
c90a393081 feat: Optimize image handling for OnTicket tenant
- Added migration to optimize OnTicket images using OptimizeOnticketImagesSeeder.
- Updated OnTicketTenantSeeder to reference optimized image formats (AVIF).
- Modified OnticketImmersiveHeroCarouselSeeder to use AVIF images for hero carousel.
- Created OptimizeOnticketImagesSeeder to replace existing attachments with optimized images.
- Adjusted TestEventsSeeder to utilize AVIF images for events.
- Updated tests to verify the correct image formats and ensure optimized images are stored.
- Added new image files in AVIF format for logos, backgrounds, and event images.
2026-09-22 10:01:05 -03:00
a4a8a3ef8f feat(seeder): add FiestaTradicionArrufoSeeder to tenant provisioning and update OnTicketTenantSeeder for image validation 2026-09-21 16:52:40 -03:00
f5f4cb4e87 feat(tenant): implement TenantProvisioningService and OnTicketTenantSeeder for tenant management 2026-09-21 16:44:57 -03:00
d33b435ba5 feat(ticket): add command to convert fixed_window validity times between local timezone and UTC 2026-09-21 16:22:48 -03:00
969e99b3ae feat(ticketing): add service and command for converting validity times to UTC 2026-09-21 16:05:18 -03:00
3e3b3bfac0 feat(ticketing): resolve validity in tenant timezone 2026-09-21 15:19:09 -03:00
f4de2ff5b2 refactor(tenants): remove general write endpoints 2026-09-21 15:18:48 -03:00
fcb8386735 feat(checkout): return event purchase summary 2026-09-21 10:59:39 -03:00
6d077647ba feat(storefront): configure checkout summary type 2026-09-21 10:59:28 -03:00
14c81fec1b feat(migrations): add migration for grouping Pyme Rural tenants and updating client associations 2026-09-21 09:35:40 -03:00
9a14b8fbbd feat(menus): add storefront category permissions 2026-09-21 09:24:19 -03:00
c665222837 refactor(bootstrap): separate event category data 2026-09-21 09:24:18 -03:00
d61a5d459d style(tenants): align footer background colors 2026-09-21 09:16:39 -03:00
8395c7f24d feat(menus): assign event routes to OnTicket 2026-09-21 09:16:33 -03:00
79d402e210 feat(events): add event categories and filtering 2026-09-21 09:16:28 -03:00
b0702b7e15 feat(catalog): add sales_end_at field to catalog items and implement sale logic
feat(event): include catalog items in event resource and update event service
test(seeder): add FiestaTradicionArrufoSeeder for event and catalog item setup
config: set event timezone for local event dates
2026-09-18 16:37:40 -03:00
dc5134a6ec feat(events): add exact_location field to events and update related logic 2026-09-18 14:48:25 -03:00
801d25b218 test(events): add explicit OnTicket demo event seeder 2026-09-18 14:33:35 -03:00
f6e8716218 feat(events): expose published events to storefront 2026-09-18 14:33:28 -03:00
8d2250d247 feat(events): support event cover attachments 2026-09-18 14:33:21 -03:00
22ef5619f3 Refactor Catalog API: Remove Create Catalog Item Endpoint and Update Related Logic
- Removed the "Create Catalog" endpoint from the Postman collection.
- Updated the CatalogController to handle tenant-specific catalog items based on active events.
- Refactored CatalogItem model to automatically set event_id based on tenant's active_event_id during creation.
- Deleted StoreCatalogItemRequest and CatalogItemResource as they are no longer needed.
- Updated various services and controllers to utilize the new tenant-based catalog item retrieval logic.
- Added tests to ensure that catalog reads are limited to items associated with the active event.
2026-09-18 13:51:13 -03:00
cdc17a13e2 feat(catalog): add event_id to catalog items and backfill existing records 2026-09-18 12:19:03 -03:00
ee9f2ba538 fix(event): backfill active event social media 2026-09-18 11:32:10 -03:00
0170a2eabb feat(event): restore event model and migrate tenant event data 2026-09-18 11:31:51 -03:00
1e67d27a90 refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared 2026-09-18 10:15:57 -03:00
1241e1f7e8 refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared 2026-09-18 10:14:02 -03:00
4659c1049d feat(tenant): update display_categories to true for onticket multi-event storefront 2026-09-18 09:49:24 -03:00
49be167029 feat(onticket): add immersive hero configuration and carousel, update tenant seeder and tests 2026-09-18 09:39:25 -03:00
164187f47b feat(tenant): add hero carousel images and update related services and tests 2026-09-17 16:47:49 -03:00
5aad43f2e3 feat(migration): enhance foreign key constraints and ensure migration integrity 2026-09-17 16:13:06 -03:00
eb1e4df63f feat(tenant): enhance storefront website types with header_type and multi-event support 2026-09-17 16:02:21 -03:00
bd77727f58 Merge pull request 'homo' (#8) from homo into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/8
2026-09-17 17:52:21 +00:00
131eb1a7ee docs(api): update split website type contracts 2026-09-17 14:25:59 -03:00
486129a92f test(tenant): cover split website types and migration 2026-09-17 14:25:54 -03:00
6bc784519f feat(tenant): separate admin and storefront website types 2026-09-17 14:25:46 -03:00
b9b5f8ec9c feat(tenant): add onticket tenant seeding with branding and assets 2026-09-17 13:48:12 -03:00
8595300071 Merge branch 'homo' of https://gitea.quo.ar/tbianchini/shopit-back into homo 2026-09-17 12:31:49 -03:00
5503559899 feat(refund): implement backfill command for historical ticket refunds reconciliation 2026-09-17 12:29:33 -03:00
7323707424 Merge pull request 'feature/refund' (#7) from feature/refund into homo
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/7
2026-09-17 15:19:27 +00:00
830f65c402 feat(refund): add support for event date suspension in cart invalidation process 2026-09-17 11:42:33 -03:00
df6892c662 feat(checkout): enhance cancellation logic to handle unavailable variants and update cart status 2026-09-17 11:34:26 -03:00
de08ee4ac7 feat(cart): implement InvalidateEventDateCartsService to handle cart invalidation for rescheduled event dates 2026-09-17 10:27:27 -03:00
1269ebcda0 feat(ticket): exclude cancelled status from common ticket filter fields 2026-09-17 09:38:57 -03:00
04e54fefad feat(notification): update ticket and order number formatting in event suspension emails 2026-09-16 16:35:51 -03:00
a46bf905e8 feat(refund): enhance refund process by adding backfill for refunded_units and updating inventory restoration logic 2026-09-16 15:00:41 -03:00
353fd34db2 feat(inventory): add refunded_units to inventory model and update related services 2026-09-16 15:00:28 -03:00
f8a3296821 feat(event): add effective date resolution for rescheduled events and update tests 2026-09-16 14:27:35 -03:00
9aa0c1f3b9 feat(entry): enhance variant filtering in all method to exclude replaced and disabled variants 2026-09-16 10:14:54 -03:00
708037677a feat(entry): add validation for single non-replaced variant in EntryResource and update tests 2026-09-16 10:08:39 -03:00
e6b5da366c feat(entry): filter out replaced variants in EntryResource to improve data accuracy 2026-09-16 09:55:28 -03:00
69bea10519 feat(entry): simplify variant data handling in EntryResource by removing replaced variant logic 2026-09-16 09:50:16 -03:00
3d6548ce4a feat(entry): refine variant selection logic to exclude replaced variants and add unit tests for replacement behavior 2026-09-16 09:34:02 -03:00
8ad9b3a4e3 Revert "feat(variants): implement restoration of previously suspended variants with usable dates"
This reverts commit 3a4f6b64d2.
2026-09-16 09:08:43 -03:00
3a4f6b64d2 feat(variants): implement restoration of previously suspended variants with usable dates 2026-09-16 09:05:21 -03:00
44178d72a5 feat(variants): enhance disableForSuspension method to manage variant replacements and inventory merging 2026-09-16 08:56:01 -03:00
d02ad5fce2 feat(event): add per-user date change notices 2026-09-15 17:06:12 -03:00
69f2dbe056 refactor(event): remove date notices from tenant config 2026-09-15 17:06:06 -03:00
24d87623cc feat(event): implement EventDateGroupingService to group historical dates and update related resources and tests 2026-09-15 16:28:39 -03:00
51937ca93f feat(event): add EventDateInfoFormatter to format event date messages and implement related tests 2026-09-15 16:08:42 -03:00
dcf4383fbf feat(variants): implement inventory cloning for historical stock updates and adjust related tests 2026-09-15 15:15:18 -03:00
ff43ba32f3 feat(food): add endpoint to update historical stock and separate current and historical food variants 2026-09-15 15:03:41 -03:00
9fa4da7de2 feat(variants): add filtering for inactive event dates in visibleVariants method 2026-09-14 16:52:34 -03:00
c27d9628fd feat(event): filter event dates to exclude rescheduled and suspended entries 2026-09-14 16:48:06 -03:00
b1e09b71ad feat(migrations): implement backfill for legacy refunds and add migration tests 2026-09-14 15:13:16 -03:00
7d45734ad7 feat(forms): add entry form event dates 2026-09-14 15:00:29 -03:00
91022c5897 fix(forms): hide inactive food event dates 2026-09-14 15:00:23 -03:00
61314d5166 feat(storefront): expose event date notices 2026-09-14 14:49:30 -03:00
0d54887602 feat(event): audit suspended event dates 2026-09-14 14:19:29 -03:00
54827fbc58 feat(event): record date reschedules 2026-09-14 14:13:46 -03:00
18a0d14fa8 feat(event): add date reschedule history 2026-09-14 14:13:39 -03:00
b4a7b80430 test: cover event variant replacements 2026-09-14 14:11:24 -03:00
2bd3bf9dc1 feat(checkout): route carts to replacement variants 2026-09-14 14:09:49 -03:00
e42bc545a5 feat(event): replace variants when dates change 2026-09-14 14:09:42 -03:00
aa14129fe7 feat(catalog): track variant replacements 2026-09-14 14:07:55 -03:00
289ceba3af fix(tests): isolate databases in memory and reject persistent connections 2026-09-14 12:35:34 -03:00
28ab6c9ae4 fix(ticket): guard legacy refund amount removal 2026-09-14 12:31:03 -03:00
67deca095e refactor(ticket): use refunds as amount source 2026-09-14 12:29:13 -03:00
740 changed files with 14854 additions and 5185 deletions

View File

@@ -1,19 +1,29 @@
# Project Conventions
## Test database safety
- Tests must use SQLite `:memory:` through `tests/bootstrap.php` and `Tests\TestCase`.
- Never run tests, `migrate:fresh`, `migrate:refresh`, or `db:wipe` against a persistent database, including the developer's `shopit` database.
- Never bypass the connection safety guard to resolve test failures. Use `php tests/verify-database-safety.php` to verify isolation without queries or migrations.
## Architecture
This project uses a domain-oriented structure under `app/Domains`.
This project uses a domain-oriented structure grouped by business area under
`app/Domains`. Cross-cutting modules live under `app/Shared`.
Each domain must live in:
Business domains must live in:
```text
app/Domains/<DomainName>/
app/Domains/<Area>/<DomainName>/
```
The current areas are `Core`, `Commerce`, and `Ticketing`. Cross-cutting
modules live in `app/Shared/<ModuleName>/`.
Supported folders inside each domain:
```text
app/Domains/<DomainName>/
app/Domains/<Area>/<DomainName>/
├── Models/
├── Controllers/
├── Requests/
@@ -28,22 +38,28 @@ app/Domains/<DomainName>/
- Root API routes live in `routes/api.php`.
- `routes/api.php` should act as an aggregator only.
- Each domain defines its own routes in `app/Domains/<DomainName>/routes/api.php`.
- Each business domain defines its own routes in
`app/Domains/<Area>/<DomainName>/routes/api.php`.
- Shared modules that expose routes define them in
`app/Shared/<ModuleName>/routes/api.php`.
## Namespaces
Use this namespace pattern:
```php
App\Domains\<DomainName>\Models
App\Domains\<DomainName>\Controllers
App\Domains\<DomainName>\Requests
App\Domains\<DomainName>\Resources
App\Domains\<DomainName>\Services
App\Domains\<DomainName>\Policies
App\Domains\<DomainName>\Exceptions
App\Domains\<Area>\<DomainName>\Models
App\Domains\<Area>\<DomainName>\Controllers
App\Domains\<Area>\<DomainName>\Requests
App\Domains\<Area>\<DomainName>\Resources
App\Domains\<Area>\<DomainName>\Services
App\Domains\<Area>\<DomainName>\Policies
App\Domains\<Area>\<DomainName>\Exceptions
```
For cross-cutting modules, use `App\Shared\<ModuleName>\...`. Reusable enums
and rules live directly under `App\Shared`.
## Responsibilities
- `Models/`: Eloquent models and model relationships.
@@ -74,13 +90,19 @@ App\Domains\<DomainName>\Exceptions
- Namespace, folder, and class names must stay aligned.
- Route files should be named `api.php`.
## Current Domains
## Current Areas and Modules
- `Tenant`
- `Product`
- `Core`: `Administrator`, `Auth`, `Authorization`, `Bootstrap`, `Client`,
`Menu`, `Staff`, `Tenant`.
- `Commerce`: `Cart`, `Catalog`, `Purchase`, `Sale`.
- `Ticketing`: `Desfile`, `Event`, `FiestaFutbolInfantil`, `Ticket`.
- `Shared`: `Attachable`, `Forms`, `Integration`, `Logging`, `MailTest`,
`Notification`, `StorageTest`, plus shared enums and rules.
## Notes For Future Changes
- New features should be added inside the corresponding domain first.
- If a concern does not belong to an existing domain, create a new domain under `app/Domains`.
- Add new business features to the appropriate area under `app/Domains`.
- Keep generic reusable code in `app/Shared`; do not use it as a catch-all for
business features.
- Avoid placing new business code in `app/Http/Controllers` or `app/Models`.

View File

@@ -2,7 +2,7 @@
"info": {
"_postman_id": "76fd6fd2-53b9-4d92-8e02-1ddcc6207fa2",
"name": "ShopIt API — Complete",
"description": "Colección canónica generada desde las rutas reales de Laravel. Incluye 137 operaciones HTTP, presets de configuración para cada integración, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.",
"description": "Colección canónica generada desde las rutas reales de Laravel. Incluye 136 operaciones HTTP, presets de configuración para cada integración, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
@@ -678,57 +678,6 @@
},
"response": []
},
{
"name": "Create Catalog",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `POST /api/tenants/{tenant:codigo}/catalog-items`\n\nControlador: `App\\Domains\\Catalog\\Controllers\\CatalogController@store`",
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_code}}/catalog-items?q=demo&page=1",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"{{tenant_code}}",
"catalog-items"
],
"query": [
{
"key": "q",
"value": "demo"
},
{
"key": "page",
"value": "1"
}
]
},
"body": {
"mode": "raw",
"raw": "{\n \"type\": \"standard\",\n \"category_id\": {{category_id}},\n \"slug\": \"producto-demo\",\n \"nombre\": \"Producto Demo\",\n \"descripcion\": \"Producto creado desde Postman\",\n \"precio\": 10000,\n \"inventory_policy\": \"tracked\",\n \"inventory_subject\": \"product\",\n \"max_units_per_user\": 5,\n \"has_tickets\": false,\n \"real_stock\": 100,\n \"images\": [\n \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n ]\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{
"name": "Get Catalog",
"request": {
@@ -1907,7 +1856,7 @@
"description": "Consulta, configura o desvincula cualquier integración usando `{{integration_code}}`.",
"item": [
{
"name": "List Website Type Integration",
"name": "List Admin Website Type Integration",
"request": {
"method": "GET",
"header": [
@@ -1917,16 +1866,16 @@
"type": "text"
}
],
"description": "Ruta Laravel: `GET /api/website-types/{websiteType:codigo}/integrations`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@index`\n\nRequiere autenticación Sanctum.",
"description": "Ruta Laravel: `GET /api/admin-website-types/{adminWebsiteType:codigo}/integrations`\n\nControlador: `App\\Domains\\Integration\\Controllers\\AdminWebsiteTypeIntegrationController@index`\n\nRequiere autenticación Sanctum.",
"url": {
"raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations",
"raw": "{{base_url}}/api/admin-website-types/{{admin_website_type_code}}/integrations",
"host": [
"{{base_url}}"
],
"path": [
"api",
"website-types",
"{{website_type_code}}",
"admin-website-types",
"{{admin_website_type_code}}",
"integrations"
]
},
@@ -1944,7 +1893,7 @@
"response": []
},
{
"name": "Get Website Type Integration",
"name": "Get Admin Website Type Integration",
"request": {
"method": "GET",
"header": [
@@ -1954,16 +1903,16 @@
"type": "text"
}
],
"description": "Ruta Laravel: `GET /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@show`\n\nRequiere autenticación Sanctum.",
"description": "Ruta Laravel: `GET /api/admin-website-types/{adminWebsiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\AdminWebsiteTypeIntegrationController@show`\n\nRequiere autenticación Sanctum.",
"url": {
"raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/{{integration_code}}",
"raw": "{{base_url}}/api/admin-website-types/{{admin_website_type_code}}/integrations/{{integration_code}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"website-types",
"{{website_type_code}}",
"admin-website-types",
"{{admin_website_type_code}}",
"integrations",
"{{integration_code}}"
]
@@ -1997,16 +1946,16 @@
"type": "text"
}
],
"description": "Ruta Laravel: `PUT /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.",
"description": "Ruta Laravel: `PUT /api/admin-website-types/{adminWebsiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\AdminWebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.",
"url": {
"raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/{{integration_code}}",
"raw": "{{base_url}}/api/admin-website-types/{{admin_website_type_code}}/integrations/{{integration_code}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"website-types",
"{{website_type_code}}",
"admin-website-types",
"{{admin_website_type_code}}",
"integrations",
"{{integration_code}}"
]
@@ -2034,7 +1983,7 @@
"response": []
},
{
"name": "Delete Website Type Integration",
"name": "Delete Admin Website Type Integration",
"request": {
"method": "DELETE",
"header": [
@@ -2044,16 +1993,16 @@
"type": "text"
}
],
"description": "Ruta Laravel: `DELETE /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@destroy`\n\nRequiere autenticación Sanctum.",
"description": "Ruta Laravel: `DELETE /api/admin-website-types/{adminWebsiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\AdminWebsiteTypeIntegrationController@destroy`\n\nRequiere autenticación Sanctum.",
"url": {
"raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/{{integration_code}}",
"raw": "{{base_url}}/api/admin-website-types/{{admin_website_type_code}}/integrations/{{integration_code}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"website-types",
"{{website_type_code}}",
"admin-website-types",
"{{admin_website_type_code}}",
"integrations",
"{{integration_code}}"
]
@@ -2093,16 +2042,16 @@
"type": "text"
}
],
"description": "Ruta Laravel: `PUT /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `email`. Configura el transporte SMTP usado para el envío de correos.",
"description": "Ruta Laravel: `PUT /api/admin-website-types/{adminWebsiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\AdminWebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `email`. Configura el transporte SMTP usado para el envío de correos.",
"url": {
"raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/email",
"raw": "{{base_url}}/api/admin-website-types/{{admin_website_type_code}}/integrations/email",
"host": [
"{{base_url}}"
],
"path": [
"api",
"website-types",
"{{website_type_code}}",
"admin-website-types",
"{{admin_website_type_code}}",
"integrations",
"email"
]
@@ -2151,16 +2100,16 @@
"type": "text"
}
],
"description": "Ruta Laravel: `PUT /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `telepagos`. Configura las credenciales productivas de Telepagos.",
"description": "Ruta Laravel: `PUT /api/admin-website-types/{adminWebsiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\AdminWebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `telepagos`. Configura las credenciales productivas de Telepagos.",
"url": {
"raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/telepagos",
"raw": "{{base_url}}/api/admin-website-types/{{admin_website_type_code}}/integrations/telepagos",
"host": [
"{{base_url}}"
],
"path": [
"api",
"website-types",
"{{website_type_code}}",
"admin-website-types",
"{{admin_website_type_code}}",
"integrations",
"telepagos"
]
@@ -2209,16 +2158,16 @@
"type": "text"
}
],
"description": "Ruta Laravel: `PUT /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `telepagos_homo`. Configura las credenciales del entorno de homologación de Telepagos.",
"description": "Ruta Laravel: `PUT /api/admin-website-types/{adminWebsiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\AdminWebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `telepagos_homo`. Configura las credenciales del entorno de homologación de Telepagos.",
"url": {
"raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/telepagos_homo",
"raw": "{{base_url}}/api/admin-website-types/{{admin_website_type_code}}/integrations/telepagos_homo",
"host": [
"{{base_url}}"
],
"path": [
"api",
"website-types",
"{{website_type_code}}",
"admin-website-types",
"{{admin_website_type_code}}",
"integrations",
"telepagos_homo"
]
@@ -2521,45 +2470,6 @@
},
"response": []
},
{
"name": "Create Tenant",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `POST /api/tenants`\n\nControlador: `App\\Domains\\Tenant\\Controllers\\TenantController@store`",
"url": {
"raw": "{{base_url}}/api/tenants",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants"
]
},
"body": {
"mode": "raw",
"raw": "{\n \"client_id\": {{client_id}},\n \"codigo\": \"{{tenant_code}}\",\n \"nombre\": \"Tenant Demo\",\n \"dominio\": \"{{tenant_domain}}\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"secondary_color\": \"#2563EB\",\n \"danger_color\": \"#DC2626\",\n \"success_color\": \"#16A34A\",\n \"header_bg_color\": \"#FFFFFF\",\n \"footer_bg_color\": \"#111827\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"search_product_layout\": \"column_with_image\",\n \"search_group_layout\": \"paginated\",\n \"search_items_per_page\": 12,\n \"display_categories\": true,\n \"display_seach_bar\": true,\n \"display_cart\": true,\n \"cart_editing_policy\": \"full\",\n \"website_type_code\": \"shopit\"\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{
"name": "Get Tenant",
"request": {
@@ -2586,86 +2496,6 @@
},
"response": []
},
{
"name": "Update Tenant (PUT)",
"request": {
"method": "PUT",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `PUT /api/tenants/{tenant}`\n\nControlador: `App\\Domains\\Tenant\\Controllers\\TenantController@update`",
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"{{tenant_id}}"
]
},
"body": {
"mode": "raw",
"raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\"\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{
"name": "Update Tenant (PATCH)",
"request": {
"method": "PATCH",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `PATCH /api/tenants/{tenant}`\n\nControlador: `App\\Domains\\Tenant\\Controllers\\TenantController@update`",
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"{{tenant_id}}"
]
},
"body": {
"mode": "raw",
"raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\"\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{
"name": "Delete Tenant",
"request": {
@@ -6268,7 +6098,7 @@
"type": "string"
},
{
"key": "website_type_code",
"key": "storefront_website_type_code",
"value": "shopit",
"type": "string"
},
@@ -6381,6 +6211,10 @@
"key": "s3_path",
"value": "postman/test-file.png",
"type": "string"
},
{
"key": "admin_website_type_code",
"value": "shopit"
}
]
}

View File

@@ -1,145 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\InventorySubject;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreCatalogItemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
$tenantCode = $this->route('tenant')?->codigo;
$type = $this->input('type', CatalogItemType::Standard->value);
$isBundle = $type === CatalogItemType::Bundle->value;
return [
'tenant_code' => ['prohibited'],
'type' => ['sometimes', Rule::enum(CatalogItemType::class)],
'category_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('categorias', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'brand_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('brands', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'slug' => [
'required',
'string',
'max:255',
Rule::unique('catalog_items', 'slug')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'nombre' => ['required', 'string', 'max:255'],
'group_order' => ['sometimes', 'integer', 'min:0'],
'descripcion' => ['sometimes', 'nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
'inventory_subject' => ['sometimes', Rule::enum(InventorySubject::class)],
'max_units_per_user' => ['sometimes', 'nullable', 'integer', 'min:1'],
'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'],
'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'],
'inventory_id' => ['prohibited'],
'reserved_stock' => ['prohibited'],
'sold_units' => ['prohibited'],
'attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'attribute_codes.*' => [
'required',
'string',
'distinct',
Rule::exists('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'multi_select_attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'multi_select_attribute_codes.*' => [
'required',
'string',
'distinct',
Rule::exists('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'hidden_attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'hidden_attribute_codes.*' => [
'required',
'string',
'distinct',
Rule::exists('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'images' => ['sometimes', 'array'],
'images.*' => ['required', new ImageOrBase64Rule],
'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'],
'variants.*.descripcion' => ['sometimes', 'nullable', 'string'],
'variants.*.precio' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:99999999.99'],
'variants.*.event_date_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variants.*.event_date_ids' => ['sometimes', 'array', 'min:1'],
'variants.*.event_date_ids.*' => [
'required',
'integer',
'distinct',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variants.*.inventory_id' => ['prohibited'],
'variants.*.reserved_stock' => ['prohibited'],
'variants.*.sold_units' => ['prohibited'],
'variants.*.values' => ['sometimes', 'array'],
'variants.*.values.*' => ['nullable'],
'variants.*.values.*.*' => ['required', 'string'],
'variants.*.images' => ['sometimes', 'array'],
'variants.*.images.*' => ['required', new ImageOrBase64Rule],
'components' => [
Rule::requiredIf($isBundle),
Rule::prohibitedIf(! $isBundle),
'array',
'min:1',
],
'components.*.catalog_item_id' => [
'required',
'integer',
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'components.*.variant_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('variantes', 'id'),
],
'components.*.quantity' => ['required', 'integer', 'min:1'],
];
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class CatalogItemResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'type' => $this->type->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'inventory_policy' => $this->inventory_policy?->value,
'inventory_subject' => $this->inventory_subject->value,
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'real_stock' => $this->whenLoaded('inventory', fn () => $this->inventory?->real_stock),
'images' => $this->whenLoaded('attachments', fn () => $this->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values()),
'variants' => $this->whenLoaded('variants', fn () => $this->variants
->map(fn ($variant) => [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'real_stock' => $variant->inventory?->real_stock,
'values' => $variant->selectorOptions($this->itemAttributes),
'images' => $variant->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values(),
])
->values()),
];
}
}

View File

@@ -1,13 +1,13 @@
<?php
namespace App\Domains\Cart\Controllers;
namespace App\Domains\Commerce\Cart\Controllers;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Cart\Requests\AddCartItemRequest;
use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest;
use App\Domains\Cart\Resources\CartResource;
use App\Domains\Cart\Services\CartService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Cart\Requests\AddCartItemRequest;
use App\Domains\Commerce\Cart\Requests\UpdateCartItemQuantityRequest;
use App\Domains\Commerce\Cart\Resources\CartResource;
use App\Domains\Commerce\Cart\Services\CartService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

View File

@@ -1,17 +1,17 @@
<?php
namespace App\Domains\Cart\Models;
namespace App\Domains\Commerce\Cart\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogInventoryService;
use App\Domains\Commerce\Catalog\Services\StockReservationService;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -375,7 +375,7 @@ class Cart extends Model
): CatalogItem|Variant {
$catalogItemQuery = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $this->tenant_codigo);
->forTenantCatalog($this->tenant()->firstOrFail());
if ($lockForUpdate) {
$catalogItemQuery->lockForUpdate();
@@ -400,6 +400,17 @@ class Cart extends Model
]);
}
if ($catalogItem->bundleComponents()
->whereNotNull('component_variant_id')
->whereHas('variant', fn ($query) => $query
->whereNotNull('sales_disabled_at')
->orWhereNotNull('replaced_by_variant_id'))
->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => [__('api.cart.bundle_component_unavailable')],
]);
}
return $catalogItem;
}
@@ -430,6 +441,12 @@ class Cart extends Model
throw new NotFoundHttpException('Variant not found for catalog item.');
}
if (! $variant->isSellable()) {
throw ValidationException::withMessages([
'variant_id' => [__('api.cart.variant_unavailable')],
]);
}
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation('inventory', $inventory);

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Cart\Models;
namespace App\Domains\Commerce\Cart\Models;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Cart\Requests;
namespace App\Domains\Commerce\Cart\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Cart\Requests;
namespace App\Domains\Commerce\Cart\Requests;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Cart\Resources;
namespace App\Domains\Commerce\Cart\Resources;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Cart\Resources;
namespace App\Domains\Commerce\Cart\Resources;
use App\Domains\Cart\Models\Cart;
use App\Domains\Commerce\Cart\Models\Cart;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View File

@@ -1,12 +1,12 @@
<?php
namespace App\Domains\Cart\Services;
namespace App\Domains\Commerce\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Services\ExpireStockReservationsService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Commerce\Catalog\Services\ExpireStockReservationsService;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Domains\Commerce\Cart\Services;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogInventoryService;
use Illuminate\Validation\ValidationException;
class CartVariantReplacementService
{
public function __construct(private readonly CatalogInventoryService $inventory) {}
public function replaceHistoricalVariants(Cart $cart): void
{
$items = $cart->items()
->whereNotNull('variant_id')
->orderBy('id')
->lockForUpdate()
->get();
foreach ($items as $item) {
$variant = Variant::query()->lockForUpdate()->find($item->variant_id);
if ($variant === null) {
throw $this->unavailableVariant();
}
$replacement = $this->latestReplacement($variant);
if ($replacement->is($variant)) {
if (! $variant->isSellable()) {
throw $this->unavailableVariant();
}
continue;
}
if (! $replacement->isSellable()) {
throw $this->unavailableVariant();
}
/** @var CartItem|null $targetItem */
$targetItem = $cart->items()
->whereKeyNot($item->getKey())
->where('catalog_item_id', $item->catalog_item_id)
->where('variant_id', $replacement->getKey())
->lockForUpdate()
->first();
$replacementQuantity = $item->cantidad + ($targetItem?->cantidad ?? 0);
if ($replacement->inventory_id !== $variant->inventory_id) {
$available = $this->inventory->availableQuantity($replacement);
if ($available !== null && $available < $replacementQuantity) {
throw $this->unavailableVariant();
}
}
if ($targetItem !== null) {
$targetItem->cantidad += $item->cantidad;
$targetItem->save();
$item->delete();
continue;
}
$item->update(['variant_id' => $replacement->getKey()]);
}
}
private function latestReplacement(Variant $variant): Variant
{
$current = $variant;
$visited = [];
while ($current->replaced_by_variant_id !== null) {
if (isset($visited[$current->getKey()])) {
throw $this->unavailableVariant();
}
$visited[$current->getKey()] = true;
$current = Variant::query()
->lockForUpdate()
->find($current->replaced_by_variant_id)
?? throw $this->unavailableVariant();
}
return $current;
}
private function unavailableVariant(): ValidationException
{
return ValidationException::withMessages([
'cart_id' => [__('api.cart.cart_variant_unavailable')],
]);
}
}

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Cart\Services;
namespace App\Domains\Commerce\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Cart\Models\Cart;
use Illuminate\Support\Facades\DB;
class GuestCartMergeService

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Domains\Commerce\Cart\Services;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Catalog\Services\StockReservationService;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class InvalidateEventDateCartsService
{
public function __construct(private readonly StockReservationService $reservations) {}
/** @param Collection<int, int> $eventDateIds */
public function invalidate(
Tenant $tenant,
Collection $eventDateIds,
string $reason = StockReservationService::REASON_EVENT_DATE_RESCHEDULED,
): void {
DB::transaction(function () use ($tenant, $eventDateIds, $reason): void {
$carts = Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', Cart::STATUS_ACTIVE)
->whereNull('current_purchase_id')
->whereHas('currentStockReservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->whereHas('items.variant', fn ($variant) => $variant
->withTrashed()
->where(fn ($dates) => $dates
->whereIn('event_date_id', $eventDateIds)
->orWhereHas('eventDates', fn ($date) => $date
->whereIn('event_dates.id', $eventDateIds))))
->orderBy('id')
->lockForUpdate()
->get();
foreach ($carts as $cart) {
// Checkout keeps the cart and purchase attached to the same reservation.
if (Purchase::query()
->where('stock_reservation_id', $cart->current_stock_reservation_id)
->exists()) {
continue;
}
$this->reservations->releaseCurrentCartReservation(
$cart,
$reason,
);
// Reuse the existing expired-cart flow: stale mutations receive the
// expiration response and the next GET replaces the whole cart.
$cart->update(['status' => Cart::STATUS_EXPIRED]);
}
});
}
}

View File

@@ -1,6 +1,6 @@
<?php
use App\Domains\Cart\Controllers\CartController;
use App\Domains\Commerce\Cart\Controllers\CartController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')

View File

@@ -1,12 +1,12 @@
<?php
namespace App\Domains\Catalog\Controllers\AdminApp;
namespace App\Domains\Commerce\Catalog\Controllers\AdminApp;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Requests\AdminApp\UpsertOnTicketFeaturedGroupRequest;
use App\Domains\Catalog\Resources\AdminApp\OnTicketFeaturedGroupResource;
use App\Domains\Catalog\Services\OnTicketFeaturedGroupService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Requests\AdminApp\UpsertOnTicketFeaturedGroupRequest;
use App\Domains\Commerce\Catalog\Resources\AdminApp\OnTicketFeaturedGroupResource;
use App\Domains\Commerce\Catalog\Services\OnTicketFeaturedGroupService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -58,7 +58,7 @@ class OnTicketFeaturedGroupController extends Controller
{
$tenant = $request->user()->tenant()->firstOrFail();
abort_unless($tenant->website_type_code === 'onticket', 404);
abort_unless(in_array($tenant->storefront_website_type_code, ['onticket', 'onticket_multi_event'], true), 404);
return $tenant;
}

View File

@@ -1,27 +1,25 @@
<?php
namespace App\Domains\Catalog\Controllers;
namespace App\Domains\Commerce\Catalog\Controllers;
use App\Domains\Cart\Services\CartService;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
use App\Domains\Catalog\Requests\CatalogVariantOptionsRequest;
use App\Domains\Catalog\Requests\CategoryPageRequest;
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
use App\Domains\Catalog\Resources\CatalogItemResource;
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
use App\Domains\Catalog\Resources\CatalogVariantOptionsResource;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Catalog\Services\FeaturedGroupService;
use App\Domains\Catalog\Services\VariantSelectionService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Cart\Services\CartService;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Requests\CatalogItemDetailRequest;
use App\Domains\Commerce\Catalog\Requests\CatalogVariantOptionsRequest;
use App\Domains\Commerce\Catalog\Requests\CategoryPageRequest;
use App\Domains\Commerce\Catalog\Requests\FeaturedGroupPageRequest;
use App\Domains\Commerce\Catalog\Requests\SearchCatalogItemsRequest;
use App\Domains\Commerce\Catalog\Resources\CatalogFeaturedGroupResource;
use App\Domains\Commerce\Catalog\Resources\CatalogItemDetailResource;
use App\Domains\Commerce\Catalog\Resources\CatalogSearchItemResource;
use App\Domains\Commerce\Catalog\Resources\CatalogVariantOptionsResource;
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Commerce\Catalog\Services\CatalogService;
use App\Domains\Commerce\Catalog\Services\FeaturedGroupService;
use App\Domains\Commerce\Catalog\Services\VariantSelectionService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -40,7 +38,7 @@ class CatalogController extends Controller
return response()->json($featuredGroups->map(
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
$featuredGroup,
$featuredGroupService->itemsResponse($featuredGroup, 1, $this->userId($request)),
$featuredGroupService->itemsResponse($featuredGroup, $tenant, 1, $this->userId($request)),
))->resolve()
));
}
@@ -102,6 +100,7 @@ class CatalogController extends Controller
return response()->json($featuredGroupService->itemsResponse(
$featuredGroup,
$tenant,
$page,
$this->userId($request),
));
@@ -114,7 +113,8 @@ class CatalogController extends Controller
CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): CatalogItemDetailResource {
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
abort_unless($catalogItem->tenant_code === $tenant->codigo
&& ($tenant->active_event_id === null || $catalogItem->event_id === $tenant->active_event_id), 404);
$variantId = $request->validated('variant_id');
@@ -134,7 +134,8 @@ class CatalogController extends Controller
VariantSelectionService $variantSelectionService,
CartService $cartService,
): CatalogVariantOptionsResource {
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
abort_unless($catalogItem->tenant_code === $tenant->codigo
&& ($tenant->active_event_id === null || $catalogItem->event_id === $tenant->active_event_id), 404);
$includedVariantId = null;
$cartItemId = $request->validated('cart_item_id');
@@ -163,21 +164,6 @@ class CatalogController extends Controller
);
}
public function store(
StoreCatalogItemRequest $request,
Tenant $tenant,
CatalogService $catalogService,
): JsonResponse {
$catalogItem = $catalogService->create([
...$request->validated(),
'tenant_code' => $tenant->codigo,
]);
return CatalogItemResource::make($catalogItem)
->response()
->setStatusCode(201);
}
private function userId(Request $request): ?int
{
$userId = $request->user()?->getAuthIdentifier() ?? Auth::guard('sanctum')->id();

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Enums;
namespace App\Domains\Commerce\Catalog\Enums;
enum CatalogItemType: string
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Enums;
namespace App\Domains\Commerce\Catalog\Enums;
enum FeaturedGroupSource: string
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Enums;
namespace App\Domains\Commerce\Catalog\Enums;
enum GroupLayout: string
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Enums;
namespace App\Domains\Commerce\Catalog\Enums;
enum InventoryPolicy: string
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Enums;
namespace App\Domains\Commerce\Catalog\Enums;
enum InventorySubject: string
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Enums;
namespace App\Domains\Commerce\Catalog\Enums;
enum ProductLayout: string
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Exceptions;
namespace App\Domains\Commerce\Catalog\Exceptions;
use RuntimeException;

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Event\Models\EventDate;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\EventDate;
use App\Shared\Enums\FieldType;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -59,6 +59,8 @@ class Attribute extends Model
public function eventDates(): HasMany
{
return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo')
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->orderBy('date')
->orderBy('time_start');
}

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Ticket\Models\ValidityTime;
use App\Domains\Ticketing\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@@ -1,14 +1,15 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\InventorySubject;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Commerce\Catalog\Enums\CatalogItemType;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Enums\InventorySubject;
use App\Domains\Commerce\Catalog\Services\CatalogInventoryService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Shared\Attachable\Models\Attachment;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -30,6 +31,7 @@ use Illuminate\Support\Collection;
'group_order',
'descripcion',
'precio',
'sales_end_at',
'inventory_policy',
'inventory_subject',
'max_units_per_user',
@@ -51,15 +53,30 @@ class CatalogItem extends Model
'group_order' => 0,
];
protected static function booted(): void
{
static::creating(function (CatalogItem $item): void {
if ($item->event_id !== null || $item->tenant_code === null) {
return;
}
$item->event_id = Tenant::query()
->where('codigo', $item->tenant_code)
->value('active_event_id');
});
}
protected function casts(): array
{
return [
'event_id' => 'integer',
'category_id' => 'integer',
'brand_id' => 'integer',
'inventory_id' => 'integer',
'type' => CatalogItemType::class,
'group_order' => 'integer',
'precio' => 'decimal:2',
'sales_end_at' => 'datetime',
'inventory_policy' => InventoryPolicy::class,
'inventory_subject' => InventorySubject::class,
'max_units_per_user' => 'integer',
@@ -73,6 +90,21 @@ class CatalogItem extends Model
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
/** @param Builder<CatalogItem> $query */
public function scopeForTenantCatalog(Builder $query, Tenant $tenant): Builder
{
return $query
->where('catalog_items.tenant_code', $tenant->codigo)
->when($tenant->active_event_id !== null, fn (Builder $query): Builder => $query
->where('catalog_items.event_id', $tenant->active_event_id));
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
{
@@ -161,6 +193,10 @@ class CatalogItem extends Model
public function isAvailable(): bool
{
if (! $this->isSaleOpen()) {
return false;
}
if ($this->type === CatalogItemType::Bundle) {
$availableStock = $this->availableStock();
@@ -178,12 +214,31 @@ class CatalogItem extends Model
public function scopeWhereAvailable(Builder $query): Builder
{
return $query->where(function (Builder $query): void {
$query->whereNull('catalog_items.sales_end_at')
->orWhere('catalog_items.sales_end_at', '>', now());
})->where(function (Builder $query): void {
$query
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->where(function (Builder $unlimitedQuery): void {
$unlimitedQuery
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->where(function (Builder $selectionQuery): void {
$selectionQuery
->whereDoesntHave('variants')
->orWhereHas('variants', fn (Builder $variantQuery): Builder => $variantQuery
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id'));
});
})
->orWhereHas(
'variants.inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
'variants',
fn (Builder $variantQuery): Builder => $variantQuery
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->whereHas(
'inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock')
)
)
->orWhere(function (Builder $directItemQuery): void {
$directItemQuery
@@ -194,20 +249,35 @@ class CatalogItem extends Model
->orWhereHas(
'inventory',
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock')
);
});
});
});
}
public function isSaleOpen(): bool
{
return $this->sales_end_at === null || now()->lt($this->sales_end_at);
}
/** @return Collection<int, Variant> */
public function visibleVariants(?int $includedVariantId = null): Collection
{
$this->variants
->filter(fn (Variant $variant): bool => $variant->exists)
->loadMissing('desfileEntryReservations');
return $this->variants
->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId)
|| $this->inventory_policy === InventoryPolicy::Unlimited
|| ($variant->inventory?->availableStock() ?? 0) > 0)
->each(fn (Variant $variant) => $variant->setRelation('catalogItem', $this))
->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates()
&& (! $variant->relationLoaded('desfileEntryReservations')
|| $variant->desfileEntryReservations->isEmpty())
&& (($includedVariantId !== null && $variant->id === $includedVariantId)
|| ($variant->isSellable() && (
$this->inventory_policy === InventoryPolicy::Unlimited
|| ($variant->inventory?->availableStock() ?? 0) > 0
))))
->values();
}

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Commerce\Catalog\Enums\GroupLayout;
use App\Domains\Commerce\Catalog\Enums\ProductLayout;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +10,9 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'sold_units',
'refunded_units',
'reserved_stock',
'entry_reserved_stock',
'real_stock',
])]
class Inventory extends Model
@@ -23,6 +25,7 @@ class Inventory extends Model
protected $attributes = [
'sold_units' => 0,
'refunded_units' => 0,
'reserved_stock' => 0,
'real_stock' => 0,
];
@@ -31,7 +34,9 @@ class Inventory extends Model
{
return [
'sold_units' => 'integer',
'refunded_units' => 'integer',
'reserved_stock' => 'integer',
'entry_reserved_stock' => 'integer',
'real_stock' => 'integer',
];
}
@@ -42,10 +47,10 @@ class Inventory extends Model
return $this->hasOne(CatalogItem::class);
}
/** @return HasOne<Variant, $this> */
public function variant(): HasOne
/** @return HasMany<Variant, $this> */
public function variants(): HasMany
{
return $this->hasOne(Variant::class);
return $this->hasMany(Variant::class);
}
/** @return HasMany<StockReservationLine, $this> */
@@ -56,7 +61,26 @@ class Inventory extends Model
public function availableStock(): int
{
return max(0, $this->real_stock - $this->reserved_stock);
return max(0, $this->real_stock - $this->reserved_stock - $this->entry_reserved_stock);
}
public function reserveEntry(int $amount, bool $tracksInventory): void
{
if ($amount < 1 || ($tracksInventory && $this->availableStock() < $amount)) {
throw new \InvalidArgumentException('No hay stock disponible para la reserva de entradas.');
}
$this->entry_reserved_stock += $amount;
$this->save();
}
public function releaseEntry(int $amount): void
{
if ($amount < 1 || $this->entry_reserved_stock < $amount) {
throw new \InvalidArgumentException('La cantidad de entradas reservadas no es válida.');
}
$this->entry_reserved_stock -= $amount;
$this->save();
}
public function reserve(int $amount, bool $tracksInventory): void
@@ -89,7 +113,7 @@ class Inventory extends Model
throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.');
}
if ($tracksInventory && $this->real_stock < $amount) {
if ($tracksInventory && $this->real_stock - $this->entry_reserved_stock < $amount) {
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Cart\Models\Cart;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Purchase\Models\Purchase;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,10 +1,11 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticketing\Desfile\Models\EntryReservation;
use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Shared\Attachable\Models\Attachment;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -20,6 +21,8 @@ use Illuminate\Support\Str;
'catalog_item_id',
'event_date_id',
'inventory_id',
'replaced_by_variant_id',
'sales_disabled_at',
'descripcion',
'precio',
])]
@@ -37,6 +40,8 @@ class Variant extends Model
'catalog_item_id' => 'integer',
'event_date_id' => 'integer',
'inventory_id' => 'integer',
'replaced_by_variant_id' => 'integer',
'sales_disabled_at' => 'datetime',
'precio' => 'decimal:2',
];
}
@@ -70,12 +75,52 @@ class Variant extends Model
return $this->hasMany(Ticket::class, 'source_variant_id');
}
/** @return HasMany<EntryReservation, $this> */
public function desfileEntryReservations(): HasMany
{
return $this->hasMany(EntryReservation::class);
}
/** @return BelongsTo<Inventory, $this> */
public function inventory(): BelongsTo
{
return $this->belongsTo(Inventory::class);
}
/** @return BelongsTo<Variant, $this> */
public function replacement(): BelongsTo
{
return $this->belongsTo(self::class, 'replaced_by_variant_id');
}
/** @return HasMany<Variant, $this> */
public function replacedVariants(): HasMany
{
return $this->hasMany(self::class, 'replaced_by_variant_id');
}
public function isSellable(): bool
{
return $this->sales_disabled_at === null
&& $this->replaced_by_variant_id === null
&& $this->catalogItem->isSaleOpen()
&& $this->hasOnlyActiveEventDates();
}
public function hasOnlyActiveEventDates(): bool
{
if (! $this->exists
&& $this->event_date_id === null
&& ! $this->relationLoaded('eventDates')) {
return true;
}
return $this->selectedEventDates()->every(
fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null
&& $eventDate->suspended_at === null,
);
}
/** @return HasMany<VariantDefinition, $this> */
public function definitions(): HasMany
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Requests\AdminApp;
namespace App\Domains\Commerce\Catalog\Requests\AdminApp;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Requests;
namespace App\Domains\Commerce\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Requests;
namespace App\Domains\Commerce\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Requests;
namespace App\Domains\Commerce\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Requests;
namespace App\Domains\Commerce\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Requests;
namespace App\Domains\Commerce\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Catalog\Resources\AdminApp;
namespace App\Domains\Commerce\Catalog\Resources\AdminApp;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Enums\ProductLayout;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Catalog\Resources;
namespace App\Domains\Commerce\Catalog\Resources;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View File

@@ -1,13 +1,13 @@
<?php
namespace App\Domains\Catalog\Resources;
namespace App\Domains\Commerce\Catalog\Resources;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Enums\ProductLayout;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View File

@@ -1,14 +1,14 @@
<?php
namespace App\Domains\Catalog\Resources;
namespace App\Domains\Commerce\Catalog\Resources;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\ItemAttribute;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Ticketing\Ticket\Resources\ValidityTimeResource;
use App\Shared\Enums\FieldType;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
@@ -140,7 +140,7 @@ class CatalogItemDetailResource extends JsonResource
'validity_time_id' => $option->validity_time_id,
'validity_time' => $option->validityTime === null
? null
: ValidityTimeResource::make($option->validityTime),
: ValidityTimeResource::make($option->validityTime, $this->tenant->timezone),
'metadata' => $option->metadata,
])
->values();

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Catalog\Resources;
namespace App\Domains\Commerce\Catalog\Resources;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Resources;
namespace App\Domains\Commerce\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View File

@@ -1,13 +1,13 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\BundleComponent;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Enums\CatalogItemType;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\BundleComponent;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\Variant;
use Illuminate\Support\Facades\DB;
class CatalogInventoryService
@@ -63,9 +63,14 @@ class CatalogInventoryService
$selection->loadMissing('variants.inventory');
return $selection->variants->sum(
fn (Variant $variant): int => $variant->inventory->availableStock(),
);
return $selection->variants
->filter(fn (Variant $variant): bool => $variant->isSellable())
->unique(fn (Variant $variant): string => $variant->inventory_id === null
? 'object:'.spl_object_id($variant->inventory)
: 'id:'.$variant->inventory_id)
->sum(
fn (Variant $variant): int => $variant->inventory->availableStock(),
);
}
$requirements = $this->inventoryRequirements($selection);
@@ -148,7 +153,7 @@ class CatalogInventoryService
if ($operation === 'commit'
&& $requirement['tracks_inventory']
&& $inventory->real_stock < $requiredQuantity) {
&& $inventory->real_stock - $inventory->entry_reserved_stock < $requiredQuantity) {
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
}
}

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Purchase\Services\UserPurchaseLimitService;
use Illuminate\Support\Collection;
class CatalogItemAllowanceService

View File

@@ -1,18 +1,18 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService;
use App\Domains\Commerce\Catalog\Enums\CatalogItemType;
use App\Domains\Commerce\Catalog\Models\Attribute;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\ItemAttribute;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
@@ -207,7 +207,8 @@ class CatalogService
$visibleVariants = $catalogItem->visibleVariants();
if ($catalogItem->type === CatalogItemType::Standard
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
&& ! $catalogItem->isAvailable()) {
&& (($catalogItem->variants->isNotEmpty() && $visibleVariants->isEmpty())
|| ! $catalogItem->isAvailable())) {
throw new NotFoundHttpException('Catalog item is out of stock.');
}
@@ -237,7 +238,7 @@ class CatalogService
$startsWithPattern = "{$normalizedTerm}%";
$paginator = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->whereAvailable()
->where(function (Builder $query) use ($containsPattern): void {
$query
@@ -286,7 +287,7 @@ class CatalogService
int $page,
): LengthAwarePaginator {
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->where('category_id', $category->id)
->whereAvailable()
->with([
@@ -324,9 +325,13 @@ class CatalogService
->findOrFail($variant->catalog_item_id);
$variant->delete();
if (! $catalogItem->variants()->exists()) {
$sellableVariants = $catalogItem->variants()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id');
if (! (clone $sellableVariants)->exists()) {
$this->delete($catalogItem);
} elseif (($minimumPrice = $catalogItem->variants()->min('precio')) !== null) {
} elseif (($minimumPrice = (clone $sellableVariants)->min('precio')) !== null) {
$catalogItem->update(['precio' => $minimumPrice]);
}
@@ -364,6 +369,8 @@ class CatalogService
$componentItem = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $bundle->tenant_code)
->when($bundle->event_id !== null, fn (Builder $query): Builder => $query
->where('event_id', $bundle->event_id))
->first();
if ($componentItem === null) {
@@ -399,7 +406,11 @@ class CatalogService
]);
}
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
if ($variantId !== null && ! $componentItem->variants()
->whereKey($variantId)
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->exists()) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
__('api.catalog.component_variant_invalid'),

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\Checkout\ReleaseCheckoutService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;

View File

@@ -1,12 +1,13 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
use App\Domains\Commerce\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Commerce\Catalog\Enums\GroupLayout;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Resources\CatalogFeaturedItemResource;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -19,10 +20,10 @@ class FeaturedGroupService
private readonly CatalogItemAllowanceService $allowances,
) {}
public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array
public function itemsResponse(FeaturedGroup $featuredGroup, Tenant $tenant, int $page, ?int $userId = null): array
{
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
$query = $this->itemsQuery($featuredGroup);
$query = $this->itemsQuery($featuredGroup, $tenant);
if ($featuredGroup->group_layout === GroupLayout::Single) {
$query->limit(1);
@@ -35,7 +36,7 @@ class FeaturedGroupService
return CatalogFeaturedItemResource::collection($items)->resolve();
}
$paginator = $this->paginateItems($featuredGroup, $page);
$paginator = $this->paginateItems($featuredGroup, $tenant, $page);
$this->attachGroup($paginator->getCollection(), $featuredGroup);
$this->allowances->attach($paginator->getCollection(), $userId);
@@ -45,10 +46,10 @@ class FeaturedGroupService
}
/** @return Builder<CatalogItem> */
private function itemsQuery(FeaturedGroup $featuredGroup): Builder
private function itemsQuery(FeaturedGroup $featuredGroup, Tenant $tenant): Builder
{
$query = CatalogItem::query()
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
->forTenantCatalog($tenant)
->whereAvailable()
->where(function (Builder $query): void {
$query
@@ -90,9 +91,10 @@ class FeaturedGroupService
private function paginateItems(
FeaturedGroup $featuredGroup,
Tenant $tenant,
int $page,
): LengthAwarePaginator {
$paginator = $this->itemsQuery($featuredGroup)->paginate(
$paginator = $this->itemsQuery($featuredGroup, $tenant)->paginate(
perPage: self::ITEMS_PER_PAGE,
pageName: 'page',
page: $page,

View File

@@ -1,13 +1,13 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Commerce\Catalog\Enums\GroupLayout;
use App\Domains\Commerce\Catalog\Enums\ProductLayout;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;

View File

@@ -1,14 +1,14 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\StockReservationLine;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Catalog\Models\StockReservationLine;
use App\Domains\Commerce\Purchase\Models\Purchase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -19,6 +19,10 @@ class StockReservationService
public const REASON_CART_CHANGED = 'cart_changed';
public const REASON_EVENT_DATE_RESCHEDULED = 'event_date_rescheduled';
public const REASON_EVENT_DATE_SUSPENDED = 'event_date_suspended';
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';
@@ -221,7 +225,7 @@ class StockReservationService
$inventory = $inventories->get($line->inventory_id)
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
if ($inventory->reserved_stock < $line->quantity
|| ($line->tracks_inventory && $inventory->real_stock < $line->quantity)) {
|| ($line->tracks_inventory && $inventory->real_stock - $inventory->entry_reserved_stock < $line->quantity)) {
throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.');
}
}

View File

@@ -0,0 +1,271 @@
<?php
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Commerce\Catalog\Models\BundleComponent;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Catalog\Models\StockReservationLine;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Ticketing\Desfile\Models\EntryReservation;
use App\Domains\Ticketing\Event\Models\EventDate;
use Illuminate\Support\Collection;
class VariantReplacementService
{
/** @return Collection<int, Variant> */
public function replaceEventDate(EventDate $source, EventDate $destination): Collection
{
$variants = Variant::query()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->where(function ($query) use ($source): void {
$query->where('event_date_id', $source->getKey())
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->where('event_dates.id', $source->getKey()));
})
->with(['eventDates', 'eventDate', 'definitions', 'allAttachments'])
->orderBy('id')
->lockForUpdate()
->get();
return $variants->map(function (Variant $variant) use ($source, $destination): Variant {
$destinationDateIds = $variant->selectedEventDates()
->pluck('id')
->map(fn ($id): int => (int) $id === (int) $source->getKey()
? (int) $destination->getKey()
: (int) $id)
->unique()
->sort()
->values();
$replacement = $this->findEquivalent($variant, $destinationDateIds)
?? $this->cloneWithDates($variant, $destinationDateIds);
$variant->update([
'replaced_by_variant_id' => $replacement->getKey(),
'sales_disabled_at' => now(),
]);
BundleComponent::query()
->where('component_variant_id', $variant->getKey())
->update(['component_variant_id' => $replacement->getKey()]);
return $replacement;
})->values();
}
public function disableForSuspension(EventDate $eventDate): void
{
$variants = Variant::query()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->where(function ($query) use ($eventDate): void {
$query->where('event_date_id', $eventDate->getKey())
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->where('event_dates.id', $eventDate->getKey()));
})
->with(['eventDates', 'eventDate', 'definitions', 'allAttachments'])
->orderBy('id')
->lockForUpdate()
->get();
foreach ($variants as $variant) {
$remainingDateIds = $variant->selectedEventDates()
->filter(fn (EventDate $date): bool => $date->suspended_at === null
&& $date->rescheduled_to_event_date_id === null)
->pluck('id')
->map(fn ($id): int => (int) $id)
->unique()
->sort()
->values();
if ($remainingDateIds->isEmpty()) {
$variant->update(['sales_disabled_at' => now()]);
continue;
}
$replacement = $this->findEquivalent($variant, $remainingDateIds);
if ($replacement === null) {
$replacement = $this->cloneWithDates($variant, $remainingDateIds);
} else {
$this->mergeInventoryInto($variant, $replacement);
}
$variant->update([
'replaced_by_variant_id' => $replacement->getKey(),
'sales_disabled_at' => now(),
]);
BundleComponent::query()
->where('component_variant_id', $variant->getKey())
->update(['component_variant_id' => $replacement->getKey()]);
}
}
/** @param Collection<int, int> $eventDateIds */
private function findEquivalent(Variant $source, Collection $eventDateIds): ?Variant
{
$definitionSignature = $this->definitionSignature($source);
$dateSignature = $eventDateIds->map(fn ($id): int => (int) $id)->sort()->values()->all();
return Variant::query()
->where('catalog_item_id', $source->catalog_item_id)
->whereKeyNot($source->getKey())
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->with(['eventDates', 'eventDate', 'definitions'])
->orderBy('id')
->lockForUpdate()
->get()
->first(fn (Variant $candidate): bool => $this->definitionSignature($candidate) === $definitionSignature
&& $candidate->selectedEventDates()
->pluck('id')
->map(fn ($id): int => (int) $id)
->sort()
->values()
->all() === $dateSignature
);
}
/** @param Collection<int, int> $eventDateIds */
private function cloneWithDates(Variant $source, Collection $eventDateIds): Variant
{
$replacementInventory = $this->cloneInventory($source);
$replacement = $source->replicate([
'event_date_id',
'inventory_id',
'replaced_by_variant_id',
'sales_disabled_at',
]);
$replacement->inventory_id = $replacementInventory->getKey();
$replacement->event_date_id = $eventDateIds->count() === 1
? $eventDateIds->first()
: null;
$replacement->save();
$replacement->eventDates()->sync($eventDateIds->all());
$replacement->definitions()->createMany(
$source->definitions
->map(fn ($definition): array => [
'item_attribute_id' => $definition->item_attribute_id,
'value' => $definition->value,
])
->all(),
);
$attachments = $source->allAttachments
->mapWithKeys(fn ($attachment): array => [
$attachment->getKey() => [
'orden' => $attachment->pivot->orden,
'is_enabled' => $attachment->pivot->is_enabled,
],
])
->all();
$replacement->allAttachments()->sync($attachments);
return $replacement->load(['eventDates', 'eventDate', 'definitions', 'allAttachments']);
}
private function cloneInventory(Variant $source): Inventory
{
$activeLines = StockReservationLine::query()
->where('inventory_id', $source->inventory_id)
->whereHas('reservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->orderBy('id')
->lockForUpdate()
->get();
$sourceInventory = Inventory::query()
->whereKey($source->inventory_id)
->lockForUpdate()
->firstOrFail();
$reservedStock = (int) $activeLines->sum('quantity');
if ($sourceInventory->reserved_stock !== $reservedStock) {
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
}
$replacementInventory = Inventory::query()->create([
'sold_units' => $sourceInventory->sold_units,
'refunded_units' => $sourceInventory->refunded_units,
'reserved_stock' => $reservedStock,
'entry_reserved_stock' => $sourceInventory->entry_reserved_stock,
'real_stock' => $sourceInventory->real_stock,
]);
if ($activeLines->isNotEmpty()) {
StockReservationLine::query()
->whereKey($activeLines->modelKeys())
->update(['inventory_id' => $replacementInventory->getKey()]);
}
EntryReservation::query()->where('inventory_id', $sourceInventory->id)
->update(['inventory_id' => $replacementInventory->id]);
$sourceInventory->update(['reserved_stock' => 0, 'entry_reserved_stock' => 0]);
return $replacementInventory;
}
private function mergeInventoryInto(Variant $source, Variant $destination): void
{
if ($source->inventory_id === $destination->inventory_id) {
return;
}
$inventories = Inventory::query()
->whereKey([$source->inventory_id, $destination->inventory_id])
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
$sourceInventory = $inventories->get($source->inventory_id);
$destinationInventory = $inventories->get($destination->inventory_id);
if ($sourceInventory === null || $destinationInventory === null) {
throw new \LogicException('No se encontró el inventario de una variante.');
}
$activeLines = StockReservationLine::query()
->where('inventory_id', $source->inventory_id)
->whereHas('reservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->orderBy('id')
->lockForUpdate()
->get();
if ($sourceInventory->reserved_stock !== (int) $activeLines->sum('quantity')) {
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
}
$destinationInventory->update([
'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock,
'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock,
'entry_reserved_stock' => $destinationInventory->entry_reserved_stock + $sourceInventory->entry_reserved_stock,
'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units,
'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units,
]);
if ($activeLines->isNotEmpty()) {
StockReservationLine::query()
->whereKey($activeLines->modelKeys())
->update(['inventory_id' => $destinationInventory->getKey()]);
}
EntryReservation::query()->where('inventory_id', $sourceInventory->id)
->update(['inventory_id' => $destinationInventory->id]);
$sourceInventory->update([
'real_stock' => 0,
'reserved_stock' => 0,
'entry_reserved_stock' => 0,
'sold_units' => 0,
'refunded_units' => 0,
]);
}
/** @return list<string> */
private function definitionSignature(Variant $variant): array
{
return $variant->definitions
->map(fn ($definition): string => $definition->item_attribute_id.'\0'.$definition->value)
->sort()
->values()
->all();
}
}

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\ItemAttribute;
use App\Domains\Commerce\Catalog\Models\Variant;
use Illuminate\Support\Collection;
class VariantSelectionService

View File

@@ -1,6 +1,6 @@
<?php
use App\Domains\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
use App\Domains\Commerce\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')

View File

@@ -1,6 +1,6 @@
<?php
use App\Domains\Catalog\Controllers\CatalogController;
use App\Domains\Commerce\Catalog\Controllers\CatalogController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
@@ -13,7 +13,6 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
->name('categories.show');
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
Route::post('catalog-items/{catalogItem}/variant-options', [CatalogController::class, 'variantOptions']);
Route::post('catalog-items', [CatalogController::class, 'store']);
});
require __DIR__.'/adminapp.php';

View File

@@ -1,17 +1,17 @@
<?php
namespace App\Domains\Purchase\Controllers;
namespace App\Domains\Commerce\Purchase\Controllers;
use App\Domains\Integration\Services\TelepagosIntegrationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Requests\PaymentIntentRequest;
use App\Domains\Purchase\Requests\StartCheckoutRequest;
use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest;
use App\Domains\Purchase\Resources\PurchaseResource;
use App\Domains\Purchase\Services\Checkout\PurchaseResponseLoader;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use App\Domains\Tenant\Models\Tenant;
use App\Shared\Integration\Services\TelepagosIntegrationService;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Requests\PaymentIntentRequest;
use App\Domains\Commerce\Purchase\Requests\StartCheckoutRequest;
use App\Domains\Commerce\Purchase\Requests\UpdatePurchaseCustomerRequest;
use App\Domains\Commerce\Purchase\Resources\PurchaseResource;
use App\Domains\Commerce\Purchase\Services\Checkout\PurchaseResponseLoader;
use App\Domains\Commerce\Purchase\Services\CheckoutService;
use App\Domains\Commerce\Purchase\Services\PurchaseStateGuard;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Events;
namespace App\Domains\Commerce\Purchase\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Exceptions;
namespace App\Domains\Commerce\Purchase\Exceptions;
use RuntimeException;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Exceptions;
namespace App\Domains\Commerce\Purchase\Exceptions;
use RuntimeException;

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Purchase\Exceptions;
namespace App\Domains\Commerce\Purchase\Exceptions;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use Illuminate\Validation\ValidationException;
class PurchaseLimitExceededException extends ValidationException

View File

@@ -1,14 +1,14 @@
<?php
namespace App\Domains\Purchase\Models;
namespace App\Domains\Commerce\Purchase\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Shared\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Commerce\Purchase\Events\PurchasePaid;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,12 +1,12 @@
<?php
namespace App\Domains\Purchase\Models;
namespace App\Domains\Commerce\Purchase\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\TicketRefund;
use App\Shared\Attachable\Models\Attachment;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Models\TicketRefund;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -28,7 +28,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'discount_total',
'tax_total',
'total',
'refunded_amount',
])]
class PurchaseItem extends Model
{
@@ -49,7 +48,6 @@ class PurchaseItem extends Model
'discount_total' => 'decimal:2',
'tax_total' => 'decimal:2',
'total' => 'decimal:2',
'refunded_amount' => 'decimal:2',
];
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Models;
namespace App\Domains\Commerce\Purchase\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Models;
namespace App\Domains\Commerce\Purchase\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Models;
namespace App\Domains\Commerce\Purchase\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Requests;
namespace App\Domains\Commerce\Purchase\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Requests;
namespace App\Domains\Commerce\Purchase\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Requests;
namespace App\Domains\Commerce\Purchase\Requests;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,9 +1,10 @@
<?php
namespace App\Domains\Purchase\Resources;
namespace App\Domains\Commerce\Purchase\Resources;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Purchase\Models\PurchaseItem;
use App\Domains\Core\Tenant\Models\StorefrontWebsiteType;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -14,7 +15,11 @@ class PurchaseItemResource extends JsonResource
public function toArray(Request $request): array
{
$tenant = $request->route('tenant');
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
$usesEventSummary = $tenant instanceof Tenant
&& $tenant->storefrontWebsiteType?->checkout_summary_type
=== StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT;
$displayImage = ! $tenant instanceof Tenant
|| (! $usesEventSummary && $tenant->display_cart_item_images);
$imageUrl = $displayImage
? $this->imageAttachment?->getTemporaryUrl(1440)
@@ -25,14 +30,13 @@ class PurchaseItemResource extends JsonResource
'quantity' => (int) $this->cantidad,
'unit_price' => $this->formatMoney($this->precio_unitario),
'line_total' => $this->formatMoney($this->total),
'refunded_amount' => $this->formatMoney($this->refunded_amount),
'source_catalog_item_id' => $this->source_catalog_item_id,
'source_variant_id' => $this->source_variant_id,
'item_details' => [
'nombre' => $this->item_nombre,
'descripcion' => $this->descripcion,
'slug' => $this->slug,
'imagen' => $imageUrl,
'imagen' => $this->when(! $usesEventSummary, $imageUrl),
'attributes' => $this->variant_attributes ?? [],
],
];

View File

@@ -1,10 +1,13 @@
<?php
namespace App\Domains\Purchase\Resources;
namespace App\Domains\Commerce\Purchase\Resources;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Models\TelepagosPaymentCandidate;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Models\PurchaseItem;
use App\Domains\Commerce\Purchase\Models\TelepagosPaymentCandidate;
use App\Domains\Core\Tenant\Models\StorefrontWebsiteType;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -27,6 +30,18 @@ class PurchaseResource extends JsonResource
? (int) $this->resource->getAttribute('tickets_count')
: null;
$paymentVerification = $this->resolvePaymentVerification();
$tenant = $request->route('tenant');
$usesEventSummary = $tenant instanceof Tenant
&& $tenant->storefrontWebsiteType?->checkout_summary_type
=== StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT;
/** @var Event|null $checkoutEvent */
$checkoutEvent = $usesEventSummary
? $items
->map(fn (PurchaseItem $item) => $item->sourceCatalogItem?->event)
->filter()
->unique('id')
->first()
: null;
$subtotal = $items->isNotEmpty()
? $items->reduce(
@@ -64,6 +79,13 @@ class PurchaseResource extends JsonResource
'email' => $this->email,
'items_source' => $items->isNotEmpty() ? 'purchase' : null,
'items' => PurchaseItemResource::collection($items),
'event' => $this->when($usesEventSummary, fn () => $checkoutEvent === null
? null
: [
'id' => $checkoutEvent->id,
'title' => $checkoutEvent->title,
'image' => $checkoutEvent->attachment?->getTemporaryUrl(1440),
]),
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
'payment_verification' => $this->when($paymentVerification !== null, $paymentVerification),

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -20,7 +20,7 @@ class CatalogSelectionResolver
/** @var CatalogItem|null $catalogItem */
$catalogItem = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->lockForUpdate()
->first();
@@ -28,6 +28,12 @@ class CatalogSelectionResolver
throw new NotFoundHttpException('Catalog item not found for tenant.');
}
if (! $catalogItem->isSaleOpen()) {
throw ValidationException::withMessages([
"{$fieldPrefix}.catalog_item_id" => ['La venta de este producto finalizó.'],
]);
}
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
@@ -41,6 +47,17 @@ class CatalogSelectionResolver
]);
}
if ($catalogItem->bundleComponents()
->whereNotNull('component_variant_id')
->whereHas('variant', fn ($query) => $query
->whereNotNull('sales_disabled_at')
->orWhereNotNull('replaced_by_variant_id'))
->exists()) {
throw ValidationException::withMessages([
"{$fieldPrefix}.catalog_item_id" => [__('api.cart.bundle_component_unavailable')],
]);
}
return $catalogItem;
}
@@ -70,6 +87,12 @@ class CatalogSelectionResolver
throw new NotFoundHttpException('Variant not found for catalog item.');
}
if (! $variant->isSellable()) {
throw ValidationException::withMessages([
"{$fieldPrefix}.variant_id" => [__('api.cart.variant_unavailable')],
]);
}
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation(
'inventory',

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Services\StockReservationService;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\PurchaseStateGuard;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\PurchaseStateGuard;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Catalog\Enums\InventorySubject;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Enums\InventorySubject;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant;
class InsufficientStockMessageBuilder
{

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Variant;
use App\Shared\Attachable\Models\Attachment;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use Illuminate\Support\Collection;
class PurchaseItemSnapshotFactory

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Core\Tenant\Models\StorefrontWebsiteType;
class PurchaseResponseLoader
{
public function load(Purchase $purchase): Purchase
{
$purchase->loadMissing('tenant.storefrontWebsiteType');
$usesEventSummary = $purchase->tenant?->storefrontWebsiteType?->checkout_summary_type
=== StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT;
$relations = [
'tenant',
'items.sourceCatalogItem.event.attachment',
'stockReservation',
];
if (! $usesEventSummary) {
$relations[] = 'items.imageAttachment';
}
if (
$purchase->status === Purchase::STATUS_IN_REVIEW
&& $purchase->payment_method === 'transfer'
) {
$relations[] = 'telepagosPaymentCandidates.payment';
}
return $purchase->load($relations);
}
}

View File

@@ -1,12 +1,13 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Catalog\Services\StockReservationService;
use App\Domains\Commerce\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Commerce\Purchase\Models\Purchase;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -91,7 +92,16 @@ class ReleaseCheckoutService
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
$this->reservations->returnToCart($purchase, $cart);
if ($this->hasUnavailableVariants($cart)) {
$this->releasePurchaseReservations($purchase, $targetStatus, $cart);
$cart->update([
'status' => Cart::STATUS_EXPIRED,
'current_purchase_id' => null,
'current_stock_reservation_id' => null,
]);
} else {
$this->reservations->returnToCart($purchase, $cart);
}
$purchase->update(['status' => Purchase::STATUS_CANCELLED]);
return $this->loadPurchase($purchase);
@@ -115,6 +125,17 @@ class ReleaseCheckoutService
});
}
private function hasUnavailableVariants(Cart $cart): bool
{
return $cart->items()
->whereNotNull('variant_id')
->with(['variant.eventDate', 'variant.eventDates'])
->lockForUpdate()
->get()
->contains(fn (CartItem $item): bool => $item->variant === null
|| ! $item->variant->isSellable());
}
private function releasePurchaseReservations(
Purchase $purchase,
string $targetStatus,

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Purchase\Models\Purchase;
class SourceCartService
{

View File

@@ -1,18 +1,19 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Exceptions\InsufficientStockException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Cart\Services\CartVariantReplacementService;
use App\Domains\Commerce\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogInventoryService;
use App\Domains\Commerce\Catalog\Services\StockReservationService;
use App\Domains\Commerce\Purchase\Exceptions\InsufficientStockException;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -29,6 +30,7 @@ class StartCheckoutService
private readonly InsufficientStockMessageBuilder $stockMessages,
private readonly PurchaseResponseLoader $responses,
private readonly PurchaseItemSnapshotFactory $snapshots,
private readonly CartVariantReplacementService $variantReplacements,
) {}
/** @param array<string, mixed> $purchaseData */
@@ -250,6 +252,7 @@ class StartCheckoutService
int $cartId,
): Purchase {
$cart = $this->resolveCart($tenant, $userId, $cartId);
$this->variantReplacements->replaceHistoricalVariants($cart);
$cartItems = $cart->items()->lockForUpdate()->get();
if ($cartItems->isEmpty()) {
@@ -356,6 +359,13 @@ class StartCheckoutService
'cart_id' => __('api.purchase.catalog_item_wrong_tenant'),
]);
}
if ($tenant->active_event_id !== null
&& $item->catalogItem->event_id !== $tenant->active_event_id) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.catalog_item_missing'),
]);
}
}
}

View File

@@ -1,13 +1,13 @@
<?php
namespace App\Domains\Purchase\Services;
namespace App\Domains\Commerce\Purchase\Services;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use App\Domains\Purchase\Services\Checkout\StartCheckoutService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\Checkout\CompleteCheckoutService;
use App\Domains\Commerce\Purchase\Services\Checkout\EditCheckoutService;
use App\Domains\Commerce\Purchase\Services\Checkout\ReleaseCheckoutService;
use App\Domains\Commerce\Purchase\Services\Checkout\StartCheckoutService;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
/**

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Services;
namespace App\Domains\Commerce\Purchase\Services;
/**
* Measures likely DNI typing errors using the optimal-string-alignment

View File

@@ -1,21 +1,21 @@
<?php
namespace App\Domains\Purchase\Services;
namespace App\Domains\Commerce\Purchase\Services;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\TicketRefund;
use Illuminate\Database\Eloquent\Builder;
class PurchaseRefundSummaryService
{
public function totalForTenant(Tenant $tenant): string
{
$total = PurchaseItem::query()
$total = TicketRefund::query()
->whereHas(
'purchase',
'purchaseItem.purchase',
fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo)
)
->sum('refunded_amount');
->sum('amount');
return number_format((float) $total, 2, '.', '');
}

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Purchase\Services;
namespace App\Domains\Commerce\Purchase\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Commerce\Purchase\Models\Purchase;
use Illuminate\Validation\ValidationException;
class PurchaseStateGuard

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Purchase\Services;
namespace App\Domains\Commerce\Purchase\Services;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Models\Purchase;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -25,6 +25,9 @@ class TenantTransactionResetService
'carts' => $scope['cart_ids']->count(),
'cart_items' => $scope['cart_item_ids']->count(),
'tickets' => DB::table('tickets')->where('tenant_code', $tenantCode)->count(),
'entry_reservations' => DB::table('desfile_entry_reservations')
->whereIn('variant_id', DB::table('variantes')->whereIn('catalog_item_id',
DB::table('catalog_items')->where('tenant_code', $tenantCode)->select('id'))->select('id'))->count(),
'stock_reservations' => $this->reservationQuery($scope)->count(),
'purchase_changes' => DB::table('value_changes')
->where('tenant_code', $tenantCode)
@@ -47,6 +50,10 @@ class TenantTransactionResetService
$telepagosQr = DB::table('telepagos_qr')->whereIn('compra_id', $scope['purchase_ids'])->count();
$summary = [
'stock_reservations_deleted' => $this->reservationQuery($scope)->delete(),
'entry_reservations_deleted' => DB::table('desfile_entry_reservations')
->whereIn('variant_id', DB::table('variantes')->whereIn('catalog_item_id',
DB::table('catalog_items')->where('tenant_code', $tenantCode)->select('id'))->select('id'))->delete(),
'entry_reservation_batches_deleted' => DB::table('desfile_reservation_batches')->where('tenant_code', $tenantCode)->delete(),
'tickets_deleted' => DB::table('tickets')->where('tenant_code', $tenantCode)->delete(),
'purchase_changes_deleted' => DB::table('value_changes')
->where('tenant_code', $tenantCode)
@@ -65,9 +72,11 @@ class TenantTransactionResetService
DB::table('inventories')
->whereIn('id', $scope['inventory_ids'])
->update([
'real_stock' => DB::raw('real_stock + sold_units'),
'real_stock' => DB::raw('real_stock + sold_units - refunded_units'),
'reserved_stock' => 0,
'entry_reserved_stock' => 0,
'sold_units' => 0,
'refunded_units' => 0,
]);
return $summary;

View File

@@ -1,12 +1,12 @@
<?php
namespace App\Domains\Purchase\Services;
namespace App\Domains\Commerce\Purchase\Services;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Purchase\Exceptions\PurchaseLimitExceededException;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Models\PurchaseItem;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

View File

@@ -1,6 +1,6 @@
<?php
use App\Domains\Purchase\Controllers\PurchaseController;
use App\Domains\Commerce\Purchase\Controllers\PurchaseController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(function (): void {

View File

@@ -1,18 +1,18 @@
<?php
namespace App\Domains\Sale\Controllers\AdminApp;
namespace App\Domains\Commerce\Sale\Controllers\AdminApp;
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Sale\Requests\AdminAppSaleModificationIndexRequest;
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Sale\Resources\AdminApp\SaleResource;
use App\Domains\Sale\Resources\AdminApp\SaleTicketResource;
use App\Domains\Sale\Services\AdminAppSaleExcelService;
use App\Domains\Sale\Services\AdminAppSalePdfService;
use App\Domains\Sale\Services\AdminAppSaleService;
use App\Domains\Commerce\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Commerce\Sale\Requests\AdminAppSaleModificationIndexRequest;
use App\Domains\Commerce\Sale\Requests\AdminAppSaleModificationPdfRequest;
use App\Domains\Commerce\Sale\Requests\AdminAppSalePdfRequest;
use App\Domains\Commerce\Sale\Resources\AdminApp\SaleDetailResource;
use App\Domains\Commerce\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Commerce\Sale\Resources\AdminApp\SaleResource;
use App\Domains\Commerce\Sale\Resources\AdminApp\SaleTicketResource;
use App\Domains\Commerce\Sale\Services\AdminAppSaleExcelService;
use App\Domains\Commerce\Sale\Services\AdminAppSalePdfService;
use App\Domains\Commerce\Sale\Services\AdminAppSaleService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Sale\Requests;
namespace App\Domains\Commerce\Sale\Requests;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Models\Purchase;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Sale\Requests;
namespace App\Domains\Commerce\Sale\Requests;
class AdminAppSaleModificationIndexRequest extends AdminAppSaleIndexRequest
{

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Sale\Requests;
namespace App\Domains\Commerce\Sale\Requests;
use App\Domains\Shared\Rules\ValidTimezone;
use App\Shared\Rules\ValidTimezone;
class AdminAppSaleModificationPdfRequest extends AdminAppSaleModificationIndexRequest
{

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