feat: implement owner verification system with enums, service, and controller updates for managing verification requests
This commit is contained in:
parent
d0223b2800
commit
21b990799e
27
app/Enums/OwnerVerificationAction.php
Normal file
27
app/Enums/OwnerVerificationAction.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Traits\ProvidesEnumOptions;
|
||||
|
||||
enum OwnerVerificationAction: string
|
||||
{
|
||||
use ProvidesEnumOptions;
|
||||
|
||||
case CREATE = 'create';
|
||||
case UPDATE = 'update';
|
||||
case DELETE = 'delete';
|
||||
case TOGGLE_STATUS = 'toggle_status';
|
||||
case STOCK_VERIFY = 'stock_verify';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CREATE => 'Tambah',
|
||||
self::UPDATE => 'Ubah',
|
||||
self::DELETE => 'Hapus',
|
||||
self::TOGGLE_STATUS => 'Ubah Status',
|
||||
self::STOCK_VERIFY => 'Verifikasi Stok',
|
||||
};
|
||||
}
|
||||
}
|
||||
32
app/Enums/OwnerVerificationStatus.php
Normal file
32
app/Enums/OwnerVerificationStatus.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Traits\ProvidesEnumOptions;
|
||||
|
||||
enum OwnerVerificationStatus: string
|
||||
{
|
||||
use ProvidesEnumOptions;
|
||||
|
||||
case PENDING = 'pending';
|
||||
case APPROVED = 'approved';
|
||||
case REJECTED = 'rejected';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::PENDING => 'Menunggu Verifikasi',
|
||||
self::APPROVED => 'Disetujui',
|
||||
self::REJECTED => 'Ditolak',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeVariant(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::PENDING => 'outline',
|
||||
self::APPROVED => 'default',
|
||||
self::REJECTED => 'destructive',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -170,6 +170,8 @@ public function permissions(): array
|
||||
Permission::PRODUCTS_DELETE,
|
||||
Permission::PRODUCTS_TOGGLE_STATUS,
|
||||
|
||||
Permission::OWNER_VERIFICATIONS_VIEW,
|
||||
|
||||
Permission::ORDERS_VIEW,
|
||||
Permission::ORDERS_CREATE,
|
||||
Permission::ORDERS_UPDATE,
|
||||
@ -280,6 +282,8 @@ public function permissions(): array
|
||||
Permission::RAW_MATERIALS_DELETE,
|
||||
Permission::RAW_MATERIALS_TOGGLE_STATUS,
|
||||
|
||||
Permission::OWNER_VERIFICATIONS_VIEW,
|
||||
|
||||
Permission::PRODUCTS_VIEW,
|
||||
|
||||
Permission::CUTTINGS_VIEW,
|
||||
|
||||
@ -2,11 +2,16 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage;
|
||||
|
||||
use App\Enums\OwnerVerificationAction;
|
||||
use App\Enums\OwnerVerificationStatus;
|
||||
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\ApproveVerificationRequest;
|
||||
use App\Http\Requests\Admin\Manage\RejectVerificationRequest;
|
||||
use App\Models\Cutting;
|
||||
use App\Models\OwnerVerificationRequest;
|
||||
use App\Services\Manage\OwnerVerificationService;
|
||||
use App\Services\Manage\StockService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@ -15,22 +20,40 @@
|
||||
|
||||
class OwnerVerificationController extends Controller
|
||||
{
|
||||
use FlashesEntityMessage;
|
||||
use FlashesEntityMessage, ParsesDataTableQuery;
|
||||
|
||||
public function __construct(
|
||||
private readonly OwnerVerificationService $ownerVerificationService,
|
||||
private readonly StockService $stockService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$tableQuery = $this->parseDataTableQuery($request);
|
||||
$status = $request->string('status')->toString();
|
||||
$subjectType = $request->string('subject_type')->toString();
|
||||
$action = $request->string('action')->toString();
|
||||
|
||||
return Inertia::render('admin/manage/owner-verifications/Index', [
|
||||
'pendingCuttings' => $this->stockService->getPendingApprovalCuttings($user),
|
||||
'verificationRequests' => $this->ownerVerificationService->paginateForIndex(
|
||||
$request->user(),
|
||||
$tableQuery,
|
||||
$status,
|
||||
$subjectType,
|
||||
$action,
|
||||
),
|
||||
'filters' => $this->dataTableFilters($tableQuery, [
|
||||
'status' => $status,
|
||||
'subject_type' => $subjectType,
|
||||
'action' => $action,
|
||||
]),
|
||||
'statusOptions' => OwnerVerificationStatus::selectOptions(),
|
||||
'subjectOptions' => $this->ownerVerificationService->subjectTypeOptions($request->user()),
|
||||
'actionOptions' => OwnerVerificationAction::selectOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function approve(ApproveVerificationRequest $request, Cutting $cutting): RedirectResponse
|
||||
public function approveCutting(ApproveVerificationRequest $request, Cutting $cutting): RedirectResponse
|
||||
{
|
||||
$this->stockService->approveVerification(
|
||||
$cutting,
|
||||
@ -38,12 +61,12 @@ public function approve(ApproveVerificationRequest $request, Cutting $cutting):
|
||||
$request->validated('approval_note'),
|
||||
);
|
||||
|
||||
$this->flashSuccess('Verifikasi berhasil disetujui. Stok produk telah ditambahkan ke toko.');
|
||||
$this->flashSuccess('Verifikasi cutting berhasil disetujui. Stok produk telah ditambahkan ke toko.');
|
||||
|
||||
return redirect()->route('admin.manage.owner_verifications.index');
|
||||
}
|
||||
|
||||
public function reject(RejectVerificationRequest $request, Cutting $cutting): RedirectResponse
|
||||
public function rejectCutting(RejectVerificationRequest $request, Cutting $cutting): RedirectResponse
|
||||
{
|
||||
$this->stockService->rejectVerification(
|
||||
$cutting,
|
||||
@ -51,6 +74,36 @@ public function reject(RejectVerificationRequest $request, Cutting $cutting): Re
|
||||
$request->validated('reason'),
|
||||
);
|
||||
|
||||
$this->flashSuccess('Verifikasi cutting berhasil ditolak.');
|
||||
|
||||
return redirect()->route('admin.manage.owner_verifications.index');
|
||||
}
|
||||
|
||||
public function approveRequest(
|
||||
ApproveVerificationRequest $request,
|
||||
OwnerVerificationRequest $ownerVerificationRequest,
|
||||
): RedirectResponse {
|
||||
$this->ownerVerificationService->approveRequest(
|
||||
$ownerVerificationRequest,
|
||||
$request->user(),
|
||||
$request->validated('approval_note'),
|
||||
);
|
||||
|
||||
$this->flashSuccess('Verifikasi berhasil disetujui.');
|
||||
|
||||
return redirect()->route('admin.manage.owner_verifications.index');
|
||||
}
|
||||
|
||||
public function rejectRequest(
|
||||
RejectVerificationRequest $request,
|
||||
OwnerVerificationRequest $ownerVerificationRequest,
|
||||
): RedirectResponse {
|
||||
$this->ownerVerificationService->rejectRequest(
|
||||
$ownerVerificationRequest,
|
||||
$request->user(),
|
||||
$request->validated('reason'),
|
||||
);
|
||||
|
||||
$this->flashSuccess('Verifikasi berhasil ditolak.');
|
||||
|
||||
return redirect()->route('admin.manage.owner_verifications.index');
|
||||
|
||||
@ -46,7 +46,7 @@ public function store(PurchaseRequest $request): RedirectResponse
|
||||
{
|
||||
$this->purchaseService->create($request->validated(), $request->user());
|
||||
|
||||
$this->flashCreated('Belanja');
|
||||
$this->flashSuccess('Belanja berhasil diajukan dan menunggu verifikasi owner.');
|
||||
|
||||
return redirect()->route('admin.manage.purchases.index');
|
||||
}
|
||||
@ -62,18 +62,18 @@ public function edit(Purchase $purchase): Response
|
||||
|
||||
public function update(PurchaseRequest $request, Purchase $purchase): RedirectResponse
|
||||
{
|
||||
$this->purchaseService->update($purchase, $request->validated());
|
||||
$this->purchaseService->update($purchase, $request->validated(), $request->user());
|
||||
|
||||
$this->flashUpdated('Belanja');
|
||||
$this->flashSuccess('Perubahan belanja berhasil diajukan dan menunggu verifikasi owner.');
|
||||
|
||||
return redirect()->route('admin.manage.purchases.index');
|
||||
}
|
||||
|
||||
public function destroy(Purchase $purchase): RedirectResponse
|
||||
public function destroy(Request $request, Purchase $purchase): RedirectResponse
|
||||
{
|
||||
$this->purchaseService->delete($purchase);
|
||||
$this->purchaseService->delete($purchase, $request->user());
|
||||
|
||||
$this->flashDeleted('Belanja');
|
||||
$this->flashSuccess('Penghapusan belanja berhasil diajukan dan menunggu verifikasi owner.');
|
||||
|
||||
return redirect()->route('admin.manage.purchases.index');
|
||||
}
|
||||
|
||||
@ -53,9 +53,9 @@ public function create(): Response
|
||||
|
||||
public function store(ProductRequest $request): RedirectResponse
|
||||
{
|
||||
$this->productService->create($request->validated());
|
||||
$this->productService->create($request->validated(), $request->user());
|
||||
|
||||
$this->flashCreated('Produk');
|
||||
$this->flashSuccess('Produk berhasil diajukan dan menunggu verifikasi owner.');
|
||||
|
||||
return redirect()->route('admin.master.products.index');
|
||||
}
|
||||
@ -81,27 +81,27 @@ public function edit(Product $product): Response
|
||||
|
||||
public function update(ProductRequest $request, Product $product): RedirectResponse
|
||||
{
|
||||
$this->productService->update($product, $request->validated());
|
||||
$this->productService->update($product, $request->validated(), $request->user());
|
||||
|
||||
$this->flashUpdated('Produk');
|
||||
$this->flashSuccess('Perubahan produk berhasil diajukan dan menunggu verifikasi owner.');
|
||||
|
||||
return redirect()->route('admin.master.products.index');
|
||||
}
|
||||
|
||||
public function toggleStatus(ToggleStatusRequest $request, Product $product): RedirectResponse
|
||||
{
|
||||
$this->productService->toggleStatus($product, $request->validated());
|
||||
$this->productService->toggleStatus($product, $request->validated(), $request->user());
|
||||
|
||||
$this->flashStatusUpdated('produk');
|
||||
$this->flashSuccess('Perubahan status produk berhasil diajukan dan menunggu verifikasi owner.');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function destroy(Product $product): RedirectResponse
|
||||
public function destroy(Request $request, Product $product): RedirectResponse
|
||||
{
|
||||
$this->productService->delete($product);
|
||||
$this->productService->delete($product, $request->user());
|
||||
|
||||
$this->flashDeleted('Produk');
|
||||
$this->flashSuccess('Penghapusan produk berhasil diajukan dan menunggu verifikasi owner.');
|
||||
|
||||
return redirect()->route('admin.master.products.index');
|
||||
}
|
||||
|
||||
@ -49,9 +49,9 @@ public function create(): Response
|
||||
|
||||
public function store(RawMaterialRequest $request): RedirectResponse
|
||||
{
|
||||
$this->rawMaterialService->create($request->validated());
|
||||
$this->rawMaterialService->create($request->validated(), $request->user());
|
||||
|
||||
$this->flashCreated('Bahan baku');
|
||||
$this->flashSuccess('Bahan baku berhasil diajukan dan menunggu verifikasi owner.');
|
||||
|
||||
return redirect()->route('admin.master.raw_materials.index');
|
||||
}
|
||||
@ -74,27 +74,27 @@ public function edit(RawMaterial $rawMaterial): Response
|
||||
|
||||
public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): RedirectResponse
|
||||
{
|
||||
$this->rawMaterialService->update($rawMaterial, $request->validated());
|
||||
$this->rawMaterialService->update($rawMaterial, $request->validated(), $request->user());
|
||||
|
||||
$this->flashUpdated('Bahan baku');
|
||||
$this->flashSuccess('Perubahan bahan baku berhasil diajukan dan menunggu verifikasi owner.');
|
||||
|
||||
return redirect()->route('admin.master.raw_materials.index');
|
||||
}
|
||||
|
||||
public function toggleStatus(ToggleStatusRequest $request, RawMaterial $rawMaterial): RedirectResponse
|
||||
{
|
||||
$this->rawMaterialService->toggleStatus($rawMaterial, $request->validated());
|
||||
$this->rawMaterialService->toggleStatus($rawMaterial, $request->validated(), $request->user());
|
||||
|
||||
$this->flashStatusUpdated('bahan baku');
|
||||
$this->flashSuccess('Perubahan status bahan baku berhasil diajukan dan menunggu verifikasi owner.');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function destroy(RawMaterial $rawMaterial): RedirectResponse
|
||||
public function destroy(Request $request, RawMaterial $rawMaterial): RedirectResponse
|
||||
{
|
||||
$this->rawMaterialService->delete($rawMaterial);
|
||||
$this->rawMaterialService->delete($rawMaterial, $request->user());
|
||||
|
||||
$this->flashDeleted('Bahan baku');
|
||||
$this->flashSuccess('Penghapusan bahan baku berhasil diajukan dan menunggu verifikasi owner.');
|
||||
|
||||
return redirect()->route('admin.master.raw_materials.index');
|
||||
}
|
||||
|
||||
45
app/Http/Middleware/EnsureNoPendingOwnerVerification.php
Normal file
45
app/Http/Middleware/EnsureNoPendingOwnerVerification.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\Purchase;
|
||||
use App\Models\RawMaterial;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureNoPendingOwnerVerification
|
||||
{
|
||||
public function handle(Request $request, Closure $next, string $routeParameter): Response
|
||||
{
|
||||
$subject = $request->route($routeParameter);
|
||||
|
||||
if (! $subject instanceof Product && ! $subject instanceof RawMaterial && ! $subject instanceof Purchase) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $subject->hasPendingOwnerVerification()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$label = match ($subject::class) {
|
||||
Product::class => 'Produk',
|
||||
RawMaterial::class => 'Bahan baku',
|
||||
Purchase::class => 'Belanja',
|
||||
default => 'Data',
|
||||
};
|
||||
|
||||
$redirectRoute = match ($subject::class) {
|
||||
Product::class => route('admin.master.products.index'),
|
||||
RawMaterial::class => route('admin.master.raw_materials.index'),
|
||||
Purchase::class => route('admin.manage.purchases.index'),
|
||||
default => route('admin.dashboard'),
|
||||
};
|
||||
|
||||
Inertia::flash('error', "{$label} sedang menunggu verifikasi owner.");
|
||||
|
||||
return redirect($redirectRoute);
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\Cutting;
|
||||
use App\Models\EmployeeAdvance;
|
||||
use App\Models\LeaveRequest;
|
||||
use App\Services\Manage\OwnerVerificationService;
|
||||
use App\Services\System\Setting\SystemService;
|
||||
use App\Settings\SystemSettings;
|
||||
use Illuminate\Http\Request;
|
||||
@ -121,8 +122,6 @@ private function pendingOwnerVerifications(Request $request): int
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Cutting::query()
|
||||
->pendingVerification()
|
||||
->count();
|
||||
return app(OwnerVerificationService::class)->pendingCountForUser($user);
|
||||
}
|
||||
}
|
||||
|
||||
11
app/Models/Concerns/HasPendingOwnerVerification.php
Normal file
11
app/Models/Concerns/HasPendingOwnerVerification.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
trait HasPendingOwnerVerification
|
||||
{
|
||||
public function hasPendingOwnerVerification(): bool
|
||||
{
|
||||
return $this->pendingOwnerVerificationRequest()->exists();
|
||||
}
|
||||
}
|
||||
186
app/Models/OwnerVerificationRequest.php
Normal file
186
app/Models/OwnerVerificationRequest.php
Normal file
@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\OwnerVerificationAction;
|
||||
use App\Enums\OwnerVerificationStatus;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Concerns\HasModuleMedia;
|
||||
use App\Models\Concerns\HasRejection;
|
||||
use App\Models\Concerns\InteractsWithActivityLog;
|
||||
use App\Support\ActivityLog\ModelLabel;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
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\MorphTo;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends([
|
||||
'action_label',
|
||||
'status_label',
|
||||
'subject_label',
|
||||
'submitted_by_name',
|
||||
'verified_by_name',
|
||||
'created_at_formatted',
|
||||
'verified_at_formatted',
|
||||
'is_pending',
|
||||
])]
|
||||
class OwnerVerificationRequest extends Model implements HasMedia
|
||||
{
|
||||
use HasFactory, HasModuleMedia, HasRejection, InteractsWithActivityLog;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'action' => OwnerVerificationAction::class,
|
||||
'status' => OwnerVerificationStatus::class,
|
||||
'payload' => 'array',
|
||||
'verified_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function subject(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function submittedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'submitted_by_id');
|
||||
}
|
||||
|
||||
public function verifiedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'verified_by_id');
|
||||
}
|
||||
|
||||
public function actionLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->action?->label(),
|
||||
);
|
||||
}
|
||||
|
||||
public function createdAtFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'),
|
||||
);
|
||||
}
|
||||
|
||||
public function isPending(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->status === OwnerVerificationStatus::PENDING,
|
||||
);
|
||||
}
|
||||
|
||||
public function statusLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->status?->label(),
|
||||
);
|
||||
}
|
||||
|
||||
public function subjectLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => ModelLabel::for($this->subject_type),
|
||||
);
|
||||
}
|
||||
|
||||
public function submittedByName(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->submittedBy?->profile?->full_name
|
||||
?? $this->submittedBy?->username
|
||||
?? '-',
|
||||
);
|
||||
}
|
||||
|
||||
public function verifiedAtFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->verified_at?->translatedFormat('l, d F Y H:i'),
|
||||
);
|
||||
}
|
||||
|
||||
public function verifiedByName(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->verifiedBy?->profile?->full_name
|
||||
?? $this->verifiedBy?->username,
|
||||
);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
public function approved(Builder $query): void
|
||||
{
|
||||
$query->where('status', OwnerVerificationStatus::APPROVED);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
public function pending(Builder $query): void
|
||||
{
|
||||
$query->where('status', OwnerVerificationStatus::PENDING);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
public function rejected(Builder $query): void
|
||||
{
|
||||
$query->where('status', OwnerVerificationStatus::REJECTED);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
public function forSubmitter(Builder $query, User $user): void
|
||||
{
|
||||
$query->where('submitted_by_id', $user->id);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
public function visibleTo(Builder $query, User $user): void
|
||||
{
|
||||
if ($user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->forSubmitter($user);
|
||||
}
|
||||
|
||||
public function pendingToggleIsActive(): ?bool
|
||||
{
|
||||
if ($this->action !== OwnerVerificationAction::TOGGLE_STATUS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = is_array($this->payload) ? $this->payload : [];
|
||||
$new = $payload['new'] ?? null;
|
||||
|
||||
if (! is_array($new) || ! array_key_exists('is_active', $new)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (bool) $new['is_active'];
|
||||
}
|
||||
|
||||
public static function mediaModuleName(): string
|
||||
{
|
||||
return 'owner_verification_request';
|
||||
}
|
||||
|
||||
public function variantImageCollection(int $index): string
|
||||
{
|
||||
return "variant_images_{$index}";
|
||||
}
|
||||
|
||||
public function priceImageCollection(int $index): string
|
||||
{
|
||||
return "price_images_{$index}";
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\OwnerVerificationStatus;
|
||||
use App\Models\Concerns\HasPendingOwnerVerification;
|
||||
use App\Models\Concerns\InteractsWithActivityLog;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
@ -11,6 +13,8 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Sluggable\Attributes\Sluggable;
|
||||
|
||||
@ -22,7 +26,7 @@
|
||||
#[Sluggable(from: 'name', to: 'slug')]
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory, InteractsWithActivityLog, SoftDeletes;
|
||||
use HasFactory, HasPendingOwnerVerification, InteractsWithActivityLog, SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
@ -41,6 +45,18 @@ public function variants(): HasMany
|
||||
return $this->hasMany(ProductVariant::class);
|
||||
}
|
||||
|
||||
public function ownerVerificationRequests(): MorphMany
|
||||
{
|
||||
return $this->morphMany(OwnerVerificationRequest::class, 'subject');
|
||||
}
|
||||
|
||||
public function pendingOwnerVerificationRequest(): MorphOne
|
||||
{
|
||||
return $this->morphOne(OwnerVerificationRequest::class, 'subject')
|
||||
->where('status', OwnerVerificationStatus::PENDING)
|
||||
->latestOfMany();
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
public function active(Builder $query): void
|
||||
{
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\OwnerVerificationStatus;
|
||||
use App\Models\Concerns\HasPendingOwnerVerification;
|
||||
use App\Models\Concerns\HasModuleMedia;
|
||||
use App\Models\Concerns\InteractsWithActivityLog;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
@ -11,6 +13,8 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
|
||||
@ -24,7 +28,7 @@
|
||||
])]
|
||||
class Purchase extends Model implements HasMedia
|
||||
{
|
||||
use HasFactory, HasModuleMedia, InteractsWithActivityLog, SoftDeletes;
|
||||
use HasFactory, HasModuleMedia, HasPendingOwnerVerification, InteractsWithActivityLog, SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
@ -46,6 +50,18 @@ public function items(): HasMany
|
||||
return $this->hasMany(PurchaseItem::class);
|
||||
}
|
||||
|
||||
public function ownerVerificationRequests(): MorphMany
|
||||
{
|
||||
return $this->morphMany(OwnerVerificationRequest::class, 'subject');
|
||||
}
|
||||
|
||||
public function pendingOwnerVerificationRequest(): MorphOne
|
||||
{
|
||||
return $this->morphOne(OwnerVerificationRequest::class, 'subject')
|
||||
->where('status', OwnerVerificationStatus::PENDING)
|
||||
->latestOfMany();
|
||||
}
|
||||
|
||||
public function supplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\OwnerVerificationStatus;
|
||||
use App\Enums\RawMaterialUnit;
|
||||
use App\Models\Concerns\HasPendingOwnerVerification;
|
||||
use App\Models\Concerns\InteractsWithActivityLog;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
@ -12,13 +14,15 @@
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['unit_label', 'unit_abbreviation'])]
|
||||
class RawMaterial extends Model
|
||||
{
|
||||
use HasFactory, InteractsWithActivityLog, SoftDeletes;
|
||||
use HasFactory, HasPendingOwnerVerification, InteractsWithActivityLog, SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
@ -45,6 +49,18 @@ public function prices(): HasMany
|
||||
return $this->hasMany(RawMaterialPrice::class);
|
||||
}
|
||||
|
||||
public function ownerVerificationRequests(): MorphMany
|
||||
{
|
||||
return $this->morphMany(OwnerVerificationRequest::class, 'subject');
|
||||
}
|
||||
|
||||
public function pendingOwnerVerificationRequest(): MorphOne
|
||||
{
|
||||
return $this->morphOne(OwnerVerificationRequest::class, 'subject')
|
||||
->where('status', OwnerVerificationStatus::PENDING)
|
||||
->latestOfMany();
|
||||
}
|
||||
|
||||
public function unitAbbreviation(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
||||
542
app/Services/Manage/OwnerVerificationService.php
Normal file
542
app/Services/Manage/OwnerVerificationService.php
Normal file
@ -0,0 +1,542 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Manage;
|
||||
|
||||
use App\Enums\OwnerVerificationAction;
|
||||
use App\Enums\OwnerVerificationStatus;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Cutting;
|
||||
use App\Models\OwnerVerificationRequest;
|
||||
use App\Models\Product;
|
||||
use App\Models\Purchase;
|
||||
use App\Models\RawMaterial;
|
||||
use App\Models\User;
|
||||
use App\Services\Manage\PurchaseService;
|
||||
use App\Services\Master\ProductService;
|
||||
use App\Services\Master\RawMaterialService;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use App\Support\ActivityLog\ModelLabel;
|
||||
use App\Support\OwnerVerification\VerificationChangeFormatter;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Pagination\LengthAwarePaginator as Paginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class OwnerVerificationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StockService $stockService,
|
||||
private readonly ProductService $productService,
|
||||
private readonly RawMaterialService $rawMaterialService,
|
||||
private readonly PurchaseService $purchaseService,
|
||||
private readonly PushNotificationService $pushNotificationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
|
||||
*/
|
||||
public function paginateForIndex(
|
||||
User $user,
|
||||
array $tableQuery,
|
||||
string $status = '',
|
||||
string $subjectType = '',
|
||||
string $action = '',
|
||||
): LengthAwarePaginator {
|
||||
$perPage = 10;
|
||||
$page = Paginator::resolveCurrentPage();
|
||||
$includeCuttings = $this->shouldIncludeCuttings($user, $status, $subjectType, $action);
|
||||
$cuttingRows = $includeCuttings
|
||||
? $this->pendingCuttingRows($user, $tableQuery)
|
||||
: collect();
|
||||
$cuttingCount = $cuttingRows->count();
|
||||
|
||||
$requestsOnly = $action === OwnerVerificationAction::STOCK_VERIFY->value
|
||||
|| $subjectType === Cutting::class;
|
||||
|
||||
$query = $this->buildVerificationRequestQuery($user, $tableQuery, $status, $subjectType, $action);
|
||||
$requestTotal = $requestsOnly ? 0 : (clone $query)->count();
|
||||
$total = $cuttingCount + $requestTotal;
|
||||
|
||||
if ($requestsOnly) {
|
||||
$items = $cuttingRows->forPage($page, $perPage)->values();
|
||||
|
||||
return $this->makePaginator($items, $total, $perPage, $page);
|
||||
}
|
||||
|
||||
if ($cuttingCount > 0) {
|
||||
if ($page === 1) {
|
||||
$requestLimit = max(0, $perPage - $cuttingCount);
|
||||
$requestItems = $requestLimit > 0
|
||||
? $query->take($requestLimit)->get()->map(fn (OwnerVerificationRequest $request) => $this->presentRequestRow($request))
|
||||
: collect();
|
||||
$items = $cuttingRows->concat($requestItems)->values();
|
||||
} else {
|
||||
$requestOffset = ($page - 1) * $perPage - $cuttingCount;
|
||||
$items = $query
|
||||
->skip(max(0, $requestOffset))
|
||||
->take($perPage)
|
||||
->get()
|
||||
->map(fn (OwnerVerificationRequest $request) => $this->presentRequestRow($request));
|
||||
}
|
||||
|
||||
return $this->makePaginator($items, $total, $perPage, $page);
|
||||
}
|
||||
|
||||
return $query
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(fn (OwnerVerificationRequest $request) => $this->presentRequestRow($request));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function pendingCuttingRows(User $user, array $tableQuery): Collection
|
||||
{
|
||||
$rows = $this->stockService
|
||||
->getPendingApprovalCuttings($user)
|
||||
->map(fn (Cutting $cutting) => $this->presentCuttingRow($cutting));
|
||||
|
||||
if ($tableQuery['search'] === '') {
|
||||
return $rows->values();
|
||||
}
|
||||
|
||||
$search = mb_strtolower($tableQuery['search']);
|
||||
|
||||
return $rows
|
||||
->filter(function (array $row) use ($search): bool {
|
||||
foreach (['title', 'summary', 'submitted_by_name', 'action_label', 'subject_label', 'status_label'] as $field) {
|
||||
$value = mb_strtolower((string) ($row[$field] ?? ''));
|
||||
|
||||
if ($value !== '' && str_contains($value, $search)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
public function pendingCountForUser(User $user): int
|
||||
{
|
||||
$requestCount = OwnerVerificationRequest::query()
|
||||
->pending()
|
||||
->visibleTo($user)
|
||||
->count();
|
||||
|
||||
if (! $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||
return $requestCount;
|
||||
}
|
||||
|
||||
return $requestCount + Cutting::query()->pendingVerification()->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{value: string, label: string}>
|
||||
*/
|
||||
public function subjectTypeOptions(User $user): array
|
||||
{
|
||||
$options = OwnerVerificationRequest::query()
|
||||
->visibleTo($user)
|
||||
->distinct()
|
||||
->pluck('subject_type')
|
||||
->filter()
|
||||
->map(fn (string $type) => [
|
||||
'value' => $type,
|
||||
'label' => ModelLabel::for($type),
|
||||
]);
|
||||
|
||||
if ($user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)
|
||||
&& Cutting::query()->pendingVerification()->exists()) {
|
||||
$options->push([
|
||||
'value' => Cutting::class,
|
||||
'label' => ModelLabel::for(Cutting::class),
|
||||
]);
|
||||
}
|
||||
|
||||
return $options
|
||||
->unique('value')
|
||||
->sortBy('label')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function approveRequest(
|
||||
OwnerVerificationRequest $request,
|
||||
User $user,
|
||||
?string $approvalNote = null,
|
||||
): void {
|
||||
if ($request->status !== OwnerVerificationStatus::PENDING) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => 'Hanya pengajuan yang menunggu verifikasi yang dapat disetujui.',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($request, $user, $approvalNote): void {
|
||||
$this->applyVerificationRequest($request);
|
||||
|
||||
if ($approvalNote !== null && trim($approvalNote) !== '') {
|
||||
$request->rejection()->create([
|
||||
'reason' => trim($approvalNote),
|
||||
'rejected_by_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$request->update([
|
||||
'status' => OwnerVerificationStatus::APPROVED,
|
||||
'verified_by_id' => $user->id,
|
||||
'verified_at' => now(),
|
||||
]);
|
||||
|
||||
$this->clearVerificationRequestMedia($request);
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal menyetujui verifikasi owner: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
]);
|
||||
}
|
||||
|
||||
$subjectLabel = ModelLabel::for($request->subject_type);
|
||||
|
||||
$this->notifyRequestSubmitter(
|
||||
$request,
|
||||
'✅ Pengajuan Disetujui',
|
||||
"Pengajuan {$request->action->label()} {$subjectLabel} '{$this->requestTitle($request)}' telah disetujui owner.",
|
||||
);
|
||||
}
|
||||
|
||||
public function rejectRequest(
|
||||
OwnerVerificationRequest $request,
|
||||
User $user,
|
||||
string $reason,
|
||||
): void {
|
||||
if ($request->status !== OwnerVerificationStatus::PENDING) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => 'Hanya pengajuan yang menunggu verifikasi yang dapat ditolak.',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($request, $user, $reason): void {
|
||||
$this->rejectVerificationRequest($request);
|
||||
|
||||
$request->rejection()->create([
|
||||
'reason' => trim($reason),
|
||||
'rejected_by_id' => $user->id,
|
||||
]);
|
||||
|
||||
$request->update([
|
||||
'status' => OwnerVerificationStatus::REJECTED,
|
||||
'verified_by_id' => $user->id,
|
||||
'verified_at' => now(),
|
||||
]);
|
||||
|
||||
$this->clearVerificationRequestMedia($request);
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal menolak verifikasi owner: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
]);
|
||||
}
|
||||
|
||||
$title = $this->requestTitle($request);
|
||||
$subjectLabel = ModelLabel::for($request->subject_type);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'❌ Pengajuan Ditolak',
|
||||
"Pengajuan {$request->action->label()} {$subjectLabel} '{$title}' ditolak dengan alasan: '{$reason}'.",
|
||||
['owner', 'developer'],
|
||||
route('admin.manage.owner_verifications.index'),
|
||||
);
|
||||
|
||||
$this->notifyRequestSubmitter(
|
||||
$request,
|
||||
'❌ Pengajuan Ditolak',
|
||||
"Pengajuan {$request->action->label()} {$subjectLabel} '{$title}' ditolak dengan alasan: '{$reason}'.",
|
||||
);
|
||||
}
|
||||
|
||||
private function rejectVerificationRequest(OwnerVerificationRequest $request): void
|
||||
{
|
||||
match ($request->subject_type) {
|
||||
Product::class => $this->productService->rejectVerificationRequest($request),
|
||||
RawMaterial::class => $this->rawMaterialService->rejectVerificationRequest($request),
|
||||
Purchase::class => $this->purchaseService->rejectVerificationRequest($request),
|
||||
default => throw ValidationException::withMessages([
|
||||
'subject_type' => 'Tipe data verifikasi tidak didukung.',
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
private function applyVerificationRequest(OwnerVerificationRequest $request): void
|
||||
{
|
||||
match ($request->subject_type) {
|
||||
Product::class => $this->productService->applyVerificationRequest($request),
|
||||
RawMaterial::class => $this->rawMaterialService->applyVerificationRequest($request),
|
||||
Purchase::class => $this->purchaseService->applyVerificationRequest($request),
|
||||
default => throw ValidationException::withMessages([
|
||||
'subject_type' => 'Tipe data verifikasi tidak didukung.',
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
private function clearVerificationRequestMedia(OwnerVerificationRequest $request): void
|
||||
{
|
||||
match ($request->subject_type) {
|
||||
Product::class => $this->productService->clearVerificationRequestMedia($request),
|
||||
RawMaterial::class => $this->rawMaterialService->clearVerificationRequestMedia($request),
|
||||
Purchase::class => $this->purchaseService->clearVerificationRequestMedia($request),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function presentRequestRow(OwnerVerificationRequest $request): array
|
||||
{
|
||||
$payload = is_array($request->payload) ? $request->payload : [];
|
||||
|
||||
return [
|
||||
'id' => $request->id,
|
||||
'source' => 'request',
|
||||
'subject_type' => $request->subject_type,
|
||||
'subject_label' => ModelLabel::for($request->subject_type),
|
||||
'subject_id' => $request->subject_id,
|
||||
'action' => $request->action->value,
|
||||
'action_label' => $request->action->label(),
|
||||
'status' => $request->status->value,
|
||||
'status_label' => $request->status->label(),
|
||||
'title' => $this->requestTitle($request),
|
||||
'submitted_by_name' => $request->submittedBy?->profile?->full_name
|
||||
?? $request->submittedBy?->username
|
||||
?? '-',
|
||||
'verified_by_name' => $request->verifiedBy?->profile?->full_name
|
||||
?? $request->verifiedBy?->username,
|
||||
'verified_at_formatted' => $request->verified_at?->translatedFormat('l, d F Y H:i'),
|
||||
'created_at' => $request->created_at?->toIso8601String(),
|
||||
'created_at_formatted' => $request->created_at?->translatedFormat('l, d F Y H:i'),
|
||||
'changes' => VerificationChangeFormatter::format($payload),
|
||||
'rejection_reason' => $request->rejection?->reason,
|
||||
'is_pending' => $request->status === OwnerVerificationStatus::PENDING,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function presentCuttingRow(Cutting $cutting): array
|
||||
{
|
||||
$totalPieces = $cutting->results->sum('cutting_result');
|
||||
|
||||
return [
|
||||
'id' => $cutting->id,
|
||||
'source' => 'cutting',
|
||||
'subject_type' => Cutting::class,
|
||||
'subject_label' => ModelLabel::for(Cutting::class),
|
||||
'subject_id' => $cutting->id,
|
||||
'action' => OwnerVerificationAction::STOCK_VERIFY->value,
|
||||
'action_label' => OwnerVerificationAction::STOCK_VERIFY->label(),
|
||||
'status' => OwnerVerificationStatus::PENDING->value,
|
||||
'status_label' => OwnerVerificationStatus::PENDING->label(),
|
||||
'title' => "Cutting #{$cutting->id}",
|
||||
'summary' => $cutting->description ?? "Total hasil {$totalPieces} pcs",
|
||||
'submitted_by_name' => $cutting->submittedBy?->profile?->full_name
|
||||
?? $cutting->submittedBy?->username
|
||||
?? '-',
|
||||
'created_at' => $cutting->created_at?->toIso8601String(),
|
||||
'created_at_formatted' => $cutting->created_at?->translatedFormat('l, d F Y H:i'),
|
||||
'changes' => [],
|
||||
'is_pending' => true,
|
||||
'detail' => [
|
||||
'results' => $cutting->results,
|
||||
'result_prices' => $cutting->result_prices,
|
||||
'total_result_pieces' => $cutting->total_result_pieces,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
|
||||
*/
|
||||
private function buildVerificationRequestQuery(
|
||||
User $user,
|
||||
array $tableQuery,
|
||||
string $status,
|
||||
string $subjectType,
|
||||
string $action,
|
||||
): Builder {
|
||||
if ($action === OwnerVerificationAction::STOCK_VERIFY->value || $subjectType === Cutting::class) {
|
||||
return OwnerVerificationRequest::query()->whereRaw('0 = 1');
|
||||
}
|
||||
|
||||
$query = OwnerVerificationRequest::query()
|
||||
->visibleTo($user)
|
||||
->with([
|
||||
'subject',
|
||||
'submittedBy.profile',
|
||||
'verifiedBy.profile',
|
||||
'rejection',
|
||||
])
|
||||
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
|
||||
$search = $tableQuery['search'];
|
||||
$query->where(function (Builder $query) use ($search): void {
|
||||
$query->where('action', 'like', "%{$search}%")
|
||||
->orWhere('status', 'like', "%{$search}%")
|
||||
->orWhereHas('submittedBy', function (Builder $query) use ($search): void {
|
||||
$query->where('username', 'like', "%{$search}%")
|
||||
->orWhereHas('profile', fn (Builder $query) => $query->where('full_name', 'like', "%{$search}%"));
|
||||
})
|
||||
->orWhereHasMorph('subject', [Product::class, RawMaterial::class, Purchase::class], function (Builder $query, string $type) use ($search): void {
|
||||
if ($type === Purchase::class) {
|
||||
$query->where('notes', 'like', "%{$search}%")
|
||||
->orWhereHas('supplier', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
})
|
||||
->when($status !== '', fn (Builder $query) => $query->where('status', $status))
|
||||
->when($subjectType !== '', fn (Builder $query) => $query->where('subject_type', $subjectType))
|
||||
->when($action !== '', fn (Builder $query) => $query->where('action', $action));
|
||||
|
||||
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function shouldIncludeCuttings(
|
||||
User $user,
|
||||
string $status,
|
||||
string $subjectType,
|
||||
string $action,
|
||||
): bool {
|
||||
if (! $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($status !== '' && $status !== OwnerVerificationStatus::PENDING->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($subjectType !== '' && $subjectType !== Cutting::class) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($action !== '' && $action !== OwnerVerificationAction::STOCK_VERIFY->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Cutting::query()->pendingVerification()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array<string, mixed>> $items
|
||||
*/
|
||||
private function makePaginator(
|
||||
Collection $items,
|
||||
int $total,
|
||||
int $perPage,
|
||||
int $page,
|
||||
): LengthAwarePaginator {
|
||||
return (new Paginator(
|
||||
$items,
|
||||
$total,
|
||||
$perPage,
|
||||
$page,
|
||||
['path' => Paginator::resolveCurrentPath(), 'pageName' => 'page'],
|
||||
))->withQueryString();
|
||||
}
|
||||
|
||||
private function requestTitle(OwnerVerificationRequest $request): string
|
||||
{
|
||||
if ($request->subject instanceof Product || $request->subject instanceof RawMaterial) {
|
||||
return $request->subject->name;
|
||||
}
|
||||
|
||||
if ($request->subject instanceof Purchase) {
|
||||
$request->subject->loadMissing('supplier');
|
||||
|
||||
return $request->subject->supplier?->name ?? 'Belanja';
|
||||
}
|
||||
|
||||
$payload = is_array($request->payload) ? $request->payload : [];
|
||||
|
||||
foreach (['new', 'old'] as $key) {
|
||||
$section = $payload[$key] ?? null;
|
||||
|
||||
if (is_array($section) && isset($section['supplier_name'])) {
|
||||
return (string) $section['supplier_name'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['new', 'old'] as $key) {
|
||||
$section = $payload[$key] ?? null;
|
||||
|
||||
if (is_array($section) && isset($section['name'])) {
|
||||
return (string) $section['name'];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($payload['name'])) {
|
||||
return (string) $payload['name'];
|
||||
}
|
||||
|
||||
return 'Item Baru';
|
||||
}
|
||||
|
||||
private function notifyRequestSubmitter(
|
||||
OwnerVerificationRequest $request,
|
||||
string $title,
|
||||
string $body,
|
||||
): void {
|
||||
if (! $request->submitted_by_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$url = match ($request->subject_type) {
|
||||
Product::class => route('admin.master.products.index'),
|
||||
RawMaterial::class => route('admin.master.raw_materials.index'),
|
||||
Purchase::class => route('admin.manage.purchases.index'),
|
||||
default => route('admin.manage.owner_verifications.index'),
|
||||
};
|
||||
|
||||
$this->pushNotificationService->sendToUser(
|
||||
$title,
|
||||
$body,
|
||||
$request->submitted_by_id,
|
||||
$url,
|
||||
);
|
||||
}
|
||||
|
||||
private function applySorting(Builder $query, string $sort, string $direction): void
|
||||
{
|
||||
if (in_array($sort, ['created_at', 'action', 'status'], true)) {
|
||||
$query->orderBy($sort, $direction);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->latest();
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Services\Manage;
|
||||
|
||||
use App\Enums\OwnerVerificationAction;
|
||||
use App\Enums\OwnerVerificationStatus;
|
||||
use App\Models\OwnerVerificationRequest;
|
||||
use App\Models\Purchase;
|
||||
use App\Models\PurchaseItem;
|
||||
use App\Models\RawMaterial;
|
||||
@ -37,6 +40,7 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator
|
||||
->with([
|
||||
'supplier:id,name',
|
||||
'createdBy.profile',
|
||||
'pendingOwnerVerificationRequest',
|
||||
'items.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'media',
|
||||
])
|
||||
@ -63,6 +67,12 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator
|
||||
MediaPresenter::first($purchase, 'photos'),
|
||||
);
|
||||
|
||||
$pendingRequest = $purchase->pendingOwnerVerificationRequest;
|
||||
|
||||
$purchase->setAttribute('has_pending_request', $pendingRequest !== null);
|
||||
$purchase->setAttribute('pending_request_action', $pendingRequest?->action->value);
|
||||
$purchase->setAttribute('pending_request_action_label', $pendingRequest?->action->label());
|
||||
|
||||
return $purchase;
|
||||
});
|
||||
}
|
||||
@ -242,17 +252,30 @@ public function create(array $validated, User $user): Purchase
|
||||
$item->update([
|
||||
'purchase_id' => $purchase->id,
|
||||
]);
|
||||
$this->incrementStock($item);
|
||||
}
|
||||
|
||||
$this->syncPhotos($purchase, $validated);
|
||||
|
||||
$purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']);
|
||||
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::CREATE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Purchase::class,
|
||||
'subject_id' => $purchase->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => null,
|
||||
'new' => $this->snapshotPurchase($purchase),
|
||||
],
|
||||
]);
|
||||
|
||||
return $purchase;
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal membuat pembelian: '.$e->getMessage(), [
|
||||
Log::error('Gagal mengajukan pembelian: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
@ -261,11 +284,10 @@ public function create(array $validated, User $user): Purchase
|
||||
]);
|
||||
}
|
||||
|
||||
$purchase->load('supplier');
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🛒 Belanja Baru',
|
||||
"Pembelian dari supplier {$purchase->supplier->name} senilai {$purchase->total_formatted} telah ditambahkan oleh {$user->profile?->full_name}.",
|
||||
['owner', 'developer'],
|
||||
$this->notifyForPendingRequest(
|
||||
$user,
|
||||
'Tambah Belanja',
|
||||
"Pengajuan belanja dari supplier {$purchase->supplier->name} senilai {$purchase->total_formatted} menunggu verifikasi owner.",
|
||||
route('admin.manage.purchases.index'),
|
||||
);
|
||||
|
||||
@ -275,44 +297,32 @@ public function create(array $validated, User $user): Purchase
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
public function update(Purchase $purchase, array $validated): void
|
||||
public function update(Purchase $purchase, array $validated, User $user): void
|
||||
{
|
||||
$purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($purchase, $validated): void {
|
||||
$purchase->load('items');
|
||||
|
||||
foreach ($purchase->items as $item) {
|
||||
$this->decrementStock($item);
|
||||
}
|
||||
|
||||
$purchase->items()->delete();
|
||||
|
||||
$lineItems = $this->buildLineItems($validated['items']);
|
||||
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
|
||||
$discount = (int) ($validated['discount'] ?? 0);
|
||||
$shippingCost = (int) ($validated['shipping_cost'] ?? 0);
|
||||
$total = max($subtotal - $discount + $shippingCost, 0);
|
||||
|
||||
$purchase->update([
|
||||
'supplier_id' => $validated['supplier_id'],
|
||||
'subtotal' => $subtotal,
|
||||
'discount' => $discount,
|
||||
'shipping_cost' => $shippingCost,
|
||||
'total' => $total,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
DB::transaction(function () use ($purchase, $validated, $user): void {
|
||||
$verificationRequest = OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::UPDATE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Purchase::class,
|
||||
'subject_id' => $purchase->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => $this->snapshotPurchase($purchase),
|
||||
'new' => $this->buildPayloadFromValidated($validated),
|
||||
],
|
||||
]);
|
||||
|
||||
foreach ($lineItems as $itemData) {
|
||||
$purchaseItem = $purchase->items()->create($itemData);
|
||||
$this->incrementStock($purchaseItem);
|
||||
if (($validated['photos'] ?? null) !== null || ($validated['remove_media_ids'] ?? []) !== []) {
|
||||
$this->syncRequestPhotos($verificationRequest, $validated);
|
||||
}
|
||||
|
||||
$this->syncPhotos($purchase, $validated);
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal memperbarui pembelian: '.$e->getMessage(), [
|
||||
Log::error('Gagal mengajukan perubahan belanja: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
@ -322,34 +332,33 @@ public function update(Purchase $purchase, array $validated): void
|
||||
}
|
||||
|
||||
$purchase->load('supplier');
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'✏️ Belanja Diperbarui',
|
||||
"Pembelian dari supplier {$purchase->supplier->name} senilai {$purchase->total_formatted} telah diperbarui.",
|
||||
['owner', 'developer'],
|
||||
|
||||
$this->notifyForPendingRequest(
|
||||
$user,
|
||||
'Ubah Belanja',
|
||||
"Pengajuan ubah belanja dari supplier {$purchase->supplier->name} menunggu verifikasi owner.",
|
||||
route('admin.manage.purchases.index'),
|
||||
);
|
||||
}
|
||||
|
||||
public function delete(Purchase $purchase): void
|
||||
public function delete(Purchase $purchase, User $user): void
|
||||
{
|
||||
$supplierName = $purchase->supplier->name;
|
||||
$purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($purchase): void {
|
||||
$purchase->load('items');
|
||||
|
||||
foreach ($purchase->items as $item) {
|
||||
$this->decrementStock($item);
|
||||
}
|
||||
|
||||
$purchase->clearMediaCollection('photos');
|
||||
$purchase->items()->delete();
|
||||
$purchase->delete();
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::DELETE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Purchase::class,
|
||||
'subject_id' => $purchase->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => $this->snapshotPurchase($purchase),
|
||||
'new' => null,
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal menghapus pembelian: '.$e->getMessage(), [
|
||||
Log::error('Gagal mengajukan penghapusan belanja: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
@ -358,14 +367,87 @@ public function delete(Purchase $purchase): void
|
||||
]);
|
||||
}
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🗑️ Belanja Dihapus',
|
||||
"Pembelian dari supplier {$supplierName} senilai {$purchase->total_formatted} telah dihapus.",
|
||||
['owner', 'developer'],
|
||||
$purchase->load('supplier');
|
||||
|
||||
$this->notifyForPendingRequest(
|
||||
$user,
|
||||
'Hapus Belanja',
|
||||
"Pengajuan hapus belanja dari supplier {$purchase->supplier->name} menunggu verifikasi owner.",
|
||||
route('admin.manage.purchases.index'),
|
||||
);
|
||||
}
|
||||
|
||||
public function applyVerificationRequest(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
match ($verificationRequest->action) {
|
||||
OwnerVerificationAction::CREATE => $this->applyCreate($verificationRequest),
|
||||
OwnerVerificationAction::UPDATE => $this->applyUpdate($verificationRequest),
|
||||
OwnerVerificationAction::DELETE => $this->applyDelete($verificationRequest),
|
||||
default => throw ValidationException::withMessages([
|
||||
'action' => 'Aksi verifikasi belanja tidak didukung.',
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
public function rejectVerificationRequest(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
match ($verificationRequest->action) {
|
||||
OwnerVerificationAction::CREATE => $this->rejectCreate($verificationRequest),
|
||||
OwnerVerificationAction::UPDATE, OwnerVerificationAction::DELETE => null,
|
||||
default => throw ValidationException::withMessages([
|
||||
'action' => 'Aksi verifikasi belanja tidak didukung.',
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$verificationRequest->clearMediaCollection('photos');
|
||||
}
|
||||
|
||||
public function applyCreate(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$purchase = $verificationRequest->subject;
|
||||
|
||||
if (! $purchase instanceof Purchase) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'Belanja tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
|
||||
$purchase->load('items');
|
||||
|
||||
foreach ($purchase->items as $item) {
|
||||
$this->incrementStock($item);
|
||||
}
|
||||
}
|
||||
|
||||
public function applyUpdate(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$purchase = $verificationRequest->subject;
|
||||
|
||||
if (! $purchase instanceof Purchase) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'Belanja tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->applyPayloadToPurchase($purchase, $this->payloadNew($verificationRequest), $verificationRequest);
|
||||
}
|
||||
|
||||
public function applyDelete(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$purchase = $verificationRequest->subject;
|
||||
|
||||
if (! $purchase instanceof Purchase) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'Belanja tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->executeDelete($purchase);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{raw_material_price_id: int, quantity: float|int|string}> $items
|
||||
* @return list<array{raw_material_price_id: int, quantity: float, unit_price: int, subtotal: int}>
|
||||
@ -455,6 +537,230 @@ private function decrementStock(PurchaseItem $item): void
|
||||
->decrement('stock', $item->quantity);
|
||||
}
|
||||
|
||||
private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl): void
|
||||
{
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
"📦 {$typeLabel} Menunggu Persetujuan Owner",
|
||||
$body,
|
||||
['owner', 'developer'],
|
||||
route('admin.manage.owner_verifications.index'),
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToUser(
|
||||
'📤 Pengajuan Terkirim',
|
||||
$body,
|
||||
$user->id,
|
||||
$submitterUrl,
|
||||
);
|
||||
}
|
||||
|
||||
private function rejectCreate(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$purchase = $verificationRequest->subject;
|
||||
|
||||
if (! $purchase instanceof Purchase) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($purchase): void {
|
||||
$purchase->clearMediaCollection('photos');
|
||||
$purchase->items()->delete();
|
||||
$purchase->delete();
|
||||
});
|
||||
}
|
||||
|
||||
private function executeDelete(Purchase $purchase): void
|
||||
{
|
||||
DB::transaction(function () use ($purchase): void {
|
||||
$purchase->load('items');
|
||||
|
||||
foreach ($purchase->items as $item) {
|
||||
$this->decrementStock($item);
|
||||
}
|
||||
|
||||
$purchase->clearMediaCollection('photos');
|
||||
$purchase->items()->delete();
|
||||
$purchase->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function applyPayloadToPurchase(
|
||||
Purchase $purchase,
|
||||
array $payload,
|
||||
?OwnerVerificationRequest $verificationRequest = null,
|
||||
): void {
|
||||
DB::transaction(function () use ($purchase, $payload, $verificationRequest): void {
|
||||
$purchase->load('items');
|
||||
|
||||
foreach ($purchase->items as $item) {
|
||||
$this->decrementStock($item);
|
||||
}
|
||||
|
||||
$purchase->items()->delete();
|
||||
|
||||
foreach ($payload['items'] ?? [] as $itemData) {
|
||||
$purchaseItem = $purchase->items()->create([
|
||||
'raw_material_price_id' => $itemData['raw_material_price_id'],
|
||||
'quantity' => $itemData['quantity'],
|
||||
'unit_price' => $itemData['unit_price'],
|
||||
'subtotal' => $itemData['subtotal'],
|
||||
]);
|
||||
$this->incrementStock($purchaseItem);
|
||||
}
|
||||
|
||||
$purchase->update([
|
||||
'supplier_id' => $payload['supplier_id'],
|
||||
'subtotal' => $payload['subtotal'],
|
||||
'discount' => $payload['discount'],
|
||||
'shipping_cost' => $payload['shipping_cost'],
|
||||
'total' => $payload['total'],
|
||||
'notes' => $payload['notes'] ?? null,
|
||||
]);
|
||||
|
||||
if ($verificationRequest !== null) {
|
||||
$this->applyRequestPhotos($verificationRequest, $purchase, $payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function snapshotPurchase(Purchase $purchase): array
|
||||
{
|
||||
$purchase->load([
|
||||
'supplier:id,name',
|
||||
'items.rawMaterialPrice.rawMaterial:id,name',
|
||||
]);
|
||||
|
||||
return [
|
||||
'supplier_id' => $purchase->supplier_id,
|
||||
'supplier_name' => $purchase->supplier?->name,
|
||||
'subtotal' => $purchase->subtotal,
|
||||
'discount' => $purchase->discount,
|
||||
'shipping_cost' => $purchase->shipping_cost,
|
||||
'total' => $purchase->total,
|
||||
'notes' => $purchase->notes,
|
||||
'items' => $purchase->items
|
||||
->map(fn (PurchaseItem $item) => [
|
||||
'raw_material_price_id' => $item->raw_material_price_id,
|
||||
'raw_material_name' => $item->rawMaterialPrice?->rawMaterial?->name,
|
||||
'variant' => $item->rawMaterialPrice?->variant,
|
||||
'quantity' => (float) $item->quantity,
|
||||
'unit_price' => $item->unit_price,
|
||||
'subtotal' => $item->subtotal,
|
||||
])
|
||||
->all(),
|
||||
'has_photos' => $purchase->hasMedia('photos'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPayloadFromValidated(array $validated): array
|
||||
{
|
||||
$lineItems = $this->enrichLineItems($this->buildLineItems($validated['items']));
|
||||
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
|
||||
$discount = (int) ($validated['discount'] ?? 0);
|
||||
$shippingCost = (int) ($validated['shipping_cost'] ?? 0);
|
||||
$total = max($subtotal - $discount + $shippingCost, 0);
|
||||
$supplier = Supplier::query()->find($validated['supplier_id']);
|
||||
|
||||
return [
|
||||
'supplier_id' => (int) $validated['supplier_id'],
|
||||
'supplier_name' => $supplier?->name,
|
||||
'subtotal' => $subtotal,
|
||||
'discount' => $discount,
|
||||
'shipping_cost' => $shippingCost,
|
||||
'total' => $total,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
'items' => $lineItems,
|
||||
'remove_media_ids' => $validated['remove_media_ids'] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{raw_material_price_id: int, quantity: float, unit_price: int, subtotal: int}> $lineItems
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function enrichLineItems(array $lineItems): array
|
||||
{
|
||||
$prices = RawMaterialPrice::query()
|
||||
->with('rawMaterial:id,name')
|
||||
->whereIn('id', array_column($lineItems, 'raw_material_price_id'))
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return collect($lineItems)
|
||||
->map(function (array $item) use ($prices) {
|
||||
$price = $prices->get($item['raw_material_price_id']);
|
||||
|
||||
return array_merge($item, [
|
||||
'raw_material_name' => $price?->rawMaterial?->name,
|
||||
'variant' => $price?->variant,
|
||||
]);
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
private function syncRequestPhotos(OwnerVerificationRequest $verificationRequest, array $validated): void
|
||||
{
|
||||
$this->mediaService->syncCollection(
|
||||
$verificationRequest,
|
||||
'photos',
|
||||
$validated['photos'] ?? null,
|
||||
$validated['remove_media_ids'] ?? null,
|
||||
self::MAX_PHOTOS,
|
||||
required: false,
|
||||
errorKey: 'photos',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function applyRequestPhotos(
|
||||
OwnerVerificationRequest $verificationRequest,
|
||||
Purchase $purchase,
|
||||
array $payload,
|
||||
): void {
|
||||
if ($verificationRequest->hasMedia('photos')) {
|
||||
$purchase->clearMediaCollection('photos');
|
||||
|
||||
foreach ($verificationRequest->getMedia('photos') as $media) {
|
||||
$media->copy($purchase, 'photos');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($payload['remove_media_ids'] ?? [] as $mediaId) {
|
||||
$purchase->deleteMedia((int) $mediaId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function payloadNew(OwnerVerificationRequest $verificationRequest): array
|
||||
{
|
||||
$payload = $verificationRequest->payload ?? [];
|
||||
|
||||
if (array_key_exists('new', $payload)) {
|
||||
return is_array($payload['new']) ? $payload['new'] : [];
|
||||
}
|
||||
|
||||
return is_array($payload) ? $payload : [];
|
||||
}
|
||||
|
||||
private function applySorting(Builder $query, string $sort, string $direction): void
|
||||
{
|
||||
if (in_array($sort, ['created_at', 'total', 'discount', 'subtotal'], true)) {
|
||||
|
||||
@ -53,6 +53,7 @@ public function getPendingApprovalCuttings(User $user): Collection
|
||||
return Cutting::query()
|
||||
->with([
|
||||
'createdBy.profile',
|
||||
'submittedBy.profile',
|
||||
'rejection.rejectedBy.profile',
|
||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'results.productVariant.product:id,name',
|
||||
|
||||
@ -2,9 +2,15 @@
|
||||
|
||||
namespace App\Services\Master;
|
||||
|
||||
use App\Enums\OwnerVerificationAction;
|
||||
use App\Enums\OwnerVerificationStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\OwnerVerificationRequest;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\User;
|
||||
use App\Services\Media\MediaService;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use App\Support\Media\MediaPresenter;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@ -18,6 +24,7 @@ class ProductService
|
||||
|
||||
public function __construct(
|
||||
private readonly MediaService $mediaService,
|
||||
private readonly PushNotificationService $pushNotificationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -28,6 +35,7 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
|
||||
$query = Product::query()
|
||||
->with([
|
||||
'categories',
|
||||
'pendingOwnerVerificationRequest',
|
||||
'variants' => fn ($query) => $query
|
||||
->with(['media', 'prices' => fn ($query) => $query->orderBy('type')])
|
||||
->orderBy('created_at'),
|
||||
@ -66,6 +74,16 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
|
||||
);
|
||||
});
|
||||
|
||||
$pendingRequest = $product->pendingOwnerVerificationRequest;
|
||||
|
||||
$product->setAttribute('has_pending_request', $pendingRequest !== null);
|
||||
$product->setAttribute('pending_request_action', $pendingRequest?->action->value);
|
||||
$product->setAttribute('pending_request_action_label', $pendingRequest?->action->label());
|
||||
$product->setAttribute(
|
||||
'display_is_active',
|
||||
$pendingRequest?->pendingToggleIsActive() ?? $product->is_active,
|
||||
);
|
||||
|
||||
return $product;
|
||||
});
|
||||
}
|
||||
@ -73,26 +91,43 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
public function create(array $validated): void
|
||||
public function create(array $validated, User $user): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($validated): void {
|
||||
DB::transaction(function () use ($validated, $user): void {
|
||||
$product = Product::create([
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'is_active' => true,
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
$product->categories()->sync($validated['category_ids']);
|
||||
$product->categories()->sync($validated['category_ids'] ?? []);
|
||||
|
||||
foreach ($validated['variants'] as $index => $variantData) {
|
||||
$this->createVariant($product, $variantData, $index);
|
||||
$variant = $product->variants()->create([
|
||||
'name' => $variantData['name'],
|
||||
'stock' => $variantData['stock'],
|
||||
]);
|
||||
|
||||
$this->syncVariantImages($variant, $variantData, $index);
|
||||
}
|
||||
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::CREATE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Product::class,
|
||||
'subject_id' => $product->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => null,
|
||||
'new' => $this->snapshotProduct($product->fresh(['categories', 'variants'])),
|
||||
],
|
||||
]);
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal membuat produk: '.$e->getMessage(), [
|
||||
Log::error('Gagal mengajukan pembuatan produk: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
@ -100,55 +135,43 @@ public function create(array $validated): void
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->notifyForPendingRequest(
|
||||
$user,
|
||||
'Tambah Produk',
|
||||
"Pengajuan tambah produk '{$validated['name']}' menunggu verifikasi owner.",
|
||||
route('admin.master.products.index'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
public function update(Product $product, array $validated): void
|
||||
public function update(Product $product, array $validated, User $user): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($validated, $product): void {
|
||||
$product->update([
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
DB::transaction(function () use ($validated, $product, $user): void {
|
||||
$verificationRequest = OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::UPDATE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Product::class,
|
||||
'subject_id' => $product->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => $this->snapshotProduct($product),
|
||||
'new' => $this->enrichPayload($this->buildPayloadFromValidated($validated)),
|
||||
],
|
||||
]);
|
||||
|
||||
$product->categories()->sync($validated['category_ids']);
|
||||
|
||||
$submittedVariantIds = collect($validated['variants'])
|
||||
->pluck('id')
|
||||
->filter()
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
$product->variants()
|
||||
->whereNotIn('id', $submittedVariantIds)
|
||||
->get()
|
||||
->each(function (ProductVariant $variant): void {
|
||||
$variant->clearMediaCollection('images');
|
||||
$variant->delete();
|
||||
});
|
||||
|
||||
foreach ($validated['variants'] as $index => $variantData) {
|
||||
if (! empty($variantData['id'])) {
|
||||
$variant = $product->variants()->findOrFail($variantData['id']);
|
||||
$variant->update([
|
||||
'name' => $variantData['name'],
|
||||
'stock' => $variantData['stock'],
|
||||
]);
|
||||
$this->syncVariantImages($variant, $variantData, $index);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->createVariant($product, $variantData, $index);
|
||||
$isNewVariant = empty($variantData['id']);
|
||||
$this->syncRequestVariantImages($verificationRequest, $variantData, $index, required: $isNewVariant);
|
||||
}
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal memperbarui produk: '.$e->getMessage(), [
|
||||
Log::error('Gagal mengajukan perubahan produk: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
@ -156,37 +179,463 @@ public function update(Product $product, array $validated): void
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->notifyForPendingRequest(
|
||||
$user,
|
||||
'Ubah Produk',
|
||||
"Pengajuan ubah produk '{$product->name}' menunggu verifikasi owner.",
|
||||
route('admin.master.products.index'),
|
||||
);
|
||||
}
|
||||
|
||||
public function toggleStatus(Product $product, array $validated): void
|
||||
public function delete(Product $product, User $user): void
|
||||
{
|
||||
try {
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::DELETE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Product::class,
|
||||
'subject_id' => $product->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => $this->snapshotProduct($product),
|
||||
'new' => null,
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal mengajukan penghapusan produk: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->notifyForPendingRequest(
|
||||
$user,
|
||||
'Hapus Produk',
|
||||
"Pengajuan hapus produk '{$product->name}' menunggu verifikasi owner.",
|
||||
route('admin.master.products.index'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
public function toggleStatus(Product $product, array $validated, User $user): void
|
||||
{
|
||||
try {
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::TOGGLE_STATUS,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Product::class,
|
||||
'subject_id' => $product->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => [
|
||||
'name' => $product->name,
|
||||
'is_active' => $product->is_active,
|
||||
],
|
||||
'new' => [
|
||||
'name' => $product->name,
|
||||
'is_active' => (bool) $validated['is_active'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal mengajukan perubahan status produk: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
]);
|
||||
}
|
||||
|
||||
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif';
|
||||
|
||||
$this->notifyForPendingRequest(
|
||||
$user,
|
||||
'Ubah Status Produk',
|
||||
"Pengajuan ubah status produk '{$product->name}' menjadi {$statusLabel} menunggu verifikasi owner.",
|
||||
route('admin.master.products.index'),
|
||||
);
|
||||
}
|
||||
|
||||
public function applyVerificationRequest(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
match ($verificationRequest->action) {
|
||||
OwnerVerificationAction::CREATE => $this->applyCreate($verificationRequest),
|
||||
OwnerVerificationAction::UPDATE => $this->applyUpdate($verificationRequest),
|
||||
OwnerVerificationAction::DELETE => $this->applyDelete($verificationRequest),
|
||||
OwnerVerificationAction::TOGGLE_STATUS => $this->applyToggleStatus($verificationRequest),
|
||||
default => throw ValidationException::withMessages([
|
||||
'action' => 'Aksi verifikasi produk tidak didukung.',
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
public function rejectVerificationRequest(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
match ($verificationRequest->action) {
|
||||
OwnerVerificationAction::CREATE => $this->rejectCreate($verificationRequest),
|
||||
OwnerVerificationAction::UPDATE => $this->rollbackUpdate($verificationRequest),
|
||||
OwnerVerificationAction::DELETE, OwnerVerificationAction::TOGGLE_STATUS => null,
|
||||
default => throw ValidationException::withMessages([
|
||||
'action' => 'Aksi verifikasi produk tidak didukung.',
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$verificationRequest->load('media');
|
||||
|
||||
$newPayload = $this->payloadNew($verificationRequest);
|
||||
|
||||
foreach ($newPayload['variants'] ?? [] as $index => $_variant) {
|
||||
$verificationRequest->clearMediaCollection($verificationRequest->variantImageCollection((int) $index));
|
||||
}
|
||||
}
|
||||
|
||||
public function applyCreate(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$product = $verificationRequest->subject;
|
||||
|
||||
if (! $product instanceof Product) {
|
||||
throw ValidationException::withMessages([
|
||||
'product' => 'Produk tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
|
||||
$product->update([
|
||||
'is_active' => $validated['is_active'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(Product $product): void
|
||||
public function applyUpdate(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($product): void {
|
||||
$product->variants()->each(function (ProductVariant $variant): void {
|
||||
$variant->clearMediaCollection('images');
|
||||
});
|
||||
$product->variants()->delete();
|
||||
$product->categories()->detach();
|
||||
$product->delete();
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal menghapus produk: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
$product = $verificationRequest->subject;
|
||||
|
||||
if (! $product instanceof Product) {
|
||||
throw ValidationException::withMessages([
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
'product' => 'Produk tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->applyPayloadToProduct($product, $this->payloadNew($verificationRequest), $verificationRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function applyPayloadToProduct(
|
||||
Product $product,
|
||||
array $payload,
|
||||
?OwnerVerificationRequest $verificationRequest = null,
|
||||
): void {
|
||||
$product->update([
|
||||
'name' => $payload['name'],
|
||||
'description' => $payload['description'] ?? null,
|
||||
]);
|
||||
|
||||
if (array_key_exists('is_active', $payload)) {
|
||||
$product->update([
|
||||
'is_active' => (bool) $payload['is_active'],
|
||||
]);
|
||||
}
|
||||
|
||||
$product->categories()->sync($payload['category_ids'] ?? []);
|
||||
|
||||
$submittedVariantIds = collect($payload['variants'] ?? [])
|
||||
->pluck('id')
|
||||
->filter()
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
$product->variants()
|
||||
->whereNotIn('id', $submittedVariantIds)
|
||||
->get()
|
||||
->each(function (ProductVariant $variant): void {
|
||||
$variant->clearMediaCollection('images');
|
||||
$variant->delete();
|
||||
});
|
||||
|
||||
foreach ($payload['variants'] ?? [] as $index => $variantData) {
|
||||
if (! empty($variantData['id'])) {
|
||||
$variant = $product->variants()->findOrFail($variantData['id']);
|
||||
$variant->update([
|
||||
'name' => $variantData['name'],
|
||||
'stock' => $variantData['stock'],
|
||||
]);
|
||||
|
||||
if ($verificationRequest !== null) {
|
||||
$this->applyVariantImageChanges($verificationRequest, $variant, $variantData, (int) $index);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$variant = $product->variants()->create([
|
||||
'name' => $variantData['name'],
|
||||
'stock' => $variantData['stock'],
|
||||
]);
|
||||
|
||||
if ($verificationRequest !== null) {
|
||||
$this->copyRequestVariantImages($verificationRequest, (int) $index, $variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function applyDelete(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$product = $verificationRequest->subject;
|
||||
|
||||
if (! $product instanceof Product) {
|
||||
throw ValidationException::withMessages([
|
||||
'product' => 'Produk tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($product): void {
|
||||
$product->variants()->each(function (ProductVariant $variant): void {
|
||||
$variant->clearMediaCollection('images');
|
||||
});
|
||||
$product->variants()->delete();
|
||||
$product->categories()->detach();
|
||||
$product->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$product = $verificationRequest->subject;
|
||||
|
||||
if (! $product instanceof Product) {
|
||||
throw ValidationException::withMessages([
|
||||
'product' => 'Produk tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
|
||||
$newPayload = $this->payloadNew($verificationRequest);
|
||||
|
||||
$product->update([
|
||||
'is_active' => (bool) ($newPayload['is_active'] ?? false),
|
||||
]);
|
||||
}
|
||||
|
||||
private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl): void
|
||||
{
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
"📦 {$typeLabel} Menunggu Persetujuan Owner",
|
||||
$body,
|
||||
['owner', 'developer'],
|
||||
route('admin.manage.owner_verifications.index'),
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToUser(
|
||||
'📤 Pengajuan Terkirim',
|
||||
$body,
|
||||
$user->id,
|
||||
$submitterUrl,
|
||||
);
|
||||
}
|
||||
|
||||
private function rejectCreate(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$product = $verificationRequest->subject;
|
||||
|
||||
if (! $product instanceof Product) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($product): void {
|
||||
$product->variants()->each(function (ProductVariant $variant): void {
|
||||
$variant->clearMediaCollection('images');
|
||||
});
|
||||
$product->variants()->delete();
|
||||
$product->categories()->detach();
|
||||
$product->delete();
|
||||
});
|
||||
}
|
||||
|
||||
private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$product = $verificationRequest->subject;
|
||||
|
||||
if (! $product instanceof Product) {
|
||||
throw ValidationException::withMessages([
|
||||
'product' => 'Produk tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
|
||||
$oldPayload = $this->payloadOld($verificationRequest);
|
||||
|
||||
if ($oldPayload === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applyPayloadToProduct($product, $oldPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function payloadOld(OwnerVerificationRequest $verificationRequest): array
|
||||
{
|
||||
$payload = $verificationRequest->payload ?? [];
|
||||
|
||||
if (array_key_exists('old', $payload)) {
|
||||
return is_array($payload['old']) ? $payload['old'] : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function payloadNew(OwnerVerificationRequest $verificationRequest): array
|
||||
{
|
||||
$payload = $verificationRequest->payload ?? [];
|
||||
|
||||
if (array_key_exists('new', $payload)) {
|
||||
return is_array($payload['new']) ? $payload['new'] : [];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function snapshotProduct(Product $product): array
|
||||
{
|
||||
$product->load(['categories', 'variants']);
|
||||
|
||||
return $this->enrichPayload([
|
||||
'name' => $product->name,
|
||||
'description' => $product->description,
|
||||
'category_ids' => $product->categories->pluck('id')->all(),
|
||||
'is_active' => $product->is_active,
|
||||
'variants' => $product->variants
|
||||
->map(fn (ProductVariant $variant) => [
|
||||
'id' => $variant->id,
|
||||
'name' => $variant->name,
|
||||
'stock' => $variant->stock,
|
||||
])
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function enrichPayload(array $data): array
|
||||
{
|
||||
$categoryIds = $data['category_ids'] ?? [];
|
||||
|
||||
$data['category_names'] = $categoryIds === []
|
||||
? []
|
||||
: Category::query()->whereIn('id', $categoryIds)->pluck('name')->all();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPayloadFromValidated(array $validated): array
|
||||
{
|
||||
return [
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'category_ids' => $validated['category_ids'] ?? [],
|
||||
'variants' => collect($validated['variants'] ?? [])
|
||||
->map(fn (array $variantData) => [
|
||||
'id' => $variantData['id'] ?? null,
|
||||
'name' => $variantData['name'],
|
||||
'stock' => $variantData['stock'],
|
||||
'remove_media_ids' => $variantData['remove_media_ids'] ?? [],
|
||||
])
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $variantData
|
||||
*/
|
||||
private function syncVariantImages(
|
||||
ProductVariant $variant,
|
||||
array $variantData,
|
||||
int $index,
|
||||
): void {
|
||||
$this->mediaService->syncCollection(
|
||||
$variant,
|
||||
'images',
|
||||
$variantData['images'] ?? null,
|
||||
$variantData['remove_media_ids'] ?? null,
|
||||
self::MAX_VARIANT_IMAGES,
|
||||
required: true,
|
||||
errorKey: "variants.{$index}.images",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $variantData
|
||||
*/
|
||||
private function syncRequestVariantImages(
|
||||
OwnerVerificationRequest $verificationRequest,
|
||||
array $variantData,
|
||||
int $index,
|
||||
bool $required = true,
|
||||
): void {
|
||||
$collection = $verificationRequest->variantImageCollection($index);
|
||||
|
||||
$this->mediaService->syncCollection(
|
||||
$verificationRequest,
|
||||
$collection,
|
||||
$variantData['images'] ?? null,
|
||||
$variantData['remove_media_ids'] ?? null,
|
||||
self::MAX_VARIANT_IMAGES,
|
||||
required: $required,
|
||||
errorKey: "variants.{$index}.images",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $variantData
|
||||
*/
|
||||
private function applyVariantImageChanges(
|
||||
OwnerVerificationRequest $verificationRequest,
|
||||
ProductVariant $variant,
|
||||
array $variantData,
|
||||
int $index,
|
||||
): void {
|
||||
$removeIds = $variantData['remove_media_ids'] ?? [];
|
||||
|
||||
if ($removeIds !== []) {
|
||||
$variant->getMedia('images')
|
||||
->whereIn('id', $removeIds)
|
||||
->each->delete();
|
||||
}
|
||||
|
||||
$this->copyRequestVariantImages($verificationRequest, $index, $variant);
|
||||
|
||||
if ($variant->getMedia('images')->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.images" => 'Foto varian wajib diisi.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function copyRequestVariantImages(OwnerVerificationRequest $verificationRequest, int $index, ProductVariant $variant): void
|
||||
{
|
||||
$verificationRequest->getMedia($verificationRequest->variantImageCollection($index))
|
||||
->each(fn ($media) => $media->copy($variant, 'images'));
|
||||
}
|
||||
|
||||
private function applySorting(Builder $query, string $sort, string $direction): void
|
||||
@ -199,35 +648,4 @@ private function applySorting(Builder $query, string $sort, string $direction):
|
||||
|
||||
$query->latest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $variantData
|
||||
*/
|
||||
private function createVariant(Product $product, array $variantData, int $index): ProductVariant
|
||||
{
|
||||
$variant = $product->variants()->create([
|
||||
'name' => $variantData['name'],
|
||||
'stock' => $variantData['stock'],
|
||||
]);
|
||||
|
||||
$this->syncVariantImages($variant, $variantData, $index);
|
||||
|
||||
return $variant;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $variantData
|
||||
*/
|
||||
private function syncVariantImages(ProductVariant $variant, array $variantData, int $index): void
|
||||
{
|
||||
$this->mediaService->syncCollection(
|
||||
$variant,
|
||||
'images',
|
||||
$variantData['images'] ?? null,
|
||||
$variantData['remove_media_ids'] ?? null,
|
||||
self::MAX_VARIANT_IMAGES,
|
||||
required: true,
|
||||
errorKey: "variants.{$index}.images",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,10 +2,15 @@
|
||||
|
||||
namespace App\Services\Master;
|
||||
|
||||
use App\Enums\OwnerVerificationAction;
|
||||
use App\Enums\OwnerVerificationStatus;
|
||||
use App\Enums\RawMaterialUnit;
|
||||
use App\Models\OwnerVerificationRequest;
|
||||
use App\Models\RawMaterial;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\User;
|
||||
use App\Services\Media\MediaService;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use App\Support\Media\MediaPresenter;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@ -19,6 +24,7 @@ class RawMaterialService
|
||||
|
||||
public function __construct(
|
||||
private readonly MediaService $mediaService,
|
||||
private readonly PushNotificationService $pushNotificationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -28,6 +34,7 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $st
|
||||
{
|
||||
$query = RawMaterial::query()
|
||||
->with([
|
||||
'pendingOwnerVerificationRequest',
|
||||
'prices' => fn ($query) => $query
|
||||
->orderBy('created_at')
|
||||
->with(['rawMaterial:id,unit', 'media']),
|
||||
@ -75,6 +82,16 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $st
|
||||
);
|
||||
});
|
||||
|
||||
$pendingRequest = $rawMaterial->pendingOwnerVerificationRequest;
|
||||
|
||||
$rawMaterial->setAttribute('has_pending_request', $pendingRequest !== null);
|
||||
$rawMaterial->setAttribute('pending_request_action', $pendingRequest?->action->value);
|
||||
$rawMaterial->setAttribute('pending_request_action_label', $pendingRequest?->action->label());
|
||||
$rawMaterial->setAttribute(
|
||||
'display_is_active',
|
||||
$pendingRequest?->pendingToggleIsActive() ?? $rawMaterial->is_active,
|
||||
);
|
||||
|
||||
return $rawMaterial;
|
||||
});
|
||||
}
|
||||
@ -82,23 +99,36 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $st
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
public function create(array $validated): void
|
||||
public function create(array $validated, User $user): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($validated): void {
|
||||
DB::transaction(function () use ($validated, $user): void {
|
||||
$rawMaterial = RawMaterial::create([
|
||||
'name' => $validated['name'],
|
||||
'unit' => $validated['unit'],
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
foreach ($validated['prices'] as $index => $priceData) {
|
||||
$this->createPrice($rawMaterial, $priceData, $index);
|
||||
}
|
||||
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::CREATE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => RawMaterial::class,
|
||||
'subject_id' => $rawMaterial->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => null,
|
||||
'new' => $this->snapshotRawMaterial($rawMaterial->fresh(['prices'])),
|
||||
],
|
||||
]);
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal membuat bahan baku: '.$e->getMessage(), [
|
||||
Log::error('Gagal mengajukan pembuatan bahan baku: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
@ -106,53 +136,43 @@ public function create(array $validated): void
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->notifyForPendingRequest(
|
||||
$user,
|
||||
'Tambah Bahan Baku',
|
||||
"Pengajuan tambah bahan baku '{$validated['name']}' menunggu verifikasi owner.",
|
||||
route('admin.master.raw_materials.index'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
public function update(RawMaterial $rawMaterial, array $validated): void
|
||||
public function update(RawMaterial $rawMaterial, array $validated, User $user): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($validated, $rawMaterial): void {
|
||||
$rawMaterial->update([
|
||||
'name' => $validated['name'],
|
||||
DB::transaction(function () use ($validated, $rawMaterial, $user): void {
|
||||
$verificationRequest = OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::UPDATE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => RawMaterial::class,
|
||||
'subject_id' => $rawMaterial->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => $this->snapshotRawMaterial($rawMaterial),
|
||||
'new' => $this->enrichPayload($this->buildPayloadFromValidated($validated)),
|
||||
],
|
||||
]);
|
||||
|
||||
$submittedPriceIds = collect($validated['prices'])
|
||||
->pluck('id')
|
||||
->filter()
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
$rawMaterial->prices()
|
||||
->whereNotIn('id', $submittedPriceIds)
|
||||
->get()
|
||||
->each(function (RawMaterialPrice $price): void {
|
||||
$price->clearMediaCollection('images');
|
||||
$price->delete();
|
||||
});
|
||||
|
||||
foreach ($validated['prices'] as $index => $priceData) {
|
||||
if (! empty($priceData['id'])) {
|
||||
$price = $rawMaterial->prices()->findOrFail($priceData['id']);
|
||||
$price->update([
|
||||
'variant' => $priceData['variant'],
|
||||
'price' => $priceData['price'],
|
||||
'stock' => $priceData['stock'],
|
||||
]);
|
||||
$this->syncPriceImages($price, $priceData, $index);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->createPrice($rawMaterial, $priceData, $index);
|
||||
$isNewPrice = empty($priceData['id']);
|
||||
$this->syncRequestPriceImages($verificationRequest, $priceData, $index, required: $isNewPrice);
|
||||
}
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal memperbarui bahan baku: '.$e->getMessage(), [
|
||||
Log::error('Gagal mengajukan perubahan bahan baku: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
@ -160,36 +180,391 @@ public function update(RawMaterial $rawMaterial, array $validated): void
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->notifyForPendingRequest(
|
||||
$user,
|
||||
'Ubah Bahan Baku',
|
||||
"Pengajuan ubah bahan baku '{$rawMaterial->name}' menunggu verifikasi owner.",
|
||||
route('admin.master.raw_materials.index'),
|
||||
);
|
||||
}
|
||||
|
||||
public function toggleStatus(RawMaterial $rawMaterial, array $validated): void
|
||||
public function delete(RawMaterial $rawMaterial, User $user): void
|
||||
{
|
||||
try {
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::DELETE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => RawMaterial::class,
|
||||
'subject_id' => $rawMaterial->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => $this->snapshotRawMaterial($rawMaterial),
|
||||
'new' => null,
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal mengajukan penghapusan bahan baku: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->notifyForPendingRequest(
|
||||
$user,
|
||||
'Hapus Bahan Baku',
|
||||
"Pengajuan hapus bahan baku '{$rawMaterial->name}' menunggu verifikasi owner.",
|
||||
route('admin.master.raw_materials.index'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
public function toggleStatus(RawMaterial $rawMaterial, array $validated, User $user): void
|
||||
{
|
||||
try {
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::TOGGLE_STATUS,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => RawMaterial::class,
|
||||
'subject_id' => $rawMaterial->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => [
|
||||
'name' => $rawMaterial->name,
|
||||
'is_active' => $rawMaterial->is_active,
|
||||
],
|
||||
'new' => [
|
||||
'name' => $rawMaterial->name,
|
||||
'is_active' => (bool) $validated['is_active'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal mengajukan perubahan status bahan baku: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
]);
|
||||
}
|
||||
|
||||
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif';
|
||||
|
||||
$this->notifyForPendingRequest(
|
||||
$user,
|
||||
'Ubah Status Bahan Baku',
|
||||
"Pengajuan ubah status bahan baku '{$rawMaterial->name}' menjadi {$statusLabel} menunggu verifikasi owner.",
|
||||
route('admin.master.raw_materials.index'),
|
||||
);
|
||||
}
|
||||
|
||||
public function applyVerificationRequest(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
match ($verificationRequest->action) {
|
||||
OwnerVerificationAction::CREATE => $this->applyCreate($verificationRequest),
|
||||
OwnerVerificationAction::UPDATE => $this->applyUpdate($verificationRequest),
|
||||
OwnerVerificationAction::DELETE => $this->applyDelete($verificationRequest),
|
||||
OwnerVerificationAction::TOGGLE_STATUS => $this->applyToggleStatus($verificationRequest),
|
||||
default => throw ValidationException::withMessages([
|
||||
'action' => 'Aksi verifikasi bahan baku tidak didukung.',
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
public function rejectVerificationRequest(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
match ($verificationRequest->action) {
|
||||
OwnerVerificationAction::CREATE => $this->rejectCreate($verificationRequest),
|
||||
OwnerVerificationAction::UPDATE => $this->rollbackUpdate($verificationRequest),
|
||||
OwnerVerificationAction::DELETE, OwnerVerificationAction::TOGGLE_STATUS => null,
|
||||
default => throw ValidationException::withMessages([
|
||||
'action' => 'Aksi verifikasi bahan baku tidak didukung.',
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$verificationRequest->load('media');
|
||||
|
||||
$newPayload = $this->payloadNew($verificationRequest);
|
||||
|
||||
foreach ($newPayload['prices'] ?? [] as $index => $_price) {
|
||||
$verificationRequest->clearMediaCollection($verificationRequest->priceImageCollection((int) $index));
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl): void
|
||||
{
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
"📦 {$typeLabel} Menunggu Persetujuan Owner",
|
||||
$body,
|
||||
['owner', 'developer'],
|
||||
route('admin.manage.owner_verifications.index'),
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToUser(
|
||||
'📤 Pengajuan Terkirim',
|
||||
$body,
|
||||
$user->id,
|
||||
$submitterUrl,
|
||||
);
|
||||
}
|
||||
|
||||
public function applyCreate(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$rawMaterial = $verificationRequest->subject;
|
||||
|
||||
if (! $rawMaterial instanceof RawMaterial) {
|
||||
throw ValidationException::withMessages([
|
||||
'raw_material' => 'Bahan baku tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
|
||||
$rawMaterial->update([
|
||||
'is_active' => $validated['is_active'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(RawMaterial $rawMaterial): void
|
||||
public function applyUpdate(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($rawMaterial): void {
|
||||
$rawMaterial->prices()->each(function (RawMaterialPrice $price): void {
|
||||
$price->clearMediaCollection('images');
|
||||
});
|
||||
$rawMaterial->prices()->delete();
|
||||
$rawMaterial->delete();
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal menghapus bahan baku: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
$rawMaterial = $verificationRequest->subject;
|
||||
|
||||
if (! $rawMaterial instanceof RawMaterial) {
|
||||
throw ValidationException::withMessages([
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
'raw_material' => 'Bahan baku tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->applyPayloadToRawMaterial($rawMaterial, $this->payloadNew($verificationRequest), $verificationRequest);
|
||||
}
|
||||
|
||||
public function applyDelete(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$rawMaterial = $verificationRequest->subject;
|
||||
|
||||
if (! $rawMaterial instanceof RawMaterial) {
|
||||
throw ValidationException::withMessages([
|
||||
'raw_material' => 'Bahan baku tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($rawMaterial): void {
|
||||
$rawMaterial->prices()->each(function (RawMaterialPrice $price): void {
|
||||
$price->clearMediaCollection('images');
|
||||
});
|
||||
$rawMaterial->prices()->delete();
|
||||
$rawMaterial->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$rawMaterial = $verificationRequest->subject;
|
||||
|
||||
if (! $rawMaterial instanceof RawMaterial) {
|
||||
throw ValidationException::withMessages([
|
||||
'raw_material' => 'Bahan baku tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
|
||||
$newPayload = $this->payloadNew($verificationRequest);
|
||||
|
||||
$rawMaterial->update([
|
||||
'is_active' => (bool) ($newPayload['is_active'] ?? false),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function applyPayloadToRawMaterial(
|
||||
RawMaterial $rawMaterial,
|
||||
array $payload,
|
||||
?OwnerVerificationRequest $verificationRequest = null,
|
||||
): void {
|
||||
$rawMaterial->update([
|
||||
'name' => $payload['name'],
|
||||
'unit' => $payload['unit'] ?? $rawMaterial->unit,
|
||||
]);
|
||||
|
||||
if (array_key_exists('is_active', $payload)) {
|
||||
$rawMaterial->update([
|
||||
'is_active' => (bool) $payload['is_active'],
|
||||
]);
|
||||
}
|
||||
|
||||
$submittedPriceIds = collect($payload['prices'] ?? [])
|
||||
->pluck('id')
|
||||
->filter()
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
$rawMaterial->prices()
|
||||
->whereNotIn('id', $submittedPriceIds)
|
||||
->get()
|
||||
->each(function (RawMaterialPrice $price): void {
|
||||
$price->clearMediaCollection('images');
|
||||
$price->delete();
|
||||
});
|
||||
|
||||
foreach ($payload['prices'] ?? [] as $index => $priceData) {
|
||||
if (! empty($priceData['id'])) {
|
||||
$price = $rawMaterial->prices()->findOrFail($priceData['id']);
|
||||
$price->update([
|
||||
'variant' => $priceData['variant'],
|
||||
'price' => $priceData['price'],
|
||||
'stock' => $priceData['stock'],
|
||||
]);
|
||||
|
||||
if ($verificationRequest !== null) {
|
||||
$this->applyPriceImageChanges($verificationRequest, $price, $priceData, (int) $index);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$price = $rawMaterial->prices()->create([
|
||||
'variant' => $priceData['variant'],
|
||||
'price' => $priceData['price'],
|
||||
'stock' => $priceData['stock'],
|
||||
]);
|
||||
|
||||
if ($verificationRequest !== null) {
|
||||
$this->copyRequestPriceImages($verificationRequest, (int) $index, $price);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function rejectCreate(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$rawMaterial = $verificationRequest->subject;
|
||||
|
||||
if (! $rawMaterial instanceof RawMaterial) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($rawMaterial): void {
|
||||
$rawMaterial->prices()->each(function (RawMaterialPrice $price): void {
|
||||
$price->clearMediaCollection('images');
|
||||
});
|
||||
$rawMaterial->prices()->delete();
|
||||
$rawMaterial->delete();
|
||||
});
|
||||
}
|
||||
|
||||
private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void
|
||||
{
|
||||
$rawMaterial = $verificationRequest->subject;
|
||||
|
||||
if (! $rawMaterial instanceof RawMaterial) {
|
||||
throw ValidationException::withMessages([
|
||||
'raw_material' => 'Bahan baku tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
|
||||
$oldPayload = $this->payloadOld($verificationRequest);
|
||||
|
||||
if ($oldPayload === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applyPayloadToRawMaterial($rawMaterial, $oldPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function payloadOld(OwnerVerificationRequest $verificationRequest): array
|
||||
{
|
||||
$payload = $verificationRequest->payload ?? [];
|
||||
|
||||
if (array_key_exists('old', $payload)) {
|
||||
return is_array($payload['old']) ? $payload['old'] : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function payloadNew(OwnerVerificationRequest $verificationRequest): array
|
||||
{
|
||||
$payload = $verificationRequest->payload ?? [];
|
||||
|
||||
if (array_key_exists('new', $payload)) {
|
||||
return is_array($payload['new']) ? $payload['new'] : [];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function snapshotRawMaterial(RawMaterial $rawMaterial): array
|
||||
{
|
||||
$rawMaterial->load(['prices']);
|
||||
|
||||
return $this->enrichPayload([
|
||||
'name' => $rawMaterial->name,
|
||||
'unit' => $rawMaterial->unit->value,
|
||||
'is_active' => $rawMaterial->is_active,
|
||||
'prices' => $rawMaterial->prices
|
||||
->map(fn (RawMaterialPrice $price) => [
|
||||
'id' => $price->id,
|
||||
'variant' => $price->variant,
|
||||
'price' => $price->price,
|
||||
'stock' => $price->stock,
|
||||
])
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function enrichPayload(array $data): array
|
||||
{
|
||||
if (isset($data['unit'])) {
|
||||
$unit = $data['unit'] instanceof RawMaterialUnit
|
||||
? $data['unit']
|
||||
: RawMaterialUnit::from($data['unit']);
|
||||
|
||||
$data['unit'] = $unit->value;
|
||||
$data['unit_label'] = $unit->label();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPayloadFromValidated(array $validated): array
|
||||
{
|
||||
return [
|
||||
'name' => $validated['name'],
|
||||
'unit' => $validated['unit'],
|
||||
'prices' => collect($validated['prices'] ?? [])
|
||||
->map(fn (array $priceData) => [
|
||||
'id' => $priceData['id'] ?? null,
|
||||
'variant' => $priceData['variant'],
|
||||
'price' => $priceData['price'],
|
||||
'stock' => $priceData['stock'],
|
||||
'remove_media_ids' => $priceData['remove_media_ids'] ?? [],
|
||||
])
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function applySorting(Builder $query, string $sort, string $direction): void
|
||||
@ -234,4 +609,58 @@ private function syncPriceImages(RawMaterialPrice $price, array $priceData, int
|
||||
errorKey: "prices.{$index}.images",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $priceData
|
||||
*/
|
||||
private function syncRequestPriceImages(
|
||||
OwnerVerificationRequest $verificationRequest,
|
||||
array $priceData,
|
||||
int $index,
|
||||
bool $required = true,
|
||||
): void {
|
||||
$collection = $verificationRequest->priceImageCollection($index);
|
||||
|
||||
$this->mediaService->syncCollection(
|
||||
$verificationRequest,
|
||||
$collection,
|
||||
$priceData['images'] ?? null,
|
||||
$priceData['remove_media_ids'] ?? null,
|
||||
self::MAX_VARIANT_IMAGES,
|
||||
required: $required,
|
||||
errorKey: "prices.{$index}.images",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $priceData
|
||||
*/
|
||||
private function applyPriceImageChanges(
|
||||
OwnerVerificationRequest $verificationRequest,
|
||||
RawMaterialPrice $price,
|
||||
array $priceData,
|
||||
int $index,
|
||||
): void {
|
||||
$removeIds = $priceData['remove_media_ids'] ?? [];
|
||||
|
||||
if ($removeIds !== []) {
|
||||
$price->getMedia('images')
|
||||
->whereIn('id', $removeIds)
|
||||
->each->delete();
|
||||
}
|
||||
|
||||
$this->copyRequestPriceImages($verificationRequest, $index, $price);
|
||||
|
||||
if ($price->getMedia('images')->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
"prices.{$index}.images" => 'Foto varian wajib diisi.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function copyRequestPriceImages(OwnerVerificationRequest $verificationRequest, int $index, RawMaterialPrice $price): void
|
||||
{
|
||||
$verificationRequest->getMedia($verificationRequest->priceImageCollection($index))
|
||||
->each(fn ($media) => $media->copy($price, 'images'));
|
||||
}
|
||||
}
|
||||
|
||||
137
app/Support/OwnerVerification/VerificationChangeFormatter.php
Normal file
137
app/Support/OwnerVerification/VerificationChangeFormatter.php
Normal file
@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\OwnerVerification;
|
||||
|
||||
class VerificationChangeFormatter
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const HIDDEN_FIELDS = [
|
||||
'category_ids',
|
||||
'unit',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array{old?: array<string, mixed>|null, new?: array<string, mixed>|null} $payload
|
||||
* @return list<array{field: string, old: mixed, new: mixed}>
|
||||
*/
|
||||
public static function format(array $payload): array
|
||||
{
|
||||
$old = $payload['old'] ?? null;
|
||||
$new = $payload['new'] ?? null;
|
||||
|
||||
if (! is_array($old) && ! is_array($new)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$fields = array_unique(array_merge(
|
||||
array_keys(is_array($old) ? $old : []),
|
||||
array_keys(is_array($new) ? $new : []),
|
||||
));
|
||||
|
||||
$changes = [];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (in_array($field, self::HIDDEN_FIELDS, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$oldValue = is_array($old) ? ($old[$field] ?? null) : null;
|
||||
$newValue = is_array($new) ? ($new[$field] ?? null) : null;
|
||||
|
||||
if ($oldValue === $newValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$changes[] = [
|
||||
'field' => self::label((string) $field),
|
||||
'old' => self::presentValue($field, $oldValue),
|
||||
'new' => self::presentValue($field, $newValue),
|
||||
];
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
private static function label(string $field): string
|
||||
{
|
||||
return match ($field) {
|
||||
'name' => 'Nama',
|
||||
'description' => 'Deskripsi',
|
||||
'category_names' => 'Kategori',
|
||||
'supplier_name' => 'Supplier',
|
||||
'supplier_id' => 'Supplier',
|
||||
'subtotal' => 'Subtotal',
|
||||
'discount' => 'Diskon',
|
||||
'shipping_cost' => 'Ongkir',
|
||||
'total' => 'Total',
|
||||
'notes' => 'Keterangan',
|
||||
'items' => 'Item Belanja',
|
||||
'has_photos' => 'Foto Bukti',
|
||||
'unit' => 'Satuan',
|
||||
'unit_label' => 'Satuan',
|
||||
'is_active' => 'Status Aktif',
|
||||
'variants' => 'Varian',
|
||||
'prices' => 'Varian Harga',
|
||||
default => ucfirst(str_replace('_', ' ', $field)),
|
||||
};
|
||||
}
|
||||
|
||||
private static function presentValue(string $field, mixed $value): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($field === 'is_active') {
|
||||
return (bool) $value;
|
||||
}
|
||||
|
||||
if ($field === 'variants' && is_array($value)) {
|
||||
return collect($value)
|
||||
->map(function (array $variant): string {
|
||||
$name = $variant['name'] ?? '-';
|
||||
$stock = $variant['stock'] ?? 0;
|
||||
|
||||
return "{$name} (stok: {$stock})";
|
||||
})
|
||||
->implode(', ');
|
||||
}
|
||||
|
||||
if ($field === 'prices' && is_array($value)) {
|
||||
return collect($value)
|
||||
->map(function (array $price): string {
|
||||
$variant = $price['variant'] ?? '-';
|
||||
$stock = $price['stock'] ?? 0;
|
||||
$priceValue = $price['price'] ?? 0;
|
||||
|
||||
return "{$variant} (stok: {$stock}, harga: {$priceValue})";
|
||||
})
|
||||
->implode(', ');
|
||||
}
|
||||
|
||||
if ($field === 'items' && is_array($value)) {
|
||||
return collect($value)
|
||||
->map(function (array $item): string {
|
||||
$name = $item['raw_material_name'] ?? '-';
|
||||
$variant = $item['variant'] ?? '-';
|
||||
$quantity = $item['quantity'] ?? 0;
|
||||
$subtotal = $item['subtotal'] ?? 0;
|
||||
|
||||
return "{$name} / {$variant} (jumlah: {$quantity}, subtotal: {$subtotal})";
|
||||
})
|
||||
->implode('; ');
|
||||
}
|
||||
|
||||
if ($field === 'has_photos') {
|
||||
return (bool) $value ? 'Ada' : 'Tidak ada';
|
||||
}
|
||||
|
||||
if ($field === 'category_names' && is_array($value)) {
|
||||
return implode(', ', $value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsureNoPendingOwnerVerification;
|
||||
use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
@ -23,6 +24,7 @@
|
||||
'role' => RoleMiddleware::class,
|
||||
'permission' => PermissionMiddleware::class,
|
||||
'role_or_permission' => RoleOrPermissionMiddleware::class,
|
||||
'no_pending_owner_verification' => EnsureNoPendingOwnerVerification::class,
|
||||
]);
|
||||
|
||||
$middleware->web(append: [
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('owner_verification_requests', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
|
||||
$table->string('action', 50);
|
||||
$table->string('status', 50)->default('pending');
|
||||
$table->nullableMorphs('subject');
|
||||
$table->foreignId('submitted_by_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('verified_by_id')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->json('payload');
|
||||
$table->timestamp('verified_at')->nullable();
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('owner_verification_requests');
|
||||
}
|
||||
};
|
||||
@ -13,6 +13,7 @@ const props = defineProps<{
|
||||
tooltip?: string;
|
||||
tooltipClass?: string;
|
||||
buttonClass?: string;
|
||||
disabled?: boolean;
|
||||
onSuccess?: () => void;
|
||||
onError?: (errors: Record<string, string>) => string | void;
|
||||
}>();
|
||||
@ -28,11 +29,13 @@ const { open, processing, destroy } = useDestroy({
|
||||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
|
||||
:class="buttonClass" @click="open = true">
|
||||
<Trash2 class="size-4" />
|
||||
<span class="sr-only">{{ tooltip || 'Hapus' }}</span>
|
||||
</Button>
|
||||
<span class="inline-flex">
|
||||
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
|
||||
:class="buttonClass" :disabled="disabled" @click="!disabled && (open = true)">
|
||||
<Trash2 class="size-4" />
|
||||
<span class="sr-only">{{ tooltip || 'Hapus' }}</span>
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent :class="tooltipClass">{{ tooltip || 'Hapus' }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@ -7,14 +7,20 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
defineProps<{
|
||||
href?: string;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-8" :as-child="!!href">
|
||||
<Link v-if="href" :href="href">
|
||||
<Button variant="ghost" size="icon" class="size-8" :disabled="disabled" :as-child="!!href && !disabled"
|
||||
@click="!href && !disabled && emit('click')">
|
||||
<Link v-if="href && !disabled" :href="href">
|
||||
<Eye class="size-4" />
|
||||
<span class="sr-only">{{ tooltip || 'Detail' }}</span>
|
||||
</Link>
|
||||
|
||||
@ -3,33 +3,70 @@ import { Link } from '@inertiajs/vue3';
|
||||
import { Pencil } from '@lucide/vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
href?: string;
|
||||
tooltip?: string;
|
||||
tooltipClass?: string;
|
||||
buttonClass?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (props.disabled) {
|
||||
event.preventDefault();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.href) {
|
||||
event.preventDefault();
|
||||
emit('click');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-8" :class="buttonClass" :as-child="!!href"
|
||||
@click="!href && emit('click')">
|
||||
<Link v-if="href" :href="href">
|
||||
<span class="inline-flex">
|
||||
<Button
|
||||
v-if="href"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
as-child
|
||||
class="size-8"
|
||||
:class="cn(buttonClass, disabled && 'opacity-50')"
|
||||
>
|
||||
<Link
|
||||
:href="href"
|
||||
:tabindex="disabled ? -1 : undefined"
|
||||
:aria-disabled="disabled || undefined"
|
||||
:class="disabled ? 'cursor-not-allowed' : undefined"
|
||||
@click="handleClick"
|
||||
>
|
||||
<Pencil class="size-4" />
|
||||
<span class="sr-only">{{ tooltip || 'Ubah' }}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
:class="buttonClass"
|
||||
:disabled="disabled"
|
||||
@click="handleClick"
|
||||
>
|
||||
<Pencil class="size-4" />
|
||||
<span class="sr-only">{{ tooltip || 'Ubah' }}</span>
|
||||
</Link>
|
||||
<template v-else>
|
||||
<Pencil class="size-4" />
|
||||
<span class="sr-only">{{ tooltip || 'Ubah' }}</span>
|
||||
</template>
|
||||
</Button>
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent :class="tooltipClass">{{ tooltip || 'Ubah' }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@ -8,13 +8,18 @@ defineProps<{
|
||||
disabled?: boolean;
|
||||
size?: 'default' | 'sm';
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" :size="size === 'sm' ? 'sm' : 'icon'"
|
||||
:class="size === 'sm' ? '' : 'size-8'" class="text-destructive hover:text-destructive" :disabled="disabled">
|
||||
:class="size === 'sm' ? '' : 'size-8'" class="text-destructive hover:text-destructive" :disabled="disabled"
|
||||
@click="$emit('click')">
|
||||
<X :class="size === 'sm' ? 'size-3.5' : 'size-4'" />
|
||||
<span class="sr-only">{{ tooltip || 'Tolak' }}</span>
|
||||
</Button>
|
||||
|
||||
@ -1,29 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import OwnerVerificationPendingSection from './table/OwnerVerificationPendingSection.vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
import type {
|
||||
OwnerVerificationItem,
|
||||
PaginatedOwnerVerificationRequests,
|
||||
SelectOption,
|
||||
} from '@/types/owner-verification';
|
||||
import type { DataTableSort } from '@/types/data-table';
|
||||
import OwnerVerificationDetailModal from './table/OwnerVerificationDetailModal.vue';
|
||||
import { createColumns } from './table/columns';
|
||||
import { index } from '@/routes/admin/manage/owner_verifications';
|
||||
|
||||
defineProps<{
|
||||
pendingCuttings: CuttingListItem[];
|
||||
const props = defineProps<{
|
||||
verificationRequests: PaginatedOwnerVerificationRequests;
|
||||
filters: {
|
||||
search: string;
|
||||
sort?: string;
|
||||
direction?: 'asc' | 'desc';
|
||||
status?: string;
|
||||
subject_type?: string;
|
||||
action?: string;
|
||||
};
|
||||
statusOptions: SelectOption[];
|
||||
subjectOptions: SelectOption[];
|
||||
actionOptions: SelectOption[];
|
||||
}>();
|
||||
|
||||
const search = ref(props.filters.search ?? '');
|
||||
const detailModalOpen = ref(false);
|
||||
const selectedItem = ref<OwnerVerificationItem | null>(null);
|
||||
|
||||
const { query, setSearch, setSort, setFilter, resetFilters, syncFromServer } = useDataTableQuery({
|
||||
url: index.url(),
|
||||
initial: { ...props.filters },
|
||||
filterKeys: ['status', 'subject_type', 'action'],
|
||||
});
|
||||
|
||||
useDataTableQuerySync(() => props.filters, syncFromServer);
|
||||
|
||||
const columns = computed(() => createColumns(openDetailModal));
|
||||
|
||||
const filterDefs = computed(() => [
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
type: 'select' as const,
|
||||
options: props.statusOptions,
|
||||
},
|
||||
{
|
||||
key: 'subject_type',
|
||||
label: 'Modul',
|
||||
type: 'select' as const,
|
||||
options: props.subjectOptions,
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Aksi',
|
||||
type: 'select' as const,
|
||||
options: props.actionOptions,
|
||||
},
|
||||
]);
|
||||
|
||||
const filterValues = computed(() => ({
|
||||
status: query.value.status ?? '',
|
||||
subject_type: query.value.subject_type ?? '',
|
||||
action: query.value.action ?? '',
|
||||
}));
|
||||
|
||||
const currentSort = computed<DataTableSort | null>(() => {
|
||||
if (!query.value.sort || !query.value.direction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
column: query.value.sort,
|
||||
direction: query.value.direction,
|
||||
};
|
||||
});
|
||||
|
||||
const pagination = computed(() => ({
|
||||
currentPage: props.verificationRequests.current_page,
|
||||
perPage: props.verificationRequests.per_page,
|
||||
lastPage: props.verificationRequests.last_page,
|
||||
total: props.verificationRequests.total,
|
||||
}));
|
||||
|
||||
function openDetailModal(item: OwnerVerificationItem) {
|
||||
selectedItem.value = item;
|
||||
detailModalOpen.value = true;
|
||||
}
|
||||
|
||||
watch(search, (value) => {
|
||||
setSearch(value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.filters.search,
|
||||
(value) => {
|
||||
search.value = value ?? '';
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Verifikasi Owner" />
|
||||
|
||||
<AdminLayout>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-bold tracking-tight">
|
||||
Verifikasi Owner
|
||||
</h2>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Persetujuan verifikasi stok cutting oleh owner.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-bold tracking-tight">
|
||||
Verifikasi Owner
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<OwnerVerificationPendingSection :cuttings="pendingCuttings" />
|
||||
<Card class="min-w-0">
|
||||
<CardContent class="min-w-0">
|
||||
<DataTable
|
||||
v-model:search="search"
|
||||
:columns="columns"
|
||||
:data="verificationRequests.data"
|
||||
:pagination="pagination"
|
||||
:pagination-links="verificationRequests.links"
|
||||
:sort="currentSort"
|
||||
:filter-defs="filterDefs"
|
||||
:filter-values="filterValues"
|
||||
search-placeholder="Cari verifikasi..."
|
||||
@sort-change="setSort"
|
||||
@filter-change="setFilter"
|
||||
@filters-reset="resetFilters"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<OwnerVerificationDetailModal
|
||||
v-model:open="detailModalOpen"
|
||||
:item="selectedItem"
|
||||
/>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { OwnerVerificationItem } from '@/types/owner-verification';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
item?: OwnerVerificationItem | null;
|
||||
}>();
|
||||
|
||||
const hasChanges = computed(() => (props.item?.changes.length ?? 0) > 0);
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Aktif' : 'Nonaktif';
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Detail Verifikasi</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="item" class="space-y-4 text-sm">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Waktu</p>
|
||||
<p class="font-medium">{{ item.created_at_formatted ?? '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Diajukan Oleh</p>
|
||||
<p class="font-medium">{{ item.submitted_by_name }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Aksi</p>
|
||||
<p class="font-medium">{{ item.action_label }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Modul</p>
|
||||
<p class="font-medium">{{ item.subject_label }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Item</p>
|
||||
<p class="font-medium">{{ item.title }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Status</p>
|
||||
<Badge
|
||||
:variant="item.status === 'approved' ? 'default' : item.status === 'rejected' ? 'destructive' : 'outline'"
|
||||
>
|
||||
{{ item.status_label }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-if="item.verified_by_name">
|
||||
<p class="text-muted-foreground">Diverifikasi Oleh</p>
|
||||
<p class="font-medium">{{ item.verified_by_name }}</p>
|
||||
</div>
|
||||
<div v-if="item.verified_at_formatted">
|
||||
<p class="text-muted-foreground">Waktu Verifikasi</p>
|
||||
<p class="font-medium">{{ item.verified_at_formatted }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="item.summary" class="rounded-md border p-3">
|
||||
<p class="text-muted-foreground">Ringkasan</p>
|
||||
<p class="font-medium">{{ item.summary }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="item.rejection_reason" class="rounded-md border border-destructive/30 bg-destructive/5 p-3">
|
||||
<p class="text-muted-foreground">Alasan Penolakan</p>
|
||||
<p class="font-medium">{{ item.rejection_reason }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="hasChanges" class="space-y-2">
|
||||
<p class="font-medium">Perubahan Data</p>
|
||||
<div class="overflow-hidden rounded-md border">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-medium">Field</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Sebelum</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Sesudah</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="change in item.changes" :key="change.field" class="border-t">
|
||||
<td class="px-3 py-2 align-top font-medium">{{ change.field }}</td>
|
||||
<td class="px-3 py-2 align-top text-muted-foreground">
|
||||
{{ formatValue(change.old) }}
|
||||
</td>
|
||||
<td class="px-3 py-2 align-top">{{ formatValue(change.new) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@ -1,91 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Card } from '@/components/ui/card';
|
||||
import type { CuttingListItem, CuttingResultPriceItem } from '@/types/cutting';
|
||||
import { PRICE_TYPE_LABELS } from '@/types/product';
|
||||
import OwnerVerificationDataTableActions from './data-table-actions.vue';
|
||||
|
||||
defineProps<{
|
||||
cuttings: CuttingListItem[];
|
||||
}>();
|
||||
|
||||
function getVariantPrices(cutting: CuttingListItem, variantId: number): CuttingResultPriceItem[] {
|
||||
return (cutting.result_prices ?? []).filter(p => p.product_variant_id === variantId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="cuttings.length" class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="cutting in cuttings" :key="cutting.id"
|
||||
class="flex flex-col justify-between overflow-hidden border bg-card/50 py-0 gap-0">
|
||||
<div class="border-b bg-muted/20 px-4 py-3 flex items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<h4 class="font-medium text-sm truncate">
|
||||
Cutting #{{ cutting.id }}
|
||||
</h4>
|
||||
<span class="text-[10px] text-muted-foreground block truncate">
|
||||
{{ cutting.created_at_formatted }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<OwnerVerificationDataTableActions :cutting="cutting" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-3 flex-1 text-xs">
|
||||
<div class="space-y-1">
|
||||
<span class="font-medium text-foreground">Hasil Produk:</span>
|
||||
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
|
||||
<li v-for="res in cutting.results" :key="res.id">
|
||||
{{ res.product_variant?.product?.name }} ({{ res.product_variant?.name }}) -
|
||||
{{ res.cutting_result }} pcs
|
||||
<span class="text-[10px]">
|
||||
({{ res.warehouse_stock }} bagus, {{ res.cutting_reject }} reject)
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="!cutting.results.length">Belum ada hasil produk</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="cutting.result_prices?.length" class="space-y-2 pt-2 border-t">
|
||||
<span class="font-medium text-foreground">Harga:</span>
|
||||
<div v-for="res in cutting.results" :key="`price-${res.id}`" class="space-y-1">
|
||||
<div v-if="getVariantPrices(cutting, res.product_variant_id ?? 0).length > 0">
|
||||
<p class="text-[11px] font-medium text-muted-foreground">
|
||||
{{ res.product_variant?.product?.name }} ({{ res.product_variant?.name }})
|
||||
</p>
|
||||
<div class="pl-2 space-y-0.5">
|
||||
<div v-for="price in getVariantPrices(cutting, res.product_variant_id ?? 0)" :key="price.id"
|
||||
class="flex items-center justify-between gap-2">
|
||||
<span class="text-muted-foreground">
|
||||
{{ PRICE_TYPE_LABELS[price.price_type as keyof typeof PRICE_TYPE_LABELS] ?? price.type_label }}
|
||||
</span>
|
||||
<span class="font-medium tabular-nums">{{ price.price_formatted }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="cutting.results.length" class="pt-2 border-t">
|
||||
<div class="flex items-center justify-between text-[11px]">
|
||||
<span class="text-muted-foreground">Total Hasil Cutting:</span>
|
||||
<span class="font-semibold tabular-nums">{{ cutting.total_result_pieces ?? 0 }} pcs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<h3 class="text-lg font-semibold">
|
||||
Menunggu Persetujuan
|
||||
</h3>
|
||||
<div class="rounded-md border px-6 py-10 text-center">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Tidak ada verifikasi yang menunggu persetujuan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,70 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { h } from 'vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DataTableColumnHeader } from '@/components/data-table';
|
||||
import type { OwnerVerificationItem } from '@/types/owner-verification';
|
||||
import DataTableActions from './data-table-actions.vue';
|
||||
|
||||
function statusBadgeVariant(status: string): 'default' | 'destructive' | 'outline' | 'secondary' {
|
||||
if (status === 'approved') {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
if (status === 'rejected') {
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
export function createColumns(
|
||||
onView: (item: OwnerVerificationItem) => void,
|
||||
): ColumnDef<OwnerVerificationItem>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'created_at_formatted',
|
||||
enableSorting: true,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Waktu', column: 'created_at' }),
|
||||
cell: ({ row }) => row.original.created_at_formatted ?? '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'submitted_by_name',
|
||||
enableSorting: false,
|
||||
header: 'Diajukan Oleh',
|
||||
},
|
||||
{
|
||||
accessorKey: 'action_label',
|
||||
enableSorting: true,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Aksi', column: 'action' }),
|
||||
},
|
||||
{
|
||||
accessorKey: 'subject_label',
|
||||
enableSorting: false,
|
||||
header: 'Modul',
|
||||
},
|
||||
{
|
||||
accessorKey: 'title',
|
||||
enableSorting: false,
|
||||
header: 'Item',
|
||||
},
|
||||
{
|
||||
accessorKey: 'status_label',
|
||||
enableSorting: true,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
|
||||
cell: ({ row }) => h(
|
||||
Badge,
|
||||
{ variant: statusBadgeVariant(row.original.status) },
|
||||
() => row.original.status_label,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => h(DataTableActions, {
|
||||
item: row.original,
|
||||
onView: () => onView(row.original),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { X } from '@lucide/vue';
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { RowApproveAction, RowRejectAction } from '@/components/button';
|
||||
import { RowApproveAction, RowDetailAction, RowRejectAction } from '@/components/button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@ -19,11 +19,15 @@ import {
|
||||
} from '@/components/ui/field';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import { approve, reject } from '@/routes/admin/manage/owner_verifications';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
import { approve, reject, approve_request, reject_request } from '@/routes/admin/manage/owner_verifications';
|
||||
import type { OwnerVerificationItem } from '@/types/owner-verification';
|
||||
|
||||
const props = defineProps<{
|
||||
cutting: CuttingListItem;
|
||||
item: OwnerVerificationItem;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
view: [];
|
||||
}>();
|
||||
|
||||
const { can } = useCan();
|
||||
@ -31,16 +35,27 @@ const { can } = useCan();
|
||||
const rejectDialogOpen = ref(false);
|
||||
|
||||
const approveForm = useForm({});
|
||||
|
||||
const rejectForm = useForm({
|
||||
reason: '',
|
||||
});
|
||||
|
||||
const approveUrl = computed(() => (
|
||||
props.item.source === 'cutting'
|
||||
? approve.url(props.item.id)
|
||||
: approve_request.url(props.item.id)
|
||||
));
|
||||
|
||||
const rejectUrl = computed(() => (
|
||||
props.item.source === 'cutting'
|
||||
? reject.url(props.item.id)
|
||||
: reject_request.url(props.item.id)
|
||||
));
|
||||
|
||||
function submitApprove() {
|
||||
approveForm
|
||||
.post(approve.url(props.cutting.id), {
|
||||
.post(approveUrl.value, {
|
||||
preserveScroll: true,
|
||||
onError: (errors: any) => {
|
||||
onError: (errors: Record<string, string>) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
@ -50,13 +65,13 @@ function submitApprove() {
|
||||
|
||||
function submitReject() {
|
||||
rejectForm
|
||||
.post(reject.url(props.cutting.id), {
|
||||
.post(rejectUrl.value, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
rejectDialogOpen.value = false;
|
||||
rejectForm.reset();
|
||||
},
|
||||
onError: (errors: any) => {
|
||||
onError: (errors: Record<string, string>) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
@ -66,13 +81,25 @@ function submitReject() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="can('owner_verifications.verify')" class="flex items-center gap-1">
|
||||
<RowApproveAction size="sm" tooltip="Setujui Verifikasi" :disabled="approveForm.processing"
|
||||
@click="submitApprove" />
|
||||
<RowRejectAction size="sm" tooltip="Tolak Verifikasi" @click="rejectDialogOpen = true" />
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<RowDetailAction tooltip="Lihat" @click="emit('view')" />
|
||||
|
||||
<template v-if="item.is_pending && can('owner_verifications.verify')">
|
||||
<RowApproveAction
|
||||
size="sm"
|
||||
tooltip="Setujui"
|
||||
:disabled="approveForm.processing"
|
||||
@click="submitApprove"
|
||||
/>
|
||||
<RowRejectAction
|
||||
size="sm"
|
||||
tooltip="Tolak"
|
||||
:disabled="rejectForm.processing"
|
||||
@click="rejectDialogOpen = true"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Reject Dialog -->
|
||||
<Dialog v-model:open="rejectDialogOpen">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
@ -82,19 +109,27 @@ function submitReject() {
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-md border p-3 text-xs space-y-2">
|
||||
<div>
|
||||
<span class="text-muted-foreground">Cutting:</span>
|
||||
<span class="ml-1 font-medium">#{{ cutting.id }}</span>
|
||||
<span class="text-muted-foreground">Modul:</span>
|
||||
<span class="ml-1 font-medium">{{ item.subject_label }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground">Deskripsi:</span>
|
||||
<span class="ml-1 font-medium">{{ cutting.description ?? '-' }}</span>
|
||||
<span class="text-muted-foreground">Item:</span>
|
||||
<span class="ml-1 font-medium">{{ item.title }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground">Aksi:</span>
|
||||
<span class="ml-1 font-medium">{{ item.action_label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="reject-reason">Alasan Penolakan</FieldLabel>
|
||||
<Textarea id="reject-reason" v-model="rejectForm.reason" placeholder="Jelaskan alasan penolakan..."
|
||||
rows="3" />
|
||||
<Textarea
|
||||
id="reject-reason"
|
||||
v-model="rejectForm.reason"
|
||||
placeholder="Jelaskan alasan penolakan..."
|
||||
rows="3"
|
||||
/>
|
||||
<FieldError :errors="rejectForm.errors.reason ? [rejectForm.errors.reason] : []" />
|
||||
</Field>
|
||||
</div>
|
||||
@ -103,7 +138,12 @@ function submitReject() {
|
||||
<Button type="button" variant="outline" @click="rejectDialogOpen = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" :disabled="rejectForm.processing" @click="submitReject">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
:disabled="rejectForm.processing"
|
||||
@click="submitReject"
|
||||
>
|
||||
<X class="size-4" />
|
||||
Tolak
|
||||
</Button>
|
||||
|
||||
@ -107,6 +107,9 @@ function getGroupedItems(items: any[]): GroupedPurchaseItems[] {
|
||||
<h3 class="font-medium leading-tight">
|
||||
{{ purchase.supplier?.name }}
|
||||
</h3>
|
||||
<p v-if="purchase.has_pending_request" class="text-sm text-amber-600">
|
||||
Sedang menunggu verifikasi owner
|
||||
</p>
|
||||
<div class="text-muted-foreground space-y-1 text-sm">
|
||||
<p>{{ purchase.created_at_formatted }}</p>
|
||||
<p>Oleh {{ purchase.created_by?.profile?.full_name ?? purchase.created_by?.username }}
|
||||
|
||||
@ -13,9 +13,20 @@ const { can } = useCan();
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<RowEditAction v-if="can('purchases.update')" :href="edit.url(purchase.id)" />
|
||||
<RowDeleteAction v-if="can('purchases.delete')" :action-url="destroy.url(purchase.id)" title="Hapus belanja?"
|
||||
:description="`Belanja ${purchase.total_formatted} dari ${purchase.supplier_name} akan dihapus. Stok bahan baku akan dikurangi.`"
|
||||
error-message="Gagal menghapus belanja." />
|
||||
<RowEditAction
|
||||
v-if="can('purchases.update')"
|
||||
:href="edit.url(purchase.id)"
|
||||
:disabled="purchase.has_pending_request"
|
||||
:tooltip="purchase.has_pending_request ? 'Menunggu verifikasi owner' : 'Ubah'"
|
||||
/>
|
||||
<RowDeleteAction
|
||||
v-if="can('purchases.delete')"
|
||||
:action-url="destroy.url(purchase.id)"
|
||||
:disabled="purchase.has_pending_request"
|
||||
:tooltip="purchase.has_pending_request ? 'Menunggu verifikasi owner' : 'Hapus'"
|
||||
title="Hapus belanja?"
|
||||
:description="`Pengajuan hapus belanja ${purchase.total_formatted} dari ${purchase.supplier?.name ?? purchase.supplier_name} akan dikirim ke owner untuk verifikasi.`"
|
||||
error-message="Gagal mengajukan penghapusan belanja."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -87,6 +87,9 @@ function rowNumber(index: number): number {
|
||||
{{ product.name }}
|
||||
</h3>
|
||||
</div>
|
||||
<p v-if="product.has_pending_request" class="text-sm text-amber-600">
|
||||
Sedang menunggu verifikasi owner
|
||||
</p>
|
||||
<div v-if="product.categories.length" class="flex flex-wrap gap-1">
|
||||
<Badge v-for="category in product.categories" :key="category.id" variant="outline">
|
||||
{{ category.name }}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { RowDeleteAction, RowEditAction } from '@/components/button';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { ProductListItem } from '@/types/product';
|
||||
@ -14,9 +13,20 @@ const { can } = useCan();
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<RowEditAction v-if="can('products.update')" :href="edit.url(product.id)" />
|
||||
<RowDeleteAction v-if="can('products.delete')" :action-url="destroy.url(product.id)" title="Hapus produk?"
|
||||
:description="`Produk ${product.name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`"
|
||||
error-message="Gagal menghapus produk." />
|
||||
<RowEditAction
|
||||
v-if="can('products.update')"
|
||||
:href="edit.url(product.id)"
|
||||
:disabled="product.has_pending_request"
|
||||
:tooltip="product.has_pending_request ? 'Menunggu verifikasi owner' : 'Ubah'"
|
||||
/>
|
||||
<RowDeleteAction
|
||||
v-if="can('products.delete')"
|
||||
:action-url="destroy.url(product.id)"
|
||||
:disabled="product.has_pending_request"
|
||||
:tooltip="product.has_pending_request ? 'Menunggu verifikasi owner' : 'Hapus'"
|
||||
title="Hapus produk?"
|
||||
:description="`Pengajuan hapus produk ${product.name} akan dikirim ke owner untuk verifikasi.`"
|
||||
error-message="Gagal mengajukan penghapusan produk."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -14,38 +14,50 @@ const props = defineProps<{
|
||||
|
||||
const { can } = useCan();
|
||||
|
||||
const isActive = ref(props.product.is_active);
|
||||
function resolveIsActive(product: ProductListItem): boolean {
|
||||
return product.display_is_active ?? product.is_active;
|
||||
}
|
||||
|
||||
const isActive = ref(resolveIsActive(props.product));
|
||||
const processing = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.product.is_active,
|
||||
() => resolveIsActive(props.product),
|
||||
(value) => {
|
||||
isActive.value = value;
|
||||
},
|
||||
);
|
||||
|
||||
function toggleStatus(checked: boolean) {
|
||||
if (!can('products.update')) {
|
||||
if (!can('products.toggle_status') || props.product.has_pending_request) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (checked === isActive.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = isActive.value;
|
||||
isActive.value = checked;
|
||||
processing.value = true;
|
||||
isActive.value = checked;
|
||||
|
||||
router.patch(toggle_status.url(props.product.id), {
|
||||
is_active: checked,
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
onError: (errors: any) => {
|
||||
isActive.value = previous;
|
||||
isActive.value = resolveIsActive(props.product);
|
||||
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
|
||||
if (errors.product) {
|
||||
toast.error(errors.product);
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
isActive.value = resolveIsActive(props.product);
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -53,8 +65,11 @@ function toggleStatus(checked: boolean) {
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch :model-value="isActive" :disabled="processing || !can('products.update')"
|
||||
@update:model-value="toggleStatus" />
|
||||
<Switch
|
||||
:model-value="isActive"
|
||||
:disabled="processing || !can('products.toggle_status') || product.has_pending_request"
|
||||
@update:model-value="toggleStatus"
|
||||
/>
|
||||
<Badge :variant="isActive ? 'default' : 'secondary'">
|
||||
{{ isActive ? 'Aktif' : 'Nonaktif' }}
|
||||
</Badge>
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import { DataTableEmpty } from '@/components/data-table';
|
||||
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
|
||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DataTableEmpty } from '@/components/data-table';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -83,12 +83,15 @@ function rowNumber(index: number): number {
|
||||
{{ material.unit_label }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p v-if="material.has_pending_request" class="text-sm text-amber-600">
|
||||
Sedang menunggu verifikasi owner
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||
<span>
|
||||
Total stok <strong class="text-primary"> {{material.prices.reduce((acc, price) =>
|
||||
acc +
|
||||
Number(price.stock), 0)
|
||||
}}
|
||||
}}
|
||||
{{ material.unit_abbreviation }}
|
||||
</strong>
|
||||
</span>
|
||||
|
||||
@ -13,9 +13,20 @@ const { can } = useCan();
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<RowEditAction v-if="can('raw_materials.update')" :href="edit.url(material.id)" />
|
||||
<RowDeleteAction v-if="can('raw_materials.delete')" :action-url="destroy.url(material.id)" title="Hapus bahan baku?"
|
||||
:description="`Bahan baku ${material.name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`"
|
||||
error-message="Gagal menghapus bahan baku." />
|
||||
<RowEditAction
|
||||
v-if="can('raw_materials.update')"
|
||||
:href="edit.url(material.id)"
|
||||
:disabled="material.has_pending_request"
|
||||
:tooltip="material.has_pending_request ? 'Menunggu verifikasi owner' : 'Ubah'"
|
||||
/>
|
||||
<RowDeleteAction
|
||||
v-if="can('raw_materials.delete')"
|
||||
:action-url="destroy.url(material.id)"
|
||||
:disabled="material.has_pending_request"
|
||||
:tooltip="material.has_pending_request ? 'Menunggu verifikasi owner' : 'Hapus'"
|
||||
title="Hapus bahan baku?"
|
||||
:description="`Pengajuan hapus bahan baku ${material.name} akan dikirim ke owner untuk verifikasi.`"
|
||||
error-message="Gagal mengajukan penghapusan bahan baku."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -14,31 +14,38 @@ const props = defineProps<{
|
||||
|
||||
const { can } = useCan();
|
||||
|
||||
const isActive = ref(props.material.is_active);
|
||||
function resolveIsActive(material: RawMaterialListItem): boolean {
|
||||
return material.display_is_active ?? material.is_active;
|
||||
}
|
||||
|
||||
const isActive = ref(resolveIsActive(props.material));
|
||||
const processing = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.material.is_active,
|
||||
() => resolveIsActive(props.material),
|
||||
(value) => {
|
||||
isActive.value = value;
|
||||
},
|
||||
);
|
||||
|
||||
function toggleStatus(checked: boolean) {
|
||||
if (!can('raw_materials.toggle_status')) {
|
||||
if (!can('raw_materials.toggle_status') || props.material.has_pending_request) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (checked === isActive.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = isActive.value;
|
||||
isActive.value = checked;
|
||||
processing.value = true;
|
||||
isActive.value = checked;
|
||||
|
||||
router.patch(toggle_status.url(props.material.id), {
|
||||
is_active: checked,
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
onError: (errors: any) => {
|
||||
isActive.value = previous;
|
||||
isActive.value = resolveIsActive(props.material);
|
||||
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
@ -46,6 +53,7 @@ function toggleStatus(checked: boolean) {
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
isActive.value = resolveIsActive(props.material);
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -53,8 +61,11 @@ function toggleStatus(checked: boolean) {
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch :model-value="isActive" :disabled="processing || !can('raw_materials.toggle_status')"
|
||||
@update:model-value="toggleStatus" />
|
||||
<Switch
|
||||
:model-value="isActive"
|
||||
:disabled="processing || !can('raw_materials.toggle_status') || material.has_pending_request"
|
||||
@update:model-value="toggleStatus"
|
||||
/>
|
||||
<Badge :variant="isActive ? 'default' : 'secondary'">
|
||||
{{ isActive ? 'Aktif' : 'Nonaktif' }}
|
||||
</Badge>
|
||||
|
||||
51
resources/js/types/owner-verification.ts
Normal file
51
resources/js/types/owner-verification.ts
Normal file
@ -0,0 +1,51 @@
|
||||
export type OwnerVerificationSource = 'cutting' | 'request';
|
||||
|
||||
export interface VerificationChange {
|
||||
field: string;
|
||||
old: unknown;
|
||||
new: unknown;
|
||||
}
|
||||
|
||||
export interface OwnerVerificationItem {
|
||||
id: number;
|
||||
source: OwnerVerificationSource;
|
||||
subject_type: string | null;
|
||||
subject_label: string;
|
||||
subject_id: number | null;
|
||||
action: string;
|
||||
action_label: string;
|
||||
status: string;
|
||||
status_label: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
submitted_by_name: string;
|
||||
verified_by_name?: string | null;
|
||||
verified_at_formatted?: string | null;
|
||||
created_at?: string | null;
|
||||
created_at_formatted: string | null;
|
||||
changes: VerificationChange[];
|
||||
rejection_reason?: string | null;
|
||||
is_pending: boolean;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** @deprecated Use OwnerVerificationItem */
|
||||
export type OwnerVerificationRequestItem = OwnerVerificationItem;
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface PaginatedOwnerVerificationRequests {
|
||||
data: OwnerVerificationItem[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
last_page: number;
|
||||
total: number;
|
||||
links: Array<{
|
||||
url: string | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
}>;
|
||||
}
|
||||
@ -61,3 +61,36 @@ export interface Product {
|
||||
categories: Category[];
|
||||
variants: Variant[];
|
||||
}
|
||||
|
||||
export interface ProductListItem extends Product {
|
||||
has_pending_request?: boolean;
|
||||
pending_request_action?: string;
|
||||
pending_request_action_label?: string;
|
||||
display_is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface CategoryOption {
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface PaginatedProducts {
|
||||
data: ProductListItem[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
last_page: number;
|
||||
total: number;
|
||||
links: Array<{
|
||||
url: string | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProductVariantFormItem {
|
||||
client_id: string;
|
||||
id?: number;
|
||||
name: string;
|
||||
stock: string | number;
|
||||
media: import('@/types/media').MediaUploadState;
|
||||
}
|
||||
|
||||
@ -38,6 +38,9 @@ export type PurchaseListItem = {
|
||||
} | null;
|
||||
photos?: MediaItem | null;
|
||||
items: any[];
|
||||
has_pending_request?: boolean;
|
||||
pending_request_action?: string;
|
||||
pending_request_action_label?: string;
|
||||
};
|
||||
|
||||
export type PurchaseCartItem = {
|
||||
|
||||
@ -27,6 +27,10 @@ export type RawMaterialListItem = {
|
||||
unit_abbreviation: string;
|
||||
is_active: boolean;
|
||||
prices: RawMaterialPrice[];
|
||||
has_pending_request?: boolean;
|
||||
pending_request_action?: string;
|
||||
pending_request_action_label?: string;
|
||||
display_is_active?: boolean;
|
||||
};
|
||||
|
||||
export type RawMaterialPriceFormItem = {
|
||||
|
||||
@ -89,19 +89,31 @@
|
||||
->name('store');
|
||||
|
||||
Route::get('{product}/edit', [ProductController::class, 'edit'])
|
||||
->middleware('permission:'.Permission::PRODUCTS_UPDATE->value)
|
||||
->middleware([
|
||||
'permission:'.Permission::PRODUCTS_UPDATE->value,
|
||||
'no_pending_owner_verification:product',
|
||||
])
|
||||
->name('edit');
|
||||
|
||||
Route::put('{product}', [ProductController::class, 'update'])
|
||||
->middleware('permission:'.Permission::PRODUCTS_UPDATE->value)
|
||||
->middleware([
|
||||
'permission:'.Permission::PRODUCTS_UPDATE->value,
|
||||
'no_pending_owner_verification:product',
|
||||
])
|
||||
->name('update');
|
||||
|
||||
Route::patch('{product}/toggle-status', [ProductController::class, 'toggleStatus'])
|
||||
->middleware('permission:'.Permission::PRODUCTS_TOGGLE_STATUS->value)
|
||||
->middleware([
|
||||
'permission:'.Permission::PRODUCTS_TOGGLE_STATUS->value,
|
||||
'no_pending_owner_verification:product',
|
||||
])
|
||||
->name('toggle_status');
|
||||
|
||||
Route::delete('{product}', [ProductController::class, 'destroy'])
|
||||
->middleware('permission:'.Permission::PRODUCTS_DELETE->value)
|
||||
->middleware([
|
||||
'permission:'.Permission::PRODUCTS_DELETE->value,
|
||||
'no_pending_owner_verification:product',
|
||||
])
|
||||
->name('destroy');
|
||||
|
||||
Route::get('/', [ProductController::class, 'index'])->name('index');
|
||||
@ -119,19 +131,31 @@
|
||||
->name('store');
|
||||
|
||||
Route::get('{rawMaterial}/edit', [RawMaterialController::class, 'edit'])
|
||||
->middleware('permission:'.Permission::RAW_MATERIALS_UPDATE->value)
|
||||
->middleware([
|
||||
'permission:'.Permission::RAW_MATERIALS_UPDATE->value,
|
||||
'no_pending_owner_verification:rawMaterial',
|
||||
])
|
||||
->name('edit');
|
||||
|
||||
Route::put('{rawMaterial}', [RawMaterialController::class, 'update'])
|
||||
->middleware('permission:'.Permission::RAW_MATERIALS_UPDATE->value)
|
||||
->middleware([
|
||||
'permission:'.Permission::RAW_MATERIALS_UPDATE->value,
|
||||
'no_pending_owner_verification:rawMaterial',
|
||||
])
|
||||
->name('update');
|
||||
|
||||
Route::patch('{rawMaterial}/toggle-status', [RawMaterialController::class, 'toggleStatus'])
|
||||
->middleware('permission:'.Permission::RAW_MATERIALS_TOGGLE_STATUS->value)
|
||||
->middleware([
|
||||
'permission:'.Permission::RAW_MATERIALS_TOGGLE_STATUS->value,
|
||||
'no_pending_owner_verification:rawMaterial',
|
||||
])
|
||||
->name('toggle_status');
|
||||
|
||||
Route::delete('{rawMaterial}', [RawMaterialController::class, 'destroy'])
|
||||
->middleware('permission:'.Permission::RAW_MATERIALS_DELETE->value)
|
||||
->middleware([
|
||||
'permission:'.Permission::RAW_MATERIALS_DELETE->value,
|
||||
'no_pending_owner_verification:rawMaterial',
|
||||
])
|
||||
->name('destroy');
|
||||
|
||||
Route::get('/', [RawMaterialController::class, 'index'])->name('index');
|
||||
@ -202,15 +226,24 @@
|
||||
->name('store');
|
||||
|
||||
Route::get('{purchase}/edit', [PurchaseController::class, 'edit'])
|
||||
->middleware('permission:'.Permission::PURCHASES_UPDATE->value)
|
||||
->middleware([
|
||||
'permission:'.Permission::PURCHASES_UPDATE->value,
|
||||
'no_pending_owner_verification:purchase',
|
||||
])
|
||||
->name('edit');
|
||||
|
||||
Route::put('{purchase}', [PurchaseController::class, 'update'])
|
||||
->middleware('permission:'.Permission::PURCHASES_UPDATE->value)
|
||||
->middleware([
|
||||
'permission:'.Permission::PURCHASES_UPDATE->value,
|
||||
'no_pending_owner_verification:purchase',
|
||||
])
|
||||
->name('update');
|
||||
|
||||
Route::delete('{purchase}', [PurchaseController::class, 'destroy'])
|
||||
->middleware('permission:'.Permission::PURCHASES_DELETE->value)
|
||||
->middleware([
|
||||
'permission:'.Permission::PURCHASES_DELETE->value,
|
||||
'no_pending_owner_verification:purchase',
|
||||
])
|
||||
->name('destroy');
|
||||
});
|
||||
|
||||
@ -319,13 +352,21 @@
|
||||
->group(function () {
|
||||
Route::get('/', [OwnerVerificationController::class, 'index'])->name('index');
|
||||
|
||||
Route::post('{cutting}/approve', [OwnerVerificationController::class, 'approve'])
|
||||
Route::post('cuttings/{cutting}/approve', [OwnerVerificationController::class, 'approveCutting'])
|
||||
->middleware('permission:'.Permission::OWNER_VERIFICATIONS_VERIFY->value)
|
||||
->name('approve');
|
||||
|
||||
Route::post('{cutting}/reject', [OwnerVerificationController::class, 'reject'])
|
||||
Route::post('cuttings/{cutting}/reject', [OwnerVerificationController::class, 'rejectCutting'])
|
||||
->middleware('permission:'.Permission::OWNER_VERIFICATIONS_REJECT->value)
|
||||
->name('reject');
|
||||
|
||||
Route::post('requests/{ownerVerificationRequest}/approve', [OwnerVerificationController::class, 'approveRequest'])
|
||||
->middleware('permission:'.Permission::OWNER_VERIFICATIONS_VERIFY->value)
|
||||
->name('approve_request');
|
||||
|
||||
Route::post('requests/{ownerVerificationRequest}/reject', [OwnerVerificationController::class, 'rejectRequest'])
|
||||
->middleware('permission:'.Permission::OWNER_VERIFICATIONS_REJECT->value)
|
||||
->name('reject_request');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
356
tests/Feature/Admin/Manage/OwnerVerificationTest.php
Normal file
356
tests/Feature/Admin/Manage/OwnerVerificationTest.php
Normal file
@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\CuttingStatus;
|
||||
use App\Enums\OwnerVerificationAction;
|
||||
use App\Enums\OwnerVerificationStatus;
|
||||
use App\Enums\Permission as PermissionEnum;
|
||||
use App\Enums\Role as RoleEnum;
|
||||
use App\Models\Cutting;
|
||||
use App\Models\OwnerVerificationRequest;
|
||||
use App\Models\Product;
|
||||
use App\Models\Purchase;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
use App\Support\ActivityLog\ModelLabel;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
});
|
||||
|
||||
function createOwnerVerificationRequestFor(User $submitter, ?Product $product = null): OwnerVerificationRequest
|
||||
{
|
||||
$product ??= Product::factory()->create(['is_active' => false]);
|
||||
|
||||
return OwnerVerificationRequest::query()->create([
|
||||
'action' => OwnerVerificationAction::CREATE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Product::class,
|
||||
'subject_id' => $product->id,
|
||||
'submitted_by_id' => $submitter->id,
|
||||
'payload' => ['old' => null, 'new' => ['name' => $product->name]],
|
||||
]);
|
||||
}
|
||||
|
||||
function createOwnerVerifierUser(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole(RoleEnum::OWNER->value);
|
||||
$user->forgetCachedPermissions();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function createPendingCutting(?User $submitter = null): Cutting
|
||||
{
|
||||
$submitter ??= User::factory()->create();
|
||||
|
||||
return Cutting::factory()->create([
|
||||
'status' => CuttingStatus::PENDING_VERIFICATION,
|
||||
'submitted_by_id' => $submitter->id,
|
||||
'created_by_id' => $submitter->id,
|
||||
'description' => 'Cutting test pending',
|
||||
]);
|
||||
}
|
||||
|
||||
describe('Owner Verification Index', function () {
|
||||
test('submitter with view permission only sees own verification requests', function () {
|
||||
$submitter = User::factory()->create();
|
||||
$submitter->assignRole(RoleEnum::ADMIN_TOKO->value);
|
||||
$submitter->forgetCachedPermissions();
|
||||
|
||||
$otherUser = User::factory()->create();
|
||||
$otherUser->assignRole(RoleEnum::ADMIN_TOKO->value);
|
||||
$otherUser->forgetCachedPermissions();
|
||||
|
||||
$ownRequest = createOwnerVerificationRequestFor($submitter);
|
||||
createOwnerVerificationRequestFor($otherUser);
|
||||
|
||||
$response = $this->actingAs($submitter)
|
||||
->get(route('admin.manage.owner_verifications.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('admin/manage/owner-verifications/Index')
|
||||
->has('verificationRequests.data', 1)
|
||||
->where('verificationRequests.data.0.id', $ownRequest->id)
|
||||
->where('verificationRequests.data.0.source', 'request')
|
||||
);
|
||||
});
|
||||
|
||||
test('verifier sees all verification requests', function () {
|
||||
$submitter = User::factory()->create();
|
||||
$submitter->assignRole(RoleEnum::ADMIN_TOKO->value);
|
||||
$submitter->forgetCachedPermissions();
|
||||
|
||||
$verifier = createOwnerVerifierUser();
|
||||
|
||||
createOwnerVerificationRequestFor($submitter);
|
||||
createOwnerVerificationRequestFor($submitter);
|
||||
|
||||
$response = $this->actingAs($verifier)
|
||||
->get(route('admin.manage.owner_verifications.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('admin/manage/owner-verifications/Index')
|
||||
->has('verificationRequests.data', 2)
|
||||
);
|
||||
});
|
||||
|
||||
test('submitter cannot approve verification request', function () {
|
||||
$submitter = User::factory()->create();
|
||||
$submitter->assignRole(RoleEnum::ADMIN_TOKO->value);
|
||||
$submitter->forgetCachedPermissions();
|
||||
|
||||
$request = createOwnerVerificationRequestFor($submitter);
|
||||
|
||||
$this->actingAs($submitter)
|
||||
->post(route('admin.manage.owner_verifications.approve_request', $request))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('admin toko role has owner verification view permission', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole(RoleEnum::ADMIN_TOKO->value);
|
||||
$user->forgetCachedPermissions();
|
||||
|
||||
expect($user->can(PermissionEnum::OWNER_VERIFICATIONS_VIEW->value))->toBeTrue();
|
||||
expect($user->can(PermissionEnum::OWNER_VERIFICATIONS_VERIFY->value))->toBeFalse();
|
||||
});
|
||||
|
||||
test('admin bahan baku role has owner verification view permission', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole(RoleEnum::ADMIN_BAHAN_BAKU->value);
|
||||
$user->forgetCachedPermissions();
|
||||
|
||||
expect($user->can(PermissionEnum::OWNER_VERIFICATIONS_VIEW->value))->toBeTrue();
|
||||
expect($user->can(PermissionEnum::OWNER_VERIFICATIONS_VERIFY->value))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Owner Verification Unified Cutting List', function () {
|
||||
test('verifier sees pending cuttings in the same verification table', function () {
|
||||
$verifier = createOwnerVerifierUser();
|
||||
$cutting = createPendingCutting();
|
||||
$request = createOwnerVerificationRequestFor(User::factory()->create());
|
||||
|
||||
$response = $this->actingAs($verifier)
|
||||
->get(route('admin.manage.owner_verifications.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('admin/manage/owner-verifications/Index')
|
||||
->has('verificationRequests.data', 2)
|
||||
->where('verificationRequests.data.0.source', 'cutting')
|
||||
->where('verificationRequests.data.0.id', $cutting->id)
|
||||
->where('verificationRequests.data.0.action', OwnerVerificationAction::STOCK_VERIFY->value)
|
||||
->where('verificationRequests.data.0.subject_type', Cutting::class)
|
||||
->where('verificationRequests.data.1.source', 'request')
|
||||
->where('verificationRequests.data.1.id', $request->id)
|
||||
);
|
||||
});
|
||||
|
||||
test('submitter does not see pending cuttings in verification table', function () {
|
||||
$submitter = User::factory()->create();
|
||||
$submitter->assignRole(RoleEnum::ADMIN_TOKO->value);
|
||||
$submitter->forgetCachedPermissions();
|
||||
|
||||
createPendingCutting();
|
||||
createOwnerVerificationRequestFor($submitter);
|
||||
|
||||
$response = $this->actingAs($submitter)
|
||||
->get(route('admin.manage.owner_verifications.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('admin/manage/owner-verifications/Index')
|
||||
->has('verificationRequests.data', 1)
|
||||
->where('verificationRequests.data.0.source', 'request')
|
||||
);
|
||||
});
|
||||
|
||||
test('filtering by cutting module shows only pending cuttings', function () {
|
||||
$verifier = createOwnerVerifierUser();
|
||||
$cutting = createPendingCutting();
|
||||
createOwnerVerificationRequestFor(User::factory()->create());
|
||||
|
||||
$response = $this->actingAs($verifier)
|
||||
->get(route('admin.manage.owner_verifications.index', [
|
||||
'subject_type' => Cutting::class,
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('verificationRequests.data', 1)
|
||||
->where('verificationRequests.data.0.source', 'cutting')
|
||||
->where('verificationRequests.data.0.id', $cutting->id)
|
||||
);
|
||||
});
|
||||
|
||||
test('filtering by stock verify action shows only pending cuttings', function () {
|
||||
$verifier = createOwnerVerifierUser();
|
||||
$cutting = createPendingCutting();
|
||||
createOwnerVerificationRequestFor(User::factory()->create());
|
||||
|
||||
$response = $this->actingAs($verifier)
|
||||
->get(route('admin.manage.owner_verifications.index', [
|
||||
'action' => OwnerVerificationAction::STOCK_VERIFY->value,
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('verificationRequests.data', 1)
|
||||
->where('verificationRequests.data.0.source', 'cutting')
|
||||
->where('verificationRequests.data.0.id', $cutting->id)
|
||||
);
|
||||
});
|
||||
|
||||
test('filtering by approved status hides pending cuttings', function () {
|
||||
$verifier = createOwnerVerifierUser();
|
||||
createPendingCutting();
|
||||
|
||||
OwnerVerificationRequest::query()->create([
|
||||
'action' => OwnerVerificationAction::CREATE,
|
||||
'status' => OwnerVerificationStatus::APPROVED,
|
||||
'subject_type' => Product::class,
|
||||
'subject_id' => Product::factory()->create()->id,
|
||||
'submitted_by_id' => User::factory()->create()->id,
|
||||
'payload' => ['old' => null, 'new' => ['name' => 'Approved Product']],
|
||||
'verified_by_id' => $verifier->id,
|
||||
'verified_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($verifier)
|
||||
->get(route('admin.manage.owner_verifications.index', [
|
||||
'status' => OwnerVerificationStatus::APPROVED->value,
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('verificationRequests.data', 1)
|
||||
->where('verificationRequests.data.0.source', 'request')
|
||||
->where('verificationRequests.data.0.status', OwnerVerificationStatus::APPROVED->value)
|
||||
);
|
||||
});
|
||||
|
||||
test('subject options include cutting when pending cuttings exist', function () {
|
||||
$verifier = createOwnerVerifierUser();
|
||||
createPendingCutting();
|
||||
|
||||
$response = $this->actingAs($verifier)
|
||||
->get(route('admin.manage.owner_verifications.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->where('subjectOptions', fn ($options) => collect($options)->contains(
|
||||
fn (array $option) => $option['value'] === Cutting::class
|
||||
&& $option['label'] === ModelLabel::for(Cutting::class),
|
||||
))
|
||||
);
|
||||
});
|
||||
|
||||
test('verifier can approve pending cutting from verification page', function () {
|
||||
$verifier = createOwnerVerifierUser();
|
||||
$cutting = createPendingCutting();
|
||||
|
||||
$this->actingAs($verifier)
|
||||
->post(route('admin.manage.owner_verifications.approve', $cutting))
|
||||
->assertRedirect(route('admin.manage.owner_verifications.index'));
|
||||
|
||||
expect($cutting->fresh()->status)->toBe(CuttingStatus::VERIFIED);
|
||||
});
|
||||
|
||||
test('verifier can reject pending cutting from verification page', function () {
|
||||
$verifier = createOwnerVerifierUser();
|
||||
$cutting = createPendingCutting();
|
||||
|
||||
$this->actingAs($verifier)
|
||||
->post(route('admin.manage.owner_verifications.reject', $cutting), [
|
||||
'reason' => 'Data tidak sesuai',
|
||||
])
|
||||
->assertRedirect(route('admin.manage.owner_verifications.index'));
|
||||
|
||||
$cutting->refresh();
|
||||
|
||||
expect($cutting->status)->toBe(CuttingStatus::COMPLETED);
|
||||
expect($cutting->rejection?->reason)->toBe('Data tidak sesuai');
|
||||
expect($cutting->rejection?->rejected_by_id)->toBe($verifier->id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Owner Verification Purchase Module', function () {
|
||||
test('verifier sees purchase verification requests in index', function () {
|
||||
$verifier = createOwnerVerifierUser();
|
||||
$submitter = User::factory()->create();
|
||||
$purchase = Purchase::factory()->create();
|
||||
|
||||
OwnerVerificationRequest::query()->create([
|
||||
'action' => OwnerVerificationAction::CREATE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Purchase::class,
|
||||
'subject_id' => $purchase->id,
|
||||
'submitted_by_id' => $submitter->id,
|
||||
'payload' => [
|
||||
'old' => null,
|
||||
'new' => ['supplier_name' => $purchase->supplier?->name ?? 'Supplier'],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($verifier)
|
||||
->get(route('admin.manage.owner_verifications.index', [
|
||||
'subject_type' => Purchase::class,
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('verificationRequests.data', 1)
|
||||
->where('verificationRequests.data.0.source', 'request')
|
||||
->where('verificationRequests.data.0.subject_type', Purchase::class)
|
||||
->where('verificationRequests.data.0.subject_id', $purchase->id)
|
||||
);
|
||||
});
|
||||
|
||||
test('purchase submitter sees only own purchase verification requests', function () {
|
||||
$submitter = User::factory()->create();
|
||||
$submitter->assignRole(RoleEnum::ADMIN_BAHAN_BAKU->value);
|
||||
$submitter->forgetCachedPermissions();
|
||||
|
||||
$otherUser = User::factory()->create();
|
||||
$ownPurchase = Purchase::factory()->create(['created_by_id' => $submitter->id]);
|
||||
$otherPurchase = Purchase::factory()->create(['created_by_id' => $otherUser->id]);
|
||||
|
||||
OwnerVerificationRequest::query()->create([
|
||||
'action' => OwnerVerificationAction::CREATE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Purchase::class,
|
||||
'subject_id' => $ownPurchase->id,
|
||||
'submitted_by_id' => $submitter->id,
|
||||
'payload' => ['old' => null, 'new' => ['supplier_name' => 'Own']],
|
||||
]);
|
||||
|
||||
OwnerVerificationRequest::query()->create([
|
||||
'action' => OwnerVerificationAction::CREATE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Purchase::class,
|
||||
'subject_id' => $otherPurchase->id,
|
||||
'submitted_by_id' => $otherUser->id,
|
||||
'payload' => ['old' => null, 'new' => ['supplier_name' => 'Other']],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($submitter)
|
||||
->get(route('admin.manage.owner_verifications.index', [
|
||||
'subject_type' => Purchase::class,
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('verificationRequests.data', 1)
|
||||
->where('verificationRequests.data.0.subject_id', $ownPurchase->id)
|
||||
);
|
||||
});
|
||||
});
|
||||
341
tests/Feature/Admin/Manage/PurchaseTest.php
Normal file
341
tests/Feature/Admin/Manage/PurchaseTest.php
Normal file
@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\OwnerVerificationAction;
|
||||
use App\Enums\OwnerVerificationStatus;
|
||||
use App\Enums\Permission as PermissionEnum;
|
||||
use App\Models\OwnerVerificationRequest;
|
||||
use App\Models\Purchase;
|
||||
use App\Models\PurchaseItem;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
});
|
||||
|
||||
function createPurchaseUserWithPermission(PermissionEnum ...$permissions): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->givePermissionTo(
|
||||
array_merge(
|
||||
[PermissionEnum::DASHBOARD_VIEW->value],
|
||||
array_map(fn (PermissionEnum $p) => $p->value, $permissions)
|
||||
)
|
||||
);
|
||||
|
||||
$user->forgetCachedPermissions();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function createPurchaseVerifierUser(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->givePermissionTo([
|
||||
PermissionEnum::DASHBOARD_VIEW->value,
|
||||
PermissionEnum::OWNER_VERIFICATIONS_VIEW->value,
|
||||
PermissionEnum::OWNER_VERIFICATIONS_VERIFY->value,
|
||||
PermissionEnum::OWNER_VERIFICATIONS_REJECT->value,
|
||||
]);
|
||||
|
||||
$user->forgetCachedPermissions();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerificationRequest
|
||||
{
|
||||
$verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail();
|
||||
|
||||
test()->actingAs($verifier)
|
||||
->post(route('admin.manage.owner_verifications.approve_request', $verificationRequest))
|
||||
->assertRedirect(route('admin.manage.owner_verifications.index'));
|
||||
|
||||
return $verificationRequest->fresh();
|
||||
}
|
||||
|
||||
describe('Purchase Owner Verification', function () {
|
||||
test('create purchase submits verification without changing stock', function () {
|
||||
$user = createPurchaseUserWithPermission(
|
||||
PermissionEnum::PURCHASES_VIEW,
|
||||
PermissionEnum::PURCHASES_CREATE,
|
||||
);
|
||||
|
||||
$supplier = Supplier::factory()->create();
|
||||
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
|
||||
$initialStock = (float) $price->stock;
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.purchases.draft_items.store'), [
|
||||
'raw_material_price_id' => $price->id,
|
||||
'quantity' => 2,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.purchases.store'), [
|
||||
'supplier_id' => $supplier->id,
|
||||
'discount' => 0,
|
||||
'shipping_cost' => 0,
|
||||
'notes' => 'Belanja test',
|
||||
])
|
||||
->assertRedirect(route('admin.manage.purchases.index'));
|
||||
|
||||
$purchase = Purchase::query()->latest()->first();
|
||||
|
||||
expect($purchase)->not->toBeNull();
|
||||
|
||||
$this->assertDatabaseHas('owner_verification_requests', [
|
||||
'subject_type' => Purchase::class,
|
||||
'subject_id' => $purchase->id,
|
||||
'action' => OwnerVerificationAction::CREATE->value,
|
||||
'status' => OwnerVerificationStatus::PENDING->value,
|
||||
]);
|
||||
|
||||
expect((float) $price->fresh()->stock)->toBe($initialStock);
|
||||
});
|
||||
|
||||
test('approving create purchase increments stock', function () {
|
||||
$user = createPurchaseUserWithPermission(
|
||||
PermissionEnum::PURCHASES_VIEW,
|
||||
PermissionEnum::PURCHASES_CREATE,
|
||||
);
|
||||
$verifier = createPurchaseVerifierUser();
|
||||
|
||||
$supplier = Supplier::factory()->create();
|
||||
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.purchases.draft_items.store'), [
|
||||
'raw_material_price_id' => $price->id,
|
||||
'quantity' => 2,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.purchases.store'), [
|
||||
'supplier_id' => $supplier->id,
|
||||
'discount' => 0,
|
||||
'shipping_cost' => 0,
|
||||
])
|
||||
->assertRedirect(route('admin.manage.purchases.index'));
|
||||
|
||||
approveLatestPurchaseVerificationRequest($verifier);
|
||||
|
||||
expect((float) $price->fresh()->stock)->toBe(12.0);
|
||||
});
|
||||
|
||||
test('update purchase submits verification request only', function () {
|
||||
$user = createPurchaseUserWithPermission(
|
||||
PermissionEnum::PURCHASES_VIEW,
|
||||
PermissionEnum::PURCHASES_CREATE,
|
||||
PermissionEnum::PURCHASES_UPDATE,
|
||||
);
|
||||
$verifier = createPurchaseVerifierUser();
|
||||
|
||||
$supplier = Supplier::factory()->create();
|
||||
$price = RawMaterialPrice::factory()->create(['stock' => 10, 'price' => 50_000]);
|
||||
$otherPrice = RawMaterialPrice::factory()->create(['stock' => 5, 'price' => 30_000]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.purchases.draft_items.store'), [
|
||||
'raw_material_price_id' => $price->id,
|
||||
'quantity' => 2,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.purchases.store'), [
|
||||
'supplier_id' => $supplier->id,
|
||||
'discount' => 0,
|
||||
'shipping_cost' => 0,
|
||||
])
|
||||
->assertRedirect(route('admin.manage.purchases.index'));
|
||||
|
||||
approveLatestPurchaseVerificationRequest($verifier);
|
||||
|
||||
$purchase = Purchase::query()->latest()->firstOrFail();
|
||||
$originalTotal = $purchase->total;
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('admin.manage.purchases.update', $purchase), [
|
||||
'supplier_id' => $supplier->id,
|
||||
'discount' => 1000,
|
||||
'shipping_cost' => 0,
|
||||
'notes' => 'Ubah belanja',
|
||||
'items' => [
|
||||
['raw_material_price_id' => $otherPrice->id, 'quantity' => 1],
|
||||
],
|
||||
])
|
||||
->assertRedirect(route('admin.manage.purchases.index'));
|
||||
|
||||
$this->assertDatabaseHas('owner_verification_requests', [
|
||||
'subject_type' => Purchase::class,
|
||||
'subject_id' => $purchase->id,
|
||||
'action' => OwnerVerificationAction::UPDATE->value,
|
||||
'status' => OwnerVerificationStatus::PENDING->value,
|
||||
]);
|
||||
|
||||
expect($purchase->fresh()->total)->toBe($originalTotal);
|
||||
});
|
||||
|
||||
test('delete purchase submits verification request only', function () {
|
||||
$user = createPurchaseUserWithPermission(
|
||||
PermissionEnum::PURCHASES_VIEW,
|
||||
PermissionEnum::PURCHASES_CREATE,
|
||||
PermissionEnum::PURCHASES_DELETE,
|
||||
);
|
||||
$verifier = createPurchaseVerifierUser();
|
||||
|
||||
$supplier = Supplier::factory()->create();
|
||||
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.purchases.draft_items.store'), [
|
||||
'raw_material_price_id' => $price->id,
|
||||
'quantity' => 2,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.purchases.store'), [
|
||||
'supplier_id' => $supplier->id,
|
||||
'discount' => 0,
|
||||
'shipping_cost' => 0,
|
||||
])
|
||||
->assertRedirect(route('admin.manage.purchases.index'));
|
||||
|
||||
approveLatestPurchaseVerificationRequest($verifier);
|
||||
|
||||
$purchase = Purchase::query()->latest()->firstOrFail();
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('admin.manage.purchases.destroy', $purchase))
|
||||
->assertRedirect(route('admin.manage.purchases.index'));
|
||||
|
||||
$this->assertDatabaseHas('owner_verification_requests', [
|
||||
'subject_type' => Purchase::class,
|
||||
'subject_id' => $purchase->id,
|
||||
'action' => OwnerVerificationAction::DELETE->value,
|
||||
'status' => OwnerVerificationStatus::PENDING->value,
|
||||
]);
|
||||
|
||||
expect(Purchase::query()->whereKey($purchase->id)->exists())->toBeTrue();
|
||||
expect((float) $price->fresh()->stock)->toBe(12.0);
|
||||
});
|
||||
|
||||
test('rejecting create purchase deletes purchase without changing stock', function () {
|
||||
$user = createPurchaseUserWithPermission(
|
||||
PermissionEnum::PURCHASES_VIEW,
|
||||
PermissionEnum::PURCHASES_CREATE,
|
||||
);
|
||||
$verifier = createPurchaseVerifierUser();
|
||||
|
||||
$supplier = Supplier::factory()->create();
|
||||
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.purchases.draft_items.store'), [
|
||||
'raw_material_price_id' => $price->id,
|
||||
'quantity' => 2,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.purchases.store'), [
|
||||
'supplier_id' => $supplier->id,
|
||||
'discount' => 0,
|
||||
'shipping_cost' => 0,
|
||||
])
|
||||
->assertRedirect(route('admin.manage.purchases.index'));
|
||||
|
||||
$purchase = Purchase::query()->latest()->firstOrFail();
|
||||
$verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail();
|
||||
|
||||
$this->actingAs($verifier)
|
||||
->post(route('admin.manage.owner_verifications.reject_request', $verificationRequest), [
|
||||
'reason' => 'Tidak sesuai',
|
||||
])
|
||||
->assertRedirect(route('admin.manage.owner_verifications.index'));
|
||||
|
||||
expect(Purchase::query()->whereKey($purchase->id)->exists())->toBeFalse();
|
||||
expect(PurchaseItem::query()->where('purchase_id', $purchase->id)->exists())->toBeFalse();
|
||||
expect((float) $price->fresh()->stock)->toBe(10.0);
|
||||
});
|
||||
|
||||
test('pending purchase blocks edit route', function () {
|
||||
$user = createPurchaseUserWithPermission(
|
||||
PermissionEnum::PURCHASES_VIEW,
|
||||
PermissionEnum::PURCHASES_CREATE,
|
||||
PermissionEnum::PURCHASES_UPDATE,
|
||||
);
|
||||
|
||||
$supplier = Supplier::factory()->create();
|
||||
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.purchases.draft_items.store'), [
|
||||
'raw_material_price_id' => $price->id,
|
||||
'quantity' => 2,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.purchases.store'), [
|
||||
'supplier_id' => $supplier->id,
|
||||
'discount' => 0,
|
||||
'shipping_cost' => 0,
|
||||
])
|
||||
->assertRedirect(route('admin.manage.purchases.index'));
|
||||
|
||||
$purchase = Purchase::query()->latest()->firstOrFail();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.manage.purchases.edit', $purchase))
|
||||
->assertRedirect(route('admin.manage.purchases.index'));
|
||||
});
|
||||
|
||||
test('purchase index exposes pending verification state', function () {
|
||||
$user = createPurchaseUserWithPermission(
|
||||
PermissionEnum::PURCHASES_VIEW,
|
||||
PermissionEnum::PURCHASES_CREATE,
|
||||
);
|
||||
|
||||
$supplier = Supplier::factory()->create();
|
||||
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.purchases.draft_items.store'), [
|
||||
'raw_material_price_id' => $price->id,
|
||||
'quantity' => 2,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.purchases.store'), [
|
||||
'supplier_id' => $supplier->id,
|
||||
'discount' => 0,
|
||||
'shipping_cost' => 0,
|
||||
])
|
||||
->assertRedirect(route('admin.manage.purchases.index'));
|
||||
|
||||
$purchase = Purchase::query()->latest()->firstOrFail();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('admin.manage.purchases.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->where('purchases.data.0.id', $purchase->id)
|
||||
->where('purchases.data.0.has_pending_request', true)
|
||||
->where('purchases.data.0.pending_request_action', OwnerVerificationAction::CREATE->value)
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -1,7 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\OwnerVerificationAction;
|
||||
use App\Enums\OwnerVerificationStatus;
|
||||
use App\Enums\Permission as PermissionEnum;
|
||||
use App\Models\Category;
|
||||
use App\Models\OwnerVerificationRequest;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\User;
|
||||
@ -51,6 +54,33 @@ function createProductWithVariants(?Category $category = null): Product
|
||||
return $product;
|
||||
}
|
||||
|
||||
function createProductVerifierUser(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->givePermissionTo([
|
||||
PermissionEnum::DASHBOARD_VIEW->value,
|
||||
PermissionEnum::OWNER_VERIFICATIONS_VIEW->value,
|
||||
PermissionEnum::OWNER_VERIFICATIONS_VERIFY->value,
|
||||
PermissionEnum::OWNER_VERIFICATIONS_REJECT->value,
|
||||
]);
|
||||
|
||||
$user->forgetCachedPermissions();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function approveLatestOwnerVerificationRequest(User $verifier): OwnerVerificationRequest
|
||||
{
|
||||
$verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail();
|
||||
|
||||
test()->actingAs($verifier)
|
||||
->post(route('admin.manage.owner_verifications.approve_request', $verificationRequest))
|
||||
->assertRedirect(route('admin.manage.owner_verifications.index'));
|
||||
|
||||
return $verificationRequest->fresh();
|
||||
}
|
||||
|
||||
function variantWithImage(string $name = 'All Size', int $stock = 10): array
|
||||
{
|
||||
return [
|
||||
@ -160,7 +190,7 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
|
||||
// ─── Store ────────────────────────────────────────────────
|
||||
|
||||
describe('Product Store', function () {
|
||||
test('authenticated user with permission can create a product', function () {
|
||||
test('authenticated user with permission can submit product creation request', function () {
|
||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE);
|
||||
|
||||
$category = Category::factory()->create();
|
||||
@ -176,8 +206,15 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
|
||||
])
|
||||
->assertRedirect(route('admin.master.products.index'));
|
||||
|
||||
$this->assertDatabaseHas('owner_verification_requests', [
|
||||
'subject_type' => Product::class,
|
||||
'action' => OwnerVerificationAction::CREATE->value,
|
||||
'status' => OwnerVerificationStatus::PENDING->value,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('products', [
|
||||
'name' => 'Produk Baru',
|
||||
'is_active' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
@ -313,8 +350,9 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
|
||||
->assertSessionHasErrors('variants.0.stock');
|
||||
});
|
||||
|
||||
test('creating product also creates variants and syncs categories', function () {
|
||||
test('approving create request activates product with variants and categories', function () {
|
||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE);
|
||||
$verifier = createProductVerifierUser();
|
||||
|
||||
$cat1 = Category::factory()->create();
|
||||
$cat2 = Category::factory()->create();
|
||||
@ -333,12 +371,19 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
|
||||
|
||||
$product = Product::where('name', 'Produk Lengkap')->first();
|
||||
expect($product)->not->toBeNull();
|
||||
expect($product->is_active)->toBeFalse();
|
||||
expect($product->categories)->toHaveCount(2);
|
||||
expect($product->variants)->toHaveCount(3);
|
||||
|
||||
approveLatestOwnerVerificationRequest($verifier);
|
||||
|
||||
$product->refresh();
|
||||
expect($product->is_active)->toBeTrue();
|
||||
});
|
||||
|
||||
test('product slug is auto-generated', function () {
|
||||
test('approving create request auto-generates product slug', function () {
|
||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE);
|
||||
$verifier = createProductVerifierUser();
|
||||
|
||||
$category = Category::factory()->create();
|
||||
|
||||
@ -352,7 +397,43 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
|
||||
$this->assertDatabaseHas('products', [
|
||||
'name' => 'Batik Modern Elegan',
|
||||
'slug' => 'batik-modern-elegan',
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
approveLatestOwnerVerificationRequest($verifier);
|
||||
|
||||
$this->assertDatabaseHas('products', [
|
||||
'name' => 'Batik Modern Elegan',
|
||||
'slug' => 'batik-modern-elegan',
|
||||
'is_active' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejecting create request deletes product', function () {
|
||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE);
|
||||
$verifier = createProductVerifierUser();
|
||||
|
||||
$category = Category::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.master.products.store'), [
|
||||
'name' => 'Produk Ditolak',
|
||||
'category_ids' => [$category->id],
|
||||
'variants' => [variantWithImage()],
|
||||
]);
|
||||
|
||||
$product = Product::where('name', 'Produk Ditolak')->first();
|
||||
expect($product)->not->toBeNull();
|
||||
|
||||
$verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail();
|
||||
|
||||
$this->actingAs($verifier)
|
||||
->post(route('admin.manage.owner_verifications.reject_request', $verificationRequest), [
|
||||
'reason' => 'Tidak sesuai standar',
|
||||
])
|
||||
->assertRedirect(route('admin.manage.owner_verifications.index'));
|
||||
|
||||
$this->assertSoftDeleted('products', ['id' => $product->id]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -385,12 +466,33 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
|
||||
->get(route('admin.master.products.edit', $product))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('edit is blocked when product has pending request', function () {
|
||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_UPDATE);
|
||||
|
||||
$product = createProductWithVariants();
|
||||
$category = $product->categories->first();
|
||||
$variant = $product->variants->first();
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('admin.master.products.update', $product), [
|
||||
'name' => 'Nama Diubah',
|
||||
'category_ids' => [$category->id],
|
||||
'variants' => [
|
||||
['id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.master.products.edit', $product))
|
||||
->assertRedirect(route('admin.master.products.index'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Update ───────────────────────────────────────────────
|
||||
|
||||
describe('Product Update', function () {
|
||||
test('authenticated user with permission can update a product', function () {
|
||||
test('authenticated user with permission can submit product update request', function () {
|
||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_UPDATE);
|
||||
|
||||
$product = createProductWithVariants();
|
||||
@ -408,6 +510,36 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
|
||||
])
|
||||
->assertRedirect(route('admin.master.products.index'));
|
||||
|
||||
$this->assertDatabaseHas('owner_verification_requests', [
|
||||
'subject_id' => $product->id,
|
||||
'subject_type' => Product::class,
|
||||
'action' => OwnerVerificationAction::UPDATE->value,
|
||||
'status' => OwnerVerificationStatus::PENDING->value,
|
||||
]);
|
||||
|
||||
expect($product->fresh()->name)->not->toBe('Nama Diubah');
|
||||
});
|
||||
|
||||
test('approving update request applies product changes', function () {
|
||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_UPDATE);
|
||||
$verifier = createProductVerifierUser();
|
||||
|
||||
$product = createProductWithVariants();
|
||||
$category = $product->categories->first();
|
||||
$variant = $product->variants->first();
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('admin.master.products.update', $product), [
|
||||
'name' => 'Nama Diubah',
|
||||
'description' => 'Deskripsi baru',
|
||||
'category_ids' => [$category->id],
|
||||
'variants' => [
|
||||
['id' => $variant->id, 'name' => 'New Variant', 'stock' => 20],
|
||||
],
|
||||
]);
|
||||
|
||||
approveLatestOwnerVerificationRequest($verifier);
|
||||
|
||||
expect($product->fresh()->name)->toBe('Nama Diubah');
|
||||
expect($variant->fresh()->name)->toBe('New Variant');
|
||||
});
|
||||
@ -473,8 +605,9 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
|
||||
->assertSessionHasErrors('category_ids');
|
||||
});
|
||||
|
||||
test('unsubmitted variants are removed on update', function () {
|
||||
test('approving update request removes unsubmitted variants', function () {
|
||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_UPDATE);
|
||||
$verifier = createProductVerifierUser();
|
||||
|
||||
$product = createProductWithVariants();
|
||||
$category = $product->categories->first();
|
||||
@ -489,6 +622,8 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
|
||||
],
|
||||
]);
|
||||
|
||||
approveLatestOwnerVerificationRequest($verifier);
|
||||
|
||||
$product->refresh();
|
||||
expect($product->variants)->toHaveCount(1);
|
||||
expect($product->variants->first()->name)->toBe('Kept Variant');
|
||||
@ -498,7 +633,7 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
|
||||
// ─── Toggle Status ────────────────────────────────────────
|
||||
|
||||
describe('Product Toggle Status', function () {
|
||||
test('authenticated user with permission can toggle product status', function () {
|
||||
test('authenticated user with permission can submit toggle status request', function () {
|
||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_TOGGLE_STATUS);
|
||||
|
||||
$product = createProductWithVariants();
|
||||
@ -509,6 +644,57 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('owner_verification_requests', [
|
||||
'subject_id' => $product->id,
|
||||
'subject_type' => Product::class,
|
||||
'action' => OwnerVerificationAction::TOGGLE_STATUS->value,
|
||||
'status' => OwnerVerificationStatus::PENDING->value,
|
||||
]);
|
||||
|
||||
expect($product->fresh()->is_active)->toBeTrue();
|
||||
});
|
||||
|
||||
test('product index exposes display is active for pending toggle status request', function () {
|
||||
$user = createProductUserWithPermission(
|
||||
PermissionEnum::PRODUCTS_VIEW,
|
||||
PermissionEnum::PRODUCTS_TOGGLE_STATUS,
|
||||
);
|
||||
|
||||
$product = createProductWithVariants();
|
||||
$product->update(['is_active' => true]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('admin.master.products.toggle_status', $product), [
|
||||
'is_active' => false,
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('admin.master.products.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->where('products.data.0.id', $product->id)
|
||||
->where('products.data.0.is_active', true)
|
||||
->where('products.data.0.display_is_active', false)
|
||||
->where('products.data.0.has_pending_request', true)
|
||||
->where('products.data.0.pending_request_action', OwnerVerificationAction::TOGGLE_STATUS->value)
|
||||
);
|
||||
});
|
||||
|
||||
test('approving toggle status request updates product status', function () {
|
||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_TOGGLE_STATUS);
|
||||
$verifier = createProductVerifierUser();
|
||||
|
||||
$product = createProductWithVariants();
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('admin.master.products.toggle_status', $product), [
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
approveLatestOwnerVerificationRequest($verifier);
|
||||
|
||||
expect($product->fresh()->is_active)->toBeFalse();
|
||||
});
|
||||
|
||||
@ -548,7 +734,7 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
|
||||
// ─── Destroy ──────────────────────────────────────────────
|
||||
|
||||
describe('Product Destroy', function () {
|
||||
test('authenticated user with permission can delete a product', function () {
|
||||
test('authenticated user with permission can submit delete request', function () {
|
||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_DELETE);
|
||||
|
||||
$product = createProductWithVariants();
|
||||
@ -557,7 +743,47 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
|
||||
->delete(route('admin.master.products.destroy', $product))
|
||||
->assertRedirect(route('admin.master.products.index'));
|
||||
|
||||
$this->assertDatabaseHas('owner_verification_requests', [
|
||||
'subject_id' => $product->id,
|
||||
'subject_type' => Product::class,
|
||||
'action' => OwnerVerificationAction::DELETE->value,
|
||||
'status' => OwnerVerificationStatus::PENDING->value,
|
||||
]);
|
||||
|
||||
$this->assertNotSoftDeleted('products', ['id' => $product->id]);
|
||||
});
|
||||
|
||||
test('approving delete request soft deletes product and variants', function () {
|
||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_DELETE);
|
||||
$verifier = createProductVerifierUser();
|
||||
|
||||
$product = createProductWithVariants();
|
||||
$variantIds = $product->variants->pluck('id')->toArray();
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('admin.master.products.destroy', $product));
|
||||
|
||||
approveLatestOwnerVerificationRequest($verifier);
|
||||
|
||||
$this->assertSoftDeleted('products', ['id' => $product->id]);
|
||||
|
||||
foreach ($variantIds as $variantId) {
|
||||
$this->assertSoftDeleted('product_variants', ['id' => $variantId]);
|
||||
}
|
||||
});
|
||||
|
||||
test('approving delete request detaches categories', function () {
|
||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_DELETE);
|
||||
$verifier = createProductVerifierUser();
|
||||
|
||||
$product = createProductWithVariants();
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('admin.master.products.destroy', $product));
|
||||
|
||||
approveLatestOwnerVerificationRequest($verifier);
|
||||
|
||||
$this->assertDatabaseMissing('product_categories', ['product_id' => $product->id]);
|
||||
});
|
||||
|
||||
test('guest cannot delete a product', function () {
|
||||
@ -580,33 +806,6 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
|
||||
|
||||
$this->assertNotSoftDeleted('products', ['id' => $product->id]);
|
||||
});
|
||||
|
||||
test('deleting product also soft deletes variants', function () {
|
||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_DELETE);
|
||||
|
||||
$product = createProductWithVariants();
|
||||
$variantIds = $product->variants->pluck('id')->toArray();
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('admin.master.products.destroy', $product));
|
||||
|
||||
$this->assertSoftDeleted('products', ['id' => $product->id]);
|
||||
|
||||
foreach ($variantIds as $variantId) {
|
||||
$this->assertSoftDeleted('product_variants', ['id' => $variantId]);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleting product detaches categories', function () {
|
||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_DELETE);
|
||||
|
||||
$product = createProductWithVariants();
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('admin.master.products.destroy', $product));
|
||||
|
||||
$this->assertDatabaseMissing('product_categories', ['product_id' => $product->id]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Product Model ────────────────────────────────────────
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\OwnerVerificationAction;
|
||||
use App\Enums\OwnerVerificationStatus;
|
||||
use App\Enums\Permission as PermissionEnum;
|
||||
use App\Enums\RawMaterialUnit;
|
||||
use App\Models\OwnerVerificationRequest;
|
||||
use App\Models\RawMaterial;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\User;
|
||||
@ -59,6 +62,33 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
|
||||
];
|
||||
}
|
||||
|
||||
function createRawMaterialVerifierUser(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->givePermissionTo([
|
||||
PermissionEnum::DASHBOARD_VIEW->value,
|
||||
PermissionEnum::OWNER_VERIFICATIONS_VIEW->value,
|
||||
PermissionEnum::OWNER_VERIFICATIONS_VERIFY->value,
|
||||
PermissionEnum::OWNER_VERIFICATIONS_REJECT->value,
|
||||
]);
|
||||
|
||||
$user->forgetCachedPermissions();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function approveLatestOwnerVerificationRequest(User $verifier): OwnerVerificationRequest
|
||||
{
|
||||
$verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail();
|
||||
|
||||
test()->actingAs($verifier)
|
||||
->post(route('admin.manage.owner_verifications.approve_request', $verificationRequest))
|
||||
->assertRedirect(route('admin.manage.owner_verifications.index'));
|
||||
|
||||
return $verificationRequest->fresh();
|
||||
}
|
||||
|
||||
// ─── Index ────────────────────────────────────────────────
|
||||
|
||||
describe('Raw Material Index', function () {
|
||||
@ -146,7 +176,7 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
|
||||
// ─── Store ────────────────────────────────────────────────
|
||||
|
||||
describe('Raw Material Store', function () {
|
||||
test('authenticated user with permission can create a raw material', function () {
|
||||
test('authenticated user with permission can submit raw material creation request', function () {
|
||||
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_CREATE);
|
||||
|
||||
$this->actingAs($user)
|
||||
@ -162,6 +192,13 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
|
||||
$this->assertDatabaseHas('raw_materials', [
|
||||
'name' => 'Kain Sutra',
|
||||
'unit' => RawMaterialUnit::METER->value,
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('owner_verification_requests', [
|
||||
'subject_type' => RawMaterial::class,
|
||||
'action' => OwnerVerificationAction::CREATE->value,
|
||||
'status' => OwnerVerificationStatus::PENDING->value,
|
||||
]);
|
||||
});
|
||||
|
||||
@ -346,7 +383,7 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
|
||||
// ─── Update ───────────────────────────────────────────────
|
||||
|
||||
describe('Raw Material Update', function () {
|
||||
test('authenticated user with permission can update a raw material', function () {
|
||||
test('authenticated user with permission can submit raw material update request', function () {
|
||||
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_UPDATE);
|
||||
|
||||
$rawMaterial = createRawMaterialWithPrices();
|
||||
@ -362,6 +399,35 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
|
||||
])
|
||||
->assertRedirect(route('admin.master.raw_materials.index'));
|
||||
|
||||
$this->assertDatabaseHas('owner_verification_requests', [
|
||||
'subject_id' => $rawMaterial->id,
|
||||
'subject_type' => RawMaterial::class,
|
||||
'action' => OwnerVerificationAction::UPDATE->value,
|
||||
'status' => OwnerVerificationStatus::PENDING->value,
|
||||
]);
|
||||
|
||||
expect($rawMaterial->fresh()->name)->not->toBe('Nama Diubah');
|
||||
expect($price->fresh()->variant)->not->toBe('Putih');
|
||||
});
|
||||
|
||||
test('approving update request applies raw material changes', function () {
|
||||
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_UPDATE);
|
||||
$verifier = createRawMaterialVerifierUser();
|
||||
|
||||
$rawMaterial = createRawMaterialWithPrices();
|
||||
$price = $rawMaterial->prices->first();
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('admin.master.raw_materials.update', $rawMaterial), [
|
||||
'name' => 'Nama Diubah',
|
||||
'unit' => RawMaterialUnit::KILOGRAM->value,
|
||||
'prices' => [
|
||||
['id' => $price->id, 'variant' => 'Putih', 'price' => 90000, 'stock' => 25],
|
||||
],
|
||||
]);
|
||||
|
||||
approveLatestOwnerVerificationRequest($verifier);
|
||||
|
||||
expect($rawMaterial->fresh()->name)->toBe('Nama Diubah');
|
||||
expect($price->fresh()->variant)->toBe('Putih');
|
||||
});
|
||||
@ -423,8 +489,9 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
|
||||
->assertSessionHasErrors('prices');
|
||||
});
|
||||
|
||||
test('unsubmitted prices are removed on update', function () {
|
||||
test('unsubmitted prices are removed after approving update request', function () {
|
||||
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_UPDATE);
|
||||
$verifier = createRawMaterialVerifierUser();
|
||||
|
||||
$rawMaterial = createRawMaterialWithPrices();
|
||||
$priceToKeep = $rawMaterial->prices->first();
|
||||
@ -438,6 +505,8 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
|
||||
],
|
||||
]);
|
||||
|
||||
approveLatestOwnerVerificationRequest($verifier);
|
||||
|
||||
$rawMaterial->refresh();
|
||||
expect($rawMaterial->prices)->toHaveCount(1);
|
||||
expect($rawMaterial->prices->first()->variant)->toBe('Kept');
|
||||
@ -447,7 +516,7 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
|
||||
// ─── Toggle Status ────────────────────────────────────────
|
||||
|
||||
describe('Raw Material Toggle Status', function () {
|
||||
test('authenticated user with permission can toggle raw material status', function () {
|
||||
test('authenticated user with permission can submit toggle status request', function () {
|
||||
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_TOGGLE_STATUS);
|
||||
|
||||
$rawMaterial = RawMaterial::factory()->create();
|
||||
@ -458,6 +527,56 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('owner_verification_requests', [
|
||||
'subject_id' => $rawMaterial->id,
|
||||
'subject_type' => RawMaterial::class,
|
||||
'action' => OwnerVerificationAction::TOGGLE_STATUS->value,
|
||||
'status' => OwnerVerificationStatus::PENDING->value,
|
||||
]);
|
||||
|
||||
expect($rawMaterial->fresh()->is_active)->toBeTrue();
|
||||
});
|
||||
|
||||
test('raw material index exposes display is active for pending toggle status request', function () {
|
||||
$user = createRawMaterialUserWithPermission(
|
||||
PermissionEnum::RAW_MATERIALS_VIEW,
|
||||
PermissionEnum::RAW_MATERIALS_TOGGLE_STATUS,
|
||||
);
|
||||
|
||||
$rawMaterial = RawMaterial::factory()->create(['is_active' => true]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('admin.master.raw_materials.toggle_status', $rawMaterial), [
|
||||
'is_active' => false,
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('admin.master.raw_materials.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->where('rawMaterials.data.0.id', $rawMaterial->id)
|
||||
->where('rawMaterials.data.0.is_active', true)
|
||||
->where('rawMaterials.data.0.display_is_active', false)
|
||||
->where('rawMaterials.data.0.has_pending_request', true)
|
||||
->where('rawMaterials.data.0.pending_request_action', OwnerVerificationAction::TOGGLE_STATUS->value)
|
||||
);
|
||||
});
|
||||
|
||||
test('approving toggle status request updates raw material status', function () {
|
||||
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_TOGGLE_STATUS);
|
||||
$verifier = createRawMaterialVerifierUser();
|
||||
|
||||
$rawMaterial = RawMaterial::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('admin.master.raw_materials.toggle_status', $rawMaterial), [
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
approveLatestOwnerVerificationRequest($verifier);
|
||||
|
||||
expect($rawMaterial->fresh()->is_active)->toBeFalse();
|
||||
});
|
||||
|
||||
@ -497,7 +616,7 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
|
||||
// ─── Destroy ──────────────────────────────────────────────
|
||||
|
||||
describe('Raw Material Destroy', function () {
|
||||
test('authenticated user with permission can delete a raw material', function () {
|
||||
test('authenticated user with permission can submit delete request', function () {
|
||||
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_DELETE);
|
||||
|
||||
$rawMaterial = createRawMaterialWithPrices();
|
||||
@ -506,7 +625,33 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
|
||||
->delete(route('admin.master.raw_materials.destroy', $rawMaterial))
|
||||
->assertRedirect(route('admin.master.raw_materials.index'));
|
||||
|
||||
$this->assertDatabaseHas('owner_verification_requests', [
|
||||
'subject_id' => $rawMaterial->id,
|
||||
'subject_type' => RawMaterial::class,
|
||||
'action' => OwnerVerificationAction::DELETE->value,
|
||||
'status' => OwnerVerificationStatus::PENDING->value,
|
||||
]);
|
||||
|
||||
$this->assertNotSoftDeleted('raw_materials', ['id' => $rawMaterial->id]);
|
||||
});
|
||||
|
||||
test('approving delete request soft deletes raw material and prices', function () {
|
||||
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_DELETE);
|
||||
$verifier = createRawMaterialVerifierUser();
|
||||
|
||||
$rawMaterial = createRawMaterialWithPrices();
|
||||
$priceIds = $rawMaterial->prices->pluck('id')->toArray();
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('admin.master.raw_materials.destroy', $rawMaterial));
|
||||
|
||||
approveLatestOwnerVerificationRequest($verifier);
|
||||
|
||||
$this->assertSoftDeleted('raw_materials', ['id' => $rawMaterial->id]);
|
||||
|
||||
foreach ($priceIds as $priceId) {
|
||||
$this->assertSoftDeleted('raw_material_prices', ['id' => $priceId]);
|
||||
}
|
||||
});
|
||||
|
||||
test('guest cannot delete a raw material', function () {
|
||||
@ -530,7 +675,7 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
|
||||
$this->assertNotSoftDeleted('raw_materials', ['id' => $rawMaterial->id]);
|
||||
});
|
||||
|
||||
test('deleting raw material also soft deletes prices', function () {
|
||||
test('deleting raw material is not applied until owner approves', function () {
|
||||
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_DELETE);
|
||||
|
||||
$rawMaterial = createRawMaterialWithPrices();
|
||||
@ -539,10 +684,10 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
|
||||
$this->actingAs($user)
|
||||
->delete(route('admin.master.raw_materials.destroy', $rawMaterial));
|
||||
|
||||
$this->assertSoftDeleted('raw_materials', ['id' => $rawMaterial->id]);
|
||||
$this->assertNotSoftDeleted('raw_materials', ['id' => $rawMaterial->id]);
|
||||
|
||||
foreach ($priceIds as $priceId) {
|
||||
$this->assertSoftDeleted('raw_material_prices', ['id' => $priceId]);
|
||||
$this->assertNotSoftDeleted('raw_material_prices', ['id' => $priceId]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user