Skip to content

Commit abc465a

Browse files
committed
feat: Phase 41 — Delivery Notes linked to sales orders and invoices
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 142bad4 commit abc465a

14 files changed

Lines changed: 1005 additions & 0 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\Contact;
7+
use App\Modules\Finance\Models\DeliveryNote;
8+
use App\Modules\Finance\Models\Invoice;
9+
use App\Modules\Finance\Models\SalesOrder;
10+
use App\Modules\Inventory\Models\Product;
11+
use Illuminate\Http\RedirectResponse;
12+
use Illuminate\Http\Request;
13+
use Inertia\Inertia;
14+
use Inertia\Response;
15+
16+
class DeliveryNoteController extends Controller
17+
{
18+
public function index(): Response
19+
{
20+
$this->authorize('viewAny', DeliveryNote::class);
21+
$deliveryNotes = DeliveryNote::with(['contact', 'salesOrder', 'invoice'])
22+
->orderByDesc('created_at')
23+
->paginate(25);
24+
return Inertia::render('Finance/DeliveryNotes/Index', compact('deliveryNotes'));
25+
}
26+
27+
public function create(Request $request): Response
28+
{
29+
$this->authorize('create', DeliveryNote::class);
30+
$contacts = Contact::where('type', 'customer')->orWhere('type', 'both')->orderBy('name')->get(['id', 'name']);
31+
$salesOrders = SalesOrder::whereIn('status', ['confirmed', 'invoiced'])->orderByDesc('order_date')->get(['id', 'reference', 'number']);
32+
$invoices = Invoice::whereIn('status', ['sent', 'partial'])->orderByDesc('issue_date')->get(['id', 'reference']);
33+
$products = Product::orderBy('name')->get(['id', 'name', 'sku']);
34+
$salesOrderId = $request->get('sales_order_id');
35+
$invoiceId = $request->get('invoice_id');
36+
return Inertia::render('Finance/DeliveryNotes/Create', compact('contacts', 'salesOrders', 'invoices', 'products', 'salesOrderId', 'invoiceId'));
37+
}
38+
39+
public function store(Request $request): RedirectResponse
40+
{
41+
$this->authorize('create', DeliveryNote::class);
42+
$data = $request->validate([
43+
'reference' => 'required|string|max:100|unique:delivery_notes,reference',
44+
'sales_order_id' => 'nullable|exists:sales_orders,id',
45+
'invoice_id' => 'nullable|exists:invoices,id',
46+
'contact_id' => 'nullable|exists:contacts,id',
47+
'carrier' => 'nullable|string|max:100',
48+
'tracking_number' => 'nullable|string|max:100',
49+
'dispatch_date' => 'nullable|date',
50+
'notes' => 'nullable|string',
51+
'items' => 'required|array|min:1',
52+
'items.*.description' => 'required|string',
53+
'items.*.product_id' => 'nullable|exists:products,id',
54+
'items.*.quantity' => 'required|numeric|min:0.01',
55+
]);
56+
57+
$dn = DeliveryNote::create([
58+
'tenant_id' => auth()->user()->tenant_id,
59+
'reference' => $data['reference'],
60+
'sales_order_id' => $data['sales_order_id'] ?? null,
61+
'invoice_id' => $data['invoice_id'] ?? null,
62+
'contact_id' => $data['contact_id'] ?? null,
63+
'carrier' => $data['carrier'] ?? null,
64+
'tracking_number' => $data['tracking_number'] ?? null,
65+
'dispatch_date' => $data['dispatch_date'] ?? null,
66+
'status' => 'draft',
67+
'notes' => $data['notes'] ?? null,
68+
]);
69+
70+
foreach ($data['items'] as $item) {
71+
$dn->items()->create([
72+
'product_id' => $item['product_id'] ?? null,
73+
'description' => $item['description'],
74+
'quantity' => $item['quantity'],
75+
]);
76+
}
77+
78+
return redirect()->route('finance.delivery-notes.show', $dn)->with('success', 'Delivery note created.');
79+
}
80+
81+
public function show(DeliveryNote $deliveryNote): Response
82+
{
83+
$this->authorize('view', $deliveryNote);
84+
$deliveryNote->load(['contact', 'salesOrder', 'invoice', 'items.product']);
85+
return Inertia::render('Finance/DeliveryNotes/Show', compact('deliveryNote'));
86+
}
87+
88+
public function dispatch(DeliveryNote $deliveryNote, Request $request): RedirectResponse
89+
{
90+
$this->authorize('update', $deliveryNote);
91+
abort_unless($deliveryNote->status === 'draft', 422, 'Only draft notes can be dispatched.');
92+
$request->validate(['dispatch_date' => 'nullable|date']);
93+
$deliveryNote->update([
94+
'status' => 'dispatched',
95+
'dispatch_date' => $request->dispatch_date ?? now()->toDateString(),
96+
]);
97+
return back()->with('success', 'Delivery note dispatched.');
98+
}
99+
100+
public function deliver(DeliveryNote $deliveryNote, Request $request): RedirectResponse
101+
{
102+
$this->authorize('update', $deliveryNote);
103+
abort_unless($deliveryNote->status === 'dispatched', 422, 'Only dispatched notes can be marked delivered.');
104+
$request->validate(['delivery_date' => 'nullable|date']);
105+
$deliveryNote->update([
106+
'status' => 'delivered',
107+
'delivery_date' => $request->delivery_date ?? now()->toDateString(),
108+
]);
109+
return back()->with('success', 'Delivery confirmed.');
110+
}
111+
112+
public function destroy(DeliveryNote $deliveryNote): RedirectResponse
113+
{
114+
$this->authorize('delete', $deliveryNote);
115+
abort_unless($deliveryNote->status === 'draft', 422, 'Only draft delivery notes can be deleted.');
116+
$deliveryNote->delete();
117+
return redirect()->route('finance.delivery-notes.index')->with('success', 'Delivery note deleted.');
118+
}
119+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\SoftDeletes;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
11+
class DeliveryNote extends Model
12+
{
13+
use BelongsToTenant, SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id', 'sales_order_id', 'invoice_id', 'contact_id', 'reference',
17+
'status', 'dispatch_date', 'delivery_date', 'carrier', 'tracking_number', 'notes',
18+
];
19+
20+
protected $casts = [
21+
'dispatch_date' => 'date',
22+
'delivery_date' => 'date',
23+
];
24+
25+
public function salesOrder(): BelongsTo
26+
{
27+
return $this->belongsTo(SalesOrder::class);
28+
}
29+
30+
public function invoice(): BelongsTo
31+
{
32+
return $this->belongsTo(Invoice::class);
33+
}
34+
35+
public function contact(): BelongsTo
36+
{
37+
return $this->belongsTo(Contact::class);
38+
}
39+
40+
public function items(): HasMany
41+
{
42+
return $this->hasMany(DeliveryNoteItem::class);
43+
}
44+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
use App\Modules\Inventory\Models\Product;
8+
9+
class DeliveryNoteItem extends Model
10+
{
11+
protected $fillable = ['delivery_note_id', 'product_id', 'description', 'quantity'];
12+
13+
public function deliveryNote(): BelongsTo
14+
{
15+
return $this->belongsTo(DeliveryNote::class);
16+
}
17+
18+
public function product(): BelongsTo
19+
{
20+
return $this->belongsTo(Product::class);
21+
}
22+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\DeliveryNote;
7+
8+
class DeliveryNotePolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('finance.view');
13+
}
14+
15+
public function view(User $user, DeliveryNote $dn): bool
16+
{
17+
return $user->can('finance.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->can('finance.create');
23+
}
24+
25+
public function update(User $user, DeliveryNote $dn): bool
26+
{
27+
return $user->can('finance.create');
28+
}
29+
30+
public function delete(User $user, DeliveryNote $dn): bool
31+
{
32+
return $user->can('finance.delete');
33+
}
34+
}

erp/app/Modules/Finance/Providers/FinanceServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use App\Modules\Finance\Models\Bill;
99
use App\Modules\Finance\Models\Contact;
1010
use App\Modules\Finance\Models\CreditNote;
11+
use App\Modules\Finance\Models\DeliveryNote;
1112
use App\Modules\Finance\Models\ExchangeRate;
1213
use App\Modules\Finance\Models\Invoice;
1314
use App\Modules\Finance\Models\JournalEntry;
@@ -24,6 +25,7 @@
2425
use App\Modules\Finance\Policies\AttachmentPolicy;
2526
use App\Modules\Finance\Policies\BatchPaymentPolicy;
2627
use App\Modules\Finance\Policies\AccountPolicy;
28+
use App\Modules\Finance\Policies\DeliveryNotePolicy;
2729
use App\Modules\Finance\Policies\PriceListPolicy;
2830
use App\Modules\Finance\Policies\ProjectPolicy;
2931
use App\Modules\Finance\Policies\BudgetPolicy;
@@ -53,6 +55,7 @@ public function boot(): void
5355
Gate::policy(Account::class, AccountPolicy::class);
5456
Gate::policy(Budget::class, BudgetPolicy::class);
5557
Gate::policy(Contact::class, ContactPolicy::class);
58+
Gate::policy(DeliveryNote::class, DeliveryNotePolicy::class);
5659
Gate::policy(JournalEntry::class, JournalEntryPolicy::class);
5760
Gate::policy(Invoice::class, InvoicePolicy::class);
5861
Gate::policy(Bill::class, BillPolicy::class);

erp/app/Modules/Finance/routes/finance.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use App\Modules\Finance\Http\Controllers\PriceListController;
2020
use App\Modules\Finance\Http\Controllers\AttachmentController;
2121
use App\Modules\Finance\Http\Controllers\BatchPaymentController;
22+
use App\Modules\Finance\Http\Controllers\DeliveryNoteController;
2223
use App\Modules\Finance\Http\Controllers\ProjectController;
2324
use Illuminate\Support\Facades\Route;
2425

@@ -162,4 +163,9 @@
162163
Route::post('attachments/{modelType}/{modelId}', [AttachmentController::class, 'store'])->name('attachments.store');
163164
Route::get('attachments/{attachment}/download', [AttachmentController::class, 'download'])->name('attachments.download');
164165
Route::delete('attachments/{attachment}', [AttachmentController::class, 'destroy'])->name('attachments.destroy');
166+
// Delivery Notes
167+
Route::resource('delivery-notes', DeliveryNoteController::class)->except(['edit', 'update']);
168+
Route::post('delivery-notes/{deliveryNote}/dispatch', [DeliveryNoteController::class, 'dispatch'])->name('delivery-notes.dispatch');
169+
Route::post('delivery-notes/{deliveryNote}/deliver', [DeliveryNoteController::class, 'deliver'])->name('delivery-notes.deliver');
170+
165171
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::create('delivery_notes', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
14+
$table->foreignId('sales_order_id')->nullable()->constrained('sales_orders')->nullOnDelete();
15+
$table->foreignId('invoice_id')->nullable()->constrained('invoices')->nullOnDelete();
16+
$table->foreignId('contact_id')->nullable()->constrained('contacts')->nullOnDelete();
17+
$table->string('reference')->unique();
18+
$table->enum('status', ['draft', 'dispatched', 'delivered'])->default('draft');
19+
$table->date('dispatch_date')->nullable();
20+
$table->date('delivery_date')->nullable();
21+
$table->string('carrier')->nullable();
22+
$table->string('tracking_number')->nullable();
23+
$table->text('notes')->nullable();
24+
$table->timestamps();
25+
$table->softDeletes();
26+
});
27+
}
28+
29+
public function down(): void
30+
{
31+
Schema::dropIfExists('delivery_notes');
32+
}
33+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::create('delivery_note_items', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('delivery_note_id')->constrained('delivery_notes')->cascadeOnDelete();
14+
$table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete();
15+
$table->string('description');
16+
$table->decimal('quantity', 10, 2)->default(1);
17+
$table->timestamps();
18+
});
19+
}
20+
21+
public function down(): void
22+
{
23+
Schema::dropIfExists('delivery_note_items');
24+
}
25+
};

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ const navItems: NavItem[] = [
7070
{ label: 'Recurring Invoices', href: '/finance/recurring-invoices', icon: <span /> },
7171
{ label: 'Quotes', href: '/finance/quotes', icon: <span /> },
7272
{ label: 'Sales Orders', href: '/finance/sales-orders', icon: <span /> },
73+
{ label: 'Delivery Notes', href: '/finance/delivery-notes', icon: <span /> },
7374
{ label: 'Credit Notes', href: '/finance/credit-notes', icon: <span /> },
7475
{ label: 'Contacts', href: '/finance/contacts', icon: <span /> },
7576
{ label: 'Journal Entries', href: '/finance/journal-entries', icon: <span /> },

0 commit comments

Comments
 (0)