Compare commits
76 Commits
feature/co
...
b94efea4e0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -3,6 +3,7 @@ APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
FRONTEND_URL=http://localhost:4200
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@@ -56,10 +57,13 @@ 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=
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=
|
||||
AWS_HTTP_VERIFY=
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
1633
ShopIt_API_Postman_Collection.json
Normal file
1633
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
253
app/Domains/Attachable/Services/AttachmentService.php
Normal file
253
app/Domains/Attachable/Services/AttachmentService.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
38
app/Domains/Auth/Controllers/LoginController.php
Normal file
38
app/Domains/Auth/Controllers/LoginController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?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.',
|
||||
]);
|
||||
}
|
||||
|
||||
$token = $user->createToken('api-token')->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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
14
app/Domains/Auth/Controllers/MeController.php
Normal file
14
app/Domains/Auth/Controllers/MeController.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MeController
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json($request->user());
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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'])]
|
||||
#[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
|
||||
24
app/Domains/Auth/Requests/LoginUserRequest.php
Normal file
24
app/Domains/Auth/Requests/LoginUserRequest.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class LoginUserRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email', 'max:255'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Domains/Auth/Requests/RegisterUserRequest.php
Normal file
26
app/Domains/Auth/Requests/RegisterUserRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class RegisterUserRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
'password' => ['required', 'string', 'confirmed'],
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Domains/Auth/Resources/UserResource.php
Normal file
24
app/Domains/Auth/Resources/UserResource.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
20
app/Domains/Auth/Services/RegisterUserService.php
Normal file
20
app/Domains/Auth/Services/RegisterUserService.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
|
||||
class RegisterUserService
|
||||
{
|
||||
/**
|
||||
* @param array{nombre_apellido: string, email: string, password: string} $data
|
||||
*/
|
||||
public function register(array $data): User
|
||||
{
|
||||
return User::query()->create([
|
||||
'nombre_apellido' => $data['nombre_apellido'],
|
||||
'email' => $data['email'],
|
||||
'password' => $data['password'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
12
app/Domains/Auth/routes/api.php
Normal file
12
app/Domains/Auth/routes/api.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
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 Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/register', RegisterController::class);
|
||||
Route::post('/login', LoginController::class);
|
||||
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
|
||||
Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
||||
@@ -33,7 +33,9 @@ class CartController extends Controller
|
||||
(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']));
|
||||
@@ -54,13 +56,13 @@ class CartController extends Controller
|
||||
$productVariant->getKey(),
|
||||
(int) $request->validated('cantidad'),
|
||||
)
|
||||
);
|
||||
)->additional(['message' => 'Cantidad de producto actualizada.']);
|
||||
}
|
||||
|
||||
public function removeItem(Request $request, Tenant $tenant, ProductVariant $productVariant): CartResource
|
||||
{
|
||||
return CartResource::make(
|
||||
$this->cartService->removeItem($tenant, $request, $productVariant->getKey())
|
||||
);
|
||||
)->additional(['message' => 'Producto eliminado del carrito.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
namespace App\Domains\Cart\Models;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
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;
|
||||
@@ -68,9 +68,9 @@ class Cart extends Model
|
||||
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
|
||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||
|
||||
if ($variant->stock < $quantity) {
|
||||
if ($variant->stock_tecnico < $quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => 'Stock insuficiente para la variante solicitada.',
|
||||
'cantidad' => "Stock insuficiente para la variante solicitada. Maximo disponible: {$variant->stock_tecnico}.",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ class Cart extends Model
|
||||
$item->save();
|
||||
}
|
||||
|
||||
$variant->decrement('stock', $quantity);
|
||||
$variant->incrementReservedStock($quantity);
|
||||
|
||||
return $item->fresh();
|
||||
});
|
||||
@@ -114,9 +114,10 @@ class Cart extends Model
|
||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||
$delta = $quantity - $item->cantidad;
|
||||
|
||||
if ($delta > 0 && $variant->stock < $delta) {
|
||||
if ($delta > 0 && $variant->stock_tecnico < $delta) {
|
||||
$maxAvailable = $variant->stock_tecnico + $item->cantidad;
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => 'Stock insuficiente para actualizar la cantidad solicitada.',
|
||||
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -124,11 +125,11 @@ class Cart extends Model
|
||||
$item->save();
|
||||
|
||||
if ($delta > 0) {
|
||||
$variant->decrement('stock', $delta);
|
||||
$variant->incrementReservedStock($delta);
|
||||
}
|
||||
|
||||
if ($delta < 0) {
|
||||
$variant->increment('stock', abs($delta));
|
||||
$variant->decrementReservedStock(abs($delta));
|
||||
}
|
||||
|
||||
return $item->fresh();
|
||||
@@ -145,7 +146,7 @@ class Cart extends Model
|
||||
->firstOrFail();
|
||||
|
||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||
$variant->increment('stock', $item->cantidad);
|
||||
$variant->decrementReservedStock($item->cantidad);
|
||||
$item->delete();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Domains\Cart\Resources;
|
||||
|
||||
use App\Domains\Catalog\Resources\ProductVariantDefinitionResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -19,21 +18,40 @@ class CartItemResource extends JsonResource
|
||||
$variant = $this->variant;
|
||||
$product = $variant?->product;
|
||||
|
||||
$attributesText = '';
|
||||
if ($variant && $variant->relationLoaded('definitions')) {
|
||||
$attributesText = $variant->definitions
|
||||
->map(function ($definition) {
|
||||
$attrName = $definition->productAttribute?->attribute?->nombre;
|
||||
$value = $definition->value;
|
||||
return $attrName ? "{$attrName}: {$value}" : $value;
|
||||
})
|
||||
->filter()
|
||||
->implode(', ');
|
||||
}
|
||||
|
||||
$productName = $product?->nombre;
|
||||
if ($productName && $attributesText !== '') {
|
||||
$productName .= " ({$attributesText})";
|
||||
}
|
||||
|
||||
$imageUrl = null;
|
||||
if ($variant && $variant->relationLoaded('attachments')) {
|
||||
$firstAttachment = $variant->attachments->first();
|
||||
if ($firstAttachment) {
|
||||
$imageUrl = $firstAttachment->getTemporaryUrl(1440);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'cantidad' => $this->cantidad,
|
||||
'precio_unitario' => $this->formatMoney($variant?->precio),
|
||||
'subtotal' => $this->formatMoney(($variant?->precio ?? 0) * $this->cantidad),
|
||||
'precio_unitario' => $this->formatMoney($product?->precio),
|
||||
'product_id' => $product?->id,
|
||||
'product_variant_id' => $this->producto_variante_id,
|
||||
'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),
|
||||
'nombre' => $productName,
|
||||
'imagen' => $imageUrl,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ 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->variant?->product?->precio ?? 0) * $item->cantidad),
|
||||
0.0,
|
||||
);
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace App\Domains\Cart\Services;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
@@ -14,13 +14,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);
|
||||
@@ -94,7 +94,8 @@ class CartService
|
||||
{
|
||||
return $cart->fresh()->load([
|
||||
'items.variant.product',
|
||||
'items.variant.definitions.attribute',
|
||||
'items.variant.definitions.productAttribute.attribute',
|
||||
'items.variant.attachments',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
68
app/Domains/Catalog/Controllers/AttributeController.php
Normal file
68
app/Domains/Catalog/Controllers/AttributeController.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Requests\StoreAttributeRequest;
|
||||
use App\Domains\Catalog\Requests\UpdateAttributeRequest;
|
||||
use App\Domains\Catalog\Resources\AttributeResource;
|
||||
use App\Domains\Catalog\Services\ProductService;
|
||||
use App\Domains\Shared\Enums\FieldType;
|
||||
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 AttributeController extends Controller
|
||||
{
|
||||
public function index(Tenant $tenant): JsonResponse
|
||||
{
|
||||
$query = Attribute::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->with('options')
|
||||
->latest();
|
||||
|
||||
return AttributeResource::collection($query->paginateFromRequest())->response();
|
||||
}
|
||||
|
||||
public function store(StoreAttributeRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
$attribute = ProductService::createAttribute($tenant, $request->validated());
|
||||
|
||||
return AttributeResource::make($attribute)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant, Attribute $attribute): AttributeResource
|
||||
{
|
||||
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
|
||||
|
||||
return AttributeResource::make($attribute->load('options'));
|
||||
}
|
||||
|
||||
public function update(UpdateAttributeRequest $request, Tenant $tenant, Attribute $attribute): AttributeResource
|
||||
{
|
||||
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
|
||||
$attribute = ProductService::updateAttribute($attribute, $request->validated());
|
||||
|
||||
return AttributeResource::make($attribute);
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant, Attribute $attribute): Response
|
||||
{
|
||||
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
|
||||
ProductService::deleteAttribute($attribute);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
protected function resolveScopedAttribute(Tenant $tenant, Attribute $attribute): Attribute
|
||||
{
|
||||
if ($attribute->tenant_codigo !== $tenant->codigo) {
|
||||
throw new NotFoundHttpException('Attribute not found for tenant.');
|
||||
}
|
||||
|
||||
return $attribute;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class BrandController extends Controller
|
||||
public function index(Tenant $tenant): JsonResponse
|
||||
{
|
||||
return BrandResource::collection(
|
||||
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->get()
|
||||
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->paginateFromRequest()
|
||||
)->response();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class CategoryController extends Controller
|
||||
@@ -18,7 +19,11 @@ class CategoryController extends Controller
|
||||
{
|
||||
return CategoryResource::collection(
|
||||
Category::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where(function ($query) use ($tenant): void {
|
||||
$query
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->orWhereNull('tenant_code');
|
||||
})
|
||||
->with(['parent', 'subCategories', 'tenant'])
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
@@ -47,6 +52,7 @@ class CategoryController extends Controller
|
||||
public function update(UpdateCategoryRequest $request, Tenant $tenant, Category $categoria): CategoryResource
|
||||
{
|
||||
$categoria = $this->resolveScopedCategory($tenant, $categoria);
|
||||
$this->ensureCategoryIsMutable($categoria);
|
||||
$categoria->update($request->validated());
|
||||
|
||||
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
|
||||
@@ -55,6 +61,7 @@ class CategoryController extends Controller
|
||||
public function destroy(Tenant $tenant, Category $categoria): Response
|
||||
{
|
||||
$categoria = $this->resolveScopedCategory($tenant, $categoria);
|
||||
$this->ensureCategoryIsMutable($categoria);
|
||||
$categoria->delete();
|
||||
|
||||
return response()->noContent();
|
||||
@@ -62,10 +69,17 @@ class CategoryController extends Controller
|
||||
|
||||
protected function resolveScopedCategory(Tenant $tenant, Category $category): Category
|
||||
{
|
||||
if ($category->tenant_code !== $tenant->codigo) {
|
||||
if ($category->tenant_code !== null && $category->tenant_code !== $tenant->codigo) {
|
||||
throw new NotFoundHttpException('Category not found for tenant.');
|
||||
}
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
protected function ensureCategoryIsMutable(Category $category): void
|
||||
{
|
||||
if ($category->isGlobal()) {
|
||||
throw new AccessDeniedHttpException('Global categories are read-only.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -3,53 +3,56 @@
|
||||
namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use App\Domains\Catalog\Requests\ProductDetailRequest;
|
||||
use App\Domains\Catalog\Requests\StoreProductRequest;
|
||||
use App\Domains\Catalog\Requests\UpdateProductRequest;
|
||||
use App\Domains\Catalog\Resources\ProductResource;
|
||||
use App\Domains\Catalog\Services\ProductService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function index(Tenant $tenant): JsonResponse
|
||||
public function index(Tenant $tenant, ProductService $productService): JsonResponse
|
||||
{
|
||||
return ProductResource::collection(
|
||||
Product::query()->where('tenant_codigo', $tenant->codigo)->latest()->get()
|
||||
$productService->getProductos($tenant)
|
||||
)->response();
|
||||
}
|
||||
|
||||
public function store(StoreProductRequest $request, Tenant $tenant): JsonResponse
|
||||
public function store(StoreProductRequest $request, Tenant $tenant, ProductService $productService): JsonResponse
|
||||
{
|
||||
$product = Product::query()->create([
|
||||
...$request->validated(),
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
$product = $productService->create($tenant, $request->validated());
|
||||
|
||||
return ProductResource::make($product)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant, Product $producto): ProductResource
|
||||
public function show(ProductDetailRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
|
||||
{
|
||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||
$variantId = $request->query('variant_id');
|
||||
$variantId = $variantId !== null ? (int) $variantId : null;
|
||||
$producto = $productService->getProductDetail($tenant, $producto, $variantId);
|
||||
|
||||
return ProductResource::make($producto);
|
||||
}
|
||||
|
||||
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto): ProductResource
|
||||
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
|
||||
{
|
||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||
$producto->update($request->validated());
|
||||
$producto = $productService->update($producto, $request->validated());
|
||||
|
||||
return ProductResource::make($producto);
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant, Product $producto): Response
|
||||
public function destroy(Tenant $tenant, Product $producto, ProductService $productService): Response
|
||||
{
|
||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||
$producto->delete();
|
||||
$productService->delete($producto);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
@@ -57,7 +60,7 @@ class ProductController extends Controller
|
||||
protected function resolveScopedProduct(Tenant $tenant, Product $product): Product
|
||||
{
|
||||
if ($product->tenant_codigo !== $tenant->codigo) {
|
||||
throw new NotFoundHttpException('Product not found for tenant.');
|
||||
throw new NotFoundHttpException('Product not found.');
|
||||
}
|
||||
|
||||
return $product;
|
||||
|
||||
@@ -7,103 +7,81 @@ 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\Catalog\Services\ProductService;
|
||||
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
|
||||
public function index(Tenant $tenant, Product $producto): JsonResponse
|
||||
{
|
||||
|
||||
$query = ProductVariant::query()
|
||||
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo))
|
||||
->with(['product', 'definitions.attribute'])
|
||||
->where('producto_id', $producto->id)
|
||||
->with(['product', 'definitions.productAttribute.attribute.options', 'attachments'])
|
||||
->latest();
|
||||
|
||||
return ProductVariantResource::collection($query->get())->response();
|
||||
return ProductVariantResource::collection($query->paginateFromRequest())->response();
|
||||
}
|
||||
|
||||
public function store(StoreProductVariantRequest $request, Tenant $tenant): JsonResponse
|
||||
public function store(StoreProductVariantRequest $request, Tenant $tenant, Product $producto, ProductService $productService): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$variant = DB::transaction(function () use ($request, $tenant): ProductVariant {
|
||||
|
||||
$definitions = $validated['definitions'] ?? [];
|
||||
unset($validated['definitions']);
|
||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||
|
||||
$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']);
|
||||
});
|
||||
$variant = $productService->createVariant($producto, $request->validated());
|
||||
|
||||
return ProductVariantResource::make($variant)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant, ProductVariant $productVariant): ProductVariantResource
|
||||
public function show(Tenant $tenant, Product $producto, ProductVariant $productVariant): ProductVariantResource
|
||||
{
|
||||
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
|
||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||
$productVariant = $this->resolveScopedVariant($producto, $productVariant);
|
||||
|
||||
return ProductVariantResource::make($productVariant->load(['product', 'definitions.attribute']));
|
||||
return ProductVariantResource::make($productVariant->load([
|
||||
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
|
||||
'definitions.productAttribute.attribute.options',
|
||||
'product.attachments' => fn ($query) => $query->orderBy('attachments.id'),
|
||||
]));
|
||||
}
|
||||
|
||||
public function update(UpdateProductVariantRequest $request, Tenant $tenant, ProductVariant $productVariant): ProductVariantResource
|
||||
public function update(UpdateProductVariantRequest $request, Tenant $tenant, Product $producto, ProductVariant $productVariant, ProductService $productService): ProductVariantResource
|
||||
{
|
||||
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
|
||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||
$productVariant = $this->resolveScopedVariant($producto, $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']);
|
||||
});
|
||||
$productVariant = $productService->updateVariant($productVariant, $request->validated());
|
||||
|
||||
return ProductVariantResource::make($productVariant);
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant, ProductVariant $productVariant): Response
|
||||
public function destroy(Tenant $tenant, Product $producto, ProductVariant $productVariant, ProductService $productService): Response
|
||||
{
|
||||
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
|
||||
$productVariant->delete();
|
||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||
$productVariant = $this->resolveScopedVariant($producto, $productVariant);
|
||||
$productService->deleteVariant($productVariant);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
protected function resolveScopedVariant(Tenant $tenant, ProductVariant $variant): ProductVariant
|
||||
protected function resolveScopedProduct(Tenant $tenant, Product $product): Product
|
||||
{
|
||||
$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) {
|
||||
if ($product->tenant_codigo !== $tenant->codigo) {
|
||||
throw new NotFoundHttpException('Product not found for tenant.');
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
protected function resolveScopedVariant(Product $product, ProductVariant $variant): ProductVariant
|
||||
{
|
||||
if ($variant->producto_id !== $product->id) {
|
||||
throw new NotFoundHttpException('Product variant not found for product.');
|
||||
}
|
||||
|
||||
return $variant;
|
||||
}
|
||||
}
|
||||
|
||||
78
app/Domains/Catalog/Models/Attribute.php
Normal file
78
app/Domains/Catalog/Models/Attribute.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Shared\Enums\FieldType;
|
||||
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;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_codigo',
|
||||
'codigo',
|
||||
'nombre',
|
||||
'is_required',
|
||||
'metadata_schema',
|
||||
'type',
|
||||
])]
|
||||
class Attribute extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'attribute';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_required' => 'boolean',
|
||||
'metadata_schema' => 'array',
|
||||
'type' => FieldType::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<AttributeOption, $this>
|
||||
*/
|
||||
public function options(): HasMany
|
||||
{
|
||||
return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProductAttribute, $this>
|
||||
*/
|
||||
public function productAttributes(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductAttribute::class, 'attribute_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasManyThrough<ProductVariantDefinition, ProductAttribute, $this>
|
||||
*/
|
||||
public function variantDefinitions(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(
|
||||
ProductVariantDefinition::class,
|
||||
ProductAttribute::class,
|
||||
'attribute_id',
|
||||
'products_attribute_id',
|
||||
'id',
|
||||
'id'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,22 @@
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Models\Brand;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\ProductAttribute;
|
||||
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_codigo',
|
||||
'categoria_id',
|
||||
'brand_id',
|
||||
'slug',
|
||||
'nombre',
|
||||
'descripcion',
|
||||
@@ -22,11 +28,13 @@ class Product extends Model
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'productos';
|
||||
|
||||
|
||||
protected ?ProductVariant $selectedVariant = null;
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'categoria_id' => 'integer',
|
||||
'brand_id' => 'integer',
|
||||
'precio' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
@@ -39,6 +47,22 @@ class Product extends Model
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Category, $this>
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'categoria_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Brand, $this>
|
||||
*/
|
||||
public function brand(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Brand::class, 'brand_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProductVariant, $this>
|
||||
*/
|
||||
@@ -46,4 +70,235 @@ class Product extends Model
|
||||
{
|
||||
return $this->hasMany(ProductVariant::class, 'producto_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Attribute, $this>
|
||||
*/
|
||||
public function attributes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Attribute::class,
|
||||
'products_attributes',
|
||||
'product_id',
|
||||
'attribute_id'
|
||||
)->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProductAttribute, $this>
|
||||
*/
|
||||
public function productAttributes(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductAttribute::class, 'product_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Attachment, $this>
|
||||
*/
|
||||
public function attachments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Attachment::class,
|
||||
'productos_attachments',
|
||||
'producto_id',
|
||||
'attachment_id'
|
||||
)->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a variant for this product with its definitions.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function createVariant(array $data): ProductVariant
|
||||
{
|
||||
$definitions = $data['definitions'] ?? [];
|
||||
$this->validateVariantDefinitions($definitions);
|
||||
|
||||
unset($data['definitions']);
|
||||
|
||||
/** @var ProductVariant $variant */
|
||||
$variant = $this->variants()->create($data);
|
||||
$variant->definitions()->createMany($definitions);
|
||||
|
||||
return $variant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple variants for this product.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $variantsData
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, ProductVariant>
|
||||
*/
|
||||
public function createVariants(array $variantsData): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
$variants = new \Illuminate\Database\Eloquent\Collection();
|
||||
|
||||
foreach ($variantsData as $variantData) {
|
||||
$variants->push($this->createVariant($variantData));
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a product variant.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function updateVariant(ProductVariant $variant, array $data): ProductVariant
|
||||
{
|
||||
$definitions = $data['definitions'] ?? [];
|
||||
$this->validateVariantDefinitions($definitions);
|
||||
|
||||
unset($data['definitions']);
|
||||
unset($data['producto_id']);
|
||||
|
||||
$variant->update($data);
|
||||
$variant->definitions()->delete();
|
||||
$variant->definitions()->createMany($definitions);
|
||||
|
||||
return $variant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a product variant.
|
||||
*/
|
||||
public function deleteVariant(ProductVariant $variant): void
|
||||
{
|
||||
$variant->definitions()->delete();
|
||||
$variant->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate variant definitions options against Attribute configuration.
|
||||
*
|
||||
* @param array $definitions
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
protected function validateVariantDefinitions(array $definitions): void
|
||||
{
|
||||
foreach ($definitions as $definition) {
|
||||
$productAttributeId = $definition['products_attribute_id'] ?? null;
|
||||
if (! $productAttributeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$productAttribute = $this->productAttributes()
|
||||
->with('attribute.options')
|
||||
->find($productAttributeId);
|
||||
|
||||
$attribute = $productAttribute?->attribute;
|
||||
|
||||
if (! $productAttribute || ! $attribute) {
|
||||
throw new \InvalidArgumentException("Product attribute with ID {$productAttributeId} not found for product {$this->id}.");
|
||||
}
|
||||
|
||||
if ($attribute->type === \App\Domains\Shared\Enums\FieldType::Select) {
|
||||
$allowedValues = $attribute->options()->pluck('value')->toArray();
|
||||
$val = $definition['value'] ?? null;
|
||||
if ($val !== null && ! in_array($val, $allowedValues, true)) {
|
||||
throw new \InvalidArgumentException("The value '{$val}' is not a valid option for the select attribute '{$attribute->nombre}'.");
|
||||
}
|
||||
} elseif ($attribute->type === \App\Domains\Shared\Enums\FieldType::Multiselect) {
|
||||
$allowedValues = $attribute->options()->pluck('value')->toArray();
|
||||
$val = $definition['value'] ?? null;
|
||||
if ($val !== null) {
|
||||
$values = [];
|
||||
if (is_array($val)) {
|
||||
$values = $val;
|
||||
} else {
|
||||
$decoded = json_decode($val, true);
|
||||
if (is_array($decoded)) {
|
||||
$values = $decoded;
|
||||
} else {
|
||||
$values = array_map('trim', explode(',', $val));
|
||||
}
|
||||
}
|
||||
foreach ($values as $v) {
|
||||
if (! in_array($v, $allowedValues, true)) {
|
||||
throw new \InvalidArgumentException("The value '{$v}' is not a valid option for the multiselect attribute '{$attribute->nombre}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an attribute.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function createAttribute(Tenant $tenant, array $data): Attribute
|
||||
{
|
||||
$options = $data['options'] ?? [];
|
||||
unset($data['options']);
|
||||
|
||||
$type = \App\Domains\Shared\Enums\FieldType::from((string) $data['type']);
|
||||
if (! $type->supportsOptions() && ! empty($options)) {
|
||||
throw new \InvalidArgumentException('Options are only allowed for select and multiselect attributes.');
|
||||
}
|
||||
|
||||
if (! $type->supportsOptions()) {
|
||||
$data['metadata_schema'] = null;
|
||||
}
|
||||
|
||||
/** @var Attribute $attribute */
|
||||
$attribute = Attribute::query()->create([
|
||||
...$data,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
$attribute->options()->createMany($options);
|
||||
|
||||
return $attribute->load('options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an attribute.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function updateAttribute(Attribute $attribute, array $data): Attribute
|
||||
{
|
||||
$options = $data['options'] ?? [];
|
||||
unset($data['options']);
|
||||
|
||||
$typeStr = $data['type'] ?? $attribute->type->value;
|
||||
$type = \App\Domains\Shared\Enums\FieldType::from((string) $typeStr);
|
||||
if (! $type->supportsOptions() && ! empty($options)) {
|
||||
throw new \InvalidArgumentException('Options are only allowed for select and multiselect attributes.');
|
||||
}
|
||||
|
||||
if (! $type->supportsOptions()) {
|
||||
$data['metadata_schema'] = null;
|
||||
}
|
||||
|
||||
$attribute->update($data);
|
||||
$attribute->options()->delete();
|
||||
$attribute->options()->createMany($options);
|
||||
|
||||
return $attribute->load('options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an attribute.
|
||||
*/
|
||||
public static function deleteAttribute(Attribute $attribute): void
|
||||
{
|
||||
$attribute->options()->delete();
|
||||
$attribute->delete();
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function setSelectedVariant(ProductVariant $variant): void
|
||||
{
|
||||
$this->selectedVariant = $variant;
|
||||
}
|
||||
|
||||
public function getSelectedVariant(): ?ProductVariant
|
||||
{
|
||||
return $this->selectedVariant;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -11,45 +9,29 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_codigo',
|
||||
'codigo',
|
||||
'nombre',
|
||||
'is_required',
|
||||
'metadata_schema',
|
||||
'type',
|
||||
'product_id',
|
||||
'attribute_id',
|
||||
])]
|
||||
class ProductAttribute extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'productos_attributes';
|
||||
protected $table = 'products_attributes';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
* @return BelongsTo<Product, $this>
|
||||
*/
|
||||
protected function casts(): array
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return [
|
||||
'is_required' => 'boolean',
|
||||
'metadata_schema' => 'array',
|
||||
'type' => ProductAttributeType::class,
|
||||
];
|
||||
return $this->belongsTo(Product::class, 'product_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
* @return BelongsTo<Attribute, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
public function attribute(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProductAttributeOption, $this>
|
||||
*/
|
||||
public function options(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductAttributeOption::class, 'attribute_id')->orderBy('sort_order');
|
||||
return $this->belongsTo(Attribute::class, 'attribute_id');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,6 +39,6 @@ class ProductAttribute extends Model
|
||||
*/
|
||||
public function variantDefinitions(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductVariantDefinition::class, 'attribute_id');
|
||||
return $this->hasMany(ProductVariantDefinition::class, 'products_attribute_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,22 @@
|
||||
|
||||
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;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
|
||||
#[Fillable([
|
||||
'producto_id',
|
||||
'slug',
|
||||
'nombre',
|
||||
'stock_real',
|
||||
'stock_reservado',
|
||||
'stock',
|
||||
'descripcion',
|
||||
'precio',
|
||||
'is_placeholder',
|
||||
])]
|
||||
class ProductVariant extends Model
|
||||
{
|
||||
@@ -22,12 +25,88 @@ class ProductVariant extends Model
|
||||
|
||||
protected $table = 'productos_variantes';
|
||||
|
||||
protected $appends = ['stock_tecnico'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (ProductVariant $variant) {
|
||||
$variant->validateStock();
|
||||
});
|
||||
}
|
||||
|
||||
public function validateStock(): void
|
||||
{
|
||||
if ($this->stock_real < 0) {
|
||||
throw new \InvalidArgumentException('El stock real no puede ser negativo.');
|
||||
}
|
||||
|
||||
if ($this->stock_reservado < 0) {
|
||||
throw new \InvalidArgumentException('El stock reservado no puede ser negativo.');
|
||||
}
|
||||
|
||||
if ($this->stock_reservado > $this->stock_real) {
|
||||
throw new \InvalidArgumentException('El stock reservado no puede ser mayor que el stock real.');
|
||||
}
|
||||
}
|
||||
|
||||
public function incrementRealStock(int $amount): void
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.');
|
||||
}
|
||||
$this->stock_real += $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function decrementRealStock(int $amount): void
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
|
||||
}
|
||||
$this->stock_real -= $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function incrementReservedStock(int $amount): void
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.');
|
||||
}
|
||||
$this->stock_reservado += $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function decrementReservedStock(int $amount): void
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
|
||||
}
|
||||
$this->stock_reservado -= $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
protected function stockTecnico(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => $this->stock_real - $this->stock_reservado);
|
||||
}
|
||||
|
||||
protected function stock(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->stock_real,
|
||||
set: fn ($value) => [
|
||||
'stock_real' => $value,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'producto_id' => 'integer',
|
||||
'stock' => 'integer',
|
||||
'precio' => 'decimal:2',
|
||||
'stock_real' => 'integer',
|
||||
'stock_reservado' => 'integer',
|
||||
'is_placeholder' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -46,4 +125,17 @@ class ProductVariant extends Model
|
||||
{
|
||||
return $this->hasMany(ProductVariantDefinition::class, 'producto_variante_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Attachment, $this>
|
||||
*/
|
||||
public function attachments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Attachment::class,
|
||||
'variantes_attachments',
|
||||
'variante_id',
|
||||
'attachment_id'
|
||||
)->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'producto_variante_id',
|
||||
'attribute_id',
|
||||
'products_attribute_id',
|
||||
'value',
|
||||
])]
|
||||
class ProductVariantDefinition extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'productos_variantes_definiciones';
|
||||
protected $table = 'productos_variantes_values';
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ProductVariant, $this>
|
||||
@@ -29,8 +29,8 @@ class ProductVariantDefinition extends Model
|
||||
/**
|
||||
* @return BelongsTo<ProductAttribute, $this>
|
||||
*/
|
||||
public function attribute(): BelongsTo
|
||||
public function productAttribute(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
|
||||
return $this->belongsTo(ProductAttribute::class, 'products_attribute_id');
|
||||
}
|
||||
}
|
||||
|
||||
23
app/Domains/Catalog/Requests/ProductDetailRequest.php
Normal file
23
app/Domains/Catalog/Requests/ProductDetailRequest.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ProductDetailRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'variant_id' => ['sometimes', 'integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
||||
use App\Domains\Shared\Enums\FieldType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class StoreProductAttributeRequest extends FormRequest
|
||||
class StoreAttributeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -24,25 +24,26 @@ class StoreProductAttributeRequest extends FormRequest
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('productos_attributes', 'codigo')->where(
|
||||
Rule::unique('attribute', '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)],
|
||||
'type' => ['required', Rule::enum(FieldType::class)],
|
||||
'options' => [
|
||||
Rule::requiredIf(fn (): bool => in_array($this->input('type'), [
|
||||
ProductAttributeType::Select->value,
|
||||
ProductAttributeType::Multiselect->value,
|
||||
FieldType::Select->value,
|
||||
FieldType::Multiselect->value,
|
||||
], true)),
|
||||
Rule::prohibitedIf(fn (): bool => ! in_array($this->input('type'), [
|
||||
ProductAttributeType::Select->value,
|
||||
ProductAttributeType::Multiselect->value,
|
||||
FieldType::Select->value,
|
||||
FieldType::Multiselect->value,
|
||||
], true)),
|
||||
'array',
|
||||
],
|
||||
'options.*.value' => ['required', 'string', 'max:255'],
|
||||
'options.*.label' => ['required', 'string', 'max:255'],
|
||||
'options.*.sort_order' => ['sometimes', 'integer'],
|
||||
'options.*.metadata' => ['nullable', 'array'],
|
||||
@@ -53,7 +54,7 @@ class StoreProductAttributeRequest extends FormRequest
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
$type = $this->input('type');
|
||||
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::Multiselect->value], true);
|
||||
$supportsOptions = in_array($type, [FieldType::Select->value, FieldType::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.');
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -19,10 +20,28 @@ class StoreProductRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'categoria_id' => ['required', 'integer'],
|
||||
'brand_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('brands', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'slug' => ['required', 'string', 'max:255', Rule::unique('productos', 'slug')],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'descripcion' => ['nullable', 'string'],
|
||||
'precio' => ['required', 'numeric', 'min:0'],
|
||||
'stock' => ['sometimes', 'integer', 'min:0'],
|
||||
'attribute_ids' => ['sometimes', 'array'],
|
||||
'attribute_ids.*' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('attribute', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'images' => ['sometimes', 'nullable', 'array'],
|
||||
'images.*' => ['required', new ImageOrBase64Rule()],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -18,35 +19,19 @@ class StoreProductVariantRequest extends FormRequest
|
||||
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' => [
|
||||
'definitions.*.products_attribute_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
'distinct',
|
||||
Rule::exists('productos_attributes', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
Rule::exists('products_attributes', 'id')->where(
|
||||
fn ($query) => $query->where('product_id', $this->route('producto')?->id)
|
||||
),
|
||||
],
|
||||
'definitions.*.value' => ['nullable', 'string'],
|
||||
'images' => ['sometimes', 'nullable', 'array'],
|
||||
'images.*' => ['required', new ImageOrBase64Rule()],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductAttribute;
|
||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Shared\Enums\FieldType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class UpdateProductAttributeRequest extends FormRequest
|
||||
class UpdateAttributeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -20,23 +20,24 @@ class UpdateProductAttributeRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var ProductAttribute|null $attribute */
|
||||
$attribute = $this->route('productAttribute');
|
||||
/** @var Attribute|null $attribute */
|
||||
$attribute = $this->route('attribute');
|
||||
|
||||
return [
|
||||
'codigo' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('productos_attributes', 'codigo')
|
||||
Rule::unique('attribute', '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)],
|
||||
'type' => ['required', Rule::enum(FieldType::class)],
|
||||
'options' => ['sometimes', 'array'],
|
||||
'options.*.value' => ['required', 'string', 'max:255'],
|
||||
'options.*.label' => ['required', 'string', 'max:255'],
|
||||
'options.*.sort_order' => ['sometimes', 'integer'],
|
||||
'options.*.metadata' => ['nullable', 'array'],
|
||||
@@ -47,7 +48,7 @@ class UpdateProductAttributeRequest extends FormRequest
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
$type = $this->input('type');
|
||||
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::Multiselect->value], true);
|
||||
$supportsOptions = in_array($type, [FieldType::Select->value, FieldType::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.');
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -23,6 +24,13 @@ class UpdateProductRequest extends FormRequest
|
||||
|
||||
return [
|
||||
'categoria_id' => ['required', 'integer'],
|
||||
'brand_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('brands', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'slug' => [
|
||||
'required',
|
||||
'string',
|
||||
@@ -32,6 +40,16 @@ class UpdateProductRequest extends FormRequest
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'descripcion' => ['nullable', 'string'],
|
||||
'precio' => ['required', 'numeric', 'min:0'],
|
||||
'attribute_ids' => ['sometimes', 'array'],
|
||||
'attribute_ids.*' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('attribute', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'images' => ['sometimes', 'nullable', 'array'],
|
||||
'images.*' => ['required', new ImageOrBase64Rule()],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -18,39 +18,20 @@ class UpdateProductVariantRequest extends FormRequest
|
||||
*/
|
||||
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' => [
|
||||
'definitions.*.products_attribute_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
'distinct',
|
||||
Rule::exists('productos_attributes', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
Rule::exists('products_attributes', 'id')->where(
|
||||
fn ($query) => $query->where('product_id', $this->route('producto')?->id)
|
||||
),
|
||||
],
|
||||
'definitions.*.value' => ['nullable', 'string'],
|
||||
'images' => ['sometimes', 'nullable', 'array'],
|
||||
'images.*' => ['required', new ImageOrBase64Rule()],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Catalog\Models\ProductAttributeOption
|
||||
* @mixin \App\Domains\Catalog\Models\AttributeOption
|
||||
*/
|
||||
class ProductAttributeOptionResource extends JsonResource
|
||||
class AttributeOptionResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
@@ -17,6 +17,7 @@ class ProductAttributeOptionResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'value' => $this->value,
|
||||
'label' => $this->label,
|
||||
'sort_order' => $this->sort_order,
|
||||
'metadata' => $this->metadata,
|
||||
@@ -6,9 +6,9 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Catalog\Models\ProductAttribute
|
||||
* @mixin \App\Domains\Catalog\Models\Attribute
|
||||
*/
|
||||
class ProductAttributeResource extends JsonResource
|
||||
class AttributeResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
@@ -17,13 +17,12 @@ class ProductAttributeResource extends JsonResource
|
||||
{
|
||||
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')),
|
||||
'options' => AttributeOptionResource::collection($this->whenLoaded('options')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ class BrandResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'tenant_codigo' => $this->tenant_codigo,
|
||||
'nombre' => $this->nombre,
|
||||
'descripcion' => $this->descripcion,
|
||||
];
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use App\Domains\Tenant\Resources\TenantResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -18,15 +17,28 @@ class CategoryResource extends JsonResource
|
||||
{
|
||||
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')),
|
||||
'parent' => $this->whenLoaded('parent', fn () => $this->serializeRelatedCategory($this->parent)),
|
||||
'sub_categories' => $this->whenLoaded('subCategories', fn () =>
|
||||
$this->subCategories->map(fn ($category) => $this->serializeRelatedCategory($category))->values()
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
protected function serializeRelatedCategory(?\App\Domains\Catalog\Models\Category $category): ?array
|
||||
{
|
||||
if ($category === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $category->id,
|
||||
'is_global' => $category->isGlobal(),
|
||||
'nombre' => $category->nombre,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Catalog\Models\Product
|
||||
* @mixin Product
|
||||
*/
|
||||
class ProductResource extends JsonResource
|
||||
{
|
||||
@@ -17,14 +18,36 @@ class ProductResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'tenant_codigo' => $this->tenant_codigo,
|
||||
'categoria_id' => $this->categoria_id,
|
||||
'category_id' => $this->categoria_id,
|
||||
'brand_id' => $this->brand_id,
|
||||
'slug' => $this->slug,
|
||||
'nombre' => $this->nombre,
|
||||
'descripcion' => $this->descripcion,
|
||||
'precio' => $this->precio,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
'category' => $this->whenLoaded('category', fn () => $this->category?->nombre),
|
||||
'brand' => $this->whenLoaded('brand', fn () => $this->brand?->nombre),
|
||||
'images' => $this->whenLoaded('attachments', fn () => $this->attachments
|
||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||
->values()
|
||||
),
|
||||
'attributes' => AttributeResource::collection($this->whenLoaded('attributes')),
|
||||
'variants_map' => $this->whenLoaded('variants', fn () => $this->variants
|
||||
->map(fn ($variant) => [
|
||||
'variant_id' => $variant->id,
|
||||
'cantidad_maxima' => $variant->stock_tecnico,
|
||||
'attributes' => $variant->definitions
|
||||
->mapWithKeys(fn ($definition) => [
|
||||
$definition->productAttribute?->attribute?->codigo => $definition->value,
|
||||
])
|
||||
->filter(fn ($value, $key) => $key !== null)
|
||||
->toArray(),
|
||||
])
|
||||
->values()
|
||||
),
|
||||
'variant' => $this->when(
|
||||
$this->getSelectedVariant() !== null,
|
||||
fn () => ProductVariantResource::make($this->getSelectedVariant())
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,35 @@ class ProductVariantDefinitionResource extends JsonResource
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'producto_variante_id' => $this->producto_variante_id,
|
||||
'attribute_id' => $this->attribute_id,
|
||||
'products_attribute_id' => $this->products_attribute_id,
|
||||
'attribute_id' => $this->whenLoaded('productAttribute', fn () => $this->productAttribute?->attribute_id),
|
||||
'value' => $this->value,
|
||||
'attribute' => ProductAttributeResource::make($this->whenLoaded('attribute')),
|
||||
'attribute' => $this->whenLoaded('productAttribute', fn () => $this->productAttribute?->attribute?->nombre),
|
||||
'metadata' => $this->resolveMetadata(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
protected function resolveMetadata(): ?array
|
||||
{
|
||||
if (! $this->relationLoaded('productAttribute')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attribute = $this->productAttribute?->attribute;
|
||||
|
||||
if (! $attribute?->relationLoaded('options')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$option = $attribute->options->firstWhere('value', $this->value);
|
||||
|
||||
if ($option === null || $option->metadata === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $option->metadata;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Catalog\Models\ProductVariant
|
||||
* @mixin ProductVariant
|
||||
*/
|
||||
class ProductVariantResource extends JsonResource
|
||||
{
|
||||
@@ -17,16 +18,38 @@ class ProductVariantResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'producto_id' => $this->producto_id,
|
||||
'slug' => $this->slug,
|
||||
'nombre' => $this->nombre,
|
||||
'stock' => $this->stock,
|
||||
'descripcion' => $this->descripcion,
|
||||
'precio' => $this->precio,
|
||||
'cantidad_maxima' => $this->stock_tecnico,
|
||||
'product' => ProductResource::make($this->whenLoaded('product')),
|
||||
'definitions' => ProductVariantDefinitionResource::collection($this->whenLoaded('definitions')),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
'definitions' => $this->whenLoaded(
|
||||
'definitions',
|
||||
fn () => $this->definitions
|
||||
->mapWithKeys(fn ($definition) => [
|
||||
$definition->productAttribute?->attribute?->codigo => $definition->value,
|
||||
])
|
||||
->filter(fn ($value, $key) => $key !== null)
|
||||
->toArray()
|
||||
),
|
||||
'images' => $this->whenLoaded('attachments', function () {
|
||||
if ($this->attachments->isNotEmpty()) {
|
||||
return $this->attachments
|
||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||
->values();
|
||||
}
|
||||
|
||||
if ($this->relationLoaded('fallbackAttachments')) {
|
||||
return $this->fallbackAttachments
|
||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||
->values();
|
||||
}
|
||||
|
||||
if ($this->relationLoaded('product') && $this->product?->relationLoaded('attachments')) {
|
||||
return $this->product->attachments
|
||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||
->values();
|
||||
}
|
||||
|
||||
return collect();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,385 @@
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Attachable\Services\AttachmentService;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class ProductService
|
||||
{
|
||||
// Reserved for product domain business logic.
|
||||
public function __construct(protected AttachmentService $attachmentService) {}
|
||||
|
||||
/**
|
||||
* Create a product.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function create(Tenant $tenant, array $data): Product
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $data) {
|
||||
$attributeIds = $data['attribute_ids'] ?? [];
|
||||
$images = $data['images'] ?? [];
|
||||
$stock = $data['stock'] ?? 0;
|
||||
unset($data['attribute_ids'], $data['images'], $data['stock']);
|
||||
|
||||
/** @var Product $product */
|
||||
$product = Product::query()->create([
|
||||
...$data,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
|
||||
$product->attributes()->sync($attributeIds);
|
||||
|
||||
if (! empty($images)) {
|
||||
$this->syncProductImages($product, $images);
|
||||
}
|
||||
|
||||
// Create default variant with stock
|
||||
$this->createVariant($product, [
|
||||
'stock' => $stock,
|
||||
'is_placeholder' => true,
|
||||
'definitions' => [],
|
||||
]);
|
||||
|
||||
return $product->load(['attributes.options', 'attachments', 'brand', 'category']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a product.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function update(Product $product, array $data): Product
|
||||
{
|
||||
return DB::transaction(function () use ($product, $data) {
|
||||
$hasAttributeIds = array_key_exists('attribute_ids', $data);
|
||||
$attributeIds = $data['attribute_ids'] ?? [];
|
||||
$hasImages = array_key_exists('images', $data);
|
||||
$images = $data['images'] ?? [];
|
||||
unset($data['attribute_ids'], $data['images']);
|
||||
|
||||
// Ensure tenant_codigo cannot be updated/changed
|
||||
unset($data['tenant_codigo']);
|
||||
|
||||
$product->update($data);
|
||||
|
||||
if ($hasAttributeIds) {
|
||||
$product->attributes()->sync($attributeIds);
|
||||
}
|
||||
|
||||
if ($hasImages) {
|
||||
$this->syncProductImages($product, $images);
|
||||
}
|
||||
|
||||
return $product->load(['attributes.options', 'attachments', 'brand', 'category']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a product.
|
||||
*/
|
||||
public function delete(Product $product): void
|
||||
{
|
||||
DB::transaction(function () use ($product) {
|
||||
foreach ($product->variants as $variant) {
|
||||
$this->deleteVariantAttachments($variant);
|
||||
$product->deleteVariant($variant);
|
||||
}
|
||||
|
||||
// Delete product-level attachments from S3 and database
|
||||
$existing = $product->attachments()->get();
|
||||
$product->attachments()->detach();
|
||||
foreach ($existing as $attachment) {
|
||||
$this->attachmentService->delete($attachment);
|
||||
}
|
||||
|
||||
$product->attributes()->detach();
|
||||
$product->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a product variant.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function createVariant(Product $product, array $data): ProductVariant
|
||||
{
|
||||
return DB::transaction(function () use ($product, $data) {
|
||||
$images = $data['images'] ?? [];
|
||||
unset($data['images']);
|
||||
|
||||
// Determine if the variant being created is a placeholder one
|
||||
$hasDefinitions = ! empty($data['definitions']);
|
||||
$isPlaceholder = $data['is_placeholder'] ?? (! $hasDefinitions);
|
||||
$data['is_placeholder'] = $isPlaceholder;
|
||||
|
||||
// Remove any existing placeholder variants
|
||||
$defaultVariants = $product->variants()->where('is_placeholder', true)->get();
|
||||
foreach ($defaultVariants as $defaultVariant) {
|
||||
$this->deleteVariantAttachments($defaultVariant);
|
||||
$product->deleteVariant($defaultVariant);
|
||||
}
|
||||
|
||||
$variant = $product->createVariant($data);
|
||||
|
||||
if (! empty($images)) {
|
||||
$this->syncVariantImages($variant, $images);
|
||||
}
|
||||
|
||||
return $variant->load(['product', 'definitions.productAttribute.attribute.options', 'attachments']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a product variant.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function updateVariant(ProductVariant $variant, array $data): ProductVariant
|
||||
{
|
||||
return DB::transaction(function () use ($variant, $data) {
|
||||
$hasImages = array_key_exists('images', $data);
|
||||
$images = $data['images'] ?? [];
|
||||
unset($data['images']);
|
||||
|
||||
/** @var Product $product */
|
||||
$product = $variant->product;
|
||||
$updatedVariant = $product->updateVariant($variant, $data);
|
||||
|
||||
if ($hasImages) {
|
||||
$this->syncVariantImages($updatedVariant, $images);
|
||||
}
|
||||
|
||||
return $updatedVariant->load(['product', 'definitions.productAttribute.attribute.options', 'attachments']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a product variant.
|
||||
*/
|
||||
public function deleteVariant(ProductVariant $variant): void
|
||||
{
|
||||
DB::transaction(function () use ($variant) {
|
||||
/** @var Product $product */
|
||||
$product = $variant->product;
|
||||
$this->deleteVariantAttachments($variant);
|
||||
$product->deleteVariant($variant);
|
||||
|
||||
// Re-create a default variant with stock 0 if it has no variants left
|
||||
if ($product->variants()->count() === 0) {
|
||||
$product->createVariant([
|
||||
'stock' => 0,
|
||||
'is_placeholder' => true,
|
||||
'definitions' => [],
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an attribute.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function createAttribute(Tenant $tenant, array $data): Attribute
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $data) {
|
||||
return Product::createAttribute($tenant, $data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an attribute.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function updateAttribute(Attribute $attribute, array $data): Attribute
|
||||
{
|
||||
return DB::transaction(function () use ($attribute, $data) {
|
||||
return Product::updateAttribute($attribute, $data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a list of image files/base64 strings and sync them to a variant.
|
||||
*
|
||||
* When called on update, the existing attachments are detached first so the
|
||||
* final set always matches exactly what was sent in the request.
|
||||
*
|
||||
* @param array<int, UploadedFile|string> $images
|
||||
*/
|
||||
protected function syncVariantImages(ProductVariant $variant, array $images): void
|
||||
{
|
||||
$this->deleteVariantAttachments($variant);
|
||||
|
||||
$attachmentIds = [];
|
||||
|
||||
foreach ($images as $image) {
|
||||
$attachment = $this->attachmentService->store($image, 'variants');
|
||||
$attachmentIds[] = $attachment->id;
|
||||
}
|
||||
|
||||
$variant->attachments()->sync($attachmentIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a list of image files/base64 strings and sync them to a product.
|
||||
*
|
||||
* Same logic as syncVariantImages but for products without variants.
|
||||
*
|
||||
* @param array<int, UploadedFile|string> $images
|
||||
*/
|
||||
protected function syncProductImages(Product $product, array $images): void
|
||||
{
|
||||
// Detach pivot record and delete attachment from S3 and database
|
||||
$existing = $product->attachments()->get();
|
||||
$product->attachments()->detach();
|
||||
foreach ($existing as $attachment) {
|
||||
$this->attachmentService->delete($attachment);
|
||||
}
|
||||
|
||||
$attachmentIds = [];
|
||||
|
||||
foreach ($images as $image) {
|
||||
$attachment = $this->attachmentService->store($image, 'products');
|
||||
$attachmentIds[] = $attachment->id;
|
||||
}
|
||||
|
||||
$product->attachments()->sync($attachmentIds);
|
||||
}
|
||||
|
||||
protected function deleteVariantAttachments(ProductVariant $variant): void
|
||||
{
|
||||
$existing = $variant->attachments()->get();
|
||||
$variant->attachments()->detach();
|
||||
|
||||
foreach ($existing as $attachment) {
|
||||
$this->attachmentService->delete($attachment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an attribute.
|
||||
*/
|
||||
public static function deleteAttribute(Attribute $attribute): void
|
||||
{
|
||||
DB::transaction(function () use ($attribute) {
|
||||
Product::deleteAttribute($attribute);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products for a tenant with resolved first image (with fallback to first variant's first image).
|
||||
*/
|
||||
public function getProductos(Tenant $tenant): LengthAwarePaginator
|
||||
{
|
||||
$products = Product::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->with([
|
||||
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
|
||||
'brand',
|
||||
'category',
|
||||
'variants.attachments' => fn ($query) => $query->orderBy('attachments.id'),
|
||||
])
|
||||
->latest()
|
||||
->paginateFromRequest();
|
||||
|
||||
foreach ($products as $product) {
|
||||
$resolvedAttachment = null;
|
||||
if ($product->attachments->isNotEmpty()) {
|
||||
$resolvedAttachment = $product->attachments->first();
|
||||
} else {
|
||||
$firstVariant = $product->variants->sortBy('id')->first();
|
||||
if ($firstVariant && $firstVariant->attachments->isNotEmpty()) {
|
||||
$resolvedAttachment = $firstVariant->attachments->first();
|
||||
}
|
||||
}
|
||||
|
||||
$product->setRelation('attachments', $resolvedAttachment ? collect([$resolvedAttachment]) : collect());
|
||||
$product->unsetRelation('variants');
|
||||
}
|
||||
|
||||
return $products;
|
||||
}
|
||||
|
||||
public function getProductDetail(Tenant $tenant, Product $product, ?int $variantId = null): Product
|
||||
{
|
||||
$product->load([
|
||||
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
|
||||
'attributes.options',
|
||||
'brand',
|
||||
'category',
|
||||
'variants' => fn ($query) => $query->orderBy('id'),
|
||||
'variants.definitions.productAttribute.attribute.options',
|
||||
]);
|
||||
|
||||
$this->filterProductDetailAttributeOptions($product);
|
||||
|
||||
$selectedVariant = $variantId !== null
|
||||
? $product->variants->firstWhere('id', $variantId)
|
||||
: $product->variants->first(fn (ProductVariant $variant) => $variant->stock_tecnico > 0);
|
||||
|
||||
if ($variantId !== null && $selectedVariant === null) {
|
||||
throw new NotFoundHttpException('Product variant not found for product.');
|
||||
}
|
||||
|
||||
if ($variantId !== null && $selectedVariant->stock_tecnico <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => 'La variante seleccionada no tiene stock.',
|
||||
]);
|
||||
}
|
||||
|
||||
$selectedVariant ??= $product->variants->first();
|
||||
|
||||
if ($selectedVariant !== null) {
|
||||
$selectedVariant->load([
|
||||
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
|
||||
]);
|
||||
$selectedVariant->setRelation('fallbackAttachments', $product->attachments);
|
||||
$product->setSelectedVariant($selectedVariant);
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
protected function filterProductDetailAttributeOptions(Product $product): void
|
||||
{
|
||||
$availableValuesByAttributeId = [];
|
||||
|
||||
foreach ($product->variants as $variant) {
|
||||
foreach ($variant->definitions as $definition) {
|
||||
$attributeId = $definition->productAttribute?->attribute_id;
|
||||
|
||||
if ($attributeId === null || $definition->value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$availableValuesByAttributeId[$attributeId][$definition->value] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($product->attributes as $attribute) {
|
||||
if (! $attribute->relationLoaded('options')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$availableValues = $availableValuesByAttributeId[$attribute->id] ?? [];
|
||||
|
||||
$attribute->setRelation(
|
||||
'options',
|
||||
$attribute->options
|
||||
->filter(fn ($option): bool => array_key_exists($option->value, $availableValues))
|
||||
->values()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
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\AttributeController;
|
||||
use App\Domains\Catalog\Controllers\ProductVariantController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -11,8 +11,10 @@ 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::apiResource('attributes', AttributeController::class);
|
||||
Route::apiResource('productos.variants', ProductVariantController::class)
|
||||
->parameters([
|
||||
'productos' => 'producto',
|
||||
'variants' => 'productVariant',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -20,11 +20,11 @@ class PurchaseController extends Controller
|
||||
{
|
||||
return PurchaseResource::collection(
|
||||
Purchase::query()
|
||||
->with(['items.variant.product', 'items.variant.definitions'])
|
||||
->with(['items.variant.product', 'items.variant.definitions.productAttribute.attribute'])
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('user_id', $request->user()->id)
|
||||
->latest()
|
||||
->get()
|
||||
->paginateFromRequest()
|
||||
)->response();
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ class PurchaseController extends Controller
|
||||
|
||||
$purchase->items()->createMany($purchaseItems);
|
||||
|
||||
return $purchase->load(['items.variant.product', 'items.variant.definitions']);
|
||||
return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']);
|
||||
});
|
||||
|
||||
return PurchaseResource::make($purchase)->response()->setStatusCode(201);
|
||||
@@ -59,7 +59,7 @@ class PurchaseController extends Controller
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
|
||||
return PurchaseResource::make(
|
||||
$compra->loadMissing(['items.variant.product', 'items.variant.definitions'])
|
||||
$compra->loadMissing(['items.variant.product', 'items.variant.definitions.productAttribute.attribute'])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ class PurchaseController extends Controller
|
||||
/** @var ProductVariant $variant */
|
||||
$variant = $variants->get((int) $item['producto_variante_id']);
|
||||
$quantity = (int) $item['cantidad'];
|
||||
$unitPrice = (float) $variant->precio;
|
||||
$unitPrice = (float) ($variant->product?->precio ?? 0);
|
||||
|
||||
return [
|
||||
'producto_variante_id' => $variant->getKey(),
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Domains\Purchase\Models;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
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;
|
||||
|
||||
@@ -31,8 +31,6 @@ class PurchaseItemResource extends JsonResource
|
||||
],
|
||||
'variant' => $variant === null ? null : [
|
||||
'id' => $variant->id,
|
||||
'nombre' => $variant->nombre,
|
||||
'slug' => $variant->slug,
|
||||
'definitions' => ProductVariantDefinitionResource::collection($variant->definitions),
|
||||
],
|
||||
];
|
||||
|
||||
27
app/Domains/Shared/Enums/FieldType.php
Normal file
27
app/Domains/Shared/Enums/FieldType.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Shared\Enums;
|
||||
|
||||
enum FieldType: string
|
||||
{
|
||||
case String = 'string';
|
||||
case Number = 'number';
|
||||
case Boolean = 'boolean';
|
||||
case Select = 'select';
|
||||
case Multiselect = 'multiselect';
|
||||
case Color = 'color';
|
||||
case Image = 'image';
|
||||
|
||||
public function supportsOptions(): bool
|
||||
{
|
||||
return in_array($this, [self::Select, self::Multiselect], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
58
app/Domains/Shared/Rules/ImageOrBase64Rule.php
Normal file
58
app/Domains/Shared/Rules/ImageOrBase64Rule.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Shared\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class ImageOrBase64Rule implements ValidationRule
|
||||
{
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value instanceof UploadedFile) {
|
||||
$mime = $value->getMimeType();
|
||||
if (! str_starts_with((string) $mime, 'image/')) {
|
||||
$fail("The :attribute must be a valid image file.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$payload = trim($value);
|
||||
|
||||
if (\Illuminate\Support\Str::isUuid($payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($payload === '') {
|
||||
$fail("The :attribute must not be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
$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 === '') {
|
||||
$fail("The :attribute must be a valid base64-encoded image.");
|
||||
return;
|
||||
}
|
||||
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$detectedMime = $finfo->buffer($decoded);
|
||||
$mime = $detectedMime ?: $declaredMimeType;
|
||||
|
||||
if (! $mime || ! str_starts_with((string) $mime, 'image/')) {
|
||||
$fail("The :attribute must be a valid image (file or base64).");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$fail("The :attribute must be an image file or a base64-encoded image string.");
|
||||
}
|
||||
}
|
||||
58
app/Domains/StorageTest/Controllers/S3TestController.php
Normal file
58
app/Domains/StorageTest/Controllers/S3TestController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\StorageTest\Controllers;
|
||||
|
||||
use App\Domains\Attachable\Services\AttachmentService;
|
||||
use App\Domains\StorageTest\Requests\GenerateS3TemporaryUrlRequest;
|
||||
use App\Domains\StorageTest\Requests\StoreS3TestFileRequest;
|
||||
use App\Domains\StorageTest\Services\S3TestService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class S3TestController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AttachmentService $attachmentService,
|
||||
protected S3TestService $s3TestService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function store(StoreS3TestFileRequest $request): JsonResponse
|
||||
{
|
||||
$attachment = $this->attachmentService->store(
|
||||
$request->file('file') ?? (string) $request->validated('file_base64'),
|
||||
$request->validated('path'),
|
||||
);
|
||||
|
||||
$temporaryUrl = $this->s3TestService->generateTemporaryUrl(
|
||||
$attachment->path,
|
||||
(int) $request->validated('expires_in_minutes', 10),
|
||||
);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
'id' => $attachment->id,
|
||||
'key' => $attachment->key,
|
||||
'path' => $attachment->path,
|
||||
'filename' => $attachment->filename,
|
||||
'type' => $attachment->type->value,
|
||||
'mime_type' => $attachment->mime_type,
|
||||
'extension' => $attachment->extension,
|
||||
'size' => $attachment->size,
|
||||
'temporary_url' => $temporaryUrl['temporary_url'],
|
||||
'temporary_url_expires_at' => $temporaryUrl['temporary_url_expires_at'],
|
||||
],
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
public function temporaryUrl(GenerateS3TemporaryUrlRequest $request): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$this->s3TestService->generateTemporaryUrl(
|
||||
$request->validated('path'),
|
||||
(int) $request->validated('expires_in_minutes', 10),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\StorageTest\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class GenerateS3TemporaryUrlRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'path' => ['required', 'string', 'max:2048'],
|
||||
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Domains/StorageTest/Requests/StoreS3TestFileRequest.php
Normal file
26
app/Domains/StorageTest/Requests/StoreS3TestFileRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\StorageTest\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreS3TestFileRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'file' => ['nullable', 'file', 'max:10240', 'required_without:file_base64'],
|
||||
'file_base64' => ['nullable', 'string', 'required_without:file'],
|
||||
'path' => ['required', 'string', 'max:2048'],
|
||||
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
|
||||
];
|
||||
}
|
||||
}
|
||||
81
app/Domains/StorageTest/Services/S3TestService.php
Normal file
81
app/Domains/StorageTest/Services/S3TestService.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\StorageTest\Services;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class S3TestService
|
||||
{
|
||||
/**
|
||||
* @return array<string, int|string|null>
|
||||
*/
|
||||
public function storeTestFile(
|
||||
UploadedFile $file,
|
||||
?string $directory = null,
|
||||
int $expiresInMinutes = 10,
|
||||
): array {
|
||||
$directory = $this->normalizeDirectory($directory);
|
||||
$disk = Storage::disk('s3');
|
||||
$path = $disk->putFile($directory, $file);
|
||||
|
||||
if (! is_string($path) || $path === '') {
|
||||
Log::error('S3 upload returned an empty path.', [
|
||||
'disk' => 's3',
|
||||
'directory' => $directory,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getClientMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
]);
|
||||
|
||||
throw new RuntimeException('No se pudo subir el archivo al disco s3.');
|
||||
}
|
||||
|
||||
return [
|
||||
'disk' => 's3',
|
||||
'directory' => $directory,
|
||||
'key' => $path,
|
||||
'path' => $path,
|
||||
'filename' => basename($path),
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getClientMimeType(),
|
||||
'extension' => $file->extension(),
|
||||
'size' => $file->getSize(),
|
||||
'temporary_url' => $disk->temporaryUrl($path, now()->addMinutes($expiresInMinutes)),
|
||||
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function generateTemporaryUrl(string $path, int $expiresInMinutes = 10): array
|
||||
{
|
||||
return [
|
||||
'disk' => 's3',
|
||||
'key' => $path,
|
||||
'path' => $path,
|
||||
'temporary_url' => $this->temporaryUrlForPath($path, $expiresInMinutes),
|
||||
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function temporaryUrlForPath(string $path, int $expiresInMinutes): string
|
||||
{
|
||||
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes($expiresInMinutes));
|
||||
}
|
||||
|
||||
protected function normalizeDirectory(?string $directory): string
|
||||
{
|
||||
$directory = trim((string) $directory, '/');
|
||||
|
||||
if ($directory !== '') {
|
||||
return $directory;
|
||||
}
|
||||
|
||||
return 'testing/attachments/'.now()->format('Y/m/d').'/'.Str::uuid();
|
||||
}
|
||||
}
|
||||
9
app/Domains/StorageTest/routes/api.php
Normal file
9
app/Domains/StorageTest/routes/api.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\StorageTest\Controllers\S3TestController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('storage-test/s3')->group(function (): void {
|
||||
Route::post('upload', [S3TestController::class, 'store']);
|
||||
Route::get('temporary-url', [S3TestController::class, 'temporaryUrl']);
|
||||
});
|
||||
@@ -15,7 +15,7 @@ class BootstrapTenantController extends Controller
|
||||
$dominio = $request->validated('dominio');
|
||||
|
||||
return TenantResource::make(
|
||||
Tenant::query()->where('dominio', $dominio)->firstOrFail()
|
||||
Tenant::query()->with(['headerLogo', 'footerLogo'])->where('dominio', $dominio)->firstOrFail()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,40 +6,39 @@ use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Requests\StoreTenantRequest;
|
||||
use App\Domains\Tenant\Requests\UpdateTenantRequest;
|
||||
use App\Domains\Tenant\Resources\TenantResource;
|
||||
use App\Domains\Tenant\Services\TenantService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class TenantController extends Controller
|
||||
{
|
||||
public function __construct(protected TenantService $tenantService)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return TenantResource::collection(Tenant::query()->latest()->get())->response();
|
||||
return TenantResource::collection(Tenant::query()->with(['headerLogo', 'footerLogo'])->latest()->paginateFromRequest())->response();
|
||||
}
|
||||
|
||||
public function store(StoreTenantRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$props = $validated['props'] ?? [];
|
||||
$tenant = $this->tenantService->create($request->validated());
|
||||
|
||||
$tenant = Tenant::createWithProps($validated, $props);
|
||||
|
||||
return TenantResource::make($tenant)->response()->setStatusCode(201);
|
||||
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']))->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant): TenantResource
|
||||
{
|
||||
return TenantResource::make($tenant);
|
||||
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']));
|
||||
}
|
||||
|
||||
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$props = $validated['props'] ?? [];
|
||||
$tenant = $this->tenantService->update($tenant, $request->validated());
|
||||
|
||||
$tenant->updateWithProps($validated, $props);
|
||||
|
||||
return TenantResource::make($tenant);
|
||||
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']));
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant): Response
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Controllers;
|
||||
|
||||
use App\Domains\Tenant\Models\TenantProp;
|
||||
use App\Domains\Tenant\Requests\StoreTenantPropRequest;
|
||||
use App\Domains\Tenant\Requests\UpdateTenantPropRequest;
|
||||
use App\Domains\Tenant\Resources\TenantPropResource;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class TenantPropController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return TenantPropResource::collection(TenantProp::query()->latest()->get())->response();
|
||||
}
|
||||
|
||||
public function store(StoreTenantPropRequest $request): JsonResponse
|
||||
{
|
||||
$tenantProp = TenantProp::query()->create($request->validated());
|
||||
|
||||
return TenantPropResource::make($tenantProp)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(TenantProp $tenantProp): TenantPropResource
|
||||
{
|
||||
return TenantPropResource::make($tenantProp);
|
||||
}
|
||||
|
||||
public function update(UpdateTenantPropRequest $request, TenantProp $tenantProp): TenantPropResource
|
||||
{
|
||||
$tenantProp->update($request->validated());
|
||||
|
||||
return TenantPropResource::make($tenantProp);
|
||||
}
|
||||
|
||||
public function destroy(TenantProp $tenantProp): Response
|
||||
{
|
||||
$tenantProp->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -2,42 +2,50 @@
|
||||
|
||||
namespace App\Domains\Tenant\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
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\Support\Facades\DB;
|
||||
use LogicException;
|
||||
|
||||
#[Fillable([
|
||||
'codigo',
|
||||
'nombre',
|
||||
'dominio',
|
||||
'primary_color',
|
||||
'secondary_color',
|
||||
'danger_color',
|
||||
'success_color',
|
||||
'header_bg_color',
|
||||
'footer_bg_color',
|
||||
'header_logo_id',
|
||||
'footer_logo_id',
|
||||
])]
|
||||
class Tenant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @param array<string, mixed> $props
|
||||
* @return static
|
||||
*/
|
||||
public static function createWithProps(array $attributes, array $props = []): static
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
unset($attributes['props']);
|
||||
return 'codigo';
|
||||
}
|
||||
|
||||
/** @var static $tenant */
|
||||
$tenant = DB::transaction(function () use ($attributes, $props): self {
|
||||
/** @var static $createdTenant */
|
||||
$createdTenant = static::query()->create($attributes);
|
||||
$createdTenant->syncPropValues($props);
|
||||
/**
|
||||
* @return BelongsTo<Attachment, $this>
|
||||
*/
|
||||
public function headerLogo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Attachment::class, 'header_logo_id');
|
||||
}
|
||||
|
||||
return $createdTenant;
|
||||
});
|
||||
|
||||
return $tenant;
|
||||
/**
|
||||
* @return BelongsTo<Attachment, $this>
|
||||
*/
|
||||
public function footerLogo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Attachment::class, 'footer_logo_id');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,78 +55,5 @@ class Tenant extends Model
|
||||
{
|
||||
return $this->hasMany(Product::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TenantPropValue, $this>
|
||||
*/
|
||||
public function propValues(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantPropValue::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
public function getPropValue(TenantProp|string $prop): ?TenantPropValue
|
||||
{
|
||||
$resolvedProp = $this->resolveProp($prop);
|
||||
|
||||
return $this->propValues()
|
||||
->where('tenant_prop_codigo', $resolvedProp->codigo)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function setPropValue(TenantProp|string $prop, mixed $value): TenantPropValue
|
||||
{
|
||||
$this->ensurePropValuesCanBeManaged();
|
||||
|
||||
$resolvedProp = $this->resolveProp($prop);
|
||||
|
||||
return $this->propValues()->updateOrCreate(
|
||||
['tenant_prop_codigo' => $resolvedProp->codigo],
|
||||
['value' => $value],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $props
|
||||
*/
|
||||
public function syncPropValues(array $props): void
|
||||
{
|
||||
foreach ($props as $codigo => $value) {
|
||||
$this->setPropValue((string) $codigo, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @param array<string, mixed> $props
|
||||
* @return static
|
||||
*/
|
||||
public function updateWithProps(array $attributes, array $props = []): static
|
||||
{
|
||||
unset($attributes['props']);
|
||||
|
||||
DB::transaction(function () use ($attributes, $props): void {
|
||||
$this->update($attributes);
|
||||
$this->syncPropValues($props);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function ensurePropValuesCanBeManaged(): void
|
||||
{
|
||||
if (! $this->exists) {
|
||||
throw new LogicException('Cannot manage prop values for an unsaved tenant.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function resolveProp(TenantProp|string $prop): TenantProp
|
||||
{
|
||||
if ($prop instanceof TenantProp) {
|
||||
return $prop;
|
||||
}
|
||||
|
||||
return TenantProp::query()
|
||||
->where('codigo', $prop)
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Models;
|
||||
|
||||
use App\Domains\Tenant\Support\TenantPropDataType;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'codigo',
|
||||
'nombre',
|
||||
'descripcion',
|
||||
'is_required',
|
||||
'data_type',
|
||||
])]
|
||||
class TenantProp extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'tenant_props';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_required' => 'boolean',
|
||||
'data_type' => TenantPropDataType::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TenantPropValue, $this>
|
||||
*/
|
||||
public function values(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantPropValue::class, 'tenant_prop_codigo', 'codigo');
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\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([
|
||||
'tenant_codigo',
|
||||
'tenant_prop_codigo',
|
||||
'value',
|
||||
])]
|
||||
class TenantPropValue extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'tenant_prop_values';
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<TenantProp, $this>
|
||||
*/
|
||||
public function prop(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TenantProp::class, 'tenant_prop_codigo', 'codigo');
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Requests;
|
||||
|
||||
use App\Domains\Tenant\Support\TenantPropDataType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreTenantPropRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenant_props', 'codigo')],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'descripcion' => ['nullable', 'string'],
|
||||
'is_required' => ['sometimes', 'boolean'],
|
||||
'data_type' => ['required', Rule::enum(TenantPropDataType::class)],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Domains\Tenant\Requests;
|
||||
|
||||
use App\Domains\Tenant\Support\TenantPropRules;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -35,6 +35,9 @@ class StoreTenantRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$logoRule = ['required', new ImageOrBase64Rule()];
|
||||
|
||||
|
||||
return [
|
||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
@@ -45,12 +48,19 @@ class StoreTenantRequest extends FormRequest
|
||||
$fail("The {$attribute} field must contain a valid domain or URL.");
|
||||
}
|
||||
},
|
||||
'nullable',
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'dominio'),
|
||||
],
|
||||
...TenantPropRules::sync(),
|
||||
'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'secondary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'danger_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'success_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_logo' => $logoRule,
|
||||
'footer_logo' => $logoRule,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Requests;
|
||||
|
||||
use App\Domains\Tenant\Models\TenantProp;
|
||||
use App\Domains\Tenant\Support\TenantPropDataType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateTenantPropRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var TenantProp|null $tenantProp */
|
||||
$tenantProp = $this->route('tenantProp');
|
||||
|
||||
return [
|
||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenant_props', 'codigo')->ignore($tenantProp?->id)],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'descripcion' => ['nullable', 'string'],
|
||||
'is_required' => ['sometimes', 'boolean'],
|
||||
'data_type' => ['required', Rule::enum(TenantPropDataType::class)],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
namespace App\Domains\Tenant\Requests;
|
||||
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||
use App\Domains\Tenant\Support\TenantPropRules;
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -20,15 +20,17 @@ class UpdateTenantRequest extends FormRequest
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$rawDomain = $this->input('dominio');
|
||||
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
||||
if ($this->has('dominio')) {
|
||||
$rawDomain = $this->input('dominio');
|
||||
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
||||
|
||||
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||
&& $normalizedDomain === null;
|
||||
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||
&& $normalizedDomain === null;
|
||||
|
||||
$this->merge([
|
||||
'dominio' => $normalizedDomain,
|
||||
]);
|
||||
$this->merge([
|
||||
'dominio' => $normalizedDomain,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,14 +41,17 @@ class UpdateTenantRequest extends FormRequest
|
||||
/** @var Tenant|null $tenant */
|
||||
$tenant = $this->route('tenant');
|
||||
|
||||
$logoRule = ['nullable', new ImageOrBase64Rule()];
|
||||
|
||||
|
||||
return [
|
||||
'codigo' => [
|
||||
'required',
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'codigo')->ignore($tenant?->id),
|
||||
],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'nombre' => ['nullable', 'string', 'max:255'],
|
||||
'dominio' => [
|
||||
'bail',
|
||||
function (string $attribute, mixed $value, Closure $fail): void {
|
||||
@@ -59,7 +64,14 @@ class UpdateTenantRequest extends FormRequest
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
|
||||
],
|
||||
...TenantPropRules::sync(),
|
||||
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'success_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_logo' => $logoRule,
|
||||
'footer_logo' => $logoRule,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Tenant\Models\TenantProp
|
||||
*/
|
||||
class TenantPropResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'codigo' => $this->codigo,
|
||||
'nombre' => $this->nombre,
|
||||
'descripcion' => $this->descripcion,
|
||||
'is_required' => $this->is_required,
|
||||
'data_type' => $this->data_type?->value ?? $this->data_type,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -10,29 +10,6 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
||||
*/
|
||||
class TenantResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function collapseProps(): array
|
||||
{
|
||||
return $this->resource->propValues()
|
||||
->with('prop')
|
||||
->get()
|
||||
->mapWithKeys(fn ($propValue): array => [
|
||||
$propValue->tenant_prop_codigo => $this->normalizePropValue($propValue->value, $propValue->prop?->data_type?->value),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function normalizePropValue(mixed $value, ?string $dataType): mixed
|
||||
{
|
||||
return match ($dataType) {
|
||||
'boolean' => filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? $value,
|
||||
'number' => is_numeric($value) ? $value + 0 : $value,
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -43,7 +20,15 @@ class TenantResource extends JsonResource
|
||||
'codigo' => $this->codigo,
|
||||
'nombre' => $this->nombre,
|
||||
'dominio' => $this->dominio,
|
||||
'props' => $this->collapseProps(),
|
||||
'primary_color' => $this->primary_color,
|
||||
'secondary_color' => $this->secondary_color,
|
||||
'danger_color' => $this->danger_color,
|
||||
'success_color' => $this->success_color,
|
||||
'header_bg_color' => $this->header_bg_color,
|
||||
'footer_bg_color' => $this->footer_bg_color,
|
||||
// 1 day
|
||||
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
|
||||
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440 ),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
118
app/Domains/Tenant/Services/TenantService.php
Normal file
118
app/Domains/Tenant/Services/TenantService.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Services;
|
||||
|
||||
use App\Domains\Attachable\Services\AttachmentService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantService
|
||||
{
|
||||
public function __construct(protected AttachmentService $attachmentService)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tenant and store its logos.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return Tenant
|
||||
*/
|
||||
public function create(array $data): Tenant
|
||||
{
|
||||
return DB::transaction(function () use ($data): Tenant {
|
||||
$headerLogo = $data['header_logo'] ?? null;
|
||||
$footerLogo = $data['footer_logo'] ?? null;
|
||||
|
||||
unset($data['header_logo'], $data['footer_logo']);
|
||||
|
||||
$headerAttachmentId = null;
|
||||
if ($headerLogo) {
|
||||
$attachment = Str::isUuid($headerLogo)
|
||||
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $headerLogo)->first()
|
||||
: $this->attachmentService->store($headerLogo, 'tenants');
|
||||
|
||||
if ($attachment) {
|
||||
$headerAttachmentId = $attachment->id;
|
||||
}
|
||||
}
|
||||
|
||||
$footerAttachmentId = null;
|
||||
if ($footerLogo) {
|
||||
$attachment = Str::isUuid($footerLogo)
|
||||
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $footerLogo)->first()
|
||||
: $this->attachmentService->store($footerLogo, 'tenants');
|
||||
|
||||
if ($attachment) {
|
||||
$footerAttachmentId = $attachment->id;
|
||||
}
|
||||
}
|
||||
|
||||
$data['header_logo_id'] = $headerAttachmentId;
|
||||
$data['footer_logo_id'] = $footerAttachmentId;
|
||||
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::query()->create($data);
|
||||
|
||||
return $tenant;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing tenant and store new logos if uploaded.
|
||||
*
|
||||
* @param Tenant $tenant
|
||||
* @param array<string, mixed> $data
|
||||
* @return Tenant
|
||||
*/
|
||||
public function update(Tenant $tenant, array $data): Tenant
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $data): Tenant {
|
||||
$hasHeaderLogoKey = array_key_exists('header_logo', $data);
|
||||
$hasFooterLogoKey = array_key_exists('footer_logo', $data);
|
||||
$headerLogo = $data['header_logo'] ?? null;
|
||||
$footerLogo = $data['footer_logo'] ?? null;
|
||||
|
||||
unset($data['header_logo'], $data['footer_logo']);
|
||||
|
||||
$tenant->fill($data);
|
||||
|
||||
if ($hasHeaderLogoKey) {
|
||||
if ($headerLogo) {
|
||||
$attachment = Str::isUuid($headerLogo)
|
||||
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $headerLogo)->first()
|
||||
: $this->attachmentService->store($headerLogo, 'tenants');
|
||||
|
||||
if ($attachment) {
|
||||
$tenant->header_logo_id = $attachment->id;
|
||||
} else {
|
||||
$tenant->header_logo_id = null;
|
||||
}
|
||||
} else {
|
||||
$tenant->header_logo_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasFooterLogoKey) {
|
||||
if ($footerLogo) {
|
||||
$attachment = Str::isUuid($footerLogo)
|
||||
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $footerLogo)->first()
|
||||
: $this->attachmentService->store($footerLogo, 'tenants');
|
||||
|
||||
if ($attachment) {
|
||||
$tenant->footer_logo_id = $attachment->id;
|
||||
} else {
|
||||
$tenant->footer_logo_id = null;
|
||||
}
|
||||
} else {
|
||||
$tenant->footer_logo_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
$tenant->save();
|
||||
|
||||
return $tenant;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Support;
|
||||
|
||||
enum TenantPropDataType: string
|
||||
{
|
||||
case String = 'string';
|
||||
case Number = 'number';
|
||||
case Boolean = 'boolean';
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Support;
|
||||
|
||||
use App\Domains\Tenant\Models\TenantProp;
|
||||
use Closure;
|
||||
|
||||
class TenantPropRules
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function sync(): array
|
||||
{
|
||||
return [
|
||||
'props' => [
|
||||
'sometimes',
|
||||
'array',
|
||||
static function (string $attribute, mixed $value, Closure $fail): void {
|
||||
static::validateExistingProps($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
'props.*' => ['nullable'],
|
||||
];
|
||||
}
|
||||
|
||||
protected static function validateExistingProps(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_array($value) || $value === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$propCodes = array_map('strval', array_keys($value));
|
||||
$existingPropCodes = TenantProp::query()
|
||||
->whereIn('codigo', $propCodes)
|
||||
->pluck('codigo')
|
||||
->all();
|
||||
|
||||
$missingPropCodes = array_values(array_diff($propCodes, $existingPropCodes));
|
||||
|
||||
if ($missingPropCodes === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fail(sprintf(
|
||||
'The selected %s are invalid for Tenant: %s.',
|
||||
$attribute,
|
||||
implode(', ', $missingPropCodes),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,9 @@
|
||||
|
||||
use App\Domains\Tenant\Controllers\BootstrapTenantController;
|
||||
use App\Domains\Tenant\Controllers\TenantController;
|
||||
use App\Domains\Tenant\Controllers\TenantPropController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('tenants/bootstrap/{dominio}', BootstrapTenantController::class)
|
||||
->where('dominio', '.*');
|
||||
|
||||
Route::apiResource('tenants', TenantController::class);
|
||||
Route::apiResource('tenant-props', TenantPropController::class)
|
||||
->parameters(['tenant-props' => 'tenantProp']);
|
||||
|
||||
@@ -19,6 +19,22 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
\Illuminate\Database\Eloquent\Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
|
||||
/** @var \Illuminate\Database\Eloquent\Builder $this */
|
||||
$perPage = (int) request()->query('per_page', $defaultPerPage);
|
||||
$page = $page ?? (int) request()->query('page', 1);
|
||||
|
||||
if ($perPage <= 0) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
if ($perPage > $maxPerPage) {
|
||||
$perPage = $maxPerPage;
|
||||
}
|
||||
if ($page <= 0) {
|
||||
$page = 1;
|
||||
}
|
||||
|
||||
return $this->paginate(perPage: $perPage, page: $page);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
$middleware->encryptCookies(except: [
|
||||
'guest_token',
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^3.0"
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/tinker": "^3.0",
|
||||
"league/flysystem-aws-s3-v3": "3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
346
composer.lock
generated
346
composer.lock
generated
@@ -4,8 +4,159 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "bf97dbe15104bcde9dd03530a8a289c8",
|
||||
"content-hash": "08dac456d84023cd816d5c96086a3b3c",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
"version": "v1.2.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/awslabs/aws-crt-php.git",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
|
||||
"yoast/phpunit-polyfills": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "AWS SDK Common Runtime Team",
|
||||
"email": "aws-sdk-common-runtime@amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS Common Runtime for PHP",
|
||||
"homepage": "https://github.com/awslabs/aws-crt-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"crt",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/awslabs/aws-crt-php/issues",
|
||||
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
|
||||
},
|
||||
"time": "2024-10-18T22:15:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-sdk-php",
|
||||
"version": "3.386.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||
"reference": "e36bc0e97e82d68acc92b9e06f5dc544913d819a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/e36bc0e97e82d68acc92b9e06f5dc544913d819a",
|
||||
"reference": "e36bc0e97e82d68acc92b9e06f5dc544913d819a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-crt-php": "^1.2.3",
|
||||
"ext-json": "*",
|
||||
"ext-pcre": "*",
|
||||
"ext-simplexml": "*",
|
||||
"guzzlehttp/guzzle": "^7.4.5",
|
||||
"guzzlehttp/promises": "^2.0",
|
||||
"guzzlehttp/psr7": "^2.4.5",
|
||||
"mtdowling/jmespath.php": "^2.9.1",
|
||||
"php": ">=8.1",
|
||||
"psr/http-message": "^1.0 || ^2.0",
|
||||
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"andrewsville/php-token-reflection": "^1.4",
|
||||
"aws/aws-php-sns-message-validator": "~1.0",
|
||||
"behat/behat": "~3.0",
|
||||
"composer/composer": "^2.7.8",
|
||||
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
|
||||
"doctrine/cache": "~1.4",
|
||||
"ext-dom": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-sockets": "*",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"psr/cache": "^2.0 || ^3.0",
|
||||
"psr/simple-cache": "^2.0 || ^3.0",
|
||||
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
|
||||
"yoast/phpunit-polyfills": "^2.0"
|
||||
},
|
||||
"suggest": {
|
||||
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
|
||||
"doctrine/cache": "To use the DoctrineCacheAdapter",
|
||||
"ext-curl": "To send requests using cURL",
|
||||
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
|
||||
"ext-pcntl": "To use client-side monitoring",
|
||||
"ext-sockets": "To use client-side monitoring"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Aws\\": "src/"
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"src/data/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Amazon Web Services",
|
||||
"homepage": "https://aws.amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
|
||||
"homepage": "https://aws.amazon.com/sdk-for-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"cloud",
|
||||
"dynamodb",
|
||||
"ec2",
|
||||
"glacier",
|
||||
"s3",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.386.1"
|
||||
},
|
||||
"time": "2026-06-23T01:24:07+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.17.2",
|
||||
@@ -1805,6 +1956,62 @@
|
||||
},
|
||||
"time": "2026-05-14T10:28:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
"version": "3.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
|
||||
"reference": "f8ba6a92a5c1fdcbdd89dede009a1e6e1b93ba8c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/f8ba6a92a5c1fdcbdd89dede009a1e6e1b93ba8c",
|
||||
"reference": "f8ba6a92a5c1fdcbdd89dede009a1e6e1b93ba8c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-sdk-php": "^3.132.4",
|
||||
"league/flysystem": "^2.0.0 || ^3.0.0",
|
||||
"league/mime-type-detection": "^1.0.0",
|
||||
"php": "^8.0.2"
|
||||
},
|
||||
"conflict": {
|
||||
"guzzlehttp/guzzle": "<7.0",
|
||||
"guzzlehttp/ringphp": "<1.1.1"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Flysystem\\AwsS3V3\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Frank de Jonge",
|
||||
"email": "info@frankdejonge.nl"
|
||||
}
|
||||
],
|
||||
"description": "AWS S3 filesystem adapter for Flysystem.",
|
||||
"keywords": [
|
||||
"Flysystem",
|
||||
"aws",
|
||||
"file",
|
||||
"files",
|
||||
"filesystem",
|
||||
"s3",
|
||||
"storage"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/flysystem-aws-s3-v3/issues",
|
||||
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.0.0"
|
||||
},
|
||||
"time": "2022-01-13T21:11:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-local",
|
||||
"version": "3.31.0",
|
||||
@@ -2195,6 +2402,72 @@
|
||||
],
|
||||
"time": "2026-01-02T08:56:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mtdowling/jmespath.php",
|
||||
"version": "2.9.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jmespath/jmespath.php.git",
|
||||
"reference": "9c208ba27ae7d90853c288b3795d6702eb251d34"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/9c208ba27ae7d90853c288b3795d6702eb251d34",
|
||||
"reference": "9c208ba27ae7d90853c288b3795d6702eb251d34",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"symfony/polyfill-mbstring": "^1.17"
|
||||
},
|
||||
"require-dev": {
|
||||
"composer/xdebug-handler": "^3.0.3",
|
||||
"phpunit/phpunit": "^8.5.52"
|
||||
},
|
||||
"bin": [
|
||||
"bin/jp.php"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.9-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/JmesPath.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"JmesPath\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Michael Dowling",
|
||||
"email": "mtdowling@gmail.com",
|
||||
"homepage": "https://github.com/mtdowling"
|
||||
}
|
||||
],
|
||||
"description": "Declaratively specify how to extract elements from a JSON document",
|
||||
"keywords": [
|
||||
"json",
|
||||
"jsonpath"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/jmespath/jmespath.php/issues",
|
||||
"source": "https://github.com/jmespath/jmespath.php/tree/2.9.1"
|
||||
},
|
||||
"time": "2026-06-11T10:43:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.12.3",
|
||||
@@ -3931,6 +4204,77 @@
|
||||
],
|
||||
"time": "2026-01-05T13:30:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/filesystem",
|
||||
"version": "v8.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/filesystem.git",
|
||||
"reference": "99aec13b82b4967ec5088222c4a3ecca955949c2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2",
|
||||
"reference": "99aec13b82b4967ec5088222c4a3ecca955949c2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4.1",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/polyfill-ctype": "~1.8",
|
||||
"symfony/polyfill-mbstring": "~1.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/process": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Filesystem\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides basic utilities for the filesystem",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/filesystem/tree/v8.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-29T05:06:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/finder",
|
||||
"version": "v8.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Domains\Auth\Models\User;
|
||||
|
||||
return [
|
||||
|
||||
|
||||
34
config/cors.php
Normal file
34
config/cors.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure your settings for cross-origin resource sharing
|
||||
| or "CORS". This determines what cross-origin operations may execute
|
||||
| in web browsers. You are free to adjust these settings as needed.
|
||||
|
|
||||
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
|
|
||||
*/
|
||||
|
||||
'paths' => ['api/*', 'sanctum/csrf-cookie'],
|
||||
|
||||
'allowed_methods' => ['*'],
|
||||
|
||||
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:4200')],
|
||||
|
||||
'allowed_origins_patterns' => [],
|
||||
|
||||
'allowed_headers' => ['*'],
|
||||
|
||||
'exposed_headers' => [],
|
||||
|
||||
'max_age' => 0,
|
||||
|
||||
'supports_credentials' => true,
|
||||
|
||||
];
|
||||
@@ -56,8 +56,11 @@ return [
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
'http' => [
|
||||
'verify' => env('AWS_HTTP_VERIFY', false),
|
||||
],
|
||||
'throw' => true,
|
||||
'report' => true,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
@@ -37,7 +37,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => ['web'],
|
||||
'guard' => [],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -12,6 +12,8 @@ use Illuminate\Support\Str;
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
protected $model = User::class;
|
||||
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
@@ -25,7 +27,7 @@ class UserFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'nombre_apellido' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
|
||||
@@ -15,7 +15,13 @@ return new class extends Migration
|
||||
$table->id();
|
||||
$table->string('codigo')->unique();
|
||||
$table->string('nombre');
|
||||
$table->string('dominio')->nullable();
|
||||
$table->string('dominio');
|
||||
$table->string('primary_color');
|
||||
$table->string('secondary_color');
|
||||
$table->string('danger_color');
|
||||
$table->string('header_footer_bg_color');
|
||||
$table->unsignedBigInteger('header_logo_id');
|
||||
$table->unsignedBigInteger('footer_logo_id');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,7 +27,9 @@ return new class extends Migration
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('productos', function (Blueprint $table) {
|
||||
$table->dropForeign(['tenant_codigo']);
|
||||
if (Schema::getConnection()->getDriverName() !== 'sqlite') {
|
||||
$table->dropForeign(['tenant_codigo']);
|
||||
}
|
||||
$table->dropColumn('tenant_codigo');
|
||||
});
|
||||
|
||||
|
||||
@@ -14,11 +14,7 @@ return new class extends Migration
|
||||
Schema::create('productos_variantes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('producto_id');
|
||||
$table->string('slug')->nullable();
|
||||
$table->string('nombre')->nullable();
|
||||
$table->unsignedInteger('stock')->default(0);
|
||||
$table->text('descripcion')->nullable();
|
||||
$table->decimal('precio', 10, 2);
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('producto_id')
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenant_props', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('codigo')->unique();
|
||||
$table->string('nombre');
|
||||
$table->text('descripcion')->nullable();
|
||||
$table->boolean('is_required')->default(false);
|
||||
$table->string('data_type');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenant_props');
|
||||
}
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenant_prop_values', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('tenant_codigo');
|
||||
$table->string('tenant_prop_codigo');
|
||||
$table->text('value')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('tenant_codigo')
|
||||
->references('codigo')
|
||||
->on('tenants')
|
||||
->cascadeOnUpdate()
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->foreign('tenant_prop_codigo')
|
||||
->references('codigo')
|
||||
->on('tenant_props')
|
||||
->cascadeOnUpdate()
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->unique(['tenant_codigo', 'tenant_prop_codigo']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenant_prop_values');
|
||||
}
|
||||
};
|
||||
@@ -14,6 +14,7 @@ return new class extends Migration
|
||||
Schema::create('attribute_options', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('attribute_id')->constrained('productos_attributes')->cascadeOnUpdate()->cascadeOnDelete();
|
||||
$table->string('value');
|
||||
$table->string('label');
|
||||
$table->unsignedInteger('sort_order')->default(0);
|
||||
$table->json('metadata')->nullable();
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('carritos', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnUpdate()->nullOnDelete();
|
||||
$table->string('guest_token')->nullable()->index();
|
||||
$table->enum('status', ['active', 'converted', 'abandoned'])->default('active');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('carritos');
|
||||
}
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('carrito_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('cart_id')->constrained('carritos')->cascadeOnUpdate()->cascadeOnDelete();
|
||||
$table->unsignedBigInteger('producto_variante_id');
|
||||
$table->unsignedInteger('cantidad');
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('producto_variante_id')
|
||||
->references('id')
|
||||
->on('productos_variantes')
|
||||
->cascadeOnUpdate()
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->unique(['cart_id', 'producto_variante_id'], 'cart_item_cart_variant_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('carrito_items');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('attachments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('key')->unique();
|
||||
$table->string('path');
|
||||
$table->string('filename');
|
||||
$table->string('type');
|
||||
$table->string('mime_type');
|
||||
$table->string('extension', 20)->nullable();
|
||||
$table->unsignedBigInteger('size')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('type');
|
||||
$table->index('mime_type');
|
||||
});
|
||||
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->foreign('header_logo_id')
|
||||
->references('id')
|
||||
->on('attachments')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->foreign('footer_logo_id')
|
||||
->references('id')
|
||||
->on('attachments')
|
||||
->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
if (Schema::getConnection()->getDriverName() !== 'sqlite') {
|
||||
$table->dropForeign(['header_logo_id']);
|
||||
$table->dropForeign(['footer_logo_id']);
|
||||
}
|
||||
});
|
||||
|
||||
Schema::dropIfExists('attachments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('productos_attributes')
|
||||
->where('type', 'text')
|
||||
->update(['type' => 'string']);
|
||||
|
||||
DB::table('productos_attributes')
|
||||
->where('type', 'numeric')
|
||||
->update(['type' => 'number']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('productos_attributes')
|
||||
->where('type', 'string')
|
||||
->update(['type' => 'text']);
|
||||
|
||||
DB::table('productos_attributes')
|
||||
->where('type', 'number')
|
||||
->update(['type' => 'numeric']);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('variantes_attachments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('variante_id')
|
||||
->constrained('productos_variantes')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignId('attachment_id')
|
||||
->constrained('attachments')
|
||||
->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['variante_id', 'attachment_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('variantes_attachments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. Rename table productos_attributes to attribute
|
||||
Schema::rename('productos_attributes', 'attribute');
|
||||
|
||||
// 2. Rename table productos_variantes_definiciones to productos_variantes_values
|
||||
Schema::rename('productos_variantes_definiciones', 'productos_variantes_values');
|
||||
|
||||
// 3. Create products_attributes intermediate table
|
||||
Schema::create('products_attributes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('product_id');
|
||||
$table->unsignedBigInteger('attribute_id');
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('product_id')
|
||||
->references('id')
|
||||
->on('productos')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->foreign('attribute_id')
|
||||
->references('id')
|
||||
->on('attribute')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->unique(['product_id', 'attribute_id']);
|
||||
});
|
||||
|
||||
// 4. Point variant values to the product-attribute pivot instead of the base attribute
|
||||
Schema::table('productos_variantes_values', function (Blueprint $table) {
|
||||
$table->index('producto_variante_id', 'prod_var_values_variant_id_index');
|
||||
});
|
||||
|
||||
Schema::table('productos_variantes_values', function (Blueprint $table) {
|
||||
if (Schema::getConnection()->getDriverName() !== 'sqlite') {
|
||||
$table->dropForeign('productos_variantes_definiciones_attribute_id_foreign');
|
||||
}
|
||||
$table->dropUnique('prod_var_def_variant_attr_unique');
|
||||
});
|
||||
|
||||
Schema::table('productos_variantes_values', function (Blueprint $table) {
|
||||
$table->renameColumn('attribute_id', 'products_attribute_id');
|
||||
});
|
||||
|
||||
Schema::table('productos_variantes_values', function (Blueprint $table) {
|
||||
$table->foreign('products_attribute_id')
|
||||
->references('id')
|
||||
->on('products_attributes')
|
||||
->cascadeOnUpdate()
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->unique(
|
||||
['producto_variante_id', 'products_attribute_id'],
|
||||
'prod_var_values_variant_product_attr_unique'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('productos_variantes_values', function (Blueprint $table) {
|
||||
if (Schema::getConnection()->getDriverName() !== 'sqlite') {
|
||||
$table->dropForeign(['products_attribute_id']);
|
||||
}
|
||||
$table->dropUnique('prod_var_values_variant_product_attr_unique');
|
||||
});
|
||||
|
||||
Schema::table('productos_variantes_values', function (Blueprint $table) {
|
||||
$table->renameColumn('products_attribute_id', 'attribute_id');
|
||||
});
|
||||
|
||||
Schema::table('productos_variantes_values', function (Blueprint $table) {
|
||||
$table->foreign('attribute_id')
|
||||
->references('id')
|
||||
->on('attribute')
|
||||
->cascadeOnUpdate()
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->unique(['producto_variante_id', 'attribute_id'], 'prod_var_def_variant_attr_unique');
|
||||
});
|
||||
|
||||
Schema::table('productos_variantes_values', function (Blueprint $table) {
|
||||
$table->dropIndex('prod_var_values_variant_id_index');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('products_attributes');
|
||||
Schema::rename('productos_variantes_values', 'productos_variantes_definiciones');
|
||||
Schema::rename('attribute', 'productos_attributes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('productos_attachments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('producto_id')
|
||||
->constrained('productos')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignId('attachment_id')
|
||||
->constrained('attachments')
|
||||
->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['producto_id', 'attachment_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('productos_attachments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('productos', function (Blueprint $table) {
|
||||
$table->foreignId('brand_id')
|
||||
->nullable()
|
||||
->after('categoria_id')
|
||||
->constrained('brands')
|
||||
->cascadeOnUpdate()
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('productos', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('brand_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user