chore: initial commit of existing codebase
This commit is contained in:
commit
11e94889b2
292 files changed
+38586
No files matched your search
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the login view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ConfirmablePasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the confirm password view.
|
||||
*/
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.confirm-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's password.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => $request->user()->email,
|
||||
'password' => $request->password,
|
||||
])) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('auth.password_confirmed_at', time());
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a new email verification notification.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with('status', 'verification-link-sent');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the email verification prompt.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse|View
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: view('auth.verify-email');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset view.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.reset-password', ['request' => $request]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $status == Password::PASSWORD_RESET
|
||||
? redirect()->route('login')->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validateWithBag('updatePassword', [
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset link request view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.forgot-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return $status == Password::RESET_LINK_SENT
|
||||
? back()->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Item;
|
||||
use App\Models\ProductionOrder;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\SalesInvoice;
|
||||
use App\Models\StockLevel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$totalSales = SalesInvoice::sum('total_amount');
|
||||
|
||||
$inventoryValue = StockLevel::join('items', 'items.id', '=', 'stock_levels.item_id')
|
||||
->select(DB::raw('SUM(stock_levels.quantity * items.cost_price) as value'))
|
||||
->value('value') ?? 0;
|
||||
|
||||
$productionInProgress = ProductionOrder::where('status', 'in_progress')->count();
|
||||
|
||||
$purchasePending = PurchaseRequest::where('stage', '!=', 'complete')->count();
|
||||
|
||||
$outstandingReceivables = SalesInvoice::whereIn('status', ['unpaid', 'partial'])
|
||||
->sum(DB::raw('total_amount - paid_amount'));
|
||||
|
||||
return view('dashboard', compact(
|
||||
'totalSales',
|
||||
'inventoryValue',
|
||||
'productionInProgress',
|
||||
'purchasePending',
|
||||
'outstandingReceivables'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Inventory;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Item;
|
||||
use App\Services\ItemImportService;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class ItemController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$items = Item::paginate(20);
|
||||
|
||||
return view('inventory.items.index', compact('items'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('inventory.items.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|string|max:100',
|
||||
'unit_of_measure' => 'required|string|max:50',
|
||||
'cost_price' => 'nullable|numeric|min:0',
|
||||
'selling_price' => 'nullable|numeric|min:0',
|
||||
'minimum_stock_level' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
$data = $request->all();
|
||||
$data['item_code'] = 'ITEM-' . str_pad(Item::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
Item::create($data);
|
||||
|
||||
return redirect()->route('inventory.items.index')->with('success', 'Item created successfully.');
|
||||
}
|
||||
|
||||
public function show(Item $item)
|
||||
{
|
||||
return view('inventory.items.show', compact('item'));
|
||||
}
|
||||
|
||||
public function edit(Item $item)
|
||||
{
|
||||
return view('inventory.items.edit', compact('item'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Item $item)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|string|max:100',
|
||||
'unit_of_measure' => 'required|string|max:50',
|
||||
'cost_price' => 'nullable|numeric|min:0',
|
||||
'selling_price' => 'nullable|numeric|min:0',
|
||||
'minimum_stock_level' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
$item->update($request->all());
|
||||
|
||||
return redirect()->route('inventory.items.index')->with('success', 'Item updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Item $item)
|
||||
{
|
||||
$item->delete();
|
||||
|
||||
return redirect()->route('inventory.items.index')->with('success', 'Item deleted successfully.');
|
||||
}
|
||||
|
||||
public function import(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls|max:10240',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = app(ItemImportService::class)->import(
|
||||
$request->file('file')->getPathname()
|
||||
);
|
||||
|
||||
$msg = "Import complete: {$result['imported']} item(s) added";
|
||||
if ($result['skipped'] > 0) {
|
||||
$msg .= ", {$result['skipped']} skipped (already exist).";
|
||||
} else {
|
||||
$msg .= '.';
|
||||
}
|
||||
|
||||
return redirect()->route('inventory.items.index')->with('success', $msg);
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->route('inventory.items.index')
|
||||
->with('error', 'Import failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadTemplate()
|
||||
{
|
||||
$path = storage_path('app/items_template.xlsx');
|
||||
|
||||
if (!file_exists($path)) {
|
||||
Artisan::call('items:template');
|
||||
}
|
||||
|
||||
return response()->download($path, 'items_import_template.xlsx');
|
||||
}
|
||||
|
||||
public function exportPdf()
|
||||
{
|
||||
$items = Item::orderBy('category')->orderBy('item_name')->get();
|
||||
|
||||
$pdf = Pdf::loadView('inventory.items.pdf', compact('items'))
|
||||
->setPaper('a4', 'landscape');
|
||||
|
||||
return $pdf->download('items_' . now()->format('Y-m-d') . '.pdf');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Inventory;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Item;
|
||||
use App\Models\StockLevel;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class StockMovementController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$movements = StockMovement::with(['item', 'warehouse'])->latest()->paginate(20);
|
||||
|
||||
return view('inventory.movements.index', compact('movements'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$items = Item::all();
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('inventory.movements.create', compact('items', 'warehouses'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'item_id' => 'required|exists:items,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'type' => 'required|in:in,out,adjustment',
|
||||
'quantity' => 'required|numeric|min:0.01',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$stockLevel = StockLevel::firstOrCreate(
|
||||
['item_id' => $request->item_id, 'warehouse_id' => $request->warehouse_id],
|
||||
['quantity' => 0]
|
||||
);
|
||||
|
||||
if ($request->type === 'in' || $request->type === 'adjustment') {
|
||||
$stockLevel->increment('quantity', $request->quantity);
|
||||
} else {
|
||||
// Decrement but do not go below zero
|
||||
$decrement = min($request->quantity, $stockLevel->quantity);
|
||||
$stockLevel->decrement('quantity', $decrement);
|
||||
}
|
||||
|
||||
StockMovement::create([
|
||||
'item_id' => $request->item_id,
|
||||
'warehouse_id' => $request->warehouse_id,
|
||||
'type' => $request->type,
|
||||
'quantity' => $request->quantity,
|
||||
'notes' => $request->notes,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()->route('inventory.movements.index')->with('success', 'Stock movement recorded successfully.');
|
||||
}
|
||||
|
||||
public function show(StockMovement $stockMovement)
|
||||
{
|
||||
$stockMovement->load(['item', 'warehouse']);
|
||||
|
||||
return view('inventory.movements.show', compact('stockMovement'));
|
||||
}
|
||||
|
||||
public function edit(StockMovement $stockMovement)
|
||||
{
|
||||
return view('inventory.movements.edit', compact('stockMovement'));
|
||||
}
|
||||
|
||||
public function update(Request $request, StockMovement $stockMovement)
|
||||
{
|
||||
$request->validate([
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$stockMovement->update($request->only('notes'));
|
||||
|
||||
return redirect()->route('inventory.movements.index')->with('success', 'Stock movement updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(StockMovement $stockMovement)
|
||||
{
|
||||
$stockMovement->delete();
|
||||
|
||||
return redirect()->route('inventory.movements.index')->with('success', 'Stock movement deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Inventory;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Item;
|
||||
use App\Models\StockLevel;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StockReportController extends Controller
|
||||
{
|
||||
public function summary()
|
||||
{
|
||||
$stockLevels = StockLevel::with(['item', 'warehouse'])->get();
|
||||
|
||||
return view('inventory.reports.summary', compact('stockLevels'));
|
||||
}
|
||||
|
||||
public function movement(Request $request)
|
||||
{
|
||||
$query = StockMovement::with(['item', 'warehouse']);
|
||||
|
||||
if ($request->filled('from_date')) {
|
||||
$query->whereDate('created_at', '>=', $request->from_date);
|
||||
}
|
||||
|
||||
if ($request->filled('to_date')) {
|
||||
$query->whereDate('created_at', '<=', $request->to_date);
|
||||
}
|
||||
|
||||
if ($request->filled('item_id')) {
|
||||
$query->where('item_id', $request->item_id);
|
||||
}
|
||||
|
||||
$movements = $query->latest()->paginate(50)->withQueryString();
|
||||
$items = Item::orderBy('item_name')->get();
|
||||
|
||||
return view('inventory.reports.movement', compact('movements', 'items'));
|
||||
}
|
||||
|
||||
public function lowStock()
|
||||
{
|
||||
// Items whose total stock across all warehouses is below their minimum_stock_level
|
||||
$items = Item::withSum('stockLevels', 'quantity')
|
||||
->having(DB::raw('COALESCE(stock_levels_sum_quantity, 0)'), '<', DB::raw('minimum_stock_level'))
|
||||
->get();
|
||||
|
||||
return view('inventory.reports.low-stock', compact('items'));
|
||||
}
|
||||
|
||||
public function valuation()
|
||||
{
|
||||
$valuations = StockLevel::join('items', 'items.id', '=', 'stock_levels.item_id')
|
||||
->join('warehouses', 'warehouses.id', '=', 'stock_levels.warehouse_id')
|
||||
->select(
|
||||
'items.id as item_id',
|
||||
'items.item_name',
|
||||
'items.item_code',
|
||||
'warehouses.name as warehouse_name',
|
||||
'stock_levels.quantity',
|
||||
'items.cost_price',
|
||||
DB::raw('stock_levels.quantity * items.cost_price as valuation')
|
||||
)
|
||||
->get();
|
||||
|
||||
$totalValuation = $valuations->sum('valuation');
|
||||
|
||||
return view('inventory.reports.valuation', compact('valuations', 'totalValuation'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Inventory;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WarehouseController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$warehouses = Warehouse::paginate(15);
|
||||
|
||||
return view('inventory.warehouses.index', compact('warehouses'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('inventory.warehouses.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'location' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
Warehouse::create($request->all());
|
||||
|
||||
return redirect()->route('inventory.warehouses.index')->with('success', 'Warehouse created successfully.');
|
||||
}
|
||||
|
||||
public function show(Warehouse $warehouse)
|
||||
{
|
||||
return view('inventory.warehouses.show', compact('warehouse'));
|
||||
}
|
||||
|
||||
public function edit(Warehouse $warehouse)
|
||||
{
|
||||
return view('inventory.warehouses.edit', compact('warehouse'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'location' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$warehouse->update($request->all());
|
||||
|
||||
return redirect()->route('inventory.warehouses.index')->with('success', 'Warehouse updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Warehouse $warehouse)
|
||||
{
|
||||
$warehouse->delete();
|
||||
|
||||
return redirect()->route('inventory.warehouses.index')->with('success', 'Warehouse deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Production;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BillOfMaterial;
|
||||
use App\Models\Item;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BillOfMaterialController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$boms = BillOfMaterial::with(['product', 'rawMaterial'])
|
||||
->get()
|
||||
->groupBy('product_id');
|
||||
|
||||
return view('production.bom.index', compact('boms'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$finishedGoods = Item::where('category', 'finished_good')->get();
|
||||
$rawMaterials = Item::where('category', 'raw_material')->get();
|
||||
|
||||
return view('production.bom.create', compact('finishedGoods', 'rawMaterials'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'product_id' => 'required|exists:items,id',
|
||||
'raw_material_id' => 'required|exists:items,id',
|
||||
'quantity_required' => 'required|numeric|min:0.01',
|
||||
'unit_of_measure' => 'required|string|max:50',
|
||||
]);
|
||||
|
||||
BillOfMaterial::create($request->all());
|
||||
|
||||
return redirect()->route('production.bom.index')->with('success', 'BOM entry created successfully.');
|
||||
}
|
||||
|
||||
public function show(BillOfMaterial $billOfMaterial)
|
||||
{
|
||||
$billOfMaterial->load(['product', 'rawMaterial']);
|
||||
|
||||
return view('production.bom.show', compact('billOfMaterial'));
|
||||
}
|
||||
|
||||
public function edit(BillOfMaterial $billOfMaterial)
|
||||
{
|
||||
$finishedGoods = Item::where('category', 'finished_good')->get();
|
||||
$rawMaterials = Item::where('category', 'raw_material')->get();
|
||||
|
||||
return view('production.bom.edit', compact('billOfMaterial', 'finishedGoods', 'rawMaterials'));
|
||||
}
|
||||
|
||||
public function update(Request $request, BillOfMaterial $billOfMaterial)
|
||||
{
|
||||
$request->validate([
|
||||
'product_id' => 'required|exists:items,id',
|
||||
'raw_material_id' => 'required|exists:items,id',
|
||||
'quantity_required' => 'required|numeric|min:0.01',
|
||||
'unit_of_measure' => 'required|string|max:50',
|
||||
]);
|
||||
|
||||
$billOfMaterial->update($request->all());
|
||||
|
||||
return redirect()->route('production.bom.index')->with('success', 'BOM entry updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(BillOfMaterial $billOfMaterial)
|
||||
{
|
||||
$billOfMaterial->delete();
|
||||
|
||||
return redirect()->route('production.bom.index')->with('success', 'BOM entry deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Production;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Item;
|
||||
use App\Models\MaterialIssue;
|
||||
use App\Models\ProductionOrder;
|
||||
use App\Models\StockLevel;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MaterialIssueController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$issues = MaterialIssue::with(['productionOrder', 'item', 'warehouse'])->paginate(15);
|
||||
|
||||
return view('production.material-issues.index', compact('issues'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$productionOrders = ProductionOrder::whereIn('status', ['planned', 'in_progress'])->get();
|
||||
$items = Item::where('category', 'raw_material')->get();
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('production.material-issues.create', compact('productionOrders', 'items', 'warehouses'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'production_order_id' => 'required|exists:production_orders,id',
|
||||
'item_id' => 'required|exists:items,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'quantity' => 'required|numeric|min:0.01',
|
||||
'issue_date' => 'required|date',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($request) {
|
||||
$issueNumber = 'MI-' . str_pad(MaterialIssue::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$stockLevel = StockLevel::firstOrCreate(
|
||||
['item_id' => $request->item_id, 'warehouse_id' => $request->warehouse_id],
|
||||
['quantity' => 0]
|
||||
);
|
||||
|
||||
// Decrement stock, floor at zero
|
||||
$decrement = min($request->quantity, $stockLevel->quantity);
|
||||
$stockLevel->decrement('quantity', $decrement);
|
||||
|
||||
StockMovement::create([
|
||||
'item_id' => $request->item_id,
|
||||
'warehouse_id' => $request->warehouse_id,
|
||||
'type' => 'out',
|
||||
'quantity' => $request->quantity,
|
||||
'reference_type' => 'MaterialIssue',
|
||||
'reference_id' => null, // will be updated after create
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$issue = MaterialIssue::create([
|
||||
'issue_number' => $issueNumber,
|
||||
'production_order_id' => $request->production_order_id,
|
||||
'item_id' => $request->item_id,
|
||||
'warehouse_id' => $request->warehouse_id,
|
||||
'quantity' => $request->quantity,
|
||||
'issue_date' => $request->issue_date,
|
||||
'issued_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Backfill reference_id on the movement just created
|
||||
StockMovement::where('item_id', $request->item_id)
|
||||
->where('warehouse_id', $request->warehouse_id)
|
||||
->where('type', 'out')
|
||||
->where('reference_type', 'MaterialIssue')
|
||||
->whereNull('reference_id')
|
||||
->latest('id')
|
||||
->first()
|
||||
?->update(['reference_id' => $issue->id]);
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', 'Material issued successfully.');
|
||||
}
|
||||
|
||||
public function show(MaterialIssue $materialIssue)
|
||||
{
|
||||
$materialIssue->load(['productionOrder', 'item', 'warehouse']);
|
||||
|
||||
return view('production.material-issues.show', compact('materialIssue'));
|
||||
}
|
||||
|
||||
public function edit(MaterialIssue $materialIssue)
|
||||
{
|
||||
return view('production.material-issues.edit', compact('materialIssue'));
|
||||
}
|
||||
|
||||
public function update(Request $request, MaterialIssue $materialIssue)
|
||||
{
|
||||
$request->validate([
|
||||
'issue_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$materialIssue->update($request->only('issue_date', 'notes'));
|
||||
|
||||
return redirect()->route('production.material-issues.index')->with('success', 'Material issue updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(MaterialIssue $materialIssue)
|
||||
{
|
||||
$materialIssue->delete();
|
||||
|
||||
return redirect()->route('production.material-issues.index')->with('success', 'Material issue deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Production;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Item;
|
||||
use App\Models\ProductionCost;
|
||||
use App\Models\ProductionOrder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProductionOrderController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$orders = ProductionOrder::with('product')->paginate(15);
|
||||
|
||||
return view('production.orders.index', compact('orders'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
// Only finished_good type items can be produced
|
||||
$items = Item::where('category', 'finished_good')->get();
|
||||
|
||||
return view('production.orders.create', compact('items'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'product_id' => 'required|exists:items,id',
|
||||
'quantity_to_produce' => 'required|numeric|min:1',
|
||||
'production_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$data = $request->all();
|
||||
$data['order_number'] = 'PRO-' . str_pad(ProductionOrder::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
$data['created_by'] = auth()->id();
|
||||
$data['status'] = 'planned';
|
||||
|
||||
$order = ProductionOrder::create($data);
|
||||
|
||||
return redirect()->route('production.orders.show', $order)->with('success', 'Production order created successfully.');
|
||||
}
|
||||
|
||||
public function show(ProductionOrder $productionOrder)
|
||||
{
|
||||
$productionOrder->load(['product', 'materialIssues.item', 'outputs.item', 'cost']);
|
||||
|
||||
return view('production.orders.show', compact('productionOrder'));
|
||||
}
|
||||
|
||||
public function edit(ProductionOrder $productionOrder)
|
||||
{
|
||||
$items = Item::where('category', 'finished_good')->get();
|
||||
|
||||
return view('production.orders.edit', compact('productionOrder', 'items'));
|
||||
}
|
||||
|
||||
public function update(Request $request, ProductionOrder $productionOrder)
|
||||
{
|
||||
$request->validate([
|
||||
'production_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$productionOrder->update($request->only('production_date', 'notes'));
|
||||
|
||||
return redirect()->route('production.orders.show', $productionOrder)->with('success', 'Production order updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(ProductionOrder $productionOrder)
|
||||
{
|
||||
$productionOrder->delete();
|
||||
|
||||
return redirect()->route('production.orders.index')->with('success', 'Production order deleted successfully.');
|
||||
}
|
||||
|
||||
public function start(ProductionOrder $productionOrder)
|
||||
{
|
||||
$productionOrder->update(['status' => 'in_progress']);
|
||||
|
||||
return redirect()->back()->with('success', 'Production order started.');
|
||||
}
|
||||
|
||||
public function complete(ProductionOrder $productionOrder)
|
||||
{
|
||||
$productionOrder->update([
|
||||
'status' => 'completed',
|
||||
'completion_date' => now(),
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', 'Production order marked as completed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Production;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Item;
|
||||
use App\Models\ProductionOrder;
|
||||
use App\Models\ProductionOutput;
|
||||
use App\Models\StockLevel;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProductionOutputController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$outputs = ProductionOutput::with(['productionOrder', 'item', 'warehouse'])->paginate(15);
|
||||
|
||||
return view('production.outputs.index', compact('outputs'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$productionOrders = ProductionOrder::whereIn('status', ['in_progress'])->with('product')->get();
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('production.outputs.create', compact('productionOrders', 'warehouses'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'production_order_id' => 'required|exists:production_orders,id',
|
||||
'item_id' => 'required|exists:items,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'quantity' => 'required|numeric|min:0.01',
|
||||
'output_date' => 'required|date',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($request) {
|
||||
$stockLevel = StockLevel::firstOrCreate(
|
||||
['item_id' => $request->item_id, 'warehouse_id' => $request->warehouse_id],
|
||||
['quantity' => 0]
|
||||
);
|
||||
$stockLevel->increment('quantity', $request->quantity);
|
||||
|
||||
$output = ProductionOutput::create([
|
||||
'production_order_id' => $request->production_order_id,
|
||||
'item_id' => $request->item_id,
|
||||
'warehouse_id' => $request->warehouse_id,
|
||||
'quantity' => $request->quantity,
|
||||
'output_date' => $request->output_date,
|
||||
'recorded_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
StockMovement::create([
|
||||
'item_id' => $request->item_id,
|
||||
'warehouse_id' => $request->warehouse_id,
|
||||
'type' => 'in',
|
||||
'quantity' => $request->quantity,
|
||||
'reference_type' => 'ProductionOutput',
|
||||
'reference_id' => $output->id,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Accumulate quantity_produced on the production order
|
||||
ProductionOrder::where('id', $request->production_order_id)
|
||||
->increment('quantity_produced', $request->quantity);
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', 'Production output recorded and stock updated.');
|
||||
}
|
||||
|
||||
public function show(ProductionOutput $productionOutput)
|
||||
{
|
||||
$productionOutput->load(['productionOrder', 'item', 'warehouse']);
|
||||
|
||||
return view('production.outputs.show', compact('productionOutput'));
|
||||
}
|
||||
|
||||
public function edit(ProductionOutput $productionOutput)
|
||||
{
|
||||
return view('production.outputs.edit', compact('productionOutput'));
|
||||
}
|
||||
|
||||
public function update(Request $request, ProductionOutput $productionOutput)
|
||||
{
|
||||
$request->validate([
|
||||
'output_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$productionOutput->update($request->only('output_date', 'notes'));
|
||||
|
||||
return redirect()->route('production.outputs.index')->with('success', 'Production output updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(ProductionOutput $productionOutput)
|
||||
{
|
||||
$productionOutput->delete();
|
||||
|
||||
return redirect()->route('production.outputs.index')->with('success', 'Production output deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ProfileUpdateRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the user's profile form.
|
||||
*/
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
return view('profile.edit', [
|
||||
'user' => $request->user(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validateWithBag('userDeletion', [
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return Redirect::to('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GoodsReceiptNote;
|
||||
use App\Models\GrnItem;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\StockLevel;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GoodsReceiptNoteController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$grns = GoodsReceiptNote::with(['purchaseOrder.supplier', 'warehouse'])->paginate(15);
|
||||
|
||||
return view('purchase.grns.index', compact('grns'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$purchaseOrders = PurchaseOrder::whereIn('status', ['sent', 'partial'])->with('supplier')->get();
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('purchase.grns.create', compact('purchaseOrders', 'warehouses'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'purchase_order_id' => 'required|exists:purchase_orders,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'received_date' => 'required|date',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.item_id' => 'required|exists:items,id',
|
||||
'items.*.quantity' => 'required|numeric|min:1',
|
||||
]);
|
||||
|
||||
$grnNumber = 'GRN-' . str_pad(GoodsReceiptNote::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$po = PurchaseOrder::findOrFail($request->purchase_order_id);
|
||||
|
||||
$grn = GoodsReceiptNote::create([
|
||||
'grn_number' => $grnNumber,
|
||||
'purchase_order_id' => $request->purchase_order_id,
|
||||
'supplier_id' => $po->supplier_id,
|
||||
'warehouse_id' => $request->warehouse_id,
|
||||
'received_date' => $request->received_date,
|
||||
'status' => 'draft',
|
||||
'received_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
GrnItem::create([
|
||||
'goods_receipt_note_id' => $grn->id,
|
||||
'purchase_order_item_id' => $item['po_item_id'] ?? $po->items()->where('item_id', $item['item_id'])->first()?->id ?? 0,
|
||||
'item_id' => $item['item_id'],
|
||||
'quantity_received' => $item['quantity'],
|
||||
'unit_cost' => $item['unit_cost'] ?? 0,
|
||||
'type' => in_array($item['type'] ?? '', ['inventory', 'consumable']) ? $item['type'] : 'inventory',
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('purchase.grns.show', $grn)->with('success', 'GRN created successfully.');
|
||||
}
|
||||
|
||||
public function show(GoodsReceiptNote $grn)
|
||||
{
|
||||
$grn->load(['purchaseOrder.supplier', 'warehouse', 'items.item']);
|
||||
|
||||
return view('purchase.grns.show', compact('grn'));
|
||||
}
|
||||
|
||||
public function edit(GoodsReceiptNote $grn)
|
||||
{
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('purchase.grns.edit', compact('grn', 'warehouses'));
|
||||
}
|
||||
|
||||
public function update(Request $request, GoodsReceiptNote $grn)
|
||||
{
|
||||
$request->validate([
|
||||
'received_date' => 'required|date',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
]);
|
||||
|
||||
$grn->update($request->only('received_date', 'warehouse_id'));
|
||||
|
||||
return redirect()->route('purchase.grns.show', $grn)->with('success', 'GRN updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(GoodsReceiptNote $grn)
|
||||
{
|
||||
$grn->delete();
|
||||
|
||||
return redirect()->route('purchase.grns.index')->with('success', 'GRN deleted successfully.');
|
||||
}
|
||||
|
||||
public function confirm(GoodsReceiptNote $grn)
|
||||
{
|
||||
if ($grn->status === 'confirmed') {
|
||||
return redirect()->back()->with('error', 'GRN is already confirmed.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($grn) {
|
||||
$grn->load('items');
|
||||
|
||||
foreach ($grn->items as $grnItem) {
|
||||
$stockLevel = StockLevel::firstOrCreate(
|
||||
['item_id' => $grnItem->item_id, 'warehouse_id' => $grn->warehouse_id],
|
||||
['quantity' => 0]
|
||||
);
|
||||
$stockLevel->increment('quantity', $grnItem->quantity_received);
|
||||
|
||||
StockMovement::create([
|
||||
'item_id' => $grnItem->item_id,
|
||||
'warehouse_id' => $grn->warehouse_id,
|
||||
'type' => 'in',
|
||||
'quantity' => $grnItem->quantity_received,
|
||||
'reference_type' => 'GoodsReceiptNote',
|
||||
'reference_id' => $grn->id,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$grn->purchaseOrder->items()
|
||||
->where('item_id', $grnItem->item_id)
|
||||
->increment('quantity_received', $grnItem->quantity_received);
|
||||
}
|
||||
|
||||
$grn->update(['status' => 'confirmed']);
|
||||
|
||||
// Check if all PO items have been fully received
|
||||
$po = $grn->purchaseOrder->fresh(['items']);
|
||||
$allFullyReceived = $po->items->every(
|
||||
fn($poItem) => $poItem->quantity_received >= $poItem->quantity
|
||||
);
|
||||
|
||||
if ($allFullyReceived) {
|
||||
$po->update(['status' => 'received']);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', 'GRN confirmed and stock updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Item;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\PurchaseOrderItem;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PurchaseOrderController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$orders = PurchaseOrder::with('supplier')->paginate(15);
|
||||
|
||||
return view('purchase.orders.index', compact('orders'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$suppliers = Supplier::all();
|
||||
$items = Item::all();
|
||||
$purchaseRequests = PurchaseRequest::where('status', 'approved')->get();
|
||||
|
||||
return view('purchase.orders.create', compact('suppliers', 'items', 'purchaseRequests'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'supplier_id' => 'required|exists:suppliers,id',
|
||||
'po_date' => 'required|date',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.item_id' => 'required|exists:items,id',
|
||||
'items.*.quantity' => 'required|numeric|min:1',
|
||||
'items.*.rate' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$poNumber = 'PO-' . str_pad(PurchaseOrder::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$totalAmount = collect($request->items)->sum(fn($item) => $item['quantity'] * $item['rate']);
|
||||
|
||||
$order = PurchaseOrder::create([
|
||||
'po_number' => $poNumber,
|
||||
'supplier_id' => $request->supplier_id,
|
||||
'po_date' => $request->po_date,
|
||||
'total_amount' => $totalAmount,
|
||||
'status' => 'draft',
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
PurchaseOrderItem::create([
|
||||
'purchase_order_id' => $order->id,
|
||||
'item_id' => $item['item_id'],
|
||||
'quantity' => $item['quantity'],
|
||||
'rate' => $item['rate'],
|
||||
'amount' => $item['quantity'] * $item['rate'],
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('purchase.orders.show', $order)->with('success', 'Purchase order created successfully.');
|
||||
}
|
||||
|
||||
public function show(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$purchaseOrder->load(['supplier', 'items.item']);
|
||||
|
||||
return view('purchase.orders.show', compact('purchaseOrder'));
|
||||
}
|
||||
|
||||
public function edit(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$suppliers = Supplier::all();
|
||||
$items = Item::all();
|
||||
|
||||
return view('purchase.orders.edit', compact('purchaseOrder', 'suppliers', 'items'));
|
||||
}
|
||||
|
||||
public function update(Request $request, PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$request->validate([
|
||||
'supplier_id' => 'required|exists:suppliers,id',
|
||||
'po_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$purchaseOrder->update($request->only('supplier_id', 'po_date', 'status'));
|
||||
|
||||
return redirect()->route('purchase.orders.show', $purchaseOrder)->with('success', 'Purchase order updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$purchaseOrder->delete();
|
||||
|
||||
return redirect()->route('purchase.orders.index')->with('success', 'Purchase order deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\PurchaseStageService;
|
||||
|
||||
class PurchasePipelineController extends Controller
|
||||
{
|
||||
private function withRelations()
|
||||
{
|
||||
return PurchaseRequest::with([
|
||||
'requestedBy',
|
||||
'signature.signedBy',
|
||||
'rfqInvitations.supplier',
|
||||
'supplierQuotes',
|
||||
'awardedQuote.supplier',
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(PurchaseStageService $stages)
|
||||
{
|
||||
$active = $this->withRelations()->where('stage', '!=', 'complete')->latest()->get();
|
||||
$completed = $this->withRelations()->where('stage', 'complete')->latest()->get();
|
||||
|
||||
return view('purchase.pipeline.index', compact('active', 'completed', 'stages'));
|
||||
}
|
||||
|
||||
public function show(PurchaseRequest $purchaseRequest, PurchaseStageService $stages)
|
||||
{
|
||||
$purchaseRequest->load([
|
||||
'requestedBy',
|
||||
'items',
|
||||
'signature.signedBy',
|
||||
'rfqInvitations.supplier',
|
||||
'supplierQuotes',
|
||||
'awardedQuote.supplier',
|
||||
]);
|
||||
|
||||
$suppliers = Supplier::where('is_active', true)->orderBy('name')->get();
|
||||
|
||||
return view('purchase.pipeline.show', [
|
||||
'pr' => $purchaseRequest,
|
||||
'stages' => $stages,
|
||||
'suppliers' => $suppliers,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\PurchaseRequestItem;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PurchaseRequestController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$requests = PurchaseRequest::with(['requestedBy', 'items'])->latest()->paginate(15);
|
||||
|
||||
return view('purchase.requests.index', compact('requests'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('purchase.requests.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'date' => 'required|date',
|
||||
'project_name' => 'required|string|max:255',
|
||||
'department' => 'nullable|string|max:255',
|
||||
'requested_by_name' => 'required|string|max:255',
|
||||
'required_date_text' => 'nullable|string|max:100',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'remarks' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.description' => 'required|string',
|
||||
'items.*.unit' => 'nullable|string|max:50',
|
||||
'items.*.quantity_required' => 'required|numeric|min:0.01',
|
||||
'items.*.purpose_use' => 'nullable|string|max:255',
|
||||
'items.*.required_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($request) {
|
||||
$year = now()->format('y');
|
||||
$next = PurchaseRequest::max('id') + 1;
|
||||
$mprNumber = 'MPR' . $year . '-' . str_pad($next, 4, '0', STR_PAD_LEFT);
|
||||
|
||||
$pr = PurchaseRequest::create([
|
||||
'request_number' => $mprNumber,
|
||||
'date' => $request->date,
|
||||
'project_name' => $request->project_name,
|
||||
'department' => $request->department,
|
||||
'requested_by_name' => $request->requested_by_name,
|
||||
'required_date_text' => $request->required_date_text,
|
||||
'location' => $request->location,
|
||||
'remarks' => $request->remarks,
|
||||
'status' => 'pending',
|
||||
'requested_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
if (empty(trim($item['description']))) {
|
||||
continue;
|
||||
}
|
||||
PurchaseRequestItem::create([
|
||||
'purchase_request_id' => $pr->id,
|
||||
'description' => $item['description'],
|
||||
'unit' => $item['unit'] ?? null,
|
||||
'quantity_required' => $item['quantity_required'],
|
||||
'purpose_use' => $item['purpose_use'] ?? null,
|
||||
'required_date' => $item['required_date'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('purchase.requests.index')->with('success', 'Purchase request submitted successfully.');
|
||||
}
|
||||
|
||||
public function show(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->load(['items', 'requestedBy', 'approvedBy']);
|
||||
|
||||
return view('purchase.requests.show', compact('purchaseRequest'));
|
||||
}
|
||||
|
||||
public function edit(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->load('items');
|
||||
|
||||
return view('purchase.requests.edit', compact('purchaseRequest'));
|
||||
}
|
||||
|
||||
public function update(Request $request, PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$request->validate([
|
||||
'date' => 'required|date',
|
||||
'project_name' => 'required|string|max:255',
|
||||
'department' => 'nullable|string|max:255',
|
||||
'requested_by_name' => 'required|string|max:255',
|
||||
'required_date_text' => 'nullable|string|max:100',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'remarks' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.description' => 'required|string',
|
||||
'items.*.unit' => 'nullable|string|max:50',
|
||||
'items.*.quantity_required' => 'required|numeric|min:0.01',
|
||||
'items.*.purpose_use' => 'nullable|string|max:255',
|
||||
'items.*.required_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($request, $purchaseRequest) {
|
||||
$purchaseRequest->update([
|
||||
'date' => $request->date,
|
||||
'project_name' => $request->project_name,
|
||||
'department' => $request->department,
|
||||
'requested_by_name' => $request->requested_by_name,
|
||||
'required_date_text' => $request->required_date_text,
|
||||
'location' => $request->location,
|
||||
'remarks' => $request->remarks,
|
||||
]);
|
||||
|
||||
$purchaseRequest->items()->delete();
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
if (empty(trim($item['description']))) {
|
||||
continue;
|
||||
}
|
||||
PurchaseRequestItem::create([
|
||||
'purchase_request_id' => $purchaseRequest->id,
|
||||
'description' => $item['description'],
|
||||
'unit' => $item['unit'] ?? null,
|
||||
'quantity_required' => $item['quantity_required'],
|
||||
'purpose_use' => $item['purpose_use'] ?? null,
|
||||
'required_date' => $item['required_date'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('purchase.requests.show', $purchaseRequest)->with('success', 'Purchase request updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->delete();
|
||||
|
||||
return redirect()->route('purchase.requests.index')->with('success', 'Purchase request deleted.');
|
||||
}
|
||||
|
||||
public function approve(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->update([
|
||||
'status' => 'approved',
|
||||
'approved_by' => auth()->id(),
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', 'Purchase request approved.');
|
||||
}
|
||||
|
||||
public function reject(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->update(['status' => 'rejected']);
|
||||
|
||||
return redirect()->back()->with('success', 'Purchase request rejected.');
|
||||
}
|
||||
|
||||
public function print(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->load(['items', 'requestedBy', 'approvedBy']);
|
||||
|
||||
return view('purchase.requests.print', compact('purchaseRequest'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\PurchaseSignature;
|
||||
use App\Services\PurchaseStageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PurchaseSignatureController extends Controller
|
||||
{
|
||||
public function show(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->load('signature.signedBy');
|
||||
return view('purchase.signature.show', ['request' => $purchaseRequest]);
|
||||
}
|
||||
|
||||
public function store(Request $request, PurchaseRequest $purchaseRequest, PurchaseStageService $stages)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'signature_image' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if ($purchaseRequest->signature) {
|
||||
return back()->with('error', 'This request has already been signed.');
|
||||
}
|
||||
|
||||
PurchaseSignature::create([
|
||||
'purchase_request_id' => $purchaseRequest->id,
|
||||
'signed_by' => auth()->id(),
|
||||
'signature_image' => $validated['signature_image'],
|
||||
'signed_at' => now(),
|
||||
'ip_address' => $request->ip(),
|
||||
]);
|
||||
|
||||
$stages->advance($purchaseRequest);
|
||||
|
||||
return redirect()->route('purchase.pipeline.index')
|
||||
->with('success', 'Signature saved. Request moved to RFQ stage.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\PurchaseStageService;
|
||||
use App\Services\RfqInvitationService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class RfqController extends Controller
|
||||
{
|
||||
public function show(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$suppliers = Supplier::where('is_active', true)->orderBy('name')->get();
|
||||
$invitations = $purchaseRequest->rfqInvitations()->with('supplier', 'quote')->get();
|
||||
return view('purchase.rfq.show', ['request' => $purchaseRequest, 'suppliers' => $suppliers, 'invitations' => $invitations]);
|
||||
}
|
||||
|
||||
public function selectSuppliers(Request $request, PurchaseRequest $purchaseRequest, RfqInvitationService $service, PurchaseStageService $stages)
|
||||
{
|
||||
$mode = $request->input('mode', 'global');
|
||||
|
||||
$alreadySelected = $purchaseRequest->rfqInvitations()->pluck('supplier_id')->toArray();
|
||||
$added = 0;
|
||||
|
||||
if ($mode === 'by_item') {
|
||||
// item_suppliers[item_id][] = supplier_id → invert to supplier_id → [item_ids]
|
||||
$itemSuppliers = $request->input('item_suppliers', []);
|
||||
$supplierItems = [];
|
||||
foreach ($itemSuppliers as $itemId => $supplierIds) {
|
||||
foreach ((array) $supplierIds as $supplierId) {
|
||||
$supplierItems[$supplierId][] = (int) $itemId;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($supplierItems)) {
|
||||
return redirect()->back()->with('error', 'Please assign at least one supplier to an item.');
|
||||
}
|
||||
|
||||
foreach ($supplierItems as $supplierId => $itemIds) {
|
||||
if (in_array($supplierId, $alreadySelected)) {
|
||||
continue;
|
||||
}
|
||||
$channel = $request->input('channel_' . $supplierId, 'email');
|
||||
$supplier = Supplier::findOrFail($supplierId);
|
||||
$service->select($purchaseRequest, $supplier, $channel, $itemIds);
|
||||
$added++;
|
||||
}
|
||||
} else {
|
||||
$validated = $request->validate([
|
||||
'supplier_ids' => ['required', 'array', 'min:1'],
|
||||
'supplier_ids.*' => ['required', 'exists:suppliers,id'],
|
||||
]);
|
||||
|
||||
foreach ($validated['supplier_ids'] as $supplierId) {
|
||||
if (in_array($supplierId, $alreadySelected)) {
|
||||
continue;
|
||||
}
|
||||
$channel = $request->input('channel_' . $supplierId, 'email');
|
||||
$supplier = Supplier::findOrFail($supplierId);
|
||||
$service->select($purchaseRequest, $supplier, $channel);
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
|
||||
$stages->setStage($purchaseRequest, 'rfq');
|
||||
|
||||
return redirect()->route('purchase.pipeline.show', $purchaseRequest)
|
||||
->with('success', $added . ' supplier(s) added. Now send them the quote request links.');
|
||||
}
|
||||
|
||||
public function sendAll(PurchaseRequest $purchaseRequest, RfqInvitationService $service, PurchaseStageService $stages)
|
||||
{
|
||||
$pending = $purchaseRequest->rfqInvitations()->where('status', 'pending')->with('supplier')->get();
|
||||
|
||||
if ($pending->isEmpty()) {
|
||||
return redirect()->back()->with('error', 'No unsent invitations. Select suppliers first.');
|
||||
}
|
||||
|
||||
foreach ($pending as $invitation) {
|
||||
$service->sendInvitation($invitation);
|
||||
}
|
||||
|
||||
$stages->setStage($purchaseRequest, 'quoting');
|
||||
|
||||
return redirect()->route('purchase.pipeline.show', $purchaseRequest)
|
||||
->with('success', $pending->count() . ' supplier(s) notified. Waiting for quotes.');
|
||||
}
|
||||
|
||||
public function store(Request $request, PurchaseRequest $purchaseRequest, RfqInvitationService $service, PurchaseStageService $stages)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'supplier_ids' => ['required', 'array', 'min:1'],
|
||||
'supplier_ids.*' => ['required', 'exists:suppliers,id'],
|
||||
]);
|
||||
|
||||
$alreadyInvited = $purchaseRequest->rfqInvitations()->pluck('supplier_id')->toArray();
|
||||
|
||||
$sent = 0;
|
||||
foreach ($validated['supplier_ids'] as $supplierId) {
|
||||
if (in_array($supplierId, $alreadyInvited)) {
|
||||
continue;
|
||||
}
|
||||
$channel = $request->input('channel_' . $supplierId, 'both');
|
||||
$supplier = Supplier::findOrFail($supplierId);
|
||||
$service->invite($purchaseRequest, $supplier, $channel);
|
||||
$sent++;
|
||||
}
|
||||
|
||||
$stages->setStage($purchaseRequest, 'quoting');
|
||||
|
||||
return redirect()->route('purchase.requests.rfq', $purchaseRequest)
|
||||
->with('success', $sent . ' invitation(s) sent. Waiting for supplier quotes.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\RfqInvitation;
|
||||
use App\Models\SupplierQuote;
|
||||
use App\Models\SupplierQuoteItem;
|
||||
use App\Services\PurchaseStageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class RfqPortalController extends Controller
|
||||
{
|
||||
private function resolve(string $token): RfqInvitation
|
||||
{
|
||||
return RfqInvitation::where('token', $token)
|
||||
->with(['purchaseRequest.items', 'supplier'])
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
public function show(string $token)
|
||||
{
|
||||
$invitation = $this->resolve($token);
|
||||
|
||||
if ($invitation->isSubmitted()) {
|
||||
return view('rfq.submitted', compact('invitation'));
|
||||
}
|
||||
|
||||
if ($invitation->isExpired()) {
|
||||
return view('rfq.expired', compact('invitation'));
|
||||
}
|
||||
|
||||
if ($invitation->status === 'sent') {
|
||||
$invitation->update(['status' => 'opened', 'opened_at' => now()]);
|
||||
}
|
||||
|
||||
$purchaseRequest = $invitation->purchaseRequest;
|
||||
$items = $purchaseRequest->items;
|
||||
|
||||
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items'));
|
||||
}
|
||||
|
||||
public function submit(Request $request, string $token)
|
||||
{
|
||||
$invitation = $this->resolve($token);
|
||||
|
||||
if ($invitation->isSubmitted() || $invitation->isExpired()) {
|
||||
abort(403, 'This link is no longer valid.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'lead_time_days' => ['nullable', 'integer', 'min:0'],
|
||||
'payment_terms' => ['nullable', 'string', 'max:200'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
'items' => ['required', 'array'],
|
||||
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
|
||||
]);
|
||||
|
||||
$purchaseItems = $invitation->purchaseRequest->items;
|
||||
|
||||
$quote = SupplierQuote::create([
|
||||
'rfq_invitation_id' => $invitation->id,
|
||||
'purchase_request_id' => $invitation->purchase_request_id,
|
||||
'supplier_id' => $invitation->supplier_id,
|
||||
'submitted_at' => now(),
|
||||
'lead_time_days' => $validated['lead_time_days'],
|
||||
'payment_terms' => $validated['payment_terms'],
|
||||
'notes' => $validated['notes'],
|
||||
'total_amount' => 0,
|
||||
]);
|
||||
|
||||
$total = 0;
|
||||
foreach ($purchaseItems as $i => $item) {
|
||||
$unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0);
|
||||
$qty = (float)$item->quantity;
|
||||
$totalPrice = round($unitPrice * $qty, 3);
|
||||
$total += $totalPrice;
|
||||
|
||||
SupplierQuoteItem::create([
|
||||
'supplier_quote_id' => $quote->id,
|
||||
'description' => $item->description,
|
||||
'unit' => $item->unit ?? '',
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unitPrice,
|
||||
'total_price' => $totalPrice,
|
||||
]);
|
||||
}
|
||||
|
||||
$quote->update(['total_amount' => round($total, 3)]);
|
||||
$invitation->update(['status' => 'submitted']);
|
||||
|
||||
// If at least 1 quote is in, move to comparison stage
|
||||
$pr = $invitation->purchaseRequest;
|
||||
if ($pr->stage === 'quoting') {
|
||||
app(PurchaseStageService::class)->setStage($pr, 'comparison');
|
||||
}
|
||||
|
||||
return view('rfq.submitted', compact('invitation'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\SupplierImportService;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class SupplierController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$suppliers = Supplier::orderBy('name')->get();
|
||||
|
||||
$stats = [
|
||||
'total' => $suppliers->count(),
|
||||
'active' => $suppliers->where('is_active', true)->count(),
|
||||
'inactive' => $suppliers->where('is_active', false)->count(),
|
||||
'with_email' => $suppliers->filter(fn($s) => !empty($s->email))->count(),
|
||||
'categories' => $suppliers->whereNotNull('category')->groupBy('category')->count(),
|
||||
];
|
||||
|
||||
$categories = Supplier::whereNotNull('category')
|
||||
->where('category', '!=', '')
|
||||
->distinct()
|
||||
->orderBy('category')
|
||||
->pluck('category');
|
||||
|
||||
return view('purchase.suppliers.index', compact('suppliers', 'stats', 'categories'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('purchase.suppliers.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'credit_days' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
Supplier::create(array_merge(
|
||||
$request->only([
|
||||
'supplier_code', 'name', 'category', 'contact_person',
|
||||
'email', 'secondary_email', 'phone', 'phone2', 'whatsapp',
|
||||
'address', 'website', 'tax_number', 'credit_terms', 'credit_days', 'remarks',
|
||||
]),
|
||||
['is_active' => (bool) $request->input('is_active', 1)]
|
||||
));
|
||||
|
||||
return redirect()->route('purchase.suppliers.index')->with('success', 'Supplier created successfully.');
|
||||
}
|
||||
|
||||
public function show(Supplier $supplier)
|
||||
{
|
||||
return view('purchase.suppliers.show', compact('supplier'));
|
||||
}
|
||||
|
||||
public function edit(Supplier $supplier)
|
||||
{
|
||||
return view('purchase.suppliers.edit', compact('supplier'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Supplier $supplier)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'credit_days' => 'nullable|integer|min:0',
|
||||
'is_active' => 'nullable',
|
||||
]);
|
||||
|
||||
$supplier->update(array_merge(
|
||||
$request->only([
|
||||
'supplier_code', 'name', 'category', 'contact_person',
|
||||
'email', 'secondary_email', 'phone', 'phone2', 'whatsapp',
|
||||
'address', 'website', 'tax_number', 'credit_terms', 'credit_days', 'remarks',
|
||||
]),
|
||||
['is_active' => (bool) $request->input('is_active', 0)]
|
||||
));
|
||||
|
||||
return redirect()->route('purchase.suppliers.index')->with('success', 'Supplier updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Supplier $supplier)
|
||||
{
|
||||
$supplier->delete();
|
||||
|
||||
return redirect()->route('purchase.suppliers.index')->with('success', 'Supplier deleted successfully.');
|
||||
}
|
||||
|
||||
public function import(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls|max:10240',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = app(SupplierImportService::class)->import(
|
||||
$request->file('file')->getPathname()
|
||||
);
|
||||
|
||||
$msg = "Import complete: {$result['imported']} added, {$result['updated']} updated"
|
||||
. ($result['skipped'] > 0 ? ", {$result['skipped']} skipped." : '.');
|
||||
|
||||
return redirect()->route('purchase.suppliers.index')->with('success', $msg);
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->route('purchase.suppliers.index')
|
||||
->with('error', 'Import failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadTemplate()
|
||||
{
|
||||
$path = storage_path('app/suppliers_template.xlsx');
|
||||
|
||||
if (!file_exists($path)) {
|
||||
Artisan::call('suppliers:template');
|
||||
}
|
||||
|
||||
return response()->download($path, 'suppliers_import_template.xlsx');
|
||||
}
|
||||
|
||||
public function exportPdf()
|
||||
{
|
||||
$suppliers = Supplier::orderBy('name')->get();
|
||||
|
||||
$pdf = Pdf::loadView('purchase.suppliers.pdf', compact('suppliers'))
|
||||
->setPaper('a4', 'landscape');
|
||||
|
||||
return $pdf->download('suppliers_' . now()->format('Y-m-d') . '.pdf');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GoodsReceiptNote;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierInvoice;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SupplierInvoiceController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$invoices = SupplierInvoice::with('supplier')->paginate(15);
|
||||
|
||||
return view('purchase.invoices.index', compact('invoices'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$suppliers = Supplier::all();
|
||||
$purchaseOrders = PurchaseOrder::where('status', 'received')->with('supplier')->get();
|
||||
$grns = GoodsReceiptNote::where('status', 'confirmed')->with('purchaseOrder.supplier')->get();
|
||||
|
||||
return view('purchase.invoices.create', compact('suppliers', 'purchaseOrders', 'grns'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'supplier_id' => 'required|exists:suppliers,id',
|
||||
'invoice_number' => 'required|string|max:255',
|
||||
'invoice_date' => 'required|date',
|
||||
'subtotal' => 'required|numeric|min:0',
|
||||
'vat_amount' => 'required|numeric|min:0',
|
||||
'total_amount' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
SupplierInvoice::create(array_merge($request->all(), [
|
||||
'status' => 'unpaid',
|
||||
'paid_amount' => 0,
|
||||
]));
|
||||
|
||||
return redirect()->route('purchase.invoices.index')->with('success', 'Supplier invoice created successfully.');
|
||||
}
|
||||
|
||||
public function show(SupplierInvoice $supplierInvoice)
|
||||
{
|
||||
$supplierInvoice->load(['supplier', 'purchaseOrder']);
|
||||
|
||||
return view('purchase.invoices.show', compact('supplierInvoice'));
|
||||
}
|
||||
|
||||
public function edit(SupplierInvoice $supplierInvoice)
|
||||
{
|
||||
$suppliers = Supplier::all();
|
||||
|
||||
return view('purchase.invoices.edit', compact('supplierInvoice', 'suppliers'));
|
||||
}
|
||||
|
||||
public function update(Request $request, SupplierInvoice $supplierInvoice)
|
||||
{
|
||||
$request->validate([
|
||||
'supplier_id' => 'required|exists:suppliers,id',
|
||||
'invoice_number' => 'required|string|max:255',
|
||||
'invoice_date' => 'required|date',
|
||||
'subtotal' => 'required|numeric|min:0',
|
||||
'vat_amount' => 'required|numeric|min:0',
|
||||
'total_amount' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$supplierInvoice->update($request->all());
|
||||
|
||||
return redirect()->route('purchase.invoices.index')->with('success', 'Supplier invoice updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(SupplierInvoice $supplierInvoice)
|
||||
{
|
||||
$supplierInvoice->delete();
|
||||
|
||||
return redirect()->route('purchase.invoices.index')->with('success', 'Supplier invoice deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SupplierInvoice;
|
||||
use App\Models\SupplierPayment;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SupplierPaymentController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$payments = SupplierPayment::with('supplierInvoice.supplier')->paginate(15);
|
||||
|
||||
return view('purchase.payments.index', compact('payments'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$invoices = SupplierInvoice::whereIn('status', ['unpaid', 'partial'])->with('supplier')->get();
|
||||
|
||||
return view('purchase.payments.create', compact('invoices'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'supplier_invoice_id' => 'required|exists:supplier_invoices,id',
|
||||
'payment_date' => 'required|date',
|
||||
'amount' => 'required|numeric|min:0.01',
|
||||
'payment_method' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$payment = SupplierPayment::create(array_merge($request->all(), [
|
||||
'created_by' => auth()->id(),
|
||||
]));
|
||||
|
||||
// Refresh invoice and recompute paid status
|
||||
$invoice = SupplierInvoice::find($request->supplier_invoice_id);
|
||||
$newPaidAmount = $invoice->paid_amount + $request->amount;
|
||||
$status = $newPaidAmount >= $invoice->total_amount ? 'paid' : 'partial';
|
||||
|
||||
$invoice->update([
|
||||
'paid_amount' => $newPaidAmount,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
return redirect()->route('purchase.payments.index')->with('success', 'Payment recorded successfully.');
|
||||
}
|
||||
|
||||
public function show(SupplierPayment $supplierPayment)
|
||||
{
|
||||
$supplierPayment->load('supplierInvoice.supplier');
|
||||
|
||||
return view('purchase.payments.show', compact('supplierPayment'));
|
||||
}
|
||||
|
||||
public function edit(SupplierPayment $supplierPayment)
|
||||
{
|
||||
$invoices = SupplierInvoice::whereIn('status', ['unpaid', 'partial'])->with('supplier')->get();
|
||||
|
||||
return view('purchase.payments.edit', compact('supplierPayment', 'invoices'));
|
||||
}
|
||||
|
||||
public function update(Request $request, SupplierPayment $supplierPayment)
|
||||
{
|
||||
$request->validate([
|
||||
'payment_date' => 'required|date',
|
||||
'amount' => 'required|numeric|min:0.01',
|
||||
'payment_method' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$supplierPayment->update($request->all());
|
||||
|
||||
return redirect()->route('purchase.payments.index')->with('success', 'Payment updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(SupplierPayment $supplierPayment)
|
||||
{
|
||||
$supplierPayment->delete();
|
||||
|
||||
return redirect()->route('purchase.payments.index')->with('success', 'Payment deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\SupplierQuote;
|
||||
use App\Services\PurchaseStageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SupplierQuoteController extends Controller
|
||||
{
|
||||
public function index(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$quotes = $purchaseRequest->supplierQuotes()->with('supplier', 'items')->get();
|
||||
return view('purchase.quotes.index', ['request' => $purchaseRequest, 'quotes' => $quotes]);
|
||||
}
|
||||
|
||||
public function compare(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$quotes = $purchaseRequest->supplierQuotes()->with('supplier', 'items')->get();
|
||||
$items = $purchaseRequest->items;
|
||||
return view('purchase.quotes.compare', ['request' => $purchaseRequest, 'quotes' => $quotes, 'items' => $items]);
|
||||
}
|
||||
|
||||
public function award(Request $request, PurchaseRequest $purchaseRequest, SupplierQuote $quote, PurchaseStageService $stages)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'award_reason' => ['required', 'string', 'min:5'],
|
||||
]);
|
||||
|
||||
if ($purchaseRequest->awardedQuote) {
|
||||
return back()->with('error', 'A quote has already been awarded for this request.');
|
||||
}
|
||||
|
||||
$quote->update([
|
||||
'is_awarded' => true,
|
||||
'award_reason' => $validated['award_reason'],
|
||||
'awarded_at' => now(),
|
||||
'awarded_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$stages->setStage($purchaseRequest, 'lpo');
|
||||
|
||||
return redirect()->route('purchase.pipeline.index')
|
||||
->with('success', 'Quote awarded to ' . $quote->supplier->name . '. Ready to issue LPO.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CustomerController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$customers = Customer::paginate(15);
|
||||
|
||||
return view('sales.customers.index', compact('customers'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('sales.customers.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'address' => 'nullable|string',
|
||||
]);
|
||||
|
||||
Customer::create($request->all());
|
||||
|
||||
return redirect()->route('sales.customers.index')->with('success', 'Customer created successfully.');
|
||||
}
|
||||
|
||||
public function show(Customer $customer)
|
||||
{
|
||||
return view('sales.customers.show', compact('customer'));
|
||||
}
|
||||
|
||||
public function edit(Customer $customer)
|
||||
{
|
||||
return view('sales.customers.edit', compact('customer'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Customer $customer)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'address' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$customer->update($request->all());
|
||||
|
||||
return redirect()->route('sales.customers.index')->with('success', 'Customer updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Customer $customer)
|
||||
{
|
||||
$customer->delete();
|
||||
|
||||
return redirect()->route('sales.customers.index')->with('success', 'Customer deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\DeliveryNote;
|
||||
use App\Models\DeliveryNoteItem;
|
||||
use App\Models\SalesOrder;
|
||||
use App\Models\SalesOrderItem;
|
||||
use App\Models\StockLevel;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeliveryNoteController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$notes = DeliveryNote::with(['salesOrder.customer', 'warehouse'])->paginate(15);
|
||||
|
||||
return view('sales.delivery-notes.index', compact('notes'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$salesOrders = SalesOrder::where('status', 'confirmed')->with('customer')->get();
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('sales.delivery-notes.create', compact('salesOrders', 'warehouses'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'sales_order_id' => 'required|exists:sales_orders,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'delivery_date' => 'required|date',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.item_id' => 'required|exists:items,id',
|
||||
'items.*.quantity' => 'required|numeric|min:1',
|
||||
]);
|
||||
|
||||
$deliveryNumber = 'DN-' . str_pad(DeliveryNote::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$note = DeliveryNote::create([
|
||||
'delivery_number' => $deliveryNumber,
|
||||
'sales_order_id' => $request->sales_order_id,
|
||||
'warehouse_id' => $request->warehouse_id,
|
||||
'delivery_date' => $request->delivery_date,
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
$salesOrder = SalesOrder::with('items')->findOrFail($request->sales_order_id);
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
$soItem = $salesOrder->items->firstWhere('item_id', $item['item_id']);
|
||||
DeliveryNoteItem::create([
|
||||
'delivery_note_id' => $note->id,
|
||||
'sales_order_item_id' => $soItem?->id ?? 0,
|
||||
'item_id' => $item['item_id'],
|
||||
'quantity_delivered' => $item['quantity'],
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('sales.delivery-notes.show', $note)->with('success', 'Delivery note created successfully.');
|
||||
}
|
||||
|
||||
public function show(DeliveryNote $deliveryNote)
|
||||
{
|
||||
$deliveryNote->load(['salesOrder.customer', 'warehouse', 'items.item']);
|
||||
|
||||
return view('sales.delivery-notes.show', compact('deliveryNote'));
|
||||
}
|
||||
|
||||
public function edit(DeliveryNote $deliveryNote)
|
||||
{
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('sales.delivery-notes.edit', compact('deliveryNote', 'warehouses'));
|
||||
}
|
||||
|
||||
public function update(Request $request, DeliveryNote $deliveryNote)
|
||||
{
|
||||
$request->validate([
|
||||
'delivery_date' => 'required|date',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
]);
|
||||
|
||||
$deliveryNote->update($request->only('delivery_date', 'warehouse_id'));
|
||||
|
||||
return redirect()->route('sales.delivery-notes.show', $deliveryNote)->with('success', 'Delivery note updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(DeliveryNote $deliveryNote)
|
||||
{
|
||||
$deliveryNote->delete();
|
||||
|
||||
return redirect()->route('sales.delivery-notes.index')->with('success', 'Delivery note deleted successfully.');
|
||||
}
|
||||
|
||||
public function dispatch(DeliveryNote $deliveryNote)
|
||||
{
|
||||
DB::transaction(function () use ($deliveryNote) {
|
||||
$deliveryNote->load(['items', 'salesOrder.items']);
|
||||
|
||||
foreach ($deliveryNote->items as $dnItem) {
|
||||
$stockLevel = StockLevel::firstOrCreate(
|
||||
['item_id' => $dnItem->item_id, 'warehouse_id' => $deliveryNote->warehouse_id],
|
||||
['quantity' => 0]
|
||||
);
|
||||
|
||||
$decrement = min($dnItem->quantity_delivered, $stockLevel->quantity);
|
||||
$stockLevel->decrement('quantity', $decrement);
|
||||
|
||||
StockMovement::create([
|
||||
'item_id' => $dnItem->item_id,
|
||||
'warehouse_id' => $deliveryNote->warehouse_id,
|
||||
'type' => 'out',
|
||||
'quantity' => $dnItem->quantity_delivered,
|
||||
'reference_type' => 'DeliveryNote',
|
||||
'reference_id' => $deliveryNote->id,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
SalesOrderItem::where('sales_order_id', $deliveryNote->sales_order_id)
|
||||
->where('item_id', $dnItem->item_id)
|
||||
->increment('quantity_delivered', $dnItem->quantity_delivered);
|
||||
}
|
||||
|
||||
$deliveryNote->update([
|
||||
'status' => 'dispatched',
|
||||
'dispatched_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Check if all sales order items are fully delivered
|
||||
$salesOrder = $deliveryNote->salesOrder->fresh(['items']);
|
||||
$allFullyDelivered = $salesOrder->items->every(
|
||||
fn($soItem) => $soItem->quantity_delivered >= $soItem->quantity
|
||||
);
|
||||
|
||||
if ($allFullyDelivered) {
|
||||
$salesOrder->update(['status' => 'dispatched']);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', 'Delivery note dispatched and stock decremented.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\PaymentReceipt;
|
||||
use App\Models\SalesInvoice;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PaymentReceiptController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$receipts = PaymentReceipt::with('salesInvoice.customer')->paginate(15);
|
||||
|
||||
return view('sales.payments.index', compact('receipts'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$invoices = SalesInvoice::whereIn('status', ['unpaid', 'partial'])->with('customer')->get();
|
||||
$customers = Customer::all();
|
||||
|
||||
return view('sales.payments.create', compact('invoices', 'customers'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'sales_invoice_id' => 'required|exists:sales_invoices,id',
|
||||
'receipt_date' => 'required|date',
|
||||
'amount' => 'required|numeric|min:0.01',
|
||||
'payment_method' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$receipt = PaymentReceipt::create(array_merge($request->all(), [
|
||||
'created_by' => auth()->id(),
|
||||
]));
|
||||
|
||||
$invoice = SalesInvoice::find($request->sales_invoice_id);
|
||||
$newPaidAmount = $invoice->paid_amount + $request->amount;
|
||||
$status = $newPaidAmount >= $invoice->total_amount ? 'paid' : 'partial';
|
||||
|
||||
$invoice->update([
|
||||
'paid_amount' => $newPaidAmount,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
// Reduce the customer's outstanding balance by the amount received
|
||||
$invoice->customer()->decrement('outstanding_balance', $request->amount);
|
||||
|
||||
return redirect()->route('sales.payments.index')->with('success', 'Payment receipt recorded successfully.');
|
||||
}
|
||||
|
||||
public function show(PaymentReceipt $paymentReceipt)
|
||||
{
|
||||
$paymentReceipt->load('salesInvoice.customer');
|
||||
|
||||
return view('sales.payments.show', compact('paymentReceipt'));
|
||||
}
|
||||
|
||||
public function edit(PaymentReceipt $paymentReceipt)
|
||||
{
|
||||
return view('sales.payments.edit', compact('paymentReceipt'));
|
||||
}
|
||||
|
||||
public function update(Request $request, PaymentReceipt $paymentReceipt)
|
||||
{
|
||||
$request->validate([
|
||||
'receipt_date' => 'required|date',
|
||||
'payment_method' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$paymentReceipt->update($request->only('receipt_date', 'payment_method', 'notes'));
|
||||
|
||||
return redirect()->route('sales.payments.index')->with('success', 'Payment receipt updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(PaymentReceipt $paymentReceipt)
|
||||
{
|
||||
$paymentReceipt->delete();
|
||||
|
||||
return redirect()->route('sales.payments.index')->with('success', 'Payment receipt deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\SalesInvoice;
|
||||
use App\Models\SalesOrder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SalesInvoiceController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$invoices = SalesInvoice::with('customer')->paginate(15);
|
||||
|
||||
return view('sales.invoices.index', compact('invoices'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$salesOrders = SalesOrder::where('status', 'dispatched')->with('customer')->get();
|
||||
$customers = Customer::all();
|
||||
|
||||
return view('sales.invoices.create', compact('salesOrders', 'customers'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'sales_order_id' => 'required|exists:sales_orders,id',
|
||||
'customer_id' => 'required|exists:customers,id',
|
||||
'invoice_date' => 'required|date',
|
||||
'subtotal' => 'required|numeric|min:0',
|
||||
'vat_rate' => 'required|numeric|min:0',
|
||||
'vat_amount' => 'required|numeric|min:0',
|
||||
'total_amount' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$invoiceNumber = 'INV-' . str_pad(SalesInvoice::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
SalesInvoice::create(array_merge($request->all(), [
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'status' => 'unpaid',
|
||||
'paid_amount' => 0,
|
||||
'created_by' => auth()->id(),
|
||||
]));
|
||||
|
||||
SalesOrder::where('id', $request->sales_order_id)->update(['status' => 'invoiced']);
|
||||
|
||||
return redirect()->route('sales.invoices.index')->with('success', 'Invoice created successfully.');
|
||||
}
|
||||
|
||||
public function show(SalesInvoice $salesInvoice)
|
||||
{
|
||||
$salesInvoice->load(['customer', 'salesOrder', 'paymentReceipts']);
|
||||
|
||||
return view('sales.invoices.show', compact('salesInvoice'));
|
||||
}
|
||||
|
||||
public function edit(SalesInvoice $salesInvoice)
|
||||
{
|
||||
$customers = Customer::all();
|
||||
|
||||
return view('sales.invoices.edit', compact('salesInvoice', 'customers'));
|
||||
}
|
||||
|
||||
public function update(Request $request, SalesInvoice $salesInvoice)
|
||||
{
|
||||
$request->validate([
|
||||
'invoice_date' => 'required|date',
|
||||
'subtotal' => 'required|numeric|min:0',
|
||||
'vat_rate' => 'required|numeric|min:0',
|
||||
'vat_amount' => 'required|numeric|min:0',
|
||||
'total_amount' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$salesInvoice->update($request->all());
|
||||
|
||||
return redirect()->route('sales.invoices.show', $salesInvoice)->with('success', 'Invoice updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(SalesInvoice $salesInvoice)
|
||||
{
|
||||
$salesInvoice->delete();
|
||||
|
||||
return redirect()->route('sales.invoices.index')->with('success', 'Invoice deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Item;
|
||||
use App\Models\SalesOrder;
|
||||
use App\Models\SalesOrderItem;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SalesOrderController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$orders = SalesOrder::with('customer')->paginate(15);
|
||||
|
||||
return view('sales.orders.index', compact('orders'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$customers = Customer::all();
|
||||
$items = Item::where('category', 'finished_good')->get();
|
||||
|
||||
return view('sales.orders.create', compact('customers', 'items'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'customer_id' => 'required|exists:customers,id',
|
||||
'order_date' => 'required|date',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.item_id' => 'required|exists:items,id',
|
||||
'items.*.quantity' => 'required|numeric|min:1',
|
||||
'items.*.unit_price' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$orderNumber = 'SO-' . str_pad(SalesOrder::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
$totalAmount = collect($request->items)->sum(fn($item) => $item['quantity'] * $item['unit_price']);
|
||||
|
||||
$order = SalesOrder::create([
|
||||
'order_number' => $orderNumber,
|
||||
'customer_id' => $request->customer_id,
|
||||
'order_date' => $request->order_date,
|
||||
'total_amount' => $totalAmount,
|
||||
'status' => 'draft',
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
SalesOrderItem::create([
|
||||
'sales_order_id' => $order->id,
|
||||
'item_id' => $item['item_id'],
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unit_price'],
|
||||
'amount' => $item['quantity'] * $item['unit_price'],
|
||||
'quantity_delivered' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('sales.orders.show', $order)->with('success', 'Sales order created successfully.');
|
||||
}
|
||||
|
||||
public function show(SalesOrder $salesOrder)
|
||||
{
|
||||
$salesOrder->load(['customer', 'items.item', 'deliveryNotes', 'invoices']);
|
||||
|
||||
return view('sales.orders.show', compact('salesOrder'));
|
||||
}
|
||||
|
||||
public function edit(SalesOrder $salesOrder)
|
||||
{
|
||||
$customers = Customer::all();
|
||||
$items = Item::where('category', 'finished_good')->get();
|
||||
|
||||
return view('sales.orders.edit', compact('salesOrder', 'customers', 'items'));
|
||||
}
|
||||
|
||||
public function update(Request $request, SalesOrder $salesOrder)
|
||||
{
|
||||
$request->validate([
|
||||
'customer_id' => 'required|exists:customers,id',
|
||||
'order_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$salesOrder->update($request->only('customer_id', 'order_date', 'notes'));
|
||||
|
||||
return redirect()->route('sales.orders.show', $salesOrder)->with('success', 'Sales order updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(SalesOrder $salesOrder)
|
||||
{
|
||||
$salesOrder->delete();
|
||||
|
||||
return redirect()->route('sales.orders.index')->with('success', 'Sales order deleted successfully.');
|
||||
}
|
||||
|
||||
public function confirm(SalesOrder $salesOrder)
|
||||
{
|
||||
$salesOrder->update(['status' => 'confirmed']);
|
||||
|
||||
return redirect()->back()->with('success', 'Sales order confirmed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the login request is not rate limited.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event(new Lockout($this));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiting throttle key for the request.
|
||||
*/
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProfileUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'lowercase',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class)->ignore($this->user()->id),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user