feat: added new changes towards the show

This commit is contained in:
brian-inspiren 2026-06-25 16:48:39 +08:00
parent d9eaaeaa92
commit 38fe43414f
7 changed files with 290 additions and 43 deletions

View File

@ -3,7 +3,7 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\ClientInvoicePaymentItem; use App\Models\ClientInvoicePaymentItem;
use App\Services\GoogleAdsService; use App\Services\GoogleAdsSpendService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -13,7 +13,7 @@ class CalculateClientInvoicePaymentItemSpending extends Command
protected $description = 'Calculate client invoice payment item spending from Google Ads spend by item date range.'; protected $description = 'Calculate client invoice payment item spending from Google Ads spend by item date range.';
public function handle(GoogleAdsService $adsService): int public function handle(GoogleAdsSpendService $spendService): int
{ {
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
$spendCache = []; $spendCache = [];
@ -26,13 +26,14 @@ public function handle(GoogleAdsService $adsService): int
->whereNotNull('start_date') ->whereNotNull('start_date')
->whereNotNull('end_date') ->whereNotNull('end_date')
->orderBy('id') ->orderBy('id')
->chunkById(50, function ($items) use ($adsService, $dryRun, &$spendCache, &$updated, &$skipped, &$failed) { ->chunkById(50, function ($items) use ($spendService, $dryRun, &$spendCache, &$updated, &$skipped, &$failed) {
foreach ($items as $item) { foreach ($items as $item) {
$client = $item->payment?->invoice?->client; $client = $item->payment?->invoice?->client;
if ($client === null || empty($client->customer_id)) { if ($client === null || empty($client->customer_id)) {
$skipped++; $skipped++;
$this->warn("Skipping item {$item->id}: missing client/customer ID."); $this->warn("Skipping item {$item->id}: missing client/customer ID.");
continue; continue;
} }
@ -41,6 +42,7 @@ public function handle(GoogleAdsService $adsService): int
if ($startDate === null || $endDate === null) { if ($startDate === null || $endDate === null) {
$skipped++; $skipped++;
continue; continue;
} }
@ -48,8 +50,7 @@ public function handle(GoogleAdsService $adsService): int
try { try {
if (! array_key_exists($cacheKey, $spendCache)) { if (! array_key_exists($cacheKey, $spendCache)) {
$spendCache[$cacheKey] = $this->spendForDateRange( $spendCache[$cacheKey] = $spendService->forDateRange(
$adsService,
$client->customer_id, $client->customer_id,
$startDate, $startDate,
$endDate, $endDate,
@ -92,30 +93,4 @@ public function handle(GoogleAdsService $adsService): int
return $failed > 0 ? self::FAILURE : self::SUCCESS; return $failed > 0 ? self::FAILURE : self::SUCCESS;
} }
private function spendForDateRange(
GoogleAdsService $adsService,
string $customerId,
string $startDate,
string $endDate,
): float {
$campaigns = $adsService->listCampaigns($customerId);
$spending = 0.0;
foreach ($campaigns as $campaign) {
$metrics = $adsService->listCampaignsMetricsById(
$customerId,
(string) $campaign['id'],
$startDate,
$endDate,
);
$spending += array_sum(array_map(
fn (array $metric): float => (float) ($metric['actual_spend'] ?? 0),
$metrics,
));
}
return round($spending, 6);
}
} }

View File

@ -0,0 +1,113 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\ClientInvoicePaymentItem;
use App\Services\GoogleAdsSpendService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
class UpdateLatestClientInvoicePaymentItemSpending extends Command
{
protected $signature = 'customer:update-latest-invoice-item-spending
{--dry-run : Calculate without saving changes}';
protected $description = 'Update spending on each client\'s latest billing type 1 invoice payment item.';
public function handle(GoogleAdsSpendService $spendService): int
{
$dryRun = (bool) $this->option('dry-run');
$today = today()->toDateString();
$updated = 0;
$skipped = 0;
$failed = 0;
Client::query()
->whereHas('invoices.payments.items', fn (Builder $query) => $this->eligibleItems($query))
->orderBy('id')
->chunkById(50, function ($clients) use (
$spendService,
$dryRun,
$today,
&$updated,
&$skipped,
&$failed,
) {
foreach ($clients as $client) {
$item = ClientInvoicePaymentItem::query()
->whereHas(
'payment.invoice',
fn (Builder $query) => $query->where('client_id', $client->id),
)
->where(fn (Builder $query) => $this->eligibleItems($query))
->latest('id')
->first();
if ($item === null || empty($client->customer_id)) {
$skipped++;
$this->warn("Skipping client {$client->id}: missing eligible item/customer ID.");
continue;
}
$startDate = $item->start_date->format('Y-m-d');
$endDate = $item->end_date?->format('Y-m-d') ?? $today;
if ($endDate < $startDate) {
$skipped++;
$this->warn("Skipping item {$item->id}: end date is before start date.");
continue;
}
try {
$spending = $spendService->forDateRange(
$client->customer_id,
$startDate,
$endDate,
);
if (! $dryRun) {
$item->forceFill(['spending' => $spending])->save();
}
$updated++;
$this->line(sprintf(
'%s client %d item %d: RM %.2f (%s to %s)',
$dryRun ? 'Calculated' : 'Updated',
$client->id,
$item->id,
$spending,
$startDate,
$endDate,
));
} catch (\Throwable $exception) {
$failed++;
Log::error('Unable to update latest invoice payment item spending.', [
'client_id' => $client->id,
'client_invoice_payment_item_id' => $item->id,
'customer_id' => $client->customer_id,
'start_date' => $startDate,
'end_date' => $endDate,
'message' => $exception->getMessage(),
]);
$this->error("Failed item {$item->id}: {$exception->getMessage()}");
}
}
});
$this->info("Done. {$updated} calculated, {$skipped} skipped, {$failed} failed.");
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function eligibleItems(Builder $query): Builder
{
return $query
->where('billing_item_types_id', 1)
->whereNotNull('start_date');
}
}

View File

@ -333,9 +333,9 @@ private function hydrateClient(array $account): array
// dd($invoices); // dd($invoices);
$campaigns = []; $campaigns = [];
// if($localClient->status !='CLOSED' && $localClient->status != 'CANCELED'){ if($localClient->status !='CLOSED' && $localClient->status != 'CANCELED'){
// $campaigns = $this->adsService->listCampaigns($localClient->customer_id); $campaigns = $this->adsService->listCampaigns($localClient->customer_id);
// } }
$lifeTimeSpend = 0; $lifeTimeSpend = 0;
@ -351,7 +351,6 @@ private function hydrateClient(array $account): array
$lifeTimeSpend += number_format($totalSpend, 2, '.', ''); $lifeTimeSpend += number_format($totalSpend, 2, '.', '');
} }
} }
$activities = $localClient->activitiesList() $activities = $localClient->activitiesList()
->orderByDesc('created_at') ->orderByDesc('created_at')
->get() ->get()

View File

@ -0,0 +1,31 @@
<?php
namespace App\Services;
class GoogleAdsSpendService
{
public function __construct(
private readonly GoogleAdsService $adsService,
) {}
public function forDateRange(string $customerId, string $startDate, string $endDate): float
{
$spending = 0.0;
foreach ($this->adsService->listCampaigns($customerId) as $campaign) {
$metrics = $this->adsService->listCampaignsMetricsById(
$customerId,
(string) $campaign['id'],
$startDate,
$endDate,
);
$spending += array_sum(array_map(
fn (array $metric): float => (float) ($metric['actual_spend'] ?? 0),
$metrics,
));
}
return round($spending, 6);
}
}

View File

@ -184,7 +184,7 @@ export default function TicketDetails({
columns={columns} columns={columns}
data={filteredClients} data={filteredClients}
enableRowActions // ✅ REQUIRED enableRowActions // ✅ REQUIRED
positionActionsColumn="last" // optional but recommended positionActionsColumn="first" // optional but recommended
renderRowActions={renderRowActions} renderRowActions={renderRowActions}
renderTopToolbarCustomActions={() => ( renderTopToolbarCustomActions={() => (
<Tabs value={activeStatus} onTabChange={setActiveStatus}> <Tabs value={activeStatus} onTabChange={setActiveStatus}>

View File

@ -55,6 +55,7 @@ import {
IconEdit, IconEdit,
IconEye, IconEye,
IconFileDollar, IconFileDollar,
IconLink,
IconNotebook, IconNotebook,
IconPlus, IconPlus,
IconReportAnalytics, IconReportAnalytics,
@ -855,43 +856,43 @@ export default function TicketDetails({
const summaryItems = [ const summaryItems = [
{ {
label: 'Total Media Fees (RM)', label: 'Total Media Fees (Invoice)',
value: invoiceTotals.mediaFee, value: invoiceTotals.mediaFee,
icon: IconDeviceTv, icon: IconDeviceTv,
color: 'grape', color: 'grape',
}, },
{ {
label: 'Net Amount (RM)', label: 'Net Amount (Invoice)',
value: invoiceTotals.nettAmount, value: invoiceTotals.nettAmount,
icon: IconWallet, icon: IconWallet,
color: 'green', color: 'green',
}, },
{ {
label: 'Billable Spend (Invoice)', label: 'Spending Media Fee (Google Live)',
value: invoiceTotals.billableInvoiceSpending, value: invoiceTotals.billableInvoiceSpending,
icon: IconCurrencyDollar, icon: IconCurrencyDollar,
color: 'indigo', color: 'indigo',
}, },
{ {
label: 'Remaining Amount (RM)', label: 'Remaining Amount',
value: invoiceTotals.remainingAmount, value: invoiceTotals.remainingAmount,
icon: IconChartBar, icon: IconChartBar,
color: 'teal', color: 'teal',
}, },
{ {
label: 'Adjustments (RM)', label: 'Adjustments',
value: invoiceTotals.adjustmentNet, value: invoiceTotals.adjustmentNet,
icon: IconChartBar, icon: IconChartBar,
color: 'cyan', color: 'cyan',
}, },
{ {
label: 'Lifetime Spend (RM)', label: 'Lifetime Spend (All)',
value: lifeTimeSpending, value: lifeTimeSpending,
icon: IconCash, icon: IconCash,
color: 'orange', color: 'orange',
}, },
{ {
label: 'Total Management Fees (RM)', label: 'Total Management Fees (Invoice)',
value: invoiceTotals.managementFee, value: invoiceTotals.managementFee,
icon: IconBriefcase, icon: IconBriefcase,
color: 'blue', color: 'blue',
@ -938,6 +939,15 @@ export default function TicketDetails({
noWrap noWrap
> >
<Text>{cell.getValue<string>()}</Text> <Text>{cell.getValue<string>()}</Text>
{row.original.linked_invoice_id && (
<Tooltip
label="Linked invoice"
withArrow
withinPortal
>
<IconLink size={16} color="blue" />
</Tooltip>
)}
{isCreditCard && ( {isCreditCard && (
<Tooltip <Tooltip
label="Credit card" label="Credit card"

View File

@ -0,0 +1,119 @@
<?php
use App\Models\BillingItemType;
use App\Models\Client;
use App\Models\ClientInvoice;
use App\Models\ClientInvoicePayment;
use App\Models\ClientInvoicePaymentItem;
use App\Services\GoogleAdsService;
use Illuminate\Support\Carbon;
function createInvoiceItem(
Client $client,
int $billingItemTypeId,
?string $startDate,
?string $endDate,
float $spending = 0,
): ClientInvoicePaymentItem {
$invoice = ClientInvoice::query()->forceCreate([
'client_id' => $client->id,
'invoice_no' => fake()->unique()->numerify('INV-####'),
]);
$payment = ClientInvoicePayment::query()->create([
'client_invoice_id' => $invoice->id,
]);
return ClientInvoicePaymentItem::query()->create([
'client_invoice_payment_id' => $payment->id,
'billing_item_types_id' => $billingItemTypeId,
'start_date' => $startDate,
'end_date' => $endDate,
'spending' => $spending,
]);
}
test('it updates only the latest eligible invoice payment item for each client', function () {
Carbon::setTestNow('2026-06-25 10:00:00');
BillingItemType::query()->insert([
['id' => 1, 'name' => 'Media'],
['id' => 2, 'name' => 'Management'],
]);
$firstClient = Client::factory()->create([
'customer_id' => '1111111111',
'status' => 'ENABLED',
'time_zone' => 'Asia/Kuala_Lumpur',
]);
$secondClient = Client::factory()->create([
'customer_id' => '2222222222',
'status' => 'ENABLED',
'time_zone' => 'Asia/Kuala_Lumpur',
]);
$olderItem = createInvoiceItem($firstClient, 1, '2026-05-01', '2026-05-31', 10);
$latestItem = createInvoiceItem($firstClient, 1, '2026-06-01', null);
$otherTypeItem = createInvoiceItem($firstClient, 2, '2026-06-01', null, 20);
$secondClientItem = createInvoiceItem($secondClient, 1, '2026-06-10', '2026-06-20');
$adsService = Mockery::mock(GoogleAdsService::class);
$adsService->shouldReceive('listCampaigns')
->once()
->with('1111111111')
->andReturn([['id' => 101], ['id' => 102]]);
$adsService->shouldReceive('listCampaignsMetricsById')
->once()
->with('1111111111', '101', '2026-06-01', '2026-06-25')
->andReturn([['actual_spend' => 12.25]]);
$adsService->shouldReceive('listCampaignsMetricsById')
->once()
->with('1111111111', '102', '2026-06-01', '2026-06-25')
->andReturn([['actual_spend' => 7.75]]);
$adsService->shouldReceive('listCampaigns')
->once()
->with('2222222222')
->andReturn([['id' => 201]]);
$adsService->shouldReceive('listCampaignsMetricsById')
->once()
->with('2222222222', '201', '2026-06-10', '2026-06-20')
->andReturn([['actual_spend' => 5.5]]);
app()->instance(GoogleAdsService::class, $adsService);
$this->artisan('customer:update-latest-invoice-item-spending')
->expectsOutputToContain('Done. 2 calculated, 0 skipped, 0 failed.')
->assertSuccessful();
expect((float) $olderItem->fresh()->spending)->toBe(10.0)
->and((float) $latestItem->fresh()->spending)->toBe(20.0)
->and((float) $otherTypeItem->fresh()->spending)->toBe(20.0)
->and((float) $secondClientItem->fresh()->spending)->toBe(5.5);
});
test('dry run calculates spending without updating the item', function () {
BillingItemType::query()->insert([
'id' => 1,
'name' => 'Media',
]);
$client = Client::factory()->create([
'customer_id' => '3333333333',
'status' => 'ENABLED',
'time_zone' => 'Asia/Kuala_Lumpur',
]);
$item = createInvoiceItem($client, 1, '2026-06-01', '2026-06-15', 8);
$adsService = Mockery::mock(GoogleAdsService::class);
$adsService->shouldReceive('listCampaigns')
->once()
->with('3333333333')
->andReturn([]);
app()->instance(GoogleAdsService::class, $adsService);
$this->artisan('customer:update-latest-invoice-item-spending', ['--dry-run' => true])
->assertSuccessful();
expect((float) $item->fresh()->spending)->toBe(8.0);
});