feat: added new changes towards the show
This commit is contained in:
parent
d9eaaeaa92
commit
38fe43414f
@ -3,7 +3,7 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ClientInvoicePaymentItem;
|
||||
use App\Services\GoogleAdsService;
|
||||
use App\Services\GoogleAdsSpendService;
|
||||
use Illuminate\Console\Command;
|
||||
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.';
|
||||
|
||||
public function handle(GoogleAdsService $adsService): int
|
||||
public function handle(GoogleAdsSpendService $spendService): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$spendCache = [];
|
||||
@ -26,13 +26,14 @@ public function handle(GoogleAdsService $adsService): int
|
||||
->whereNotNull('start_date')
|
||||
->whereNotNull('end_date')
|
||||
->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) {
|
||||
$client = $item->payment?->invoice?->client;
|
||||
|
||||
if ($client === null || empty($client->customer_id)) {
|
||||
$skipped++;
|
||||
$this->warn("Skipping item {$item->id}: missing client/customer ID.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -41,6 +42,7 @@ public function handle(GoogleAdsService $adsService): int
|
||||
|
||||
if ($startDate === null || $endDate === null) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -48,8 +50,7 @@ public function handle(GoogleAdsService $adsService): int
|
||||
|
||||
try {
|
||||
if (! array_key_exists($cacheKey, $spendCache)) {
|
||||
$spendCache[$cacheKey] = $this->spendForDateRange(
|
||||
$adsService,
|
||||
$spendCache[$cacheKey] = $spendService->forDateRange(
|
||||
$client->customer_id,
|
||||
$startDate,
|
||||
$endDate,
|
||||
@ -92,30 +93,4 @@ public function handle(GoogleAdsService $adsService): int
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -333,9 +333,9 @@ private function hydrateClient(array $account): array
|
||||
|
||||
// dd($invoices);
|
||||
$campaigns = [];
|
||||
// if($localClient->status !='CLOSED' && $localClient->status != 'CANCELED'){
|
||||
// $campaigns = $this->adsService->listCampaigns($localClient->customer_id);
|
||||
// }
|
||||
if($localClient->status !='CLOSED' && $localClient->status != 'CANCELED'){
|
||||
$campaigns = $this->adsService->listCampaigns($localClient->customer_id);
|
||||
}
|
||||
|
||||
$lifeTimeSpend = 0;
|
||||
|
||||
@ -351,7 +351,6 @@ private function hydrateClient(array $account): array
|
||||
$lifeTimeSpend += number_format($totalSpend, 2, '.', '');
|
||||
}
|
||||
}
|
||||
|
||||
$activities = $localClient->activitiesList()
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
|
||||
31
app/Services/GoogleAdsSpendService.php
Normal file
31
app/Services/GoogleAdsSpendService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -184,7 +184,7 @@ export default function TicketDetails({
|
||||
columns={columns}
|
||||
data={filteredClients}
|
||||
enableRowActions // ✅ REQUIRED
|
||||
positionActionsColumn="last" // optional but recommended
|
||||
positionActionsColumn="first" // optional but recommended
|
||||
renderRowActions={renderRowActions}
|
||||
renderTopToolbarCustomActions={() => (
|
||||
<Tabs value={activeStatus} onTabChange={setActiveStatus}>
|
||||
|
||||
@ -55,6 +55,7 @@ import {
|
||||
IconEdit,
|
||||
IconEye,
|
||||
IconFileDollar,
|
||||
IconLink,
|
||||
IconNotebook,
|
||||
IconPlus,
|
||||
IconReportAnalytics,
|
||||
@ -855,43 +856,43 @@ export default function TicketDetails({
|
||||
|
||||
const summaryItems = [
|
||||
{
|
||||
label: 'Total Media Fees (RM)',
|
||||
label: 'Total Media Fees (Invoice)',
|
||||
value: invoiceTotals.mediaFee,
|
||||
icon: IconDeviceTv,
|
||||
color: 'grape',
|
||||
},
|
||||
{
|
||||
label: 'Net Amount (RM)',
|
||||
label: 'Net Amount (Invoice)',
|
||||
value: invoiceTotals.nettAmount,
|
||||
icon: IconWallet,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
label: 'Billable Spend (Invoice)',
|
||||
label: 'Spending Media Fee (Google Live)',
|
||||
value: invoiceTotals.billableInvoiceSpending,
|
||||
icon: IconCurrencyDollar,
|
||||
color: 'indigo',
|
||||
},
|
||||
{
|
||||
label: 'Remaining Amount (RM)',
|
||||
label: 'Remaining Amount',
|
||||
value: invoiceTotals.remainingAmount,
|
||||
icon: IconChartBar,
|
||||
color: 'teal',
|
||||
},
|
||||
{
|
||||
label: 'Adjustments (RM)',
|
||||
label: 'Adjustments',
|
||||
value: invoiceTotals.adjustmentNet,
|
||||
icon: IconChartBar,
|
||||
color: 'cyan',
|
||||
},
|
||||
{
|
||||
label: 'Lifetime Spend (RM)',
|
||||
label: 'Lifetime Spend (All)',
|
||||
value: lifeTimeSpending,
|
||||
icon: IconCash,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
label: 'Total Management Fees (RM)',
|
||||
label: 'Total Management Fees (Invoice)',
|
||||
value: invoiceTotals.managementFee,
|
||||
icon: IconBriefcase,
|
||||
color: 'blue',
|
||||
@ -938,6 +939,15 @@ export default function TicketDetails({
|
||||
noWrap
|
||||
>
|
||||
<Text>{cell.getValue<string>()}</Text>
|
||||
{row.original.linked_invoice_id && (
|
||||
<Tooltip
|
||||
label="Linked invoice"
|
||||
withArrow
|
||||
withinPortal
|
||||
>
|
||||
<IconLink size={16} color="blue" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{isCreditCard && (
|
||||
<Tooltip
|
||||
label="Credit card"
|
||||
|
||||
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user