first commit

This commit is contained in:
Saufi
2026-06-24 20:32:14 +08:00
commit 10fb30ad69
201 changed files with 21356 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Services;
use App\Models\Consultant;
use App\Models\DataCentre;
use App\Models\DataCentreConsultantAssignment;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class AssignmentService
{
/**
* Tugaskan perunding kepada pusat data.
*
* Peraturan: satu pusat data hanya boleh ada SATU perunding aktif pada satu masa.
* Penugasan terdahulu ditamatkan (end_date diisi, is_active=false) dan disimpan
* dalam sejarah penugasan.
*/
public function assign(DataCentre $dataCentre, Consultant $consultant, User $officer, ?string $startDate = null, ?string $reason = null): DataCentreConsultantAssignment
{
return DB::transaction(function () use ($dataCentre, $consultant, $officer, $startDate, $reason) {
$start = $startDate ? Carbon::parse($startDate) : now();
// Tamatkan semua penugasan aktif sedia ada untuk pusat data ini.
$dataCentre->assignments()
->where('is_active', true)
->get()
->each(function (DataCentreConsultantAssignment $existing) use ($start) {
$existing->update([
'is_active' => false,
'end_date' => $existing->end_date ?? $start->copy()->subDay(),
]);
});
$assignment = $dataCentre->assignments()->create([
'consultant_id' => $consultant->id,
'start_date' => $start,
'reason' => $reason,
'assigned_by' => $officer->id,
'is_active' => true,
]);
$dataCentre->update(['current_consultant_id' => $consultant->id]);
activity('penugasan')
->performedOn($dataCentre)
->causedBy($officer)
->withProperties([
'consultant_id' => $consultant->id,
'consultant' => $consultant->nama_perunding,
'reason' => $reason,
])
->log('Perunding ditugaskan kepada pusat data');
return $assignment;
});
}
/**
* Tamatkan penugasan aktif tanpa menggantikan dengan perunding baharu.
*/
public function endActive(DataCentre $dataCentre, User $officer, ?string $reason = null): void
{
DB::transaction(function () use ($dataCentre, $officer, $reason) {
$dataCentre->assignments()->where('is_active', true)->update([
'is_active' => false,
'end_date' => now(),
'reason' => $reason,
]);
$dataCentre->update(['current_consultant_id' => null]);
activity('penugasan')
->performedOn($dataCentre)
->causedBy($officer)
->log('Penugasan perunding ditamatkan');
});
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Services;
use App\Models\Submission;
use App\Models\SubmissionDocument;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class DocumentService
{
/**
* Simpan laporan PDF baharu untuk serahan dengan versi & hash.
* Versi sebelumnya ditanda tidak aktif tetapi dikekalkan (sejarah versi).
* Fail disimpan pada disk 'local' (di luar direktori public).
*/
public function store(Submission $submission, UploadedFile $file, User $user): SubmissionDocument
{
return DB::transaction(function () use ($submission, $file, $user) {
$nextVersion = (int) $submission->documents()->max('version') + 1;
$hash = hash_file('sha256', $file->getRealPath());
$path = $file->storeAs(
"reports/{$submission->id}",
'v'.$nextVersion.'_'.uniqid().'.pdf',
'local'
);
// Nyahaktifkan versi aktif sebelumnya.
$submission->documents()->where('is_active', true)->update(['is_active' => false]);
$document = $submission->documents()->create([
'original_filename' => $file->getClientOriginalName(),
'stored_path' => $path,
'file_hash' => $hash,
'mime_type' => $file->getClientMimeType(),
'size' => $file->getSize(),
'version' => $nextVersion,
'is_active' => true,
'uploaded_by' => $user->id,
]);
activity('dokumen')
->performedOn($submission)
->causedBy($user)
->withProperties(['version' => $nextVersion, 'filename' => $document->original_filename])
->log('Laporan PDF dimuat naik');
return $document;
});
}
public function setActiveVersion(Submission $submission, SubmissionDocument $document): void
{
$submission->documents()->where('is_active', true)->update(['is_active' => false]);
$document->update(['is_active' => true]);
}
public function download(SubmissionDocument $document)
{
abort_unless(Storage::disk('local')->exists($document->stored_path), 404, 'Fail laporan tidak dijumpai.');
return Storage::disk('local')->download($document->stored_path, $document->original_filename);
}
}

View File

@@ -0,0 +1,204 @@
<?php
namespace App\Services;
use RuntimeException;
/**
* Penilai ungkapan aritmetik yang selamat (recursive-descent parser).
*
* Menyokong: + - * / ( ), nombor perpuluhan, dan pemboleh ubah (token) yang
* dipetakan daripada array $variables. Tiada eval()/PHP code dilaksanakan,
* jadi selamat untuk formula yang dikonfigur oleh pengguna JPP.
*/
class ExpressionEvaluator
{
/** @var array<int, array{type:string, value:string}> */
protected array $tokens = [];
protected int $pos = 0;
/** @var array<string, float> */
protected array $variables = [];
protected bool $guardDivZero = false;
protected bool $divByZeroHit = false;
/**
* @param array<string, float|int|null> $variables
*/
public function evaluate(string $expression, array $variables = [], bool $guardDivZero = false): ?float
{
$this->variables = array_map(fn ($v) => $v === null ? 0.0 : (float) $v, $variables);
$this->guardDivZero = $guardDivZero;
$this->divByZeroHit = false;
$this->tokens = $this->tokenize($expression);
$this->pos = 0;
if (empty($this->tokens)) {
return null;
}
$result = $this->parseExpression();
if ($this->pos < count($this->tokens)) {
throw new RuntimeException('Ungkapan formula tidak sah berhampiran: '.($this->tokens[$this->pos]['value'] ?? ''));
}
if ($this->guardDivZero && $this->divByZeroHit) {
return 0.0;
}
return $result;
}
/**
* @return array<int, array{type:string, value:string}>
*/
protected function tokenize(string $expr): array
{
$tokens = [];
$len = strlen($expr);
$i = 0;
while ($i < $len) {
$ch = $expr[$i];
if (ctype_space($ch)) {
$i++;
continue;
}
if (in_array($ch, ['+', '-', '*', '/', '(', ')'], true)) {
$tokens[] = ['type' => 'op', 'value' => $ch];
$i++;
continue;
}
// Nombor (termasuk perpuluhan)
if (ctype_digit($ch) || ($ch === '.' && $i + 1 < $len && ctype_digit($expr[$i + 1]))) {
$num = '';
while ($i < $len && (ctype_digit($expr[$i]) || $expr[$i] === '.')) {
$num .= $expr[$i];
$i++;
}
$tokens[] = ['type' => 'num', 'value' => $num];
continue;
}
// Token / pemboleh ubah: huruf, nombor, underscore (cth: SKOP1, A, D4I)
if (ctype_alpha($ch) || $ch === '_') {
$tok = '';
while ($i < $len && (ctype_alnum($expr[$i]) || $expr[$i] === '_')) {
$tok .= $expr[$i];
$i++;
}
$tokens[] = ['type' => 'var', 'value' => $tok];
continue;
}
throw new RuntimeException("Aksara tidak sah dalam formula: '$ch'");
}
return $tokens;
}
protected function peek(): ?array
{
return $this->tokens[$this->pos] ?? null;
}
protected function consume(): ?array
{
return $this->tokens[$this->pos++] ?? null;
}
/** expression = term (('+' | '-') term)* */
protected function parseExpression(): float
{
$value = $this->parseTerm();
while (($t = $this->peek()) && $t['type'] === 'op' && in_array($t['value'], ['+', '-'], true)) {
$op = $this->consume()['value'];
$right = $this->parseTerm();
$value = $op === '+' ? $value + $right : $value - $right;
}
return $value;
}
/** term = factor (('*' | '/') factor)* */
protected function parseTerm(): float
{
$value = $this->parseFactor();
while (($t = $this->peek()) && $t['type'] === 'op' && in_array($t['value'], ['*', '/'], true)) {
$op = $this->consume()['value'];
$right = $this->parseFactor();
if ($op === '*') {
$value *= $right;
} else {
if ($right == 0.0) {
$this->divByZeroHit = true;
if ($this->guardDivZero) {
$value = 0.0;
continue;
}
throw new RuntimeException('Pembahagian dengan sifar dalam formula.');
}
$value /= $right;
}
}
return $value;
}
/** factor = number | variable | '(' expression ')' | ('+'|'-') factor */
protected function parseFactor(): float
{
$t = $this->peek();
if ($t === null) {
throw new RuntimeException('Ungkapan formula tidak lengkap.');
}
if ($t['type'] === 'op' && $t['value'] === '(') {
$this->consume();
$value = $this->parseExpression();
$close = $this->consume();
if (! $close || $close['value'] !== ')') {
throw new RuntimeException('Kurungan tidak seimbang dalam formula.');
}
return $value;
}
if ($t['type'] === 'op' && in_array($t['value'], ['+', '-'], true)) {
$op = $this->consume()['value'];
$value = $this->parseFactor();
return $op === '-' ? -$value : $value;
}
if ($t['type'] === 'num') {
$this->consume();
return (float) $t['value'];
}
if ($t['type'] === 'var') {
$this->consume();
return $this->variables[$t['value']] ?? 0.0;
}
throw new RuntimeException('Token formula tidak dijangka: '.$t['value']);
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Services;
use App\Models\ChecklistTemplate;
use App\Models\Submission;
/**
* Mengira hasil formula seksyen (A, B, C, D, E ...) berdasarkan jawapan serahan.
*
* Token tersedia kepada formula:
* - formula_token bagi setiap checklist_item (cth: SKOP1, EE, RE, D4I)
* - result_token bagi setiap formula yang telah dikira sebelumnya (cth: A, B)
*
* Formula dinilai mengikut turutan `order` supaya C boleh merujuk A & B, dsb.
*/
class FormulaService
{
public function __construct(protected ExpressionEvaluator $evaluator) {}
/**
* Kira semua hasil formula bagi satu serahan.
*
* @return array<int, array{result_token:string,label:?string,value:?float,unit:?string}>
*/
public function computeForSubmission(Submission $submission): array
{
$template = $submission->checklistTemplate
?: ($submission->checklist_template_id ? ChecklistTemplate::find($submission->checklist_template_id) : ChecklistTemplate::activeTemplate());
if (! $template) {
return [];
}
// Bina peta token => nilai daripada jawapan item.
$variables = [];
$answers = $submission->relationLoaded('answers')
? $submission->answers
: $submission->answers()->with('item')->get();
foreach ($answers as $answer) {
$item = $answer->item;
if ($item && $item->formula_token) {
$variables[$item->formula_token] = is_numeric($answer->value) ? (float) $answer->value : 0.0;
}
}
return $this->compute($template, $variables);
}
/**
* Kira hasil formula daripada peta pemboleh ubah mentah (berguna untuk ujian).
*
* @param array<string, float|int|null> $variables
* @return array<int, array{result_token:string,label:?string,value:?float,unit:?string}>
*/
public function compute(ChecklistTemplate $template, array $variables): array
{
$formulas = $template->relationLoaded('formulas')
? $template->formulas->sortBy('order')
: $template->formulas()->orderBy('order')->get();
$results = [];
foreach ($formulas as $formula) {
$value = $this->evaluator->evaluate($formula->expression, $variables, $formula->guard_div_zero);
// Hasil ini menjadi token yang boleh dirujuk oleh formula seterusnya.
$variables[$formula->result_token] = $value ?? 0.0;
$results[] = [
'result_token' => $formula->result_token,
'label' => $formula->label,
'value' => $value,
'unit' => $formula->unit,
];
}
return $results;
}
/**
* Kira semula dan simpan hasil ke dalam jadual submission_results.
*/
public function recalculateAndStore(Submission $submission): void
{
$results = $this->computeForSubmission($submission);
foreach ($results as $r) {
$submission->results()->updateOrCreate(
['result_token' => $r['result_token']],
[
'label' => $r['label'],
'value' => $r['value'],
'unit' => $r['unit'],
]
);
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Services;
use App\Models\AppSetting;
use App\Models\ChecklistTemplate;
use App\Models\ReportingCycle;
use Illuminate\Support\Carbon;
class ReportingCycleService
{
/**
* Bina atribut kitaran daripada tahun pembaharuan.
* Peraturan perniagaan:
* - reporting_year = renewal_year - 2 (cth: pembaharuan 2028 => pelaporan 2026)
* - tempoh pelaporan: 1 Jan - 31 Dis tahun pelaporan
* - tarikh tutup lalai: hari/bulan yang dikonfigur JPP, tahun (renewal_year - 1)
*/
public function buildAttributes(int $renewalYear, ?string $cutOffDate = null): array
{
$reportingYear = ReportingCycle::reportingYearFor($renewalYear);
$cutOffDay = (int) AppSetting::get('default_cut_off_day', 1);
$cutOffMonth = (int) AppSetting::get('default_cut_off_month', 12);
// Pembaharuan dibuat sebelum/pada 1 Disember tahun sebelum pembaharuan.
$defaultCutOff = Carbon::create($renewalYear - 1, $cutOffMonth, $cutOffDay);
return [
'reporting_year' => $reportingYear,
'period_start' => Carbon::create($reportingYear, 1, 1),
'period_end' => Carbon::create($reportingYear, 12, 31),
'cut_off_date' => $cutOffDate ? Carbon::parse($cutOffDate) : $defaultCutOff,
'checklist_template_id' => ChecklistTemplate::activeTemplate()?->id,
];
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Services;
use App\Models\ChecklistTemplate;
use App\Models\DataCentre;
use App\Models\ReportingCycle;
use App\Models\Submission;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class SubmissionWorkflowService
{
public function __construct(protected FormulaService $formulaService) {}
/**
* Dapatkan atau cipta serahan draf untuk pusat data + kitaran tertentu.
*/
public function findOrCreateDraft(DataCentre $dataCentre, ReportingCycle $cycle): Submission
{
$consultantId = $dataCentre->current_consultant_id;
if (! $consultantId) {
throw ValidationException::withMessages([
'data_centre_id' => 'Pusat data ini tiada perunding aktif. Sila hubungi JPP.',
]);
}
return Submission::firstOrCreate(
[
'data_centre_id' => $dataCentre->id,
'reporting_cycle_id' => $cycle->id,
],
[
'consultant_id' => $consultantId,
'checklist_template_id' => $cycle->checklist_template_id ?? ChecklistTemplate::activeTemplate()?->id,
'status' => 'draf',
]
);
}
/**
* Simpan jawapan senarai semak (draf). $answers = [item_id => [value, unit, page_reference, consultant_note]]
*/
public function saveAnswers(Submission $submission, array $answers): void
{
DB::transaction(function () use ($submission, $answers) {
foreach ($answers as $itemId => $data) {
$submission->answers()->updateOrCreate(
['checklist_item_id' => $itemId],
[
'value' => $data['value'] ?? null,
'unit' => $data['unit'] ?? null,
'page_reference' => $data['page_reference'] ?? null,
'consultant_note' => $data['consultant_note'] ?? null,
]
);
}
// Kira semula nilai formula (A, B, C, D, E ...).
$submission->load('answers.item');
$this->formulaService->recalculateAndStore($submission);
});
}
/**
* Hantar serahan secara muktamad (draf -> baru).
*/
public function submit(Submission $submission, User $user): void
{
if ($submission->is_locked) {
throw ValidationException::withMessages(['status' => 'Serahan telah dikunci dan tidak boleh dihantar semula.']);
}
// Mesti ada sekurang-kurangnya satu laporan PDF aktif sebelum hantar.
if (! $submission->activeDocument()->exists()) {
throw ValidationException::withMessages(['document' => 'Sila muat naik laporan PDF sebelum menghantar serahan.']);
}
$from = $submission->status;
// Jika sedang dalam pembetulan, hantar semula sebagai 'dalam_tindakan'.
$to = $from === 'pembetulan_perunding' ? 'dalam_tindakan' : 'baru';
$this->changeStatus($submission, $to, $user, 'Serahan dihantar oleh perunding.', [
'submitted_at' => now(),
'submitted_by' => $user->id,
]);
$this->formulaService->recalculateAndStore($submission);
}
/**
* Tukar status serahan dengan rekod sejarah & audit.
*/
public function changeStatus(Submission $submission, string $to, User $user, ?string $notes = null, array $extra = []): void
{
DB::transaction(function () use ($submission, $to, $user, $notes, $extra) {
$from = $submission->status;
$submission->fill(array_merge(['status' => $to], $extra));
$submission->save();
$submission->statusHistories()->create([
'from_status' => $from,
'to_status' => $to,
'changed_by' => $user->id,
'notes' => $notes,
]);
activity('serahan')
->performedOn($submission)
->causedBy($user)
->withProperties(['from' => $from, 'to' => $to, 'notes' => $notes])
->log("Status serahan ditukar daripada {$from} kepada {$to}");
});
}
public function lock(Submission $submission, User $user, ?string $reason = null): void
{
$submission->update([
'is_locked' => true,
'locked_by' => $user->id,
'locked_at' => now(),
'lock_reason' => $reason,
]);
activity('serahan')->performedOn($submission)->causedBy($user)
->withProperties(['reason' => $reason])->log('Serahan dikunci');
}
public function unlock(Submission $submission, User $user, string $reason): void
{
$submission->update([
'is_locked' => false,
'lock_reason' => $reason,
'locked_by' => $user->id,
'locked_at' => now(),
]);
activity('serahan')->performedOn($submission)->causedBy($user)
->withProperties(['reason' => $reason])->log('Serahan dibuka semula');
}
}