Improve document type cleanup and student import reliability

This commit is contained in:
2026-05-02 10:42:33 +07:00
parent ac22f8b7c8
commit 700c24209d
6 changed files with 345 additions and 40 deletions
@@ -3,9 +3,11 @@
namespace App\Http\Controllers;
use App\Models\DocumentType;
use App\Models\StudentDocument;
use Illuminate\Database\QueryException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use RealRashid\SweetAlert\Facades\Alert;
@@ -17,6 +19,9 @@ class DocumentTypeController extends Controller
$search = trim((string) $request->get('search'));
$documentTypes = DocumentType::withCount('studentDocuments')
->withCount([
'studentDocuments as student_documents_with_trashed_count' => fn ($query) => $query->withTrashed(),
])
->when($search !== '', function ($query) use ($search) {
$query->where('code', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%")
@@ -93,23 +98,67 @@ class DocumentTypeController extends Controller
public function destroy(DocumentType $documentType): RedirectResponse
{
$documentType->loadCount('studentDocuments');
$documentType->loadCount([
'studentDocuments',
'studentDocuments as student_documents_with_trashed_count' => fn ($query) => $query->withTrashed(),
]);
if ($documentType->student_documents_count > 0) {
Alert::error('Tidak bisa dihapus', 'Jenis dokumen ini masih digunakan oleh data dokumen siswa.');
Alert::error('Tidak bisa dihapus', 'Jenis dokumen ini masih digunakan oleh data dokumen siswa aktif.');
return redirect()->route('document-types.index')->withErrors([
'delete' => "Jenis dokumen '{$documentType->name}' tidak bisa dihapus karena masih digunakan oleh {$documentType->student_documents_count} dokumen siswa.",
'delete' => "Jenis dokumen '{$documentType->name}' tidak bisa dihapus karena masih digunakan oleh {$documentType->student_documents_count} dokumen siswa aktif.",
]);
}
$trashedDocumentsCount = $documentType->student_documents_with_trashed_count - $documentType->student_documents_count;
if ($trashedDocumentsCount > 0) {
Alert::error('Tidak bisa dihapus', 'Jenis dokumen ini masih dipakai oleh dokumen siswa yang sudah diarsipkan.');
return redirect()->route('document-types.index')->withErrors([
'delete' => "Jenis dokumen '{$documentType->name}' tidak bisa dihapus karena masih direferensikan oleh {$trashedDocumentsCount} dokumen siswa yang sudah diarsipkan. Bersihkan arsip dokumen terkait terlebih dahulu atau gunakan aksi bersihkan arsip dari modul ini.",
]);
}
return $this->deleteDocumentType($documentType);
}
public function purgeArchivedDocuments(DocumentType $documentType): RedirectResponse
{
$trashedDocuments = StudentDocument::withTrashed()
->where('document_type_id', $documentType->id)
->whereNotNull('deleted_at')
->get();
if ($trashedDocuments->isEmpty()) {
Alert::info('Tidak ada arsip', 'Tidak ditemukan dokumen siswa arsip untuk jenis dokumen ini.');
return $this->deleteDocumentType($documentType);
}
foreach ($trashedDocuments as $document) {
if ($document->file_path && Storage::disk('public')->exists($document->file_path)) {
Storage::disk('public')->delete($document->file_path);
}
$document->forceDelete();
}
Alert::success('Arsip dibersihkan', 'Arsip dokumen siswa yang terkait berhasil dibersihkan.');
return $this->deleteDocumentType($documentType);
}
protected function deleteDocumentType(DocumentType $documentType): RedirectResponse
{
try {
$documentType->delete();
} catch (QueryException $exception) {
Alert::error('Tidak bisa dihapus', 'Jenis dokumen ini masih direferensikan oleh data lain di sistem.');
return redirect()->route('document-types.index')->withErrors([
'delete' => "Jenis dokumen '{$documentType->name}' tidak bisa dihapus karena masih direferensikan oleh data lain di sistem. Untuk kasus ini, cek dokumen siswa yang masih memakai jenis dokumen tersebut terlebih dahulu.",
'delete' => "Jenis dokumen '{$documentType->name}' tidak bisa dihapus karena masih direferensikan oleh data lain di sistem. Cek relasi data lain yang masih memakai jenis dokumen ini terlebih dahulu.",
]);
}
+251 -22
View File
@@ -342,8 +342,15 @@ class StudentController extends Controller
{
$this->ensureAdmin();
return view('students.import', array_merge($this->importReferenceData(), [
'preview' => $request->session()->get('student_import_preview'),
$referenceData = $this->importReferenceData();
$preview = $request->session()->get('student_import_preview');
if ($preview) {
$preview = $this->hydrateImportPreviewDisplay($preview, $referenceData);
}
return view('students.import', array_merge($referenceData, [
'preview' => $preview,
]));
}
@@ -432,6 +439,8 @@ class StudentController extends Controller
'notes',
]);
$payload = $this->hydrateRegionNamesFromIds($payload);
$lookup = null;
if (! empty($payload['student_number'])) {
@@ -447,8 +456,14 @@ class StudentController extends Controller
$lookup->restore();
}
$lookup->update($payload);
$this->syncAcademicHistory($lookup, $payload);
$updatePayload = $this->buildStudentImportUpdatePayload($lookup, $payload);
$historyPayload = $this->buildStudentImportHistoryPayload($lookup, $payload);
$lookup->update($updatePayload);
$this->syncAcademicHistory($lookup, $historyPayload);
$this->syncExitRecord($lookup, $historyPayload);
$this->syncTransferRecord($lookup, $historyPayload);
$this->syncGraduationRecord($lookup, $historyPayload);
$this->syncImportedParents($lookup, $row['normalized']['parents'] ?? []);
$updated++;
continue;
@@ -456,6 +471,9 @@ class StudentController extends Controller
$student = Student::create($payload);
$this->syncAcademicHistory($student, $payload);
$this->syncExitRecord($student, $payload);
$this->syncTransferRecord($student, $payload);
$this->syncGraduationRecord($student, $payload);
$this->syncImportedParents($student, $row['normalized']['parents'] ?? []);
$inserted++;
}
@@ -804,14 +822,92 @@ class StudentController extends Controller
protected function importReferenceData(): array
{
$academicYears = AcademicYear::where('is_active', true)->orderBy('name')->pluck('name', 'id');
$departments = Department::where('is_active', true)->orderBy('name')->pluck('name', 'id');
$classes = SchoolClass::with(['academicYear', 'department'])->where('is_active', true)->orderBy('name')->get();
return [
'academicYears' => AcademicYear::where('is_active', true)->orderBy('name')->pluck('name', 'id'),
'departments' => Department::where('is_active', true)->orderBy('name')->pluck('name', 'id'),
'classes' => SchoolClass::with(['academicYear', 'department'])->where('is_active', true)->orderBy('name')->get(),
'academicYears' => $academicYears,
'departments' => $departments,
'classes' => $classes,
'statuses' => $this->statusOptions(),
'academicYearsById' => $academicYears->all(),
'departmentsById' => $departments->all(),
'classesById' => $classes->keyBy('id')->map(fn ($classRoom) => [
'name' => $classRoom->name,
'academic_year' => $classRoom->academicYear?->name,
'department' => $classRoom->department?->name,
])->all(),
];
}
protected function hydrateRegionNamesFromIds(array $payload): array
{
$payload['province'] = ! empty($payload['province_id']) ? Province::where('id', $payload['province_id'])->value('name') : null;
$payload['city'] = ! empty($payload['regency_id']) ? Regency::where('id', $payload['regency_id'])->value('name') : null;
$payload['district'] = ! empty($payload['district_id']) ? District::where('id', $payload['district_id'])->value('name') : null;
$payload['village'] = ! empty($payload['village_id']) ? Village::where('id', $payload['village_id'])->value('name') : null;
return $payload;
}
protected function buildStudentImportUpdatePayload(Student $student, array $payload): array
{
$preserveWhenBlank = [
'student_number',
'nisn',
'nik',
'place_of_birth',
'date_of_birth',
'religion',
'nationality',
'family_card_number',
'child_order',
'number_of_siblings',
'phone_number',
'email',
'address',
'province_id',
'regency_id',
'district_id',
'village_id',
'province',
'city',
'district',
'village',
'postal_code',
'entry_date',
'notes',
];
foreach ($preserveWhenBlank as $field) {
if (array_key_exists($field, $payload) && blank($payload[$field])) {
$payload[$field] = $student->{$field};
}
}
return $payload;
}
protected function buildStudentImportHistoryPayload(Student $student, array $payload): array
{
$historyPayload = Arr::only($payload, [
'academic_year_id',
'department_id',
'class_id',
'status',
'entry_date',
]);
foreach (['academic_year_id', 'department_id', 'class_id', 'status', 'entry_date'] as $field) {
if (! array_key_exists($field, $historyPayload) || blank($historyPayload[$field])) {
$historyPayload[$field] = $student->{$field};
}
}
return $historyPayload;
}
protected function statusOptions(): array
{
return [
@@ -1241,6 +1337,40 @@ class StudentController extends Controller
];
}
protected function hydrateImportPreviewDisplay(array $preview, array $referenceData): array
{
$academicYearsById = $referenceData['academicYearsById'] ?? [];
$departmentsById = $referenceData['departmentsById'] ?? [];
$classesById = $referenceData['classesById'] ?? [];
$statuses = $referenceData['statuses'] ?? [];
$preview['rows'] = array_map(function (array $row) use ($academicYearsById, $departmentsById, $classesById, $statuses) {
$academicYearId = $row['data']['academic_year_id'] ?? null;
$departmentId = $row['data']['department_id'] ?? null;
$classId = $row['data']['class_id'] ?? null;
$status = $row['data']['status'] ?? null;
$classMeta = $classId ? ($classesById[$classId] ?? null) : null;
$row['display'] = [
'academic_year' => $academicYearId ? ($academicYearsById[$academicYearId] ?? 'ID ' . $academicYearId) : '-',
'department' => $departmentId ? ($departmentsById[$departmentId] ?? 'ID ' . $departmentId) : '-',
'class' => $classMeta['name'] ?? ($classId ? 'ID ' . $classId : '-'),
'class_context' => $classMeta
? collect([$classMeta['academic_year'] ?? null, $classMeta['department'] ?? null])->filter()->implode(' · ')
: null,
'status' => $status ? ($statuses[$status] ?? strtoupper((string) $status)) : '-',
'province' => $row['data']['province'] ?? '-',
'city' => $row['data']['city'] ?? '-',
'district' => $row['data']['district'] ?? '-',
'village' => $row['data']['village'] ?? '-',
];
return $row;
}, $preview['rows'] ?? []);
return $preview;
}
protected function normalizeImportRow(array $row): array
{
$normalized = [];
@@ -1292,6 +1422,10 @@ class StudentController extends Controller
$regencyId = $this->resolveRegencyId($regencySource, $provinceId);
$districtId = $this->resolveDistrictId($districtSource, $regencyId);
$villageId = $this->resolveVillageId($villageSource, $districtId);
$provinceName = $provinceId ? Province::where('id', $provinceId)->value('name') : null;
$regencyName = $regencyId ? Regency::where('id', $regencyId)->value('name') : null;
$districtName = $districtId ? District::where('id', $districtId)->value('name') : null;
$villageName = $villageId ? Village::where('id', $villageId)->value('name') : null;
return [
'student_number' => $studentNumber,
@@ -1313,10 +1447,10 @@ class StudentController extends Controller
'regency_id' => $regencyId,
'district_id' => $districtId,
'village_id' => $villageId,
'province' => $provinceId ? Province::where('id', $provinceId)->value('name') : null,
'city' => $regencyId ? Regency::where('id', $regencyId)->value('name') : null,
'district' => $districtId ? District::where('id', $districtId)->value('name') : null,
'village' => $villageId ? Village::where('id', $villageId)->value('name') : null,
'province' => $provinceName,
'city' => $regencyName,
'district' => $districtName,
'village' => $villageName,
'postal_code' => $postalCode,
'academic_year_id' => $academicYearId,
'department_id' => $departmentId,
@@ -1355,7 +1489,12 @@ class StudentController extends Controller
return $value;
}
return Department::where('name', trim((string) $value))->value('id');
$normalizedValue = trim((string) $value);
return Department::query()
->where('name', $normalizedValue)
->orWhere('code', strtoupper($normalizedValue))
->value('id');
}
protected function resolveClassId(mixed $value, mixed $academicYearId = null, mixed $departmentId = null): mixed
@@ -1385,7 +1524,12 @@ class StudentController extends Controller
return (string) $value;
}
return Province::where('name', trim((string) $value))->value('id');
$normalizedValue = $this->normalizeRegionLookupValue((string) $value);
return Province::query()
->get(['id', 'name'])
->first(fn ($province) => $this->normalizeRegionLookupValue($province->name) === $normalizedValue)
?->id;
}
protected function resolveRegencyId(mixed $value, mixed $provinceId = null): mixed
@@ -1398,10 +1542,19 @@ class StudentController extends Controller
return (string) $value;
}
$normalizedValue = $this->normalizeRegionLookupValue((string) $value);
return Regency::query()
->when($provinceId, fn ($query) => $query->where('province_id', $provinceId))
->where('name', trim((string) $value))
->value('id');
->get(['id', 'name'])
->first(function ($regency) use ($normalizedValue) {
$candidate = $this->normalizeRegionLookupValue($regency->name);
return $candidate === $normalizedValue
|| $candidate === 'KABUPATEN ' . $normalizedValue
|| $candidate === 'KOTA ' . $normalizedValue;
})
?->id;
}
protected function resolveDistrictId(mixed $value, mixed $regencyId = null): mixed
@@ -1414,10 +1567,13 @@ class StudentController extends Controller
return (string) $value;
}
$normalizedValue = $this->normalizeRegionLookupValue((string) $value);
return District::query()
->when($regencyId, fn ($query) => $query->where('regency_id', $regencyId))
->where('name', trim((string) $value))
->value('id');
->get(['id', 'name'])
->first(fn ($district) => $this->normalizeRegionLookupValue($district->name) === $normalizedValue)
?->id;
}
protected function resolveVillageId(mixed $value, mixed $districtId = null): mixed
@@ -1430,10 +1586,22 @@ class StudentController extends Controller
return (string) $value;
}
$normalizedValue = $this->normalizeRegionLookupValue((string) $value);
return Village::query()
->when($districtId, fn ($query) => $query->where('district_id', $districtId))
->where('name', trim((string) $value))
->value('id');
->get(['id', 'name'])
->first(fn ($village) => $this->normalizeRegionLookupValue($village->name) === $normalizedValue)
?->id;
}
protected function normalizeRegionLookupValue(string $value): string
{
$normalized = strtoupper(trim($value));
$normalized = preg_replace('/\s+/', ' ', $normalized) ?? $normalized;
$normalized = str_replace(['KAB.', 'KABUPATEN.', 'KOTA.'], ['KABUPATEN', 'KABUPATEN', 'KOTA'], $normalized);
return trim($normalized);
}
protected function normalizeImportDate(mixed $value): mixed
@@ -1463,13 +1631,68 @@ class StudentController extends Controller
return null;
}
if (is_int($value)) {
return $value;
}
if (is_float($value)) {
return (int) round($value);
}
if (is_numeric($value)) {
return (int) $value;
return (int) round((float) $value);
}
if (is_string($value)) {
$normalized = trim($value);
$normalized = str_replace([',', ' '], '', $normalized);
if (preg_match('/^\d+(?:\.0+)?$/', $normalized) === 1) {
return (int) round((float) $normalized);
}
}
return $value;
}
protected function normalizeImportYear(mixed $value): mixed
{
$value = $this->nullIfBlank($value);
if ($value === null) {
return null;
}
if ($value instanceof \DateTimeInterface) {
return (int) $value->format('Y');
}
if (is_string($value)) {
$normalized = trim($value);
if (preg_match('/^(\d{4})[-\/]/', $normalized, $matches) === 1) {
return (int) $matches[1];
}
}
if (is_numeric($value)) {
$numericValue = (float) $value;
$intValue = (int) round($numericValue);
if ($intValue >= 1900 && $intValue <= now()->year) {
return $intValue;
}
try {
return (int) \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($numericValue)->format('Y');
} catch (\Throwable) {
return $intValue;
}
}
return $this->normalizeImportInteger($value);
}
protected function normalizeImportBoolean(mixed $value): ?bool
{
$value = $this->nullIfBlank($value);
@@ -1496,7 +1719,7 @@ class StudentController extends Controller
return [
'name' => $this->nullIfBlank($normalized["parent_{$relationKey}_name"] ?? ($normalized["nama_{$legacyPrefix}"] ?? null)),
'nik' => $this->nullIfBlank($normalized["parent_{$relationKey}_nik"] ?? ($normalized["nik_{$legacyPrefix}"] ?? null)),
'birth_year' => $this->normalizeImportInteger($normalized["parent_{$relationKey}_birth_year"] ?? ($normalized["tahun_lahir_{$legacyPrefix}"] ?? null)),
'birth_year' => $this->normalizeImportYear($normalized["parent_{$relationKey}_birth_year"] ?? ($normalized["tahun_lahir_{$legacyPrefix}"] ?? null)),
'education' => $this->nullIfBlank($normalized["parent_{$relationKey}_education"] ?? ($normalized["pendidikan_{$legacyPrefix}"] ?? null)),
'occupation' => $this->nullIfBlank($normalized["parent_{$relationKey}_occupation"] ?? ($normalized["pekerjaan_{$legacyPrefix}"] ?? null)),
'phone_number' => $this->nullIfBlank($normalized["parent_{$relationKey}_phone_number"] ?? ($normalized["no_hp_{$legacyPrefix}"] ?? null)),
@@ -1662,7 +1885,13 @@ class StudentController extends Controller
}
ksort($values);
$ordered = array_values($values);
$maxIndex = max(array_keys($values));
$ordered = [];
for ($index = 0; $index <= $maxIndex; $index++) {
$ordered[$index] = $values[$index] ?? null;
}
if ($headers === null) {
$headers = array_map(fn ($header) => trim((string) $header), $ordered);