Hoan thien core finance v2

This commit is contained in:
2026-04-25 04:04:14 +00:00
parent 86216ef872
commit 002c9a8b99
39 changed files with 1308 additions and 89 deletions

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Contract;
use App\Services\ContractScheduleService;
class GenerateContractSchedules extends Command
{
protected $signature = 'contracts:generate-schedules {--force : Tạo lại lịch cho các hợp đồng đã có lịch}';
protected $description = 'Tự động tạo lịch thanh toán cho các hợp đồng chưa có lịch';
public function handle()
{
$force = $this->option('force');
$query = Contract::query()
->whereNotNull('signing_date')
->when(! $force, fn ($q) => $q->whereDoesntHave('paymentSchedule'));
$total = $query->count();
if ($total === 0) {
$this->info('Không có hợp đồng nào cần tạo lịch thanh toán.');
return 0;
}
$this->info("Tìm thấy {$total} hợp đồng cần tạo lịch thanh toán...");
$success = 0;
$skipped = 0;
$errors = [];
$query->chunk(50, function ($contracts) use (&$success, &$skipped, &$errors) {
foreach ($contracts as $contract) {
try {
// Xác định template
$template = $contract->paymentTemplate;
if (! $template) {
$template = $contract->product?->project?->paymentTemplate;
}
if (! $template) {
$skipped++;
$this->warn("Bỏ qua HĐ {$contract->contract_number}: Không tìm thấy mẫu thanh toán.");
continue;
}
ContractScheduleService::generateFromTemplate($contract, $template);
$success++;
$this->info("[OK] HĐ {$contract->contract_number} - Đã tạo lịch từ mẫu '{$template->name}'.");
} catch (\Exception $e) {
$errors[] = "{$contract->contract_number}: " . $e->getMessage();
$this->error("[LỖI] HĐ {$contract->contract_number}: " . $e->getMessage());
}
}
});
$this->newLine();
$this->info("===== KẾT QUẢ =====");
$this->info("Thành công: {$success}");
$this->info("Bỏ qua: {$skipped}");
if (count($errors) > 0) {
$this->error("Lỗi: " . count($errors));
foreach ($errors as $err) {
$this->error(" - {$err}");
}
}
return 0;
}
}

View File

