Commit a22986b4 authored by DevPilot's avatar DevPilot

feat(auctions): add asset-sale and facility-rental auction module

New Auctions module covering the full committee-driven auction cycle:
one auction carries one booklet and many lots, each lot bundles one or
more assets/facilities and snapshots their cost/depreciation/book value
at lot-creation time, technical committee marks lots fit or not fit for
disposal, every bid (including losing ones) stays on the lot, and the
financial committee's award is per-lot so different lots can go to
different winners. An asset only flips to disposed once its settlement
is fully paid — not at award time — and disposal reuses the existing
GL gain/loss posting path. A rental-type award creates a lease contract
and marks the facility under lease instead of sold.
parent 1d760560
<?php
declare(strict_types=1);
namespace App\Modules\Auctions\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Auctions\Services\AuctionCommitteeService;
class AuctionCommitteeController extends Controller
{
public function create(Request $request, string $auctionId, string $type): Response
{
$this->authorize('auction.manage');
if (!\in_array($type, ['technical', 'financial'], true)) {
return $this->redirect('/auctions/' . $auctionId)->withError('نوع اللجنة غير صحيح');
}
$db = App::getInstance()->db();
$auction = $db->selectOne("SELECT * FROM auctions WHERE id = ?", [(int) $auctionId]);
if (!$auction) {
return $this->redirect('/auctions')->withError('المزاد غير موجود');
}
$employees = $db->select("SELECT id, full_name_ar FROM employees WHERE is_active = 1 ORDER BY full_name_ar");
return $this->view('Auctions.Views.committees.form', [
'auction' => $auction,
'type' => $type,
'employees' => $employees,
]);
}
public function store(Request $request, string $auctionId, string $type): Response
{
$this->authorize('auction.manage');
if (!\in_array($type, ['technical', 'financial'], true)) {
return $this->redirect('/auctions/' . $auctionId)->withError('نوع اللجنة غير صحيح');
}
try {
AuctionCommitteeService::create($type, (int) $auctionId, [
'committee_name' => $request->post('committee_name'),
'formed_date' => $request->post('formed_date'),
'chairman_employee_id' => $request->post('chairman_employee_id'),
'minutes' => $request->post('minutes'),
], $request->post('member_ids', []));
} catch (\Throwable $e) {
return $this->redirect('/auctions/' . $auctionId . '/committees/' . $type . '/create')->withError($e->getMessage());
}
return $this->redirect('/auctions/' . $auctionId)->withSuccess('تم تشكيل اللجنة');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Auctions\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Auctions\Services\AuctionService;
use App\Modules\Auctions\Services\AuctionCommitteeService;
class AuctionController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('auction.view');
$db = App::getInstance()->db();
$auctions = $db->select(
"SELECT a.*,
(SELECT COUNT(*) FROM auction_lots WHERE auction_id = a.id) AS lot_count
FROM auctions a
ORDER BY a.created_at DESC"
);
return $this->view('Auctions.Views.auctions.index', ['auctions' => $auctions]);
}
public function create(Request $request): Response
{
$this->authorize('auction.manage');
return $this->view('Auctions.Views.auctions.form', []);
}
public function store(Request $request): Response
{
$this->authorize('auction.manage');
try {
$id = AuctionService::createAuction(
(string) $request->post('auction_type', 'sale'),
(string) $request->post('title', ''),
$request->post('notes')
);
} catch (\Throwable $e) {
return $this->redirect('/auctions/create')->withError($e->getMessage());
}
return $this->redirect('/auctions/' . $id)->withSuccess('تم إنشاء المزاد');
}
public function show(Request $request, string $id): Response
{
$this->authorize('auction.view');
$db = App::getInstance()->db();
$auction = $db->selectOne("SELECT * FROM auctions WHERE id = ?", [(int) $id]);
if (!$auction) {
return $this->redirect('/auctions')->withError('المزاد غير موجود');
}
$lots = $db->select("SELECT * FROM auction_lots WHERE auction_id = ? ORDER BY id", [(int) $id]);
foreach ($lots as &$lot) {
$lot = AuctionService::getLotWithBids((int) $lot['id']);
$lot['award'] = $db->selectOne(
"SELECT aw.*, s.amount_due, s.amount_paid, s.status AS settlement_status
FROM auction_awards aw LEFT JOIN auction_settlements s ON s.award_id = aw.id
WHERE aw.lot_id = ?",
[(int) $lot['id']]
);
}
$booklet = $db->selectOne("SELECT * FROM auction_booklets WHERE auction_id = ? ORDER BY id DESC LIMIT 1", [(int) $id]);
if ($booklet) {
$booklet['participants'] = $db->select(
"SELECT p.*, d.name AS bidder_name FROM auction_booklet_participants p
JOIN auction_bidders d ON d.id = p.bidder_id WHERE p.booklet_id = ?",
[(int) $booklet['id']]
);
}
$assets = $db->select(
"SELECT id, asset_tag, asset_name, status, book_value FROM asset_register WHERE status = 'active' ORDER BY asset_tag"
);
$bidders = $db->select("SELECT id, name FROM auction_bidders WHERE is_active = 1 ORDER BY name");
$employees = $db->select("SELECT id, full_name_ar FROM employees WHERE is_active = 1 ORDER BY full_name_ar");
$committees = AuctionCommitteeService::getForAuction((int) $id);
return $this->view('Auctions.Views.auctions.show', [
'auction' => $auction,
'lots' => $lots,
'booklet' => $booklet,
'assets' => $assets,
'bidders' => $bidders,
'employees' => $employees,
'committees' => $committees,
]);
}
public function storeLot(Request $request, string $id): Response
{
$this->authorize('auction.manage');
try {
AuctionService::createLot(
(int) $id,
(string) $request->post('lot_name', ''),
$request->post('lot_type'),
$request->post('asset_ids', []),
$request->post('reserve_price') ?: null
);
} catch (\Throwable $e) {
return $this->redirect('/auctions/' . $id)->withError($e->getMessage());
}
return $this->redirect('/auctions/' . $id)->withSuccess('تم إنشاء الـ Lot');
}
public function evaluateLot(Request $request, string $id, string $lotId): Response
{
$this->authorize('auction.manage');
try {
AuctionService::evaluateLot((int) $lotId, (string) $request->post('technical_status'), (string) $request->post('technical_notes', ''));
} catch (\Throwable $e) {
return $this->redirect('/auctions/' . $id)->withError($e->getMessage());
}
return $this->redirect('/auctions/' . $id)->withSuccess('تم تسجيل التقييم الفني للـ Lot');
}
public function storeBooklet(Request $request, string $id): Response
{
$this->authorize('auction.manage');
try {
AuctionService::createBooklet((int) $id, [
'issue_date' => $request->post('issue_date'),
'terms' => $request->post('terms'),
'fee' => $request->post('fee'),
'bid_deadline' => $request->post('bid_deadline'),
]);
} catch (\Throwable $e) {
return $this->redirect('/auctions/' . $id)->withError($e->getMessage());
}
return $this->redirect('/auctions/' . $id)->withSuccess('تم إصدار كراسة الشروط');
}
public function inviteBidder(Request $request, string $id, string $bookletId): Response
{
$this->authorize('auction.manage');
$name = trim((string) $request->post('bidder_name', ''));
if ($name === '') {
return $this->redirect('/auctions/' . $id)->withError('اسم المتزايد مطلوب');
}
$bidderId = AuctionService::findOrCreateBidder($name, $request->post('phone'), $request->post('national_id'));
AuctionService::inviteBidder((int) $bookletId, $bidderId, $request->post('fee_paid') ?: null);
return $this->redirect('/auctions/' . $id)->withSuccess('تم تسجيل المتزايد');
}
public function recordBid(Request $request, string $id, string $lotId): Response
{
$this->authorize('auction.manage');
$bidderId = (int) $request->post('bidder_id', 0);
$amount = (string) $request->post('bid_amount', '0');
if ($bidderId <= 0 || !is_numeric($amount) || bccomp($amount, '0', 2) <= 0) {
return $this->redirect('/auctions/' . $id)->withError('بيانات العرض غير صحيحة');
}
AuctionService::recordBid((int) $lotId, $bidderId, $amount, $request->post('notes'));
return $this->redirect('/auctions/' . $id)->withSuccess('تم تسجيل عرض المزايدة');
}
public function awardLot(Request $request, string $id, string $lotId): Response
{
$this->authorize('auction.manage');
$winningBidId = (int) $request->post('winning_bid_id', 0);
$awardValue = (string) $request->post('award_value', '0');
$committeeId = (int) $request->post('committee_id', 0);
$justification = (string) $request->post('decision_notes', '');
try {
AuctionService::awardLot((int) $lotId, $winningBidId, $awardValue, $committeeId ?: null, $justification);
} catch (\Throwable $e) {
return $this->redirect('/auctions/' . $id)->withError($e->getMessage());
}
return $this->redirect('/auctions/' . $id)->withSuccess('تمت الترسية — بانتظار التسوية المالية');
}
public function recordSettlement(Request $request, string $id, string $awardId): Response
{
$this->authorize('auction.manage');
$amountPaid = (string) $request->post('amount_paid', '0');
if (!is_numeric($amountPaid) || bccomp($amountPaid, '0', 2) <= 0) {
return $this->redirect('/auctions/' . $id)->withError('قيمة السداد غير صحيحة');
}
try {
AuctionService::recordSettlementPayment(
(int) $awardId,
$amountPaid,
(string) $request->post('paid_date', date('Y-m-d')),
(string) $request->post('payment_method', 'cash')
);
} catch (\Throwable $e) {
return $this->redirect('/auctions/' . $id)->withError($e->getMessage());
}
return $this->redirect('/auctions/' . $id)->withSuccess('تم تسجيل السداد');
}
}
<?php
declare(strict_types=1);
return [
['GET', '/auctions', 'Auctions\Controllers\AuctionController@index', ['auth'], 'auction.view'],
['GET', '/auctions/create', 'Auctions\Controllers\AuctionController@create', ['auth'], 'auction.manage'],
['POST', '/auctions', 'Auctions\Controllers\AuctionController@store', ['auth', 'csrf'], 'auction.manage'],
['GET', '/auctions/{id}', 'Auctions\Controllers\AuctionController@show', ['auth'], 'auction.view'],
['POST', '/auctions/{id}/lots', 'Auctions\Controllers\AuctionController@storeLot', ['auth', 'csrf'], 'auction.manage'],
['POST', '/auctions/{id}/lots/{lotId}/evaluate', 'Auctions\Controllers\AuctionController@evaluateLot', ['auth', 'csrf'], 'auction.manage'],
['POST', '/auctions/{id}/booklet', 'Auctions\Controllers\AuctionController@storeBooklet', ['auth', 'csrf'], 'auction.manage'],
['POST', '/auctions/{id}/booklet/{bookletId}/invite', 'Auctions\Controllers\AuctionController@inviteBidder', ['auth', 'csrf'], 'auction.manage'],
['POST', '/auctions/{id}/lots/{lotId}/bids', 'Auctions\Controllers\AuctionController@recordBid', ['auth', 'csrf'], 'auction.manage'],
['POST', '/auctions/{id}/lots/{lotId}/award', 'Auctions\Controllers\AuctionController@awardLot', ['auth', 'csrf'], 'auction.manage'],
['POST', '/auctions/{id}/awards/{awardId}/settle', 'Auctions\Controllers\AuctionController@recordSettlement', ['auth', 'csrf'], 'auction.manage'],
['GET', '/auctions/{auctionId}/committees/{type}/create', 'Auctions\Controllers\AuctionCommitteeController@create', ['auth'], 'auction.manage'],
['POST', '/auctions/{auctionId}/committees/{type}', 'Auctions\Controllers\AuctionCommitteeController@store', ['auth', 'csrf'], 'auction.manage'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Auctions\Services;
use App\Core\App;
use App\Core\Logger;
final class AuctionCommitteeService
{
public static function create(string $type, int $auctionId, array $data, array $memberIds): int
{
if (!\in_array($type, ['technical', 'financial'], true)) {
throw new \RuntimeException('نوع اللجنة غير صحيح');
}
if (empty($memberIds)) {
throw new \RuntimeException('يجب إضافة عضو واحد على الأقل للجنة');
}
$chairmanId = (int) ($data['chairman_employee_id'] ?? 0);
if ($chairmanId <= 0) {
throw new \RuntimeException('يجب تحديد رئيس اللجنة');
}
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$db->beginTransaction();
try {
$committeeId = (int) $db->insert('auction_committees', [
'auction_id' => $auctionId,
'committee_type' => $type,
'committee_name' => trim((string) ($data['committee_name'] ?? '')) ?: (
$type === 'technical' ? 'اللجنة الفنية' : 'اللجنة المالية'
),
'formed_date' => $data['formed_date'] ?? date('Y-m-d'),
'chairman_employee_id' => $chairmanId,
'minutes' => $data['minutes'] ?? null,
'status' => 'active',
'created_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
]);
foreach (array_unique(array_map('intval', $memberIds)) as $memberId) {
$db->insert('auction_committee_members', [
'committee_id' => $committeeId,
'employee_id' => $memberId,
]);
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
Logger::info("Auction committee #{$committeeId} ({$type}) formed for auction #{$auctionId}");
return $committeeId;
}
public static function getForAuction(int $auctionId): array
{
$db = App::getInstance()->db();
$committees = $db->select(
"SELECT c.*, e.full_name_ar AS chairman_name
FROM auction_committees c
LEFT JOIN employees e ON e.id = c.chairman_employee_id
WHERE c.auction_id = ?
ORDER BY c.created_at ASC",
[$auctionId]
);
foreach ($committees as &$committee) {
$committee['members'] = $db->select(
"SELECT m.*, e.full_name_ar AS employee_name
FROM auction_committee_members m
JOIN employees e ON e.id = m.employee_id
WHERE m.committee_id = ?",
[(int) $committee['id']]
);
}
return $committees;
}
}
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مزاد جديد<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/auctions" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> القائمة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="max-width:600px;">
<div style="padding:20px;">
<form method="POST" action="/auctions">
<?= csrf_field() ?>
<div class="form-group">
<label class="form-label">نوع المزاد <span style="color:#DC2626;">*</span></label>
<select name="auction_type" class="form-select" required>
<option value="sale">بيع أصول — Asset Sale</option>
<option value="rental">تأجير منشآت — Asset/Facility Rental</option>
</select>
</div>
<div class="form-group">
<label class="form-label">عنوان المزاد <span style="color:#DC2626;">*</span></label>
<input type="text" name="title" class="form-input" required placeholder="مثال: مزاد بيع أصول مستغنى عنها 2026">
</div>
<div class="form-group">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-input" rows="3"></textarea>
</div>
<button type="submit" class="btn btn-primary">إنشاء المزاد</button>
</form>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>المزادات<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<?php if (can('auction.manage')): ?>
<a href="/auctions/create" class="btn btn-primary"><i data-lucide="plus" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> مزاد جديد</a>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$statusLabels = [
'draft' => 'مسودة', 'technical_review' => 'تقييم فني', 'lots_defined' => 'تم تحديد الـ Lots',
'published' => 'منشور', 'bidding_closed' => 'إقفال المزايدة', 'financial_review' => 'تقييم مالي',
'awarded' => 'تمت الترسية', 'settled' => 'تمت التسوية', 'cancelled' => 'ملغي',
];
$typeLabels = ['sale' => 'بيع أصول', 'rental' => 'تأجير منشآت'];
?>
<?php if (!empty($auctions)): ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead><tr><th>رقم المزاد</th><th>العنوان</th><th>النوع</th><th>عدد الـ Lots</th><th>الحالة</th></tr></thead>
<tbody>
<?php foreach ($auctions as $a): ?>
<tr>
<td><a href="/auctions/<?= (int) $a['id'] ?>"><code><?= e($a['auction_number']) ?></code></a></td>
<td><?= e($a['title']) ?></td>
<td><?= e($typeLabels[$a['auction_type']] ?? $a['auction_type']) ?></td>
<td><?= (int) $a['lot_count'] ?></td>
<td><?= e($statusLabels[$a['status']] ?? $a['status']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php else: ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">لا توجد مزادات حتى الآن</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= $type === 'technical' ? 'تشكيل اللجنة الفنية' : 'تشكيل اللجنة المالية' ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/auctions/<?= (int) $auction['id'] ?>" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> رجوع للمزاد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="max-width:700px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:<?= $type === 'technical' ? '#D97706' : '#7C3AED' ?>;font-size:15px;">
<?= $type === 'technical' ? 'تشكيل اللجنة الفنية' : 'تشكيل اللجنة المالية' ?> — مزاد <?= e($auction['auction_number']) ?>
</h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/auctions/<?= (int) $auction['id'] ?>/committees/<?= e($type) ?>">
<?= csrf_field() ?>
<div class="form-group">
<label class="form-label">اسم اللجنة</label>
<input type="text" name="committee_name" class="form-input" placeholder="<?= $type === 'technical' ? 'اللجنة الفنية' : 'اللجنة المالية' ?>">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">تاريخ التشكيل</label>
<input type="date" name="formed_date" class="form-input" value="<?= e(date('Y-m-d')) ?>">
</div>
<div class="form-group">
<label class="form-label">رئيس اللجنة <span style="color:#DC2626;">*</span></label>
<select name="chairman_employee_id" class="form-select" required>
<option value="">— اختر —</option>
<?php foreach ($employees as $e): ?>
<option value="<?= (int) $e['id'] ?>"><?= e($e['full_name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">أعضاء اللجنة <span style="color:#DC2626;">*</span></label>
<select name="member_ids[]" class="form-select" multiple size="8" required>
<?php foreach ($employees as $e): ?>
<option value="<?= (int) $e['id'] ?>"><?= e($e['full_name_ar']) ?></option>
<?php endforeach; ?>
</select>
<small style="color:#6B7280;">اضغط Ctrl (أو Cmd) لاختيار أكثر من عضو</small>
</div>
<div class="form-group">
<label class="form-label">محضر اللجنة (إن وجد)</label>
<textarea name="minutes" class="form-input" rows="4"></textarea>
</div>
<button type="submit" class="btn btn-primary">تشكيل اللجنة</button>
</form>
</div>
</div>
<?php $__template->endSection(); ?>
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
// ────────────────────────────────────────────────────────────
// Auctions — Permissions
// ────────────────────────────────────────────────────────────
PermissionRegistry::register('auctions', [
'auction.view' => ['ar' => 'عرض المزادات', 'en' => 'View Auctions'],
'auction.manage' => ['ar' => 'إدارة المزادات والترسية', 'en' => 'Manage Auctions & Award'],
]);
// ────────────────────────────────────────────────────────────
// Auctions — Sidebar menu
// ────────────────────────────────────────────────────────────
MenuRegistry::register('auctions', [
'label_ar' => 'المزادات',
'label_en' => 'Auctions',
'icon' => 'gavel',
'route' => '/auctions',
'permission' => 'auction.view',
'parent' => null,
'order' => 420,
'children' => [
['label_ar' => 'كل المزادات', 'label_en' => 'All Auctions', 'route' => '/auctions', 'permission' => 'auction.view', 'order' => 1],
['label_ar' => 'مزاد جديد', 'label_en' => 'New Auction', 'route' => '/auctions/create', 'permission' => 'auction.manage', 'order' => 2],
],
]);
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment