Add Invoice All Tasks scheduler?

This commit is contained in:
David Bomba 2025-07-13 12:12:17 +10:00
parent 688a7b5e60
commit 5abe5f1fbb
10 changed files with 312 additions and 32 deletions

View File

@ -0,0 +1,56 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper\Schedule;
class InvoiceOutstandingTasks
{
/**
* Defines the template name
*
* @var string
*/
public string $template = 'invoice_outstanding_tasks';
/**
* The date range the report should include
*
* @var string
*/
public string $date_range = 'this_month';
/**
* An array of clients hashed_ids
*
* Leave blank if this action should apply to all clients
*
* @var array
*/
public array $clients = [];
/**
* If true, the invoice will be auto-sent
* else it will be generated and kept in a draft state
*
* @var bool
*/
public bool $auto_send = false;
/**
* If true, the project tasks will be included in the report
*
* @var bool
*/
public bool $include_project_tasks = false;
}

View File

@ -70,6 +70,8 @@ class StoreSchedulerRequest extends Request
'parameters.report_name' => ['bail','sometimes', 'string', 'required_if:template,email_report','in:vendor,purchase_order_item,purchase_order,ar_detailed,ar_summary,client_balance,tax_summary,profitloss,client_sales,user_sales,product_sales,activity,activities,client,clients,client_contact,client_contacts,credit,credits,document,documents,expense,expenses,invoice,invoices,invoice_item,invoice_items,quote,quotes,quote_item,quote_items,recurring_invoice,recurring_invoices,payment,payments,product,products,task,tasks'],
'parameters.date_key' => ['bail','sometimes', 'string'],
'parameters.status' => ['bail','sometimes', 'nullable', 'string'],
'parameters.include_project_tasks' => ['bail','sometimes', 'boolean', 'required_if:template,invoice_outstanding_tasks'],
'parameters.auto_send' => ['bail','sometimes', 'boolean', 'required_if:template,invoice_outstanding_tasks'],
];
return $rules;

View File

@ -70,6 +70,8 @@ class UpdateSchedulerRequest extends Request
'parameters.report_name' => ['bail','sometimes', 'string', 'required_if:template,email_report','in:vendor,purchase_order_item,purchase_order,ar_detailed,ar_summary,client_balance,tax_summary,profitloss,client_sales,user_sales,product_sales,activity,activities,client,clients,client_contact,client_contacts,credit,credits,document,documents,expense,expenses,invoice,invoices,invoice_item,invoice_items,quote,quotes,quote_item,quote_items,recurring_invoice,recurring_invoices,payment,payments,product,products,task,tasks'],
'parameters.date_key' => ['bail','sometimes', 'string'],
'parameters.status' => ['bail','sometimes', 'nullable', 'string'],
'parameters.include_project_tasks' => ['bail','sometimes', 'boolean', 'required_if:template,invoice_outstanding_tasks'],
'parameters.auto_send' => ['bail','sometimes', 'boolean', 'required_if:template,invoice_outstanding_tasks'],
];
return $rules;

View File

@ -66,7 +66,10 @@ class AdjustProductInventory implements ShouldQueue
collect($this->invoice->line_items)->filter(function ($item) {
return $item->type_id == '1';
})->each(function ($i) {
$p = Product::query()->where('product_key', $i->product_key)->where('company_id', $this->company->id)->first();
$p = Product::query()
->where('company_id', $this->company->id)
->where('product_key', $i->product_key)
->first();
if ($p) {
$p->in_stock_quantity += $i->quantity;
@ -82,7 +85,10 @@ class AdjustProductInventory implements ShouldQueue
collect($this->invoice->line_items)->filter(function ($item) {
return $item->type_id == '1';
})->each(function ($i) {
$p = Product::query()->where('product_key', $i->product_key)->where('company_id', $this->company->id)->first();
$p = Product::query()
->where('company_id', $this->company->id)
->where('product_key', $i->product_key)
->first();
if ($p) {
$p->in_stock_quantity -= $i->quantity;
@ -103,7 +109,10 @@ class AdjustProductInventory implements ShouldQueue
collect($this->invoice->line_items)->filter(function ($item) {
return $item->type_id == '1';
})->each(function ($i) {
$p = Product::query()->where('product_key', $i->product_key)->where('company_id', $this->company->id)->first();
$p = Product::query()
->where('company_id', $this->company->id)
->where('product_key', $i->product_key)
->first();
if ($p) {
$p->in_stock_quantity -= $i->quantity;
@ -125,7 +134,10 @@ class AdjustProductInventory implements ShouldQueue
collect($this->old_invoice)->filter(function ($item) {
return $item->type_id == '1';
})->each(function ($i) {
$p = Product::query()->where('product_key', $i->product_key)->where('company_id', $this->company->id)->first();
$p = Product::query()
->where('company_id', $this->company->id)
->where('product_key', $i->product_key)
->first();
if ($p) {
$p->in_stock_quantity += $i->quantity;

View File

@ -248,30 +248,28 @@ class Task extends BaseModel
}
}
public function calcDuration($start_time_cutoff = 0, $end_time_cutoff = 0)
public function calcDuration(bool $billable = false)
{
$duration = 0;
$parts = json_decode($this->time_log ?? '{}') ?: [];
foreach ($parts as $part) {
if($billable && isset($part[3]) && !$part[3]){
continue;
}
$start_time = $part[0];
if (count($part) == 1 || ! $part[1]) {
$end_time = time();
} else {
$end_time = $part[1];
}
if ($start_time_cutoff) {
$start_time = max($start_time, $start_time_cutoff);
}
if ($end_time_cutoff) {
$end_time = min($end_time, $end_time_cutoff);
}
$duration += max($end_time - $start_time, 0);
}
// return CarbonInterval::seconds(round($duration))->locale($this->company->locale())->cascade()->forHumans();
return round($duration);
}
@ -313,7 +311,7 @@ class Task extends BaseModel
public function getQuantity(): float
{
return round(($this->calcDuration() / 3600), 2);
return round(($this->calcDuration(true) / 3600), 2);
}
public function logDuration(int $start_time, int $end_time)
@ -323,7 +321,7 @@ class Task extends BaseModel
public function taskValue(): float
{
return round(($this->calcDuration() / 3600) * $this->getRate(), 2);
return round(($this->calcDuration(true) / 3600) * $this->getRate(), 2);
}
public function isRunning(): bool

View File

@ -40,7 +40,7 @@ class ProjectRepository extends BaseRepository
->cursor()
->each(function ($task, $key) use (&$lines) {
if (!$task->isRunning()) {
if (!$task->isRunning() && $task->calcDuration(true) > 0) {
if ($key == 0 && $task->company->invoice_task_project) {
$body = '<div class="project-header">'.$task->project->name.'</div>' .$task->project?->public_notes ?? ''; //@phpstan-ignore-line
$body .= '<div class="task-time-details">'.$task->description().'</div>';

View File

@ -42,7 +42,7 @@ class ProjectService
return [
'date' => $task->calculated_start_date ?? \Carbon\Carbon::parse($task->created_at)->format('Y-m-d'),
'hours_used' => $task->calcDuration() / 60 / 60,
'hours_used' => $task->calcDuration(true) / 60 / 60,
];
});

View File

@ -0,0 +1,152 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Scheduler;
use Carbon\Carbon;
use App\Models\Task;
use App\Models\Client;
use App\Models\Product;
use App\Models\Scheduler;
use App\DataMapper\InvoiceItem;
use App\Factory\InvoiceFactory;
use App\Utils\Traits\MakesHash;
use App\Utils\Traits\MakesDates;
use App\DataMapper\Schedule\EmailStatement;
use App\Repositories\InvoiceRepository;
class InvoiceOutstandingTasksService
{
use MakesHash;
use MakesDates;
public function __construct(public Scheduler $scheduler)
{
}
public function run()
{
// $query = Task::query()
// ->where('company_id', $this->scheduler->company_id)
// ->where('is_deleted', 0);
// if (count($this->scheduler->parameters['clients']) >= 1) {
// $query->whereIn('client_id', $this->transformKeys($this->scheduler->parameters['clients']));
// }
// if (!$this->scheduler->parameters['include_project_tasks']) {
// $query->whereNull('project_id');
// }
$query = Client::query()
->where('company_id', $this->scheduler->company_id)
->where('is_deleted', 0);
if (count($this->scheduler->parameters['clients']) >= 1) {
$query->whereIn('id', $this->transformKeys($this->scheduler->parameters['clients']));
}
$query->whereHas('tasks', function ($sub_query){
$sub_query->whereNull('invoice_id')
->where('is_deleted', 0)
->whereBetween('calculated_start_date', $this->calculateStartAndEndDates())
->when(!$this->scheduler->parameters['include_project_tasks'], function ($sub_query_two){
$sub_query_two->whereNull('project_id');
});
});
$invoice_repo = new InvoiceRepository();
$query->cursor()
->each(function (Client $client) use ($invoice_repo) {
$line_items = $client->tasks()->whereNull('invoice_id')
->where('is_deleted', 0)
->whereBetween('calculated_start_date', $this->calculateStartAndEndDates())
->when(!$this->scheduler->parameters['include_project_tasks'], function ($sub_query_two){
return $sub_query_two->whereNull('project_id');
})
->get()
->filter(function (Task $task){
return $task->calcDuration(true) > 0 && !$task->isRunning();
})
->map(function (Task $task, $key){
if ($key == 0 && $task->company->invoice_task_project) {
$body = '<div class="project-header">'.$task->project->name.'</div>' .$task->project?->public_notes ?? ''; //@phpstan-ignore-line
$body .= '<div class="task-time-details">'.$task->description().'</div>';
} elseif (!$task->company->invoice_task_hours && !$task->company->invoice_task_timelog && !$task->company->invoice_task_datelog && !$task->company->invoice_task_item_description) {
$body = $task->description ?? '';
} else {
$body = '<div class="task-time-details">'.$task->description().'</div>';
}
$item = new InvoiceItem();
$item->quantity = $task->getQuantity();
$item->cost = $task->getRate();
$item->product_key = '';
$item->notes = $body;
$item->task_id = $task->hashed_id;
$item->tax_id = (string) Product::PRODUCT_TYPE_SERVICE;
$item->type_id = '2';
return $item;
})
->toArray();
$data = [
'client_id' => $client->id,
'date' => now()->addSeconds($client->company->utc_offset())->format('Y-m-d'),
'line_items' => array_values($line_items),
'uses_inclusive_taxes' => $client->company->settings->inclusive_taxes ?? false,
];
$invoice = $invoice_repo->save($data, InvoiceFactory::create($client->company_id, $client->user_id));
if($this->scheduler->parameters['auto_send']){
nlog('sending email');
$invoice->service()->sendEmail();
}
});
$this->scheduler->calculateNextRun();
}
/**
* Start and end date of the statement
*
* @return array [$start_date, $end_date];
*/
private function calculateStartAndEndDates(): array
{
return match ($this->scheduler->parameters['date_range']) {
EmailStatement::LAST7 => [now()->startOfDay()->subDays(7)->format('Y-m-d'), now()->startOfDay()->format('Y-m-d')],
EmailStatement::LAST30 => [now()->startOfDay()->subDays(30)->format('Y-m-d'), now()->startOfDay()->format('Y-m-d')],
EmailStatement::LAST365 => [now()->startOfDay()->subDays(365)->format('Y-m-d'), now()->startOfDay()->format('Y-m-d')],
EmailStatement::THIS_MONTH => [now()->startOfDay()->firstOfMonth()->format('Y-m-d'), now()->startOfDay()->lastOfMonth()->format('Y-m-d')],
EmailStatement::LAST_MONTH => [now()->startOfDay()->subMonthNoOverflow()->firstOfMonth()->format('Y-m-d'), now()->startOfDay()->subMonthNoOverflow()->lastOfMonth()->format('Y-m-d')],
EmailStatement::THIS_QUARTER => [now()->startOfDay()->startOfQuarter()->format('Y-m-d'), now()->startOfDay()->endOfQuarter()->format('Y-m-d')],
EmailStatement::LAST_QUARTER => [now()->startOfDay()->subQuarterNoOverflow()->startOfQuarter()->format('Y-m-d'), now()->startOfDay()->subQuarterNoOverflow()->endOfQuarter()->format('Y-m-d')],
EmailStatement::THIS_YEAR => [now()->startOfDay()->firstOfYear()->format('Y-m-d'), now()->startOfDay()->lastOfYear()->format('Y-m-d')],
EmailStatement::LAST_YEAR => [now()->startOfDay()->subYearNoOverflow()->firstOfYear()->format('Y-m-d'), now()->startOfDay()->subYearNoOverflow()->lastOfYear()->format('Y-m-d')],
EmailStatement::ALL_TIME => [
$client->tasks()->selectRaw('MIN(tasks.calculated_start_date) as start_date')->pluck('start_date')->first()
?: Carbon::now()->format('Y-m-d'),
Carbon::now()->format('Y-m-d')
],
EmailStatement::CUSTOM_RANGE => [$this->scheduler->parameters['start_date'], $this->scheduler->parameters['end_date']],
default => [now()->startOfDay()->firstOfMonth()->format('Y-m-d'), now()->startOfDay()->lastOfMonth()->format('Y-m-d')],
};
}
}

View File

@ -15,7 +15,6 @@ namespace App\Services\Scheduler;
use App\Models\Scheduler;
use App\Utils\Traits\MakesDates;
use App\Utils\Traits\MakesHash;
class SchedulerService
{
use MakesHash;
@ -52,6 +51,11 @@ class SchedulerService
(new EmailReport($this->scheduler))->run();
}
private function invoice_outstanding_tasks()
{
(new InvoiceOutstandingTasksService($this->scheduler))->run();
}
/**
* Sets the next run date of the scheduled task

View File

@ -11,22 +11,24 @@
namespace Tests\Feature\Scheduler;
use App\DataMapper\Schedule\EmailStatement;
use App\Factory\SchedulerFactory;
use App\Models\Client;
use App\Models\RecurringInvoice;
use App\Models\Scheduler;
use App\Services\Scheduler\EmailReport;
use App\Services\Scheduler\EmailStatementService;
use App\Utils\Traits\MakesHash;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\Session;
use Illuminate\Validation\ValidationException;
use Tests\MockAccountData;
use Tests\TestCase;
use App\Models\Task;
use App\Models\Client;
use App\Models\Scheduler;
use Tests\MockAccountData;
use App\Utils\Traits\MakesHash;
use App\Models\RecurringInvoice;
use App\Factory\SchedulerFactory;
use App\Services\Scheduler\EmailReport;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Session;
use App\DataMapper\Schedule\EmailStatement;
use Illuminate\Validation\ValidationException;
use App\Services\Scheduler\EmailStatementService;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use App\Services\Scheduler\InvoiceOutstandingTasksService;
/**
*
@ -59,6 +61,58 @@ class SchedulerTest extends TestCase
// $this->withoutExceptionHandling();
}
public function testInvoiceOutstandingTasks()
{
$start = now()->subMonth()->addDays(1)->timestamp;
$end = now()->subMonth()->addDays(5)->timestamp;
Task::factory()->count(10)->create([
'company_id' => $this->company->id,
'client_id' => $this->client->id,
'user_id' => $this->user->id,
'description' => 'Test task',
'time_log' => '[['.$start.','.$end.',null,false]]',
'rate' => 100,
]);
$data = [
'name' => 'A test invoice outstanding tasks scheduler',
'frequency_id' => RecurringInvoice::FREQUENCY_MONTHLY,
'next_run' => now()->format('Y-m-d'),
'template' => 'invoice_outstanding_tasks',
'parameters' => [
'clients' => [],
'include_project_tasks' => true,
'auto_send' => true,
'date_range' => 'last_month',
],
];
$response = false;
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/task_schedulers', $data);
$response->assertStatus(200);
$arr = $response->json();
$id = $this->decodePrimaryKey($arr['data']['id']);
$scheduler = Scheduler::find($id);
$user = $scheduler->user;
$user->email = "{rand(5,555555}@gmail.com";
$user->save();
$this->assertNotNull($scheduler);
$export = (new InvoiceOutstandingTasksService($scheduler))->run();
}
public function testReportValidationRulesForStartAndEndDate()
{