Skip to content

Commit 142bad4

Browse files
committed
feat: Phase 40 — Sales Orders with invoice conversion
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 60a728c commit 142bad4

11 files changed

Lines changed: 330 additions & 15 deletions

erp/app/Modules/Finance/Http/Controllers/SalesOrderController.php

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,11 @@ public function store(StoreSalesOrderRequest $request): RedirectResponse
7070
'tenant_id' => auth()->user()->tenant_id,
7171
'contact_id' => $data['contact_id'] ?? null,
7272
'warehouse_id' => $data['warehouse_id'] ?? null,
73+
'reference' => $data['reference'] ?? null,
7374
'order_date' => $data['order_date'],
7475
'expected_date' => $data['expected_date'] ?? null,
76+
'currency_code' => $data['currency_code'] ?? 'USD',
77+
'exchange_rate' => $data['exchange_rate'] ?? 1,
7578
'notes' => $data['notes'] ?? null,
7679
'created_by' => auth()->id(),
7780
]);
@@ -169,13 +172,16 @@ public function convertToInvoice(SalesOrder $salesOrder): RedirectResponse
169172
$salesOrder->load('items');
170173

171174
$invoice = Invoice::create([
172-
'tenant_id' => $salesOrder->tenant_id,
173-
'contact_id' => $salesOrder->contact_id,
174-
'issue_date' => now()->toDateString(),
175-
'due_date' => now()->addDays(30)->toDateString(),
176-
'status' => 'draft',
177-
'notes' => $salesOrder->notes,
178-
'created_by' => auth()->id(),
175+
'tenant_id' => $salesOrder->tenant_id,
176+
'sales_order_id' => $salesOrder->id,
177+
'contact_id' => $salesOrder->contact_id,
178+
'issue_date' => now()->toDateString(),
179+
'due_date' => now()->addDays(30)->toDateString(),
180+
'status' => 'draft',
181+
'notes' => $salesOrder->notes,
182+
'created_by' => auth()->id(),
183+
'currency_code' => $salesOrder->currency_code ?? 'USD',
184+
'exchange_rate' => $salesOrder->exchange_rate ?? 1,
179185
]);
180186

