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;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Auctions\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
/**
* Asset-sale and facility-rental auctions.
*
* One auction never assumes one winner: a booklet can carry several lots,
* each lot can bundle several assets, and each lot is awarded to whichever
* bidder wins IT — not the auction as a whole. Nothing here is ever deleted;
* a losing bid stays on the lot exactly like an accepted one, because the
* whole point is that this file can be reopened years later and read start
* to finish.
*/
final class AuctionService
{
public static function createAuction(string $type, string $title, ?string $notes = null): int
{
if (!\in_array($type, ['sale', 'rental'], true)) {
throw new \RuntimeException('نوع المزاد غير صحيح');
}
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$auctionNumber = 'AUC-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$id = (int) $db->insert('auctions', [
'auction_number' => $auctionNumber,
'auction_type' => $type,
'title' => $title,
'status' => 'draft',
'notes' => $notes,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
]);
Logger::info("Auction #{$id} ({$auctionNumber}, {$type}) created");
return $id;
}
/**
* A lot is one bid-able unit. Assets snapshot their cost/depreciation/book
* value the moment they join the lot — the sale decision has to be judged
* against the value at the time of the auction, not whatever the register
* says by the time someone opens this record later.
*/
public static function createLot(int $auctionId, string $lotName, ?string $lotType, array $assetIds, ?string $reservePrice = null): int
{
$db = App::getInstance()->db();
$auction = $db->selectOne("SELECT * FROM auctions WHERE id = ?", [$auctionId]);
if (!$auction) {
throw new \RuntimeException('المزاد غير موجود');
}
if (empty($assetIds)) {
throw new \RuntimeException('يجب إضافة أصل واحد على الأقل للـ Lot');
}
$db->beginTransaction();
try {
$lotId = (int) $db->insert('auction_lots', [
'lot_name' => $lotName,
'auction_id' => $auctionId,
'lot_type' => $lotType,
'technical_status' => 'pending',
'reserve_price' => $reservePrice,
'created_at' => date('Y-m-d H:i:s'),
]);
foreach ($assetIds as $assetId) {
$asset = $db->selectOne("SELECT * FROM asset_register WHERE id = ?", [(int) $assetId]);
if (!$asset) {
continue;
}
$db->insert('auction_lot_assets', [
'lot_id' => $lotId,
'asset_id' => (int) $assetId,
'snapshot_cost' => $asset['purchase_cost'],
'snapshot_accum_depreciation' => $asset['accumulated_depreciation'],
'snapshot_book_value' => $asset['book_value'],
]);
}
if ($auction['status'] === 'draft') {
$db->update('auctions', ['status' => 'lots_defined', 'updated_at' => date('Y-m-d H:i:s')], 'id = ?', [$auctionId]);
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
return $lotId;
}
/** Technical committee's verdict on a lot — fit for sale/lease, or not. */
public static function evaluateLot(int $lotId, string $status, string $notes): void
{
if (!\in_array($status, ['fit_for_sale', 'not_fit'], true)) {
throw new \RuntimeException('حالة التقييم الفني غير صحيحة');
}
App::getInstance()->db()->update('auction_lots', [
'technical_status' => $status,
'technical_notes' => $notes,
], 'id = ?', [$lotId]);
}
public static function createBooklet(int $auctionId, array $data): int
{
$db = App::getInstance()->db();
$auction = $db->selectOne("SELECT * FROM auctions WHERE id = ?", [$auctionId]);
if (!$auction) {
throw new \RuntimeException('المزاد غير موجود');
}
$booklet = 'BKL-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$id = (int) $db->insert('auction_booklets', [
'auction_id' => $auctionId,
'booklet_number' => $booklet,
'issue_date' => $data['issue_date'] ?? date('Y-m-d'),
'terms' => $data['terms'] ?? null,
'fee' => !empty($data['fee']) ? $data['fee'] : null,
'bid_deadline' => $data['bid_deadline'] ?? null,
'created_at' => date('Y-m-d H:i:s'),
]);
return $id;
}
/** A bidder can be new to the system — registered here, on the spot. */
public static function findOrCreateBidder(string $name, ?string $phone = null, ?string $nationalId = null): int
{
$db = App::getInstance()->db();
if ($nationalId) {
$existing = $db->selectOne("SELECT id FROM auction_bidders WHERE national_id = ?", [$nationalId]);
if ($existing) {
return (int) $existing['id'];
}
}
return (int) $db->insert('auction_bidders', [
'name' => $name,
'phone' => $phone,
'national_id' => $nationalId,
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
]);
}
public static function inviteBidder(int $bookletId, int $bidderId, ?string $feePaid = null): void
{
$db = App::getInstance()->db();
$existing = $db->selectOne(
"SELECT id FROM auction_booklet_participants WHERE booklet_id = ? AND bidder_id = ?",
[$bookletId, $bidderId]
);
if ($existing) {
return;
}
$db->insert('auction_booklet_participants', [
'booklet_id' => $bookletId,
'bidder_id' => $bidderId,
'fee_paid' => $feePaid,
'invited_at' => date('Y-m-d H:i:s'),
]);
$auctionId = $db->selectOne("SELECT auction_id FROM auction_booklets WHERE id = ?", [$bookletId])['auction_id'] ?? null;
if ($auctionId) {
$db->update('auctions', ['status' => 'published', 'updated_at' => date('Y-m-d H:i:s')], 'id = ? AND status != ?', [$auctionId, 'published']);
}
}
/**
* Every bid submitted on a lot stays recorded — including every losing
* one — with nothing marked "winning" until the financial committee acts.
*/
public static function recordBid(int $lotId, int $bidderId, string $amount, ?string $notes = null): int
{
return (int) App::getInstance()->db()->insert('auction_bids', [
'lot_id' => $lotId,
'bidder_id' => $bidderId,
'bid_amount' => $amount,
'bid_at' => date('Y-m-d H:i:s'),
'status' => 'submitted',
'notes' => $notes,
]);
}
public static function getLotWithBids(int $lotId): ?array
{
$db = App::getInstance()->db();
$lot = $db->selectOne("SELECT * FROM auction_lots WHERE id = ?", [$lotId]);
if (!$lot) {
return null;
}
$lot['assets'] = $db->select(
"SELECT la.*, a.asset_tag, a.asset_name
FROM auction_lot_assets la
JOIN asset_register a ON a.id = la.asset_id
WHERE la.lot_id = ?",
[$lotId]
);
$lot['bids'] = $db->select(
"SELECT b.*, d.name AS bidder_name
FROM auction_bids b
JOIN auction_bidders d ON d.id = b.bidder_id
WHERE b.lot_id = ?
ORDER BY b.bid_amount DESC, b.bid_at ASC",
[$lotId]
);
return $lot;
}
/**
* The financial committee's award — the highest bid per this auction's
* rules, but the decision itself, and who made it, is what gets recorded.
*/
public static function awardLot(int $lotId, int $winningBidId, string $awardValue, ?int $committeeId, string $decisionNotes): int
{
$db = App::getInstance()->db();
$bid = $db->selectOne("SELECT * FROM auction_bids WHERE id = ? AND lot_id = ?", [$winningBidId, $lotId]);
if (!$bid) {
throw new \RuntimeException('العرض المحدد غير موجود على هذا الـ Lot');
}
$existing = $db->selectOne("SELECT id FROM auction_awards WHERE lot_id = ?", [$lotId]);
if ($existing) {
throw new \RuntimeException('تمت ترسية هذا الـ Lot من قبل');
}
$db->beginTransaction();
try {
$awardId = (int) $db->insert('auction_awards', [
'lot_id' => $lotId,
'winning_bid_id' => $winningBidId,
'award_value' => $awardValue,
'award_date' => date('Y-m-d'),
'committee_id' => $committeeId,
'decision_notes' => $decisionNotes,
'created_at' => date('Y-m-d H:i:s'),
]);
$db->update('auction_bids', ['status' => 'winning'], 'id = ?', [$winningBidId]);
$db->query(
"UPDATE auction_bids SET status = 'not_selected' WHERE lot_id = ? AND id != ? AND status = 'submitted'",
[$lotId, $winningBidId]
);
$db->insert('auction_settlements', [
'award_id' => $awardId,
'amount_due' => $awardValue,
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
Logger::info("Lot #{$lotId} awarded — bid #{$winningBidId}, value {$awardValue}");
return $awardId;
}
/**
* The asset does NOT change status until money is actually in — awarding
* a lot is a decision, settlement is the cash. Confusing the two would
* mark an asset sold before the club had been paid for it.
*/
public static function recordSettlementPayment(int $awardId, string $amountPaid, string $paidDate, string $method): void
{
$db = App::getInstance()->db();
$settlement = $db->selectOne("SELECT * FROM auction_settlements WHERE award_id = ?", [$awardId]);
if (!$settlement) {
throw new \RuntimeException('سجل التسوية غير موجود');
}
$newPaid = bcadd((string) $settlement['amount_paid'], $amountPaid, 2);
$status = bccomp($newPaid, (string) $settlement['amount_due'], 2) >= 0 ? 'settled' : 'partial';
$db->update('auction_settlements', [
'amount_paid' => $newPaid,
'paid_date' => $paidDate,
'payment_method' => $method,
'status' => $status,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $settlement['id']]);
if ($status === 'settled') {
self::finalizeAward($awardId);
}
}
/**
* Settlement complete: for a sale, dispose the assets through the SAME
* path a routine disposal uses, so gain/loss posts to the GL exactly like
* any other disposal. For a rental, create the lease contract and mark
* the facility leased instead of sold.
*/
private static function finalizeAward(int $awardId): void
{
$db = App::getInstance()->db();
$award = $db->selectOne(
"SELECT aw.*, l.auction_id, l.id AS lot_id, au.auction_type
FROM auction_awards aw
JOIN auction_lots l ON l.id = aw.lot_id
JOIN auctions au ON au.id = l.auction_id
WHERE aw.id = ?",
[$awardId]
);
if (!$award) {
return;
}
$assets = $db->select("SELECT * FROM auction_lot_assets WHERE lot_id = ?", [(int) $award['lot_id']]);
$assetCount = max(1, count($assets));
$employee = App::getInstance()->currentEmployee();
if ($award['auction_type'] === 'sale') {
foreach ($assets as $la) {
$assetId = (int) $la['asset_id'];
$asset = $db->selectOne("SELECT * FROM asset_register WHERE id = ?", [$assetId]);
if (!$asset || $asset['status'] !== 'active') {
continue;
}
// Proceeds split evenly across the lot's assets — the award is
// for the lot as a whole, not itemised per asset.
$share = bcdiv((string) $award['award_value'], (string) $assetCount, 2);
$db->update('asset_register', [
'status' => 'disposed',
'disposed_at' => date('Y-m-d'),
'disposal_value' => $share,
'disposal_reason' => 'بيع بالمزاد — ' . $award['auction_id'],
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => $employee ? (int) $employee->id : null,
], 'id = ?', [$assetId]);
EventBus::dispatch('inventory.asset_disposed', [
'asset_id' => $assetId,
'disposal_value' => $share,
'reason' => 'بيع بالمزاد',
]);
}
} else {
$bid = $db->selectOne("SELECT * FROM auction_bids WHERE id = ?", [(int) $award['winning_bid_id']]);
$leaseId = $db->insert('auction_lease_contracts', [
'award_id' => $awardId,
'tenant_bidder_id' => (int) $bid['bidder_id'],
'annual_rent' => $award['award_value'],
'start_date' => date('Y-m-d'),
'end_date' => date('Y-m-d', strtotime('+1 year')),
'status' => 'active',
'created_at' => date('Y-m-d H:i:s'),
]);
foreach ($assets as $la) {
$db->update('asset_register', [
'leased_status' => 'under_lease',
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $la['asset_id']]);
}
Logger::info("Lease contract #{$leaseId} created from auction award #{$awardId}");
}
// Only once every lot in the auction has settled — an auction with
// several lots can close them on different days.
$unsettled = $db->selectOne(
"SELECT COUNT(*) AS c
FROM auction_lots l
LEFT JOIN auction_awards aw ON aw.lot_id = l.id
LEFT JOIN auction_settlements s ON s.award_id = aw.id
WHERE l.auction_id = ? AND (s.status IS NULL OR s.status != 'settled')",
[(int) $award['auction_id']]
);
if ((int) ($unsettled['c'] ?? 1) === 0) {
$db->update('auctions', ['status' => 'settled', 'updated_at' => date('Y-m-d H:i:s')], 'id = ?', [(int) $award['auction_id']]);
}
}
}
<?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(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مزاد <?= e($auction['auction_number']) ?><?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'); ?>
<?php
$statusLabels = [
'draft' => 'مسودة', 'technical_review' => 'تقييم فني', 'lots_defined' => 'تم تحديد الـ Lots',
'published' => 'منشور', 'bidding_closed' => 'إقفال المزايدة', 'financial_review' => 'تقييم مالي',
'awarded' => 'تمت الترسية', 'settled' => 'تمت التسوية', 'cancelled' => 'ملغي',
];
$typeLabels = ['sale' => 'بيع أصول', 'rental' => 'تأجير منشآت'];
?>
<!-- Header -->
<div class="card" style="margin-bottom:20px;padding:20px;">
<div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px;">
<div>
<h3 style="margin:0;"><?= e($auction['title']) ?></h3>
<p style="color:#6B7280;margin:5px 0 0;font-size:13px;">
رقم المزاد: <code><?= e($auction['auction_number']) ?></code> — النوع: <?= e($typeLabels[$auction['auction_type']] ?? $auction['auction_type']) ?>
</p>
</div>
<span style="display:inline-block;padding:5px 14px;border-radius:10px;font-size:13px;font-weight:700;background:#F5F3FF;color:#7C3AED;height:fit-content;">
<?= e($statusLabels[$auction['status']] ?? $auction['status']) ?>
</span>
</div>
</div>
<!-- Committees -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;color:#374151;font-size:15px;">اللجان</h3>
<?php if (can('auction.manage')): ?>
<div style="display:flex;gap:8px;">
<a href="/auctions/<?= (int) $auction['id'] ?>/committees/technical/create" class="btn btn-sm btn-outline">+ لجنة فنية</a>
<a href="/auctions/<?= (int) $auction['id'] ?>/committees/financial/create" class="btn btn-sm btn-outline">+ لجنة مالية</a>
</div>
<?php endif; ?>
</div>
<?php if (!empty($committees)): ?>
<div style="padding:15px 20px;">
<?php foreach ($committees as $c): ?>
<div style="padding:10px 0;border-bottom:1px solid #F3F4F6;">
<strong><?= e($c['committee_name']) ?></strong>
<span style="color:#6B7280;font-size:12px;">(<?= $c['committee_type'] === 'technical' ? 'فنية' : 'مالية' ?><?= e($c['formed_date']) ?>)</span>
<div style="font-size:13px;color:#6B7280;margin-top:3px;">
رئيس اللجنة: <?= e($c['chairman_name'] ?? '—') ?> — الأعضاء: <?= e(implode('، ', array_column($c['members'], 'employee_name'))) ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div style="padding:20px;text-align:center;color:#9CA3AF;font-size:13px;">لم تشكل أي لجنة بعد</div>
<?php endif; ?>
</div>
<!-- Create Lot -->
<?php if (can('auction.manage')): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#2563EB;font-size:15px;">إضافة Lot جديد</h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/auctions/<?= (int) $auction['id'] ?>/lots">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">اسم الـ Lot <span style="color:#DC2626;">*</span></label>
<input type="text" name="lot_name" class="form-input" required placeholder="مثال: Lot 1 — سيارات">
</div>
<div class="form-group">
<label class="form-label">نوع الـ Lot</label>
<input type="text" name="lot_type" class="form-input" placeholder="خردة، سيارات، معدات، منشآت رياضية...">
</div>
<div class="form-group">
<label class="form-label">السعر الاحتياطي (اختياري)</label>
<input type="number" name="reserve_price" class="form-input" step="0.01" min="0" style="direction:ltr;text-align:left;">
</div>
</div>
<div class="form-group">
<label class="form-label">الأصول / المنشآت في هذا الـ Lot <span style="color:#DC2626;">*</span></label>
<select name="asset_ids[]" class="form-select" multiple size="8" required>
<?php foreach ($assets as $a): ?>
<option value="<?= (int) $a['id'] ?>"><?= e($a['asset_name'] ?: $a['asset_tag']) ?><?= money($a['book_value']) ?></option>
<?php endforeach; ?>
</select>
<small style="color:#6B7280;">اضغط Ctrl (أو Cmd) لاختيار أكثر من أصل/منشأة في نفس الـ Lot</small>
</div>
<button type="submit" class="btn btn-primary">إضافة Lot</button>
</form>
</div>
</div>
<?php endif; ?>
<!-- Booklet -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#7C3AED;font-size:15px;">كراسة الشروط</h3>
</div>
<?php if (!$booklet): ?>
<?php if (can('auction.manage')): ?>
<div style="padding:20px;">
<form method="POST" action="/auctions/<?= (int) $auction['id'] ?>/booklet">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">تاريخ الإصدار</label>
<input type="date" name="issue_date" class="form-input" value="<?= e(date('Y-m-d')) ?>">
</div>
<div class="form-group">
<label class="form-label">موعد إقفال المزايدة</label>
<input type="datetime-local" name="bid_deadline" class="form-input">
</div>
<div class="form-group">
<label class="form-label">رسوم الكراسة</label>
<input type="number" name="fee" class="form-input" step="0.01" min="0" style="direction:ltr;text-align:left;">
</div>
</div>
<div class="form-group">
<label class="form-label">الشروط والأحكام</label>
<textarea name="terms" class="form-input" rows="4"></textarea>
</div>
<button type="submit" class="btn btn-primary">إصدار كراسة الشروط</button>
</form>
</div>
<?php endif; ?>
<?php else: ?>
<div style="padding:20px;">
<p style="margin:0 0 10px;">رقم الكراسة: <code><?= e($booklet['booklet_number']) ?></code> — تاريخ الإصدار: <?= e($booklet['issue_date']) ?></p>
<?php if (can('auction.manage')): ?>
<form method="POST" action="/auctions/<?= (int) $auction['id'] ?>/booklet/<?= (int) $booklet['id'] ?>/invite" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;margin-bottom:15px;">
<?= csrf_field() ?>
<div class="form-group" style="margin:0;">
<label class="form-label">اسم المتزايد</label>
<input type="text" name="bidder_name" class="form-input" required>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label">الهاتف</label>
<input type="text" name="phone" class="form-input">
</div>
<div class="form-group" style="margin:0;">
<label class="form-label">الرقم القومي/السجل التجاري</label>
<input type="text" name="national_id" class="form-input">
</div>
<div class="form-group" style="margin:0;">
<label class="form-label">رسم الكراسة المدفوع</label>
<input type="number" name="fee_paid" class="form-input" step="0.01" style="direction:ltr;text-align:left;">
</div>
<button type="submit" class="btn btn-outline">تسجيل متزايد</button>
</form>
<?php endif; ?>
<?php if (!empty($booklet['participants'])): ?>
<table class="data-table">
<thead><tr><th>المتزايد</th><th>رسم مدفوع</th><th>تاريخ الدعوة</th></tr></thead>
<tbody>
<?php foreach ($booklet['participants'] as $p): ?>
<tr><td><?= e($p['bidder_name']) ?></td><td><?= $p['fee_paid'] ? money($p['fee_paid']) : '—' ?></td><td><?= e($p['invited_at']) ?></td></tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<!-- Lots -->
<?php foreach ($lots as $lot): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;font-size:15px;"><?= e($lot['lot_name']) ?> <span style="color:#6B7280;font-size:12px;">(<?= e($lot['lot_type'] ?: '—') ?>)</span></h3>
<span style="font-size:12px;font-weight:600;color:<?= $lot['technical_status'] === 'fit_for_sale' ? '#059669' : ($lot['technical_status'] === 'not_fit' ? '#DC2626' : '#6B7280') ?>;">
<?= ['pending' => 'بانتظار التقييم الفني', 'fit_for_sale' => 'صالح للطرح', 'not_fit' => 'مستغنى عنه — غير مطروح'][$lot['technical_status']] ?? $lot['technical_status'] ?>
</span>
</div>
<div style="padding:15px 20px;">
<table class="data-table" style="margin-bottom:10px;">
<thead><tr><th>الأصل</th><th>التكلفة</th><th>مجمع الإهلاك</th><th>القيمة الدفترية</th></tr></thead>
<tbody>
<?php foreach ($lot['assets'] as $la): ?>
<tr>
<td><?= e($la['asset_name'] ?: $la['asset_tag']) ?></td>
<td style="direction:ltr;text-align:left;"><?= money($la['snapshot_cost']) ?></td>
<td style="direction:ltr;text-align:left;"><?= money($la['snapshot_accum_depreciation']) ?></td>
<td style="direction:ltr;text-align:left;font-weight:700;"><?= money($la['snapshot_book_value']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ($lot['technical_status'] === 'pending' && can('auction.manage')): ?>
<form method="POST" action="/auctions/<?= (int) $auction['id'] ?>/lots/<?= (int) $lot['id'] ?>/evaluate" style="margin-bottom:15px;padding:12px;background:#F9FAFB;border-radius:6px;">
<?= csrf_field() ?>
<strong style="font-size:13px;">تقييم اللجنة الفنية لهذا الـ Lot</strong>
<div style="display:flex;gap:10px;margin-top:8px;flex-wrap:wrap;align-items:end;">
<select name="technical_status" class="form-select" required>
<option value="fit_for_sale">صالح للطرح — مستغنى عنه</option>
<option value="not_fit">غير صالح — لا يُطرح</option>
</select>
<input type="text" name="technical_notes" class="form-input" placeholder="ملاحظات/سبب التوصية" style="flex:1;">
<button type="submit" class="btn btn-sm btn-primary">حفظ</button>
</div>
</form>
<?php endif; ?>
<?php if ($lot['technical_status'] === 'fit_for_sale' && !$lot['award']): ?>
<?php if (can('auction.manage')): ?>
<form method="POST" action="/auctions/<?= (int) $auction['id'] ?>/lots/<?= (int) $lot['id'] ?>/bids" style="display:flex;gap:10px;margin-bottom:12px;flex-wrap:wrap;align-items:end;">
<?= csrf_field() ?>
<select name="bidder_id" class="form-select" required>
<option value="">— المتزايد —</option>
<?php foreach ($bidders as $b): ?><option value="<?= (int) $b['id'] ?>"><?= e($b['name']) ?></option><?php endforeach; ?>
</select>
<input type="number" name="bid_amount" class="form-input" step="0.01" min="0" placeholder="قيمة العرض" required style="direction:ltr;text-align:left;">
<button type="submit" class="btn btn-sm btn-outline">تسجيل عرض</button>
</form>
<?php endif; ?>
<?php if (!empty($lot['bids'])): ?>
<table class="data-table" style="margin-bottom:12px;">
<thead><tr><th>المتزايد</th><th>القيمة</th><th>التاريخ</th><th>الحالة</th></tr></thead>
<tbody>
<?php foreach ($lot['bids'] as $bid): ?>
<tr>
<td><?= e($bid['bidder_name']) ?></td>
<td style="direction:ltr;text-align:left;font-weight:700;"><?= money($bid['bid_amount']) ?></td>
<td><?= e($bid['bid_at']) ?></td>
<td><?= ['submitted' => 'مقدَّم', 'winning' => 'فائز', 'not_selected' => 'غير فائز'][$bid['status']] ?? $bid['status'] ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if (can('auction.manage')): ?>
<form method="POST" action="/auctions/<?= (int) $auction['id'] ?>/lots/<?= (int) $lot['id'] ?>/award" style="padding:12px;background:#F5F3FF;border-radius:6px;">
<?= csrf_field() ?>
<strong style="font-size:13px;color:#7C3AED;">ترسية اللجنة المالية</strong>
<div style="display:flex;gap:10px;margin-top:8px;flex-wrap:wrap;align-items:end;">
<select name="winning_bid_id" class="form-select" required>
<option value="">— اختر العرض الفائز —</option>
<?php foreach ($lot['bids'] as $bid): ?>
<option value="<?= (int) $bid['id'] ?>"><?= e($bid['bidder_name']) ?><?= money($bid['bid_amount']) ?></option>
<?php endforeach; ?>
</select>
<input type="number" name="award_value" class="form-input" step="0.01" placeholder="قيمة الترسية" required style="direction:ltr;text-align:left;">
<input type="text" name="decision_notes" class="form-input" placeholder="محضر الترسية / سبب الاختيار" required style="flex:1;">
<button type="submit" class="btn btn-sm btn-primary">تسجيل الترسية</button>
</div>
</form>
<?php endif; ?>
<?php endif; ?>
<?php endif; ?>
<?php if ($lot['award']): ?>
<div style="padding:12px;background:#ECFDF5;border-radius:6px;">
<strong style="color:#059669;">تمت الترسية</strong> — القيمة: <?= money($lot['award']['award_value']) ?>
بتاريخ <?= e($lot['award']['award_date']) ?>
<div style="font-size:13px;color:#6B7280;margin-top:4px;"><?= e($lot['award']['decision_notes']) ?></div>
<div style="margin-top:10px;">
المسدد: <?= money($lot['award']['amount_paid'] ?? '0.00') ?> من <?= money($lot['award']['amount_due']) ?>
— الحالة: <?= ['pending' => 'بانتظار السداد', 'partial' => 'سداد جزئي', 'settled' => 'تمت التسوية'][$lot['award']['settlement_status']] ?? '—' ?>
</div>
<?php if (can('auction.manage') && ($lot['award']['settlement_status'] ?? '') !== 'settled'): ?>
<form method="POST" action="/auctions/<?= (int) $auction['id'] ?>/awards/<?= (int) $lot['award']['id'] ?>/settle" style="display:flex;gap:10px;margin-top:10px;flex-wrap:wrap;align-items:end;">
<?= csrf_field() ?>
<input type="number" name="amount_paid" class="form-input" step="0.01" min="0" placeholder="المبلغ المسدد" required style="direction:ltr;text-align:left;">
<input type="date" name="paid_date" class="form-input" value="<?= e(date('Y-m-d')) ?>">
<select name="payment_method" class="form-select">
<option value="cash">نقدي</option>
<option value="bank">تحويل بنكي</option>
<option value="check">شيك</option>
</select>
<button type="submit" class="btn btn-sm" style="background:#059669;color:#fff;border:none;">تسجيل السداد</button>
</form>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php $__template->endSection(); ?>
<?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],
],
]);
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `auctions` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`auction_number` VARCHAR(50) NOT NULL,
`auction_type` ENUM('sale','rental') NOT NULL,
`title` VARCHAR(300) NOT NULL,
`status` ENUM('draft','technical_review','lots_defined','published','bidding_closed','financial_review','awarded','settled','cancelled') NOT NULL DEFAULT 'draft',
`notes` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
UNIQUE INDEX `uq_auction_number` (`auction_number`),
INDEX `idx_auction_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `auction_committees` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`auction_id` BIGINT UNSIGNED NOT NULL,
`committee_type` ENUM('technical','financial') NOT NULL,
`committee_name` VARCHAR(200) NOT NULL,
`formed_date` DATE NOT NULL,
`chairman_employee_id` BIGINT UNSIGNED NOT NULL,
`minutes` TEXT NULL,
`status` ENUM('active','closed') NOT NULL DEFAULT 'active',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
INDEX `idx_ac_auction` (`auction_id`),
CONSTRAINT `fk_ac_auction` FOREIGN KEY (`auction_id`) REFERENCES `auctions`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_ac_chairman` FOREIGN KEY (`chairman_employee_id`) REFERENCES `employees`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `auction_committee_members` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`committee_id` BIGINT UNSIGNED NOT NULL,
`employee_id` BIGINT UNSIGNED NOT NULL,
`role_title` VARCHAR(150) NULL,
INDEX `idx_acm_committee` (`committee_id`),
CONSTRAINT `fk_acm_committee` FOREIGN KEY (`committee_id`) REFERENCES `auction_committees`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_acm_employee` FOREIGN KEY (`employee_id`) REFERENCES `employees`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `auction_lots` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`auction_id` BIGINT UNSIGNED NOT NULL,
`lot_name` VARCHAR(200) NOT NULL,
`lot_type` VARCHAR(100) NULL COMMENT 'free text: scrap, vehicles, equipment, sports facilities...',
`technical_status` ENUM('pending','fit_for_sale','not_fit') NOT NULL DEFAULT 'pending',
`technical_notes` TEXT NULL,
`reserve_price` DECIMAL(15,2) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_lot_auction` (`auction_id`),
CONSTRAINT `fk_lot_auction` FOREIGN KEY (`auction_id`) REFERENCES `auctions`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `auction_lot_assets` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`lot_id` BIGINT UNSIGNED NOT NULL,
`asset_id` BIGINT UNSIGNED NOT NULL,
`snapshot_cost` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`snapshot_accum_depreciation` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`snapshot_book_value` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
INDEX `idx_lotasset_lot` (`lot_id`),
INDEX `idx_lotasset_asset` (`asset_id`),
CONSTRAINT `fk_lotasset_lot` FOREIGN KEY (`lot_id`) REFERENCES `auction_lots`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_lotasset_asset` FOREIGN KEY (`asset_id`) REFERENCES `asset_register`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `auction_booklets` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`auction_id` BIGINT UNSIGNED NOT NULL,
`booklet_number` VARCHAR(50) NOT NULL,
`issue_date` DATE NOT NULL,
`terms` TEXT NULL,
`fee` DECIMAL(15,2) NULL,
`bid_deadline` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX `uq_booklet_number` (`booklet_number`),
CONSTRAINT `fk_booklet_auction` FOREIGN KEY (`auction_id`) REFERENCES `auctions`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `auction_bidders` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(200) NOT NULL,
`phone` VARCHAR(30) NULL,
`national_id` VARCHAR(50) NULL,
`notes` TEXT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `auction_booklet_participants` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`booklet_id` BIGINT UNSIGNED NOT NULL,
`bidder_id` BIGINT UNSIGNED NOT NULL,
`fee_paid` DECIMAL(15,2) NULL,
`invited_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX `uq_booklet_bidder` (`booklet_id`, `bidder_id`),
CONSTRAINT `fk_bp_booklet` FOREIGN KEY (`booklet_id`) REFERENCES `auction_booklets`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_bp_bidder` FOREIGN KEY (`bidder_id`) REFERENCES `auction_bidders`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `auction_bids` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`lot_id` BIGINT UNSIGNED NOT NULL,
`bidder_id` BIGINT UNSIGNED NOT NULL,
`bid_amount` DECIMAL(15,2) NOT NULL,
`bid_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`status` ENUM('submitted','winning','not_selected') NOT NULL DEFAULT 'submitted',
`notes` TEXT NULL,
INDEX `idx_bid_lot` (`lot_id`),
INDEX `idx_bid_bidder` (`bidder_id`),
CONSTRAINT `fk_bid_lot` FOREIGN KEY (`lot_id`) REFERENCES `auction_lots`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_bid_bidder` FOREIGN KEY (`bidder_id`) REFERENCES `auction_bidders`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `auction_awards` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`lot_id` BIGINT UNSIGNED NOT NULL,
`winning_bid_id` BIGINT UNSIGNED NOT NULL,
`award_value` DECIMAL(15,2) NOT NULL,
`award_date` DATE NOT NULL,
`committee_id` BIGINT UNSIGNED NULL,
`decision_notes` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX `uq_award_lot` (`lot_id`),
CONSTRAINT `fk_award_lot` FOREIGN KEY (`lot_id`) REFERENCES `auction_lots`(`id`),
CONSTRAINT `fk_award_bid` FOREIGN KEY (`winning_bid_id`) REFERENCES `auction_bids`(`id`),
CONSTRAINT `fk_award_committee` FOREIGN KEY (`committee_id`) REFERENCES `auction_committees`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `auction_settlements` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`award_id` BIGINT UNSIGNED NOT NULL,
`amount_due` DECIMAL(15,2) NOT NULL,
`amount_paid` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`paid_date` DATE NULL,
`payment_method` ENUM('cash','bank','check') NULL,
`status` ENUM('pending','partial','settled') NOT NULL DEFAULT 'pending',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE INDEX `uq_settlement_award` (`award_id`),
CONSTRAINT `fk_settlement_award` FOREIGN KEY (`award_id`) REFERENCES `auction_awards`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `auction_lease_contracts` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`award_id` BIGINT UNSIGNED NOT NULL,
`tenant_bidder_id` BIGINT UNSIGNED NOT NULL,
`annual_rent` DECIMAL(15,2) NOT NULL,
`start_date` DATE NOT NULL,
`end_date` DATE NOT NULL,
`terms` TEXT NULL,
`status` ENUM('active','ended','terminated') NOT NULL DEFAULT 'active',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX `uq_lease_award` (`award_id`),
CONSTRAINT `fk_lease_award` FOREIGN KEY (`award_id`) REFERENCES `auction_awards`(`id`),
CONSTRAINT `fk_lease_tenant` FOREIGN KEY (`tenant_bidder_id`) REFERENCES `auction_bidders`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE `asset_register`
ADD COLUMN `leased_status` ENUM('none','under_lease') NOT NULL DEFAULT 'none' AFTER `status`;
",
'down' => "
ALTER TABLE `asset_register` DROP COLUMN `leased_status`;
DROP TABLE IF EXISTS `auction_lease_contracts`;
DROP TABLE IF EXISTS `auction_settlements`;
DROP TABLE IF EXISTS `auction_awards`;
DROP TABLE IF EXISTS `auction_bids`;
DROP TABLE IF EXISTS `auction_booklet_participants`;
DROP TABLE IF EXISTS `auction_bidders`;
DROP TABLE IF EXISTS `auction_booklets`;
DROP TABLE IF EXISTS `auction_lot_assets`;
DROP TABLE IF EXISTS `auction_lots`;
DROP TABLE IF EXISTS `auction_committee_members`;
DROP TABLE IF EXISTS `auction_committees`;
DROP TABLE IF EXISTS `auctions`;
",
];
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