Compare commits
157 Commits
feature/co
...
auth/googl
| Author | SHA1 | Date | |
|---|---|---|---|
| 873855c85a | |||
| 855ac13990 | |||
| 87153b7839 | |||
| 528772fc96 | |||
| 7e1ffbf417 | |||
| 03d8361e5f | |||
| 3fe48fbfa5 | |||
| 22da4966e9 | |||
| 1a05c380a0 | |||
| e1a67fd9f3 | |||
| 38f74739da | |||
| c0978033c1 | |||
| a928a2e848 | |||
| c3f54c79cb | |||
| 0f36c3402b | |||
| 29a2ca19a3 | |||
| d3182fbd13 | |||
| b8eb71896c | |||
| cb0fb2899c | |||
| 615eacea91 | |||
| 84c9fa4c9d | |||
| b3d06431e5 | |||
| 379c2bdbeb | |||
| 6fc6ed1fac | |||
| c05206cc51 | |||
| 480ee70393 | |||
| ceb1e2b04a | |||
| 1d56d27350 | |||
| 18c80b075b | |||
| 10157d7c63 | |||
| 716d8e447c | |||
| cababe1a43 | |||
| 0e9f7faaf1 | |||
| 13bdb436ed | |||
| c2efec8906 | |||
| 299a462283 | |||
| 38f36a3a23 | |||
| b4b888adef | |||
| 3afef63fc7 | |||
| e325c49a89 | |||
| 9c7d025258 | |||
| ba58bf79f8 | |||
| 2910d3f929 | |||
| ca7a2ae55c | |||
| ca4fea6bcd | |||
| 73bf285bad | |||
| b273b2866f | |||
| 05a92b7ec3 | |||
| f23029e785 | |||
| f18fbc8147 | |||
| c276ccd311 | |||
| 786b4eb6a9 | |||
| eca3b01083 | |||
| 7b2a741884 | |||
| 362e888e46 | |||
| 92458e217e | |||
| 4edda0bfd8 | |||
| 2c2cf45f78 | |||
| 1fcb3129ea | |||
| 3703ae9bcf | |||
| c19e00987e | |||
| 7be2221bb8 | |||
| dc32eb9bee | |||
| 6bda3bf013 | |||
| 3731cd67fe | |||
| 403caf77f5 | |||
| 04b2bb0b2a | |||
| 1af5b1d7ed | |||
| 387e136707 | |||
| 9a1e36337d | |||
| 0c28302f84 | |||
| 0cb1c37566 | |||
| 057e6757dc | |||
| 6d066beea3 | |||
| 1d94ff8885 | |||
| ee3be8d550 | |||
| 21b8fa5f35 | |||
| 3887a4fcba | |||
| 0d4c6a4492 | |||
| c1b7b12fe1 | |||
| b1e40b3430 | |||
| b94efea4e0 | |||
| 1ae3e9ce92 | |||
| 0efa1650a0 | |||
| 9a5a8e85b9 | |||
| a95cfc780b | |||
| 18eedd3209 | |||
| 00837850e6 | |||
| e5bc2cbe38 | |||
| efa499d993 | |||
| 6a0b08c9d7 | |||
| 230ea57ce0 | |||
| 4943e68606 | |||
| 3285015841 | |||
| 52b422c1ca | |||
| a79f58d520 | |||
| a7dba4dc10 | |||
| 4aedf7ca86 | |||
| a150a9f694 | |||
| 6d0d600b4b | |||
| 67c2a7350e | |||
| a0a0cc7255 | |||
| d6a17cb449 | |||
| 83f42f3cf9 | |||
| bbfe632926 | |||
| e78e64ed2e | |||
| 35384b48ab | |||
| 46685a7141 | |||
| 1aa831f921 | |||
| 429fa36b05 | |||
| cb15905c46 | |||
| 191a40fcb9 | |||
| 1d0f9e323d | |||
| fdeba7540b | |||
| 8b10996319 | |||
| 72261bf422 | |||
| f664fbe2dc | |||
| 46e7c27ae7 | |||
| 14f3906ef0 | |||
| edcb73f748 | |||
| a7372f9d08 | |||
| b05a574610 | |||
| 59ed592787 | |||
| 24235c5ca9 | |||
| 49771ebb13 | |||
| cd420e02d8 | |||
| 0206ee2985 | |||
| 9b7d728117 | |||
| b5b57a753c | |||
| 18504594eb | |||
| 9d3ed17c00 | |||
| 74e443ece1 | |||
| 6efda4727f | |||
| b0508d79b3 | |||
| 67bc9e6cb4 | |||
| 4125d8353d | |||
| 31b65eb676 | |||
| 8708cea122 | |||
| 54aa696145 | |||
| 1f9d876bc3 | |||
| 0dae985230 | |||
| 4c237fb729 | |||
| edb4c128d6 | |||
| 2ce21799c7 | |||
| 17799122e2 | |||
| e4448d810e | |||
| 7994fe0c96 | |||
| ee59236d55 | |||
| aec92d5346 | |||
| 6db98ddc1a | |||
| 56b87f0c12 | |||
| 9c5a29d933 | |||
| 049484dee5 | |||
| 3360e81e96 | |||
| 93aead8dbf | |||
| 740566b02d | |||
| b874a45724 |
28
.env.example
28
.env.example
@@ -1,8 +1,10 @@
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
FRONTEND_URL=http://localhost:4200
|
||||
INTEGRATION_SECRET=
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@@ -33,6 +35,8 @@ SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
SANCTUM_EXPIRATION=720
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
@@ -47,19 +51,27 @@ REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
|
||||
GOOGLE_CLIENT_ID=...
|
||||
GOOGLE_CLIENT_SECRET=...
|
||||
GOOGLE_REDIRECT_URI=https://grub-renewed-nicely.ngrok-free.app/auth/google/callback
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
|
||||
AWS_ENDPOINT=
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_DEFAULT_REGION=garage
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=
|
||||
AWS_HTTP_VERIFY=
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.testing
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
|
||||
1435
ShopIt_API_Postman_Collection.json
Normal file
1435
ShopIt_API_Postman_Collection.json
Normal file
File diff suppressed because it is too large
Load Diff
367
Tenant_CRUD_Postman_Collection.json
Normal file
367
Tenant_CRUD_Postman_Collection.json
Normal file
@@ -0,0 +1,367 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "76df1733-a3d8-4f2b-9ffb-7c70e3032549",
|
||||
"name": "ShopIt - Tenant CRUD",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "List Tenants",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/tenants",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"tenants"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Bootstrap Tenant",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/tenants/bootstrap/acme.com",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"tenants",
|
||||
"bootstrap",
|
||||
"acme.com"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Create Tenant (JSON)",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"codigo\": \"acme\",\n \"nombre\": \"Acme Corporation\",\n \"dominio\": \"acme.com\",\n \"primary_color\": \"#111111\",\n \"secondary_color\": \"#222222\",\n \"danger_color\": \"#333333\",\n \"header_bg_color\": \"#444444\",\n \"footer_bg_color\": \"#444444\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/tenants",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"tenants"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Create Tenant (Form Data)",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "codigo",
|
||||
"value": "acme",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "nombre",
|
||||
"value": "Acme Corporation",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "dominio",
|
||||
"value": "acme.com",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "primary_color",
|
||||
"value": "#111111",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "secondary_color",
|
||||
"value": "#222222",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "danger_color",
|
||||
"value": "#333333",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "header_bg_color",
|
||||
"value": "#444444",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "footer_bg_color",
|
||||
"value": "#444444",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "header_logo",
|
||||
"type": "file",
|
||||
"src": []
|
||||
},
|
||||
{
|
||||
"key": "footer_logo",
|
||||
"type": "file",
|
||||
"src": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/tenants",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"tenants"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Show Tenant",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/tenants/acme",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"tenants",
|
||||
"acme"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Update Tenant (JSON)",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"codigo\": \"acme\",\n \"nombre\": \"Acme Corporation Updated\",\n \"dominio\": \"acme.com\",\n \"primary_color\": \"#555555\",\n \"secondary_color\": \"#666666\",\n \"danger_color\": \"#777777\",\n \"header_bg_color\": \"#888888\",\n \"footer_bg_color\": \"#888888\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/tenants/acme",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"tenants",
|
||||
"acme"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Update Tenant (Form Data)",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "_method",
|
||||
"value": "PUT",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "codigo",
|
||||
"value": "acme",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "nombre",
|
||||
"value": "Acme Corporation Updated",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "dominio",
|
||||
"value": "acme.com",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "primary_color",
|
||||
"value": "#555555",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "secondary_color",
|
||||
"value": "#666666",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "danger_color",
|
||||
"value": "#777777",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "header_bg_color",
|
||||
"value": "#888888",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "footer_bg_color",
|
||||
"value": "#888888",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "header_logo",
|
||||
"type": "file",
|
||||
"src": []
|
||||
},
|
||||
{
|
||||
"key": "footer_logo",
|
||||
"type": "file",
|
||||
"src": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/tenants/acme",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"tenants",
|
||||
"acme"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Delete Tenant",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/tenants/acme",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"tenants",
|
||||
"acme"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
],
|
||||
"event": [
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
""
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
""
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "base_url",
|
||||
"value": "http://localhost:8000",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
app/Domains/Attachable/Enums/AttachmentType.php
Normal file
13
app/Domains/Attachable/Enums/AttachmentType.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Attachable\Enums;
|
||||
|
||||
enum AttachmentType: string
|
||||
{
|
||||
case Image = 'image';
|
||||
case Video = 'video';
|
||||
case Pdf = 'pdf';
|
||||
case Document = 'document';
|
||||
case Audio = 'audio';
|
||||
case Other = 'other';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Attachable\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class AttachmentStorageException extends RuntimeException
|
||||
{
|
||||
}
|
||||
53
app/Domains/Attachable/Models/Attachment.php
Normal file
53
app/Domains/Attachable/Models/Attachment.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Attachable\Models;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable([
|
||||
'key',
|
||||
'path',
|
||||
'filename',
|
||||
'type',
|
||||
'mime_type',
|
||||
'extension',
|
||||
'size',
|
||||
])]
|
||||
class Attachment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'attachments';
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $attachment): void {
|
||||
if (! $attachment->key) {
|
||||
$attachment->key = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => AttachmentType::class,
|
||||
'size' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pre-signed temporary S3 URL for this attachment.
|
||||
*/
|
||||
public function getTemporaryUrl(int $expiresInMinutes = 10): string
|
||||
{
|
||||
return \Illuminate\Support\Facades\Storage::disk('s3')->temporaryUrl(
|
||||
$this->path,
|
||||
now()->addMinutes($expiresInMinutes)
|
||||
);
|
||||
}
|
||||
}
|
||||
289
app/Domains/Attachable/Services/AttachmentService.php
Normal file
289
app/Domains/Attachable/Services/AttachmentService.php
Normal file
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Attachable\Services;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Exceptions\AttachmentStorageException;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Mime\MimeTypes;
|
||||
use Throwable;
|
||||
|
||||
class AttachmentService
|
||||
{
|
||||
public function store(
|
||||
UploadedFile|string $file,
|
||||
string $path,
|
||||
): Attachment {
|
||||
$normalizedPath = $this->normalizeDirectory($path);
|
||||
|
||||
if ($normalizedPath === '') {
|
||||
throw new AttachmentStorageException('The attachment path cannot be empty.');
|
||||
}
|
||||
|
||||
$key = (string) Str::uuid();
|
||||
$fileData = $this->resolveFileData($file, $key);
|
||||
$storedPath = $this->storeFile(
|
||||
$fileData,
|
||||
$normalizedPath,
|
||||
$this->buildStoredFilename($key, $fileData['extension']),
|
||||
);
|
||||
|
||||
if (! is_string($storedPath) || $storedPath === '') {
|
||||
throw new AttachmentStorageException('No se pudo subir el archivo al disco s3.');
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var Attachment $attachment */
|
||||
$attachment = Attachment::query()->create([
|
||||
'key' => $key,
|
||||
'path' => $storedPath,
|
||||
'filename' => $this->resolveFilename($file, $key),
|
||||
'type' => $this->resolveAttachmentType($fileData['mime_type']),
|
||||
'mime_type' => $fileData['mime_type'],
|
||||
'extension' => $fileData['extension'],
|
||||
'size' => $fileData['size'],
|
||||
]);
|
||||
|
||||
return $attachment;
|
||||
} catch (Throwable $throwable) {
|
||||
Storage::disk('s3')->delete($storedPath);
|
||||
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(Attachment $attachment): void
|
||||
{
|
||||
$deleted = Storage::disk('s3')->delete($attachment->path);
|
||||
|
||||
if (! $deleted) {
|
||||
throw new AttachmentStorageException('No se pudo eliminar el archivo del disco s3.');
|
||||
}
|
||||
|
||||
$attachment->delete();
|
||||
}
|
||||
|
||||
public function copy(Attachment $source, string $path): Attachment
|
||||
{
|
||||
$normalizedPath = $this->normalizeDirectory($path);
|
||||
|
||||
if ($normalizedPath === '') {
|
||||
throw new AttachmentStorageException('The attachment path cannot be empty.');
|
||||
}
|
||||
|
||||
$key = (string) Str::uuid();
|
||||
$storedPath = $normalizedPath.'/'.$this->buildStoredFilename($key, (string) $source->extension);
|
||||
$copied = Storage::disk('s3')->copy($source->path, $storedPath);
|
||||
|
||||
if (! $copied) {
|
||||
throw new AttachmentStorageException('No se pudo copiar el archivo en el disco s3.');
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var Attachment $attachment */
|
||||
$attachment = Attachment::query()->create([
|
||||
'key' => $key,
|
||||
'path' => $storedPath,
|
||||
'filename' => $source->filename,
|
||||
'type' => $source->type,
|
||||
'mime_type' => $source->mime_type,
|
||||
'extension' => $source->extension,
|
||||
'size' => $source->size,
|
||||
]);
|
||||
|
||||
return $attachment;
|
||||
} catch (Throwable $throwable) {
|
||||
Storage::disk('s3')->delete($storedPath);
|
||||
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
protected function normalizeDirectory(string $path): string
|
||||
{
|
||||
return trim($path, '/');
|
||||
}
|
||||
|
||||
protected function resolveFilename(UploadedFile|string $file, string $key): string
|
||||
{
|
||||
if ($file instanceof UploadedFile) {
|
||||
$originalName = trim($file->getClientOriginalName());
|
||||
|
||||
if ($originalName !== '') {
|
||||
return $originalName;
|
||||
}
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int}
|
||||
*/
|
||||
protected function resolveFileData(UploadedFile|string $file, string $filename): array
|
||||
{
|
||||
if ($file instanceof UploadedFile) {
|
||||
return $this->fileDataFromUploadedFile($file);
|
||||
}
|
||||
|
||||
return $this->fileDataFromBase64($file, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string|null, extension: string, file: UploadedFile, mime_type: string, size: int}
|
||||
*/
|
||||
protected function fileDataFromUploadedFile(UploadedFile $file): array
|
||||
{
|
||||
$mimeType = strtolower($file->getClientMimeType() ?? $file->getMimeType() ?? 'application/octet-stream');
|
||||
|
||||
return [
|
||||
'contents' => null,
|
||||
'extension' => strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: ''),
|
||||
'file' => $file,
|
||||
'mime_type' => $mimeType,
|
||||
'size' => $file->getSize() ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string, extension: string, file: null, mime_type: string, size: int}
|
||||
*/
|
||||
protected function fileDataFromBase64(string $file, string $filename): array
|
||||
{
|
||||
['data' => $contents, 'mime_type' => $declaredMimeType] = $this->decodeBase64File($file);
|
||||
$mimeType = $this->detectMimeType($contents, $declaredMimeType);
|
||||
|
||||
return [
|
||||
'contents' => $contents,
|
||||
'extension' => $this->resolveExtension($filename, $mimeType),
|
||||
'file' => null,
|
||||
'mime_type' => $mimeType,
|
||||
'size' => strlen($contents),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{data: string, mime_type: string|null}
|
||||
*/
|
||||
protected function decodeBase64File(string $file): array
|
||||
{
|
||||
$payload = trim($file);
|
||||
|
||||
if ($payload === '') {
|
||||
throw new AttachmentStorageException('The base64 attachment content cannot be empty.');
|
||||
}
|
||||
|
||||
$declaredMimeType = null;
|
||||
|
||||
if (preg_match('/^data:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/s', $payload, $matches) === 1) {
|
||||
$declaredMimeType = strtolower($matches['mime']);
|
||||
$payload = $matches['data'];
|
||||
}
|
||||
|
||||
$decoded = base64_decode(preg_replace('/\s+/', '', $payload), true);
|
||||
|
||||
if ($decoded === false || $decoded === '') {
|
||||
throw new AttachmentStorageException('The attachment base64 payload is invalid.');
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $decoded,
|
||||
'mime_type' => $declaredMimeType,
|
||||
];
|
||||
}
|
||||
|
||||
protected function detectMimeType(string $contents, ?string $fallback = null): string
|
||||
{
|
||||
if (is_string($fallback) && $fallback !== '') {
|
||||
return strtolower($fallback);
|
||||
}
|
||||
|
||||
$detectedMimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($contents);
|
||||
|
||||
if (is_string($detectedMimeType) && $detectedMimeType !== '') {
|
||||
return strtolower($detectedMimeType);
|
||||
}
|
||||
|
||||
return strtolower($fallback ?? 'application/octet-stream');
|
||||
}
|
||||
|
||||
protected function resolveExtension(string $filename, string $mimeType): string
|
||||
{
|
||||
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||
|
||||
if ($extension !== '') {
|
||||
return $extension;
|
||||
}
|
||||
|
||||
return strtolower(MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int} $fileData
|
||||
*/
|
||||
protected function storeFile(array $fileData, string $directory, string $storedFilename): string
|
||||
{
|
||||
if ($fileData['file'] instanceof UploadedFile) {
|
||||
return Storage::disk('s3')->putFileAs($directory, $fileData['file'], $storedFilename);
|
||||
}
|
||||
|
||||
$storedPath = $directory !== ''
|
||||
? $directory.'/'.$storedFilename
|
||||
: $storedFilename;
|
||||
|
||||
$stored = Storage::disk('s3')->put($storedPath, $fileData['contents'] ?? '');
|
||||
|
||||
if (! $stored) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $storedPath;
|
||||
}
|
||||
|
||||
protected function resolveAttachmentType(string $mimeType): AttachmentType
|
||||
{
|
||||
$mimeType = strtolower($mimeType);
|
||||
|
||||
if (str_starts_with($mimeType, 'image/')) {
|
||||
return AttachmentType::Image;
|
||||
}
|
||||
|
||||
if (str_starts_with($mimeType, 'video/')) {
|
||||
return AttachmentType::Video;
|
||||
}
|
||||
|
||||
if ($mimeType === 'application/pdf') {
|
||||
return AttachmentType::Pdf;
|
||||
}
|
||||
|
||||
if (str_starts_with($mimeType, 'audio/')) {
|
||||
return AttachmentType::Audio;
|
||||
}
|
||||
|
||||
if (
|
||||
str_starts_with($mimeType, 'text/')
|
||||
|| str_contains($mimeType, 'document')
|
||||
|| str_contains($mimeType, 'word')
|
||||
|| str_contains($mimeType, 'excel')
|
||||
|| str_contains($mimeType, 'spreadsheet')
|
||||
|| str_contains($mimeType, 'presentation')
|
||||
|| str_contains($mimeType, 'officedocument')
|
||||
) {
|
||||
return AttachmentType::Document;
|
||||
}
|
||||
|
||||
return AttachmentType::Other;
|
||||
}
|
||||
|
||||
protected function buildStoredFilename(string $key, string $extension): string
|
||||
{
|
||||
if ($extension === '') {
|
||||
return $key;
|
||||
}
|
||||
|
||||
return $key.'.'.$extension;
|
||||
}
|
||||
}
|
||||
23
app/Domains/Auth/Controllers/GoogleAuthController.php
Normal file
23
app/Domains/Auth/Controllers/GoogleAuthController.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Services\GoogleAuthService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GoogleAuthController extends Controller
|
||||
{
|
||||
public function __construct(private readonly GoogleAuthService $googleAuthService) {}
|
||||
|
||||
public function redirect(Request $request): RedirectResponse
|
||||
{
|
||||
return $this->googleAuthService->redirect($request);
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
return $this->googleAuthService->callback($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Requests\GoogleTokenExchangeRequest;
|
||||
use App\Domains\Auth\Resources\UserResource;
|
||||
use App\Domains\Auth\Services\GoogleAuthService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class GoogleTokenExchangeController extends Controller
|
||||
{
|
||||
public function __construct(private readonly GoogleAuthService $googleAuthService) {}
|
||||
|
||||
public function __invoke(GoogleTokenExchangeRequest $request): JsonResponse
|
||||
{
|
||||
$authentication = $this->googleAuthService->exchange($request->validated('oauth_code'));
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Sesion iniciada correctamente.',
|
||||
'token' => $authentication['token'],
|
||||
'token_type' => 'Bearer',
|
||||
'user' => UserResource::make($authentication['user']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
43
app/Domains/Auth/Controllers/LoginController.php
Normal file
43
app/Domains/Auth/Controllers/LoginController.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Requests\LoginUserRequest;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Auth\Resources\UserResource;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function __invoke(LoginUserRequest $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->validated();
|
||||
$user = User::query()->where('email', $credentials['email'])->first();
|
||||
|
||||
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => 'Email o Contraseña incorrectos.',
|
||||
]);
|
||||
}
|
||||
|
||||
$expirationMinutes = (int) config('sanctum.expiration');
|
||||
$token = $user->createToken(
|
||||
'api-token',
|
||||
['*'],
|
||||
now()->addMinutes($expirationMinutes),
|
||||
)->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Sesion iniciada correctamente.',
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
'user' => UserResource::make($user),
|
||||
]);
|
||||
}
|
||||
}
|
||||
29
app/Domains/Auth/Controllers/LogoutController.php
Normal file
29
app/Domains/Auth/Controllers/LogoutController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class LogoutController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$user->currentAccessToken()?->delete();
|
||||
$user->tokens()->delete();
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
if ($request->hasSession()) {
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Sesion cerrada correctamente.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
15
app/Domains/Auth/Controllers/MeController.php
Normal file
15
app/Domains/Auth/Controllers/MeController.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Resources\UserResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MeController
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json(UserResource::make($request->user())->resolve());
|
||||
}
|
||||
}
|
||||
27
app/Domains/Auth/Controllers/RegisterController.php
Normal file
27
app/Domains/Auth/Controllers/RegisterController.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Requests\RegisterUserRequest;
|
||||
use App\Domains\Auth\Resources\UserResource;
|
||||
use App\Domains\Auth\Services\RegisterUserService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected RegisterUserService $registerUserService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(RegisterUserRequest $request): JsonResponse
|
||||
{
|
||||
$user = $this->registerUserService->register($request->validated());
|
||||
|
||||
return UserResource::make($user)
|
||||
->additional(['message' => 'Usuario registrado correctamente.'])
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
}
|
||||
18
app/Domains/Auth/Controllers/UpdateProfileController.php
Normal file
18
app/Domains/Auth/Controllers/UpdateProfileController.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Requests\UpdateProfileRequest;
|
||||
use App\Domains\Auth\Resources\UserResource;
|
||||
use App\Domains\Auth\Services\ProfileService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class UpdateProfileController
|
||||
{
|
||||
public function __invoke(UpdateProfileRequest $request, ProfileService $service): JsonResponse
|
||||
{
|
||||
$user = $service->update($request->user(), $request->validated());
|
||||
|
||||
return response()->json(UserResource::make($user)->resolve());
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace App\Domains\Auth\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
protected static function newFactory(): UserFactory
|
||||
{
|
||||
return UserFactory::new();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
21
app/Domains/Auth/Requests/GoogleTokenExchangeRequest.php
Normal file
21
app/Domains/Auth/Requests/GoogleTokenExchangeRequest.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class GoogleTokenExchangeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'oauth_code' => ['required', 'uuid'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
namespace App\Domains\Auth\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateBrandRequest extends FormRequest
|
||||
class LoginUserRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -17,8 +17,8 @@ class UpdateBrandRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'descripcion' => ['nullable', 'string'],
|
||||
'email' => ['required', 'string', 'email', 'max:255'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Domains/Auth/Requests/RegisterUserRequest.php
Normal file
30
app/Domains/Auth/Requests/RegisterUserRequest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class RegisterUserRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
|
||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
|
||||
'dni' => ['nullable', 'string', 'max:255'],
|
||||
'telefono' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Domains/Auth/Requests/UpdateProfileRequest.php
Normal file
29
app/Domains/Auth/Requests/UpdateProfileRequest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateProfileRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
Rule::unique('users', 'email')->ignore($this->user()->id),
|
||||
],
|
||||
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
|
||||
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
|
||||
'password' => ['nullable', 'string', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Domains/Auth/Resources/UserResource.php
Normal file
26
app/Domains/Auth/Resources/UserResource.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Auth\Models\User
|
||||
*/
|
||||
class UserResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'nombre_apellido' => $this->nombre_apellido,
|
||||
'email' => $this->email,
|
||||
'dni' => $this->dni,
|
||||
'telefono' => $this->telefono,
|
||||
];
|
||||
}
|
||||
}
|
||||
165
app/Domains/Auth/Services/GoogleAuthService.php
Normal file
165
app/Domains/Auth/Services/GoogleAuthService.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Notification\Events\UserRegistered;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Socialite\Contracts\User as SocialiteUser;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
class GoogleAuthService
|
||||
{
|
||||
public function redirect(Request $request): RedirectResponse
|
||||
{
|
||||
$tenantCode = $request->string('tenant')->toString();
|
||||
$returnUrl = $request->string('return_url')->toString();
|
||||
|
||||
$tenant = Tenant::query()->where('codigo', $tenantCode)->first();
|
||||
|
||||
if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) {
|
||||
throw ValidationException::withMessages([
|
||||
'tenant' => 'El tenant o la URL de retorno no son validos.',
|
||||
]);
|
||||
}
|
||||
|
||||
$state = (string) Str::uuid();
|
||||
Cache::put("google-oauth-context:{$state}", [
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'return_url' => rtrim($returnUrl, '/'),
|
||||
], now()->addMinutes(10));
|
||||
|
||||
return Socialite::driver('google')
|
||||
->scopes(['openid', 'profile', 'email'])
|
||||
->stateless()
|
||||
->with(['state' => $state])
|
||||
->redirect();
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$state = $request->string('state')->toString();
|
||||
|
||||
/** @var array{tenant_codigo?: string, return_url?: string}|null $context */
|
||||
$context = Str::isUuid($state) ? Cache::pull("google-oauth-context:{$state}") : null;
|
||||
|
||||
if (! is_array($context) || ! isset($context['tenant_codigo'], $context['return_url'])) {
|
||||
abort(400, 'La solicitud de autenticacion expiro. Intenta nuevamente.');
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->where('codigo', $context['tenant_codigo'])->first();
|
||||
|
||||
if (! $tenant || ! $this->isTenantReturnUrl($context['return_url'], $tenant)) {
|
||||
abort(400, 'La URL de retorno no es valida.');
|
||||
}
|
||||
|
||||
/** @var SocialiteUser $googleUser */
|
||||
$googleUser = Socialite::driver('google')->stateless()->user();
|
||||
$user = $this->resolveUser($googleUser, $tenant);
|
||||
$token = $user->createToken(
|
||||
'google-oauth',
|
||||
['*'],
|
||||
now()->addMinutes((int) config('sanctum.expiration')),
|
||||
)->plainTextToken;
|
||||
|
||||
$exchangeCode = (string) Str::uuid();
|
||||
Cache::put("google-oauth-exchange:{$exchangeCode}", [
|
||||
'user_id' => $user->id,
|
||||
'token' => $token,
|
||||
], now()->addMinutes(5));
|
||||
|
||||
return redirect()->to($context['return_url'].'/login?'.http_build_query([
|
||||
'oauth_code' => $exchangeCode,
|
||||
]));
|
||||
}
|
||||
|
||||
/** @return array{user: User, token: string} */
|
||||
public function exchange(string $exchangeCode): array
|
||||
{
|
||||
/** @var array{user_id: int, token: string}|null $authentication */
|
||||
$authentication = Cache::pull("google-oauth-exchange:{$exchangeCode}");
|
||||
|
||||
if (! $authentication) {
|
||||
throw ValidationException::withMessages([
|
||||
'oauth_code' => 'El codigo de autenticacion expiro o ya fue utilizado.',
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => User::query()->findOrFail($authentication['user_id']),
|
||||
'token' => $authentication['token'],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveUser(SocialiteUser $googleUser, Tenant $tenant): User
|
||||
{
|
||||
$googleId = $googleUser->getId();
|
||||
$email = $googleUser->getEmail();
|
||||
|
||||
if (! is_string($googleId) || $googleId === '' || ! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw ValidationException::withMessages([
|
||||
'google' => 'Google no devolvio una identidad valida.',
|
||||
]);
|
||||
}
|
||||
|
||||
$rawUser = $googleUser instanceof \Laravel\Socialite\Two\User ? $googleUser->getRaw() : [];
|
||||
$emailVerified = $rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false;
|
||||
if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'google' => 'La cuenta de Google debe tener el email verificado.',
|
||||
]);
|
||||
}
|
||||
|
||||
$user = User::query()->where('google_id', $googleId)->first();
|
||||
if ($user) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
if ($user) {
|
||||
$user->forceFill(['google_id' => $googleId])->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
$name = $googleUser->getName();
|
||||
$user = User::query()->create([
|
||||
'nombre_apellido' => is_string($name) && $name !== '' ? $name : $email,
|
||||
'email' => $email,
|
||||
'email_verified_at' => now(),
|
||||
'google_id' => $googleId,
|
||||
'password' => Str::password(64),
|
||||
]);
|
||||
|
||||
UserRegistered::dispatch($user, $tenant->codigo);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function isTenantReturnUrl(string $returnUrl, Tenant $tenant): bool
|
||||
{
|
||||
$parts = parse_url($returnUrl);
|
||||
if (! is_array($parts)
|
||||
|| ! isset($parts['scheme'], $parts['host'])
|
||||
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])
|
||||
|| ($parts['path'] ?? '') !== '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scheme = strtolower($parts['scheme']);
|
||||
$host = TenantDomainNormalizer::normalize($parts['host']);
|
||||
$tenantDomain = TenantDomainNormalizer::normalize($tenant->dominio);
|
||||
|
||||
if ($host === null || $tenantDomain === null || $host !== $tenantDomain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $scheme === 'https' || ($scheme === 'http' && in_array($host, ['localhost', '127.0.0.1'], true));
|
||||
}
|
||||
}
|
||||
41
app/Domains/Auth/Services/ProfileService.php
Normal file
41
app/Domains/Auth/Services/ProfileService.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class ProfileService
|
||||
{
|
||||
/**
|
||||
* Update the given user's profile information.
|
||||
*
|
||||
* @param User $user
|
||||
* @param array $data
|
||||
* @return User
|
||||
*/
|
||||
public function update(User $user, array $data): User
|
||||
{
|
||||
// Handle password hashing if a new password is provided
|
||||
if (!empty($data['password'])) {
|
||||
$data['password'] = Hash::make($data['password']);
|
||||
} else {
|
||||
// Remove password from array if empty so we don't overwrite it with null
|
||||
unset($data['password']);
|
||||
}
|
||||
|
||||
// Standardize phone number (strip all but numbers and leading '+')
|
||||
if (!empty($data['telefono'])) {
|
||||
$data['telefono'] = preg_replace('/[^\+0-9]/', '', $data['telefono']);
|
||||
}
|
||||
|
||||
// DNI is already validated as numbers only, but we can do a quick strip just in case
|
||||
if (!empty($data['dni'])) {
|
||||
$data['dni'] = preg_replace('/[^0-9]/', '', $data['dni']);
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
29
app/Domains/Auth/Services/RegisterUserService.php
Normal file
29
app/Domains/Auth/Services/RegisterUserService.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Notification\Events\UserRegistered;
|
||||
|
||||
class RegisterUserService
|
||||
{
|
||||
/**
|
||||
* @param array{tenant_codigo?: string|null, nombre_apellido: string, email: string, password: string, dni?: string|null, telefono?: string|null} $data
|
||||
*/
|
||||
public function register(array $data): User
|
||||
{
|
||||
$user = User::query()->create([
|
||||
'nombre_apellido' => $data['nombre_apellido'],
|
||||
'email' => $data['email'],
|
||||
'password' => $data['password'],
|
||||
'dni' => $data['dni'] ?? null,
|
||||
'telefono' => $data['telefono'] ?? null,
|
||||
]);
|
||||
|
||||
if (! empty($data['tenant_codigo'])) {
|
||||
UserRegistered::dispatch($user, $data['tenant_codigo']);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
16
app/Domains/Auth/routes/api.php
Normal file
16
app/Domains/Auth/routes/api.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Auth\Controllers\GoogleTokenExchangeController;
|
||||
use App\Domains\Auth\Controllers\LoginController;
|
||||
use App\Domains\Auth\Controllers\LogoutController;
|
||||
use App\Domains\Auth\Controllers\MeController;
|
||||
use App\Domains\Auth\Controllers\RegisterController;
|
||||
use App\Domains\Auth\Controllers\UpdateProfileController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/register', RegisterController::class);
|
||||
Route::post('/login', LoginController::class);
|
||||
Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
|
||||
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
|
||||
Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
||||
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
namespace App\Domains\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\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -16,8 +16,7 @@ class CartController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected CartService $cartService,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Tenant $tenant): CartResource
|
||||
{
|
||||
@@ -29,11 +28,16 @@ class CartController extends Controller
|
||||
$result = $this->cartService->addItem(
|
||||
$tenant,
|
||||
$request,
|
||||
(int) $request->validated('product_variant_id'),
|
||||
(int) $request->validated('catalog_item_id'),
|
||||
$request->validated('variant_id') !== null
|
||||
? (int) $request->validated('variant_id')
|
||||
: null,
|
||||
(int) $request->validated('cantidad'),
|
||||
);
|
||||
|
||||
$response = CartResource::make($result['cart'])->response();
|
||||
$response = CartResource::make($result['cart'])
|
||||
->additional(['message' => 'Producto agregado al carrito.'])
|
||||
->response();
|
||||
|
||||
if ($result['guest_token'] !== null) {
|
||||
$response->withCookie($this->cartService->makeGuestTokenCookie($result['guest_token']));
|
||||
@@ -45,22 +49,22 @@ class CartController extends Controller
|
||||
public function updateItemQuantity(
|
||||
UpdateCartItemQuantityRequest $request,
|
||||
Tenant $tenant,
|
||||
ProductVariant $productVariant,
|
||||
CartItem $cartItem,
|
||||
): CartResource {
|
||||
return CartResource::make(
|
||||
$this->cartService->updateItemQuantity(
|
||||
$tenant,
|
||||
$request,
|
||||
$productVariant->getKey(),
|
||||
$cartItem->getKey(),
|
||||
(int) $request->validated('cantidad'),
|
||||
)
|
||||
);
|
||||
)->additional(['message' => 'Cantidad de producto actualizada.']);
|
||||
}
|
||||
|
||||
public function removeItem(Request $request, Tenant $tenant, ProductVariant $productVariant): CartResource
|
||||
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
|
||||
{
|
||||
return CartResource::make(
|
||||
$this->cartService->removeItem($tenant, $request, $productVariant->getKey())
|
||||
);
|
||||
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
|
||||
)->additional(['message' => 'Producto eliminado del carrito.']);
|
||||
}
|
||||
}
|
||||
|
||||
73
app/Domains/Cart/Middleware/MergeGuestCartMiddleware.php
Normal file
73
app/Domains/Cart/Middleware/MergeGuestCartMiddleware.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Cart\Middleware;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class MergeGuestCartMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$guestToken = $request->cookie('guest_token');
|
||||
|
||||
// Resolve authenticated user optionally (from default guard or sanctum guard)
|
||||
$user = $request->user() ?? Auth::guard('sanctum')->user();
|
||||
$userId = $user?->getKey();
|
||||
|
||||
\Illuminate\Support\Facades\Log::info('MergeGuestCartMiddleware processed', [
|
||||
'user_id' => $userId,
|
||||
'guest_token' => $guestToken,
|
||||
]);
|
||||
|
||||
if ($user !== null && is_string($guestToken) && $guestToken !== '') {
|
||||
$tenantParam = $request->route('tenant');
|
||||
$tenantCodigo = null;
|
||||
|
||||
if ($tenantParam instanceof Tenant) {
|
||||
$tenantCodigo = $tenantParam->codigo;
|
||||
} elseif (is_string($tenantParam)) {
|
||||
$tenantCodigo = $tenantParam;
|
||||
}
|
||||
|
||||
if ($tenantCodigo !== null) {
|
||||
$guestCart = Cart::query()
|
||||
->where('tenant_codigo', $tenantCodigo)
|
||||
->where('guest_token', $guestToken)
|
||||
->first();
|
||||
|
||||
if ($guestCart !== null && $guestCart->items()->exists()) {
|
||||
$userCart = Cart::query()
|
||||
->where('tenant_codigo', $tenantCodigo)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if ($userCart !== null) {
|
||||
$userCart->delete();
|
||||
}
|
||||
|
||||
$guestCart->update([
|
||||
'user_id' => $userId,
|
||||
'guest_token' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
if ($user !== null && is_string($guestToken) && $guestToken !== '') {
|
||||
// Remove the cookie from the response since the user is authenticated.
|
||||
if (method_exists($response, 'withCookie')) {
|
||||
$response->withCookie(Cookie::forget('guest_token'));
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
namespace App\Domains\Cart\Models;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -23,6 +27,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
class Cart extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'carritos';
|
||||
|
||||
@@ -57,7 +62,20 @@ class Cart extends Model
|
||||
return $this->hasMany(CartItem::class, 'cart_id');
|
||||
}
|
||||
|
||||
public function addItem(int $productVariantId, int $quantity): CartItem
|
||||
public function getTotalAmount(): float
|
||||
{
|
||||
$items = $this->relationLoaded('items')
|
||||
? $this->getRelation('items')
|
||||
: $this->items()->with(['catalogItem', 'variant'])->get();
|
||||
|
||||
return (float) $items->reduce(
|
||||
fn (float $carry, CartItem $item): float => $carry
|
||||
+ (($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad),
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
|
||||
public function addItem(int $catalogItemId, ?int $variantId, int $quantity): CartItem
|
||||
{
|
||||
if ($quantity <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -65,24 +83,29 @@ class Cart extends Model
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
|
||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
||||
self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail();
|
||||
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
||||
$inventoryService = app(CatalogInventoryService::class);
|
||||
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
|
||||
|
||||
if ($variant->stock < $quantity) {
|
||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => 'Stock insuficiente para la variante solicitada.',
|
||||
'cantidad' => "Stock insuficiente para el producto solicitado. Maximo disponible: {$availableQuantity}.",
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var CartItem|null $item */
|
||||
$item = $this->items()
|
||||
->where('producto_variante_id', $variant->getKey())
|
||||
->where('catalog_item_id', $catalogItemId)
|
||||
->where('variant_id', $variantId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($item === null) {
|
||||
$item = $this->items()->create([
|
||||
'producto_variante_id' => $variant->getKey(),
|
||||
'catalog_item_id' => $catalogItemId,
|
||||
'variant_id' => $variantId,
|
||||
'cantidad' => $quantity,
|
||||
]);
|
||||
} else {
|
||||
@@ -90,13 +113,13 @@ class Cart extends Model
|
||||
$item->save();
|
||||
}
|
||||
|
||||
$variant->decrement('stock', $quantity);
|
||||
$inventoryService->reserve($selectedItem, $quantity);
|
||||
|
||||
return $item->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function updateItem(int $productVariantId, int $quantity): CartItem
|
||||
public function updateItem(int $cartItemId, int $quantity): CartItem
|
||||
{
|
||||
if ($quantity <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -104,19 +127,26 @@ class Cart extends Model
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
|
||||
return DB::transaction(function () use ($cartItemId, $quantity): CartItem {
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
->where('producto_variante_id', $productVariantId)
|
||||
->where('id', $cartItemId)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||
$selectedItem = $this->resolveScopedItem(
|
||||
$item->catalog_item_id,
|
||||
$item->variant_id,
|
||||
true,
|
||||
);
|
||||
$inventoryService = app(CatalogInventoryService::class);
|
||||
$delta = $quantity - $item->cantidad;
|
||||
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
|
||||
|
||||
if ($delta > 0 && $variant->stock < $delta) {
|
||||
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
||||
$maxAvailable = $availableQuantity + $item->cantidad;
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => 'Stock insuficiente para actualizar la cantidad solicitada.',
|
||||
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -124,49 +154,116 @@ class Cart extends Model
|
||||
$item->save();
|
||||
|
||||
if ($delta > 0) {
|
||||
$variant->decrement('stock', $delta);
|
||||
$inventoryService->reserve($selectedItem, $delta);
|
||||
}
|
||||
|
||||
if ($delta < 0) {
|
||||
$variant->increment('stock', abs($delta));
|
||||
$inventoryService->release($selectedItem, abs($delta));
|
||||
}
|
||||
|
||||
return $item->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function removeItem(int $productVariantId): void
|
||||
public function removeItem(int $cartItemId): void
|
||||
{
|
||||
DB::transaction(function () use ($productVariantId): void {
|
||||
DB::transaction(function () use ($cartItemId): void {
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
->where('producto_variante_id', $productVariantId)
|
||||
->where('id', $cartItemId)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||
$variant->increment('stock', $item->cantidad);
|
||||
$selectedItem = $this->resolveScopedItem(
|
||||
$item->catalog_item_id,
|
||||
$item->variant_id,
|
||||
true,
|
||||
);
|
||||
app(CatalogInventoryService::class)->release(
|
||||
$selectedItem,
|
||||
$item->cantidad,
|
||||
);
|
||||
$item->delete();
|
||||
});
|
||||
}
|
||||
|
||||
protected function resolveScopedVariant(int $productVariantId, bool $lockForUpdate = false): ProductVariant
|
||||
protected function resolveScopedItem(
|
||||
int $catalogItemId,
|
||||
?int $variantId,
|
||||
bool $lockForUpdate = false,
|
||||
): CatalogItem|Variant {
|
||||
$catalogItemQuery = CatalogItem::query()
|
||||
->whereKey($catalogItemId)
|
||||
->where('tenant_code', $this->tenant_codigo);
|
||||
|
||||
if ($lockForUpdate) {
|
||||
$catalogItemQuery->lockForUpdate();
|
||||
}
|
||||
|
||||
$catalogItem = $catalogItemQuery->first();
|
||||
|
||||
if ($catalogItem === null) {
|
||||
throw new NotFoundHttpException('Catalog item not found for tenant.');
|
||||
}
|
||||
|
||||
if ($catalogItem->isBundle()) {
|
||||
if ($variantId !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => 'Un bundle no admite una variante.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $catalogItem->bundleComponents()->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'catalog_item_id' => 'El bundle no tiene componentes.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $catalogItem;
|
||||
}
|
||||
|
||||
if ($variantId === null) {
|
||||
if ($catalogItem->inventory_id === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => 'Debe seleccionar una variante para este ítem.',
|
||||
]);
|
||||
}
|
||||
|
||||
$inventory = $this->resolveInventory($catalogItem->inventory_id, $lockForUpdate);
|
||||
$catalogItem->setRelation('inventory', $inventory);
|
||||
|
||||
return $catalogItem;
|
||||
}
|
||||
|
||||
$variantQuery = Variant::query()
|
||||
->whereKey($variantId)
|
||||
->where('catalog_item_id', $catalogItem->id);
|
||||
|
||||
if ($lockForUpdate) {
|
||||
$variantQuery->lockForUpdate();
|
||||
}
|
||||
|
||||
$variant = $variantQuery->first();
|
||||
|
||||
if ($variant === null) {
|
||||
throw new NotFoundHttpException('Variant not found for catalog item.');
|
||||
}
|
||||
|
||||
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
|
||||
$variant->setRelation('catalogItem', $catalogItem);
|
||||
$variant->setRelation('inventory', $inventory);
|
||||
|
||||
return $variant;
|
||||
}
|
||||
|
||||
protected function resolveInventory(int $inventoryId, bool $lockForUpdate): Inventory
|
||||
{
|
||||
$query = ProductVariant::query()
|
||||
->whereKey($productVariantId)
|
||||
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo));
|
||||
$query = Inventory::query()->whereKey($inventoryId);
|
||||
|
||||
if ($lockForUpdate) {
|
||||
$query->lockForUpdate();
|
||||
}
|
||||
|
||||
/** @var ProductVariant|null $variant */
|
||||
$variant = $query->first();
|
||||
|
||||
if ($variant === null) {
|
||||
throw new NotFoundHttpException('Product variant not found for tenant.');
|
||||
}
|
||||
|
||||
return $variant;
|
||||
return $query->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
namespace App\Domains\Cart\Models;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -10,7 +11,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'cart_id',
|
||||
'producto_variante_id',
|
||||
'catalog_item_id',
|
||||
'variant_id',
|
||||
'cantidad',
|
||||
])]
|
||||
class CartItem extends Model
|
||||
@@ -23,7 +25,8 @@ class CartItem extends Model
|
||||
{
|
||||
return [
|
||||
'cart_id' => 'integer',
|
||||
'producto_variante_id' => 'integer',
|
||||
'catalog_item_id' => 'integer',
|
||||
'variant_id' => 'integer',
|
||||
'cantidad' => 'integer',
|
||||
];
|
||||
}
|
||||
@@ -36,11 +39,20 @@ class CartItem extends Model
|
||||
return $this->belongsTo(Cart::class, 'cart_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ProductVariant, $this>
|
||||
*/
|
||||
/** @return BelongsTo<CatalogItem, $this> */
|
||||
public function catalogItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CatalogItem::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Variant, $this> */
|
||||
public function variant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
|
||||
return $this->belongsTo(Variant::class);
|
||||
}
|
||||
|
||||
public function selectedItem(): CatalogItem|Variant|null
|
||||
{
|
||||
return $this->variant ?? $this->catalogItem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Cart\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AddCartItemRequest extends FormRequest
|
||||
{
|
||||
@@ -16,8 +17,24 @@ class AddCartItemRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$tenantCode = $this->route('tenant')?->codigo;
|
||||
|
||||
return [
|
||||
'product_variant_id' => ['required', 'integer'],
|
||||
'catalog_item_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('catalog_items', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_code', $tenantCode)
|
||||
),
|
||||
],
|
||||
'variant_id' => [
|
||||
'sometimes',
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('variantes', 'id')->where(
|
||||
fn ($query) => $query->where('catalog_item_id', $this->input('catalog_item_id'))
|
||||
),
|
||||
],
|
||||
'cantidad' => ['required', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ class UpdateCartItemQuantityRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'cantidad' => ['required', 'integer', 'min:1'],
|
||||
'catalog_item_id' => ['prohibited'],
|
||||
'variant_id' => ['prohibited'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
namespace App\Domains\Cart\Resources;
|
||||
|
||||
use App\Domains\Catalog\Resources\ProductVariantDefinitionResource;
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Cart\Models\CartItem
|
||||
* @mixin CartItem
|
||||
*/
|
||||
class CartItemResource extends JsonResource
|
||||
{
|
||||
@@ -16,24 +16,26 @@ class CartItemResource extends JsonResource
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$variant = $this->variant;
|
||||
$product = $variant?->product;
|
||||
$selectedItem = $this->selectedItem();
|
||||
$imageUrl = null;
|
||||
|
||||
if ($selectedItem?->relationLoaded('attachments')) {
|
||||
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);
|
||||
}
|
||||
|
||||
if ($imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
|
||||
$imageUrl = $this->catalogItem->attachments->first()?->getTemporaryUrl(1440);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'cantidad' => $this->cantidad,
|
||||
'precio_unitario' => $this->formatMoney($variant?->precio),
|
||||
'subtotal' => $this->formatMoney(($variant?->precio ?? 0) * $this->cantidad),
|
||||
'product' => $product === null ? null : [
|
||||
'id' => $product->id,
|
||||
'nombre' => $product->nombre,
|
||||
'slug' => $product->slug,
|
||||
],
|
||||
'variant' => $variant === null ? null : [
|
||||
'id' => $variant->id,
|
||||
'nombre' => $variant->nombre,
|
||||
'slug' => $variant->slug,
|
||||
'definitions' => ProductVariantDefinitionResource::collection($variant->definitions),
|
||||
'precio_unitario' => $this->formatMoney($selectedItem?->getPrice()),
|
||||
'catalog_item_id' => $this->catalog_item_id,
|
||||
'variant_id' => $this->variant_id,
|
||||
'product' => $selectedItem === null ? null : [
|
||||
'nombre' => $selectedItem->getName(),
|
||||
'imagen' => $imageUrl,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
namespace App\Domains\Cart\Resources;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Cart\Models\Cart
|
||||
* @mixin Cart
|
||||
*/
|
||||
class CartResource extends JsonResource
|
||||
{
|
||||
@@ -20,7 +21,8 @@ class CartResource extends JsonResource
|
||||
: collect();
|
||||
|
||||
$subtotal = $items->reduce(
|
||||
fn (float $carry, $item): float => $carry + ((float) ($item->variant?->precio ?? 0) * $item->cantidad),
|
||||
fn (float $carry, $item): float => $carry
|
||||
+ ((float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad),
|
||||
0.0,
|
||||
);
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
namespace App\Domains\Cart\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -14,13 +15,13 @@ class CartService
|
||||
{
|
||||
public function show(Tenant $tenant, Request $request): Cart
|
||||
{
|
||||
$identity = $this->resolveIdentity($request);
|
||||
$resolvedIdentity = $this->resolveIdentity($request);
|
||||
|
||||
if ($identity === null) {
|
||||
if ($resolvedIdentity === null) {
|
||||
return $this->makeEmptyCart($tenant);
|
||||
}
|
||||
|
||||
$cart = $this->findCart($tenant, $identity);
|
||||
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
|
||||
|
||||
if ($cart === null) {
|
||||
return $this->makeEmptyCart($tenant);
|
||||
@@ -32,12 +33,17 @@ class CartService
|
||||
/**
|
||||
* @return array{cart: Cart, guest_token: ?string}
|
||||
*/
|
||||
public function addItem(Tenant $tenant, Request $request, int $productVariantId, int $quantity): array
|
||||
{
|
||||
public function addItem(
|
||||
Tenant $tenant,
|
||||
Request $request,
|
||||
int $catalogItemId,
|
||||
?int $variantId,
|
||||
int $quantity,
|
||||
): array {
|
||||
$resolvedIdentity = $this->resolveIdentity($request, true);
|
||||
$identity = $resolvedIdentity['identity'];
|
||||
$cart = $this->findOrCreateCart($tenant, $identity);
|
||||
$cart->addItem($productVariantId, $quantity);
|
||||
$cart->addItem($catalogItemId, $variantId, $quantity);
|
||||
|
||||
return [
|
||||
'cart' => $this->loadCart($cart),
|
||||
@@ -45,20 +51,20 @@ class CartService
|
||||
];
|
||||
}
|
||||
|
||||
public function updateItemQuantity(Tenant $tenant, Request $request, int $productVariantId, int $quantity): Cart
|
||||
public function updateItemQuantity(Tenant $tenant, Request $request, int $cartItemId, int $quantity): Cart
|
||||
{
|
||||
$identity = $this->requireIdentity($request);
|
||||
$cart = $this->findCartOrFail($tenant, $identity);
|
||||
$cart->updateItem($productVariantId, $quantity);
|
||||
$cart->updateItem($cartItemId, $quantity);
|
||||
|
||||
return $this->loadCart($cart);
|
||||
}
|
||||
|
||||
public function removeItem(Tenant $tenant, Request $request, int $productVariantId): Cart
|
||||
public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
|
||||
{
|
||||
$identity = $this->requireIdentity($request);
|
||||
$cart = $this->findCartOrFail($tenant, $identity);
|
||||
$cart->removeItem($productVariantId);
|
||||
$cart->removeItem($cartItemId);
|
||||
|
||||
return $this->loadCart($cart);
|
||||
}
|
||||
@@ -93,8 +99,11 @@ class CartService
|
||||
protected function loadCart(Cart $cart): Cart
|
||||
{
|
||||
return $cart->fresh()->load([
|
||||
'items.variant.product',
|
||||
'items.variant.definitions.attribute',
|
||||
'items.catalogItem.attachments',
|
||||
'items.catalogItem.inventory',
|
||||
'items.variant.attachments',
|
||||
'items.variant.inventory',
|
||||
'items.variant.definitions.itemAttribute.attribute',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -103,7 +112,7 @@ class CartService
|
||||
*/
|
||||
protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array
|
||||
{
|
||||
$user = $request->user();
|
||||
$user = $request->user() ?? Auth::guard('sanctum')->user();
|
||||
|
||||
if ($user instanceof User) {
|
||||
return [
|
||||
@@ -200,5 +209,4 @@ class CartService
|
||||
|
||||
return Cart::query()->firstOrCreate($attributes, ['status' => 'active']);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
use App\Domains\Cart\Controllers\CartController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
||||
Route::get('cart', [CartController::class, 'show']);
|
||||
Route::post('cart/items', [CartController::class, 'addItem']);
|
||||
Route::patch('cart/items/{productVariant}', [CartController::class, 'updateItemQuantity']);
|
||||
Route::delete('cart/items/{productVariant}', [CartController::class, 'removeItem']);
|
||||
});
|
||||
Route::prefix('tenants/{tenant:codigo}')
|
||||
->middleware(\App\Domains\Cart\Middleware\MergeGuestCartMiddleware::class)
|
||||
->group(function (): void {
|
||||
Route::get('cart', [CartController::class, 'show']);
|
||||
Route::post('cart/items', [CartController::class, 'addItem']);
|
||||
Route::patch('cart/items/{cartItem}', [CartController::class, 'updateItemQuantity']);
|
||||
Route::delete('cart/items/{cartItem}', [CartController::class, 'removeItem']);
|
||||
});
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Models\Brand;
|
||||
use App\Domains\Catalog\Requests\StoreBrandRequest;
|
||||
use App\Domains\Catalog\Requests\UpdateBrandRequest;
|
||||
use App\Domains\Catalog\Resources\BrandResource;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class BrandController extends Controller
|
||||
{
|
||||
public function index(Tenant $tenant): JsonResponse
|
||||
{
|
||||
return BrandResource::collection(
|
||||
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->get()
|
||||
)->response();
|
||||
}
|
||||
|
||||
public function store(StoreBrandRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
$brand = Brand::query()->create([
|
||||
...$request->validated(),
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
|
||||
return BrandResource::make($brand)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant, Brand $marca): BrandResource
|
||||
{
|
||||
$marca = $this->resolveScopedBrand($tenant, $marca);
|
||||
|
||||
return BrandResource::make($marca);
|
||||
}
|
||||
|
||||
public function update(UpdateBrandRequest $request, Tenant $tenant, Brand $marca): BrandResource
|
||||
{
|
||||
$marca = $this->resolveScopedBrand($tenant, $marca);
|
||||
$marca->update($request->validated());
|
||||
|
||||
return BrandResource::make($marca);
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant, Brand $marca): Response
|
||||
{
|
||||
$marca = $this->resolveScopedBrand($tenant, $marca);
|
||||
$marca->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
protected function resolveScopedBrand(Tenant $tenant, Brand $brand): Brand
|
||||
{
|
||||
if ($brand->tenant_codigo !== $tenant->codigo) {
|
||||
throw new NotFoundHttpException('Brand not found for tenant.');
|
||||
}
|
||||
|
||||
return $brand;
|
||||
}
|
||||
}
|
||||
123
app/Domains/Catalog/Controllers/CatalogController.php
Normal file
123
app/Domains/Catalog/Controllers/CatalogController.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
||||
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
||||
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
|
||||
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
|
||||
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
|
||||
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
|
||||
use App\Domains\Catalog\Resources\CatalogItemResource;
|
||||
use App\Domains\Catalog\Services\CatalogService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class CatalogController extends Controller
|
||||
{
|
||||
private const ITEMS_PER_PAGE = 12;
|
||||
|
||||
public function index(Tenant $tenant): JsonResponse
|
||||
{
|
||||
$featuredGroups = FeaturedGroup::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->orderBy('group_order')
|
||||
->get();
|
||||
|
||||
return response()->json($featuredGroups->map(
|
||||
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
|
||||
$featuredGroup,
|
||||
$this->featuredItemsResponse($featuredGroup, 1),
|
||||
))->resolve()
|
||||
));
|
||||
}
|
||||
|
||||
public function featuredGroupItems(
|
||||
FeaturedGroupPageRequest $request,
|
||||
Tenant $tenant,
|
||||
FeaturedGroup $featuredGroup,
|
||||
): JsonResponse {
|
||||
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
|
||||
|
||||
$page = (int) $request->validated('page', 1);
|
||||
|
||||
return response()->json($this->featuredItemsResponse($featuredGroup, $page));
|
||||
}
|
||||
|
||||
public function show(
|
||||
CatalogItemDetailRequest $request,
|
||||
Tenant $tenant,
|
||||
CatalogItem $catalogItem,
|
||||
CatalogService $catalogService,
|
||||
): CatalogItemDetailResource {
|
||||
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
|
||||
|
||||
$variantId = $request->validated('variant_id');
|
||||
|
||||
return CatalogItemDetailResource::make(
|
||||
$catalogService->getDetail(
|
||||
$catalogItem,
|
||||
$variantId === null ? null : (int) $variantId,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function featuredItemsResponse(FeaturedGroup $featuredGroup, int $page): array
|
||||
{
|
||||
$paginator = $this->paginateFeaturedItems($featuredGroup, $page);
|
||||
|
||||
$paginator->getCollection()->each(
|
||||
fn ($featuredItem) => $featuredItem->setRelation('featuredGroup', $featuredGroup)
|
||||
);
|
||||
|
||||
return CatalogFeaturedItemResource::collection($paginator)
|
||||
->response()
|
||||
->getData(true);
|
||||
}
|
||||
|
||||
private function paginateFeaturedItems(
|
||||
FeaturedGroup $featuredGroup,
|
||||
int $page,
|
||||
): LengthAwarePaginator {
|
||||
$paginator = $featuredGroup->featuredItems()
|
||||
->with([
|
||||
'catalogItem.inventory',
|
||||
'catalogItem.attachments',
|
||||
'catalogItem.variants.inventory',
|
||||
'catalogItem.variants.attachments',
|
||||
'catalogItem.variants.definitions.itemAttribute.attribute',
|
||||
'catalogItem.bundleComponents.catalogItem',
|
||||
'catalogItem.bundleComponents.variant.catalogItem',
|
||||
])
|
||||
->paginate(
|
||||
perPage: self::ITEMS_PER_PAGE,
|
||||
pageName: 'page',
|
||||
page: $page,
|
||||
);
|
||||
|
||||
return $paginator->withPath(route('catalog.featured-groups.items.index', [
|
||||
'tenant' => $featuredGroup->tenant_code,
|
||||
'featuredGroup' => $featuredGroup->id,
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Requests\StoreCategoryRequest;
|
||||
use App\Domains\Catalog\Requests\UpdateCategoryRequest;
|
||||
use App\Domains\Catalog\Resources\CategoryResource;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index(Tenant $tenant): JsonResponse
|
||||
{
|
||||
return CategoryResource::collection(
|
||||
Category::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->with(['parent', 'subCategories', 'tenant'])
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
)->response();
|
||||
}
|
||||
|
||||
public function store(StoreCategoryRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
$category = Category::query()->create([
|
||||
...$request->validated(),
|
||||
'tenant_code' => $tenant->codigo,
|
||||
]);
|
||||
|
||||
return CategoryResource::make($category->load(['parent', 'subCategories', 'tenant']))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant, Category $categoria): CategoryResource
|
||||
{
|
||||
$categoria = $this->resolveScopedCategory($tenant, $categoria);
|
||||
|
||||
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
|
||||
}
|
||||
|
||||
public function update(UpdateCategoryRequest $request, Tenant $tenant, Category $categoria): CategoryResource
|
||||
{
|
||||
$categoria = $this->resolveScopedCategory($tenant, $categoria);
|
||||
$categoria->update($request->validated());
|
||||
|
||||
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant, Category $categoria): Response
|
||||
{
|
||||
$categoria = $this->resolveScopedCategory($tenant, $categoria);
|
||||
$categoria->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
protected function resolveScopedCategory(Tenant $tenant, Category $category): Category
|
||||
{
|
||||
if ($category->tenant_code !== $tenant->codigo) {
|
||||
throw new NotFoundHttpException('Category not found for tenant.');
|
||||
}
|
||||
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductAttribute;
|
||||
use App\Domains\Catalog\Requests\StoreProductAttributeRequest;
|
||||
use App\Domains\Catalog\Requests\UpdateProductAttributeRequest;
|
||||
use App\Domains\Catalog\Resources\ProductAttributeResource;
|
||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class ProductAttributeController extends Controller
|
||||
{
|
||||
public function index(Tenant $tenant): JsonResponse
|
||||
{
|
||||
$query = ProductAttribute::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->with('options')
|
||||
->latest();
|
||||
|
||||
return ProductAttributeResource::collection($query->get())->response();
|
||||
}
|
||||
|
||||
public function store(StoreProductAttributeRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
$attribute = DB::transaction(function () use ($request, $tenant): ProductAttribute {
|
||||
$validated = $request->validated();
|
||||
$options = $validated['options'] ?? [];
|
||||
unset($validated['options']);
|
||||
|
||||
$type = ProductAttributeType::from((string) $validated['type']);
|
||||
if (! $type->supportsOptions()) {
|
||||
$validated['metadata_schema'] = null;
|
||||
$options = [];
|
||||
}
|
||||
|
||||
/** @var ProductAttribute $attribute */
|
||||
$attribute = ProductAttribute::query()->create([
|
||||
...$validated,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
$attribute->options()->createMany($options);
|
||||
|
||||
return $attribute->load('options');
|
||||
});
|
||||
|
||||
return ProductAttributeResource::make($attribute)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant, ProductAttribute $productAttribute): ProductAttributeResource
|
||||
{
|
||||
$productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute);
|
||||
|
||||
return ProductAttributeResource::make($productAttribute->load('options'));
|
||||
}
|
||||
|
||||
public function update(UpdateProductAttributeRequest $request, Tenant $tenant, ProductAttribute $productAttribute): ProductAttributeResource
|
||||
{
|
||||
$productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute);
|
||||
|
||||
$productAttribute = DB::transaction(function () use ($request, $productAttribute): ProductAttribute {
|
||||
$validated = $request->validated();
|
||||
$options = $validated['options'] ?? [];
|
||||
unset($validated['options']);
|
||||
|
||||
$type = ProductAttributeType::from((string) $validated['type']);
|
||||
if (! $type->supportsOptions()) {
|
||||
$validated['metadata_schema'] = null;
|
||||
$options = [];
|
||||
}
|
||||
|
||||
$productAttribute->update($validated);
|
||||
$productAttribute->options()->delete();
|
||||
$productAttribute->options()->createMany($options);
|
||||
|
||||
return $productAttribute->load('options');
|
||||
});
|
||||
|
||||
return ProductAttributeResource::make($productAttribute);
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant, ProductAttribute $productAttribute): Response
|
||||
{
|
||||
$productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute);
|
||||
$productAttribute->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
protected function resolveScopedAttribute(Tenant $tenant, ProductAttribute $attribute): ProductAttribute
|
||||
{
|
||||
if ($attribute->tenant_codigo !== $tenant->codigo) {
|
||||
throw new NotFoundHttpException('Product attribute not found for tenant.');
|
||||
}
|
||||
|
||||
return $attribute;
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use App\Domains\Catalog\Requests\StoreProductRequest;
|
||||
use App\Domains\Catalog\Requests\UpdateProductRequest;
|
||||
use App\Domains\Catalog\Resources\ProductResource;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function index(Tenant $tenant): JsonResponse
|
||||
{
|
||||
return ProductResource::collection(
|
||||
Product::query()->where('tenant_codigo', $tenant->codigo)->latest()->get()
|
||||
)->response();
|
||||
}
|
||||
|
||||
public function store(StoreProductRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
$product = Product::query()->create([
|
||||
...$request->validated(),
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
|
||||
return ProductResource::make($product)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant, Product $producto): ProductResource
|
||||
{
|
||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||
|
||||
return ProductResource::make($producto);
|
||||
}
|
||||
|
||||
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto): ProductResource
|
||||
{
|
||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||
$producto->update($request->validated());
|
||||
|
||||
return ProductResource::make($producto);
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant, Product $producto): Response
|
||||
{
|
||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||
$producto->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
protected function resolveScopedProduct(Tenant $tenant, Product $product): Product
|
||||
{
|
||||
if ($product->tenant_codigo !== $tenant->codigo) {
|
||||
throw new NotFoundHttpException('Product not found for tenant.');
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Catalog\Requests\StoreProductVariantRequest;
|
||||
use App\Domains\Catalog\Requests\UpdateProductVariantRequest;
|
||||
use App\Domains\Catalog\Resources\ProductVariantResource;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class ProductVariantController extends Controller
|
||||
{
|
||||
public function index(Tenant $tenant): JsonResponse
|
||||
{
|
||||
$query = ProductVariant::query()
|
||||
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo))
|
||||
->with(['product', 'definitions.attribute'])
|
||||
->latest();
|
||||
|
||||
return ProductVariantResource::collection($query->get())->response();
|
||||
}
|
||||
|
||||
public function store(StoreProductVariantRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$variant = DB::transaction(function () use ($request, $tenant): ProductVariant {
|
||||
|
||||
$definitions = $validated['definitions'] ?? [];
|
||||
unset($validated['definitions']);
|
||||
|
||||
$product = $this->resolveTenantProduct($tenant, (int) $validated['producto_id']);
|
||||
|
||||
/** @var ProductVariant $variant */
|
||||
$variant = $product->variants()->create($validated);
|
||||
$variant->definitions()->createMany($definitions);
|
||||
|
||||
return $variant->load(['product', 'definitions.attribute']);
|
||||
});
|
||||
|
||||
return ProductVariantResource::make($variant)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant, ProductVariant $productVariant): ProductVariantResource
|
||||
{
|
||||
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
|
||||
|
||||
return ProductVariantResource::make($productVariant->load(['product', 'definitions.attribute']));
|
||||
}
|
||||
|
||||
public function update(UpdateProductVariantRequest $request, Tenant $tenant, ProductVariant $productVariant): ProductVariantResource
|
||||
{
|
||||
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
|
||||
|
||||
$productVariant = DB::transaction(function () use ($request, $tenant, $productVariant): ProductVariant {
|
||||
$validated = $request->validated();
|
||||
$definitions = $validated['definitions'] ?? [];
|
||||
unset($validated['definitions']);
|
||||
|
||||
$this->resolveTenantProduct($tenant, (int) $validated['producto_id']);
|
||||
|
||||
$productVariant->update($validated);
|
||||
$productVariant->definitions()->delete();
|
||||
$productVariant->definitions()->createMany($definitions);
|
||||
|
||||
return $productVariant->load(['product', 'definitions.attribute']);
|
||||
});
|
||||
|
||||
return ProductVariantResource::make($productVariant);
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant, ProductVariant $productVariant): Response
|
||||
{
|
||||
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
|
||||
$productVariant->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
protected function resolveScopedVariant(Tenant $tenant, ProductVariant $variant): ProductVariant
|
||||
{
|
||||
$variant->loadMissing('product');
|
||||
|
||||
if ($variant->product === null || $variant->product->tenant_codigo !== $tenant->codigo) {
|
||||
throw new NotFoundHttpException('Product variant not found for tenant.');
|
||||
}
|
||||
|
||||
return $variant;
|
||||
}
|
||||
|
||||
protected function resolveTenantProduct(Tenant $tenant, int $productId): Product
|
||||
{
|
||||
$product = Product::query()
|
||||
->whereKey($productId)
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->first();
|
||||
|
||||
if ($product === null) {
|
||||
throw new NotFoundHttpException('Product not found for tenant.');
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
}
|
||||
15
app/Domains/Catalog/Enums/CatalogItemType.php
Normal file
15
app/Domains/Catalog/Enums/CatalogItemType.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Enums;
|
||||
|
||||
enum CatalogItemType: string
|
||||
{
|
||||
case Standard = 'standard';
|
||||
case Bundle = 'bundle';
|
||||
|
||||
/** @return array<int, string> */
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Support;
|
||||
namespace App\Domains\Catalog\Enums;
|
||||
|
||||
enum TenantPropDataType: string
|
||||
enum InventoryPolicy: string
|
||||
{
|
||||
case String = 'string';
|
||||
case Number = 'number';
|
||||
case Boolean = 'boolean';
|
||||
case Tracked = 'tracked';
|
||||
case Unlimited = 'unlimited';
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
18
app/Domains/Catalog/Enums/ProductLayout.php
Normal file
18
app/Domains/Catalog/Enums/ProductLayout.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Enums;
|
||||
|
||||
enum ProductLayout: string
|
||||
{
|
||||
case Row = 'row';
|
||||
case ColumnWithImage = 'column_with_image';
|
||||
case ColumnWithCart = 'column_with_cart';
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
||||
use App\Domains\Shared\Enums\FieldType;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -18,11 +18,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
'metadata_schema',
|
||||
'type',
|
||||
])]
|
||||
class ProductAttribute extends Model
|
||||
class Attribute extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'productos_attributes';
|
||||
protected $table = 'attribute';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
@@ -32,7 +32,7 @@ class ProductAttribute extends Model
|
||||
return [
|
||||
'is_required' => 'boolean',
|
||||
'metadata_schema' => 'array',
|
||||
'type' => ProductAttributeType::class,
|
||||
'type' => FieldType::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -45,18 +45,10 @@ class ProductAttribute extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProductAttributeOption, $this>
|
||||
* @return HasMany<AttributeOption, $this>
|
||||
*/
|
||||
public function options(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductAttributeOption::class, 'attribute_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProductVariantDefinition, $this>
|
||||
*/
|
||||
public function variantDefinitions(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductVariantDefinition::class, 'attribute_id');
|
||||
return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order');
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'attribute_id',
|
||||
'value',
|
||||
'label',
|
||||
'sort_order',
|
||||
'metadata',
|
||||
])]
|
||||
class ProductAttributeOption extends Model
|
||||
class AttributeOption extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
@@ -31,10 +32,10 @@ class ProductAttributeOption extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ProductAttribute, $this>
|
||||
* @return BelongsTo<Attribute, $this>
|
||||
*/
|
||||
public function attribute(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
|
||||
return $this->belongsTo(Attribute::class, 'attribute_id');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_codigo',
|
||||
@@ -26,4 +27,10 @@ class Brand extends Model
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/** @return HasMany<CatalogItem, $this> */
|
||||
public function catalogItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(CatalogItem::class);
|
||||
}
|
||||
}
|
||||
|
||||
49
app/Domains/Catalog/Models/BundleComponent.php
Normal file
49
app/Domains/Catalog/Models/BundleComponent.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'bundle_catalog_item_id',
|
||||
'component_catalog_item_id',
|
||||
'component_variant_id',
|
||||
'quantity',
|
||||
])]
|
||||
class BundleComponent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'bundle_catalog_item_id' => 'integer',
|
||||
'component_catalog_item_id' => 'integer',
|
||||
'component_variant_id' => 'integer',
|
||||
'quantity' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<CatalogItem, $this> */
|
||||
public function bundle(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CatalogItem::class, 'bundle_catalog_item_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<CatalogItem, $this> */
|
||||
public function catalogItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CatalogItem::class, 'component_catalog_item_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Variant, $this> */
|
||||
public function variant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Variant::class, 'component_variant_id');
|
||||
}
|
||||
}
|
||||
170
app/Domains/Catalog/Models/CatalogItem.php
Normal file
170
app/Domains/Catalog/Models/CatalogItem.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_code',
|
||||
'category_id',
|
||||
'brand_id',
|
||||
'inventory_id',
|
||||
'type',
|
||||
'slug',
|
||||
'nombre',
|
||||
'descripcion',
|
||||
'precio',
|
||||
'inventory_policy',
|
||||
'has_tickets',
|
||||
'maximum_use_date',
|
||||
'minimum_use_date',
|
||||
])]
|
||||
class CatalogItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'catalog_items';
|
||||
|
||||
protected $attributes = [
|
||||
'type' => CatalogItemType::Standard->value,
|
||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||
'has_tickets' => false,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => 'integer',
|
||||
'brand_id' => 'integer',
|
||||
'inventory_id' => 'integer',
|
||||
'type' => CatalogItemType::class,
|
||||
'precio' => 'decimal:2',
|
||||
'inventory_policy' => InventoryPolicy::class,
|
||||
'has_tickets' => 'boolean',
|
||||
'maximum_use_date' => 'datetime',
|
||||
'minimum_use_date' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Category, $this> */
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Brand, $this> */
|
||||
public function brand(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Inventory, $this> */
|
||||
public function inventory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<BundleComponent, $this> */
|
||||
public function bundleComponents(): HasMany
|
||||
{
|
||||
return $this->hasMany(BundleComponent::class, 'bundle_catalog_item_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<BundleComponent, $this> */
|
||||
public function bundleComponentUsages(): HasMany
|
||||
{
|
||||
return $this->hasMany(BundleComponent::class, 'component_catalog_item_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<Variant, $this> */
|
||||
public function variants(): HasMany
|
||||
{
|
||||
return $this->hasMany(Variant::class);
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<Attribute, $this> */
|
||||
public function attributes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Attribute::class, 'item_attributes')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/** @return HasMany<ItemAttribute, $this> */
|
||||
public function itemAttributes(): HasMany
|
||||
{
|
||||
return $this->hasMany(ItemAttribute::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<FeaturedItem, $this> */
|
||||
public function featuredItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(FeaturedItem::class);
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<Attachment, $this> */
|
||||
public function attachments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Attachment::class,
|
||||
'catalog_items_attachments',
|
||||
'catalog_item_id',
|
||||
'attachment_id'
|
||||
)
|
||||
->withPivot('orden')
|
||||
->wherePivotNull('variant_id')
|
||||
->orderByPivot('orden');
|
||||
}
|
||||
|
||||
public function availableStock(): ?int
|
||||
{
|
||||
return app(CatalogInventoryService::class)->availableQuantity($this);
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
if ($this->type === CatalogItemType::Bundle) {
|
||||
$availableStock = $this->availableStock();
|
||||
|
||||
return $availableStock === null || $availableStock > 0;
|
||||
}
|
||||
|
||||
if ($this->inventory_policy === InventoryPolicy::Unlimited) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ($this->availableStock() ?? 0) > 0;
|
||||
}
|
||||
|
||||
public function getPrice(): float
|
||||
{
|
||||
return (float) $this->precio;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->nombre;
|
||||
}
|
||||
|
||||
public function isBundle(): bool
|
||||
{
|
||||
return $this->type === CatalogItemType::Bundle;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ class Category extends Model
|
||||
|
||||
protected $table = 'categorias';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
@@ -55,4 +58,10 @@ class Category extends Model
|
||||
{
|
||||
return $this->hasMany(self::class, 'categoria_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<CatalogItem, $this> */
|
||||
public function catalogItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(CatalogItem::class);
|
||||
}
|
||||
}
|
||||
|
||||
46
app/Domains/Catalog/Models/FeaturedGroup.php
Normal file
46
app/Domains/Catalog/Models/FeaturedGroup.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_code',
|
||||
'product_layout',
|
||||
'group_name',
|
||||
'group_order',
|
||||
])]
|
||||
class FeaturedGroup extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'featured_groups';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'product_layout' => ProductLayout::class,
|
||||
'group_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
/** @return HasMany<FeaturedItem, $this> */
|
||||
public function featuredItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(FeaturedItem::class)->orderBy('order');
|
||||
}
|
||||
}
|
||||
43
app/Domains/Catalog/Models/FeaturedItem.php
Normal file
43
app/Domains/Catalog/Models/FeaturedItem.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'featured_group_id',
|
||||
'catalog_item_id',
|
||||
'order',
|
||||
])]
|
||||
class FeaturedItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'featured_items';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'featured_group_id' => 'integer',
|
||||
'catalog_item_id' => 'integer',
|
||||
'order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<FeaturedGroup, $this> */
|
||||
public function featuredGroup(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FeaturedGroup::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<CatalogItem, $this> */
|
||||
public function catalogItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CatalogItem::class);
|
||||
}
|
||||
}
|
||||
97
app/Domains/Catalog/Models/Inventory.php
Normal file
97
app/Domains/Catalog/Models/Inventory.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
#[Fillable([
|
||||
'sold_units',
|
||||
'reserved_stock',
|
||||
'real_stock',
|
||||
])]
|
||||
class Inventory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'inventories';
|
||||
|
||||
protected $attributes = [
|
||||
'sold_units' => 0,
|
||||
'reserved_stock' => 0,
|
||||
'real_stock' => 0,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'sold_units' => 'integer',
|
||||
'reserved_stock' => 'integer',
|
||||
'real_stock' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return HasOne<CatalogItem, $this> */
|
||||
public function catalogItem(): HasOne
|
||||
{
|
||||
return $this->hasOne(CatalogItem::class);
|
||||
}
|
||||
|
||||
/** @return HasOne<Variant, $this> */
|
||||
public function variant(): HasOne
|
||||
{
|
||||
return $this->hasOne(Variant::class);
|
||||
}
|
||||
|
||||
public function availableStock(): int
|
||||
{
|
||||
return max(0, $this->real_stock - $this->reserved_stock);
|
||||
}
|
||||
|
||||
public function reserve(int $amount, bool $tracksInventory): void
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('La cantidad a reservar debe ser positiva.');
|
||||
}
|
||||
|
||||
if ($tracksInventory && $this->availableStock() < $amount) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
|
||||
}
|
||||
|
||||
$this->reserved_stock += $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function release(int $amount): void
|
||||
{
|
||||
if ($amount < 0 || $this->reserved_stock < $amount) {
|
||||
throw new \InvalidArgumentException('La cantidad reservada no es válida.');
|
||||
}
|
||||
|
||||
$this->reserved_stock -= $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function buy(int $amount, bool $tracksInventory): void
|
||||
{
|
||||
if ($amount < 0 || $this->reserved_stock < $amount) {
|
||||
throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.');
|
||||
}
|
||||
|
||||
if ($tracksInventory && $this->real_stock < $amount) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
|
||||
}
|
||||
|
||||
if ($tracksInventory) {
|
||||
$this->real_stock -= $amount;
|
||||
}
|
||||
|
||||
$this->reserved_stock -= $amount;
|
||||
$this->sold_units += $amount;
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
38
app/Domains/Catalog/Models/ItemAttribute.php
Normal file
38
app/Domains/Catalog/Models/ItemAttribute.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'catalog_item_id',
|
||||
'attribute_id',
|
||||
])]
|
||||
class ItemAttribute extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'item_attributes';
|
||||
|
||||
/** @return BelongsTo<CatalogItem, $this> */
|
||||
public function catalogItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CatalogItem::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Attribute, $this> */
|
||||
public function attribute(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Attribute::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<VariantDefinition, $this> */
|
||||
public function variantDefinitions(): HasMany
|
||||
{
|
||||
return $this->hasMany(VariantDefinition::class);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_codigo',
|
||||
'categoria_id',
|
||||
'slug',
|
||||
'nombre',
|
||||
'descripcion',
|
||||
'precio',
|
||||
])]
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'productos';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'categoria_id' => 'integer',
|
||||
'precio' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProductVariant, $this>
|
||||
*/
|
||||
public function variants(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductVariant::class, 'producto_id');
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'producto_id',
|
||||
'slug',
|
||||
'nombre',
|
||||
'stock',
|
||||
'descripcion',
|
||||
'precio',
|
||||
])]
|
||||
class ProductVariant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'productos_variantes';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'producto_id' => 'integer',
|
||||
'stock' => 'integer',
|
||||
'precio' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Product, $this>
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class, 'producto_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProductVariantDefinition, $this>
|
||||
*/
|
||||
public function definitions(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductVariantDefinition::class, 'producto_variante_id');
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'producto_variante_id',
|
||||
'attribute_id',
|
||||
'value',
|
||||
])]
|
||||
class ProductVariantDefinition extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'productos_variantes_definiciones';
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ProductVariant, $this>
|
||||
*/
|
||||
public function variant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ProductAttribute, $this>
|
||||
*/
|
||||
public function attribute(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
|
||||
}
|
||||
}
|
||||
92
app/Domains/Catalog/Models/Variant.php
Normal file
92
app/Domains/Catalog/Models/Variant.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'catalog_item_id',
|
||||
'inventory_id',
|
||||
])]
|
||||
class Variant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'variantes';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'catalog_item_id' => 'integer',
|
||||
'inventory_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<CatalogItem, $this> */
|
||||
public function catalogItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CatalogItem::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Inventory, $this> */
|
||||
public function inventory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<VariantDefinition, $this> */
|
||||
public function definitions(): HasMany
|
||||
{
|
||||
return $this->hasMany(VariantDefinition::class);
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<Attachment, $this> */
|
||||
public function attachments(): BelongsToMany
|
||||
{
|
||||
$relation = $this->belongsToMany(
|
||||
Attachment::class,
|
||||
'catalog_items_attachments',
|
||||
'variant_id',
|
||||
'attachment_id'
|
||||
)
|
||||
->withPivot('orden')
|
||||
->orderByPivot('orden');
|
||||
|
||||
if ($this->catalog_item_id !== null) {
|
||||
$relation->withPivotValue('catalog_item_id', $this->catalog_item_id);
|
||||
}
|
||||
|
||||
return $relation;
|
||||
}
|
||||
|
||||
public function getPrice(): float
|
||||
{
|
||||
return $this->catalogItem->getPrice();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
$name = $this->catalogItem->nombre;
|
||||
$this->loadMissing('definitions.itemAttribute.attribute');
|
||||
$definitions = $this->definitions
|
||||
->map(function (VariantDefinition $definition): ?string {
|
||||
$attributeName = $definition->itemAttribute?->attribute?->nombre;
|
||||
|
||||
return $attributeName
|
||||
? "{$attributeName}: {$definition->value}"
|
||||
: $definition->value;
|
||||
})
|
||||
->filter()
|
||||
->implode(', ');
|
||||
|
||||
return $definitions === '' ? $name : "{$name} ({$definitions})";
|
||||
}
|
||||
}
|
||||
32
app/Domains/Catalog/Models/VariantDefinition.php
Normal file
32
app/Domains/Catalog/Models/VariantDefinition.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'variant_id',
|
||||
'item_attribute_id',
|
||||
'value',
|
||||
])]
|
||||
class VariantDefinition extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'variant_values';
|
||||
|
||||
/** @return BelongsTo<Variant, $this> */
|
||||
public function variant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Variant::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<ItemAttribute, $this> */
|
||||
public function itemAttribute(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ItemAttribute::class);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -4,21 +4,18 @@ namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreBrandRequest extends FormRequest
|
||||
class CatalogItemDetailRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
/** @return array<string, list<string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'descripcion' => ['nullable', 'string'],
|
||||
'variant_id' => ['sometimes', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,18 @@ namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreCategoryRequest extends FormRequest
|
||||
class FeaturedGroupPageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
/** @return array<string, list<string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'categoria_id' => ['nullable', 'integer', 'exists:categorias,id'],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'page' => ['sometimes', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
105
app/Domains/Catalog/Requests/StoreCatalogItemRequest.php
Normal file
105
app/Domains/Catalog/Requests/StoreCatalogItemRequest.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
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'],
|
||||
'descripcion' => ['sometimes', 'nullable', 'string'],
|
||||
'precio' => ['required', 'numeric', 'min:0'],
|
||||
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
|
||||
'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'],
|
||||
'minimum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date'],
|
||||
'maximum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date', 'after_or_equal:minimum_use_date'],
|
||||
'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)
|
||||
),
|
||||
],
|
||||
'images' => ['sometimes', 'array'],
|
||||
'images.*' => ['required', new ImageOrBase64Rule],
|
||||
'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
|
||||
'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'],
|
||||
'variants.*.inventory_id' => ['prohibited'],
|
||||
'variants.*.reserved_stock' => ['prohibited'],
|
||||
'variants.*.sold_units' => ['prohibited'],
|
||||
'variants.*.values' => ['sometimes', 'array'],
|
||||
'variants.*.values.*' => ['nullable', '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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class StoreProductAttributeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'codigo' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('productos_attributes', 'codigo')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'is_required' => ['sometimes', 'boolean'],
|
||||
'metadata_schema' => ['nullable', 'array'],
|
||||
'type' => ['required', Rule::enum(ProductAttributeType::class)],
|
||||
'options' => [
|
||||
Rule::requiredIf(fn (): bool => in_array($this->input('type'), [
|
||||
ProductAttributeType::Select->value,
|
||||
ProductAttributeType::Multiselect->value,
|
||||
], true)),
|
||||
Rule::prohibitedIf(fn (): bool => ! in_array($this->input('type'), [
|
||||
ProductAttributeType::Select->value,
|
||||
ProductAttributeType::Multiselect->value,
|
||||
], true)),
|
||||
'array',
|
||||
],
|
||||
'options.*.label' => ['required', 'string', 'max:255'],
|
||||
'options.*.sort_order' => ['sometimes', 'integer'],
|
||||
'options.*.metadata' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
$type = $this->input('type');
|
||||
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::Multiselect->value], true);
|
||||
|
||||
if (! $supportsOptions && $this->filled('metadata_schema')) {
|
||||
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
|
||||
}
|
||||
|
||||
if (! $supportsOptions && $this->filled('options')) {
|
||||
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreProductRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'categoria_id' => ['required', 'integer'],
|
||||
'slug' => ['required', 'string', 'max:255', Rule::unique('productos', 'slug')],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'descripcion' => ['nullable', 'string'],
|
||||
'precio' => ['required', 'numeric', 'min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreProductVariantRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'producto_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('productos', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'slug' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('productos_variantes', 'slug')->where(
|
||||
fn ($query) => $query->where('producto_id', $this->input('producto_id'))
|
||||
),
|
||||
],
|
||||
'nombre' => ['nullable', 'string', 'max:255'],
|
||||
'stock' => ['sometimes', 'integer', 'min:0'],
|
||||
'descripcion' => ['nullable', 'string'],
|
||||
'precio' => ['required', 'numeric', 'min:0'],
|
||||
'definitions' => ['sometimes', 'array'],
|
||||
'definitions.*.attribute_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
'distinct',
|
||||
Rule::exists('productos_attributes', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'definitions.*.value' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateCategoryRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var Category|null $category */
|
||||
$category = $this->route('categoria');
|
||||
|
||||
return [
|
||||
'categoria_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'exists:categorias,id',
|
||||
Rule::notIn([$category?->id]),
|
||||
],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductAttribute;
|
||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class UpdateProductAttributeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var ProductAttribute|null $attribute */
|
||||
$attribute = $this->route('productAttribute');
|
||||
|
||||
return [
|
||||
'codigo' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('productos_attributes', 'codigo')
|
||||
->ignore($attribute?->id)
|
||||
->where(fn ($query) => $query->where('tenant_codigo', $attribute?->tenant_codigo)),
|
||||
],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'is_required' => ['sometimes', 'boolean'],
|
||||
'metadata_schema' => ['nullable', 'array'],
|
||||
'type' => ['required', Rule::enum(ProductAttributeType::class)],
|
||||
'options' => ['sometimes', 'array'],
|
||||
'options.*.label' => ['required', 'string', 'max:255'],
|
||||
'options.*.sort_order' => ['sometimes', 'integer'],
|
||||
'options.*.metadata' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
$type = $this->input('type');
|
||||
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::Multiselect->value], true);
|
||||
|
||||
if (! $supportsOptions && $this->filled('metadata_schema')) {
|
||||
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
|
||||
}
|
||||
|
||||
if (! $supportsOptions && $this->filled('options')) {
|
||||
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateProductRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var Product|null $product */
|
||||
$product = $this->route('producto');
|
||||
|
||||
return [
|
||||
'categoria_id' => ['required', 'integer'],
|
||||
'slug' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('productos', 'slug')->ignore($product?->id),
|
||||
],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'descripcion' => ['nullable', 'string'],
|
||||
'precio' => ['required', 'numeric', 'min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateProductVariantRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var ProductVariant|null $productVariant */
|
||||
$productVariant = $this->route('productVariant');
|
||||
|
||||
return [
|
||||
'producto_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('productos', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'slug' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('productos_variantes', 'slug')
|
||||
->ignore($productVariant?->id)
|
||||
->where(fn ($query) => $query->where('producto_id', $this->input('producto_id'))),
|
||||
],
|
||||
'nombre' => ['nullable', 'string', 'max:255'],
|
||||
'stock' => ['sometimes', 'integer', 'min:0'],
|
||||
'descripcion' => ['nullable', 'string'],
|
||||
'precio' => ['required', 'numeric', 'min:0'],
|
||||
'definitions' => ['sometimes', 'array'],
|
||||
'definitions.*.attribute_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
'distinct',
|
||||
Rule::exists('productos_attributes', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'definitions.*.value' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Catalog\Models\Brand
|
||||
*/
|
||||
class BrandResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'tenant_codigo' => $this->tenant_codigo,
|
||||
'nombre' => $this->nombre,
|
||||
'descripcion' => $this->descripcion,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin FeaturedGroup */
|
||||
class CatalogFeaturedGroupResource extends JsonResource
|
||||
{
|
||||
/** @param array<string, mixed> $itemsPage */
|
||||
public function __construct($resource, private readonly array $itemsPage)
|
||||
{
|
||||
parent::__construct($resource);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->group_name,
|
||||
'layout' => $this->product_layout->value,
|
||||
'group_order' => $this->group_order,
|
||||
'items' => $this->itemsPage,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\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\FeaturedItem;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin FeaturedItem */
|
||||
class CatalogFeaturedItemResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$catalogItem = $this->catalogItem;
|
||||
|
||||
if ($this->featuredGroup->product_layout === ProductLayout::ColumnWithImage) {
|
||||
return $this->columnWithImageData($catalogItem);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $catalogItem->id,
|
||||
'type' => $catalogItem->type->value,
|
||||
'nombre' => $catalogItem->nombre,
|
||||
'descripcion' => $catalogItem->descripcion,
|
||||
'precio' => $catalogItem->precio,
|
||||
'stock_tecnico' => $catalogItem->availableStock(),
|
||||
'variants' => $catalogItem->variants
|
||||
->map(fn (Variant $variant): array => [
|
||||
'id' => $variant->id,
|
||||
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
: $variant->inventory->availableStock(),
|
||||
'values' => $variant->definitions
|
||||
->mapWithKeys(fn ($definition) => [
|
||||
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
||||
])
|
||||
->filter(fn ($value, $key): bool => $key !== null),
|
||||
])
|
||||
->values(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function columnWithImageData(CatalogItem $catalogItem): array
|
||||
{
|
||||
$attachment = $catalogItem->attachments->first()
|
||||
?? $catalogItem->variants
|
||||
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||
->first();
|
||||
|
||||
return [
|
||||
'id' => $catalogItem->id,
|
||||
'type' => $catalogItem->type->value,
|
||||
'nombre' => $catalogItem->nombre,
|
||||
'precio' => $catalogItem->precio,
|
||||
'image' => $attachment?->getTemporaryUrl(1440),
|
||||
];
|
||||
}
|
||||
}
|
||||
136
app/Domains/Catalog/Resources/CatalogItemDetailResource.php
Normal file
136
app/Domains/Catalog/Resources/CatalogItemDetailResource.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\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 Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/** @mixin CatalogItem */
|
||||
class CatalogItemDetailResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
/** @var Variant|null $selectedVariant */
|
||||
$selectedVariant = $this->resource->getRelation('selectedVariant');
|
||||
|
||||
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,
|
||||
'category' => $this->category?->nombre,
|
||||
'brand' => $this->brand?->nombre,
|
||||
'inventory_policy' => $this->inventory_policy?->value,
|
||||
'has_tickets' => $this->has_tickets,
|
||||
'minimum_use_date' => $this->minimum_use_date,
|
||||
'maximum_use_date' => $this->maximum_use_date,
|
||||
'attributes' => $this->itemAttributes
|
||||
->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute))
|
||||
->values(),
|
||||
'stock_tecnico' => $this->when(
|
||||
$selectedVariant === null,
|
||||
fn () => $this->availableStock(),
|
||||
),
|
||||
'images' => $this->when(
|
||||
$selectedVariant === null,
|
||||
fn () => $this->imageUrls($this->attachments),
|
||||
),
|
||||
'variants' => $this->variants
|
||||
->map(fn (Variant $variant): array => $this->variantData($variant))
|
||||
->values(),
|
||||
'selected_variant' => $this->when(
|
||||
$selectedVariant !== null,
|
||||
fn (): array => [
|
||||
...$this->variantData($selectedVariant),
|
||||
'images' => $this->imageUrls($selectedVariant->attachments),
|
||||
],
|
||||
),
|
||||
'components' => $this->when(
|
||||
$this->isBundle(),
|
||||
fn () => $this->bundleComponents
|
||||
->map(function ($component): array {
|
||||
$selectedItem = $component->variant ?? $component->catalogItem;
|
||||
|
||||
return [
|
||||
'catalog_item_id' => $component->component_catalog_item_id,
|
||||
'variant_id' => $component->component_variant_id,
|
||||
'quantity' => $component->quantity,
|
||||
'nombre' => $component->catalogItem->nombre,
|
||||
'item_nombre' => $selectedItem->getName(),
|
||||
];
|
||||
})
|
||||
->values(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function attributeData(ItemAttribute $itemAttribute): array
|
||||
{
|
||||
$attribute = $itemAttribute->attribute;
|
||||
$availableValues = $this->variants
|
||||
->flatMap->definitions
|
||||
->where('item_attribute_id', $itemAttribute->id)
|
||||
->pluck('value')
|
||||
->filter()
|
||||
->unique();
|
||||
|
||||
return [
|
||||
'id' => $attribute->id,
|
||||
'codigo' => $attribute->codigo,
|
||||
'nombre' => $attribute->nombre,
|
||||
'is_required' => $attribute->is_required,
|
||||
'metadata_schema' => $attribute->metadata_schema,
|
||||
'type' => $attribute->type->value,
|
||||
'options' => $attribute->options
|
||||
->whereIn('value', $availableValues)
|
||||
->map(fn ($option): array => [
|
||||
'id' => $option->id,
|
||||
'value' => $option->value,
|
||||
'label' => $option->label,
|
||||
'sort_order' => $option->sort_order,
|
||||
'metadata' => $option->metadata,
|
||||
])
|
||||
->values(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function variantData(Variant $variant): array
|
||||
{
|
||||
return [
|
||||
'id' => $variant->id,
|
||||
'stock_tecnico' => $this->variantStock($variant),
|
||||
'values' => $variant->definitions
|
||||
->mapWithKeys(fn ($definition) => [
|
||||
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
||||
])
|
||||
->filter(fn ($value, $key): bool => $key !== null),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return Collection<int, string> */
|
||||
private function imageUrls(Collection $attachments): Collection
|
||||
{
|
||||
return $attachments
|
||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||
->values();
|
||||
}
|
||||
|
||||
private function variantStock(Variant $variant): ?int
|
||||
{
|
||||
return $this->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
: $variant->inventory->availableStock();
|
||||
}
|
||||
}
|
||||
48
app/Domains/Catalog/Resources/CatalogItemResource.php
Normal file
48
app/Domains/Catalog/Resources/CatalogItemResource.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?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,
|
||||
'has_tickets' => $this->has_tickets,
|
||||
'minimum_use_date' => $this->minimum_use_date,
|
||||
'maximum_use_date' => $this->maximum_use_date,
|
||||
'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,
|
||||
'real_stock' => $variant->inventory?->real_stock,
|
||||
'values' => $variant->definitions
|
||||
->mapWithKeys(fn ($definition) => [
|
||||
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
||||
])
|
||||
->filter(fn ($value, $key) => $key !== null),
|
||||
'images' => $variant->attachments
|
||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||
->values(),
|
||||
])
|
||||
->values()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use App\Domains\Tenant\Resources\TenantResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Catalog\Models\Category
|
||||
*/
|
||||
class CategoryResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'tenant_code' => $this->tenant_code,
|
||||
'is_global' => $this->resource->isGlobal(),
|
||||
'tenant' => $this->resource->isGlobal()
|
||||
? null
|
||||
: TenantResource::make($this->whenLoaded('tenant')),
|
||||
'categoria_id' => $this->categoria_id,
|
||||
'nombre' => $this->nombre,
|
||||
'parent' => self::make($this->whenLoaded('parent')),
|
||||
'sub_categories' => self::collection($this->whenLoaded('subCategories')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Catalog\Models\ProductAttributeOption
|
||||
*/
|
||||
class ProductAttributeOptionResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'label' => $this->label,
|
||||
'sort_order' => $this->sort_order,
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Catalog\Models\ProductAttribute
|
||||
*/
|
||||
class ProductAttributeResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'tenant_codigo' => $this->tenant_codigo,
|
||||
'codigo' => $this->codigo,
|
||||
'nombre' => $this->nombre,
|
||||
'is_required' => $this->is_required,
|
||||
'metadata_schema' => $this->metadata_schema,
|
||||
'type' => $this->type?->value ?? $this->type,
|
||||
'options' => ProductAttributeOptionResource::collection($this->whenLoaded('options')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Catalog\Models\Product
|
||||
*/
|
||||
class ProductResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'tenant_codigo' => $this->tenant_codigo,
|
||||
'categoria_id' => $this->categoria_id,
|
||||
'slug' => $this->slug,
|
||||
'nombre' => $this->nombre,
|
||||
'descripcion' => $this->descripcion,
|
||||
'precio' => $this->precio,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Catalog\Models\ProductVariantDefinition
|
||||
*/
|
||||
class ProductVariantDefinitionResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'producto_variante_id' => $this->producto_variante_id,
|
||||
'attribute_id' => $this->attribute_id,
|
||||
'value' => $this->value,
|
||||
'attribute' => ProductAttributeResource::make($this->whenLoaded('attribute')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Catalog\Models\ProductVariant
|
||||
*/
|
||||
class ProductVariantResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'producto_id' => $this->producto_id,
|
||||
'slug' => $this->slug,
|
||||
'nombre' => $this->nombre,
|
||||
'stock' => $this->stock,
|
||||
'descripcion' => $this->descripcion,
|
||||
'precio' => $this->precio,
|
||||
'product' => ProductResource::make($this->whenLoaded('product')),
|
||||
'definitions' => ProductVariantDefinitionResource::collection($this->whenLoaded('definitions')),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
218
app/Domains/Catalog/Services/CatalogInventoryService.php
Normal file
218
app/Domains/Catalog/Services/CatalogInventoryService.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\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 Illuminate\Support\Facades\DB;
|
||||
|
||||
class CatalogInventoryService
|
||||
{
|
||||
public function availableQuantity(CatalogItem|Variant $selection): ?int
|
||||
{
|
||||
if ($selection instanceof CatalogItem
|
||||
&& $selection->type === CatalogItemType::Standard
|
||||
&& $selection->relationLoaded('inventory')
|
||||
&& $selection->inventory !== null) {
|
||||
return $selection->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
: $selection->inventory->availableStock();
|
||||
}
|
||||
|
||||
if ($selection instanceof CatalogItem
|
||||
&& $selection->type === CatalogItemType::Standard
|
||||
&& $selection->inventory_id === null) {
|
||||
if ($selection->inventory_policy === InventoryPolicy::Unlimited) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$selection->loadMissing('variants.inventory');
|
||||
|
||||
return $selection->variants->sum(
|
||||
fn (Variant $variant): int => $variant->inventory->availableStock(),
|
||||
);
|
||||
}
|
||||
|
||||
$requirements = $this->inventoryRequirements($selection);
|
||||
$trackedRequirements = array_filter(
|
||||
$requirements,
|
||||
fn (array $requirement): bool => $requirement['tracks_inventory'],
|
||||
);
|
||||
|
||||
if ($trackedRequirements === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$inventories = Inventory::query()
|
||||
->whereKey(array_keys($trackedRequirements))
|
||||
->get()
|
||||
->keyBy('id');
|
||||
$available = [];
|
||||
|
||||
foreach ($trackedRequirements as $inventoryId => $requirement) {
|
||||
$inventory = $inventories->get($inventoryId)
|
||||
?? throw new \InvalidArgumentException('No se encontro el inventario requerido.');
|
||||
$available[] = intdiv(
|
||||
$inventory->availableStock(),
|
||||
$requirement['quantity'],
|
||||
);
|
||||
}
|
||||
|
||||
return min($available);
|
||||
}
|
||||
|
||||
public function reserve(CatalogItem|Variant $selection, int $quantity): void
|
||||
{
|
||||
$this->mutate($selection, $quantity, 'reserve');
|
||||
}
|
||||
|
||||
public function release(CatalogItem|Variant $selection, int $quantity): void
|
||||
{
|
||||
$this->mutate($selection, $quantity, 'release');
|
||||
}
|
||||
|
||||
public function commit(CatalogItem|Variant $selection, int $quantity): void
|
||||
{
|
||||
$this->mutate($selection, $quantity, 'commit');
|
||||
}
|
||||
|
||||
private function mutate(
|
||||
CatalogItem|Variant $selection,
|
||||
int $quantity,
|
||||
string $operation,
|
||||
): void {
|
||||
if ($quantity <= 0) {
|
||||
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($selection, $quantity, $operation): void {
|
||||
$requirements = $this->inventoryRequirements($selection);
|
||||
ksort($requirements);
|
||||
$inventories = Inventory::query()
|
||||
->whereKey(array_keys($requirements))
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($requirements as $inventoryId => $requirement) {
|
||||
$inventory = $inventories->get($inventoryId)
|
||||
?? throw new \InvalidArgumentException('No se encontro el inventario requerido.');
|
||||
$requiredQuantity = $requirement['quantity'] * $quantity;
|
||||
|
||||
if ($operation === 'reserve'
|
||||
&& $requirement['tracks_inventory']
|
||||
&& $inventory->availableStock() < $requiredQuantity) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
|
||||
}
|
||||
|
||||
if (in_array($operation, ['release', 'commit'], true)
|
||||
&& $inventory->reserved_stock < $requiredQuantity) {
|
||||
throw new \InvalidArgumentException('La cantidad reservada no alcanza para la operacion.');
|
||||
}
|
||||
|
||||
if ($operation === 'commit'
|
||||
&& $requirement['tracks_inventory']
|
||||
&& $inventory->real_stock < $requiredQuantity) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requirements as $inventoryId => $requirement) {
|
||||
/** @var Inventory $inventory */
|
||||
$inventory = $inventories->get($inventoryId);
|
||||
$requiredQuantity = $requirement['quantity'] * $quantity;
|
||||
|
||||
match ($operation) {
|
||||
'reserve' => $inventory->reserve($requiredQuantity, $requirement['tracks_inventory']),
|
||||
'release' => $inventory->release($requiredQuantity),
|
||||
'commit' => $inventory->buy($requiredQuantity, $requirement['tracks_inventory']),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
||||
*/
|
||||
private function inventoryRequirements(CatalogItem|Variant $selection): array
|
||||
{
|
||||
if ($selection instanceof Variant) {
|
||||
$selection->loadMissing('catalogItem');
|
||||
|
||||
return $this->singleRequirement(
|
||||
$selection->inventory_id,
|
||||
$selection->catalogItem->inventory_policy,
|
||||
);
|
||||
}
|
||||
|
||||
if ($selection->type !== CatalogItemType::Bundle) {
|
||||
return $this->singleRequirement(
|
||||
$selection->inventory_id,
|
||||
$selection->inventory_policy,
|
||||
);
|
||||
}
|
||||
|
||||
$selection->loadMissing([
|
||||
'bundleComponents.catalogItem',
|
||||
'bundleComponents.variant.catalogItem',
|
||||
]);
|
||||
$requirements = [];
|
||||
|
||||
foreach ($selection->bundleComponents as $component) {
|
||||
$this->addComponentRequirement($requirements, $component);
|
||||
}
|
||||
|
||||
if ($requirements === []) {
|
||||
throw new \InvalidArgumentException('El bundle no tiene componentes.');
|
||||
}
|
||||
|
||||
return $requirements;
|
||||
}
|
||||
|
||||
/** @return array<int, array{quantity: int, tracks_inventory: bool}> */
|
||||
private function singleRequirement(
|
||||
?int $inventoryId,
|
||||
?InventoryPolicy $policy,
|
||||
): array {
|
||||
if ($inventoryId === null || $policy === null) {
|
||||
throw new \InvalidArgumentException('El item requiere una variante con inventario.');
|
||||
}
|
||||
|
||||
return [
|
||||
$inventoryId => [
|
||||
'quantity' => 1,
|
||||
'tracks_inventory' => $policy === InventoryPolicy::Tracked,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{quantity: int, tracks_inventory: bool}> $requirements
|
||||
*/
|
||||
private function addComponentRequirement(array &$requirements, BundleComponent $component): void
|
||||
{
|
||||
$selectedItem = $component->variant ?? $component->catalogItem;
|
||||
$inventoryId = $selectedItem->inventory_id;
|
||||
$policy = $component->catalogItem->inventory_policy;
|
||||
|
||||
if ($inventoryId === null || $policy === null) {
|
||||
throw new \InvalidArgumentException('Un componente del bundle no tiene inventario.');
|
||||
}
|
||||
|
||||
if (isset($requirements[$inventoryId])) {
|
||||
$requirements[$inventoryId]['quantity'] += $component->quantity;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$requirements[$inventoryId] = [
|
||||
'quantity' => $component->quantity,
|
||||
'tracks_inventory' => $policy === InventoryPolicy::Tracked,
|
||||
];
|
||||
}
|
||||
}
|
||||
469
app/Domains/Catalog/Services/CatalogService.php
Normal file
469
app/Domains/Catalog/Services/CatalogService.php
Normal file
@@ -0,0 +1,469 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\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\Inventory;
|
||||
use App\Domains\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class CatalogService
|
||||
{
|
||||
public function __construct(protected AttachmentService $attachmentService) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function create(array $data): CatalogItem
|
||||
{
|
||||
return DB::transaction(function () use ($data): CatalogItem {
|
||||
$type = CatalogItemType::from(
|
||||
$data['type'] ?? CatalogItemType::Standard->value,
|
||||
);
|
||||
$variants = $data['variants'] ?? [];
|
||||
$images = $data['images'] ?? [];
|
||||
$attributeCodes = $data['attribute_codes'] ?? [];
|
||||
$components = $data['components'] ?? [];
|
||||
$hasDirectStock = array_key_exists('real_stock', $data);
|
||||
$realStock = (int) ($data['real_stock'] ?? 0);
|
||||
|
||||
$hasVariants = $attributeCodes !== [];
|
||||
|
||||
if ($type === CatalogItemType::Bundle) {
|
||||
$this->validateBundleData($data, $components);
|
||||
} else {
|
||||
if (array_key_exists('components', $data)) {
|
||||
throw ValidationException::withMessages([
|
||||
'components' => ['Un item standard no puede tener componentes.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->validateInventoryStrategy(
|
||||
$data,
|
||||
$variants,
|
||||
$hasVariants,
|
||||
$hasDirectStock,
|
||||
);
|
||||
}
|
||||
|
||||
unset(
|
||||
$data['variants'],
|
||||
$data['images'],
|
||||
$data['attribute_codes'],
|
||||
$data['components'],
|
||||
$data['real_stock'],
|
||||
$data['reserved_stock'],
|
||||
$data['sold_units'],
|
||||
$data['inventory_id'],
|
||||
);
|
||||
|
||||
$data['type'] = $type;
|
||||
|
||||
if ($type === CatalogItemType::Bundle) {
|
||||
$data['inventory_id'] = null;
|
||||
$data['inventory_policy'] = null;
|
||||
$data['has_tickets'] = false;
|
||||
$data['minimum_use_date'] = null;
|
||||
$data['maximum_use_date'] = null;
|
||||
} elseif ($hasVariants) {
|
||||
$data['inventory_id'] = null;
|
||||
} else {
|
||||
$data['inventory_id'] = $this->createInventory($realStock)->id;
|
||||
}
|
||||
|
||||
$catalogItem = CatalogItem::query()->create($data);
|
||||
$itemAttributes = $type === CatalogItemType::Standard
|
||||
? $this->createItemAttributes($catalogItem, $attributeCodes)
|
||||
: [];
|
||||
|
||||
if ($type === CatalogItemType::Bundle) {
|
||||
$this->createBundleComponents($catalogItem, $components);
|
||||
}
|
||||
|
||||
$createdVariants = [];
|
||||
foreach ($variants as $index => $variantData) {
|
||||
$createdVariants[] = [
|
||||
'variant' => $this->createVariant(
|
||||
$catalogItem,
|
||||
$variantData,
|
||||
$itemAttributes,
|
||||
$index,
|
||||
),
|
||||
'images' => $variantData['images'] ?? [],
|
||||
'index' => $index,
|
||||
];
|
||||
}
|
||||
|
||||
$this->attachImages($catalogItem, $images, 'images');
|
||||
|
||||
foreach ($createdVariants as $createdVariant) {
|
||||
$this->attachImages(
|
||||
$createdVariant['variant'],
|
||||
$createdVariant['images'],
|
||||
"variants.{$createdVariant['index']}.images",
|
||||
);
|
||||
}
|
||||
|
||||
return $catalogItem->load([
|
||||
'attachments',
|
||||
'inventory',
|
||||
'category',
|
||||
'brand',
|
||||
'itemAttributes.attribute',
|
||||
'variants.inventory',
|
||||
'variants.attachments',
|
||||
'variants.definitions.itemAttribute.attribute',
|
||||
'bundleComponents.catalogItem',
|
||||
'bundleComponents.variant.catalogItem',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function getDetail(CatalogItem $catalogItem, ?int $variantId = null): CatalogItem
|
||||
{
|
||||
$catalogItem->load([
|
||||
'attachments',
|
||||
'inventory',
|
||||
'category',
|
||||
'brand',
|
||||
'itemAttributes.attribute.options',
|
||||
'variants' => fn ($query) => $query->orderBy('id'),
|
||||
'variants.inventory',
|
||||
'variants.attachments',
|
||||
'variants.definitions' => fn ($query) => $query->orderBy('id'),
|
||||
'variants.definitions.itemAttribute.attribute',
|
||||
'bundleComponents.catalogItem.inventory',
|
||||
'bundleComponents.variant.inventory',
|
||||
'bundleComponents.variant.definitions.itemAttribute.attribute',
|
||||
]);
|
||||
|
||||
$selectedVariant = $variantId === null
|
||||
? $catalogItem->variants->first()
|
||||
: $catalogItem->variants->firstWhere('id', $variantId);
|
||||
|
||||
if ($variantId !== null && $selectedVariant === null) {
|
||||
throw new NotFoundHttpException('Variant not found for catalog item.');
|
||||
}
|
||||
|
||||
$catalogItem->setRelation('selectedVariant', $selectedVariant);
|
||||
|
||||
return $catalogItem;
|
||||
}
|
||||
|
||||
public function delete(CatalogItem $catalogItem): void
|
||||
{
|
||||
DB::transaction(function () use ($catalogItem): void {
|
||||
$catalogItem->load([
|
||||
'attachments',
|
||||
'variants.attachments',
|
||||
]);
|
||||
|
||||
$attachments = $catalogItem->attachments
|
||||
->merge($catalogItem->variants->flatMap->attachments)
|
||||
->unique('id');
|
||||
$inventoryIds = collect([$catalogItem->inventory_id])
|
||||
->merge($catalogItem->variants->pluck('inventory_id'))
|
||||
->filter()
|
||||
->unique();
|
||||
|
||||
$catalogItem->attachments()->detach();
|
||||
foreach ($catalogItem->variants as $variant) {
|
||||
$variant->attachments()->detach();
|
||||
}
|
||||
|
||||
$catalogItem->delete();
|
||||
Inventory::query()->whereKey($inventoryIds)->delete();
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
if (! DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) {
|
||||
$this->attachmentService->delete($attachment);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function createInventory(int $realStock): Inventory
|
||||
{
|
||||
return Inventory::query()->create([
|
||||
'real_stock' => $realStock,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $components
|
||||
*/
|
||||
private function createBundleComponents(CatalogItem $bundle, array $components): void
|
||||
{
|
||||
$seen = [];
|
||||
|
||||
foreach (array_values($components) as $index => $componentData) {
|
||||
$catalogItemId = (int) $componentData['catalog_item_id'];
|
||||
$variantId = isset($componentData['variant_id'])
|
||||
? (int) $componentData['variant_id']
|
||||
: null;
|
||||
$key = $catalogItemId.':'.($variantId ?? 'direct');
|
||||
|
||||
if (isset($seen[$key])) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}" => ['El componente esta duplicado.'],
|
||||
]);
|
||||
}
|
||||
$seen[$key] = true;
|
||||
|
||||
$componentItem = CatalogItem::query()
|
||||
->whereKey($catalogItemId)
|
||||
->where('tenant_code', $bundle->tenant_code)
|
||||
->first();
|
||||
|
||||
if ($componentItem === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.catalog_item_id" => [
|
||||
'El item no pertenece al tenant del bundle.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.catalog_item_id" => [
|
||||
'El componente debe ser un item standard distinto del bundle.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$hasVariants = $componentItem->variants()->exists();
|
||||
if ($hasVariants && $variantId === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
'Debe seleccionar una variante para este componente.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $hasVariants && $variantId !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
'El componente con inventario directo no admite una variante.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
'La variante no pertenece al componente indicado.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$bundle->bundleComponents()->create([
|
||||
'component_catalog_item_id' => $componentItem->id,
|
||||
'component_variant_id' => $variantId,
|
||||
'quantity' => (int) $componentData['quantity'],
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<int, array<string, mixed>> $components
|
||||
*/
|
||||
private function validateBundleData(array $data, array $components): void
|
||||
{
|
||||
if ($components === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'components' => ['Un bundle debe tener al menos un componente.'],
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'real_stock',
|
||||
'inventory_policy',
|
||||
'attribute_codes',
|
||||
'variants',
|
||||
'has_tickets',
|
||||
'minimum_use_date',
|
||||
'maximum_use_date',
|
||||
] as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
throw ValidationException::withMessages([
|
||||
$field => ["{$field} no se admite para un bundle."],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $images
|
||||
*/
|
||||
private function attachImages(
|
||||
CatalogItem|Variant $owner,
|
||||
array $images,
|
||||
string $validationKey,
|
||||
): void {
|
||||
foreach (array_values($images) as $order => $image) {
|
||||
$attachment = $this->resolveAttachment($image, "{$validationKey}.{$order}");
|
||||
|
||||
$owner->attachments()->attach($attachment->id, ['orden' => $order]);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveAttachment(mixed $image, string $validationKey): Attachment
|
||||
{
|
||||
if (is_string($image) && Str::isUuid($image)) {
|
||||
$attachment = Attachment::query()->where('key', $image)->first();
|
||||
|
||||
if ($attachment === null) {
|
||||
throw ValidationException::withMessages([
|
||||
$validationKey => ['El attachment indicado no existe.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
return $this->attachmentService->store($image, 'catalog-items');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $attributeCodes
|
||||
* @return array<string, ItemAttribute>
|
||||
*/
|
||||
private function createItemAttributes(
|
||||
CatalogItem $catalogItem,
|
||||
array $attributeCodes,
|
||||
): array {
|
||||
$itemAttributes = [];
|
||||
$attributeCodes = array_values(array_unique($attributeCodes));
|
||||
$attributes = Attribute::query()
|
||||
->where('tenant_codigo', $catalogItem->tenant_code)
|
||||
->whereIn('codigo', $attributeCodes)
|
||||
->get()
|
||||
->keyBy('codigo');
|
||||
|
||||
foreach ($attributeCodes as $attributeCode) {
|
||||
$attribute = $attributes->get($attributeCode);
|
||||
|
||||
if ($attribute === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'attribute_codes' => [
|
||||
"El atributo {$attributeCode} no existe para el tenant del ítem.",
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$itemAttribute = $catalogItem->itemAttributes()->create([
|
||||
'attribute_id' => $attribute->id,
|
||||
]);
|
||||
|
||||
$itemAttributes[$attributeCode] = $itemAttribute;
|
||||
}
|
||||
|
||||
return $itemAttributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, ItemAttribute> $itemAttributes
|
||||
*/
|
||||
private function createVariant(
|
||||
CatalogItem $catalogItem,
|
||||
array $data,
|
||||
array $itemAttributes,
|
||||
int $index,
|
||||
): Variant {
|
||||
unset($data['images']);
|
||||
|
||||
if (
|
||||
array_key_exists('inventory_id', $data)
|
||||
|| array_key_exists('reserved_stock', $data)
|
||||
|| array_key_exists('sold_units', $data)
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.inventory" => [
|
||||
'inventory_id, reserved_stock y sold_units son administrados internamente.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
|
||||
$variant = $catalogItem->variants()->create([
|
||||
'inventory_id' => $inventory->id,
|
||||
]);
|
||||
|
||||
foreach ($data['values'] ?? [] as $attributeCode => $value) {
|
||||
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
|
||||
|
||||
if ($itemAttribute === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.values.{$attributeCode}" => [
|
||||
'El atributo no pertenece al ítem de catálogo.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$variant->definitions()->create([
|
||||
'item_attribute_id' => $itemAttribute->id,
|
||||
'value' => $value,
|
||||
]);
|
||||
}
|
||||
|
||||
return $variant;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<int, array<string, mixed>> $variants
|
||||
*/
|
||||
private function validateInventoryStrategy(
|
||||
array $data,
|
||||
array $variants,
|
||||
bool $hasVariants,
|
||||
bool $hasDirectStock,
|
||||
): void {
|
||||
if ($hasVariants && $variants === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'variants' => [
|
||||
'Un ítem con attribute_codes debe tener variantes.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $hasVariants && $variants !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'variants' => [
|
||||
'Un ítem sin attribute_codes no puede tener variantes.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($hasVariants && $hasDirectStock) {
|
||||
throw ValidationException::withMessages([
|
||||
'real_stock' => [
|
||||
'Un ítem con variantes no puede tener inventario directo.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if (
|
||||
array_key_exists('inventory_id', $data)
|
||||
|| array_key_exists('reserved_stock', $data)
|
||||
|| array_key_exists('sold_units', $data)
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'inventory' => [
|
||||
'inventory_id, reserved_stock y sold_units son administrados internamente.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
class ProductService
|
||||
{
|
||||
// Reserved for product domain business logic.
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Support;
|
||||
|
||||
enum ProductAttributeType: string
|
||||
{
|
||||
case Text = 'text';
|
||||
case Numeric = 'numeric';
|
||||
case Select = 'select';
|
||||
case Multiselect = 'multiselect';
|
||||
|
||||
public function supportsOptions(): bool
|
||||
{
|
||||
return in_array($this, [self::Select, self::Multiselect], true);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Catalog\Controllers\BrandController;
|
||||
use App\Domains\Catalog\Controllers\CategoryController;
|
||||
use App\Domains\Catalog\Controllers\ProductController;
|
||||
use App\Domains\Catalog\Controllers\ProductAttributeController;
|
||||
use App\Domains\Catalog\Controllers\ProductVariantController;
|
||||
use App\Domains\Catalog\Controllers\CatalogController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
||||
Route::apiResource('marcas', BrandController::class)->parameters(['marcas' => 'marca']);
|
||||
Route::apiResource('categorias', CategoryController::class)->parameters(['categorias' => 'categoria']);
|
||||
Route::apiResource('productos', ProductController::class);
|
||||
Route::apiResource('product-attributes', ProductAttributeController::class)
|
||||
->parameters(['product-attributes' => 'productAttribute']);
|
||||
Route::apiResource('product-variants', ProductVariantController::class)
|
||||
->parameters(['product-variants' => 'productVariant']);
|
||||
Route::get('catalog', [CatalogController::class, 'index']);
|
||||
Route::get('catalog/featured-groups/{featuredGroup}/items', [CatalogController::class, 'featuredGroupItems'])
|
||||
->name('catalog.featured-groups.items.index');
|
||||
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
|
||||
Route::post('catalog-items', [CatalogController::class, 'store']);
|
||||
});
|
||||
|
||||
66
app/Domains/Integration/Casts/EncryptedIntegrationData.php
Normal file
66
app/Domains/Integration/Casts/EncryptedIntegrationData.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Casts;
|
||||
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Exception;
|
||||
|
||||
class EncryptedIntegrationData implements CastsAttributes
|
||||
{
|
||||
protected function getEncrypter(): Encrypter
|
||||
{
|
||||
$secret = config('services.integrations.secret');
|
||||
|
||||
if (empty($secret)) {
|
||||
throw new Exception('The integrations secret is not configured.');
|
||||
}
|
||||
|
||||
// Laravel encrypter requires a key of exact length. Typically 32 bytes for AES-256-CBC.
|
||||
// If the secret is base64 encoded like the APP_KEY:
|
||||
if (str_starts_with($secret, 'base64:')) {
|
||||
$key = base64_decode(substr($secret, 7));
|
||||
} else {
|
||||
// Otherwise, we hash it to ensure 32 bytes for AES-256-CBC.
|
||||
$key = hash('sha256', $secret, true);
|
||||
}
|
||||
|
||||
return new Encrypter($key, config('app.cipher', 'AES-256-CBC'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast the given value.
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$decrypted = $this->getEncrypter()->decryptString($value);
|
||||
return json_decode($decrypted, true);
|
||||
} catch (Exception $e) {
|
||||
// Return null or throw depending on how strict we want to be.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the given value for storage.
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_encode($value);
|
||||
return $this->getEncrypter()->encryptString($json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Controllers;
|
||||
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use App\Domains\Integration\Requests\StoreIntegrationRequest;
|
||||
use App\Domains\Integration\Requests\UpdateIntegrationRequest;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class IntegrationController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return response()->json(Integration::all());
|
||||
}
|
||||
|
||||
public function store(StoreIntegrationRequest $request)
|
||||
{
|
||||
$integration = Integration::create($request->validated());
|
||||
|
||||
return response()->json($integration, 201);
|
||||
}
|
||||
|
||||
public function show(Integration $integration)
|
||||
{
|
||||
return response()->json($integration);
|
||||
}
|
||||
|
||||
public function update(UpdateIntegrationRequest $request, Integration $integration)
|
||||
{
|
||||
$integration->update($request->validated());
|
||||
|
||||
return response()->json($integration->fresh());
|
||||
}
|
||||
|
||||
public function destroy(Integration $integration)
|
||||
{
|
||||
$integration->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Controllers;
|
||||
|
||||
use App\Domains\Integration\Requests\TelepagosWebhookRequest;
|
||||
use App\Domains\Integration\Services\TelepagosWebhookService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TelepagosWebhookController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Telepagos webhook.
|
||||
*
|
||||
* @param TelepagosWebhookRequest $request
|
||||
* @param string $tenantCodigo
|
||||
* @param TelepagosWebhookService $service
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse
|
||||
{
|
||||
try {
|
||||
$cashinId = $request->validated('id');
|
||||
|
||||
$service->handleWebhook($tenantCodigo, $cashinId);
|
||||
|
||||
return response()->json(['status' => 'success']);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Controllers;
|
||||
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use App\Domains\Integration\Requests\StoreTenantIntegrationRequest;
|
||||
use App\Domains\Integration\Services\TenantIntegrationService;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class TenantIntegrationController extends Controller
|
||||
{
|
||||
protected TenantIntegrationService $tenantIntegrationService;
|
||||
|
||||
public function __construct(TenantIntegrationService $tenantIntegrationService)
|
||||
{
|
||||
$this->tenantIntegrationService = $tenantIntegrationService;
|
||||
}
|
||||
|
||||
public function index(string $tenantCode)
|
||||
{
|
||||
return response()->json($this->tenantIntegrationService->getAllForTenant($tenantCode));
|
||||
}
|
||||
|
||||
public function show(string $tenantCode, string $integrationCode)
|
||||
{
|
||||
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
|
||||
|
||||
if (!$integration) {
|
||||
return response()->json(['message' => 'Integration not configured for this tenant'], 404);
|
||||
}
|
||||
|
||||
return response()->json($integration);
|
||||
}
|
||||
|
||||
public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode)
|
||||
{
|
||||
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
|
||||
|
||||
try {
|
||||
$this->tenantIntegrationService->updateOrCreateIntegration(
|
||||
$tenantCode,
|
||||
$integration,
|
||||
$request->input('integration_data', [])
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'integration configured correctly',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'message' => 'Error validando la configuración: ' . $e->getMessage()
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
app/Domains/Integration/Models/Integration.php
Normal file
28
app/Domains/Integration/Models/Integration.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Integration extends Model
|
||||
{
|
||||
protected $table = 'integrations';
|
||||
|
||||
protected $fillable = [
|
||||
'integration_code',
|
||||
'name',
|
||||
'url',
|
||||
'integration_data_schema',
|
||||
'requires_tenant_configuration',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'integration_data_schema' => 'array',
|
||||
'requires_tenant_configuration' => 'boolean',
|
||||
];
|
||||
|
||||
public function tenantIntegrations()
|
||||
{
|
||||
return $this->hasMany(TenantIntegration::class, 'integration_code', 'integration_code');
|
||||
}
|
||||
}
|
||||
26
app/Domains/Integration/Models/TenantIntegration.php
Normal file
26
app/Domains/Integration/Models/TenantIntegration.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Models;
|
||||
|
||||
use App\Domains\Integration\Casts\EncryptedIntegrationData;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TenantIntegration extends Model
|
||||
{
|
||||
protected $table = 'tenant_integration';
|
||||
|
||||
protected $fillable = [
|
||||
'integration_code',
|
||||
'tenant_code',
|
||||
'integration_data',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'integration_data' => EncryptedIntegrationData::class,
|
||||
];
|
||||
|
||||
public function integration()
|
||||
{
|
||||
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||
}
|
||||
}
|
||||
24
app/Domains/Integration/Requests/StoreIntegrationRequest.php
Normal file
24
app/Domains/Integration/Requests/StoreIntegrationRequest.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreIntegrationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'integration_code' => ['required', 'string', 'unique:integrations,integration_code'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
'integration_data_schema' => ['nullable', 'array'],
|
||||
'requires_tenant_configuration' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class StoreTenantIntegrationRequest extends FormRequest
|
||||
{
|
||||
protected ?Integration $integrationModel = null;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation()
|
||||
{
|
||||
$integrationCode = $this->route('integration_code');
|
||||
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
|
||||
|
||||
if (!$this->integrationModel) {
|
||||
throw ValidationException::withMessages([
|
||||
'integration_code' => 'Integration not found.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
// Dynamic validation rules based on the integration data schema
|
||||
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
|
||||
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
|
||||
$rules['integration_data.' . $field] = $rule;
|
||||
}
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
28
app/Domains/Integration/Requests/TelepagosWebhookRequest.php
Normal file
28
app/Domains/Integration/Requests/TelepagosWebhookRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TelepagosWebhookRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'id' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateIntegrationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$integration = $this->route('integration');
|
||||
|
||||
return [
|
||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
'integration_data_schema' => ['nullable', 'array'],
|
||||
'requires_tenant_configuration' => ['sometimes', 'boolean'],
|
||||
// the code shouldn't ideally be updatable, but if it is:
|
||||
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,' . ($integration->id ?? '')],
|
||||
];
|
||||
}
|
||||
}
|
||||
167
app/Domains/Integration/Services/BaseIntegrationService.php
Normal file
167
app/Domains/Integration/Services/BaseIntegrationService.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use App\Domains\Integration\Models\TenantIntegration;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Exception;
|
||||
|
||||
abstract class BaseIntegrationService
|
||||
{
|
||||
/**
|
||||
* The unique code of the integration.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $integrationCode;
|
||||
|
||||
/**
|
||||
* The current tenant code.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $tenantCode;
|
||||
|
||||
/**
|
||||
* The integration model instance.
|
||||
*
|
||||
* @var Integration|null
|
||||
*/
|
||||
protected ?Integration $integration = null;
|
||||
|
||||
/**
|
||||
* The tenant-specific integration model instance.
|
||||
*
|
||||
* @var TenantIntegration|null
|
||||
*/
|
||||
protected ?TenantIntegration $tenantIntegration = null;
|
||||
|
||||
/**
|
||||
* Set the integration code.
|
||||
*
|
||||
* @param string $integrationCode
|
||||
* @return $this
|
||||
*/
|
||||
public function setIntegrationCode(string $integrationCode): self
|
||||
{
|
||||
$this->integrationCode = $integrationCode;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the integration code.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getIntegrationCode(): string
|
||||
{
|
||||
return $this->integrationCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the tenant code and load the integration models.
|
||||
*
|
||||
* @param string $tenantCode
|
||||
* @return $this
|
||||
* @throws Exception
|
||||
*/
|
||||
public function forTenant(string $tenantCode): self
|
||||
{
|
||||
$this->tenantCode = $tenantCode;
|
||||
$this->loadIntegration();
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the Integration and TenantIntegration models.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function loadIntegration(): void
|
||||
{
|
||||
if (empty($this->integrationCode)) {
|
||||
throw new Exception("Integration code is not set.");
|
||||
}
|
||||
|
||||
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
|
||||
if (!$this->integration) {
|
||||
throw new Exception("Integration with code '{$this->integrationCode}' not found.");
|
||||
}
|
||||
|
||||
$this->tenantIntegration = TenantIntegration::where('tenant_code', $this->tenantCode)
|
||||
->where('integration_code', $this->integrationCode)
|
||||
->first();
|
||||
|
||||
if (!$this->tenantIntegration && $this->integration->requires_tenant_configuration) {
|
||||
throw new Exception("Tenant '{$this->tenantCode}' does not have integration '{$this->integrationCode}' configured.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request URL.
|
||||
*
|
||||
* @param string $path
|
||||
* @return string
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getUrl(string $path = ''): string
|
||||
{
|
||||
if (!$this->integration) {
|
||||
throw new Exception("Integration is not loaded. Call forTenant() first.");
|
||||
}
|
||||
|
||||
$baseUrl = rtrim($this->integration->url, '/');
|
||||
$path = ltrim($path, '/');
|
||||
|
||||
return $path !== '' ? "{$baseUrl}/{$path}" : $baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get integration setting by key from tenant's integration data.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $default
|
||||
* @return mixed
|
||||
*/
|
||||
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
|
||||
{
|
||||
if (!$this->tenantIntegration || !$this->tenantIntegration->integration_data) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $this->tenantIntegration->integration_data[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pre-configured HTTP client builder.
|
||||
*
|
||||
* @return PendingRequest
|
||||
* @throws Exception
|
||||
*/
|
||||
public function client(): PendingRequest
|
||||
{
|
||||
return Http::baseUrl($this->getUrl())
|
||||
->withHeaders($this->getHeaders());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the headers for the integration.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
abstract public function getHeaders(): array;
|
||||
|
||||
/**
|
||||
* Hook called after the integration is configured for the tenant.
|
||||
* Can be used to validate credentials or perform initial setups.
|
||||
* Throw an Exception on failure.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function onSetup(): void
|
||||
{
|
||||
// Override in child classes if needed
|
||||
}
|
||||
}
|
||||
158
app/Domains/Integration/Services/MailService.php
Normal file
158
app/Domains/Integration/Services/MailService.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Mail\Factory as MailFactory;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\MailManager;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class MailService extends BaseIntegrationService
|
||||
{
|
||||
private const REQUIRED_SMTP_FIELDS = [
|
||||
'MAIL_HOST',
|
||||
'MAIL_PORT',
|
||||
'MAIL_USERNAME',
|
||||
'MAIL_PASSWORD',
|
||||
'MAIL_FROM_ADDRESS',
|
||||
];
|
||||
|
||||
protected string $integrationCode = 'email';
|
||||
|
||||
private readonly MailFactory $mailFactory;
|
||||
|
||||
private ?Mailer $mailer = null;
|
||||
|
||||
private ?Tenant $tenant = null;
|
||||
|
||||
private bool $usesTenantMailer = false;
|
||||
|
||||
public function __construct(?MailFactory $mailFactory = null)
|
||||
{
|
||||
$this->mailFactory = $mailFactory ?? app(MailFactory::class);
|
||||
}
|
||||
|
||||
public function forTenant(string $tenantCode): self
|
||||
{
|
||||
parent::forTenant($tenantCode);
|
||||
|
||||
$this->tenant = Tenant::query()
|
||||
->where('codigo', $tenantCode)
|
||||
->firstOrFail();
|
||||
|
||||
if ($this->tenantIntegration) {
|
||||
$this->mailer = $this->resolveMailer();
|
||||
$this->usesTenantMailer = true;
|
||||
} else {
|
||||
$this->mailer = $this->mailFactory->mailer();
|
||||
$this->usesTenantMailer = false;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function send(string|array $recipient, string $subject, string $content): void
|
||||
{
|
||||
if (! $this->mailer || ! $this->tenant) {
|
||||
throw new Exception('MailService no está configurado. Llamá a forTenant() primero.');
|
||||
}
|
||||
|
||||
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
|
||||
|
||||
$html = Blade::render(
|
||||
<<<'BLADE'
|
||||
<x-mail.branded-layout :tenant="$tenant" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
||||
{!! $content !!}
|
||||
</x-mail.branded-layout>
|
||||
BLADE,
|
||||
[
|
||||
'tenant' => $this->tenant,
|
||||
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
|
||||
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
|
||||
'content' => $content,
|
||||
],
|
||||
);
|
||||
|
||||
$mail = (new Mailable)
|
||||
->subject($subject)
|
||||
->html($html);
|
||||
|
||||
$this->mailer->to($recipient)->send($mail);
|
||||
}
|
||||
|
||||
public function mailerName(): string
|
||||
{
|
||||
return $this->usesTenantMailer
|
||||
? 'tenant-smtp'
|
||||
: (string) config('mail.default');
|
||||
}
|
||||
|
||||
public function onSetup(): void
|
||||
{
|
||||
if (! $this->tenant) {
|
||||
throw new Exception('MailService no está configurado. Llamá a forTenant() primero.');
|
||||
}
|
||||
|
||||
$recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS');
|
||||
|
||||
if (! is_string($recipient) || $recipient === '') {
|
||||
throw new InvalidArgumentException('Falta MAIL_FROM_ADDRESS en la configuración SMTP del tenant.');
|
||||
}
|
||||
|
||||
$this->send(
|
||||
$recipient,
|
||||
'Configuración de correo validada',
|
||||
'<h1 style="margin: 0 0 20px;">Configuración de correo validada</h1>'
|
||||
.'<p>La integración SMTP de '.e($this->tenant->nombre).' fue configurada correctamente.</p>'
|
||||
.'<p style="color: #64748b; font-size: 13px;">Este mensaje fue enviado automáticamente para validar las credenciales de correo.</p>',
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveMailer(): Mailer
|
||||
{
|
||||
$data = $this->tenantIntegration?->integration_data;
|
||||
|
||||
if (! is_array($data)) {
|
||||
throw new InvalidArgumentException('La configuración SMTP del tenant no es válida.');
|
||||
}
|
||||
|
||||
foreach (self::REQUIRED_SMTP_FIELDS as $field) {
|
||||
if (! array_key_exists($field, $data) || $data[$field] === null || $data[$field] === '') {
|
||||
throw new InvalidArgumentException("Falta {$field} en la configuración SMTP del tenant.");
|
||||
}
|
||||
}
|
||||
|
||||
// MailFake implements MailFactory but cannot build transports.
|
||||
if (! $this->mailFactory instanceof MailManager) {
|
||||
return $this->mailFactory->mailer();
|
||||
}
|
||||
|
||||
$mailer = $this->mailFactory->build([
|
||||
'name' => "tenant-smtp-{$this->tenantCode}",
|
||||
'transport' => 'smtp',
|
||||
'scheme' => $data['MAIL_SCHEME'] ?? null,
|
||||
'host' => $data['MAIL_HOST'],
|
||||
'port' => (int) $data['MAIL_PORT'],
|
||||
'username' => $data['MAIL_USERNAME'],
|
||||
'password' => $data['MAIL_PASSWORD'],
|
||||
'timeout' => isset($data['MAIL_TIMEOUT']) ? (int) $data['MAIL_TIMEOUT'] : null,
|
||||
'local_domain' => $data['MAIL_EHLO_DOMAIN'] ?? null,
|
||||
]);
|
||||
|
||||
$mailer->alwaysFrom(
|
||||
$data['MAIL_FROM_ADDRESS'],
|
||||
$data['MAIL_FROM_NAME'] ?? $this->tenant?->nombre,
|
||||
);
|
||||
|
||||
return $mailer;
|
||||
}
|
||||
}
|
||||
228
app/Domains/Integration/Services/TelepagosIntegrationService.php
Normal file
228
app/Domains/Integration/Services/TelepagosIntegrationService.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class TelepagosIntegrationService extends BaseIntegrationService
|
||||
{
|
||||
/**
|
||||
* TelepagosIntegrationService constructor.
|
||||
*
|
||||
* @param string $integrationCode
|
||||
*/
|
||||
public function __construct(string $integrationCode = 'telepagos')
|
||||
{
|
||||
// Force homologation code if not in production and using default
|
||||
if ($integrationCode === 'telepagos' && !app()->environment('production')) {
|
||||
$integrationCode = 'telepagos_homo';
|
||||
}
|
||||
|
||||
$this->integrationCode = $integrationCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the headers for Telepagos integration.
|
||||
*
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer ' . $this->getToken(),
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid token, either from cache or by performing a login.
|
||||
*
|
||||
* @return string
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getToken(): string
|
||||
{
|
||||
if (!$this->tenantIntegration) {
|
||||
throw new Exception("Tenant integration is not loaded. Call forTenant() first.");
|
||||
}
|
||||
|
||||
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
|
||||
|
||||
$token = Cache::get($cacheKey);
|
||||
|
||||
if ($token) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return $this->login();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with Telepagos and cache the returned token.
|
||||
*
|
||||
* @return string
|
||||
* @throws Exception
|
||||
*/
|
||||
public function login(): string
|
||||
{
|
||||
$username = $this->getIntegrationSetting('username');
|
||||
$password = $this->getIntegrationSetting('password');
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
throw new Exception("Missing username or password in Telepagos integration settings.");
|
||||
}
|
||||
|
||||
$url = $this->getUrl('/v2/auth/token');
|
||||
|
||||
$response = Http::post($url, [
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
]);
|
||||
|
||||
$data = $this->handleResponse($response, 'authentication', [
|
||||
'username' => $username,
|
||||
]);
|
||||
|
||||
$token = $data['token'] ?? null;
|
||||
$expiresAtStr = $data['expires_at'] ?? null;
|
||||
|
||||
if (!$token || !$expiresAtStr) {
|
||||
throw new Exception("Telepagos authentication response is missing token or expires_at.");
|
||||
}
|
||||
|
||||
$expiresAt = Carbon::parse($expiresAtStr);
|
||||
// Calculate TTL and subtract a buffer of 60 seconds
|
||||
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
|
||||
|
||||
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
|
||||
Cache::put($cacheKey, $token, $ttlSeconds);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to Telepagos, handling 401 Unauthorized for token refresh.
|
||||
*
|
||||
* @param string $method
|
||||
* @param string $endpoint
|
||||
* @param array $data
|
||||
* @return \Illuminate\Http\Client\Response
|
||||
*/
|
||||
protected function sendRequest(string $method, string $endpoint, array $data = []): \Illuminate\Http\Client\Response
|
||||
{
|
||||
$response = $this->client()->$method($endpoint, $data);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
Log::info("Telepagos 401 Unauthorized. Refreshing token and retrying...");
|
||||
|
||||
$this->clearToken();
|
||||
|
||||
$response = $this->client()->$method($endpoint, $data);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code for cash-in.
|
||||
*
|
||||
* @param float $amount
|
||||
* @param string $concept
|
||||
* @param string $description
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public function generateQr(float $amount, string $concept, string $description): array
|
||||
{
|
||||
$payload = [
|
||||
'amount' => $amount,
|
||||
'concept' => $concept,
|
||||
'description' => $description,
|
||||
];
|
||||
|
||||
$response = $this->sendRequest('post', '/v2/payment/cashin/qr/generate', $payload);
|
||||
|
||||
return $this->handleResponse($response, 'QR generation', $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the details of a cash-in payment.
|
||||
*
|
||||
* @param int $cashinId
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getCashinDetails(string $cashinId): array
|
||||
{
|
||||
$response = $this->sendRequest('get', "/v2/payment/cashin/{$cashinId}");
|
||||
|
||||
return $this->handleResponse($response, 'get cash-in details', [
|
||||
'cashin_id' => $cashinId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the account info.
|
||||
*
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getAccountInfo(): array
|
||||
{
|
||||
$response = $this->sendRequest('get', '/v2/account/info');
|
||||
return $this->handleResponse($response, 'get account info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Telepagos API response, logging any failures and throwing Exceptions.
|
||||
*
|
||||
* @param \Illuminate\Http\Client\Response $response
|
||||
* @param string $actionDescription
|
||||
* @param array $context
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function handleResponse(\Illuminate\Http\Client\Response $response, string $actionDescription, array $context = []): array
|
||||
{
|
||||
if ($response->failed() || $response->json('status') !== 'ok') {
|
||||
$errorMessage = $response->json('message') ?? $response->body();
|
||||
Log::error("Telepagos {$actionDescription} failed: {$errorMessage}", array_merge([
|
||||
'response_status' => $response->status(),
|
||||
'response_body' => $response->json() ?? $response->body(),
|
||||
], $context));
|
||||
|
||||
throw new Exception("Telepagos {$actionDescription} failed: {$errorMessage}");
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached token.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function clearToken(): void
|
||||
{
|
||||
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform initial setup validation for Telepagos.
|
||||
*
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function onSetup(): void
|
||||
{
|
||||
// Realiza un login de prueba para validar que las credenciales son correctas.
|
||||
$this->login();
|
||||
}
|
||||
}
|
||||
134
app/Domains/Integration/Services/TelepagosWebhookService.php
Normal file
134
app/Domains/Integration/Services/TelepagosWebhookService.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Models\TelepagosPayment;
|
||||
use App\Domains\Purchase\Models\TelepagosQr;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TelepagosWebhookService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CheckoutService $checkoutService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle the Telepagos webhook notification.
|
||||
*
|
||||
* @param string $tenantCodigo
|
||||
* @param string $cashinId
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handleWebhook(string $tenantCodigo, string $cashinId): void
|
||||
{
|
||||
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
|
||||
|
||||
$telepagosService = new TelepagosIntegrationService();
|
||||
$telepagosService->forTenant($tenant->codigo);
|
||||
|
||||
try {
|
||||
$details = $telepagosService->getCashinDetails($cashinId);
|
||||
|
||||
$qrOrderId = $details['data']['qr_order_id'] ?? $details['qr_order_id'] ?? null;
|
||||
$amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0);
|
||||
$operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null;
|
||||
|
||||
$transferenciaOperationIds = [1, 3, 11];
|
||||
$qrOperationIds = [31, 37, 47];
|
||||
|
||||
$compra = null;
|
||||
|
||||
if (in_array((int) $operationId, $transferenciaOperationIds, true)) {
|
||||
$cuit = $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null;
|
||||
|
||||
if (! $cuit) {
|
||||
Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}");
|
||||
return;
|
||||
}
|
||||
|
||||
$dni = substr($cuit, 2, -1);
|
||||
|
||||
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
|
||||
->where('transfer_payer_dni', $dni)
|
||||
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
||||
->where('payment_method', 'transfer')
|
||||
->where('total', $amount)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (! $compra) {
|
||||
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
|
||||
return;
|
||||
}
|
||||
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
||||
if (! $qrOrderId) {
|
||||
Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}");
|
||||
return;
|
||||
}
|
||||
|
||||
$telepagosQr = TelepagosQr::where('qr_order_id', $qrOrderId)->first();
|
||||
|
||||
if (! $telepagosQr) {
|
||||
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
|
||||
return;
|
||||
}
|
||||
|
||||
$compra = $telepagosQr->compra;
|
||||
|
||||
if (! $compra) {
|
||||
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
|
||||
return;
|
||||
}
|
||||
|
||||
if ($compra->status !== Purchase::STATUS_PENDING_PAYMENT) {
|
||||
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
|
||||
return;
|
||||
}
|
||||
|
||||
$totalAmount = $this->normalizeAmount($compra->getTotalAmount());
|
||||
|
||||
if ($amount !== $totalAmount) {
|
||||
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}");
|
||||
return;
|
||||
}
|
||||
|
||||
$paymentData = [
|
||||
'compra_id' => $compra->id,
|
||||
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
|
||||
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
|
||||
'amount' => $amount,
|
||||
'concept' => $details['data']['concept'] ?? $details['concept'] ?? null,
|
||||
'operation' => $details['data']['operation'] ?? $details['operation'] ?? null,
|
||||
'operation_id' => $details['data']['operation_id'] ?? $details['operation_id'] ?? null,
|
||||
'transaction_id' => $details['data']['transaction_id'] ?? $details['transaction_id'] ?? null,
|
||||
'qr_order_id' => $qrOrderId,
|
||||
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
|
||||
];
|
||||
|
||||
\Illuminate\Support\Facades\DB::transaction(function () use ($compra, $paymentData) {
|
||||
TelepagosPayment::create($paymentData);
|
||||
$this->checkoutService->confirmPurchase($compra);
|
||||
$compra->markAsPaid();
|
||||
});
|
||||
|
||||
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
|
||||
} catch (Exception $e) {
|
||||
Log::error("Telepagos webhook error: " . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
protected function normalizeAmount(mixed $amount): string
|
||||
{
|
||||
return number_format((float) $amount, 2, '.', '');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user