Compare commits
129 Commits
feature/at
...
dev
| Author | SHA1 | Date |
|---|---|---|
|
|
d3182fbd13 | |
|
|
b8eb71896c | |
|
|
cb0fb2899c | |
|
|
615eacea91 | |
|
|
84c9fa4c9d | |
|
|
b3d06431e5 | |
|
|
379c2bdbeb | |
|
|
6fc6ed1fac | |
|
|
c05206cc51 | |
|
|
480ee70393 | |
|
|
ceb1e2b04a | |
|
|
1d56d27350 | |
|
|
18c80b075b | |
|
|
10157d7c63 | |
|
|
716d8e447c | |
|
|
cababe1a43 | |
|
|
0e9f7faaf1 | |
|
|
13bdb436ed | |
|
|
c2efec8906 | |
|
|
299a462283 | |
|
|
38f36a3a23 | |
|
|
b4b888adef | |
|
|
3afef63fc7 | |
|
|
e325c49a89 | |
|
|
9c7d025258 | |
|
|
ba58bf79f8 | |
|
|
2910d3f929 | |
|
|
ca7a2ae55c | |
|
|
ca4fea6bcd | |
|
|
73bf285bad | |
|
|
b273b2866f | |
|
|
05a92b7ec3 | |
|
|
f23029e785 | |
|
|
f18fbc8147 | |
|
|
c276ccd311 | |
|
|
786b4eb6a9 | |
|
|
eca3b01083 | |
|
|
7b2a741884 | |
|
|
362e888e46 | |
|
|
92458e217e | |
|
|
4edda0bfd8 | |
|
|
2c2cf45f78 | |
|
|
1fcb3129ea | |
|
|
3703ae9bcf | |
|
|
c19e00987e | |
|
|
7be2221bb8 | |
|
|
dc32eb9bee | |
|
|
6bda3bf013 | |
|
|
3731cd67fe | |
|
|
403caf77f5 | |
|
|
04b2bb0b2a | |
|
|
1af5b1d7ed | |
|
|
387e136707 | |
|
|
9a1e36337d | |
|
|
0c28302f84 | |
|
|
0cb1c37566 | |
|
|
057e6757dc | |
|
|
6d066beea3 | |
|
|
1d94ff8885 | |
|
|
ee3be8d550 | |
|
|
21b8fa5f35 | |
|
|
3887a4fcba | |
|
|
0d4c6a4492 | |
|
|
c1b7b12fe1 | |
|
|
b1e40b3430 | |
|
|
b94efea4e0 | |
|
|
1ae3e9ce92 | |
|
|
0efa1650a0 | |
|
|
9a5a8e85b9 | |
|
|
a95cfc780b | |
|
|
18eedd3209 | |
|
|
00837850e6 | |
|
|
e5bc2cbe38 | |
|
|
efa499d993 | |
|
|
6a0b08c9d7 | |
|
|
230ea57ce0 | |
|
|
4943e68606 | |
|
|
3285015841 | |
|
|
52b422c1ca | |
|
|
a79f58d520 | |
|
|
a7dba4dc10 | |
|
|
4aedf7ca86 | |
|
|
a150a9f694 | |
|
|
6d0d600b4b | |
|
|
67c2a7350e | |
|
|
a0a0cc7255 | |
|
|
d6a17cb449 | |
|
|
83f42f3cf9 | |
|
|
bbfe632926 | |
|
|
e78e64ed2e | |
|
|
35384b48ab | |
|
|
46685a7141 | |
|
|
1aa831f921 | |
|
|
429fa36b05 | |
|
|
cb15905c46 | |
|
|
191a40fcb9 | |
|
|
1d0f9e323d | |
|
|
fdeba7540b | |
|
|
8b10996319 | |
|
|
72261bf422 | |
|
|
f664fbe2dc | |
|
|
46e7c27ae7 | |
|
|
14f3906ef0 | |
|
|
edcb73f748 | |
|
|
a7372f9d08 | |
|
|
b05a574610 | |
|
|
59ed592787 | |
|
|
24235c5ca9 | |
|
|
49771ebb13 | |
|
|
cd420e02d8 | |
|
|
0206ee2985 | |
|
|
9b7d728117 | |
|
|
b5b57a753c | |
|
|
18504594eb | |
|
|
9d3ed17c00 | |
|
|
74e443ece1 | |
|
|
6efda4727f | |
|
|
b0508d79b3 | |
|
|
67bc9e6cb4 | |
|
|
4125d8353d | |
|
|
31b65eb676 | |
|
|
8708cea122 | |
|
|
54aa696145 | |
|
|
1f9d876bc3 | |
|
|
0dae985230 | |
|
|
4c237fb729 | |
|
|
edb4c128d6 | |
|
|
6c45f45815 | |
|
|
70f51ac863 |
|
|
@ -1,8 +1,10 @@
|
|||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
FRONTEND_URL=http://localhost:4200
|
||||
INTEGRATION_SECRET=
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.testing
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Attachable\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
#[Fillable([
|
||||
'attachable_type',
|
||||
'attachable_id',
|
||||
'attachment_id',
|
||||
])]
|
||||
class AttachableAttachment extends Model
|
||||
{
|
||||
protected $table = 'attachable_attachments';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'attachable_id' => 'integer',
|
||||
'attachment_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphTo<Model, $this>
|
||||
*/
|
||||
public function attachable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Attachment, $this>
|
||||
*/
|
||||
public function attachment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Attachment::class, 'attachment_id');
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ 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\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable([
|
||||
|
|
@ -42,10 +41,13 @@ class Attachment extends Model
|
|||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<AttachableAttachment, $this>
|
||||
* Get the pre-signed temporary S3 URL for this attachment.
|
||||
*/
|
||||
public function attachables(): HasMany
|
||||
public function getTemporaryUrl(int $expiresInMinutes = 10): string
|
||||
{
|
||||
return $this->hasMany(AttachableAttachment::class, 'attachment_id');
|
||||
return \Illuminate\Support\Facades\Storage::disk('s3')->temporaryUrl(
|
||||
$this->path,
|
||||
now()->addMinutes($expiresInMinutes)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Attachable\Models\Concerns;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphToMany;
|
||||
|
||||
trait HasAttachments
|
||||
{
|
||||
/**
|
||||
* @return MorphToMany<Attachment, Model, $this>
|
||||
*/
|
||||
public function attachments(): MorphToMany
|
||||
{
|
||||
return $this->morphToMany(
|
||||
Attachment::class,
|
||||
'attachable',
|
||||
'attachable_attachments',
|
||||
'attachable_id',
|
||||
'attachment_id',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Resources\UserResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MeController
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json(UserResource::make($request->user())->resolve());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Requests\UpdateProfileRequest;
|
||||
use App\Domains\Auth\Resources\UserResource;
|
||||
use App\Domains\Auth\Services\ProfileService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class UpdateProfileController
|
||||
{
|
||||
public function __invoke(UpdateProfileRequest $request, ProfileService $service): JsonResponse
|
||||
{
|
||||
$user = $service->update($request->user(), $request->validated());
|
||||
|
||||
return response()->json(UserResource::make($user)->resolve());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace App\Domains\Auth\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono'])]
|
||||
#[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
|
||||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?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', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
|
||||
'dni' => ['nullable', 'string', 'max:255'],
|
||||
'telefono' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateProfileRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
Rule::unique('users', 'email')->ignore($this->user()->id),
|
||||
],
|
||||
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
|
||||
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
|
||||
'password' => ['nullable', 'string', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Auth\Models\User
|
||||
*/
|
||||
class UserResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'nombre_apellido' => $this->nombre_apellido,
|
||||
'email' => $this->email,
|
||||
'dni' => $this->dni,
|
||||
'telefono' => $this->telefono,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class ProfileService
|
||||
{
|
||||
/**
|
||||
* Update the given user's profile information.
|
||||
*
|
||||
* @param User $user
|
||||
* @param array $data
|
||||
* @return User
|
||||
*/
|
||||
public function update(User $user, array $data): User
|
||||
{
|
||||
// Handle password hashing if a new password is provided
|
||||
if (!empty($data['password'])) {
|
||||
$data['password'] = Hash::make($data['password']);
|
||||
} else {
|
||||
// Remove password from array if empty so we don't overwrite it with null
|
||||
unset($data['password']);
|
||||
}
|
||||
|
||||
// Standardize phone number (strip all but numbers and leading '+')
|
||||
if (!empty($data['telefono'])) {
|
||||
$data['telefono'] = preg_replace('/[^\+0-9]/', '', $data['telefono']);
|
||||
}
|
||||
|
||||
// DNI is already validated as numbers only, but we can do a quick strip just in case
|
||||
if (!empty($data['dni'])) {
|
||||
$data['dni'] = preg_replace('/[^0-9]/', '', $data['dni']);
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
|
||||
class RegisterUserService
|
||||
{
|
||||
/**
|
||||
* @param array{nombre_apellido: string, email: string, password: string, dni?: string|null, telefono?: string|null} $data
|
||||
*/
|
||||
public function register(array $data): User
|
||||
{
|
||||
return User::query()->create([
|
||||
'nombre_apellido' => $data['nombre_apellido'],
|
||||
'email' => $data['email'],
|
||||
'password' => $data['password'],
|
||||
'dni' => $data['dni'] ?? null,
|
||||
'telefono' => $data['telefono'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?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 App\Domains\Auth\Controllers\UpdateProfileController;
|
||||
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);
|
||||
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Bundle\Models;
|
||||
|
||||
use App\Domains\Catalog\Models\GroupItem;
|
||||
use App\Domains\Shared\Contracts\Buyable;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
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\MorphMany;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_codigo',
|
||||
'nombre',
|
||||
'descripcion',
|
||||
'precio',
|
||||
])]
|
||||
class Bundle extends Model implements Buyable
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'bundles';
|
||||
|
||||
protected $appends = ['stock_tecnico'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'precio' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<BundleItem, $this>
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(BundleItem::class, 'bundle_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphMany<GroupItem, $this>
|
||||
*/
|
||||
public function groupItems(): MorphMany
|
||||
{
|
||||
return $this->morphMany(GroupItem::class, 'groupable');
|
||||
}
|
||||
|
||||
public function getPrice(): float
|
||||
{
|
||||
return (float) $this->precio;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->nombre ?? 'Bundle';
|
||||
}
|
||||
|
||||
public function availableQuantity(): ?int
|
||||
{
|
||||
if ($this->items->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$availableQuantities = [];
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
$variantQuantity = $item->variant?->availableQuantity();
|
||||
|
||||
if ($variantQuantity !== null) {
|
||||
$availableQuantities[] = intdiv($variantQuantity, $item->cantidad);
|
||||
}
|
||||
}
|
||||
|
||||
return $availableQuantities === [] ? null : min($availableQuantities);
|
||||
}
|
||||
|
||||
public function reserveStock(int $amount): void
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('El monto a reservar debe ser positivo.');
|
||||
}
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
$item->variant->reserveStock($amount * $item->cantidad);
|
||||
}
|
||||
}
|
||||
|
||||
public function decrementReservedStock(int $amount): void
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
|
||||
}
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
$item->variant->decrementReservedStock($amount * $item->cantidad);
|
||||
}
|
||||
}
|
||||
|
||||
public function buy(int $amount): void
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('El monto a comprar debe ser positivo.');
|
||||
}
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
$item->variant->buy($amount * $item->cantidad);
|
||||
}
|
||||
}
|
||||
|
||||
public function validateStock(): void
|
||||
{
|
||||
$availableQuantity = $this->availableQuantity();
|
||||
|
||||
if ($availableQuantity !== null && $availableQuantity <= 0) {
|
||||
throw new \InvalidArgumentException('El bundle no tiene stock tecnico disponible.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function stockTecnico(): Attribute
|
||||
{
|
||||
return Attribute::get(fn (): ?int => $this->availableQuantity());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Bundle\Models;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'bundle_id',
|
||||
'producto_variante_id',
|
||||
'cantidad',
|
||||
])]
|
||||
class BundleItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'bundle_items';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'bundle_id' => 'integer',
|
||||
'producto_variante_id' => 'integer',
|
||||
'cantidad' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Bundle, $this>
|
||||
*/
|
||||
public function bundle(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Bundle::class, 'bundle_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ProductVariant, $this>
|
||||
*/
|
||||
public function variant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
namespace App\Domains\Cart\Controllers;
|
||||
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Cart\Requests\AddCartItemRequest;
|
||||
use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest;
|
||||
use App\Domains\Cart\Resources\CartResource;
|
||||
use App\Domains\Cart\Services\CartService;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
|
@ -16,8 +16,7 @@ class CartController extends Controller
|
|||
{
|
||||
public function __construct(
|
||||
protected CartService $cartService,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Tenant $tenant): CartResource
|
||||
{
|
||||
|
|
@ -29,11 +28,14 @@ class CartController extends Controller
|
|||
$result = $this->cartService->addItem(
|
||||
$tenant,
|
||||
$request,
|
||||
(int) $request->validated('product_variant_id'),
|
||||
$request->mappedBuyableType(),
|
||||
(int) $request->validated('buyable_id'),
|
||||
(int) $request->validated('cantidad'),
|
||||
);
|
||||
|
||||
$response = CartResource::make($result['cart'])->response();
|
||||
$response = CartResource::make($result['cart'])
|
||||
->additional(['message' => 'Producto agregado al carrito.'])
|
||||
->response();
|
||||
|
||||
if ($result['guest_token'] !== null) {
|
||||
$response->withCookie($this->cartService->makeGuestTokenCookie($result['guest_token']));
|
||||
|
|
@ -45,22 +47,22 @@ class CartController extends Controller
|
|||
public function updateItemQuantity(
|
||||
UpdateCartItemQuantityRequest $request,
|
||||
Tenant $tenant,
|
||||
ProductVariant $productVariant,
|
||||
CartItem $cartItem,
|
||||
): CartResource {
|
||||
return CartResource::make(
|
||||
$this->cartService->updateItemQuantity(
|
||||
$tenant,
|
||||
$request,
|
||||
$productVariant->getKey(),
|
||||
$cartItem->getKey(),
|
||||
(int) $request->validated('cantidad'),
|
||||
)
|
||||
);
|
||||
)->additional(['message' => 'Cantidad de producto actualizada.']);
|
||||
}
|
||||
|
||||
public function removeItem(Request $request, Tenant $tenant, ProductVariant $productVariant): CartResource
|
||||
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
|
||||
{
|
||||
return CartResource::make(
|
||||
$this->cartService->removeItem($tenant, $request, $productVariant->getKey())
|
||||
);
|
||||
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
|
||||
)->additional(['message' => 'Producto eliminado del carrito.']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Cart\Middleware;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class MergeGuestCartMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$guestToken = $request->cookie('guest_token');
|
||||
|
||||
// Resolve authenticated user optionally (from default guard or sanctum guard)
|
||||
$user = $request->user() ?? Auth::guard('sanctum')->user();
|
||||
$userId = $user?->getKey();
|
||||
|
||||
\Illuminate\Support\Facades\Log::info('MergeGuestCartMiddleware processed', [
|
||||
'user_id' => $userId,
|
||||
'guest_token' => $guestToken,
|
||||
]);
|
||||
|
||||
if ($user !== null && is_string($guestToken) && $guestToken !== '') {
|
||||
$tenantParam = $request->route('tenant');
|
||||
$tenantCodigo = null;
|
||||
|
||||
if ($tenantParam instanceof Tenant) {
|
||||
$tenantCodigo = $tenantParam->codigo;
|
||||
} elseif (is_string($tenantParam)) {
|
||||
$tenantCodigo = $tenantParam;
|
||||
}
|
||||
|
||||
if ($tenantCodigo !== null) {
|
||||
$guestCart = Cart::query()
|
||||
->where('tenant_codigo', $tenantCodigo)
|
||||
->where('guest_token', $guestToken)
|
||||
->first();
|
||||
|
||||
if ($guestCart !== null && $guestCart->items()->exists()) {
|
||||
$userCart = Cart::query()
|
||||
->where('tenant_codigo', $tenantCodigo)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if ($userCart !== null) {
|
||||
$userCart->delete();
|
||||
}
|
||||
|
||||
$guestCart->update([
|
||||
'user_id' => $userId,
|
||||
'guest_token' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
if ($user !== null && is_string($guestToken) && $guestToken !== '') {
|
||||
// Remove the cookie from the response since the user is authenticated.
|
||||
if (method_exists($response, 'withCookie')) {
|
||||
$response->withCookie(Cookie::forget('guest_token'));
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,14 +2,17 @@
|
|||
|
||||
namespace App\Domains\Cart\Models;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Bundle\Models\Bundle;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Shared\Contracts\Buyable;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
|
@ -23,6 +26,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|||
class Cart extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'carritos';
|
||||
|
||||
|
|
@ -57,7 +61,19 @@ class Cart extends Model
|
|||
return $this->hasMany(CartItem::class, 'cart_id');
|
||||
}
|
||||
|
||||
public function addItem(int $productVariantId, int $quantity): CartItem
|
||||
public function getTotalAmount(): float
|
||||
{
|
||||
$items = $this->relationLoaded('items')
|
||||
? $this->getRelation('items')
|
||||
: $this->items()->with('buyable')->get();
|
||||
|
||||
return (float) $items->reduce(
|
||||
fn (float $carry, $item): float => $carry + ($item->buyable?->getPrice() * $item->cantidad),
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
|
||||
public function addItem(string $buyableType, int $buyableId, int $quantity): CartItem
|
||||
{
|
||||
if ($quantity <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
|
|
@ -65,24 +81,28 @@ class Cart extends Model
|
|||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
|
||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||
return DB::transaction(function () use ($buyableType, $buyableId, $quantity): CartItem {
|
||||
$buyable = $this->resolveScopedBuyable($buyableType, $buyableId, true);
|
||||
$canonicalType = $buyable::class;
|
||||
$availableQuantity = $buyable->availableQuantity();
|
||||
|
||||
if ($variant->stock < $quantity) {
|
||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => 'Stock insuficiente para la variante solicitada.',
|
||||
'cantidad' => "Stock insuficiente para el producto solicitado. Maximo disponible: {$availableQuantity}.",
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var CartItem|null $item */
|
||||
$item = $this->items()
|
||||
->where('producto_variante_id', $variant->getKey())
|
||||
->where('buyable_type', $canonicalType)
|
||||
->where('buyable_id', $buyable->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($item === null) {
|
||||
$item = $this->items()->create([
|
||||
'producto_variante_id' => $variant->getKey(),
|
||||
'buyable_type' => $canonicalType,
|
||||
'buyable_id' => $buyable->getKey(),
|
||||
'cantidad' => $quantity,
|
||||
]);
|
||||
} else {
|
||||
|
|
@ -90,13 +110,13 @@ class Cart extends Model
|
|||
$item->save();
|
||||
}
|
||||
|
||||
$variant->decrement('stock', $quantity);
|
||||
$buyable->reserveStock($quantity);
|
||||
|
||||
return $item->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function updateItem(int $productVariantId, int $quantity): CartItem
|
||||
public function updateItem(int $cartItemId, int $quantity): CartItem
|
||||
{
|
||||
if ($quantity <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
|
|
@ -104,19 +124,21 @@ class Cart extends Model
|
|||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
|
||||
return DB::transaction(function () use ($cartItemId, $quantity): CartItem {
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
->where('producto_variante_id', $productVariantId)
|
||||
->where('id', $cartItemId)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||
$buyable = $this->resolveScopedBuyable($item->buyable_type, $item->buyable_id, true);
|
||||
$delta = $quantity - $item->cantidad;
|
||||
$availableQuantity = $buyable->availableQuantity();
|
||||
|
||||
if ($delta > 0 && $variant->stock < $delta) {
|
||||
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
||||
$maxAvailable = $availableQuantity + $item->cantidad;
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => 'Stock insuficiente para actualizar la cantidad solicitada.',
|
||||
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -124,49 +146,65 @@ class Cart extends Model
|
|||
$item->save();
|
||||
|
||||
if ($delta > 0) {
|
||||
$variant->decrement('stock', $delta);
|
||||
$buyable->reserveStock($delta);
|
||||
}
|
||||
|
||||
if ($delta < 0) {
|
||||
$variant->increment('stock', abs($delta));
|
||||
$buyable->decrementReservedStock(abs($delta));
|
||||
}
|
||||
|
||||
return $item->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function removeItem(int $productVariantId): void
|
||||
public function removeItem(int $cartItemId): void
|
||||
{
|
||||
DB::transaction(function () use ($productVariantId): void {
|
||||
DB::transaction(function () use ($cartItemId): void {
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
->where('producto_variante_id', $productVariantId)
|
||||
->where('id', $cartItemId)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||
$variant->increment('stock', $item->cantidad);
|
||||
$buyable = $this->resolveScopedBuyable($item->buyable_type, $item->buyable_id, true);
|
||||
$buyable->decrementReservedStock($item->cantidad);
|
||||
$item->delete();
|
||||
});
|
||||
}
|
||||
|
||||
protected function resolveScopedVariant(int $productVariantId, bool $lockForUpdate = false): ProductVariant
|
||||
protected function resolveScopedBuyable(string $buyableType, int $buyableId, bool $lockForUpdate = false): Buyable
|
||||
{
|
||||
$query = ProductVariant::query()
|
||||
->whereKey($productVariantId)
|
||||
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo));
|
||||
$buyableClass = $this->resolveBuyableClass($buyableType);
|
||||
|
||||
if ($buyableClass === ProductVariant::class) {
|
||||
$query = ProductVariant::query()
|
||||
->whereKey($buyableId)
|
||||
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo));
|
||||
} else {
|
||||
$query = Bundle::query()
|
||||
->whereKey($buyableId)
|
||||
->where('tenant_codigo', $this->tenant_codigo);
|
||||
}
|
||||
|
||||
if ($lockForUpdate) {
|
||||
$query->lockForUpdate();
|
||||
}
|
||||
|
||||
/** @var ProductVariant|null $variant */
|
||||
$variant = $query->first();
|
||||
$buyable = $query->first();
|
||||
|
||||
if ($variant === null) {
|
||||
throw new NotFoundHttpException('Product variant not found for tenant.');
|
||||
if ($buyable === null) {
|
||||
throw new NotFoundHttpException('Buyable not found for tenant.');
|
||||
}
|
||||
|
||||
return $variant;
|
||||
return $buyable;
|
||||
}
|
||||
|
||||
protected function resolveBuyableClass(string $buyableType): string
|
||||
{
|
||||
return match ($buyableType) {
|
||||
'variant', ProductVariant::class => ProductVariant::class,
|
||||
'bundle', Bundle::class => Bundle::class,
|
||||
default => throw new \InvalidArgumentException('Invalid buyable type'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
|
||||
#[Fillable([
|
||||
'cart_id',
|
||||
'producto_variante_id',
|
||||
'buyable_id',
|
||||
'buyable_type',
|
||||
'cantidad',
|
||||
])]
|
||||
class CartItem extends Model
|
||||
|
|
@ -23,7 +24,8 @@ class CartItem extends Model
|
|||
{
|
||||
return [
|
||||
'cart_id' => 'integer',
|
||||
'producto_variante_id' => 'integer',
|
||||
'buyable_id' => 'integer',
|
||||
'buyable_type' => 'string',
|
||||
'cantidad' => 'integer',
|
||||
];
|
||||
}
|
||||
|
|
@ -37,10 +39,10 @@ class CartItem extends Model
|
|||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ProductVariant, $this>
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||
*/
|
||||
public function variant(): BelongsTo
|
||||
public function buyable()
|
||||
{
|
||||
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
namespace App\Domains\Cart\Requests;
|
||||
|
||||
use App\Domains\Bundle\Models\Bundle;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AddCartItemRequest extends FormRequest
|
||||
{
|
||||
|
|
@ -17,8 +20,18 @@ class AddCartItemRequest extends FormRequest
|
|||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'product_variant_id' => ['required', 'integer'],
|
||||
'buyable_type' => ['required', 'string', Rule::in(['variant', 'bundle'])],
|
||||
'buyable_id' => ['required', 'integer'],
|
||||
'cantidad' => ['required', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
|
||||
public function mappedBuyableType(): string
|
||||
{
|
||||
return match ($this->input('buyable_type')) {
|
||||
'variant' => ProductVariant::class,
|
||||
'bundle' => Bundle::class,
|
||||
default => throw new \InvalidArgumentException('Invalid buyable type'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ class UpdateCartItemQuantityRequest extends FormRequest
|
|||
{
|
||||
return [
|
||||
'cantidad' => ['required', 'integer', 'min:1'],
|
||||
'buyable_type' => ['prohibited'],
|
||||
'buyable_id' => ['prohibited'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -16,28 +15,42 @@ class CartItemResource extends JsonResource
|
|||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$variant = $this->variant;
|
||||
$product = $variant?->product;
|
||||
/** @var \App\Domains\Shared\Contracts\Buyable|null $buyable */
|
||||
$buyable = $this->buyable;
|
||||
|
||||
$productName = $buyable?->getName();
|
||||
$precio = $buyable?->getPrice();
|
||||
|
||||
$imageUrl = null;
|
||||
if ($this->buyable_type === \App\Domains\Catalog\Models\ProductVariant::class && $buyable && $buyable->relationLoaded('attachments')) {
|
||||
$firstAttachment = $buyable->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),
|
||||
'product' => $product === null ? null : [
|
||||
'id' => $product->id,
|
||||
'nombre' => $product->nombre,
|
||||
'slug' => $product->slug,
|
||||
],
|
||||
'variant' => $variant === null ? null : [
|
||||
'id' => $variant->id,
|
||||
'nombre' => $variant->nombre,
|
||||
'slug' => $variant->slug,
|
||||
'definitions' => ProductVariantDefinitionResource::collection($variant->definitions),
|
||||
'precio_unitario' => $this->formatMoney($precio),
|
||||
'buyable_type' => $this->mapBuyableTypeToAlias($this->buyable_type),
|
||||
'buyable_id' => $this->buyable_id,
|
||||
'product' => $buyable === null ? null : [
|
||||
'nombre' => $productName,
|
||||
'imagen' => $imageUrl,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function mapBuyableTypeToAlias(?string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
\App\Domains\Catalog\Models\ProductVariant::class => 'variant',
|
||||
\App\Domains\Bundle\Models\Bundle::class => 'bundle',
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
protected function formatMoney(float|int|string|null $amount): string
|
||||
{
|
||||
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
|
||||
namespace App\Domains\Cart\Resources;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Cart\Models\Cart
|
||||
* @mixin Cart
|
||||
*/
|
||||
class CartResource extends JsonResource
|
||||
{
|
||||
|
|
@ -20,7 +21,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->buyable?->getPrice() ?? 0) * $item->cantidad),
|
||||
0.0,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@
|
|||
|
||||
namespace App\Domains\Cart\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Bundle\Models\Bundle;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
|
@ -14,13 +18,13 @@ class CartService
|
|||
{
|
||||
public function show(Tenant $tenant, Request $request): Cart
|
||||
{
|
||||
$identity = $this->resolveIdentity($request);
|
||||
$resolvedIdentity = $this->resolveIdentity($request);
|
||||
|
||||
if ($identity === null) {
|
||||
if ($resolvedIdentity === null) {
|
||||
return $this->makeEmptyCart($tenant);
|
||||
}
|
||||
|
||||
$cart = $this->findCart($tenant, $identity);
|
||||
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
|
||||
|
||||
if ($cart === null) {
|
||||
return $this->makeEmptyCart($tenant);
|
||||
|
|
@ -32,12 +36,12 @@ class CartService
|
|||
/**
|
||||
* @return array{cart: Cart, guest_token: ?string}
|
||||
*/
|
||||
public function addItem(Tenant $tenant, Request $request, int $productVariantId, int $quantity): array
|
||||
public function addItem(Tenant $tenant, Request $request, string $buyableType, int $buyableId, int $quantity): array
|
||||
{
|
||||
$resolvedIdentity = $this->resolveIdentity($request, true);
|
||||
$identity = $resolvedIdentity['identity'];
|
||||
$cart = $this->findOrCreateCart($tenant, $identity);
|
||||
$cart->addItem($productVariantId, $quantity);
|
||||
$cart->addItem($buyableType, $buyableId, $quantity);
|
||||
|
||||
return [
|
||||
'cart' => $this->loadCart($cart),
|
||||
|
|
@ -45,20 +49,20 @@ class CartService
|
|||
];
|
||||
}
|
||||
|
||||
public function updateItemQuantity(Tenant $tenant, Request $request, int $productVariantId, int $quantity): Cart
|
||||
public function updateItemQuantity(Tenant $tenant, Request $request, int $cartItemId, int $quantity): Cart
|
||||
{
|
||||
$identity = $this->requireIdentity($request);
|
||||
$cart = $this->findCartOrFail($tenant, $identity);
|
||||
$cart->updateItem($productVariantId, $quantity);
|
||||
$cart->updateItem($cartItemId, $quantity);
|
||||
|
||||
return $this->loadCart($cart);
|
||||
}
|
||||
|
||||
public function removeItem(Tenant $tenant, Request $request, int $productVariantId): Cart
|
||||
public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
|
||||
{
|
||||
$identity = $this->requireIdentity($request);
|
||||
$cart = $this->findCartOrFail($tenant, $identity);
|
||||
$cart->removeItem($productVariantId);
|
||||
$cart->removeItem($cartItemId);
|
||||
|
||||
return $this->loadCart($cart);
|
||||
}
|
||||
|
|
@ -93,8 +97,16 @@ class CartService
|
|||
protected function loadCart(Cart $cart): Cart
|
||||
{
|
||||
return $cart->fresh()->load([
|
||||
'items.variant.product',
|
||||
'items.variant.definitions.attribute',
|
||||
'items.buyable' => function (MorphTo $morphTo): void {
|
||||
$morphTo->morphWith([
|
||||
ProductVariant::class => [
|
||||
'product',
|
||||
'definitions.productAttribute.attribute',
|
||||
'attachments',
|
||||
],
|
||||
Bundle::class => ['items.variant'],
|
||||
]);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -103,7 +115,7 @@ class CartService
|
|||
*/
|
||||
protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array
|
||||
{
|
||||
$user = $request->user();
|
||||
$user = $request->user() ?? Auth::guard('sanctum')->user();
|
||||
|
||||
if ($user instanceof User) {
|
||||
return [
|
||||
|
|
@ -200,5 +212,4 @@ class CartService
|
|||
|
||||
return Cart::query()->firstOrCreate($attributes, ['status' => 'active']);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@
|
|||
use App\Domains\Cart\Controllers\CartController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
||||
Route::get('cart', [CartController::class, 'show']);
|
||||
Route::post('cart/items', [CartController::class, 'addItem']);
|
||||
Route::patch('cart/items/{productVariant}', [CartController::class, 'updateItemQuantity']);
|
||||
Route::delete('cart/items/{productVariant}', [CartController::class, 'removeItem']);
|
||||
});
|
||||
Route::prefix('tenants/{tenant:codigo}')
|
||||
->middleware(\App\Domains\Cart\Middleware\MergeGuestCartMiddleware::class)
|
||||
->group(function (): void {
|
||||
Route::get('cart', [CartController::class, 'show']);
|
||||
Route::post('cart/items', [CartController::class, 'addItem']);
|
||||
Route::patch('cart/items/{cartItem}', [CartController::class, 'updateItemQuantity']);
|
||||
Route::delete('cart/items/{cartItem}', [CartController::class, 'removeItem']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Bundle\Models\Bundle;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class CatalogController extends Controller
|
||||
{
|
||||
public function index(string $tenant): JsonResponse
|
||||
{
|
||||
$featuredGroups = FeaturedGroup::where('tenant_codigo', $tenant)
|
||||
->with([
|
||||
'groupItems' => fn ($query) => $query->orderBy('order'),
|
||||
'groupItems.groupable' => function (MorphTo $morphTo): void {
|
||||
$morphTo->morphWith([
|
||||
ProductVariant::class => ['product', 'attachments', 'product.attachments'],
|
||||
Bundle::class => ['items.variant.product', 'items.variant.attachments', 'items.variant.product.attachments'],
|
||||
]);
|
||||
},
|
||||
])
|
||||
->orderBy('group_order')
|
||||
->get();
|
||||
|
||||
return response()->json(CatalogFeaturedGroupResource::collection($featuredGroups)->resolve());
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Requests\StoreFeaturedGroupRequest;
|
||||
use App\Domains\Catalog\Requests\UpdateFeaturedGroupRequest;
|
||||
use App\Domains\Catalog\Resources\FeaturedGroupResource;
|
||||
use App\Domains\Catalog\Services\FeaturedGroupService;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class FeaturedGroupController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FeaturedGroupService $featuredGroupService
|
||||
) {}
|
||||
|
||||
public function index(string $tenant): AnonymousResourceCollection
|
||||
{
|
||||
$groups = FeaturedGroup::where('tenant_codigo', $tenant)->get();
|
||||
return FeaturedGroupResource::collection($groups);
|
||||
}
|
||||
|
||||
public function store(StoreFeaturedGroupRequest $request, string $tenant): FeaturedGroupResource
|
||||
{
|
||||
$group = $this->featuredGroupService->createGroup($tenant, $request->validated());
|
||||
return new FeaturedGroupResource($group);
|
||||
}
|
||||
|
||||
public function show(string $tenant, FeaturedGroup $featuredGroup): FeaturedGroupResource
|
||||
{
|
||||
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
|
||||
return new FeaturedGroupResource($featuredGroup->load('groupItems'));
|
||||
}
|
||||
|
||||
public function update(UpdateFeaturedGroupRequest $request, string $tenant, FeaturedGroup $featuredGroup): FeaturedGroupResource
|
||||
{
|
||||
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
|
||||
$group = $this->featuredGroupService->updateGroup($featuredGroup, $request->validated());
|
||||
return new FeaturedGroupResource($group);
|
||||
}
|
||||
|
||||
public function destroy(string $tenant, FeaturedGroup $featuredGroup): JsonResponse
|
||||
{
|
||||
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
|
||||
$this->featuredGroupService->deleteGroup($featuredGroup);
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Bundle\Models\Bundle;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Models\GroupItem;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Catalog\Requests\StoreGroupItemRequest;
|
||||
use App\Domains\Catalog\Requests\UpdateGroupItemRequest;
|
||||
use App\Domains\Catalog\Resources\GroupItemResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class GroupItemController extends Controller
|
||||
{
|
||||
public function index(string $tenant, FeaturedGroup $featuredGroup): AnonymousResourceCollection
|
||||
{
|
||||
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
|
||||
|
||||
return GroupItemResource::collection($featuredGroup->groupItems);
|
||||
}
|
||||
|
||||
public function store(StoreGroupItemRequest $request, string $tenant, FeaturedGroup $featuredGroup): GroupItemResource
|
||||
{
|
||||
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
|
||||
|
||||
$groupableType = $request->mappedGroupableType();
|
||||
$groupable = $groupableType::query()->findOrFail($request->integer('groupable_id'));
|
||||
|
||||
abort_if(
|
||||
($groupable instanceof ProductVariant && $groupable->product()->where('tenant_codigo', $tenant)->doesntExist())
|
||||
|| ($groupable instanceof Bundle && $groupable->tenant_codigo !== $tenant),
|
||||
404
|
||||
);
|
||||
|
||||
$groupItem = $featuredGroup->groupItems()->create([
|
||||
'groupable_type' => $groupableType,
|
||||
'groupable_id' => $groupable->getKey(),
|
||||
'order' => $request->validated('order'),
|
||||
]);
|
||||
|
||||
return new GroupItemResource($groupItem);
|
||||
}
|
||||
|
||||
public function update(UpdateGroupItemRequest $request, string $tenant, FeaturedGroup $featuredGroup, GroupItem $groupItem): GroupItemResource
|
||||
{
|
||||
abort_if($featuredGroup->tenant_codigo !== $tenant || $groupItem->featured_group_id !== $featuredGroup->id, 404);
|
||||
$groupItem->update($request->validated());
|
||||
|
||||
return new GroupItemResource($groupItem);
|
||||
}
|
||||
|
||||
public function destroy(string $tenant, FeaturedGroup $featuredGroup, GroupItem $groupItem): JsonResponse
|
||||
{
|
||||
abort_if($featuredGroup->tenant_codigo !== $tenant || $groupItem->featured_group_id !== $featuredGroup->id, 404);
|
||||
$groupItem->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Enums;
|
||||
|
||||
enum InventoryPolicy: string
|
||||
{
|
||||
case Tracked = 'tracked';
|
||||
case Unlimited = 'unlimited';
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class FeaturedGroup extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'tenant_codigo',
|
||||
'group_name',
|
||||
'product_layout',
|
||||
'group_order',
|
||||
];
|
||||
|
||||
public function groupItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupItem::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class GroupItem extends Model
|
||||
{
|
||||
protected $table = 'group_items';
|
||||
|
||||
protected $fillable = [
|
||||
'featured_group_id',
|
||||
'groupable_type',
|
||||
'groupable_id',
|
||||
'order',
|
||||
];
|
||||
|
||||
public function featuredGroup(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FeaturedGroup::class);
|
||||
}
|
||||
|
||||
public function groupable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,32 +2,191 @@
|
|||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Shared\Contracts\Buyable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
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\Relations\MorphMany;
|
||||
|
||||
#[Fillable([
|
||||
'producto_id',
|
||||
'slug',
|
||||
'nombre',
|
||||
'inventory_policy',
|
||||
'stock_real',
|
||||
'stock_reservado',
|
||||
'stock',
|
||||
'descripcion',
|
||||
'precio',
|
||||
'is_placeholder',
|
||||
'has_tickets',
|
||||
'minimum_use_date',
|
||||
'maximum_use_date',
|
||||
])]
|
||||
class ProductVariant extends Model
|
||||
class ProductVariant extends Model implements Buyable
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'productos_variantes';
|
||||
|
||||
protected $appends = ['stock_tecnico'];
|
||||
|
||||
protected $attributes = [
|
||||
'inventory_policy' => 'tracked',
|
||||
'stock_real' => 0,
|
||||
'stock_reservado' => 0,
|
||||
'cantidad_vendida' => 0,
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (ProductVariant $variant) {
|
||||
if ($variant->exists && $variant->isDirty('inventory_policy')) {
|
||||
throw new \InvalidArgumentException('La politica de inventario no puede modificarse.');
|
||||
}
|
||||
|
||||
$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->cantidad_vendida < 0) {
|
||||
throw new \InvalidArgumentException('La cantidad vendida no puede ser negativa.');
|
||||
}
|
||||
|
||||
if ($this->tracksInventory() && $this->stock_reservado > $this->stock_real) {
|
||||
throw new \InvalidArgumentException('El stock reservado no puede ser mayor que el stock real.');
|
||||
}
|
||||
}
|
||||
|
||||
public function tracksInventory(): bool
|
||||
{
|
||||
return $this->inventory_policy === InventoryPolicy::Tracked;
|
||||
}
|
||||
|
||||
public function availableQuantity(): ?int
|
||||
{
|
||||
if (! $this->tracksInventory()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->stock_real - $this->stock_reservado;
|
||||
}
|
||||
|
||||
public function isAvailableForSale(): bool
|
||||
{
|
||||
return ! $this->tracksInventory() || $this->availableQuantity() > 0;
|
||||
}
|
||||
|
||||
public function getPrice(): float
|
||||
{
|
||||
return (float) ($this->product->precio ?? 0.0);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
$name = $this->product?->nombre ?? 'Producto';
|
||||
|
||||
if ($this->relationLoaded('definitions') && $this->definitions->isNotEmpty()) {
|
||||
$definitions = $this->definitions->map(function ($def) {
|
||||
$attributeName = $def->productAttribute?->attribute?->nombre;
|
||||
$value = $def->value;
|
||||
|
||||
return $attributeName ? "{$attributeName}: {$value}" : $value;
|
||||
})->filter()->implode(', ');
|
||||
|
||||
if ($definitions !== '') {
|
||||
$name .= " ({$definitions})";
|
||||
}
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
public function reserveStock(int $amount): void
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.');
|
||||
}
|
||||
|
||||
if ($this->tracksInventory() && $this->availableQuantity() < $amount) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
|
||||
}
|
||||
|
||||
$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();
|
||||
}
|
||||
|
||||
public function buy(int $amount): void
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('El monto a confirmar debe ser positivo.');
|
||||
}
|
||||
|
||||
if ($this->tracksInventory() && $this->stock_real < $amount) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la reserva.');
|
||||
}
|
||||
|
||||
if ($this->stock_reservado < $amount) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock reservado para confirmar la reserva.');
|
||||
}
|
||||
|
||||
if ($this->tracksInventory()) {
|
||||
$this->stock_real -= $amount;
|
||||
}
|
||||
|
||||
$this->stock_reservado -= $amount;
|
||||
$this->cantidad_vendida += $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
protected function stockTecnico(): Attribute
|
||||
{
|
||||
return Attribute::get(fn (): ?int => $this->availableQuantity());
|
||||
}
|
||||
|
||||
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',
|
||||
'inventory_policy' => InventoryPolicy::class,
|
||||
'stock_real' => 'integer',
|
||||
'stock_reservado' => 'integer',
|
||||
'cantidad_vendida' => 'integer',
|
||||
'is_placeholder' => 'boolean',
|
||||
'has_tickets' => 'boolean',
|
||||
'minimum_use_date' => 'datetime',
|
||||
'maximum_use_date' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -46,4 +205,25 @@ 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphMany<GroupItem, $this>
|
||||
*/
|
||||
public function groupItems(): MorphMany
|
||||
{
|
||||
return $this->morphMany(GroupItem::class, 'groupable');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreFeaturedGroupRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'group_name' => ['required', 'string', 'max:255'],
|
||||
'product_layout' => ['required', 'string', 'in:row,column_with_image,column_with_cart,vertical_with_image,vertical_with_cart'],
|
||||
'group_order' => ['nullable', 'integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Bundle\Models\Bundle;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreGroupItemRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'groupable_type' => ['required', 'string', Rule::in(['variant', 'bundle'])],
|
||||
'groupable_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists(match ($this->input('groupable_type')) {
|
||||
'bundle' => 'bundles',
|
||||
default => 'productos_variantes',
|
||||
}, 'id'),
|
||||
],
|
||||
'order' => ['nullable', 'integer'],
|
||||
];
|
||||
}
|
||||
|
||||
public function mappedGroupableType(): string
|
||||
{
|
||||
return match ($this->input('groupable_type')) {
|
||||
'variant' => ProductVariant::class,
|
||||
'bundle' => Bundle::class,
|
||||
default => throw new \InvalidArgumentException('Invalid groupable type'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
|
|
@ -19,10 +21,30 @@ 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'],
|
||||
'inventory_policy' => ['sometimes', Rule::enum(InventoryPolicy::class)],
|
||||
'cantidad_vendida' => ['prohibited'],
|
||||
'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,8 @@
|
|||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
|
|
@ -18,35 +20,24 @@ 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'],
|
||||
'inventory_policy' => ['sometimes', Rule::enum(InventoryPolicy::class)],
|
||||
'cantidad_vendida' => ['prohibited'],
|
||||
'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],
|
||||
'has_tickets' => ['boolean'],
|
||||
'minimum_use_date' => ['nullable', 'date'],
|
||||
'maximum_use_date' => ['nullable', 'date', 'after_or_equal:minimum_use_date'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateFeaturedGroupRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'group_name' => ['sometimes', 'string', 'max:255'],
|
||||
'product_layout' => ['sometimes', 'string', 'in:row,column_with_image,column_with_cart,vertical_with_image,vertical_with_cart'],
|
||||
'group_order' => ['nullable', 'integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateGroupItemRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'order' => ['sometimes', 'integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,25 @@ 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'],
|
||||
'inventory_policy' => ['prohibited'],
|
||||
'cantidad_vendida' => ['prohibited'],
|
||||
'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],
|
||||
'has_tickets' => ['boolean'],
|
||||
'minimum_use_date' => ['nullable', 'date'],
|
||||
'maximum_use_date' => ['nullable', 'date', 'after_or_equal:minimum_use_date'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use App\Domains\Bundle\Models\Bundle;
|
||||
use App\Domains\Catalog\Models\GroupItem;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CatalogFeaturedGroupResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->group_name,
|
||||
'layout' => $this->product_layout,
|
||||
'group_order' => $this->group_order,
|
||||
'items' => $this->whenLoaded('groupItems', function () use ($request) {
|
||||
return $this->groupItems->map(function (GroupItem $groupItem) use ($request) {
|
||||
$groupable = $groupItem->groupable;
|
||||
|
||||
if ($groupable instanceof Bundle) {
|
||||
return [
|
||||
'id' => $groupable->id,
|
||||
'groupable_type' => 'bundle',
|
||||
'groupable_id' => $groupable->id,
|
||||
'nombre' => $groupable->nombre,
|
||||
'descripcion' => $groupable->descripcion,
|
||||
'precio' => $groupable->precio,
|
||||
'cantidad_maxima' => $groupable->stock_tecnico,
|
||||
'items' => $groupable->items->map(fn ($item) => [
|
||||
'cantidad' => $item->cantidad,
|
||||
'variant' => (new ProductVariantResource($item->variant))->toArray($request),
|
||||
])->values(),
|
||||
];
|
||||
}
|
||||
|
||||
if (! $groupable instanceof ProductVariant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$variantResource = (new ProductVariantResource($groupable))->toArray($request);
|
||||
$variantResource['groupable_type'] = 'variant';
|
||||
$variantResource['groupable_id'] = $groupable->id;
|
||||
|
||||
if ($this->product_layout === 'row' || $this->product_layout === 'column_with_cart' || $this->product_layout === 'vertical_with_cart') {
|
||||
// Devuelve el Product Variant Resource completo como pidio el usuario
|
||||
// "Que todo devuelva el product variant resource"
|
||||
return $variantResource;
|
||||
}
|
||||
|
||||
// Para column_with_image o vertical_with_image
|
||||
if ($this->product_layout === 'column_with_image' || $this->product_layout === 'vertical_with_image') {
|
||||
if (isset($variantResource['images']) && count($variantResource['images']) > 0) {
|
||||
$variantResource['images'] = [$variantResource['images'][0]];
|
||||
}
|
||||
|
||||
return $variantResource;
|
||||
}
|
||||
|
||||
return $variantResource;
|
||||
})->filter()->values();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class FeaturedGroupResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'tenant_codigo' => $this->tenant_codigo,
|
||||
'group_name' => $this->group_name,
|
||||
'product_layout' => $this->product_layout,
|
||||
'group_order' => $this->group_order,
|
||||
'group_items' => GroupItemResource::collection($this->whenLoaded('groupItems')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use App\Domains\Bundle\Models\Bundle;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class GroupItemResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'featured_group_id' => $this->featured_group_id,
|
||||
'groupable_type' => match ($this->groupable_type) {
|
||||
ProductVariant::class => 'variant',
|
||||
Bundle::class => 'bundle',
|
||||
default => 'unknown',
|
||||
},
|
||||
'groupable_id' => $this->groupable_id,
|
||||
'order' => $this->order,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,39 @@ 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,
|
||||
'inventory_policy' => $variant->inventory_policy->value,
|
||||
'cantidad_maxima' => $variant->stock_tecnico,
|
||||
'cantidad_vendida' => $variant->cantidad_vendida,
|
||||
'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,43 @@ 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,
|
||||
'inventory_policy' => $this->inventory_policy->value,
|
||||
'cantidad_maxima' => $this->stock_tecnico,
|
||||
'cantidad_vendida' => $this->cantidad_vendida,
|
||||
'has_tickets' => $this->has_tickets,
|
||||
'minimum_use_date' => $this->minimum_use_date,
|
||||
'maximum_use_date' => $this->maximum_use_date,
|
||||
'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();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
|
||||
class FeaturedGroupService
|
||||
{
|
||||
public function createGroup(string $tenantCodigo, array $data): FeaturedGroup
|
||||
{
|
||||
$data['tenant_codigo'] = $tenantCodigo;
|
||||
return FeaturedGroup::create($data);
|
||||
}
|
||||
|
||||
public function updateGroup(FeaturedGroup $group, array $data): FeaturedGroup
|
||||
{
|
||||
$group->update($data);
|
||||
return $group;
|
||||
}
|
||||
|
||||
public function deleteGroup(FeaturedGroup $group): bool|null
|
||||
{
|
||||
return $group->delete();
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,389 @@
|
|||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Attachable\Services\AttachmentService;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
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;
|
||||
$inventoryPolicy = $data['inventory_policy'] ?? InventoryPolicy::Tracked->value;
|
||||
unset($data['attribute_ids'], $data['images'], $data['stock'], $data['inventory_policy']);
|
||||
|
||||
/** @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,
|
||||
'inventory_policy' => $inventoryPolicy,
|
||||
'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,
|
||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||
'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->isAvailableForSale());
|
||||
|
||||
if ($variantId !== null && $selectedVariant === null) {
|
||||
throw new NotFoundHttpException('Product variant not found for product.');
|
||||
}
|
||||
|
||||
if ($variantId !== null && ! $selectedVariant->isAvailableForSale()) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,31 @@
|
|||
<?php
|
||||
|
||||
use App\Domains\Catalog\Controllers\AttributeController;
|
||||
use App\Domains\Catalog\Controllers\BrandController;
|
||||
use App\Domains\Catalog\Controllers\CatalogController;
|
||||
use App\Domains\Catalog\Controllers\CategoryController;
|
||||
use App\Domains\Catalog\Controllers\FeaturedGroupController;
|
||||
use App\Domains\Catalog\Controllers\GroupItemController;
|
||||
use App\Domains\Catalog\Controllers\ProductController;
|
||||
use App\Domains\Catalog\Controllers\ProductAttributeController;
|
||||
use App\Domains\Catalog\Controllers\ProductVariantController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
||||
Route::get('catalog', [CatalogController::class, 'index']);
|
||||
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',
|
||||
]);
|
||||
Route::apiResource('featured-groups', FeaturedGroupController::class);
|
||||
Route::apiResource('featured-groups.items', GroupItemController::class)
|
||||
->only(['index', 'store', 'update', 'destroy'])
|
||||
->parameters([
|
||||
'featured-groups' => 'featuredGroup',
|
||||
'items' => 'groupItem',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Casts;
|
||||
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Exception;
|
||||
|
||||
class EncryptedIntegrationData implements CastsAttributes
|
||||
{
|
||||
protected function getEncrypter(): Encrypter
|
||||
{
|
||||
$secret = config('services.integrations.secret');
|
||||
|
||||
if (empty($secret)) {
|
||||
throw new Exception('The integrations secret is not configured.');
|
||||
}
|
||||
|
||||
// Laravel encrypter requires a key of exact length. Typically 32 bytes for AES-256-CBC.
|
||||
// If the secret is base64 encoded like the APP_KEY:
|
||||
if (str_starts_with($secret, 'base64:')) {
|
||||
$key = base64_decode(substr($secret, 7));
|
||||
} else {
|
||||
// Otherwise, we hash it to ensure 32 bytes for AES-256-CBC.
|
||||
$key = hash('sha256', $secret, true);
|
||||
}
|
||||
|
||||
return new Encrypter($key, config('app.cipher', 'AES-256-CBC'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast the given value.
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$decrypted = $this->getEncrypter()->decryptString($value);
|
||||
return json_decode($decrypted, true);
|
||||
} catch (Exception $e) {
|
||||
// Return null or throw depending on how strict we want to be.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the given value for storage.
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_encode($value);
|
||||
return $this->getEncrypter()->encryptString($json);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Controllers;
|
||||
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use App\Domains\Integration\Requests\StoreIntegrationRequest;
|
||||
use App\Domains\Integration\Requests\UpdateIntegrationRequest;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class IntegrationController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return response()->json(Integration::all());
|
||||
}
|
||||
|
||||
public function store(StoreIntegrationRequest $request)
|
||||
{
|
||||
$integration = Integration::create($request->validated());
|
||||
|
||||
return response()->json($integration, 201);
|
||||
}
|
||||
|
||||
public function show(Integration $integration)
|
||||
{
|
||||
return response()->json($integration);
|
||||
}
|
||||
|
||||
public function update(UpdateIntegrationRequest $request, Integration $integration)
|
||||
{
|
||||
$integration->update($request->validated());
|
||||
|
||||
return response()->json($integration->fresh());
|
||||
}
|
||||
|
||||
public function destroy(Integration $integration)
|
||||
{
|
||||
$integration->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Controllers;
|
||||
|
||||
use App\Domains\Integration\Requests\TelepagosWebhookRequest;
|
||||
use App\Domains\Integration\Services\TelepagosWebhookService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TelepagosWebhookController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Telepagos webhook.
|
||||
*
|
||||
* @param TelepagosWebhookRequest $request
|
||||
* @param string $tenantCodigo
|
||||
* @param TelepagosWebhookService $service
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse
|
||||
{
|
||||
try {
|
||||
$cashinId = $request->validated('id');
|
||||
|
||||
$service->handleWebhook($tenantCodigo, $cashinId);
|
||||
|
||||
return response()->json(['status' => 'success']);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Controllers;
|
||||
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use App\Domains\Integration\Requests\StoreTenantIntegrationRequest;
|
||||
use App\Domains\Integration\Services\TenantIntegrationService;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class TenantIntegrationController extends Controller
|
||||
{
|
||||
protected TenantIntegrationService $tenantIntegrationService;
|
||||
|
||||
public function __construct(TenantIntegrationService $tenantIntegrationService)
|
||||
{
|
||||
$this->tenantIntegrationService = $tenantIntegrationService;
|
||||
}
|
||||
|
||||
public function index(string $tenantCode)
|
||||
{
|
||||
return response()->json($this->tenantIntegrationService->getAllForTenant($tenantCode));
|
||||
}
|
||||
|
||||
public function show(string $tenantCode, string $integrationCode)
|
||||
{
|
||||
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
|
||||
|
||||
if (!$integration) {
|
||||
return response()->json(['message' => 'Integration not configured for this tenant'], 404);
|
||||
}
|
||||
|
||||
return response()->json($integration);
|
||||
}
|
||||
|
||||
public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode)
|
||||
{
|
||||
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
|
||||
|
||||
try {
|
||||
$tenantIntegration = $this->tenantIntegrationService->updateOrCreateIntegration(
|
||||
$tenantCode,
|
||||
$integration,
|
||||
$request->input('integration_data', [])
|
||||
);
|
||||
|
||||
return response()->json($tenantIntegration);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'message' => 'Error validando la configuración: ' . $e->getMessage()
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Integration extends Model
|
||||
{
|
||||
protected $table = 'integrations';
|
||||
|
||||
protected $fillable = [
|
||||
'integration_code',
|
||||
'name',
|
||||
'url',
|
||||
'integration_data_schema',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'integration_data_schema' => 'array',
|
||||
];
|
||||
|
||||
public function tenantIntegrations()
|
||||
{
|
||||
return $this->hasMany(TenantIntegration::class, 'integration_code', 'integration_code');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Models;
|
||||
|
||||
use App\Domains\Integration\Casts\EncryptedIntegrationData;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TenantIntegration extends Model
|
||||
{
|
||||
protected $table = 'tenant_integration';
|
||||
|
||||
protected $fillable = [
|
||||
'integration_code',
|
||||
'tenant_code',
|
||||
'integration_data',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'integration_data' => EncryptedIntegrationData::class,
|
||||
];
|
||||
|
||||
public function integration()
|
||||
{
|
||||
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreIntegrationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'integration_code' => ['required', 'string', 'unique:integrations,integration_code'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
'integration_data_schema' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class StoreTenantIntegrationRequest extends FormRequest
|
||||
{
|
||||
protected ?Integration $integrationModel = null;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation()
|
||||
{
|
||||
$integrationCode = $this->route('integration_code');
|
||||
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
|
||||
|
||||
if (!$this->integrationModel) {
|
||||
throw ValidationException::withMessages([
|
||||
'integration_code' => 'Integration not found.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
// Dynamic validation rules based on the integration data schema
|
||||
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
|
||||
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
|
||||
$rules['integration_data.' . $field] = $rule;
|
||||
}
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TelepagosWebhookRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'id' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateIntegrationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$integration = $this->route('integration');
|
||||
|
||||
return [
|
||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
'integration_data_schema' => ['nullable', 'array'],
|
||||
// the code shouldn't ideally be updatable, but if it is:
|
||||
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,' . ($integration->id ?? '')],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use App\Domains\Integration\Models\TenantIntegration;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Exception;
|
||||
|
||||
abstract class BaseIntegrationService
|
||||
{
|
||||
/**
|
||||
* The unique code of the integration.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $integrationCode;
|
||||
|
||||
/**
|
||||
* The current tenant code.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $tenantCode;
|
||||
|
||||
/**
|
||||
* The integration model instance.
|
||||
*
|
||||
* @var Integration|null
|
||||
*/
|
||||
protected ?Integration $integration = null;
|
||||
|
||||
/**
|
||||
* The tenant-specific integration model instance.
|
||||
*
|
||||
* @var TenantIntegration|null
|
||||
*/
|
||||
protected ?TenantIntegration $tenantIntegration = null;
|
||||
|
||||
/**
|
||||
* Set the integration code.
|
||||
*
|
||||
* @param string $integrationCode
|
||||
* @return $this
|
||||
*/
|
||||
public function setIntegrationCode(string $integrationCode): self
|
||||
{
|
||||
$this->integrationCode = $integrationCode;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the integration code.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getIntegrationCode(): string
|
||||
{
|
||||
return $this->integrationCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the tenant code and load the integration models.
|
||||
*
|
||||
* @param string $tenantCode
|
||||
* @return $this
|
||||
* @throws Exception
|
||||
*/
|
||||
public function forTenant(string $tenantCode): self
|
||||
{
|
||||
$this->tenantCode = $tenantCode;
|
||||
$this->loadIntegration();
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the Integration and TenantIntegration models.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function loadIntegration(): void
|
||||
{
|
||||
if (empty($this->integrationCode)) {
|
||||
throw new Exception("Integration code is not set.");
|
||||
}
|
||||
|
||||
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
|
||||
if (!$this->integration) {
|
||||
throw new Exception("Integration with code '{$this->integrationCode}' not found.");
|
||||
}
|
||||
|
||||
$this->tenantIntegration = TenantIntegration::where('tenant_code', $this->tenantCode)
|
||||
->where('integration_code', $this->integrationCode)
|
||||
->first();
|
||||
|
||||
if (!$this->tenantIntegration) {
|
||||
throw new Exception("Tenant '{$this->tenantCode}' does not have integration '{$this->integrationCode}' configured.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request URL.
|
||||
*
|
||||
* @param string $path
|
||||
* @return string
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getUrl(string $path = ''): string
|
||||
{
|
||||
if (!$this->integration) {
|
||||
throw new Exception("Integration is not loaded. Call forTenant() first.");
|
||||
}
|
||||
|
||||
$baseUrl = rtrim($this->integration->url, '/');
|
||||
$path = ltrim($path, '/');
|
||||
|
||||
return $path !== '' ? "{$baseUrl}/{$path}" : $baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get integration setting by key from tenant's integration data.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $default
|
||||
* @return mixed
|
||||
*/
|
||||
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
|
||||
{
|
||||
if (!$this->tenantIntegration || !$this->tenantIntegration->integration_data) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $this->tenantIntegration->integration_data[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pre-configured HTTP client builder.
|
||||
*
|
||||
* @return PendingRequest
|
||||
* @throws Exception
|
||||
*/
|
||||
public function client(): PendingRequest
|
||||
{
|
||||
return Http::baseUrl($this->getUrl())
|
||||
->withHeaders($this->getHeaders());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the headers for the integration.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
abstract public function getHeaders(): array;
|
||||
|
||||
/**
|
||||
* Hook called after the integration is configured for the tenant.
|
||||
* Can be used to validate credentials or perform initial setups.
|
||||
* Throw an Exception on failure.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function onSetup(): void
|
||||
{
|
||||
// Override in child classes if needed
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class TelepagosIntegrationService extends BaseIntegrationService
|
||||
{
|
||||
/**
|
||||
* TelepagosIntegrationService constructor.
|
||||
*
|
||||
* @param string $integrationCode
|
||||
*/
|
||||
public function __construct(string $integrationCode = 'telepagos')
|
||||
{
|
||||
// Force homologation code if not in production and using default
|
||||
if ($integrationCode === 'telepagos' && !app()->environment('production')) {
|
||||
$integrationCode = 'telepagos_homo';
|
||||
}
|
||||
|
||||
$this->integrationCode = $integrationCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the headers for Telepagos integration.
|
||||
*
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer ' . $this->getToken(),
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid token, either from cache or by performing a login.
|
||||
*
|
||||
* @return string
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getToken(): string
|
||||
{
|
||||
if (!$this->tenantIntegration) {
|
||||
throw new Exception("Tenant integration is not loaded. Call forTenant() first.");
|
||||
}
|
||||
|
||||
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
|
||||
|
||||
$token = Cache::get($cacheKey);
|
||||
|
||||
if ($token) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return $this->login();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with Telepagos and cache the returned token.
|
||||
*
|
||||
* @return string
|
||||
* @throws Exception
|
||||
*/
|
||||
public function login(): string
|
||||
{
|
||||
$username = $this->getIntegrationSetting('username');
|
||||
$password = $this->getIntegrationSetting('password');
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
throw new Exception("Missing username or password in Telepagos integration settings.");
|
||||
}
|
||||
|
||||
$url = $this->getUrl('/v2/auth/token');
|
||||
|
||||
$response = Http::post($url, [
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
]);
|
||||
|
||||
$data = $this->handleResponse($response, 'authentication', [
|
||||
'username' => $username,
|
||||
]);
|
||||
|
||||
$token = $data['token'] ?? null;
|
||||
$expiresAtStr = $data['expires_at'] ?? null;
|
||||
|
||||
if (!$token || !$expiresAtStr) {
|
||||
throw new Exception("Telepagos authentication response is missing token or expires_at.");
|
||||
}
|
||||
|
||||
$expiresAt = Carbon::parse($expiresAtStr);
|
||||
// Calculate TTL and subtract a buffer of 60 seconds
|
||||
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
|
||||
|
||||
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
|
||||
Cache::put($cacheKey, $token, $ttlSeconds);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to Telepagos, handling 401 Unauthorized for token refresh.
|
||||
*
|
||||
* @param string $method
|
||||
* @param string $endpoint
|
||||
* @param array $data
|
||||
* @return \Illuminate\Http\Client\Response
|
||||
*/
|
||||
protected function sendRequest(string $method, string $endpoint, array $data = []): \Illuminate\Http\Client\Response
|
||||
{
|
||||
$response = $this->client()->$method($endpoint, $data);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
Log::info("Telepagos 401 Unauthorized. Refreshing token and retrying...");
|
||||
|
||||
$this->clearToken();
|
||||
|
||||
$response = $this->client()->$method($endpoint, $data);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code for cash-in.
|
||||
*
|
||||
* @param float $amount
|
||||
* @param string $concept
|
||||
* @param string $description
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public function generateQr(float $amount, string $concept, string $description): array
|
||||
{
|
||||
$payload = [
|
||||
'amount' => $amount,
|
||||
'concept' => $concept,
|
||||
'description' => $description,
|
||||
];
|
||||
|
||||
$response = $this->sendRequest('post', '/v2/payment/cashin/qr/generate', $payload);
|
||||
|
||||
return $this->handleResponse($response, 'QR generation', $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the details of a cash-in payment.
|
||||
*
|
||||
* @param int $cashinId
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getCashinDetails(string $cashinId): array
|
||||
{
|
||||
$response = $this->sendRequest('get', "/v2/payment/cashin/{$cashinId}");
|
||||
|
||||
return $this->handleResponse($response, 'get cash-in details', [
|
||||
'cashin_id' => $cashinId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the account info.
|
||||
*
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getAccountInfo(): array
|
||||
{
|
||||
$response = $this->sendRequest('get', '/v2/account/info');
|
||||
return $this->handleResponse($response, 'get account info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Telepagos API response, logging any failures and throwing Exceptions.
|
||||
*
|
||||
* @param \Illuminate\Http\Client\Response $response
|
||||
* @param string $actionDescription
|
||||
* @param array $context
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function handleResponse(\Illuminate\Http\Client\Response $response, string $actionDescription, array $context = []): array
|
||||
{
|
||||
if ($response->failed() || $response->json('status') !== 'ok') {
|
||||
$errorMessage = $response->json('message') ?? $response->body();
|
||||
Log::error("Telepagos {$actionDescription} failed: {$errorMessage}", array_merge([
|
||||
'response_status' => $response->status(),
|
||||
'response_body' => $response->json() ?? $response->body(),
|
||||
], $context));
|
||||
|
||||
throw new Exception("Telepagos {$actionDescription} failed: {$errorMessage}");
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached token.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function clearToken(): void
|
||||
{
|
||||
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform initial setup validation for Telepagos.
|
||||
*
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function onSetup(): void
|
||||
{
|
||||
// Realiza un login de prueba para validar que las credenciales son correctas.
|
||||
$this->login();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Models\TelepagosPayment;
|
||||
use App\Domains\Purchase\Models\TelepagosQr;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TelepagosWebhookService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CheckoutService $checkoutService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle the Telepagos webhook notification.
|
||||
*
|
||||
* @param string $tenantCodigo
|
||||
* @param string $cashinId
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handleWebhook(string $tenantCodigo, string $cashinId): void
|
||||
{
|
||||
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
|
||||
|
||||
$telepagosService = new TelepagosIntegrationService();
|
||||
$telepagosService->forTenant($tenant->codigo);
|
||||
|
||||
try {
|
||||
$details = $telepagosService->getCashinDetails($cashinId);
|
||||
|
||||
$qrOrderId = $details['data']['qr_order_id'] ?? $details['qr_order_id'] ?? null;
|
||||
$amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0);
|
||||
$operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null;
|
||||
|
||||
$transferenciaOperationIds = [1, 3, 11];
|
||||
$qrOperationIds = [31, 37, 47];
|
||||
|
||||
$compra = null;
|
||||
|
||||
if (in_array((int) $operationId, $transferenciaOperationIds, true)) {
|
||||
$cuit = $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null;
|
||||
|
||||
if (! $cuit) {
|
||||
Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}");
|
||||
return;
|
||||
}
|
||||
|
||||
$dni = substr($cuit, 2, -1);
|
||||
|
||||
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
|
||||
->where('transfer_payer_dni', $dni)
|
||||
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
||||
->where('payment_method', 'transfer')
|
||||
->where('total', $amount)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (! $compra) {
|
||||
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
|
||||
return;
|
||||
}
|
||||
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
||||
if (! $qrOrderId) {
|
||||
Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}");
|
||||
return;
|
||||
}
|
||||
|
||||
$telepagosQr = TelepagosQr::where('qr_order_id', $qrOrderId)->first();
|
||||
|
||||
if (! $telepagosQr) {
|
||||
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
|
||||
return;
|
||||
}
|
||||
|
||||
$compra = $telepagosQr->compra;
|
||||
|
||||
if (! $compra) {
|
||||
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
|
||||
return;
|
||||
}
|
||||
|
||||
if ($compra->status !== Purchase::STATUS_PENDING_PAYMENT) {
|
||||
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
|
||||
return;
|
||||
}
|
||||
|
||||
$totalAmount = $this->normalizeAmount($compra->getTotalAmount());
|
||||
|
||||
if ($amount !== $totalAmount) {
|
||||
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}");
|
||||
return;
|
||||
}
|
||||
|
||||
$paymentData = [
|
||||
'compra_id' => $compra->id,
|
||||
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
|
||||
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
|
||||
'amount' => $amount,
|
||||
'concept' => $details['data']['concept'] ?? $details['concept'] ?? null,
|
||||
'operation' => $details['data']['operation'] ?? $details['operation'] ?? null,
|
||||
'operation_id' => $details['data']['operation_id'] ?? $details['operation_id'] ?? null,
|
||||
'transaction_id' => $details['data']['transaction_id'] ?? $details['transaction_id'] ?? null,
|
||||
'qr_order_id' => $qrOrderId,
|
||||
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
|
||||
];
|
||||
|
||||
\Illuminate\Support\Facades\DB::transaction(function () use ($compra, $paymentData) {
|
||||
TelepagosPayment::create($paymentData);
|
||||
$this->checkoutService->confirmPurchase($compra);
|
||||
$compra->markAsPaid();
|
||||
});
|
||||
|
||||
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
|
||||
} catch (Exception $e) {
|
||||
Log::error("Telepagos webhook error: " . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
protected function normalizeAmount(mixed $amount): string
|
||||
{
|
||||
return number_format((float) $amount, 2, '.', '');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use App\Domains\Integration\Models\TenantIntegration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TenantIntegrationService
|
||||
{
|
||||
public function getTenantIntegration(string $tenantCode, string $integrationCode): ?TenantIntegration
|
||||
{
|
||||
return TenantIntegration::where('tenant_code', $tenantCode)
|
||||
->where('integration_code', $integrationCode)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function getAllForTenant(string $tenantCode)
|
||||
{
|
||||
return TenantIntegration::with('integration')
|
||||
->where('tenant_code', $tenantCode)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function updateOrCreateIntegration(string $tenantCode, Integration $integration, array $data): TenantIntegration
|
||||
{
|
||||
return DB::transaction(function () use ($tenantCode, $integration, $data) {
|
||||
$tenantIntegration = TenantIntegration::updateOrCreate(
|
||||
[
|
||||
'tenant_code' => $tenantCode,
|
||||
'integration_code' => $integration->integration_code,
|
||||
],
|
||||
[
|
||||
'integration_data' => $data,
|
||||
]
|
||||
);
|
||||
|
||||
$service = $this->resolveService($integration->integration_code);
|
||||
if ($service) {
|
||||
$service->forTenant($tenantCode)->onSetup();
|
||||
}
|
||||
|
||||
return $tenantIntegration;
|
||||
});
|
||||
}
|
||||
|
||||
protected function resolveService(string $integrationCode): ?BaseIntegrationService
|
||||
{
|
||||
switch ($integrationCode) {
|
||||
case 'telepagos':
|
||||
case 'telepagos_homo':
|
||||
return new TelepagosIntegrationService($integrationCode);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
use App\Domains\Integration\Controllers\IntegrationController;
|
||||
use App\Domains\Integration\Controllers\TenantIntegrationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::group(['prefix' => 'integrations'], function () {
|
||||
Route::get('/', [IntegrationController::class, 'index']);
|
||||
Route::post('/', [IntegrationController::class, 'store']);
|
||||
Route::get('/{integration}', [IntegrationController::class, 'show']);
|
||||
Route::put('/{integration}', [IntegrationController::class, 'update']);
|
||||
Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
|
||||
});
|
||||
|
||||
Route::group(['prefix' => '{tenant_code}/integrations'], function () {
|
||||
Route::get('/', [TenantIntegrationController::class, 'index']);
|
||||
Route::get('/{integration_code}', [TenantIntegrationController::class, 'show']);
|
||||
Route::post('/{integration_code}', [TenantIntegrationController::class, 'store']);
|
||||
});
|
||||
|
||||
Route::post('webhooks/telepagos/{tenant_codigo}', [\App\Domains\Integration\Controllers\TelepagosWebhookController::class, 'handle']);
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Menu\Controllers;
|
||||
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class MenuController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$menues = Menu::all();
|
||||
return response()->json($menues);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|unique:menues,code',
|
||||
'route' => 'required|string',
|
||||
]);
|
||||
|
||||
$menu = Menu::create($validated);
|
||||
return response()->json($menu, 201);
|
||||
}
|
||||
|
||||
public function show(Menu $menu): JsonResponse
|
||||
{
|
||||
return response()->json($menu);
|
||||
}
|
||||
|
||||
public function update(Request $request, Menu $menu): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'sometimes|required|string|unique:menues,code,' . $menu->id,
|
||||
'route' => 'sometimes|required|string',
|
||||
]);
|
||||
|
||||
$menu->update($validated);
|
||||
return response()->json($menu);
|
||||
}
|
||||
|
||||
public function destroy(Menu $menu): JsonResponse
|
||||
{
|
||||
$menu->delete();
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Menu\Models;
|
||||
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Menu extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'menues';
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'route',
|
||||
];
|
||||
|
||||
public function tenants(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Tenant::class,
|
||||
'tenant_menues',
|
||||
'menu_code',
|
||||
'tenant_codigo',
|
||||
'code',
|
||||
'codigo'
|
||||
)->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Menu\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class TenantMenu extends Pivot
|
||||
{
|
||||
protected $table = 'tenant_menues';
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Domains\Menu\Controllers\MenuController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::apiResource('menues', MenuController::class);
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\Controllers;
|
||||
|
||||
use App\Domains\Bundle\Models\Bundle;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Integration\Services\TelepagosIntegrationService;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Requests\PaymentIntentRequest;
|
||||
use App\Domains\Purchase\Requests\StorePurchaseRequest;
|
||||
use App\Domains\Purchase\Resources\PurchaseResource;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class PurchaseController extends Controller
|
||||
{
|
||||
public function index(Request $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
return PurchaseResource::collection(
|
||||
Purchase::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('user_id', $request->user()->id)
|
||||
->when($request->query('status'), function ($query, $status) {
|
||||
$query->where('status', $status);
|
||||
})
|
||||
->latest()
|
||||
->paginateFromRequest()
|
||||
)->response();
|
||||
}
|
||||
|
||||
public function store(StorePurchaseRequest $request, Tenant $tenant, CheckoutService $checkoutService): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
$purchase = $checkoutService->startCheckout(
|
||||
$tenant,
|
||||
$request->user()->id,
|
||||
$data
|
||||
);
|
||||
|
||||
return PurchaseResource::make($purchase)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Request $request, Tenant $tenant, Purchase $compra): PurchaseResource
|
||||
{
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
|
||||
$compra->loadMissing(['items', 'cart.items']);
|
||||
$this->loadBuyables($compra->items);
|
||||
|
||||
if ($compra->cart !== null) {
|
||||
$this->loadBuyables($compra->cart->items);
|
||||
}
|
||||
|
||||
return PurchaseResource::make($compra);
|
||||
}
|
||||
|
||||
public function paymentIntent(PaymentIntentRequest $request, Tenant $tenant, Purchase $compra): JsonResponse
|
||||
{
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
$method = $request->validated('method');
|
||||
$totalAmount = $compra->calculateCurrentTotalAmount();
|
||||
|
||||
$purchaseUpdate = [
|
||||
'payment_method' => $method,
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
'total' => $totalAmount,
|
||||
];
|
||||
|
||||
if ($method === 'transfer') {
|
||||
$purchaseUpdate['transfer_payer_dni'] = preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'));
|
||||
}
|
||||
|
||||
$compra->update($purchaseUpdate);
|
||||
|
||||
if ($method === 'transfer') {
|
||||
$telepagosService = new TelepagosIntegrationService;
|
||||
$telepagosService->forTenant($tenant->codigo);
|
||||
|
||||
try {
|
||||
$accountInfo = $telepagosService->getAccountInfo();
|
||||
|
||||
return response()->json([
|
||||
'payment_method' => 'transfer',
|
||||
'transfer_data' => [
|
||||
'titular' => $accountInfo['holder'] ?? null,
|
||||
'cvu' => $accountInfo['cvu'] ?? null,
|
||||
'alias' => $accountInfo['alias'] ?? null,
|
||||
'cuit' => $accountInfo['cuit'] ?? null,
|
||||
'entidad' => $accountInfo['entity'] ?? 'Telepagos S.A.',
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'message' => 'Error getting account info: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($method === 'qr') {
|
||||
$telepagosService = new TelepagosIntegrationService;
|
||||
$telepagosService->forTenant($tenant->codigo);
|
||||
|
||||
try {
|
||||
Log::info("Generating QR for purchase ID: {$compra->id}, amount: {$totalAmount}");
|
||||
$qrResponse = $telepagosService->generateQr(
|
||||
$totalAmount,
|
||||
'Compra',
|
||||
"Compra #{$compra->id}"
|
||||
);
|
||||
|
||||
$telepagosQr = $compra->telepagosQr()->create([
|
||||
'qr_order_id' => (string) ($qrResponse['qr_order_id'] ?? ''),
|
||||
'qr_code' => $qrResponse['qr_code'] ?? '',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'message' => 'Error generating QR: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'payment_method' => 'qr',
|
||||
'qr_data' => [
|
||||
'qr_code' => $telepagosQr->qr_code,
|
||||
'qr_order_id' => $telepagosQr->qr_order_id,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Invalid payment method.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
public function complete(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource
|
||||
{
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
|
||||
return PurchaseResource::make(
|
||||
$checkoutService->completePurchase($compra)
|
||||
);
|
||||
}
|
||||
|
||||
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
|
||||
{
|
||||
if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) {
|
||||
throw new NotFoundHttpException('Purchase not found for tenant.');
|
||||
}
|
||||
|
||||
return $purchase;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function loadBuyables(Collection $items): void
|
||||
{
|
||||
$items->load([
|
||||
'buyable' => function (MorphTo $morphTo): void {
|
||||
$morphTo->morphWith([
|
||||
ProductVariant::class => [
|
||||
'product',
|
||||
'definitions.productAttribute.attribute',
|
||||
'attachments',
|
||||
],
|
||||
Bundle::class => ['items.variant'],
|
||||
]);
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\Models;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
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\HasOne;
|
||||
|
||||
#[Fillable([
|
||||
'cart_id',
|
||||
'tenant_codigo',
|
||||
'user_id',
|
||||
'status',
|
||||
'payment_method',
|
||||
'total',
|
||||
'dni',
|
||||
'transfer_payer_dni',
|
||||
'telefono',
|
||||
'nombre_apellido',
|
||||
'email',
|
||||
])]
|
||||
class Purchase extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_CREATED = 'created';
|
||||
|
||||
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
||||
|
||||
public const STATUS_PAID = 'paid';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
protected $table = 'compras';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'cart_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'total' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Cart, $this>
|
||||
*/
|
||||
public function cart(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cart::class, 'cart_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<PurchaseItem, $this>
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<TelepagosQr, $this>
|
||||
*/
|
||||
public function telepagosQr()
|
||||
{
|
||||
return $this->hasOne(TelepagosQr::class, 'compra_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TelepagosPayment, $this>
|
||||
*/
|
||||
public function telepagosPayments(): HasMany
|
||||
{
|
||||
return $this->hasMany(TelepagosPayment::class, 'compra_id');
|
||||
}
|
||||
|
||||
public function getTotalAmount(): float
|
||||
{
|
||||
if ($this->total !== null) {
|
||||
return (float) $this->total;
|
||||
}
|
||||
|
||||
return $this->calculateCurrentTotalAmount();
|
||||
}
|
||||
|
||||
public function calculateCurrentTotalAmount(): float
|
||||
{
|
||||
if ($this->relationLoaded('items') && $this->getRelation('items')->isNotEmpty()) {
|
||||
return (float) $this->getRelation('items')->sum('total');
|
||||
}
|
||||
|
||||
if ($this->items()->exists()) {
|
||||
return (float) $this->items()->sum('total');
|
||||
}
|
||||
|
||||
$cart = $this->relationLoaded('cart')
|
||||
? $this->getRelation('cart')
|
||||
: $this->cart()->with('items.buyable')->first();
|
||||
|
||||
if (! $cart) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $cart->getTotalAmount();
|
||||
}
|
||||
|
||||
public function markAsPendingPayment(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_PENDING_PAYMENT,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsPaid(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_PAID,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\Models;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'compra_id',
|
||||
'buyable_id',
|
||||
'buyable_type',
|
||||
'cantidad',
|
||||
'precio_unitario',
|
||||
'discount_total',
|
||||
'tax_total',
|
||||
'total',
|
||||
])]
|
||||
class PurchaseItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'compra_items';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'compra_id' => 'integer',
|
||||
'buyable_id' => 'integer',
|
||||
'buyable_type' => 'string',
|
||||
'cantidad' => 'integer',
|
||||
'precio_unitario' => 'decimal:2',
|
||||
'discount_total' => 'decimal:2',
|
||||
'tax_total' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Purchase, $this>
|
||||
*/
|
||||
public function purchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||
*/
|
||||
public function buyable()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\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([
|
||||
'compra_id',
|
||||
'cuit_buyer',
|
||||
'cvu_buyer',
|
||||
'amount',
|
||||
'concept',
|
||||
'operation',
|
||||
'operation_id',
|
||||
'transaction_id',
|
||||
'qr_order_id',
|
||||
'link_id',
|
||||
])]
|
||||
class TelepagosPayment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'telepagos_payments';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'compra_id' => 'integer',
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Purchase, $this>
|
||||
*/
|
||||
public function compra(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\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([
|
||||
'compra_id',
|
||||
'qr_order_id',
|
||||
'qr_code',
|
||||
])]
|
||||
class TelepagosQr extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'telepagos_qr';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'compra_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Purchase, $this>
|
||||
*/
|
||||
public function compra(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class PaymentIntentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'method' => ['required', 'string', Rule::in(['qr', 'transfer'])],
|
||||
'transfer_payer_dni' => [
|
||||
Rule::requiredIf(fn (): bool => $this->input('method') === 'transfer'),
|
||||
'string',
|
||||
'regex:/^\d{7,8}$/',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue