260 lines
7.5 KiB
PHP
260 lines
7.5 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Http\Exceptions\HttpResponseException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use SimpleXMLElement;
|
|
|
|
class RateMasImportService
|
|
{
|
|
private const COLUMNS = [
|
|
'no_akaun' => 'string',
|
|
'no_akaun_lama' => 'string',
|
|
'nama_pemilik' => 'string',
|
|
'alamatpos_1' => 'string',
|
|
'alamatpos_2' => 'string',
|
|
'alamatpos_3' => 'string',
|
|
'ic_no' => 'string',
|
|
'old_ic_no' => 'string',
|
|
'bangsa' => 'string',
|
|
'warganegara' => 'string',
|
|
'nolot' => 'string',
|
|
'nogeran' => 'string',
|
|
'kegunaan' => 'string',
|
|
'mukim' => 'string',
|
|
'no_bangunan' => 'string',
|
|
'lokasiharta_1' => 'string',
|
|
'lokasiharta_2' => 'string',
|
|
'kawasan' => 'string',
|
|
'jenisbangunan' => 'string',
|
|
'new_taksiran' => 'decimal',
|
|
'kadar' => 'decimal',
|
|
'cukai_setengahtahun' => 'decimal',
|
|
'tarikh_kuatkuasa' => 'string',
|
|
'old_taksiran' => 'decimal',
|
|
'old_kadar' => 'decimal',
|
|
'tunggakan_cukai' => 'decimal',
|
|
'cukai_semasa_1' => 'decimal',
|
|
'cukai_semasa_2' => 'decimal',
|
|
'pelarasan_cukai' => 'decimal',
|
|
'bayaran_cukai_diterima' => 'decimal',
|
|
'baki_cukai' => 'decimal',
|
|
'baki_najis' => 'decimal',
|
|
'baki_notis' => 'decimal',
|
|
'baki_waran' => 'decimal',
|
|
'jumlah_baki' => 'decimal',
|
|
];
|
|
|
|
public function import(int $year, string $path, string $extension, bool $replaceExisting): array
|
|
{
|
|
$table = $year.'_ratemas';
|
|
|
|
if (! preg_match('/^\d{4}_ratemas$/', $table)) {
|
|
$this->fail('Tahun tidak sah.');
|
|
}
|
|
|
|
$tableExists = Schema::hasTable($table);
|
|
if ($tableExists && ! $replaceExisting) {
|
|
$this->fail("Table {$table} sudah wujud. Tandakan pilihan ganti data jika mahu overwrite.");
|
|
}
|
|
|
|
$rows = $extension === 'xml'
|
|
? $this->readXml($path)
|
|
: $this->readCsv($path);
|
|
|
|
if ($rows === []) {
|
|
$this->fail('Fail tidak mengandungi rekod yang boleh diimport.');
|
|
}
|
|
|
|
DB::transaction(function () use ($table, $tableExists, $replaceExisting, $rows): void {
|
|
if (! $tableExists) {
|
|
$this->createRateMasTable($table);
|
|
} elseif ($replaceExisting) {
|
|
DB::table($table)->truncate();
|
|
}
|
|
|
|
foreach (array_chunk($rows, 500) as $chunk) {
|
|
DB::table($table)->insert($chunk);
|
|
}
|
|
});
|
|
|
|
return [
|
|
'table' => $table,
|
|
'rows' => count($rows),
|
|
];
|
|
}
|
|
|
|
private function createRateMasTable(string $table): void
|
|
{
|
|
Schema::create($table, function (Blueprint $blueprint): void {
|
|
$blueprint->string('no_akaun')->primary();
|
|
|
|
foreach (self::COLUMNS as $column => $type) {
|
|
if ($column === 'no_akaun') {
|
|
continue;
|
|
}
|
|
|
|
if ($type === 'decimal') {
|
|
$blueprint->decimal($column, 14, 2)->default(0);
|
|
} else {
|
|
$blueprint->text($column)->nullable();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private function readCsv(string $path): array
|
|
{
|
|
$handle = fopen($path, 'rb');
|
|
if ($handle === false) {
|
|
$this->fail('Fail CSV tidak boleh dibaca.');
|
|
}
|
|
|
|
$firstLine = fgets($handle) ?: '';
|
|
rewind($handle);
|
|
|
|
$delimiter = $this->detectDelimiter($firstLine);
|
|
$headers = fgetcsv($handle, 0, $delimiter);
|
|
if (! is_array($headers)) {
|
|
fclose($handle);
|
|
$this->fail('Header CSV tidak sah.');
|
|
}
|
|
|
|
$headers = array_map(fn ($header) => $this->normalizeHeader((string) $header), $headers);
|
|
$this->assertRequiredHeaders($headers);
|
|
|
|
$rows = [];
|
|
while (($data = fgetcsv($handle, 0, $delimiter)) !== false) {
|
|
if ($data === [null] || $this->isEmptyRow($data)) {
|
|
continue;
|
|
}
|
|
|
|
$rows[] = $this->normalizeRow(array_combine($headers, array_pad($data, count($headers), null)) ?: []);
|
|
}
|
|
|
|
fclose($handle);
|
|
|
|
return $rows;
|
|
}
|
|
|
|
private function readXml(string $path): array
|
|
{
|
|
$xml = simplexml_load_file($path);
|
|
if (! $xml instanceof SimpleXMLElement) {
|
|
$this->fail('Fail XML tidak sah.');
|
|
}
|
|
|
|
$nodes = $this->recordNodes($xml);
|
|
$rows = [];
|
|
|
|
foreach ($nodes as $node) {
|
|
$row = [];
|
|
foreach ($node->children() as $key => $value) {
|
|
$row[$this->normalizeHeader($key)] = trim((string) $value);
|
|
}
|
|
|
|
if ($row !== []) {
|
|
$rows[] = $this->normalizeRow($row);
|
|
}
|
|
}
|
|
|
|
if ($rows !== []) {
|
|
$this->assertRequiredHeaders(array_keys($rows[0]));
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, SimpleXMLElement>
|
|
*/
|
|
private function recordNodes(SimpleXMLElement $xml): array
|
|
{
|
|
$children = iterator_to_array($xml->children());
|
|
if ($children === []) {
|
|
return [];
|
|
}
|
|
|
|
$first = reset($children);
|
|
if ($first instanceof SimpleXMLElement && $first->children()->count() > 0) {
|
|
return array_values(array_filter($children, fn ($child) => $child instanceof SimpleXMLElement));
|
|
}
|
|
|
|
return [$xml];
|
|
}
|
|
|
|
private function normalizeRow(array $row): array
|
|
{
|
|
$normalized = [];
|
|
|
|
foreach (self::COLUMNS as $column => $type) {
|
|
$value = $row[$column] ?? null;
|
|
$normalized[$column] = $type === 'decimal'
|
|
? $this->decimalValue($value)
|
|
: $this->stringValue($value);
|
|
}
|
|
|
|
if ($normalized['no_akaun'] === null || $normalized['no_akaun'] === '') {
|
|
$this->fail('Setiap rekod mesti mempunyai no_akaun.');
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
private function detectDelimiter(string $line): string
|
|
{
|
|
$delimiters = [',', ';', "\t", '|'];
|
|
$counts = array_map(fn ($delimiter) => substr_count($line, $delimiter), $delimiters);
|
|
arsort($counts);
|
|
|
|
return $delimiters[(int) array_key_first($counts)];
|
|
}
|
|
|
|
private function normalizeHeader(string $header): string
|
|
{
|
|
return Str::of($header)
|
|
->trim()
|
|
->lower()
|
|
->replace([' ', '-', '.', '/', '(', ')', '%'], '_')
|
|
->replaceMatches('/_+/', '_')
|
|
->trim('_')
|
|
->toString();
|
|
}
|
|
|
|
private function assertRequiredHeaders(array $headers): void
|
|
{
|
|
if (! in_array('no_akaun', $headers, true)) {
|
|
$this->fail('Fail mesti mempunyai kolum no_akaun.');
|
|
}
|
|
}
|
|
|
|
private function isEmptyRow(array $row): bool
|
|
{
|
|
return trim(implode('', array_map(fn ($value) => (string) $value, $row))) === '';
|
|
}
|
|
|
|
private function stringValue(mixed $value): ?string
|
|
{
|
|
$value = trim((string) ($value ?? ''));
|
|
|
|
return $value === '' ? null : $value;
|
|
}
|
|
|
|
private function decimalValue(mixed $value): float
|
|
{
|
|
$value = trim((string) ($value ?? ''));
|
|
$value = str_replace([',', 'RM', 'rm', ' '], '', $value);
|
|
|
|
return $value === '' || ! is_numeric($value) ? 0.0 : (float) $value;
|
|
}
|
|
|
|
private function fail(string $message): never
|
|
{
|
|
throw new HttpResponseException(back()->withErrors(['file' => $message])->withInput());
|
|
}
|
|
}
|