Kimi chinh sua

This commit is contained in:
2026-04-24 08:58:53 +00:00
parent 91ff4a5e4d
commit 86216ef872
43 changed files with 2868 additions and 597 deletions

View File

@@ -0,0 +1,167 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Contract;
use App\Models\Product;
use App\Models\Customer;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class ImportContractsComplex extends Command
{
protected $signature = 'import:contracts-complex {hopdong=hopdong.xlsx} {hdkh=Hd_kh.xlsx}';
protected $description = 'Import hợp đồng và liên kết khách hàng từ 2 file Excel';
public function handle()
{
$fileHopDong = $this->argument('hopdong');
$fileHdKh = $this->argument('hdkh');
if (!file_exists($fileHopDong) || !file_exists($fileHdKh)) {
$this->error("Không tìm thấy một trong hai file Excel.");
return 1;
}
// BƯỚC 1: ĐỌC FILE HOPDONG.XLSX ĐỂ LẤY DỮ LIỆU TÀI CHÍNH
$this->info("Đang xử lý dữ liệu tài chính từ hopdong.xlsx...");
$sheetFinance = IOFactory::load($fileHopDong)->getActiveSheet();
$rowsFinance = $sheetFinance->toArray();
$financeMap = [];
foreach ($rowsFinance as $idx => $row) {
if ($idx === 0 || empty($row[2])) continue; // Bỏ qua header hoặc Số HĐMB trống
$contractNumber = trim($row[2]);
$financeMap[$contractNumber] = [
'signing_date' => $this->parseExcelDate($row[1]),
'sale_date' => $this->parseExcelDate($row[3]),
'hql_confirmation_date' => $this->parseExcelDate($row[4]),
'brokerage_name' => $row[5],
'land_value' => $this->parseMoney($row[9]),
'foundation_value' => $this->parseMoney($row[10]),
'total_value_with_foundation' => $this->parseMoney($row[11]),
'stored_contract_count' => (int)$row[23],
'filing_note' => $row[26],
'discounts' => [
'open_sale' => $row[13],
'multi_lot' => $row[14],
'wholesale' => $row[15],
'ctv' => $row[16],
'full_payment' => $row[17],
'total_percentage' => $row[18],
'total_amount' => $this->parseMoney($row[19]),
]
];
}
// BƯỚC 2: ĐỌC FILE HD_KH.XLSX ĐỂ TẠO HỢP ĐỒNG VÀ LIÊN KẾT
$this->info("Đang xử lý liên kết khách hàng từ Hd_kh.xlsx...");
$sheetLink = IOFactory::load($fileHdKh)->getActiveSheet();
$rowsLink = $sheetLink->toArray();
$count = 0;
DB::beginTransaction();
try {
foreach ($rowsLink as $idx => $row) {
if ($idx === 0 || empty($row[2])) continue; // Bỏ qua header hoặc Mã Lô trống
$plotCode = trim($row[2]);
$customerCmnd = trim($row[5]);
$transferOrder = (int)$row[3];
// Tìm sản phẩm
$product = Product::where('code', $plotCode)->first();
if (!$product) {
$this->warn("Bỏ qua: Không tìm thấy Lô {$plotCode} trong database.");
continue;
}
// Tìm khách hàng
$customer = Customer::where('cmnd_cccd', $customerCmnd)->first();
if (!$customer) {
$this->warn("Bỏ qua: Không tìm thấy Khách hàng CMND {$customerCmnd} ({$row[6]}).");
continue;
}
// Logic tìm Hợp đồng tương ứng trong financeMap
// Vì hopdong.xlsx không có mã lô, ta sẽ tìm trong financeMap xem Số HĐMB nào có chứa mã lô này
$targetContractNumber = null;
$financeData = null;
foreach ($financeMap as $number => $data) {
if (str_contains($number, $plotCode)) {
$targetContractNumber = $number;
$financeData = $data;
break;
}
}
if (!$targetContractNumber) {
$this->warn("{$plotCode}: Không tìm thấy thông tin tài chính trong hopdong.xlsx. Sẽ dùng mã tạm.");
$targetContractNumber = "HD-TEMP-" . $plotCode . "-" . $transferOrder;
}
// Tạo/Cập nhật Hợp đồng
$contract = Contract::updateOrCreate(
['contract_number' => $targetContractNumber],
[
'product_id' => $product->id,
'signing_date' => $financeData['signing_date'] ?? null,
'total_value' => $financeData['total_value_with_foundation'] ?? 0,
'land_value' => $financeData['land_value'] ?? 0,
'foundation_value' => $financeData['foundation_value'] ?? 0,
'total_value_with_foundation' => $financeData['total_value_with_foundation'] ?? 0,
'discount_details' => $financeData['discounts'] ?? [],
'brokerage_name' => $financeData['brokerage_name'] ?? null,
'sale_date' => $financeData['sale_date'] ?? null,
'hql_confirmation_date' => $financeData['hql_confirmation_date'] ?? null,
'stored_contract_count' => $financeData['stored_contract_count'] ?? 0,
'filing_note' => $financeData['filing_note'] ?? null,
'transfer_order' => $transferOrder,
'contract_type' => 'HĐMB',
'status' => 'Đang hiệu lực', // Tạm thời set mặc định
]
);
// Liên kết khách hàng (Pivot)
$contract->customers()->syncWithoutDetaching([
$customer->id => [
'role' => $row[7] ?? 'Chủ SH',
'transfer_order' => $transferOrder
]
]);
$count++;
}
DB::commit();
$this->info("Thành công! Đã tạo và liên kết {$count} bản ghi hợp đồng.");
} catch (\Exception $e) {
DB::rollBack();
$this->error("Lỗi: " . $e->getMessage());
}
return 0;
}
private function parseMoney($value)
{
if (empty($value)) return 0;
return (float) str_replace([',', ' '], '', $value);
}
private function parseExcelDate($value)
{
if (empty($value)) return null;
try {
if (is_numeric($value)) {
return Carbon::instance(ExcelDate::excelToDateTimeObject($value))->format('Y-m-d');
}
return Carbon::parse(str_replace('/', '-', $value))->format('Y-m-d');
} catch (\Exception $e) {
return null;
}
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Customer;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use Carbon\Carbon;
class ImportCustomersExcel extends Command
{
protected $signature = 'import:customers-excel {file=khachhang.xlsx}';
protected $description = 'Import khách hàng từ file Excel và tạo dữ liệu mẫu Công ty';
public function handle()
{
$filePath = $this->argument('file');
if (!file_exists($filePath)) {
$this->error("Không tìm thấy file: {$filePath}");
return 1;
}
$this->info("Đang đọc file Excel...");
$spreadsheet = IOFactory::load($filePath);
$worksheet = $spreadsheet->getActiveSheet();
$rows = $worksheet->toArray();
$count = 0;
foreach ($rows as $index => $row) {
if ($index === 0 || empty($row[1])) continue; // Bỏ qua header hoặc CMND trống
// 1. Xử lý số điện thoại (Tách nếu có nhiều số)
$phoneRaw = $row[5] ?? '';
$phones = preg_split('/[,\/ \n]+/', $phoneRaw, -1, PREG_SPLIT_NO_EMPTY);
$mainPhone = $phones[0] ?? null;
$secondaryPhones = array_slice($phones, 1);
// 2. Xử lý ngày tháng (Excel thường lưu ngày là số serial)
$dob = $this->parseExcelDate($row[4]);
$issueDate = $this->parseExcelDate($row[7]);
Customer::updateOrCreate(
['cmnd_cccd' => (string)$row[1]],
[
'title' => $row[2],
'full_name' => $row[3],
'dob' => $dob,
'phone' => $mainPhone,
'secondary_phones' => $secondaryPhones,
'email' => $row[6],
'id_issue_date' => $issueDate,
'id_issue_place' => $row[8],
'permanent_address' => $row[9],
'contact_address' => $row[10],
'type' => 'INDIVIDUAL',
]
);
$count++;
if ($count % 10 === 0) $this->line("Đã import: {$count} khách hàng...");
}
$this->info("--- TẠO DỮ LIỆU MẪU CÔNG TY ---");
$this->createSampleCompany();
$this->info("Thành công! Đã import {$count} khách hàng và tạo 1 cặp Công ty + Người đại diện mẫu.");
return 0;
}
private function parseExcelDate($value)
{
if (empty($value)) return null;
try {
if (is_numeric($value)) {
return Carbon::instance(ExcelDate::excelToDateTimeObject($value))->format('Y-m-d');
}
return Carbon::parse(str_replace('/', '-', $value))->format('Y-m-d');
} catch (\Exception $e) {
return null;
}
}
private function createSampleCompany()
{
// 1. Tạo người đại diện (Cá nhân)
$rep = Customer::updateOrCreate(
['cmnd_cccd' => '079083000123'],
[
'title' => 'Ông',
'full_name' => 'NGUYỄN VĂN ĐẠI DIỆN',
'phone' => '0909123456',
'permanent_address' => '123 Đường ABC, Phường 1, Quận 1, TP.HCM',
'contact_address' => '123 Đường ABC, Phường 1, Quận 1, TP.HCM',
'type' => 'INDIVIDUAL',
]
);
// 2. Tạo công ty liên kết với người đại diện trên
Customer::updateOrCreate(
['tax_code' => '0102030405'],
[
'type' => 'COMPANY',
'full_name' => 'CÔNG TY TNHH BẤT ĐỘNG SẢN THỊNH VƯỢNG',
'cmnd_cccd' => '0102030405', // GPKD
'representative_id' => $rep->id,
'permanent_address' => '456 Đường XYZ, Phường 2, Quận Tân Bình, TP.HCM', // Trụ sở chính
'contact_address' => '456 Đường XYZ, Phường 2, Quận Tân Bình, TP.HCM',
'phone' => '02838111222',
]
);
$this->info("Đã tạo: Công ty Thịnh Vượng (Đại diện bởi: {$rep->full_name})");
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Project;
use App\Models\Product;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Illuminate\Support\Str;
class ImportProductsExcel extends Command
{
protected $signature = 'import:products-excel {file=sanpham.xlsx}';
protected $description = 'Import sản phẩm từ file Excel vào dự án Hà Quang 1';
public function handle()
{
$filePath = $this->argument('file');
if (!file_exists($filePath)) {
$this->error("Không tìm thấy file: {$filePath}");
return 1;
}
$this->info("Đang đọc file Excel...");
$spreadsheet = IOFactory::load($filePath);
$worksheet = $spreadsheet->getActiveSheet();
$rows = $worksheet->toArray();
// 1. Đảm bảo có dự án Hà Quang 1
$project = Project::firstOrCreate(
['name' => 'Hà Quang 1'],
['code' => 'HQ1']
);
$this->info("Dự án: {$project->name} (ID: {$project->id})");
// 2. Duyệt dữ liệu (bỏ qua dòng tiêu đề)
$count = 0;
foreach ($rows as $index => $row) {
if ($index === 0 || empty($row[2])) continue; // Bỏ qua header hoặc dòng trống mã lô
$code = $row[2];
// Chuẩn hóa số
$area = (float) $row[3];
$price_per_unit = $this->parseMoney($row[4]);
$total_price = $this->parseMoney($row[5]);
$qsdd_value = $this->parseMoney($row[6]);
$foundation_temp_value = $this->parseMoney($row[7]);
$contract_temp_value = $this->parseMoney($row[8]);
// Phân tách hạ tầng (JSONB)
$infraRaw = $row[14] ?? '';
$infraJson = $this->parseInfrastructure($infraRaw);
// Custom data
$customData = [
'block' => $row[1],
'building_density' => $row[12],
'legal_status_raw' => $row[15],
'summary_legal' => $row[19],
];
Product::updateOrCreate(
['code' => $code, 'project_id' => $project->id],
[
'product_type' => 'LAND', // Mặc định là đất nền theo file
'area' => $area,
'price_per_unit' => $price_per_unit,
'total_price' => $total_price,
'qsdd_value' => $qsdd_value,
'foundation_temp_value' => $foundation_temp_value,
'contract_temp_value' => $contract_temp_value,
'adjacent_road' => $row[9],
'frontage_count' => (int) $row[10],
'max_floors' => (int) $row[11],
'construction_status' => $row[13] ?? 'Chưa xây dựng',
'infrastructure_status' => $infraJson,
'custom_data' => $customData,
'status' => 'Đang mở bán',
]
);
$count++;
if ($count % 10 === 0) $this->line("Đã import: {$count} sản phẩm...");
}
$this->info("Thành công! Đã import tổng cộng {$count} sản phẩm vào dự án Hà Quang 1.");
return 0;
}
private function parseMoney($value)
{
if (empty($value)) return 0;
// Xóa dấu phẩy và khoảng trắng
return (float) str_replace([',', ' '], '', $value);
}
private function parseInfrastructure($raw)
{
if (empty($raw)) return [];
$result = [];
// Tách theo dấu gạch ngang " - "
$parts = explode(' - ', $raw);
foreach ($parts as $part) {
// Tách theo dấu hai chấm ":"
$subParts = explode(':', $part, 2);
if (count($subParts) === 2) {
$key = trim($subParts[0]);
$value = trim($subParts[1]);
$result[$key] = $value;
}
}
return $result;
}
}

View File

@@ -4,15 +4,13 @@ namespace App\Filament\Resources\Contracts;
use App\Filament\Resources\Contracts\Pages;
use App\Models\Contract;
use App\Models\Product;
use App\Models\PaymentTemplate;
use App\Enums\NavigationGroup;
use App\Services\ContractScheduleService;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use App\Filament\Resources\Contracts\ContractResource\RelationManagers\ScheduleItemsRelationManager;
use App\Filament\Resources\Contracts\Schemas\ContractForm;
class ContractResource extends Resource
@@ -37,6 +35,27 @@ class ContractResource extends Resource
Tables\Columns\TextColumn::make('contract_number')->label('Số HĐ')->searchable(),
Tables\Columns\TextColumn::make('product.code')->label('Sản phẩm'),
Tables\Columns\TextColumn::make('total_value')->label('Giá trị')->money('VND'),
Tables\Columns\TextColumn::make('paid_amount')->label('Đã thu')->money('VND'),
Tables\Columns\TextColumn::make('remaining_amount')->label('Còn lại')->money('VND'),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\Action::make('generateSchedule')
->label('Tạo lịch TT')
->icon('heroicon-o-calendar-days')
->color('warning')
->requiresConfirmation()
->modalHeading('Tạo lịch thanh toán')
->modalDescription('Hành động này sẽ xóa lịch thanh toán cũ (nếu có) và tạo lại từ mẫu của dự án.')
->action(function (Contract $record) {
try {
ContractScheduleService::generateFromTemplate($record);
} catch (\InvalidArgumentException $e) {
// Filament sẽ tự động hiển thị lỗi nếu throw ra trong action
throw $e;
}
})
->visible(fn (Contract $record) => $record->signing_date !== null),
]);
}

View File

@@ -3,11 +3,8 @@
namespace App\Filament\Resources\Contracts\Pages;
use App\Filament\Resources\Contracts\ContractResource;
use App\Models\PaymentTemplate;
use App\Models\PaymentSchedule;
use App\Models\PaymentScheduleItem;
use App\Services\ContractScheduleService;
use Filament\Resources\Pages\CreateRecord;
use Carbon\Carbon;
class CreateContract extends CreateRecord
{
@@ -19,42 +16,9 @@ class CreateContract extends CreateRecord
$templateId = $this->data['payment_template_id'] ?? null;
if ($templateId) {
$template = PaymentTemplate::find($templateId);
// 1. Tạo Schedule cho Hợp đồng
$schedule = PaymentSchedule::create([
'contract_id' => $contract->id,
'template_id' => $template->id,
]);
// 2. Clone từng Item từ Template sang Schedule thực tế
$items = $template->items()->orderBy('installment_no')->get();
$lastDueDate = Carbon::parse($contract->signing_date);
foreach ($items as $item) {
$dueDate = null;
// Logic tính ngày đến hạn chuẩn v5.5
if ($item->days_after_signing !== null) {
$dueDate = Carbon::parse($contract->signing_date)->addDays($item->days_after_signing);
} elseif ($item->days_after_previous !== null) {
$dueDate = $lastDueDate->copy()->addDays($item->days_after_previous);
} elseif ($item->due_date !== null) {
$dueDate = $item->due_date;
}
PaymentScheduleItem::create([
'schedule_id' => $schedule->id,
'installment_no' => $item->installment_no,
'type' => $item->type,
'percentage' => $item->percentage,
'amount' => $contract->total_value * ($item->percentage / 100),
'due_date' => $dueDate,
]);
if ($dueDate) {
$lastDueDate = $dueDate;
}
$template = \App\Models\PaymentTemplate::find($templateId);
if ($template) {
ContractScheduleService::generateFromTemplate($contract, $template);
}
}
}

View File

@@ -8,9 +8,12 @@ use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\DatePicker;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Grid;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Support\HtmlString;
class ContractForm
{
@@ -18,63 +21,154 @@ class ContractForm
{
return $schema
->components([
Section::make('Liên kết & Mẫu thanh toán')
->columns(2)
Grid::make(3)
->schema([
Select::make('product_id')
->label('Sản phẩm')
->relationship('product', 'code')
->required()
->live()
->afterStateUpdated(function (Set $set, $state) {
if ($state) {
$product = Product::find($state);
if ($product) {
$set('total_value', $product->total_price);
}
Section::make('Thông tin định danh')
->columnSpan(2)
->columns(2)
->schema([
Select::make('product_id')
->label('Sản phẩm (Lô đất)')
->relationship('product', 'code')
->searchable()
->preload()
->required()
->live()
->afterStateUpdated(function (Set $set, $state) {
if ($state) {
$product = Product::find($state);
if ($product) {
$set('total_value', $product->total_price);
$set('land_value', $product->qsdd_value);
$set('foundation_value', $product->foundation_temp_value);
}
}
}),
TextInput::make('contract_number')
->label('Số HĐMB')
->required()
->unique(ignoreRecord: true),
Select::make('contract_type')
->label('Loại hợp đồng')
->options([
'HĐMB' => 'Hợp đồng mua bán',
'HĐGV' => 'Hợp đồng góp vốn',
'HĐDC' => 'Hợp đồng đặt cọc',
])
->default('HĐMB')
->required(),
TextInput::make('transfer_order')
->label('Thứ tự chuyển nhượng')
->numeric()
->default(0)
->helperText('0 là chủ hiện tại, 1 là F0, 2 là F1...'),
]),
Section::make('Trạng thái')
->columnSpan(1)
->schema([
Select::make('status')
->label('Trạng thái pháp lý')
->options([
'Đang hiệu lực' => 'Đang hiệu lực',
'Đã hoàn thành' => 'Đã hoàn thành',
'Đã hủy' => 'Đã hủy',
])
->default('Đang hiệu lực')
->required(),
DatePicker::make('signing_date')
->label('Ngày ký HĐ')
->required(),
DatePicker::make('sale_date')
->label('Ngày bán thực tế'),
]),
]),
Section::make('Chi tiết Tài chính & Chiết khấu')
->columns(3)
->schema([
TextInput::make('land_value')
->label('Giá trị QSDĐ')
->numeric()
->prefix('VND')
->live(onBlur: true)
->afterStateUpdated(fn ($state, $get, $set) => $set('total_value', (float)$state + (float)$get('foundation_value'))),
TextInput::make('foundation_value')
->label('Giá trị Móng')
->numeric()
->prefix('VND')
->live(onBlur: true)
->afterStateUpdated(fn ($state, $get, $set) => $set('total_value', (float)$get('land_value') + (float)$state)),
TextInput::make('total_value')
->label('Tổng giá trị niêm yết')
->numeric()
->prefix('VND')
->readOnly(),
Placeholder::make('discount_overview')
->label('Tổng quan chiết khấu (Dữ liệu từ Excel)')
->columnSpanFull()
->content(function ($record) {
if (!$record || !$record->discount_details) return 'Không có chiết khấu';
$details = $record->discount_details;
$html = '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; background: #f9fafb; padding: 15px; border-radius: 8px; border: 1px solid #e5e7eb;">';
foreach ($details as $key => $val) {
if (empty($val)) continue;
$label = match($key) {
'open_sale' => 'Mở bán',
'multi_lot' => 'Số nhiều',
'wholesale' => 'Mua sỉ',
'ctv' => 'Cộng tác viên',
'full_payment' => 'Trả 1 lần',
'total_amount' => 'Tổng tiền CK',
'total_percentage' => 'Tổng % CK',
default => $key
};
$style = str_contains($key, 'total') ? 'font-weight: bold; color: #16a34a;' : 'color: #4b5563;';
$html .= "<div>
<div style='font-size: 0.7rem; color: #9ca3af; text-transform: uppercase; margin-bottom: 4px;'>{$label}</div>
<div style='{$style} font-size: 0.9rem;'>{$val}</div>
</div>";
}
$html .= '</div>';
return new HtmlString($html);
}),
Select::make('payment_template_id')
->label('Mẫu thanh toán')
->options(fn () => PaymentTemplate::pluck('name', 'id'))
->required()
->dehydrated(false),
Select::make('customers')
->label('Khách hàng')
->relationship('customers', 'full_name')
->multiple()
->required()
KeyValue::make('discount_details')
->label('Bảng chi tiết chiết khấu (Dạng Key-Value)')
->columnSpanFull(),
]),
Section::make('Chi tiết Hợp đồng')
Section::make('Thông tin quản lý & Khách hàng')
->columns(2)
->schema([
TextInput::make('contract_number')->label('Số HĐ')->required(),
Select::make('contract_type')
->label('Loại HĐ')
->options([
'HĐMB' => 'HĐMB',
'HĐGV' => 'HĐGV',
'HĐDC' => 'HĐDC',
])
->default('HĐMB')
->required(),
DatePicker::make('signing_date')->label('Ngày ký')->required(),
TextInput::make('total_value')
->label('Giá trị HĐ')
->numeric()
Select::make('customers')
->label('Khách hàng đứng tên')
->multiple()
->relationship('customers', 'full_name')
->preload()
->required()
->prefix('VND'),
Select::make('status')
->label('Trạng thái')
->options([
'Đang hiệu lực' => 'Đang hiệu lực',
'Đã hoàn thành' => 'Đã hoàn thành',
'Đã hủy' => 'Đã hủy',
])
->default('Đang hiệu lực')
->required(),
])
->columnSpanFull(),
TextInput::make('brokerage_name')
->label('Đơn vị môi giới'),
DatePicker::make('hql_confirmation_date')
->label('Ngày HQL xác nhận'),
TextInput::make('stored_contract_count')
->label('Số lượng HĐ lưu')
->numeric()
->default(0),
TextInput::make('filing_note')
->label('Ghi chú hồ sơ')
->columnSpanFull(),
Select::make('payment_template_id')
->label('Áp dụng mẫu thanh toán')
->placeholder('Chọn mẫu để tự động tạo lịch trình...')
->options(PaymentTemplate::pluck('name', 'id'))
->searchable()
->dehydrated(false)
->helperText('Lưu ý: Chỉ chọn nếu bạn muốn khởi tạo lại lịch trình thanh toán.'),
]),
]);
}
}

View File

@@ -5,6 +5,8 @@ namespace App\Filament\Resources\Contracts\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Table;
class ContractsTable
@@ -13,18 +15,70 @@ class ContractsTable
{
return $table
->columns([
//
TextColumn::make('contract_number')
->label('Số HĐMB')
->searchable()
->sortable()
->copyable()
->description(fn ($record) => "Lô: {$record->product?->code}"),
TextColumn::make('customers.full_name')
->label('Khách hàng')
->searchable()
->listWithLineBreaks()
->bulleted(),
TextColumn::make('signing_date')
->label('Ngày ký')
->date('d/m/Y')
->sortable(),
TextColumn::make('total_value')
->label('Giá trị HĐ')
->money('VND')
->sortable()
->summarize(\Filament\Tables\Columns\Summarizers\Sum::make()->label('Tổng doanh thu')->money('VND')),
TextColumn::make('transfer_order')
->label('Đời CN')
->badge()
->color(fn ($state) => $state == 0 ? 'success' : 'gray')
->formatStateUsing(fn ($state) => $state == 0 ? 'Hiện tại' : "F{$state}")
->alignCenter(),
TextColumn::make('status')
->label('Trạng thái')
->badge()
->color(fn (string $state): string => match ($state) {
'Đang hiệu lực' => 'success',
'Đã hoàn thành' => 'primary',
'Đã hủy' => 'danger',
default => 'gray',
}),
])
->filters([
//
\Filament\Tables\Filters\SelectFilter::make('status')
->label('Trạng thái')
->options([
'Đang hiệu lực' => 'Đang hiệu lực',
'Đã hoàn thành' => 'Đã hoàn thành',
'Đã hủy' => 'Đã hủy',
]),
\Filament\Tables\Filters\TernaryFilter::make('is_current')
->label('Chủ sở hữu hiện tại')
->queries(
true: fn ($query) => $query->where('transfer_order', 0),
false: fn ($query) => $query->where('transfer_order', '>', 0),
)
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
])
->defaultSort('created_at', 'desc');
}
}

View File

@@ -4,16 +4,12 @@ namespace App\Filament\Resources\Customers;
use App\Filament\Resources\Customers\Pages;
use App\Models\Customer;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use App\Enums\NavigationGroup;
use App\Filament\Resources\Customers\Schemas\CustomerForm;
use App\Filament\Resources\Customers\Tables\CustomersTable;
class CustomerResource extends Resource
{
@@ -22,47 +18,17 @@ class CustomerResource extends Resource
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::CUSTOMER->value;
protected static ?int $navigationSort = 3;
protected static ?string $modelLabel = 'Khách hàng';
protected static ?string $pluralModelLabel = 'Khách hàng';
public static function form(Schema $schema): Schema
{
return $schema
->components([
Section::make('Thông tin định danh')
->columns(2)
->schema([
TextInput::make('full_name')->label('Họ và Tên')->required(),
TextInput::make('cmnd_cccd')->label('Số CMND / CCCD')->required()->unique(ignoreRecord: true),
DatePicker::make('dob')->label('Ngày sinh')->displayFormat('d/m/Y'),
]),
Section::make('Thông liên lạc')
->columns(2)
->schema([
TextInput::make('phone')->label('Số điện thoại')->tel()->required(),
TextInput::make('email')->label('Email')->email(),
]),
Section::make('Địa chỉ chi tiết')
->schema([
Fieldset::make('address')
->label('Cấu trúc địa chỉ')->columns(3)
->schema([
TextInput::make('address.street')->label('Số nhà, đường')->columnSpan(3),
TextInput::make('address.ward')->label('Phường / Xã'),
TextInput::make('address.district')->label('Quận / Huyện'),
TextInput::make('address.city')->label('Tỉnh / Thành phố'),
]),
]),
]);
return CustomerForm::configure($schema);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('full_name')->label('Họ Tên')->searchable(),
Tables\Columns\TextColumn::make('cmnd_cccd')->label('CMND/CCCD')->searchable(),
Tables\Columns\TextColumn::make('phone')->label('Điện thoại'),
Tables\Columns\TextColumn::make('address.city')->label('Tỉnh/Thành')->sortable(),
])
->defaultSort('created_at', 'desc');
return CustomersTable::configure($table);
}
public static function getPages(): array

View File

@@ -2,9 +2,15 @@
namespace App\Filament\Resources\Customers\Schemas;
use App\Models\Customer;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\TagsInput;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\Utilities\Set;
class CustomerForm
{
@@ -12,20 +18,75 @@ class CustomerForm
{
return $schema
->components([
TextInput::make('full_name')
->required(),
TextInput::make('cmnd_cccd')
->required(),
TextInput::make('phone')
->tel(),
TextInput::make('email')
->label('Email address')
->email(),
TextInput::make('address_permanent'),
TextInput::make('address_contact'),
DatePicker::make('dob'),
DatePicker::make('id_issue_date'),
TextInput::make('id_issue_place'),
Section::make('Thông tin định danh')
->columns(2)
->schema([
Select::make('type')
->label('Loại khách hàng')
->options([
'INDIVIDUAL' => 'Cá nhân',
'COMPANY' => 'Công ty',
])
->required()
->live(),
TextInput::make('full_name')
->label(fn ($get) => $get('type') === 'COMPANY' ? 'Tên công ty' : 'Họ và tên')
->required(),
TextInput::make('cmnd_cccd')
->label(fn ($get) => $get('type') === 'COMPANY' ? 'GPKD / Mã số thuế' : 'CMND / CCCD')
->required(),
TextInput::make('tax_code')
->label('Mã số thuế')
->visible(fn ($get) => $get('type') === 'COMPANY'),
Select::make('representative_id')
->label('Người đại diện pháp luật')
->options(Customer::where('type', 'INDIVIDUAL')->pluck('full_name', 'id'))
->searchable()
->visible(fn ($get) => $get('type') === 'COMPANY')
->required(fn ($get) => $get('type') === 'COMPANY'),
]),
Section::make('Liên lạc')
->columns(2)
->schema([
TextInput::make('phone')
->label('Số điện thoại chính')
->tel(),
TagsInput::make('secondary_phones')
->label('Số điện thoại phụ')
->placeholder('Nhập số và nhấn Enter'),
TextInput::make('email')
->label('Địa chỉ Email')
->email(),
]),
Section::make('Địa chỉ')
->columns(2)
->schema([
TextInput::make('permanent_address')
->label('Địa chỉ thường trú / Trụ sở')
->required()
->suffixAction(
Action::make('clone_to_contact')
->label('Copy sang liên hệ')
->icon('heroicon-m-arrow-right-start-on-rectangle')
->action(function (Set $set, $state) {
$set('contact_address', $state);
})
),
TextInput::make('contact_address')
->label('Địa chỉ liên hệ')
->required(),
]),
Section::make('Thông tin bổ sung')
->columns(3)
->visible(fn ($get) => $get('type') === 'INDIVIDUAL')
->schema([
DatePicker::make('dob')->label('Ngày sinh'),
DatePicker::make('id_issue_date')->label('Ngày cấp CMND'),
TextInput::make('id_issue_place')->label('Nơi cấp'),
]),
]);
}
}

View File

@@ -6,6 +6,7 @@ use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Table;
class CustomersTable
@@ -14,45 +15,60 @@ class CustomersTable
{
return $table
->columns([
TextColumn::make('id')
->label('ID'),
TextColumn::make('full_name')
->searchable(),
TextColumn::make('cmnd_cccd')
->searchable(),
TextColumn::make('phone')
->searchable(),
TextColumn::make('email')
->label('Email address')
->searchable(),
TextColumn::make('address_permanent')
->searchable(),
TextColumn::make('address_contact')
->searchable(),
TextColumn::make('dob')
->date()
->sortable(),
TextColumn::make('id_issue_date')
->date()
->sortable(),
TextColumn::make('id_issue_place')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->label('Họ tên / Công ty')
->searchable()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->description(fn ($record) => $get_desc = $record->type === 'COMPANY' ? "ĐD: {$record->representative?->full_name}" : $record->cmnd_cccd),
TextColumn::make('type')
->label('Loại')
->badge()
->color(fn (string $state): string => match ($state) {
'COMPANY' => 'warning',
'INDIVIDUAL' => 'success',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'COMPANY' => 'Công ty',
'INDIVIDUAL' => 'Cá nhân',
default => $state,
}),
TextColumn::make('phone')
->label('Điện thoại')
->searchable(),
TextColumn::make('permanent_address')
->label('Địa chỉ thường trú')
->limit(30)
->searchable()
->toggleable(),
TextColumn::make('contact_address')
->label('Địa chỉ liên hệ')
->limit(30)
->searchable()
->toggleable(),
TextColumn::make('created_at')
->label('Ngày tạo')
->dateTime('d/m/Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
\Filament\Tables\Filters\SelectFilter::make('type')
->label('Loại khách hàng')
->options([
'INDIVIDUAL' => 'Cá nhân',
'COMPANY' => 'Công ty',
]),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Payments\Pages;
use App\Filament\Resources\Payments\PaymentResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePayment extends CreateRecord
{
protected static string $resource = PaymentResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Payments\Pages;
use App\Filament\Resources\Payments\PaymentResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditPayment extends EditRecord
{
protected static string $resource = PaymentResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Payments\Pages;
use App\Filament\Resources\Payments\PaymentResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListPayments extends ListRecords
{
protected static string $resource = PaymentResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\Payments;
use App\Filament\Resources\Payments\Pages;
use App\Models\Payment;
use App\Enums\NavigationGroup;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use App\Filament\Resources\Payments\Schemas\PaymentForm;
use App\Filament\Resources\Payments\Tables\PaymentsTable;
class PaymentResource extends Resource
{
protected static ?string $model = Payment::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-banknotes';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value;
protected static ?int $navigationSort = 5;
protected static ?string $modelLabel = 'Phiếu thu';
protected static ?string $pluralModelLabel = 'Phiếu thu';
public static function form(Schema $schema): Schema
{
return PaymentForm::configure($schema);
}
public static function table(Table $table): Table
{
return PaymentsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => Pages\ListPayments::route('/'),
'create' => Pages\CreatePayment::route('/create'),
'edit' => Pages\EditPayment::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Filament\Resources\Payments\Schemas;
use App\Models\PaymentScheduleItem;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\Utilities\Set;
class PaymentForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Grid::make(3)
->schema([
Section::make('Thông tin phiếu thu')
->columnSpan(2)
->columns(2)
->schema([
Select::make('contract_id')
->label('Hợp đồng')
->relationship('contract', 'contract_number')
->searchable()
->preload()
->required()
->live()
->afterStateUpdated(function (Set $set) {
$set('schedule_item_id', null);
}),
Select::make('schedule_item_id')
->label('Đợt thanh toán')
->placeholder('Để trống nếu là tạm ứng / không đối soát đợt')
->options(function (callable $get) {
$contractId = $get('contract_id');
if (! $contractId) {
return [];
}
return PaymentScheduleItem::query()
->whereHas('schedule', fn ($q) => $q->where('contract_id', $contractId))
->get()
->mapWithKeys(function ($item) {
$label = 'Đợt '.$item->installment_no.' - '.$item->type;
if ($item->amount) {
$label .= ' ('.number_format($item->amount).' VNĐ)';
}
return [$item->id => $label];
});
})
->searchable(),
TextInput::make('amount')
->label('Số tiền thu')
->numeric()
->prefix('VND')
->required(),
DatePicker::make('paid_date')
->label('Ngày thu')
->required()
->default(now()),
TextInput::make('receipt_number')
->label('Số phiếu thu / Mã giao dịch'),
Select::make('method')
->label('Phương thức thanh toán')
->options([
'Chuyển khoản' => 'Chuyển khoản',
'Tiền mặt' => 'Tiền mặt',
'Thẻ' => 'Thẻ',
'Khác' => 'Khác',
])
->default('Chuyển khoản')
->required(),
]),
Section::make('Bổ sung')
->columnSpan(1)
->schema([
KeyValue::make('metadata')
->label('Dữ liệu bổ sung (nếu có)')
->keyLabel('Thông tin')
->valueLabel('Giá trị'),
]),
]),
]);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Filament\Resources\Payments\Tables;
use Filament\Tables;
use Filament\Tables\Table;
class PaymentsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('contract.contract_number')
->label('Hợp đồng')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('amount')
->label('Số tiền')
->money('VND')
->sortable(),
Tables\Columns\TextColumn::make('paid_date')
->label('Ngày thu')
->date('d/m/Y')
->sortable(),
Tables\Columns\TextColumn::make('method')
->label('Phương thức')
->badge(),
Tables\Columns\TextColumn::make('receipt_number')
->label('Số phiếu thu')
->searchable(),
Tables\Columns\TextColumn::make('scheduleItem.installment_no')
->label('Đợt TT')
->placeholder('Tạm ứng'),
])
->filters([
Tables\Filters\SelectFilter::make('method')
->label('Phương thức')
->options([
'Chuyển khoản' => 'Chuyển khoản',
'Tiền mặt' => 'Tiền mặt',
'Thẻ' => 'Thẻ',
'Khác' => 'Khác',
]),
Tables\Filters\Filter::make('paid_date')
->label('Ngày thu')
->form([
\Filament\Forms\Components\DatePicker::make('from')->label('Từ ngày'),
\Filament\Forms\Components\DatePicker::make('to')->label('Đến ngày'),
])
->query(function ($query, array $data) {
return $query
->when($data['from'], fn ($q) => $q->whereDate('paid_date', '>=', $data['from']))
->when($data['to'], fn ($q) => $q->whereDate('paid_date', '<=', $data['to']));
}),
])
->defaultSort('paid_date', 'desc');
}
}

View File

@@ -8,7 +8,10 @@ use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Section;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Schemas\Schema;
use Illuminate\Support\HtmlString;
class ProductForm
{
@@ -49,29 +52,64 @@ class ProductForm
]),
Tabs\Tab::make('Kỹ thuật & Hạ tầng')
->icon('heroicon-o-cog')
->columns(2)
->schema([
TextInput::make('adjacent_road')->label('Đường tiếp giáp'),
TextInput::make('frontage_count')->label('Số mặt tiền')->numeric(),
TextInput::make('max_floors')->label('Số tầng tối đa')->numeric(),
TextInput::make('construction_status')->label('Trạng thái XD'),
KeyValue::make('infrastructure_status')
->label('Trạng thái hạ tầng')
->columnSpanFull(),
Section::make('Thông số kỹ thuật')
->columns(3)
->schema([
TextInput::make('adjacent_road')->label('Đường tiếp giáp'),
TextInput::make('frontage_count')->label('Số mặt tiền')->numeric(),
TextInput::make('max_floors')->label('Số tầng cao')->numeric(),
]),
Section::make('Chi tiết hạ tầng thực tế')
->description('Trình trạng hạ tầng kỹ thuật thực tế tại lô đất')
->schema([
Placeholder::make('infra_overview')
->label('Tổng quan trạng thái (Tự động cập nhật)')
->content(function ($record) {
if (!$record || !$record->infrastructure_status || !is_array($record->infrastructure_status)) {
return new HtmlString('<span style="color: #6b7280;">Chưa có dữ liệu hạ tầng</span>');
}
$html = '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; background: #f9fafb; padding: 15px; border-radius: 8px; border: 1px solid #e5e7eb;">';
foreach ($record->infrastructure_status as $key => $status) {
$statusLower = mb_strtolower($status);
$color = '#6b7280'; // Mặc định xám
$icon = '○';
if (str_contains($statusLower, 'hoàn thành') || str_contains($statusLower, 'đã bàn giao')) {
$color = '#16a34a'; // Xanh lá
$icon = '●';
} elseif (str_contains($statusLower, 'đang thi công') || str_contains($statusLower, 'đang triển khai')) {
$color = '#ca8a04'; // Vàng cam
$icon = '◐';
}
$html .= "<div style='display: flex; align-items: center; gap: 8px;'>
<span style='color: {$color}; font-size: 1.2rem;'>{$icon}</span>
<div>
<div style='font-size: 0.75rem; color: #6b7280; text-transform: uppercase;'>{$key}</div>
<div style='font-weight: 600; color: {$color}; font-size: 0.875rem;'>{$status}</div>
</div>
</div>";
}
$html .= '</div>';
return new HtmlString($html);
}),
KeyValue::make('infrastructure_status')
->label('Chỉnh sửa hạ tầng chi tiết')
->addActionLabel('Thêm hạng mục hạ tầng')
->keyLabel('Hạng mục')
->valueLabel('Trạng thái hiện tại')
->columnSpanFull(),
])
]),
Tabs\Tab::make('Tiến độ thanh toán')
->icon('heroicon-o-calendar-days')
->schema([
\Filament\Forms\Components\Repeater::make('payment_schedule_preview')
->label('Lịch trình dự kiến / Thực tế')
->relationship(function ($record) {
// Nếu sản phẩm đã có hợp đồng, lấy lịch trình từ hợp đồng đầu tiên
if ($record && $record->contracts()->exists()) {
return $record->contracts()->first()->scheduleItems();
}
// Nếu chưa có, chúng ta sẽ hiển thị mẫu từ Project (phần này cần logic state giả lập hoặc view)
return null;
})
->schema([
TextInput::make('installment_no')->label('Đợt')->disabled(),
TextInput::make('type')->label('Loại')->disabled(),
@@ -83,7 +121,7 @@ class ProductForm
->addable(false)
->deletable(false)
->reorderable(false)
->placeholder('Sản phẩm chưa có hợp đồng hoặc Dự án chưa gán mẫu thanh toán mặc định.')
->dehydrated(false)
]),
])->columnSpanFull()
]);

View File

@@ -4,14 +4,12 @@ namespace App\Filament\Resources\Projects;
use App\Filament\Resources\Projects\Pages;
use App\Models\Project;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use App\Enums\NavigationGroup;
use App\Filament\Resources\Projects\Schemas\ProjectForm;
class ProjectResource extends Resource
{
@@ -25,28 +23,7 @@ class ProjectResource extends Resource
public static function form(Schema $schema): Schema
{
return $schema
->components([
Section::make('Thông tin Dự án')
->columns(2)
->schema([
TextInput::make('code')->label('Mã Dự án')->required()->unique(ignoreRecord: true),
TextInput::make('name')->label('Tên Dự án')->required(),
Select::make('payment_template_id')
->label('Mẫu thanh toán mặc định')
->relationship('paymentTemplate', 'name')
->placeholder('Chọn mẫu thanh toán cho toàn dự án')
->columnSpanFull(),
Select::make('type')
->label('Loại hình')
->options([
'Khu đô thị' => 'Khu đô thị',
'Chung cư' => 'Chung cư',
'Đất nền phân lô' => 'Đất nền phân lô',
])->required(),
TextInput::make('address')->label('Địa chỉ')->columnSpanFull(),
])
]);
return ProjectForm::configure($schema);
}
public static function table(Table $table): Table

View File

@@ -2,6 +2,9 @@
namespace App\Filament\Resources\Projects\Schemas;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class ProjectForm
@@ -10,7 +13,25 @@ class ProjectForm
{
return $schema
->components([
//
Section::make('Thông tin Dự án')
->columns(2)
->schema([
TextInput::make('code')->label('Mã Dự án')->required()->unique(ignoreRecord: true),
TextInput::make('name')->label('Tên Dự án')->required(),
Select::make('payment_template_id')
->label('Mẫu thanh toán mặc định')
->relationship('paymentTemplate', 'name')
->placeholder('Chọn mẫu thanh toán cho toàn dự án')
->columnSpanFull(),
Select::make('type')
->label('Loại hình')
->options([
'Khu đô thị' => 'Khu đô thị',
'Chung cư' => 'Chung cư',
'Đất nền phân lô' => 'Đất nền phân lô',
])->required(),
TextInput::make('address')->label('Địa chỉ')->columnSpanFull(),
])
]);
}
}

View File

@@ -15,11 +15,17 @@ class Contract extends Model
protected $casts = [
'metadata' => 'array',
'discount_details' => 'array',
'total_value' => 'decimal:2',
'land_value' => 'decimal:2',
'foundation_value' => 'decimal:2',
'total_value_with_foundation' => 'decimal:2',
'paid_amount' => 'decimal:2',
'remaining_amount' => 'decimal:2',
'excess_amount' => 'decimal:2',
'signing_date' => 'date',
'sale_date' => 'date',
'hql_confirmation_date' => 'date',
];
public function product()
@@ -45,18 +51,15 @@ class Contract extends Model
return $this->hasOne(PaymentSchedule::class);
}
/**
* Lấy trực tiếp các đợt thanh toán của hợp đồng này
*/
public function scheduleItems(): HasManyThrough
{
return $this->hasManyThrough(
PaymentScheduleItem::class,
PaymentSchedule::class,
'contract_id', // Khóa ngoại trên bảng PaymentSchedule
'schedule_id', // Khóa ngoại trên bảng PaymentScheduleItem
'id', // Khóa chính trên bảng Contract
'id' // Khóa chính trên bảng PaymentSchedule
'contract_id',
'schedule_id',
'id',
'id'
);
}
@@ -72,17 +75,22 @@ class Contract extends Model
protected static function booted()
{
static::creating(function ($contract) {
// Tự động lấy giá trị từ sản phẩm nếu chưa có
if (empty($contract->total_value) && !empty($contract->product_id)) {
static::saving(function ($contract) {
// Bảo vệ tính toán tài chính: total_value luôn bằng land_value + foundation_value
$landValue = (float) ($contract->land_value ?? 0);
$foundationValue = (float) ($contract->foundation_value ?? 0);
if ($landValue > 0 || $foundationValue > 0) {
$contract->total_value = $landValue + $foundationValue;
} elseif ($contract->exists === false && empty($contract->total_value) && !empty($contract->product_id)) {
// Fallback khi tạo mới và chưa có giá trị tài chính chi tiết
$product = Product::find($contract->product_id);
if ($product) {
$contract->total_value = $product->total_price;
}
}
// Tính toán số tiền còn lại
$contract->remaining_amount = ($contract->total_value ?? 0) - ($contract->paid_amount ?? 0);
$contract->remaining_amount = (float) ($contract->total_value ?? 0) - (float) ($contract->paid_amount ?? 0);
});
}
}

View File

@@ -5,6 +5,8 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Customer extends Model
{
@@ -12,12 +14,28 @@ class Customer extends Model
protected $guarded = [];
// Ép kiểu để Laravel tự động dịch JSON thành Mảng khi hiển thị lên Form
protected $casts = [
'address' => 'array',
'secondary_phones' => 'array',
'dob' => 'date',
'id_issue_date' => 'date',
];
/**
* Lấy các công ty khách hàng này đại diện
*/
public function representedCompanies(): HasMany
{
return $this->hasMany(Customer::class, 'representative_id');
}
/**
* Lấy người đại diện của công ty này
*/
public function representative(): BelongsTo
{
return $this->belongsTo(Customer::class, 'representative_id');
}
public function contracts()
{
return $this->belongsToMany(Contract::class, 'contract_customers')
@@ -25,4 +43,4 @@ class Customer extends Model
->withPivot('id', 'role', 'transfer_order')
->withTimestamps();
}
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Observers;
use App\Models\Payment;
use App\Models\Contract;
use App\Models\PaymentScheduleItem;
class PaymentObserver
{
private static bool $handlingSurplus = false;
/**
* Tính toán lại tài chính hợp đồng sau mỗi thay đổi payment.
*/
private function recalculateContract(Contract $contract): void
{
$totalPaid = (float) $contract->payments()->sum('amount');
$contractValue = (float) $contract->total_value;
$contract->paid_amount = $totalPaid;
if ($totalPaid > $contractValue) {
$contract->remaining_amount = 0;
$contract->excess_amount = $totalPaid - $contractValue;
} else {
$contract->remaining_amount = $contractValue - $totalPaid;
$contract->excess_amount = 0;
}
$contract->saveQuietly();
}
/**
* Tự động khấu trừ tiền vào đợt thanh toán tiếp theo.
*/
private function applySurplusToNextInstallment(Contract $contract): void
{
if (self::$handlingSurplus) {
return;
}
$excess = (float) $contract->excess_amount;
if ($excess <= 0) {
return;
}
// Tìm đợt tiếp theo chưa thanh toán đủ (hoặc chưa có payment nào)
$nextItem = PaymentScheduleItem::query()
->whereHas('schedule', fn ($q) => $q->where('contract_id', $contract->id))
->whereNotNull('amount')
->orderBy('installment_no')
->get()
->first(function ($item) use ($contract) {
$paidForItem = (float) $contract->payments()
->where('schedule_item_id', $item->id)
->sum('amount');
return $paidForItem < (float) $item->amount;
});
if (! $nextItem) {
return;
}
$paidForItem = (float) $contract->payments()
->where('schedule_item_id', $nextItem->id)
->sum('amount');
$remainingForItem = (float) $nextItem->amount - $paidForItem;
if ($remainingForItem <= 0) {
return;
}
$applyAmount = min($excess, $remainingForItem);
self::$handlingSurplus = true;
Payment::create([
'contract_id' => $contract->id,
'schedule_item_id' => $nextItem->id,
'amount' => $applyAmount,
'paid_date' => now(),
'method' => 'Tự động khấu trừ',
'receipt_number' => 'AUTO-SURPLUS-' . now()->format('YmdHis'),
'metadata' => ['auto_surplus' => true, 'source' => 'excess_amount'],
]);
self::$handlingSurplus = false;
}
public function created(Payment $payment): void
{
if ($payment->contract) {
$this->recalculateContract($payment->contract);
$this->applySurplusToNextInstallment($payment->contract);
}
}
public function updated(Payment $payment): void
{
if ($payment->contract) {
$this->recalculateContract($payment->contract);
$this->applySurplusToNextInstallment($payment->contract);
}
if ($payment->wasChanged('contract_id') && $payment->getOriginal('contract_id')) {
$oldContract = Contract::find($payment->getOriginal('contract_id'));
if ($oldContract) {
$this->recalculateContract($oldContract);
}
}
}
public function deleted(Payment $payment): void
{
if ($payment->contract) {
$this->recalculateContract($payment->contract);
}
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Providers;
use App\Models\Payment;
use App\Observers\PaymentObserver;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +21,6 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
Payment::observe(PaymentObserver::class);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Services;
use App\Models\Contract;
use App\Models\PaymentSchedule;
use App\Models\PaymentScheduleItem;
use App\Models\PaymentTemplate;
use Carbon\Carbon;
class ContractScheduleService
{
/**
* Tạo lịch thanh toán cho hợp đồng dựa trên mẫu.
* Nếu đã tồn tại lịch , sẽ xóa tạo lại.
*/
public static function generateFromTemplate(Contract $contract, ?PaymentTemplate $template = null): PaymentSchedule
{
if (! $template) {
// Ưu tiên template của dự án
$template = $contract->product?->project?->paymentTemplate;
}
if (! $template) {
throw new \InvalidArgumentException('Không tìm thấy mẫu thanh toán cho hợp đồng này.');
}
// Xóa lịch cũ nếu có
if ($contract->paymentSchedule) {
$contract->paymentSchedule->items()->delete();
$contract->paymentSchedule->delete();
}
$schedule = PaymentSchedule::create([
'contract_id' => $contract->id,
'template_id' => $template->id,
]);
$items = $template->items()->orderBy('installment_no')->get();
$lastDueDate = Carbon::parse($contract->signing_date);
foreach ($items as $item) {
$dueDate = null;
if ($item->days_after_signing !== null) {
$dueDate = Carbon::parse($contract->signing_date)->addDays($item->days_after_signing);
} elseif ($item->days_after_previous !== null) {
$dueDate = $lastDueDate->copy()->addDays($item->days_after_previous);
} elseif ($item->due_date !== null) {
$dueDate = $item->due_date;
}
PaymentScheduleItem::create([
'schedule_id' => $schedule->id,
'installment_no' => $item->installment_no,
'type' => $item->type,
'percentage' => $item->percentage,
'amount' => $contract->total_value * ($item->percentage / 100),
'due_date' => $dueDate,
]);
if ($dueDate) {
$lastDueDate = $dueDate;
}
}
return $schedule;
}
}