Compare commits
6 Commits
feature/at
...
1f9d876bc3
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f9d876bc3 | |||
| 0dae985230 | |||
| 4c237fb729 | |||
| edb4c128d6 | |||
| 6c45f45815 | |||
| 70f51ac863 |
@@ -6,7 +6,7 @@ use App\Domains\Catalog\Models\ProductAttribute;
|
|||||||
use App\Domains\Catalog\Requests\StoreProductAttributeRequest;
|
use App\Domains\Catalog\Requests\StoreProductAttributeRequest;
|
||||||
use App\Domains\Catalog\Requests\UpdateProductAttributeRequest;
|
use App\Domains\Catalog\Requests\UpdateProductAttributeRequest;
|
||||||
use App\Domains\Catalog\Resources\ProductAttributeResource;
|
use App\Domains\Catalog\Resources\ProductAttributeResource;
|
||||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -33,7 +33,7 @@ class ProductAttributeController extends Controller
|
|||||||
$options = $validated['options'] ?? [];
|
$options = $validated['options'] ?? [];
|
||||||
unset($validated['options']);
|
unset($validated['options']);
|
||||||
|
|
||||||
$type = ProductAttributeType::from((string) $validated['type']);
|
$type = FieldType::from((string) $validated['type']);
|
||||||
if (! $type->supportsOptions()) {
|
if (! $type->supportsOptions()) {
|
||||||
$validated['metadata_schema'] = null;
|
$validated['metadata_schema'] = null;
|
||||||
$options = [];
|
$options = [];
|
||||||
@@ -68,7 +68,7 @@ class ProductAttributeController extends Controller
|
|||||||
$options = $validated['options'] ?? [];
|
$options = $validated['options'] ?? [];
|
||||||
unset($validated['options']);
|
unset($validated['options']);
|
||||||
|
|
||||||
$type = ProductAttributeType::from((string) $validated['type']);
|
$type = FieldType::from((string) $validated['type']);
|
||||||
if (! $type->supportsOptions()) {
|
if (! $type->supportsOptions()) {
|
||||||
$validated['metadata_schema'] = null;
|
$validated['metadata_schema'] = null;
|
||||||
$options = [];
|
$options = [];
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -32,7 +32,7 @@ class ProductAttribute extends Model
|
|||||||
return [
|
return [
|
||||||
'is_required' => 'boolean',
|
'is_required' => 'boolean',
|
||||||
'metadata_schema' => 'array',
|
'metadata_schema' => 'array',
|
||||||
'type' => ProductAttributeType::class,
|
'type' => FieldType::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Validator;
|
use Illuminate\Validation\Validator;
|
||||||
@@ -31,15 +31,15 @@ class StoreProductAttributeRequest extends FormRequest
|
|||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
'is_required' => ['sometimes', 'boolean'],
|
'is_required' => ['sometimes', 'boolean'],
|
||||||
'metadata_schema' => ['nullable', 'array'],
|
'metadata_schema' => ['nullable', 'array'],
|
||||||
'type' => ['required', Rule::enum(ProductAttributeType::class)],
|
'type' => ['required', Rule::enum(FieldType::class)],
|
||||||
'options' => [
|
'options' => [
|
||||||
Rule::requiredIf(fn (): bool => in_array($this->input('type'), [
|
Rule::requiredIf(fn (): bool => in_array($this->input('type'), [
|
||||||
ProductAttributeType::Select->value,
|
FieldType::Select->value,
|
||||||
ProductAttributeType::Multiselect->value,
|
FieldType::Multiselect->value,
|
||||||
], true)),
|
], true)),
|
||||||
Rule::prohibitedIf(fn (): bool => ! in_array($this->input('type'), [
|
Rule::prohibitedIf(fn (): bool => ! in_array($this->input('type'), [
|
||||||
ProductAttributeType::Select->value,
|
FieldType::Select->value,
|
||||||
ProductAttributeType::Multiselect->value,
|
FieldType::Multiselect->value,
|
||||||
], true)),
|
], true)),
|
||||||
'array',
|
'array',
|
||||||
],
|
],
|
||||||
@@ -53,7 +53,7 @@ class StoreProductAttributeRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
$validator->after(function (Validator $validator): void {
|
$validator->after(function (Validator $validator): void {
|
||||||
$type = $this->input('type');
|
$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')) {
|
if (! $supportsOptions && $this->filled('metadata_schema')) {
|
||||||
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
|
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
namespace App\Domains\Catalog\Requests;
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\ProductAttribute;
|
use App\Domains\Catalog\Models\ProductAttribute;
|
||||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Validator;
|
use Illuminate\Validation\Validator;
|
||||||
@@ -35,7 +35,7 @@ class UpdateProductAttributeRequest extends FormRequest
|
|||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
'is_required' => ['sometimes', 'boolean'],
|
'is_required' => ['sometimes', 'boolean'],
|
||||||
'metadata_schema' => ['nullable', 'array'],
|
'metadata_schema' => ['nullable', 'array'],
|
||||||
'type' => ['required', Rule::enum(ProductAttributeType::class)],
|
'type' => ['required', Rule::enum(FieldType::class)],
|
||||||
'options' => ['sometimes', 'array'],
|
'options' => ['sometimes', 'array'],
|
||||||
'options.*.label' => ['required', 'string', 'max:255'],
|
'options.*.label' => ['required', 'string', 'max:255'],
|
||||||
'options.*.sort_order' => ['sometimes', 'integer'],
|
'options.*.sort_order' => ['sometimes', 'integer'],
|
||||||
@@ -47,7 +47,7 @@ class UpdateProductAttributeRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
$validator->after(function (Validator $validator): void {
|
$validator->after(function (Validator $validator): void {
|
||||||
$type = $this->input('type');
|
$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')) {
|
if (! $supportsOptions && $this->filled('metadata_schema')) {
|
||||||
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
|
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
129
app/Domains/Purchase/Controllers/PurchaseController.php
Normal file
129
app/Domains/Purchase/Controllers/PurchaseController.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Purchase\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\ProductVariant;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Purchase\Requests\StorePurchaseRequest;
|
||||||
|
use App\Domains\Purchase\Resources\PurchaseResource;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class PurchaseController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request, Tenant $tenant): JsonResponse
|
||||||
|
{
|
||||||
|
return PurchaseResource::collection(
|
||||||
|
Purchase::query()
|
||||||
|
->with(['items.variant.product', 'items.variant.definitions'])
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->latest()
|
||||||
|
->get()
|
||||||
|
)->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StorePurchaseRequest $request, Tenant $tenant): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$items = $data['items'];
|
||||||
|
|
||||||
|
unset($data['items']);
|
||||||
|
|
||||||
|
$variants = $this->resolveTenantVariants($tenant, $items);
|
||||||
|
$purchaseItems = $this->buildPurchaseItemsPayload($items, $variants);
|
||||||
|
|
||||||
|
$purchase = DB::transaction(function () use ($request, $tenant, $data, $purchaseItems): Purchase {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()->create([
|
||||||
|
...$data,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$purchase->items()->createMany($purchaseItems);
|
||||||
|
|
||||||
|
return $purchase->load(['items.variant.product', 'items.variant.definitions']);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return PurchaseResource::make(
|
||||||
|
$compra->loadMissing(['items.variant.product', 'items.variant.definitions'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $items
|
||||||
|
* @return \Illuminate\Support\Collection<int, ProductVariant>
|
||||||
|
*/
|
||||||
|
protected function resolveTenantVariants(Tenant $tenant, array $items)
|
||||||
|
{
|
||||||
|
$variantIds = collect($items)
|
||||||
|
->pluck('producto_variante_id')
|
||||||
|
->filter()
|
||||||
|
->map(static fn (mixed $id): int => (int) $id)
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$variants = ProductVariant::query()
|
||||||
|
->with('product')
|
||||||
|
->whereIn('id', $variantIds)
|
||||||
|
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo))
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
|
if ($variants->count() !== $variantIds->count()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'items' => 'One or more product variants do not belong to the tenant.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $variants;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $items
|
||||||
|
* @param \Illuminate\Support\Collection<int, ProductVariant> $variants
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
protected function buildPurchaseItemsPayload(array $items, $variants): array
|
||||||
|
{
|
||||||
|
return collect($items)
|
||||||
|
->map(function (array $item) use ($variants): array {
|
||||||
|
/** @var ProductVariant $variant */
|
||||||
|
$variant = $variants->get((int) $item['producto_variante_id']);
|
||||||
|
$quantity = (int) $item['cantidad'];
|
||||||
|
$unitPrice = (float) $variant->precio;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'producto_variante_id' => $variant->getKey(),
|
||||||
|
'cantidad' => $quantity,
|
||||||
|
'precio_unitario' => $unitPrice,
|
||||||
|
'discount_total' => null,
|
||||||
|
'tax_total' => null,
|
||||||
|
'total' => $unitPrice * $quantity,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Domains/Purchase/Models/Purchase.php
Normal file
56
app/Domains/Purchase/Models/Purchase.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Purchase\Models;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'tenant_codigo',
|
||||||
|
'user_id',
|
||||||
|
'status',
|
||||||
|
'payment_status',
|
||||||
|
'payment_method',
|
||||||
|
])]
|
||||||
|
class Purchase extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'compras';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 HasMany<PurchaseItem, $this>
|
||||||
|
*/
|
||||||
|
public function items(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Domains/Purchase/Models/PurchaseItem.php
Normal file
54
app/Domains/Purchase/Models/PurchaseItem.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?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',
|
||||||
|
'producto_variante_id',
|
||||||
|
'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',
|
||||||
|
'producto_variante_id' => 'integer',
|
||||||
|
'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 BelongsTo<ProductVariant, $this>
|
||||||
|
*/
|
||||||
|
public function variant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Domains/Purchase/Requests/StorePurchaseRequest.php
Normal file
29
app/Domains/Purchase/Requests/StorePurchaseRequest.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Purchase\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StorePurchaseRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => ['sometimes', 'string', Rule::in(['pending', 'paid', 'cancelled'])],
|
||||||
|
'payment_status' => ['sometimes', 'string', Rule::in(['pending', 'approved', 'rejected'])],
|
||||||
|
'payment_method' => ['nullable', 'string', 'max:255'],
|
||||||
|
'items' => ['required', 'array', 'min:1'],
|
||||||
|
'items.*.producto_variante_id' => ['required', 'integer', 'exists:productos_variantes,id'],
|
||||||
|
'items.*.cantidad' => ['required', 'integer', 'min:1'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Domains/Purchase/Resources/PurchaseItemResource.php
Normal file
49
app/Domains/Purchase/Resources/PurchaseItemResource.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Purchase\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Resources\ProductVariantDefinitionResource;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin \App\Domains\Purchase\Models\PurchaseItem
|
||||||
|
*/
|
||||||
|
class PurchaseItemResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$variant = $this->variant;
|
||||||
|
$product = $variant?->product;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'cantidad' => $this->cantidad,
|
||||||
|
'precio_unitario' => $this->formatMoney($this->precio_unitario),
|
||||||
|
'total' => $this->formatMoney($this->total),
|
||||||
|
'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),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatMoney(float|int|string|null $amount): ?string
|
||||||
|
{
|
||||||
|
if ($amount === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return number_format((float) $amount, 2, '.', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Domains/Purchase/Resources/PurchaseResource.php
Normal file
49
app/Domains/Purchase/Resources/PurchaseResource.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Purchase\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin \App\Domains\Purchase\Models\Purchase
|
||||||
|
*/
|
||||||
|
class PurchaseResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$items = $this->resource->relationLoaded('items')
|
||||||
|
? $this->resource->getRelation('items')
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
$subtotal = $items->reduce(
|
||||||
|
fn (float $carry, $item): float => $carry + ((float) $item->precio_unitario * $item->cantidad),
|
||||||
|
0.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
$total = $items->reduce(
|
||||||
|
fn (float $carry, $item): float => $carry + (float) $item->total,
|
||||||
|
0.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'tenant_codigo' => $this->tenant_codigo,
|
||||||
|
'user_id' => $this->user_id,
|
||||||
|
'status' => $this->status,
|
||||||
|
'payment_status' => $this->payment_status,
|
||||||
|
'payment_method' => $this->payment_method,
|
||||||
|
'items' => PurchaseItemResource::collection($items),
|
||||||
|
'subtotal' => $this->formatMoney($subtotal),
|
||||||
|
'total' => $this->formatMoney($total),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatMoney(float|int|string|null $amount): string
|
||||||
|
{
|
||||||
|
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/Domains/Purchase/routes/api.php
Normal file
8
app/Domains/Purchase/routes/api.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Purchase\Controllers\PurchaseController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(function (): void {
|
||||||
|
Route::apiResource('compras', PurchaseController::class)->only(['index', 'store', 'show']);
|
||||||
|
});
|
||||||
27
app/Domains/Shared/Enums/FieldType.php
Normal file
27
app/Domains/Shared/Enums/FieldType.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Shared\Enums;
|
||||||
|
|
||||||
|
enum FieldType: string
|
||||||
|
{
|
||||||
|
case String = 'string';
|
||||||
|
case Number = 'number';
|
||||||
|
case Boolean = 'boolean';
|
||||||
|
case Select = 'select';
|
||||||
|
case Multiselect = 'multiselect';
|
||||||
|
case Color = 'color';
|
||||||
|
case Image = 'image';
|
||||||
|
|
||||||
|
public function supportsOptions(): bool
|
||||||
|
{
|
||||||
|
return in_array($this, [self::Select, self::Multiselect], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_column(self::cases(), 'value');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,17 @@ use App\Domains\Tenant\Models\Tenant;
|
|||||||
use App\Domains\Tenant\Requests\StoreTenantRequest;
|
use App\Domains\Tenant\Requests\StoreTenantRequest;
|
||||||
use App\Domains\Tenant\Requests\UpdateTenantRequest;
|
use App\Domains\Tenant\Requests\UpdateTenantRequest;
|
||||||
use App\Domains\Tenant\Resources\TenantResource;
|
use App\Domains\Tenant\Resources\TenantResource;
|
||||||
|
use App\Domains\Tenant\Services\TenantService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
class TenantController extends Controller
|
class TenantController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(protected TenantService $tenantService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
return TenantResource::collection(Tenant::query()->latest()->get())->response();
|
return TenantResource::collection(Tenant::query()->latest()->get())->response();
|
||||||
@@ -19,10 +24,7 @@ class TenantController extends Controller
|
|||||||
|
|
||||||
public function store(StoreTenantRequest $request): JsonResponse
|
public function store(StoreTenantRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$tenant = $this->tenantService->create($request->validated());
|
||||||
$props = $validated['props'] ?? [];
|
|
||||||
|
|
||||||
$tenant = Tenant::createWithProps($validated, $props);
|
|
||||||
|
|
||||||
return TenantResource::make($tenant)->response()->setStatusCode(201);
|
return TenantResource::make($tenant)->response()->setStatusCode(201);
|
||||||
}
|
}
|
||||||
@@ -34,10 +36,7 @@ class TenantController extends Controller
|
|||||||
|
|
||||||
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
|
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$tenant = $this->tenantService->update($tenant, $request->validated());
|
||||||
$props = $validated['props'] ?? [];
|
|
||||||
|
|
||||||
$tenant->updateWithProps($validated, $props);
|
|
||||||
|
|
||||||
return TenantResource::make($tenant);
|
return TenantResource::make($tenant);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\TenantProp;
|
|
||||||
use App\Domains\Tenant\Requests\StoreTenantPropRequest;
|
|
||||||
use App\Domains\Tenant\Requests\UpdateTenantPropRequest;
|
|
||||||
use App\Domains\Tenant\Resources\TenantPropResource;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
|
|
||||||
class TenantPropController extends Controller
|
|
||||||
{
|
|
||||||
public function index(): JsonResponse
|
|
||||||
{
|
|
||||||
return TenantPropResource::collection(TenantProp::query()->latest()->get())->response();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreTenantPropRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
$tenantProp = TenantProp::query()->create($request->validated());
|
|
||||||
|
|
||||||
return TenantPropResource::make($tenantProp)->response()->setStatusCode(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(TenantProp $tenantProp): TenantPropResource
|
|
||||||
{
|
|
||||||
return TenantPropResource::make($tenantProp);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateTenantPropRequest $request, TenantProp $tenantProp): TenantPropResource
|
|
||||||
{
|
|
||||||
$tenantProp->update($request->validated());
|
|
||||||
|
|
||||||
return TenantPropResource::make($tenantProp);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(TenantProp $tenantProp): Response
|
|
||||||
{
|
|
||||||
$tenantProp->delete();
|
|
||||||
|
|
||||||
return response()->noContent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,40 +8,23 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use LogicException;
|
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'codigo',
|
'codigo',
|
||||||
'nombre',
|
'nombre',
|
||||||
'dominio',
|
'dominio',
|
||||||
|
'primary_color',
|
||||||
|
'secondary_color',
|
||||||
|
'danger_color',
|
||||||
|
'header_footer_bg_color',
|
||||||
|
'header_logo',
|
||||||
|
'footer_logo',
|
||||||
])]
|
])]
|
||||||
class Tenant extends Model
|
class Tenant extends Model
|
||||||
{
|
{
|
||||||
use HasAttachments;
|
use HasAttachments;
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $attributes
|
|
||||||
* @param array<string, mixed> $props
|
|
||||||
* @return static
|
|
||||||
*/
|
|
||||||
public static function createWithProps(array $attributes, array $props = []): static
|
|
||||||
{
|
|
||||||
unset($attributes['props']);
|
|
||||||
|
|
||||||
/** @var static $tenant */
|
|
||||||
$tenant = DB::transaction(function () use ($attributes, $props): self {
|
|
||||||
/** @var static $createdTenant */
|
|
||||||
$createdTenant = static::query()->create($attributes);
|
|
||||||
$createdTenant->syncPropValues($props);
|
|
||||||
|
|
||||||
return $createdTenant;
|
|
||||||
});
|
|
||||||
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return HasMany<Product, $this>
|
* @return HasMany<Product, $this>
|
||||||
*/
|
*/
|
||||||
@@ -49,77 +32,5 @@ class Tenant extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Product::class, 'tenant_codigo', 'codigo');
|
return $this->hasMany(Product::class, 'tenant_codigo', 'codigo');
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* @return HasMany<TenantPropValue, $this>
|
|
||||||
*/
|
|
||||||
public function propValues(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(TenantPropValue::class, 'tenant_codigo', 'codigo');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPropValue(TenantProp|string $prop): ?TenantPropValue
|
|
||||||
{
|
|
||||||
$resolvedProp = $this->resolveProp($prop);
|
|
||||||
|
|
||||||
return $this->propValues()
|
|
||||||
->where('tenant_prop_codigo', $resolvedProp->codigo)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setPropValue(TenantProp|string $prop, mixed $value): TenantPropValue
|
|
||||||
{
|
|
||||||
$this->ensurePropValuesCanBeManaged();
|
|
||||||
|
|
||||||
$resolvedProp = $this->resolveProp($prop);
|
|
||||||
|
|
||||||
return $this->propValues()->updateOrCreate(
|
|
||||||
['tenant_prop_codigo' => $resolvedProp->codigo],
|
|
||||||
['value' => $value],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $props
|
|
||||||
*/
|
|
||||||
public function syncPropValues(array $props): void
|
|
||||||
{
|
|
||||||
foreach ($props as $codigo => $value) {
|
|
||||||
$this->setPropValue((string) $codigo, $value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $attributes
|
|
||||||
* @param array<string, mixed> $props
|
|
||||||
* @return static
|
|
||||||
*/
|
|
||||||
public function updateWithProps(array $attributes, array $props = []): static
|
|
||||||
{
|
|
||||||
unset($attributes['props']);
|
|
||||||
|
|
||||||
DB::transaction(function () use ($attributes, $props): void {
|
|
||||||
$this->update($attributes);
|
|
||||||
$this->syncPropValues($props);
|
|
||||||
});
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function ensurePropValuesCanBeManaged(): void
|
|
||||||
{
|
|
||||||
if (! $this->exists) {
|
|
||||||
throw new LogicException('Cannot manage prop values for an unsaved tenant.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveProp(TenantProp|string $prop): TenantProp
|
|
||||||
{
|
|
||||||
if ($prop instanceof TenantProp) {
|
|
||||||
return $prop;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TenantProp::query()
|
|
||||||
->where('codigo', $prop)
|
|
||||||
->firstOrFail();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Models;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Support\TenantPropDataType;
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
#[Fillable([
|
|
||||||
'codigo',
|
|
||||||
'nombre',
|
|
||||||
'descripcion',
|
|
||||||
'is_required',
|
|
||||||
'data_type',
|
|
||||||
])]
|
|
||||||
class TenantProp extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'tenant_props';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'is_required' => 'boolean',
|
|
||||||
'data_type' => TenantPropDataType::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<TenantPropValue, $this>
|
|
||||||
*/
|
|
||||||
public function values(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(TenantPropValue::class, 'tenant_prop_codigo', 'codigo');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
#[Fillable([
|
|
||||||
'tenant_codigo',
|
|
||||||
'tenant_prop_codigo',
|
|
||||||
'value',
|
|
||||||
])]
|
|
||||||
class TenantPropValue extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'tenant_prop_values';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<TenantProp, $this>
|
|
||||||
*/
|
|
||||||
public function prop(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(TenantProp::class, 'tenant_prop_codigo', 'codigo');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Support\TenantPropDataType;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class StoreTenantPropRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenant_props', 'codigo')],
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
'descripcion' => ['nullable', 'string'],
|
|
||||||
'is_required' => ['sometimes', 'boolean'],
|
|
||||||
'data_type' => ['required', Rule::enum(TenantPropDataType::class)],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
namespace App\Domains\Tenant\Requests;
|
||||||
|
|
||||||
use App\Domains\Tenant\Support\TenantPropRules;
|
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class StoreTenantRequest extends FormRequest
|
class StoreTenantRequest extends FormRequest
|
||||||
@@ -35,6 +36,52 @@ class StoreTenantRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$logoRule = [
|
||||||
|
'nullable',
|
||||||
|
static function (string $attribute, mixed $value, Closure $fail): void {
|
||||||
|
if ($value instanceof UploadedFile) {
|
||||||
|
$mime = $value->getMimeType();
|
||||||
|
if (! str_starts_with($mime, 'image/')) {
|
||||||
|
$fail("The {$attribute} must be a valid image or SVG.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
if (Str::isUuid($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = trim($value);
|
||||||
|
$declaredMimeType = null;
|
||||||
|
if (preg_match('/^data:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/s', $payload, $matches) === 1) {
|
||||||
|
$declaredMimeType = strtolower($matches['mime']);
|
||||||
|
$payload = $matches['data'];
|
||||||
|
}
|
||||||
|
$decoded = base64_decode(preg_replace('/\s+/', '', $payload), true);
|
||||||
|
if ($decoded === false || $decoded === '') {
|
||||||
|
$fail("The {$attribute} must be a valid base64 image or SVG.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$mime = $finfo->buffer($decoded);
|
||||||
|
if (! $mime && $declaredMimeType) {
|
||||||
|
$mime = $declaredMimeType;
|
||||||
|
}
|
||||||
|
if (! $mime || ! str_starts_with($mime, 'image/')) {
|
||||||
|
$fail("The {$attribute} must be a valid image or SVG.");
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$fail("The {$attribute} must be a valid image or SVG.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fail("The {$attribute} must be a valid file or base64 string.");
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
|
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
@@ -50,7 +97,12 @@ class StoreTenantRequest extends FormRequest
|
|||||||
'max:255',
|
'max:255',
|
||||||
Rule::unique('tenants', 'dominio'),
|
Rule::unique('tenants', 'dominio'),
|
||||||
],
|
],
|
||||||
...TenantPropRules::sync(),
|
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'header_footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'header_logo' => $logoRule,
|
||||||
|
'footer_logo' => $logoRule,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\TenantProp;
|
|
||||||
use App\Domains\Tenant\Support\TenantPropDataType;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class UpdateTenantPropRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
/** @var TenantProp|null $tenantProp */
|
|
||||||
$tenantProp = $this->route('tenantProp');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenant_props', 'codigo')->ignore($tenantProp?->id)],
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
'descripcion' => ['nullable', 'string'],
|
|
||||||
'is_required' => ['sometimes', 'boolean'],
|
|
||||||
'data_type' => ['required', Rule::enum(TenantPropDataType::class)],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,10 @@ namespace App\Domains\Tenant\Requests;
|
|||||||
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
use App\Domains\Tenant\Support\TenantPropRules;
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class UpdateTenantRequest extends FormRequest
|
class UpdateTenantRequest extends FormRequest
|
||||||
@@ -39,6 +40,52 @@ class UpdateTenantRequest extends FormRequest
|
|||||||
/** @var Tenant|null $tenant */
|
/** @var Tenant|null $tenant */
|
||||||
$tenant = $this->route('tenant');
|
$tenant = $this->route('tenant');
|
||||||
|
|
||||||
|
$logoRule = [
|
||||||
|
'nullable',
|
||||||
|
static function (string $attribute, mixed $value, Closure $fail): void {
|
||||||
|
if ($value instanceof UploadedFile) {
|
||||||
|
$mime = $value->getMimeType();
|
||||||
|
if (! str_starts_with($mime, 'image/')) {
|
||||||
|
$fail("The {$attribute} must be a valid image or SVG.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
if (Str::isUuid($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = trim($value);
|
||||||
|
$declaredMimeType = null;
|
||||||
|
if (preg_match('/^data:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/s', $payload, $matches) === 1) {
|
||||||
|
$declaredMimeType = strtolower($matches['mime']);
|
||||||
|
$payload = $matches['data'];
|
||||||
|
}
|
||||||
|
$decoded = base64_decode(preg_replace('/\s+/', '', $payload), true);
|
||||||
|
if ($decoded === false || $decoded === '') {
|
||||||
|
$fail("The {$attribute} must be a valid base64 image or SVG.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$mime = $finfo->buffer($decoded);
|
||||||
|
if (! $mime && $declaredMimeType) {
|
||||||
|
$mime = $declaredMimeType;
|
||||||
|
}
|
||||||
|
if (! $mime || ! str_starts_with($mime, 'image/')) {
|
||||||
|
$fail("The {$attribute} must be a valid image or SVG.");
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$fail("The {$attribute} must be a valid image or SVG.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fail("The {$attribute} must be a valid file or base64 string.");
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'codigo' => [
|
'codigo' => [
|
||||||
'required',
|
'required',
|
||||||
@@ -59,7 +106,12 @@ class UpdateTenantRequest extends FormRequest
|
|||||||
'max:255',
|
'max:255',
|
||||||
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
|
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
|
||||||
],
|
],
|
||||||
...TenantPropRules::sync(),
|
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'header_footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'header_logo' => $logoRule,
|
||||||
|
'footer_logo' => $logoRule,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @mixin \App\Domains\Tenant\Models\TenantProp
|
|
||||||
*/
|
|
||||||
class TenantPropResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'codigo' => $this->codigo,
|
|
||||||
'nombre' => $this->nombre,
|
|
||||||
'descripcion' => $this->descripcion,
|
|
||||||
'is_required' => $this->is_required,
|
|
||||||
'data_type' => $this->data_type?->value ?? $this->data_type,
|
|
||||||
'created_at' => $this->created_at,
|
|
||||||
'updated_at' => $this->updated_at,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,29 +10,6 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
|||||||
*/
|
*/
|
||||||
class TenantResource extends JsonResource
|
class TenantResource extends JsonResource
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function collapseProps(): array
|
|
||||||
{
|
|
||||||
return $this->resource->propValues()
|
|
||||||
->with('prop')
|
|
||||||
->get()
|
|
||||||
->mapWithKeys(fn ($propValue): array => [
|
|
||||||
$propValue->tenant_prop_codigo => $this->normalizePropValue($propValue->value, $propValue->prop?->data_type?->value),
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function normalizePropValue(mixed $value, ?string $dataType): mixed
|
|
||||||
{
|
|
||||||
return match ($dataType) {
|
|
||||||
'boolean' => filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? $value,
|
|
||||||
'number' => is_numeric($value) ? $value + 0 : $value,
|
|
||||||
default => $value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -43,7 +20,12 @@ class TenantResource extends JsonResource
|
|||||||
'codigo' => $this->codigo,
|
'codigo' => $this->codigo,
|
||||||
'nombre' => $this->nombre,
|
'nombre' => $this->nombre,
|
||||||
'dominio' => $this->dominio,
|
'dominio' => $this->dominio,
|
||||||
'props' => $this->collapseProps(),
|
'primary_color' => $this->primary_color,
|
||||||
|
'secondary_color' => $this->secondary_color,
|
||||||
|
'danger_color' => $this->danger_color,
|
||||||
|
'header_footer_bg_color' => $this->header_footer_bg_color,
|
||||||
|
'header_logo' => $this->header_logo,
|
||||||
|
'footer_logo' => $this->footer_logo,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
105
app/Domains/Tenant/Services/TenantService.php
Normal file
105
app/Domains/Tenant/Services/TenantService.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Tenant\Services;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Services\AttachmentService;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class TenantService
|
||||||
|
{
|
||||||
|
public function __construct(protected AttachmentService $attachmentService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tenant and store its logos.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return Tenant
|
||||||
|
*/
|
||||||
|
public function create(array $data): Tenant
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($data): Tenant {
|
||||||
|
$headerLogo = $data['header_logo'] ?? null;
|
||||||
|
$footerLogo = $data['footer_logo'] ?? null;
|
||||||
|
|
||||||
|
unset($data['header_logo'], $data['footer_logo']);
|
||||||
|
|
||||||
|
/** @var Tenant $tenant */
|
||||||
|
$tenant = Tenant::query()->create($data);
|
||||||
|
|
||||||
|
if ($headerLogo) {
|
||||||
|
$attachment = $this->attachmentService->store($headerLogo, 'tenants');
|
||||||
|
$tenant->header_logo = $attachment->key;
|
||||||
|
$tenant->attachments()->attach($attachment->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($footerLogo) {
|
||||||
|
$attachment = $this->attachmentService->store($footerLogo, 'tenants');
|
||||||
|
$tenant->footer_logo = $attachment->key;
|
||||||
|
$tenant->attachments()->attach($attachment->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($headerLogo || $footerLogo) {
|
||||||
|
$tenant->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing tenant and store new logos if uploaded.
|
||||||
|
*
|
||||||
|
* @param Tenant $tenant
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return Tenant
|
||||||
|
*/
|
||||||
|
public function update(Tenant $tenant, array $data): Tenant
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $data): Tenant {
|
||||||
|
$hasHeaderLogoKey = array_key_exists('header_logo', $data);
|
||||||
|
$hasFooterLogoKey = array_key_exists('footer_logo', $data);
|
||||||
|
$headerLogo = $data['header_logo'] ?? null;
|
||||||
|
$footerLogo = $data['footer_logo'] ?? null;
|
||||||
|
|
||||||
|
unset($data['header_logo'], $data['footer_logo']);
|
||||||
|
|
||||||
|
$tenant->fill($data);
|
||||||
|
|
||||||
|
if ($hasHeaderLogoKey) {
|
||||||
|
if ($headerLogo) {
|
||||||
|
if (! Str::isUuid($headerLogo)) {
|
||||||
|
$attachment = $this->attachmentService->store($headerLogo, 'tenants');
|
||||||
|
$tenant->header_logo = $attachment->key;
|
||||||
|
$tenant->attachments()->attach($attachment->id);
|
||||||
|
} else {
|
||||||
|
$tenant->header_logo = $headerLogo;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$tenant->header_logo = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasFooterLogoKey) {
|
||||||
|
if ($footerLogo) {
|
||||||
|
if (! Str::isUuid($footerLogo)) {
|
||||||
|
$attachment = $this->attachmentService->store($footerLogo, 'tenants');
|
||||||
|
$tenant->footer_logo = $attachment->key;
|
||||||
|
$tenant->attachments()->attach($attachment->id);
|
||||||
|
} else {
|
||||||
|
$tenant->footer_logo = $footerLogo;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$tenant->footer_logo = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->save();
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Support;
|
|
||||||
|
|
||||||
enum TenantPropDataType: string
|
|
||||||
{
|
|
||||||
case String = 'string';
|
|
||||||
case Number = 'number';
|
|
||||||
case Boolean = 'boolean';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public static function values(): array
|
|
||||||
{
|
|
||||||
return array_column(self::cases(), 'value');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Support;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\TenantProp;
|
|
||||||
use Closure;
|
|
||||||
|
|
||||||
class TenantPropRules
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function sync(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'props' => [
|
|
||||||
'sometimes',
|
|
||||||
'array',
|
|
||||||
static function (string $attribute, mixed $value, Closure $fail): void {
|
|
||||||
static::validateExistingProps($attribute, $value, $fail);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'props.*' => ['nullable'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function validateExistingProps(string $attribute, mixed $value, Closure $fail): void
|
|
||||||
{
|
|
||||||
if (! is_array($value) || $value === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$propCodes = array_map('strval', array_keys($value));
|
|
||||||
$existingPropCodes = TenantProp::query()
|
|
||||||
->whereIn('codigo', $propCodes)
|
|
||||||
->pluck('codigo')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$missingPropCodes = array_values(array_diff($propCodes, $existingPropCodes));
|
|
||||||
|
|
||||||
if ($missingPropCodes === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fail(sprintf(
|
|
||||||
'The selected %s are invalid for Tenant: %s.',
|
|
||||||
$attribute,
|
|
||||||
implode(', ', $missingPropCodes),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
use App\Domains\Tenant\Controllers\BootstrapTenantController;
|
use App\Domains\Tenant\Controllers\BootstrapTenantController;
|
||||||
use App\Domains\Tenant\Controllers\TenantController;
|
use App\Domains\Tenant\Controllers\TenantController;
|
||||||
use App\Domains\Tenant\Controllers\TenantPropController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('tenants/bootstrap/{dominio}', BootstrapTenantController::class)
|
Route::get('tenants/bootstrap/{dominio}', BootstrapTenantController::class)
|
||||||
->where('dominio', '.*');
|
->where('dominio', '.*');
|
||||||
|
|
||||||
Route::apiResource('tenants', TenantController::class);
|
Route::apiResource('tenants', TenantController::class);
|
||||||
Route::apiResource('tenant-props', TenantPropController::class)
|
|
||||||
->parameters(['tenant-props' => 'tenantProp']);
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ return new class extends Migration
|
|||||||
$table->string('codigo')->unique();
|
$table->string('codigo')->unique();
|
||||||
$table->string('nombre');
|
$table->string('nombre');
|
||||||
$table->string('dominio')->nullable();
|
$table->string('dominio')->nullable();
|
||||||
|
$table->string('primary_color')->nullable();
|
||||||
|
$table->string('secondary_color')->nullable();
|
||||||
|
$table->string('danger_color')->nullable();
|
||||||
|
$table->string('header_footer_bg_color')->nullable();
|
||||||
|
$table->string('header_logo')->nullable();
|
||||||
|
$table->string('footer_logo')->nullable();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('tenant_props', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->string('codigo')->unique();
|
|
||||||
$table->string('nombre');
|
|
||||||
$table->text('descripcion')->nullable();
|
|
||||||
$table->boolean('is_required')->default(false);
|
|
||||||
$table->string('data_type');
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('tenant_props');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -11,26 +11,20 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::create('tenant_prop_values', function (Blueprint $table) {
|
Schema::create('compras', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->string('tenant_codigo');
|
$table->string('tenant_codigo');
|
||||||
$table->string('tenant_prop_codigo');
|
$table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnUpdate()->nullOnDelete();
|
||||||
$table->text('value')->nullable();
|
$table->enum('status', ['pending', 'paid', 'cancelled'])->default('pending');
|
||||||
|
$table->enum('payment_status', ['pending', 'approved', 'rejected'])->default('pending');
|
||||||
|
$table->string('payment_method')->nullable();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
$table->foreign('tenant_codigo')
|
$table->foreign('tenant_codigo')
|
||||||
->references('codigo')
|
->references('codigo')
|
||||||
->on('tenants')
|
->on('tenants')
|
||||||
->cascadeOnUpdate()
|
->cascadeOnUpdate()
|
||||||
->cascadeOnDelete();
|
->restrictOnDelete();
|
||||||
|
|
||||||
$table->foreign('tenant_prop_codigo')
|
|
||||||
->references('codigo')
|
|
||||||
->on('tenant_props')
|
|
||||||
->cascadeOnUpdate()
|
|
||||||
->cascadeOnDelete();
|
|
||||||
|
|
||||||
$table->unique(['tenant_codigo', 'tenant_prop_codigo']);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +33,6 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::dropIfExists('tenant_prop_values');
|
Schema::dropIfExists('compras');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('compra_items', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('compra_id')->constrained('compras')->cascadeOnUpdate()->cascadeOnDelete();
|
||||||
|
$table->unsignedBigInteger('producto_variante_id');
|
||||||
|
$table->unsignedInteger('cantidad');
|
||||||
|
$table->decimal('precio_unitario', 10, 2);
|
||||||
|
$table->decimal('discount_total', 10, 2)->nullable();
|
||||||
|
$table->decimal('tax_total', 10, 2)->nullable();
|
||||||
|
$table->decimal('total', 10, 2);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('producto_variante_id')
|
||||||
|
->references('id')
|
||||||
|
->on('productos_variantes')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->restrictOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('compra_items');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('productos_attributes')
|
||||||
|
->where('type', 'text')
|
||||||
|
->update(['type' => 'string']);
|
||||||
|
|
||||||
|
DB::table('productos_attributes')
|
||||||
|
->where('type', 'numeric')
|
||||||
|
->update(['type' => 'number']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::table('productos_attributes')
|
||||||
|
->where('type', 'string')
|
||||||
|
->update(['type' => 'text']);
|
||||||
|
|
||||||
|
DB::table('productos_attributes')
|
||||||
|
->where('type', 'number')
|
||||||
|
->update(['type' => 'numeric']);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -10,4 +10,5 @@ Route::get('/user', function (Request $request) {
|
|||||||
require __DIR__.'/../app/Domains/Catalog/routes/api.php';
|
require __DIR__.'/../app/Domains/Catalog/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Cart/routes/api.php';
|
require __DIR__.'/../app/Domains/Cart/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
|
require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
|
||||||
|
require __DIR__.'/../app/Domains/Purchase/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
|
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ class ProductAttributeControllerTest extends TestCase
|
|||||||
|
|
||||||
$response
|
$response
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('tenant_codigo', 'acme')
|
->assertJsonPath('data.tenant_codigo', 'acme')
|
||||||
->assertJsonPath('codigo', 'color')
|
->assertJsonPath('data.codigo', 'color')
|
||||||
->assertJsonPath('type', 'select')
|
->assertJsonPath('data.type', 'select')
|
||||||
->assertJsonPath('options.0.label', 'Red')
|
->assertJsonPath('data.options.0.label', 'Red')
|
||||||
->assertJsonPath('options.1.metadata.hex', '#0000ff');
|
->assertJsonPath('data.options.1.metadata.hex', '#0000ff');
|
||||||
|
|
||||||
$this->assertDatabaseHas('productos_attributes', [
|
$this->assertDatabaseHas('productos_attributes', [
|
||||||
'tenant_codigo' => 'acme',
|
'tenant_codigo' => 'acme',
|
||||||
@@ -60,7 +60,7 @@ class ProductAttributeControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_rejects_options_for_text_attributes(): void
|
public function test_it_rejects_options_for_string_attributes(): void
|
||||||
{
|
{
|
||||||
Tenant::create([
|
Tenant::create([
|
||||||
'codigo' => 'acme',
|
'codigo' => 'acme',
|
||||||
@@ -71,7 +71,7 @@ class ProductAttributeControllerTest extends TestCase
|
|||||||
$response = $this->postJson('/api/tenants/acme/product-attributes', [
|
$response = $this->postJson('/api/tenants/acme/product-attributes', [
|
||||||
'codigo' => 'material',
|
'codigo' => 'material',
|
||||||
'nombre' => 'Material',
|
'nombre' => 'Material',
|
||||||
'type' => 'text',
|
'type' => 'string',
|
||||||
'options' => [
|
'options' => [
|
||||||
['label' => 'Cotton'],
|
['label' => 'Cotton'],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,39 +3,44 @@
|
|||||||
namespace Tests\Feature\Tenant;
|
namespace Tests\Feature\Tenant;
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Models\TenantProp;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class BootstrapTenantControllerTest extends TestCase
|
class BootstrapTenantControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_it_bootstraps_a_tenant_by_domain_and_includes_props(): void
|
public function test_it_bootstraps_a_tenant_by_domain(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'codigo' => 'acme',
|
'codigo' => 'acme',
|
||||||
'nombre' => 'Acme',
|
'nombre' => 'Acme',
|
||||||
'dominio' => 'acme.com',
|
'dominio' => 'acme.com',
|
||||||
|
'primary_color' => '#ff0000',
|
||||||
|
'secondary_color' => '#00ff00',
|
||||||
|
'danger_color' => '#0000ff',
|
||||||
|
'header_footer_bg_color' => '#ffffff',
|
||||||
|
'header_logo' => 'logo_header.png',
|
||||||
|
'footer_logo' => 'logo_footer.png',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
TenantProp::create([
|
|
||||||
'codigo' => 'primary_color',
|
|
||||||
'nombre' => 'Primary Color',
|
|
||||||
'descripcion' => 'Brand color',
|
|
||||||
'is_required' => false,
|
|
||||||
'data_type' => 'string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant->setPropValue('primary_color', 'blue');
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/tenants/bootstrap/acme.com');
|
$response = $this->getJson('/api/tenants/bootstrap/acme.com');
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('codigo', 'acme')
|
->assertJsonPath('data.codigo', 'acme')
|
||||||
->assertJsonPath('dominio', 'acme.com')
|
->assertJsonPath('data.dominio', 'acme.com')
|
||||||
->assertJsonPath('props.primary_color', 'blue');
|
->assertJsonPath('data.primary_color', '#ff0000')
|
||||||
|
->assertJsonPath('data.secondary_color', '#00ff00')
|
||||||
|
->assertJsonPath('data.danger_color', '#0000ff')
|
||||||
|
->assertJsonPath('data.header_footer_bg_color', '#ffffff')
|
||||||
|
->assertJsonPath('data.header_logo', 'logo_header.png')
|
||||||
|
->assertJsonPath('data.footer_logo', 'logo_footer.png');
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('props', $response->json('data'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_bootstraps_a_tenant_from_a_full_url(): void
|
public function test_it_bootstraps_a_tenant_from_a_full_url(): void
|
||||||
@@ -52,8 +57,8 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
|
|
||||||
$response
|
$response
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('codigo', 'acme')
|
->assertJsonPath('data.codigo', 'acme')
|
||||||
->assertJsonPath('dominio', 'acme.com');
|
->assertJsonPath('data.dominio', 'acme.com');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_returns_not_found_when_the_domain_does_not_exist(): void
|
public function test_it_returns_not_found_when_the_domain_does_not_exist(): void
|
||||||
@@ -65,32 +70,42 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
|
|
||||||
public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void
|
public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void
|
||||||
{
|
{
|
||||||
TenantProp::create([
|
$base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
'codigo' => 'primary_color',
|
|
||||||
'nombre' => 'Primary Color',
|
|
||||||
'descripcion' => 'Brand color',
|
|
||||||
'is_required' => false,
|
|
||||||
'data_type' => 'string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$firstResponse = $this->postJson('/api/tenants', [
|
$firstResponse = $this->postJson('/api/tenants', [
|
||||||
'codigo' => 'acme',
|
'codigo' => 'acme',
|
||||||
'nombre' => 'Acme',
|
'nombre' => 'Acme',
|
||||||
'dominio' => 'https://ACME.com/path',
|
'dominio' => 'https://ACME.com/path',
|
||||||
'props' => [
|
'primary_color' => '#111111',
|
||||||
'primary_color' => 'blue',
|
'secondary_color' => '#222222',
|
||||||
],
|
'danger_color' => '#333333',
|
||||||
|
'header_footer_bg_color' => '#444444',
|
||||||
|
'header_logo' => $base64Image,
|
||||||
|
'footer_logo' => $base64Image,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$firstResponse
|
$firstResponse
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('dominio', 'acme.com')
|
->assertJsonPath('data.dominio', 'acme.com')
|
||||||
->assertJsonPath('props.primary_color', 'blue');
|
->assertJsonPath('data.primary_color', '#111111')
|
||||||
|
->assertJsonPath('data.secondary_color', '#222222')
|
||||||
|
->assertJsonPath('data.danger_color', '#333333')
|
||||||
|
->assertJsonPath('data.header_footer_bg_color', '#444444');
|
||||||
|
|
||||||
$this->assertDatabaseHas('tenant_prop_values', [
|
$headerUuid = $firstResponse->json('data.header_logo');
|
||||||
'tenant_codigo' => 'acme',
|
$footerUuid = $firstResponse->json('data.footer_logo');
|
||||||
'tenant_prop_codigo' => 'primary_color',
|
|
||||||
'value' => 'blue',
|
$this->assertTrue(Str::isUuid($headerUuid));
|
||||||
|
$this->assertTrue(Str::isUuid($footerUuid));
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tenants', [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'primary_color' => '#111111',
|
||||||
|
'secondary_color' => '#222222',
|
||||||
|
'danger_color' => '#333333',
|
||||||
|
'header_footer_bg_color' => '#444444',
|
||||||
|
'header_logo' => $headerUuid,
|
||||||
|
'footer_logo' => $footerUuid,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$secondResponse = $this->postJson('/api/tenants', [
|
$secondResponse = $this->postJson('/api/tenants', [
|
||||||
@@ -106,14 +121,6 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
|
|
||||||
public function test_it_allows_keeping_the_same_domain_on_update_but_rejects_collisions(): void
|
public function test_it_allows_keeping_the_same_domain_on_update_but_rejects_collisions(): void
|
||||||
{
|
{
|
||||||
TenantProp::create([
|
|
||||||
'codigo' => 'primary_color',
|
|
||||||
'nombre' => 'Primary Color',
|
|
||||||
'descripcion' => 'Brand color',
|
|
||||||
'is_required' => false,
|
|
||||||
'data_type' => 'string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'codigo' => 'acme',
|
'codigo' => 'acme',
|
||||||
'nombre' => 'Acme',
|
'nombre' => 'Acme',
|
||||||
@@ -126,20 +133,41 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
'dominio' => 'globex.com',
|
'dominio' => 'globex.com',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$hdrUuid = (string) Str::uuid();
|
||||||
|
$ftrUuid = (string) Str::uuid();
|
||||||
|
|
||||||
$successfulResponse = $this->putJson("/api/tenants/{$tenant->id}", [
|
$successfulResponse = $this->putJson("/api/tenants/{$tenant->id}", [
|
||||||
'codigo' => 'acme',
|
'codigo' => 'acme',
|
||||||
'nombre' => 'Acme Updated',
|
'nombre' => 'Acme Updated',
|
||||||
'dominio' => 'https://ACME.com:443/admin',
|
'dominio' => 'https://ACME.com:443/admin',
|
||||||
'props' => [
|
'primary_color' => '#555555',
|
||||||
'primary_color' => 'green',
|
'secondary_color' => '#666666',
|
||||||
],
|
'danger_color' => '#777777',
|
||||||
|
'header_footer_bg_color' => '#888888',
|
||||||
|
'header_logo' => $hdrUuid,
|
||||||
|
'footer_logo' => $ftrUuid,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$successfulResponse
|
$successfulResponse
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('nombre', 'Acme Updated')
|
->assertJsonPath('data.nombre', 'Acme Updated')
|
||||||
->assertJsonPath('dominio', 'acme.com')
|
->assertJsonPath('data.dominio', 'acme.com')
|
||||||
->assertJsonPath('props.primary_color', 'green');
|
->assertJsonPath('data.primary_color', '#555555')
|
||||||
|
->assertJsonPath('data.secondary_color', '#666666')
|
||||||
|
->assertJsonPath('data.danger_color', '#777777')
|
||||||
|
->assertJsonPath('data.header_footer_bg_color', '#888888')
|
||||||
|
->assertJsonPath('data.header_logo', $hdrUuid)
|
||||||
|
->assertJsonPath('data.footer_logo', $ftrUuid);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tenants', [
|
||||||
|
'id' => $tenant->id,
|
||||||
|
'primary_color' => '#555555',
|
||||||
|
'secondary_color' => '#666666',
|
||||||
|
'danger_color' => '#777777',
|
||||||
|
'header_footer_bg_color' => '#888888',
|
||||||
|
'header_logo' => $hdrUuid,
|
||||||
|
'footer_logo' => $ftrUuid,
|
||||||
|
]);
|
||||||
|
|
||||||
$failingResponse = $this->putJson("/api/tenants/{$otherTenant->id}", [
|
$failingResponse = $this->putJson("/api/tenants/{$otherTenant->id}", [
|
||||||
'codigo' => 'globex',
|
'codigo' => 'globex',
|
||||||
@@ -151,4 +179,89 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
->assertUnprocessable()
|
->assertUnprocessable()
|
||||||
->assertJsonValidationErrors(['dominio']);
|
->assertJsonValidationErrors(['dominio']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_validates_aesthetic_colors(): void
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/tenants', [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'primary_color' => 'invalid-color',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertJsonValidationErrors(['primary_color']);
|
||||||
|
|
||||||
|
$response2 = $this->postJson('/api/tenants', [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'primary_color' => '#12345',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response2->assertJsonValidationErrors(['primary_color']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_validates_logo_must_be_image_or_svg(): void
|
||||||
|
{
|
||||||
|
Storage::fake('s3');
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/tenants', [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'header_logo' => UploadedFile::fake()->create('document.pdf', 10, 'application/pdf'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertJsonValidationErrors(['header_logo']);
|
||||||
|
|
||||||
|
$response2 = $this->postJson('/api/tenants', [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'header_logo' => 'data:application/pdf;base64,JVBERi0xLjQKJdcfqksKMSAwIG9iagogIDw8IC9UeXBlIC9DYXRhbG9nCiAgICAvUGFnZXMgMiAwIFI...',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response2->assertJsonValidationErrors(['header_logo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_stores_uploaded_file_logos_in_tenants_directory(): void
|
||||||
|
{
|
||||||
|
Storage::fake('s3');
|
||||||
|
|
||||||
|
$header = UploadedFile::fake()->image('header.png');
|
||||||
|
$footer = UploadedFile::fake()->image('footer.svg', 100, 100);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/tenants', [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'header_logo' => $header,
|
||||||
|
'footer_logo' => $footer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertCreated();
|
||||||
|
|
||||||
|
$headerUuid = $response->json('data.header_logo');
|
||||||
|
$footerUuid = $response->json('data.footer_logo');
|
||||||
|
|
||||||
|
$this->assertTrue(Str::isUuid($headerUuid));
|
||||||
|
$this->assertTrue(Str::isUuid($footerUuid));
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('attachments', ['key' => $headerUuid]);
|
||||||
|
$this->assertDatabaseHas('attachments', ['key' => $footerUuid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_stores_base64_logos_in_tenants_directory(): void
|
||||||
|
{
|
||||||
|
Storage::fake('s3');
|
||||||
|
|
||||||
|
$base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/tenants', [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'header_logo' => $base64Image,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertCreated();
|
||||||
|
|
||||||
|
$headerUuid = $response->json('data.header_logo');
|
||||||
|
$this->assertTrue(Str::isUuid($headerUuid));
|
||||||
|
$this->assertDatabaseHas('attachments', ['key' => $headerUuid]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Tenant;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class TenantPropControllerTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_it_creates_a_tenant_prop(): void
|
|
||||||
{
|
|
||||||
$response = $this->postJson('/api/tenant-props', [
|
|
||||||
'codigo' => 'primary_color',
|
|
||||||
'nombre' => 'Primary Color',
|
|
||||||
'descripcion' => 'Brand color',
|
|
||||||
'is_required' => false,
|
|
||||||
'data_type' => 'string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertCreated()
|
|
||||||
->assertJsonPath('codigo', 'primary_color')
|
|
||||||
->assertJsonPath('data_type', 'string');
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('tenant_props', [
|
|
||||||
'codigo' => 'primary_color',
|
|
||||||
'data_type' => 'string',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_unknown_data_type(): void
|
|
||||||
{
|
|
||||||
$response = $this->postJson('/api/tenant-props', [
|
|
||||||
'codigo' => 'primary_color',
|
|
||||||
'nombre' => 'Primary Color',
|
|
||||||
'data_type' => 'unsupported',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertUnprocessable()
|
|
||||||
->assertJsonValidationErrors(['data_type']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user