Commit 35200985 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(financial): attribute revenue to what was actually sold

Measured against the live oc-sport database, subscription revenue read
457,970 EGP against a genuine 345,257 — overstated by 32.6% — while the
per-programme breakdown summed to 39,873, about 12% of reality.

Three distinct causes:

POSService::buildInvoiceItems() discarded the item_type/item_id it was
handed, so every POS line landed with a NULL itemable_type. Reporting
reads NULL as "programme subscription", which moved 102,000 EGP of
product sales into subscription revenue — 90% of the error — and meant
no product-ownership check could ever pass. Lines now carry their
Product or Kit. A migration backfills history by matching invoice lines
to their POS lines, filling only NULL rows and only where the match is
unambiguous; a production dry run matched 44 of 45 with 0 ambiguous.

Pro-rata allocation divided by invoices.total_amount, but line totals
sum to subtotal_amount — total_amount also carries discount, tax and
service fees. Every bundled invoice was therefore split on the wrong
denominator (10,713 EGP).

topPrograms joined enrolments to invoices and dropped anything without
an invoice_id. Only 88 of 350 enrolments have one, so 75% of programmes
reported zero. Now a UNION: the exact link where it exists, participant
fallback where it does not, split evenly across a participant's
programmes. Reconciles at 333,105 EGP.

Also: the mounted revenue widgets and the receptionist dashboard omitted
direction='inbound', counting refunds as income, and the widgets' raw
queries bypassed SoftDeletes and cancelled invoices.

