feat: added new changes towards the show
This commit is contained in:
parent
d9eaaeaa92
commit
38fe43414f
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
// 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()
|
||||||
|
|||||||
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}
|
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}>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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