@@ -127,6 +127,7 @@ class ImportContractsComplex extends Command
);
// Liên kết khách hàng (Pivot)
// syncWithoutDetaching đảm bảo nhiều KH cùng 1 HĐ không bị ghi đè lẫn nhau
$contract->customers()->syncWithoutDetaching([
$customer->id => [
'role' => $row[7] ?? 'Chủ SH',

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\Appendices;
use App\Filament\Resources\Appendices\Pages;
use App\Models\Appendix;
use App\Enums\NavigationGroup;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use App\Filament\Resources\Appendices\Schemas\AppendixForm;
use App\Filament\Resources\Appendices\Tables\AppendicesTable;
class AppendixResource extends Resource
{
protected static ?string $model = Appendix::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value;
protected static ?int $navigationSort = 4;
protected static ?string $modelLabel = 'Phụ lục';
protected static ?string $pluralModelLabel = 'Phụ lục HĐ';
public static function form(Schema $schema): Schema
{
return AppendixForm::configure($schema);
}
public static function table(Table $table): Table
{
return AppendicesTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => Pages\ListAppendices::route('/'),
'create' => Pages\CreateAppendix::route('/create'),
'edit' => Pages\EditAppendix::route('/{record}/edit'),
];
}
}

View File

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

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Appendices\Pages;
use App\Filament\Resources\Appendices\AppendixResource;
use Filament\Resources\Pages\EditRecord;
class EditAppendix extends EditRecord
{
protected static string $resource = AppendixResource::class;
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Appendices\Pages;
use App\Filament\Resources\Appendices\AppendixResource;
use Filament\Resources\Pages\ListRecords;
class ListAppendices extends ListRecords
{
protected static string $resource = AppendixResource::class;
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Filament\Resources\Appendices\Schemas;
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;
class AppendixForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Grid::make(3)
->schema([
Section::make('Thông tin phụ lục')
->columnSpan(2)
->columns(2)
->schema([
Select::make('contract_id')
->label('Hợp đồng gốc')
->relationship('contract', 'contract_number')
->searchable()
->preload()
->required(),
Select::make('product_id')
->label('Sản phẩm')
->relationship('product', 'code')
->searchable()
->preload()
->required(),
TextInput::make('type')
->label('Loại phụ lục')
->required(),
TextInput::make('apply_from_order')
->label('Áp dụng từ CN thứ')
->numeric()
->default(0)
->required(),
DatePicker::make('signing_date')
->label('Ngày ký phụ lục')
->required(),
]),
Section::make('Dữ liệu bổ sung')
->columnSpan(1)
->schema([
KeyValue::make('custom_data')
->label('Thông tin bổ sung')
->keyLabel('Thông tin')
->valueLabel('Giá trị'),
]),
]),
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Filament\Resources\Appendices\Tables;
use Filament\Tables;
use Filament\Tables\Table;
class AppendicesTable
{
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('product.code')
->label('Sản phẩm')
->searchable(),
Tables\Columns\TextColumn::make('type')
->label('Loại phụ lục')
->badge(),
Tables\Columns\TextColumn::make('apply_from_order')
->label('Từ CN')
->alignCenter(),
Tables\Columns\TextColumn::make('signing_date')
->label('Ngày ký')
->date('d/m/Y')
->sortable(),
])
->defaultSort('signing_date', 'desc');
}
}

View File

@@ -5,13 +5,12 @@ namespace App\Filament\Resources\Contracts;
use App\Filament\Resources\Contracts\Pages;
use App\Models\Contract;
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;
use App\Filament\Resources\Contracts\Tables\ContractsTable;
class ContractResource extends Resource
{
@@ -30,33 +29,7 @@ class ContractResource extends Resource
public static function table(Table $table): Table
{
return $table
->columns([
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),
]);
return ContractsTable::configure($table);
}
public static function getRelations(): array { return [ScheduleItemsRelationManager::class]; }

View File

@@ -13,10 +13,9 @@ class CreateContract extends CreateRecord
protected function afterCreate(): void
{
$contract = $this->record;
$templateId = $this->data['payment_template_id'] ?? null;
if ($templateId) {
$template = \App\Models\PaymentTemplate::find($templateId);
if ($contract->payment_template_id) {
$template = $contract->paymentTemplate;
if ($template) {
ContractScheduleService::generateFromTemplate($contract, $template);
}

View File

@@ -137,6 +137,24 @@ class ContractForm
KeyValue::make('discount_details')
->label('Bảng chi tiết chiết khấu (Dạng Key-Value)')
->columnSpanFull(),
Placeholder::make('final_value_display')
->label('Giá trị sau chiết khấu')
->columnSpanFull()
->content(function ($record, $get) {
$totalValue = $record ? (float) $record->total_value : (float) ($get('total_value') ?? 0);
$discountDetails = $record ? $record->discount_details : ($get('discount_details') ?? []);
if ($totalValue <= 0) {
return new HtmlString("<div style='font-size: 0.9rem; color: #9ca3af;'>Chưa có giá trị hợp đồng để tính chiết khấu.</div>");
}
$result = \App\Services\DiscountEngine::calculate($totalValue, $discountDetails);
$final = number_format($result['final_value']);
$discount = number_format($result['discount_amount']);
return new HtmlString("<div style='font-size: 1.1rem; font-weight: bold; color: #16a34a;'>{$final} VNĐ</div><div style='font-size: 0.8rem; color: #9ca3af;'>Đã chiết khấu: {$discount} VNĐ</div>");
}),
]),
Section::make('Thông tin quản lý & Khách hàng')
@@ -166,8 +184,8 @@ class ContractForm
->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.'),
->hiddenOn('edit')
->helperText('Hệ thống sẽ tự động tạo lịch thanh toán sau khi lưu hợp đồng.'),
]),
]);
}

View File