EnrollExistingWizard read BasePrice directly, ignoring membership type
and every pricing rule, so it quoted a different figure than the
registration wizard for the same player. Both now go through
PricingService.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent fa4c5088
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
use App\Domain\Financial\Services\PaymentService; use App\Domain\Financial\Services\PaymentService;
use App\Domain\Financial\Services\WalletService; use App\Domain\Financial\Services\WalletService;
use App\Domain\Inventory\Enums\MovementType; use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Models\Kit;
use App\Domain\Inventory\Models\Product; use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\Warehouse; use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService; use App\Domain\Inventory\Services\InventoryService;
...@@ -285,13 +286,34 @@ private function recordSinglePayment($invoice, string $method, int $amount, User ...@@ -285,13 +286,34 @@ private function recordSinglePayment($invoice, string $method, int $amount, User
*/ */
private function buildInvoiceItems(array $cartItems): array private function buildInvoiceItems(array $cartItems): array
{ {
return array_map(fn (array $item) => [ return array_map(function (array $item) {
'description' => $item['item_name_ar'], $line = [
'quantity' => $item['quantity'] ?? 1, 'description' => $item['item_name_ar'],
'unit_price' => (int) $item['unit_price'], 'quantity' => $item['quantity'] ?? 1,
'discount_amount' => (int) ($item['discount_amount'] ?? 0), 'unit_price' => (int) $item['unit_price'],
'tax_amount' => 0, 'discount_amount' => (int) ($item['discount_amount'] ?? 0),
], $cartItems); 'tax_amount' => 0,
];
// Point the line at what was actually sold. Without this every POS
// sale lands with a null itemable_type, which reporting reads as a
// programme subscription — so product sales inflated subscription
// revenue, were missing from product revenue, and never counted
// towards "has this participant bought the required product".
$type = $item['item_type'] ?? null;
$type = $type instanceof POSItemType ? $type : POSItemType::tryFrom((string) $type);
$id = $item['item_id'] ?? null;
if ($id && $type === POSItemType::Product) {
$line['itemable_type'] = Product::class;
$line['itemable_id'] = (int) $id;
} elseif ($id && $type === POSItemType::Kit) {
$line['itemable_type'] = Kit::class;
$line['itemable_id'] = (int) $id;
}
return $line;
}, $cartItems);
} }
/** /**
......
...@@ -65,6 +65,10 @@ public function render() ...@@ -65,6 +65,10 @@ public function render()
->join('payments', 'payments.invoice_id', '=', 'invoices.id') ->join('payments', 'payments.invoice_id', '=', 'invoices.id')
->where('invoice_items.itemable_type', $productType) ->where('invoice_items.itemable_type', $productType)
->where('payments.status', 'confirmed') ->where('payments.status', 'confirmed')
->where('payments.direction', 'inbound')
->whereNull('payments.deleted_at')
->whereNull('invoices.deleted_at')
->where('invoices.status', '!=', 'cancelled')
->where('payments.created_at', '>=', $startDate) ->where('payments.created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId)) ->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->when($branchId, fn ($q) => $q->where('payments.branch_id', $branchId)) ->when($branchId, fn ($q) => $q->where('payments.branch_id', $branchId))
...@@ -93,13 +97,17 @@ private function getProductRevenue($from, $to, $academyId, ?int $branchId, strin ...@@ -93,13 +97,17 @@ private function getProductRevenue($from, $to, $academyId, ?int $branchId, strin
'prod_items.invoice_id', '=', 'invoices.id' 'prod_items.invoice_id', '=', 'invoices.id'
) )
->where('payments.status', 'confirmed') ->where('payments.status', 'confirmed')
->where('invoices.total_amount', '>', 0) ->where('payments.direction', 'inbound')
->whereNull('payments.deleted_at')
->whereNull('invoices.deleted_at')
->where('invoices.status', '!=', 'cancelled')
->where('invoices.subtotal_amount', '>', 0)
->where('payments.created_at', '>=', $from) ->where('payments.created_at', '>=', $from)
->when($to, fn ($q) => $q->where('payments.created_at', '<', $to)) ->when($to, fn ($q) => $q->where('payments.created_at', '<', $to))
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId)) ->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->when($branchId, fn ($q) => $q->where('payments.branch_id', $branchId)) ->when($branchId, fn ($q) => $q->where('payments.branch_id', $branchId))
->select( ->select(
DB::raw('SUM(payments.amount * prod_items.product_total / invoices.total_amount) as product_revenue') DB::raw('SUM(payments.amount * prod_items.product_total / invoices.subtotal_amount) as product_revenue')
) )
->value('product_revenue'); ->value('product_revenue');
...@@ -116,14 +124,18 @@ private function getRevenueByProduct($from, $academyId, ?int $branchId, string $ ...@@ -116,14 +124,18 @@ private function getRevenueByProduct($from, $academyId, ?int $branchId, string $
}) })
->join('products', 'invoice_items.itemable_id', '=', 'products.id') ->join('products', 'invoice_items.itemable_id', '=', 'products.id')
->where('payments.status', 'confirmed') ->where('payments.status', 'confirmed')
->where('invoices.total_amount', '>', 0) ->where('payments.direction', 'inbound')
->whereNull('payments.deleted_at')
->whereNull('invoices.deleted_at')
->where('invoices.status', '!=', 'cancelled')
->where('invoices.subtotal_amount', '>', 0)
->where('payments.created_at', '>=', $from) ->where('payments.created_at', '>=', $from)
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId)) ->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->when($branchId, fn ($q) => $q->where('payments.branch_id', $branchId)) ->when($branchId, fn ($q) => $q->where('payments.branch_id', $branchId))
->select( ->select(
'products.id as product_id', 'products.id as product_id',
'products.name_ar as product_name', 'products.name_ar as product_name',
DB::raw('SUM(payments.amount * invoice_items.total_amount / invoices.total_amount) as total'), DB::raw('SUM(payments.amount * invoice_items.total_amount / invoices.subtotal_amount) as total'),
DB::raw('SUM(invoice_items.quantity) as units_sold'), DB::raw('SUM(invoice_items.quantity) as units_sold'),
DB::raw('COUNT(DISTINCT invoices.id) as invoice_count'), DB::raw('COUNT(DISTINCT invoices.id) as invoice_count'),
) )
......
...@@ -110,13 +110,17 @@ private function getSubscriptionRevenue($from, $to, $academyId, ?int $branchId): ...@@ -110,13 +110,17 @@ private function getSubscriptionRevenue($from, $to, $academyId, ?int $branchId):
'sub_items.invoice_id', '=', 'invoices.id' 'sub_items.invoice_id', '=', 'invoices.id'
) )
->where('payments.status', 'confirmed') ->where('payments.status', 'confirmed')
->where('invoices.total_amount', '>', 0) ->where('payments.direction', 'inbound')
->whereNull('payments.deleted_at')
->whereNull('invoices.deleted_at')
->where('invoices.status', '!=', 'cancelled')
->where('invoices.subtotal_amount', '>', 0)
->where('payments.created_at', '>=', $from) ->where('payments.created_at', '>=', $from)
->when($to, fn ($q) => $q->where('payments.created_at', '<', $to)) ->when($to, fn ($q) => $q->where('payments.created_at', '<', $to))
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId)) ->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->when($branchId, fn ($q) => $q->where('payments.branch_id', $branchId)) ->when($branchId, fn ($q) => $q->where('payments.branch_id', $branchId))
->select( ->select(
DB::raw('SUM(payments.amount * sub_items.sub_total / invoices.total_amount) as subscription_revenue') DB::raw('SUM(payments.amount * sub_items.sub_total / invoices.subtotal_amount) as subscription_revenue')
) )
->value('subscription_revenue'); ->value('subscription_revenue');
...@@ -134,7 +138,7 @@ private function getRevenueByActivity($from, $academyId, ?int $branchId) ...@@ -134,7 +138,7 @@ private function getRevenueByActivity($from, $academyId, ?int $branchId)
WITH payment_sub_revenue AS ( WITH payment_sub_revenue AS (
SELECT SELECT
p.id as payment_id, p.id as payment_id,
p.amount * si.sub_total / i.total_amount as sub_revenue, p.amount * si.sub_total / i.subtotal_amount as sub_revenue,
i.billable_id as participant_id i.billable_id as participant_id
FROM payments p FROM payments p
JOIN invoices i ON p.invoice_id = i.id JOIN invoices i ON p.invoice_id = i.id
...@@ -145,7 +149,11 @@ private function getRevenueByActivity($from, $academyId, ?int $branchId) ...@@ -145,7 +149,11 @@ private function getRevenueByActivity($from, $academyId, ?int $branchId)
GROUP BY invoice_id GROUP BY invoice_id
) si ON si.invoice_id = i.id ) si ON si.invoice_id = i.id
WHERE p.status = 'confirmed' WHERE p.status = 'confirmed'
AND i.total_amount > 0 AND p.direction = 'inbound'
AND p.deleted_at IS NULL
AND i.deleted_at IS NULL
AND i.status <> 'cancelled'
AND i.subtotal_amount > 0
AND i.billable_type = 'App\\Domain\\Participant\\Models\\Participant' AND i.billable_type = 'App\\Domain\\Participant\\Models\\Participant'
AND p.created_at >= ? AND p.created_at >= ?
" . ($academyId ? "AND p.academy_id = {$academyId}" : "") . " " . ($academyId ? "AND p.academy_id = {$academyId}" : "") . "
...@@ -201,7 +209,7 @@ private function getTopPrograms($from, $academyId, ?int $branchId) ...@@ -201,7 +209,7 @@ private function getTopPrograms($from, $academyId, ?int $branchId)
WITH payment_sub_revenue AS ( WITH payment_sub_revenue AS (
SELECT SELECT
p.id as payment_id, p.id as payment_id,
p.amount * si.sub_total / i.total_amount as sub_revenue, p.amount * si.sub_total / i.subtotal_amount as sub_revenue,
i.billable_id as participant_id i.billable_id as participant_id
FROM payments p FROM payments p
JOIN invoices i ON p.invoice_id = i.id JOIN invoices i ON p.invoice_id = i.id
...@@ -212,7 +220,11 @@ private function getTopPrograms($from, $academyId, ?int $branchId) ...@@ -212,7 +220,11 @@ private function getTopPrograms($from, $academyId, ?int $branchId)
GROUP BY invoice_id GROUP BY invoice_id
) si ON si.invoice_id = i.id ) si ON si.invoice_id = i.id
WHERE p.status = 'confirmed' WHERE p.status = 'confirmed'
AND i.total_amount > 0 AND p.direction = 'inbound'
AND p.deleted_at IS NULL
AND i.deleted_at IS NULL
AND i.status <> 'cancelled'
AND i.subtotal_amount > 0
AND i.billable_type = 'App\\Domain\\Participant\\Models\\Participant' AND i.billable_type = 'App\\Domain\\Participant\\Models\\Participant'
AND p.created_at >= ? AND p.created_at >= ?
" . ($academyId ? "AND p.academy_id = {$academyId}" : "") . " " . ($academyId ? "AND p.academy_id = {$academyId}" : "") . "
......
...@@ -234,7 +234,12 @@ private function getExpenses($from, $to): array ...@@ -234,7 +234,12 @@ private function getExpenses($from, $to): array
->orderByDesc('total') ->orderByDesc('total')
->get() ->get()
->map(fn ($e) => [ ->map(fn ($e) => [
'category' => $e->category, // Expense::$casts turns this into an ExpenseCategory instance.
// Unwrap it to its scalar here so callers can build array keys
// and strings from it — concatenating the enum is fatal.
'category' => $e->category instanceof ExpenseCategory
? $e->category->value
: (string) $e->category,
'label' => $e->category instanceof ExpenseCategory ? $e->category->label() : $e->category, 'label' => $e->category instanceof ExpenseCategory ? $e->category->label() : $e->category,
'total' => (int) $e->total, 'total' => (int) $e->total,
'count' => (int) $e->count, 'count' => (int) $e->count,
...@@ -446,11 +451,11 @@ private function getRevenueBreakdown($from, $to): array ...@@ -446,11 +451,11 @@ private function getRevenueBreakdown($from, $to): array
) )
->where('payments.status', 'confirmed') ->where('payments.status', 'confirmed')
->where('payments.direction', 'inbound') ->where('payments.direction', 'inbound')
->where('invoices.total_amount', '>', 0) ->where('invoices.subtotal_amount', '>', 0)
->whereBetween('payments.payment_date', [$from, $to]) ->whereBetween('payments.payment_date', [$from, $to])
->when($this->branch_id, fn ($q) => $q->where('payments.branch_id', $this->branch_id)) ->when($this->branch_id, fn ($q) => $q->where('payments.branch_id', $this->branch_id))
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId)) ->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->value(DB::raw('SUM(payments.amount * sub_items.sub_total / invoices.total_amount)')) ?? 0; ->value(DB::raw('SUM(payments.amount * sub_items.sub_total / invoices.subtotal_amount)')) ?? 0;
// Product revenue (invoice items where itemable_type = Product) // Product revenue (invoice items where itemable_type = Product)
$productRevenue = (int) DB::table('payments') $productRevenue = (int) DB::table('payments')
...@@ -465,36 +470,76 @@ private function getRevenueBreakdown($from, $to): array ...@@ -465,36 +470,76 @@ private function getRevenueBreakdown($from, $to): array
) )
->where('payments.status', 'confirmed') ->where('payments.status', 'confirmed')
->where('payments.direction', 'inbound') ->where('payments.direction', 'inbound')
->where('invoices.total_amount', '>', 0) ->where('invoices.subtotal_amount', '>', 0)
->whereBetween('payments.payment_date', [$from, $to]) ->whereBetween('payments.payment_date', [$from, $to])
->when($this->branch_id, fn ($q) => $q->where('payments.branch_id', $this->branch_id)) ->when($this->branch_id, fn ($q) => $q->where('payments.branch_id', $this->branch_id))
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId)) ->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->value(DB::raw('SUM(payments.amount * prod_items.product_total / invoices.total_amount)')) ?? 0; ->value(DB::raw('SUM(payments.amount * prod_items.product_total / invoices.subtotal_amount)')) ?? 0;
// Top programs by revenue // Top programs by revenue
$topPrograms = DB::table('payments') // Attributing subscription money to a programme is only exact when the
->join('invoices', 'payments.invoice_id', '=', 'invoices.id') // enrolment records which invoice billed it — and in practice most do
->join('invoice_items', fn ($j) => $j->on('invoice_items.invoice_id', '=', 'invoices.id')->whereNull('invoice_items.itemable_type')) // not (88 of 350 on the live instance). So: use the exact link where it
->join('enrollments', 'enrollments.participant_id', '=', 'invoices.billable_id') // exists, fall back to the participant's own enrolments otherwise, and
->join('training_programs', 'training_programs.id', '=', 'enrollments.training_program_id') // split an invoice evenly across however many programmes it maps to.
->where('payments.status', 'confirmed') //
->where('payments.direction', 'inbound') // The previous version joined on participant_id alone, which fanned a
->where('invoices.total_amount', '>', 0) // payment across every programme that participant had ever joined, then
->where('invoices.billable_type', Participant::class) // used SUM(DISTINCT) to hide the inflation — which instead discarded
->whereBetween('payments.payment_date', [$from, $to]) // genuinely equal amounts. Measured against live data it reported about
->when($this->branch_id, fn ($q) => $q->where('payments.branch_id', $this->branch_id)) // 12% of the real figure.
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId)) //
->select( // Written as raw SQL because the CTE + UNION shape does not express
'training_programs.id as program_id', // cleanly through the query builder; every value is bound.
'training_programs.name_ar as program_name', $topProgramsSql = <<<'SQL'
DB::raw('SUM(DISTINCT payments.amount * invoice_items.total_amount / invoices.total_amount) as total'), WITH sub AS (
DB::raw('COUNT(DISTINCT invoices.billable_id) as subscriber_count') SELECT invoice_id, SUM(total_amount) AS sub_total
FROM invoice_items
WHERE itemable_type IS NULL
GROUP BY invoice_id
),
map AS (
SELECT DISTINCT i.id AS invoice_id, e.training_program_id
FROM invoices i
JOIN enrollments e ON e.invoice_id = i.id
UNION
SELECT DISTINCT i.id, e.training_program_id
FROM invoices i
JOIN enrollments e ON e.participant_id = i.billable_id
WHERE i.billable_type = :billable
AND NOT EXISTS (SELECT 1 FROM enrollments e2 WHERE e2.invoice_id = i.id)
),
cnt AS (
SELECT invoice_id, COUNT(*) AS program_count FROM map GROUP BY invoice_id
) )
->groupBy('training_programs.id', 'training_programs.name_ar') SELECT tp.id AS program_id,
->orderByDesc('total') tp.name_ar AS program_name,
->limit(10) SUM(p.amount * s.sub_total / NULLIF(i.subtotal_amount, 0) / c.program_count) AS total,
->get() COUNT(DISTINCT i.billable_id) AS subscriber_count
->toArray(); FROM payments p
JOIN invoices i ON p.invoice_id = i.id
JOIN sub s ON s.invoice_id = i.id
JOIN map m ON m.invoice_id = i.id
JOIN cnt c ON c.invoice_id = i.id
JOIN training_programs tp ON tp.id = m.training_program_id
WHERE p.status = 'confirmed'
AND p.direction = 'inbound'
AND i.subtotal_amount > 0
AND p.payment_date BETWEEN :from AND :to
AND (:branch_id IS NULL OR p.branch_id = :branch_id)
AND (:academy_id IS NULL OR p.academy_id = :academy_id)
GROUP BY tp.id, tp.name_ar
ORDER BY total DESC
LIMIT 10
SQL;
$topPrograms = DB::select($topProgramsSql, [
'billable' => Participant::class,
'from' => $from,
'to' => $to,
'branch_id' => $this->branch_id ?: null,
'academy_id' => $academyId,
]);
// Per-product revenue breakdown // Per-product revenue breakdown
$byProduct = DB::table('payments') $byProduct = DB::table('payments')
...@@ -503,14 +548,14 @@ private function getRevenueBreakdown($from, $to): array ...@@ -503,14 +548,14 @@ private function getRevenueBreakdown($from, $to): array
->join('products', 'invoice_items.itemable_id', '=', 'products.id') ->join('products', 'invoice_items.itemable_id', '=', 'products.id')
->where('payments.status', 'confirmed') ->where('payments.status', 'confirmed')
->where('payments.direction', 'inbound') ->where('payments.direction', 'inbound')
->where('invoices.total_amount', '>', 0) ->where('invoices.subtotal_amount', '>', 0)
->whereBetween('payments.payment_date', [$from, $to]) ->whereBetween('payments.payment_date', [$from, $to])
->when($this->branch_id, fn ($q) => $q->where('payments.branch_id', $this->branch_id)) ->when($this->branch_id, fn ($q) => $q->where('payments.branch_id', $this->branch_id))
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId)) ->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->select( ->select(
'products.id as product_id', 'products.id as product_id',
'products.name_ar as product_name', 'products.name_ar as product_name',
DB::raw('SUM(payments.amount * invoice_items.total_amount / invoices.total_amount) as total'), DB::raw('SUM(payments.amount * invoice_items.total_amount / invoices.subtotal_amount) as total'),
DB::raw('SUM(invoice_items.quantity) as units_sold') DB::raw('SUM(invoice_items.quantity) as units_sold')
) )
->groupBy('products.id', 'products.name_ar') ->groupBy('products.id', 'products.name_ar')
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Models\BasePrice; use App\Domain\Pricing\Models\BasePrice;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\DTOs\ProrationResult; use App\Domain\Shared\DTOs\ProrationResult;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\ProrationService; use App\Domain\Shared\Services\ProrationService;
...@@ -159,16 +160,31 @@ public function selectedProgramFee(): int ...@@ -159,16 +160,31 @@ public function selectedProgramFee(): int
return 0; return 0;
} }
$price = BasePrice::where('priceable_type', TrainingProgram::class) // Go through PricingService rather than reading BasePrice directly.
->where('priceable_id', $this->selected_program_id) // The raw lookup ignored base_prices.metadata->membership_type, so a
->where('is_active', true) // club member was quoted the non-member price (or whichever row
->where('effective_from', '<=', now()) // happened to sort first), and every pricing rule and discount was
->where(fn ($q) => $q->whereNull('effective_to')->orWhere('effective_to', '>=', now())) // skipped — quoting a different figure here than the registration
->forBranch($this->branchId) // wizard quotes for the same player and programme.
->orderByDesc('priority') $program = TrainingProgram::find($this->selected_program_id);
->first(); $participant = $this->selected_participant_id
? Participant::find($this->selected_participant_id)
: null;
if (! $program) {
return 0;
}
return $price?->amount ?? 0; try {
return app(PricingService::class)
->calculate($program, $participant, $this->branchId)
->finalAmount;
} catch (DomainException $e) {
// No active base price is a hard stop by design — surface zero and
// let the existing validation block the enrolment rather than
// inventing a price.
return 0;
}
} }
#[Computed] #[Computed]
......
...@@ -36,11 +36,15 @@ public function render() ...@@ -36,11 +36,15 @@ public function render()
$stats = [ $stats = [
'registrations_today' => Participant::where('branch_id', $this->branchId) 'registrations_today' => Participant::where('branch_id', $this->branchId)
->whereDate('created_at', $today)->count(), ->whereDate('created_at', $today)->count(),
// direction: a refund is an outbound payment row. Without this
// filter money handed back to a parent is counted as money taken in.
'payments_today' => Payment::where('branch_id', $this->branchId) 'payments_today' => Payment::where('branch_id', $this->branchId)
->where('status', 'confirmed') ->where('status', 'confirmed')
->where('direction', 'inbound')
->whereDate('created_at', $today)->sum('amount'), ->whereDate('created_at', $today)->sum('amount'),
'payments_count' => Payment::where('branch_id', $this->branchId) 'payments_count' => Payment::where('branch_id', $this->branchId)
->where('status', 'confirmed') ->where('status', 'confirmed')
->where('direction', 'inbound')
->whereDate('created_at', $today)->count(), ->whereDate('created_at', $today)->count(),
'sessions_in_progress' => TrainingSession::whereHas('group', fn ($q) => $q->where('branch_id', $this->branchId)) 'sessions_in_progress' => TrainingSession::whereHas('group', fn ($q) => $q->where('branch_id', $this->branchId))
->where('session_date', $today) ->where('session_date', $today)
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Point historical POS invoice lines at the product they sold.
*
* POSService::buildInvoiceItems() used to write description-only lines, leaving
* itemable_type NULL. Reporting reads a NULL itemable_type as "programme
* subscription", so every past POS sale was counted as subscription revenue,
* was missing from product revenue, and never satisfied the "has this
* participant bought the required product" check.
*
* Matches an invoice line back to its POS line by invoice -> pos_transaction ->
* pos_transaction_items on (name, quantity). Only fills rows that are still
* NULL, and only where the match is unambiguous, so it is safe to re-run and
* cannot overwrite a correct value.
*/
return new class extends Migration
{
public function up(): void
{
foreach (['invoice_items', 'pos_transactions', 'pos_transaction_items'] as $t) {
if (! Schema::hasTable($t)) {
return;
}
}
$productClass = 'App\\Domain\\Inventory\\Models\\Product';
$kitClass = 'App\\Domain\\Inventory\\Models\\Kit';
// One UPDATE per item type. The subquery requires exactly one matching
// POS line, so ambiguous rows are left untouched rather than guessed.
foreach ([['product', $productClass], ['kit', $kitClass]] as [$posType, $class]) {
DB::statement("
UPDATE invoice_items ii
SET itemable_type = ?, itemable_id = m.item_id
FROM (
SELECT pti.item_id,
pt.invoice_id,
pti.item_name_ar,
pti.quantity
FROM pos_transaction_items pti
JOIN pos_transactions pt ON pt.id = pti.pos_transaction_id
WHERE pti.item_type = ?
AND pti.item_id IS NOT NULL
AND pt.invoice_id IS NOT NULL
) AS m
WHERE ii.invoice_id = m.invoice_id
AND ii.description = m.item_name_ar
AND ii.quantity = m.quantity
AND ii.itemable_type IS NULL
", [$class, $posType]);
}
}
public function down(): void
{
// Intentionally irreversible.
//
// up() only *fills* rows that were NULL, but there is no record of
// which ones it touched. POSService now stamps itemable_type at
// creation time, so a blanket "NULL out every POS invoice line" would
// destroy correct data written after this migration ran — losing more
// than rolling back gains. A repair migration that cannot be undone
// without collateral damage is better left un-undone.
}
};
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