181187
$invoice->update([

erp/app/Modules/Finance/Http/Requests/StoreSalesOrderRequest.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ public function rules(): array
1414
return [
1515
'contact_id' => ['nullable', Rule::exists('contacts', 'id')],
1616
'warehouse_id' => ['nullable', Rule::exists('warehouses', 'id')],
17+
'reference' => ['nullable', 'string', 'max:100', Rule::unique('sales_orders', 'reference')],
1718
'order_date' => ['required', 'date'],
1819
'expected_date' => ['nullable', 'date', 'after_or_equal:order_date'],
20+
'currency_code' => ['nullable', 'string', 'size:3'],
21+
'exchange_rate' => ['nullable', 'numeric', 'min:0.000001'],
1922
'notes' => ['nullable', 'string'],
2023
'items' => ['required', 'array', 'min:1'],
2124
'items.*.product_id' => ['nullable', Rule::exists('products', 'id')],

erp/app/Modules/Finance/Models/Invoice.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class Invoice extends Model
2323
use HasStatusTransitions;
2424

2525
protected $fillable = [
26-
'tenant_id', 'recurring_invoice_id', 'contact_id', 'number',
26+
'tenant_id', 'recurring_invoice_id', 'sales_order_id', 'contact_id', 'number',
2727
'issue_date', 'due_date', 'status', 'notes', 'created_by',
2828
'currency_code', 'exchange_rate',
2929
];
@@ -76,4 +76,9 @@ public function recurringInvoice(): BelongsTo
7676
{
7777
return $this->belongsTo(RecurringInvoice::class);
7878
}
79+
80+
public function salesOrder(): BelongsTo
81+
{
82+
return $this->belongsTo(SalesOrder::class);
83+
}
7984
}

erp/app/Modules/Finance/Models/SalesOrder.php

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Illuminate\Database\Eloquent\Model;
1111
use Illuminate\Database\Eloquent\Relations\BelongsTo;
1212
use Illuminate\Database\Eloquent\Relations\HasMany;
13+
use Illuminate\Database\Eloquent\Relations\HasOne;
1314
use Illuminate\Database\Eloquent\SoftDeletes;
1415

1516
class SalesOrder extends Model
@@ -22,7 +23,8 @@ class SalesOrder extends Model
2223

2324
protected $fillable = [
2425
'tenant_id', 'contact_id', 'warehouse_id', 'invoice_id', 'number',
25-
'order_date', 'expected_date', 'status', 'notes', 'created_by',
26+
'reference', 'order_date', 'expected_date', 'status', 'notes', 'created_by',
27+
'currency_code', 'exchange_rate',
2628
];
2729

2830
protected $casts = [
@@ -36,8 +38,9 @@ protected function getTransitions(): array
3638
{
3739
return [
3840
'draft' => ['confirmed', 'cancelled'],
39-
'confirmed' => ['fulfilled', 'cancelled'],
40-
'fulfilled' => [],
41+
'confirmed' => ['fulfilled', 'invoiced', 'cancelled'],
42+
'fulfilled' => ['invoiced'],
43+
'invoiced' => [],
4144
'cancelled' => [],
4245
];
4346
}
@@ -57,6 +60,11 @@ public function invoice(): BelongsTo
5760
return $this->belongsTo(Invoice::class);
5861
}
5962

63+
public function generatedInvoice(): HasOne
64+
{
65+
return $this->hasOne(Invoice::class, 'sales_order_id');
66+
}
67+
6068
public function items(): HasMany
6169
{
6270
return $this->hasMany(SalesOrderItem::class);
@@ -67,6 +75,50 @@ public function creator(): BelongsTo
6775
return $this->belongsTo(User::class, 'created_by');
6876
}
6977

78+
/** Convert this confirmed SO to a new Invoice and mark SO as invoiced. */
79+
public function convertToInvoice(): Invoice
80+
{
81+
abort_unless($this->status === 'confirmed', 422, 'Only confirmed orders can be invoiced.');
82+
$this->load('items');
83+
84+
$ref = $this->reference ?? $this->number;
85+
$invoiceRef = $ref ? 'INV-' . preg_replace('/^SO-/', '', $ref) : null;
86+
87+
$invoice = Invoice::create([
88+
'tenant_id' => $this->tenant_id,
89+
'sales_order_id' => $this->id,
90+
'contact_id' => $this->contact_id,
91+
'issue_date' => now()->toDateString(),
92+
'due_date' => now()->addDays(30)->toDateString(),
93+
'status' => 'draft',
94+
'notes' => $this->notes,
95+
'created_by' => auth()->id(),
96+
'currency_code' => $this->currency_code ?? 'USD',
97+
'exchange_rate' => $this->exchange_rate ?? 1,
98+
]);
99+
100+
if ($invoiceRef) {
101+
$invoice->update(['number' => $invoiceRef]);
102+
} else {
103+
$invoice->update([
104+
'number' => 'INV-' . now()->format('Y') . '-' . str_pad((string) $invoice->id, 5, '0', STR_PAD_LEFT),
105+
]);
106+
}
107+
108+
foreach ($this->items as $item) {
109+
$invoice->items()->create([
110+
'description' => $item->description,
111+
'quantity' => $item->quantity,
112+
'unit_price' => $item->unit_price,
113+
'tax_rate' => $item->tax_rate,
114+
]);
115+
}
116+
117+
$this->update(['status' => 'invoiced', 'invoice_id' => $invoice->id]);
118+
119+
return $invoice;
120+
}
121+
70122
public function fulfill(): void
71123
{
72124
if (! $this->canTransitionTo('fulfilled')) {

erp/app/Modules/Finance/Models/SalesOrderItem.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class SalesOrderItem extends Model
1111

1212
protected $fillable = [
1313
'sales_order_id', 'product_id', 'description',
14-
'quantity', 'unit_price', 'tax_rate', 'quantity_fulfilled',
14+
'quantity', 'unit_price', 'tax_rate', 'line_total', 'quantity_fulfilled',
1515
];
1616

1717
protected $casts = [

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,12 @@
7676
Route::post('sales-orders', [SalesOrderController::class, 'store'])->name('sales-orders.store');
7777
Route::get('sales-orders/{salesOrder}', [SalesOrderController::class, 'show'])->name('sales-orders.show');
7878
Route::patch('sales-orders/{salesOrder}/confirm', [SalesOrderController::class, 'confirm'])->name('sales-orders.confirm');
79+
Route::post('sales-orders/{salesOrder}/confirm', [SalesOrderController::class, 'confirm'])->name('sales-orders.confirm-post');
7980
Route::post('sales-orders/{salesOrder}/fulfill', [SalesOrderController::class, 'fulfill'])->name('sales-orders.fulfill');
8081
Route::patch('sales-orders/{salesOrder}/cancel', [SalesOrderController::class, 'cancel'])->name('sales-orders.cancel');
82+
Route::post('sales-orders/{salesOrder}/cancel', [SalesOrderController::class, 'cancel'])->name('sales-orders.cancel-post');
8183
Route::post('sales-orders/{salesOrder}/convert', [SalesOrderController::class, 'convertToInvoice'])->name('sales-orders.convert');
84+
Route::post('sales-orders/{salesOrder}/convert-to-invoice', [SalesOrderController::class, 'convertToInvoice'])->name('sales-orders.convert-to-invoice');
8285
Route::delete('sales-orders/{salesOrder}', [SalesOrderController::class, 'destroy'])->name('sales-orders.destroy');
8386

8487
// Recurring Invoices
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::table('sales_orders', function (Blueprint $table) {
12+
$table->string('reference', 100)->nullable()->unique()->after('contact_id');
13+
$table->string('currency_code', 3)->default('USD')->after('notes');
14+
$table->decimal('exchange_rate', 15, 6)->default(1)->after('currency_code');
15+
});
16+
17+
Schema::table('sales_order_items', function (Blueprint $table) {
18+
$table->decimal('line_total', 15, 2)->default(0)->after('tax_rate');
19+
});
20+
}
21+
22+
public function down(): void
23+
{
24+
Schema::table('sales_orders', function (Blueprint $table) {
25+
$table->dropUnique(['reference']);
26+
$table->dropColumn(['reference', 'currency_code', 'exchange_rate']);
27+
});
28+
29+
Schema::table('sales_order_items', function (Blueprint $table) {
30+
$table->dropColumn('line_total');
31+
});
32+
}
33+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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::table('invoices', function (Blueprint $table) {
12+
$table->unsignedBigInteger('sales_order_id')->nullable()->after('contact_id');
13+
$table->foreign('sales_order_id')->references('id')->on('sales_orders')->nullOnDelete();
14+
});
15+
}
16+
17+
public function down(): void
18+
{
19+
Schema::table('invoices', function (Blueprint $table) {
20+
$table->dropForeign(['sales_order_id']);
21+
$table->dropColumn('sales_order_id');
22+
});
23+
}
24+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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::table('sales_orders', function (Blueprint $table) {
12+
$table->enum('status', ['draft', 'confirmed', 'fulfilled', 'invoiced', 'cancelled'])
13+
->default('draft')->change();
14+
});
15+
}
16+
17+
public function down(): void
18+
{
19+
Schema::table('sales_orders', function (Blueprint $table) {
20+
$table->enum('status', ['draft', 'confirmed', 'fulfilled', 'cancelled'])
21+
->default('draft')->change();
22+
});
23+
}
24+
};

erp/resources/js/types/finance.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,18 +234,22 @@ export interface CreditNote {
234234
created_at: string;
235235
}
236236

237-
export type SalesOrderStatus = 'draft' | 'confirmed' | 'fulfilled' | 'cancelled';
237+
export type SalesOrderStatus = 'draft' | 'confirmed' | 'fulfilled' | 'invoiced' | 'cancelled';
238238
export interface SalesOrderItem {
239239
id?: number; product_id?: number | null; product_name?: string; product_sku?: string;
240240
description: string; quantity: number; unit_price: number; tax_rate: number;
241241
quantity_fulfilled?: number; line_total?: number;
242+
product?: { id: number; name: string; sku: string } | null;
242243
}
243244
export interface SalesOrder {
244-
id: number; number?: string; status: SalesOrderStatus;
245-
order_date: string; expected_date?: string; notes?: string;
245+
id: number; number?: string; reference?: string | null; status: SalesOrderStatus;
246+
order_date: string; expected_date?: string | null; notes?: string | null;
247+
currency_code?: string; exchange_rate?: number;
246248
contact?: { id: number; name: string } | null;
249+
contact_id?: number | null;
247250
warehouse?: { id: number; name: string } | null;
248251
invoice?: { id: number; number?: string } | null;
252+
generated_invoice?: { id: number; number?: string } | null;
249253
items?: SalesOrderItem[]; subtotal?: number; tax_total?: number; total?: number;
250254
transitions?: string[]; created_by?: string; created_at?: string;
251255
}

0 commit comments

Comments
 (0)