@@ -2,11 +2,13 @@
namespace App\Filament\Resources\Contracts\Tables;
use App\Models\Contract;
use App\Services\ContractScheduleService;
use Filament\Actions\Action;
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
@@ -55,6 +57,19 @@ class ContractsTable
'Đã hủy' => 'danger',
default => 'gray',
}),
TextColumn::make('paid_amount')
->label('Đã thu')
->money('VND')
->sortable()
->toggleable(),
TextColumn::make('remaining_amount')
->label('Còn lại')
->money('VND')
->sortable()
->color('danger')
->toggleable(),
])
->filters([
\Filament\Tables\Filters\SelectFilter::make('status')
@@ -73,6 +88,17 @@ class ContractsTable
])
->recordActions([
EditAction::make(),
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) {
ContractScheduleService::generateFromTemplate($record);
})
->visible(fn (Contract $record) => $record->signing_date !== null),
])
->bulkActions([
BulkActionGroup::make([

View File

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

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\PaymentFines\Pages;
use App\Filament\Resources\PaymentFines\PaymentFineResource;
use Filament\Resources\Pages\EditRecord;
class EditPaymentFine extends EditRecord
{
protected static string $resource = PaymentFineResource::class;
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\PaymentFines\Pages;
use App\Filament\Resources\PaymentFines\PaymentFineResource;
use Filament\Resources\Pages\ListRecords;
class ListPaymentFines extends ListRecords
{
protected static string $resource = PaymentFineResource::class;
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\PaymentFines;
use App\Filament\Resources\PaymentFines\Pages;
use App\Models\PaymentFine;
use App\Enums\NavigationGroup;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use App\Filament\Resources\PaymentFines\Schemas\PaymentFineForm;
use App\Filament\Resources\PaymentFines\Tables\PaymentFinesTable;
class PaymentFineResource extends Resource
{
protected static ?string $model = PaymentFine::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-exclamation-triangle';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value;
protected static ?int $navigationSort = 6;
protected static ?string $modelLabel = 'Tiền phạt';
protected static ?string $pluralModelLabel = 'Tiền phạt';
public static function form(Schema $schema): Schema
{
return PaymentFineForm::configure($schema);
}
public static function table(Table $table): Table
{
return PaymentFinesTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => Pages\ListPaymentFines::route('/'),
'create' => Pages\CreatePaymentFine::route('/create'),
'edit' => Pages\EditPaymentFine::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Filament\Resources\PaymentFines\Schemas;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
class PaymentFineForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Grid::make(3)
->schema([
Section::make('Thông tin tiền phạt')
->columnSpan(2)
->columns(2)
->schema([
Select::make('contract_id')
->label('Hợp đồng')
->relationship('contract', 'contract_number')
->searchable()
->preload()
->required(),
TextInput::make('amount')
->label('Số tiền phạt')
->numeric()
->prefix('VND')
->required(),
TextInput::make('reason')
->label('Lý do phạt')
->required(),
DatePicker::make('due_date')
->label('Ngày đến hạn nộp phạt')
->required(),
DatePicker::make('paid_date')
->label('Ngày thực nộp')
->nullable(),
]),
]),
]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Filament\Resources\PaymentFines\Tables;
use Filament\Tables;
use Filament\Tables\Table;
class PaymentFinesTable
{
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 phạt')
->money('VND')
->sortable(),
Tables\Columns\TextColumn::make('reason')
->label('Lý do')
->searchable(),
Tables\Columns\TextColumn::make('due_date')
->label('Hạn nộp')
->date('d/m/Y')
->sortable(),
Tables\Columns\TextColumn::make('paid_date')
->label('Ngày nộp')
->date('d/m/Y')
->placeholder('Chưa nộp')
->color(fn ($state) => $state ? 'success' : 'danger'),
])
->filters([
Tables\Filters\Filter::make('unpaid')
->label('Chưa nộp')
->query(fn ($query) => $query->whereNull('paid_date')),
])
->defaultSort('due_date', 'desc');
}
}

View File

@@ -31,6 +31,12 @@ class PaymentResource extends Resource
return PaymentsTable::configure($table);
}
public static function getEloquentQuery(): \Illuminate\Database\Eloquent\Builder
{
return parent::getEloquentQuery()
->with(['scheduleItem.payments']);
}
public static function getPages(): array
{
return [

View File

@@ -62,7 +62,74 @@ class PaymentForm
->label('Số tiền thu')
->numeric()
->prefix('VND')
->required(),
->required()
->live(onBlur: true)
->helperText(function ($component) {
$data = $component->getContainer()->getRawState();
$contractId = $data['contract_id'] ?? null;
$scheduleItemId = $data['schedule_item_id'] ?? null;
if (! $contractId) {
return 'Vui lòng chọn hợp đồng trước.';
}
$contract = \App\Models\Contract::find($contractId);
if (! $contract) {
return null;
}
if ($scheduleItemId) {
$item = PaymentScheduleItem::find($scheduleItemId);
if ($item) {
$paid = $contract->payments()
->where('schedule_item_id', $scheduleItemId)
->when($component->getRecord() instanceof \App\Models\Payment, fn ($q, $r) => $q->where('id', '!=', $r->id))
->sum('amount');
$remaining = (float) $item->amount - (float) $paid;
return 'Công nợ đợt này: '.number_format($remaining).' VNĐ';
}
}
return 'Công nợ HĐ còn lại: '.number_format($contract->remaining_amount).' VNĐ';
})
->rules([
function ($component) {
return function (string $attribute, $value, \Closure $fail) use ($component) {
$data = $component->getContainer()->getRawState();
$contractId = $data['contract_id'] ?? null;
$scheduleItemId = $data['schedule_item_id'] ?? null;
if (! $contractId || ! is_numeric($value)) {
return;
}
$contract = \App\Models\Contract::find($contractId);
if (! $contract) {
return;
}
$maxAmount = null;
if ($scheduleItemId) {
$item = PaymentScheduleItem::find($scheduleItemId);
if ($item) {
$paid = $contract->payments()
->where('schedule_item_id', $scheduleItemId)
->when($component->getRecord() instanceof \App\Models\Payment, fn ($q, $r) => $q->where('id', '!=', $r->id))
->sum('amount');
$maxAmount = (float) $item->amount - (float) $paid;
}
} else {
$maxAmount = (float) $contract->remaining_amount;
}
if ($maxAmount !== null && (float) $value > $maxAmount) {
$fail('Số tiền thu không được vượt quá '.number_format($maxAmount).' VNĐ.');
}
};
},
]),
DatePicker::make('paid_date')
->label('Ngày thu')

View File

@@ -29,9 +29,50 @@ class PaymentsTable
Tables\Columns\TextColumn::make('receipt_number')
->label('Số phiếu thu')
->searchable(),
Tables\Columns\TextColumn::make('scheduleItem.type')
->label('Loại đợt')
->placeholder('Tạm ứng')
->formatStateUsing(fn ($state) => $state?->getLabel()),
Tables\Columns\TextColumn::make('scheduleItem.installment_no')
->label('Đợt TT')
->placeholder('Tạm ứng'),
Tables\Columns\TextColumn::make('reconciliation_status')
->label('Đối soát')
->badge()
->color(function ($record) {
if (! $record->scheduleItem) {
return 'gray';
}
$remaining = (float) $record->scheduleItem->remaining_amount;
if ($remaining == 0) {
return 'success';
}
if ($remaining > 0) {
return 'warning';
}
return 'danger';
})
->state(function ($record) {
if (! $record->scheduleItem) {
return 'Tạm ứng';
}
$remaining = (float) $record->scheduleItem->remaining_amount;
if ($remaining == 0) {
return 'Đủ';
}
if ($remaining > 0) {
return 'Thiếu';
}
return 'Thừa';
}),
Tables\Columns\TextColumn::make('scheduleItem.remaining_amount')
->label('Còn thiếu')
->money('VND')
->placeholder('-')
->color('danger'),
])
->filters([
Tables\Filters\SelectFilter::make('method')

View File

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

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Settlements\Pages;
use App\Filament\Resources\Settlements\SettlementResource;
use Filament\Resources\Pages\EditRecord;
class EditSettlement extends EditRecord
{
protected static string $resource = SettlementResource::class;
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Settlements\Pages;
use App\Filament\Resources\Settlements\SettlementResource;
use Filament\Resources\Pages\ListRecords;
class ListSettlements extends ListRecords
{
protected static string $resource = SettlementResource::class;
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Filament\Resources\Settlements\Schemas;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
class SettlementForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Grid::make(3)
->schema([
Section::make('Thông tin quyết toán')
->columnSpan(2)
->columns(2)
->schema([
Select::make('product_id')
->label('Sản phẩm')
->relationship('product', 'code')
->searchable()
->preload()
->required(),
TextInput::make('type')
->label('Loại quyết toán')
->required(),
TextInput::make('temp_value')
->label('Giá trị tạm tính')
->numeric()
->prefix('VND')
->required(),
TextInput::make('final_value')
->label('Giá trị chốt')
->numeric()
->prefix('VND')
->required(),
TextInput::make('difference')
->label('Chênh lệch')
->numeric()
->prefix('VND')
->required(),
TextInput::make('red_book_status')
->label('Trạng thái sổ đỏ')
->required(),
DatePicker::make('issue_date')
->label('Ngày cấp sổ')
->nullable(),
]),
]),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\Settlements;
use App\Filament\Resources\Settlements\Pages;
use App\Models\Settlement;
use App\Enums\NavigationGroup;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use App\Filament\Resources\Settlements\Schemas\SettlementForm;
use App\Filament\Resources\Settlements\Tables\SettlementsTable;
class SettlementResource extends Resource
{
protected static ?string $model = Settlement::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-check';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value;
protected static ?int $navigationSort = 5;
protected static ?string $modelLabel = 'Quyết toán';
protected static ?string $pluralModelLabel = 'Quyết toán & Sổ đỏ';
public static function form(Schema $schema): Schema
{
return SettlementForm::configure($schema);
}
public static function table(Table $table): Table
{
return SettlementsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => Pages\ListSettlements::route('/'),
'create' => Pages\CreateSettlement::route('/create'),
'edit' => Pages\EditSettlement::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Filament\Resources\Settlements\Tables;
use Filament\Tables;
use Filament\Tables\Table;
class SettlementsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('product.code')
->label('Sản phẩm')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('type')
->label('Loại QT')
->badge(),
Tables\Columns\TextColumn::make('temp_value')
->label('Tạm tính')
->money('VND')
->sortable(),
Tables\Columns\TextColumn::make('final_value')
->label('Chốt')
->money('VND')
->sortable(),
Tables\Columns\TextColumn::make('difference')
->label('Chênh lệch')
->money('VND')
->color(fn ($state) => (float) $state > 0 ? 'danger' : 'success'),
Tables\Columns\TextColumn::make('red_book_status')
->label('Trạng thái sổ')
->badge(),
Tables\Columns\TextColumn::make('issue_date')
->label('Ngày cấp')
->date('d/m/Y')
->placeholder('Chưa cấp'),
])
->defaultSort('created_at', 'desc');
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Contract;
use App\Models\PaymentScheduleItem;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class ContractStatsOverview extends BaseWidget
{
protected function getStats(): array
{
$totalRevenue = (float) Contract::sum('total_value');
$totalPaid = (float) Contract::sum('paid_amount');
$totalRemaining = (float) Contract::sum('remaining_amount');
$activeContracts = Contract::where('status', 'Đang hiệu lực')->count();
$upcomingPayments = PaymentScheduleItem::whereNull('schedule_id')
->orWhereHas('schedule', fn ($q) => $q->whereHas('contract'))
->whereDate('due_date', '<=', now()->addDays(30))
->whereDate('due_date', '>=', now())
->count();
return [
Stat::make('Tổng doanh thu', number_format($totalRevenue) . ' VNĐ')
->description('Tổng giá trị tất cả HĐ')
->color('primary'),
Stat::make('Đã thu', number_format($totalPaid) . ' VNĐ')
->description('Tổng tiền đã thanh toán')
->color('success'),
Stat::make('Công nợ phải thu', number_format($totalRemaining) . ' VNĐ')
->description('Tổng tiền chưa thu')
->color('danger'),
Stat::make('HĐ hiệu lực', $activeContracts)
->description('Số hợp đồng đang hiệu lực')
->color('warning'),
Stat::make('Đợt TT sắp đến hạn', $upcomingPayments)
->description('Trong 30 ngày tới')
->color('info'),
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Filament\Widgets;
use App\Models\PaymentScheduleItem;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as BaseWidget;
class UpcomingPaymentsTable extends BaseWidget
{
protected int | string | array $columnSpan = 'full';
public function table(Table $table): Table
{
return $table
->query(
PaymentScheduleItem::query()
->whereHas('schedule.contract')
->whereDate('due_date', '>=', now())
->whereDate('due_date', '<=', now()->addDays(30))
->orderBy('due_date')
)
->columns([
Tables\Columns\TextColumn::make('schedule.contract.contract_number')
->label('Số HĐ')
->searchable(),
Tables\Columns\TextColumn::make('installment_no')
->label('Đợt')
->alignCenter(),
Tables\Columns\TextColumn::make('type')
->label('Loại')
->badge(),
Tables\Columns\TextColumn::make('amount')
->label('Số tiền')
->money('VND'),
Tables\Columns\TextColumn::make('due_date')
->label('Ngày đến hạn')
->date('d/m/Y')
->color('danger'),
Tables\Columns\TextColumn::make('remaining_amount')
->label('Còn thiếu')
->money('VND'),
])
->paginated([10, 25, 50]);
}
}

View File

@@ -33,6 +33,11 @@ class Contract extends Model
return $this->belongsTo(Product::class);
}
public function paymentTemplate()
{
return $this->belongsTo(PaymentTemplate::class);
}
public function customers()
{
return $this->belongsToMany(Customer::class, 'contract_customers')
@@ -73,6 +78,19 @@ class Contract extends Model
return $this->hasMany(PaymentFine::class);
}
/**
* Giá trị sau chiết khấu.
*/
public function getFinalValueAttribute(): float
{
$result = \App\Services\DiscountEngine::calculate(
(float) $this->total_value,
$this->discount_details
);
return $result['final_value'];
}
protected static function booted()
{
static::saving(function ($contract) {

View File

@@ -20,6 +20,21 @@ class PaymentScheduleItem extends Model
'due_date' => 'date',
];
public function getPaidAmountAttribute(): float
{
// Nếu đã eager load payments, dùng collection sum để tránh query thêm
if ($this->relationLoaded('payments')) {
return (float) $this->payments->sum('amount');
}
return (float) $this->payments()->sum('amount');
}
public function getRemainingAmountAttribute(): float
{
return (float) $this->amount - $this->paid_amount;
}
public function template()
{
return $this->belongsTo(PaymentTemplate::class);

View File

@@ -7,12 +7,14 @@ use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
class User extends Authenticatable implements FilamentUser
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
@@ -29,4 +31,9 @@ class User extends Authenticatable
'password' => 'hashed',
];
}
public function canAccessPanel(Panel $panel): bool
{
return true;
}
}

View File

@@ -10,6 +10,8 @@ use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use App\Filament\Widgets\ContractStatsOverview;
use App\Filament\Widgets\UpcomingPaymentsTable;
use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
@@ -39,6 +41,8 @@ class AdminPanelProvider extends PanelProvider
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([
ContractStatsOverview::class,
UpcomingPaymentsTable::class,
AccountWidget::class,
FilamentInfoWidget::class,
])

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Services;
class DiscountEngine
{
/**
* Tính tổng chiết khấu giá trị sau chiết khấu.
*
* @param float $totalValue Giá trị gốc
* @param array|null $discountDetails Dữ liệu chiết khấu từ contract
* @return array ['discount_amount' => float, 'final_value' => float]
*/
public static function calculate(float $totalValue, ?array $discountDetails): array
{
if (empty($discountDetails)) {
return [
'discount_amount' => 0,
'final_value' => $totalValue,
];
}
$discountAmount = 0;
// Ưu tiên total_amount nếu có
if (! empty($discountDetails['total_amount'])) {
$discountAmount = (float) $discountDetails['total_amount'];
} elseif (! empty($discountDetails['total_percentage'])) {
$discountAmount = $totalValue * ((float) $discountDetails['total_percentage'] / 100);
}
// Đảm bảo chiết khấu không vượt quá giá trị hợp đồng
$discountAmount = min($discountAmount, $totalValue);
return [
'discount_amount' => $discountAmount,
'final_value' => $totalValue - $discountAmount,
];
}
}