diff --git a/app/Enums/OwnerVerificationAction.php b/app/Enums/OwnerVerificationAction.php new file mode 100644 index 0000000..528b8a9 --- /dev/null +++ b/app/Enums/OwnerVerificationAction.php @@ -0,0 +1,27 @@ + 'Tambah', + self::UPDATE => 'Ubah', + self::DELETE => 'Hapus', + self::TOGGLE_STATUS => 'Ubah Status', + self::STOCK_VERIFY => 'Verifikasi Stok', + }; + } +} diff --git a/app/Enums/OwnerVerificationStatus.php b/app/Enums/OwnerVerificationStatus.php new file mode 100644 index 0000000..5be05fa --- /dev/null +++ b/app/Enums/OwnerVerificationStatus.php @@ -0,0 +1,32 @@ + 'Menunggu Verifikasi', + self::APPROVED => 'Disetujui', + self::REJECTED => 'Ditolak', + }; + } + + public function badgeVariant(): string + { + return match ($this) { + self::PENDING => 'outline', + self::APPROVED => 'default', + self::REJECTED => 'destructive', + }; + } +} diff --git a/app/Enums/Role.php b/app/Enums/Role.php index bd1e9b9..ab63cf2 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -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, diff --git a/app/Http/Controllers/Admin/Manage/OwnerVerificationController.php b/app/Http/Controllers/Admin/Manage/OwnerVerificationController.php index 4eff83a..c740c22 100644 --- a/app/Http/Controllers/Admin/Manage/OwnerVerificationController.php +++ b/app/Http/Controllers/Admin/Manage/OwnerVerificationController.php @@ -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'); diff --git a/app/Http/Controllers/Admin/Manage/PurchaseController.php b/app/Http/Controllers/Admin/Manage/PurchaseController.php index b9c2db3..4ab9e4e 100644 --- a/app/Http/Controllers/Admin/Manage/PurchaseController.php +++ b/app/Http/Controllers/Admin/Manage/PurchaseController.php @@ -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'); } diff --git a/app/Http/Controllers/Admin/Master/ProductController.php b/app/Http/Controllers/Admin/Master/ProductController.php index 71e58f4..3d5f9dd 100644 --- a/app/Http/Controllers/Admin/Master/ProductController.php +++ b/app/Http/Controllers/Admin/Master/ProductController.php @@ -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'); } diff --git a/app/Http/Controllers/Admin/Master/RawMaterialController.php b/app/Http/Controllers/Admin/Master/RawMaterialController.php index 44b2c23..4d3c56b 100644 --- a/app/Http/Controllers/Admin/Master/RawMaterialController.php +++ b/app/Http/Controllers/Admin/Master/RawMaterialController.php @@ -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'); } diff --git a/app/Http/Middleware/EnsureNoPendingOwnerVerification.php b/app/Http/Middleware/EnsureNoPendingOwnerVerification.php new file mode 100644 index 0000000..c47b809 --- /dev/null +++ b/app/Http/Middleware/EnsureNoPendingOwnerVerification.php @@ -0,0 +1,45 @@ +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); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 2624718..bd9f377 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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); } } diff --git a/app/Models/Concerns/HasPendingOwnerVerification.php b/app/Models/Concerns/HasPendingOwnerVerification.php new file mode 100644 index 0000000..4706c1f --- /dev/null +++ b/app/Models/Concerns/HasPendingOwnerVerification.php @@ -0,0 +1,11 @@ +pendingOwnerVerificationRequest()->exists(); + } +} diff --git a/app/Models/OwnerVerificationRequest.php b/app/Models/OwnerVerificationRequest.php new file mode 100644 index 0000000..ce688fc --- /dev/null +++ b/app/Models/OwnerVerificationRequest.php @@ -0,0 +1,186 @@ + 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}"; + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index 887273a..815227a 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -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 { diff --git a/app/Models/Purchase.php b/app/Models/Purchase.php index 4d4151e..09ffaac 100644 --- a/app/Models/Purchase.php +++ b/app/Models/Purchase.php @@ -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); diff --git a/app/Models/RawMaterial.php b/app/Models/RawMaterial.php index 0fbc0ab..940db1f 100644 --- a/app/Models/RawMaterial.php +++ b/app/Models/RawMaterial.php @@ -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( diff --git a/app/Services/Manage/OwnerVerificationService.php b/app/Services/Manage/OwnerVerificationService.php new file mode 100644 index 0000000..d839085 --- /dev/null +++ b/app/Services/Manage/OwnerVerificationService.php @@ -0,0 +1,542 @@ +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> + */ + 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 + */ + 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 + */ + 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 + */ + 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> $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(); + } +} diff --git a/app/Services/Manage/PurchaseService.php b/app/Services/Manage/PurchaseService.php index 83e6d61..7682cbf 100644 --- a/app/Services/Manage/PurchaseService.php +++ b/app/Services/Manage/PurchaseService.php @@ -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 $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 $items * @return list @@ -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 $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 + */ + 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 $validated + * @return array + */ + 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 $lineItems + * @return list> + */ + 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 $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 $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 + */ + 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)) { diff --git a/app/Services/Manage/StockService.php b/app/Services/Manage/StockService.php index be1e95c..11bc9cf 100644 --- a/app/Services/Manage/StockService.php +++ b/app/Services/Manage/StockService.php @@ -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', diff --git a/app/Services/Master/ProductService.php b/app/Services/Master/ProductService.php index 84fa59c..21a5d0b 100644 --- a/app/Services/Master/ProductService.php +++ b/app/Services/Master/ProductService.php @@ -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 $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 $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 $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 $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 + */ + 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 + */ + 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 + */ + 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 $data + * @return array + */ + 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 $validated + * @return array + */ + 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 $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 $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 $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 $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 $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", - ); - } } diff --git a/app/Services/Master/RawMaterialService.php b/app/Services/Master/RawMaterialService.php index 48b5880..bd5f1b5 100644 --- a/app/Services/Master/RawMaterialService.php +++ b/app/Services/Master/RawMaterialService.php @@ -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 $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 $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 $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 $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 + */ + 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 + */ + 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 + */ + 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 $data + * @return array + */ + 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 $validated + * @return array + */ + 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 $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 $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')); + } } diff --git a/app/Support/OwnerVerification/VerificationChangeFormatter.php b/app/Support/OwnerVerification/VerificationChangeFormatter.php new file mode 100644 index 0000000..343dae2 --- /dev/null +++ b/app/Support/OwnerVerification/VerificationChangeFormatter.php @@ -0,0 +1,137 @@ + + */ + private const HIDDEN_FIELDS = [ + 'category_ids', + 'unit', + ]; + + /** + * @param array{old?: array|null, new?: array|null} $payload + * @return list + */ + 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; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 7adc116..fc6932e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ RoleMiddleware::class, 'permission' => PermissionMiddleware::class, 'role_or_permission' => RoleOrPermissionMiddleware::class, + 'no_pending_owner_verification' => EnsureNoPendingOwnerVerification::class, ]); $middleware->web(append: [ diff --git a/database/migrations/2026_06_26_100000_create_owner_verification_requests_table.php b/database/migrations/2026_06_26_100000_create_owner_verification_requests_table.php new file mode 100644 index 0000000..9ddebd1 --- /dev/null +++ b/database/migrations/2026_06_26_100000_create_owner_verification_requests_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/resources/js/components/button/RowDeleteAction.vue b/resources/js/components/button/RowDeleteAction.vue index cb2cc80..844e959 100644 --- a/resources/js/components/button/RowDeleteAction.vue +++ b/resources/js/components/button/RowDeleteAction.vue @@ -13,6 +13,7 @@ const props = defineProps<{ tooltip?: string; tooltipClass?: string; buttonClass?: string; + disabled?: boolean; onSuccess?: () => void; onError?: (errors: Record) => string | void; }>(); @@ -28,11 +29,13 @@ const { open, processing, destroy } = useDestroy({