This commit is contained in:
2026-11-02 08:08:05 +02:00
parent 13a6cb9922
commit 504b01b244
246 changed files with 24499 additions and 185 deletions
@@ -8,6 +8,9 @@ use App\Models\StockItem;
use App\Models\StockOperation;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\InventoryCounts;
use App\Services\InventoryLots;
use App\Services\InventoryReversals;
use App\Services\StockWorkflow;
use Brick\Math\BigDecimal;
use Illuminate\Http\JsonResponse;
@@ -15,6 +18,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -22,31 +26,47 @@ class StockOperationController extends Controller
{
public function index(Request $request, StockWorkflow $workflow): Response
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403);
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless($actor->hasFunctions([PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement, 'inventory_adjust_review']), 403);
$items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']);
$warehouses = Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']);
$balances = [];
$lotCounts = [];
foreach ($items as $item) {
foreach ($warehouses as $warehouse) {
$balances[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$workflow->balance($item->id, $warehouse->id)];
$balances[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$workflow->balance($item->id, $warehouse->id),
'lot_control' => app(InventoryLots::class)->summary($item->id, $warehouse->id)];
if ($actor->hasFunctions([PurchasingRole::Warehouse]) && app(InventoryLots::class)->setup($item->id, $warehouse->id) !== null) {
try {
$source = app(InventoryCounts::class)->source($item->id, $warehouse->id);
$lotCounts[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$source];
} catch (ValidationException) {
// The summary still reports the divergence; no count can use an invented baseline.
}
}
}
}
return Inertia::render('stock/operations', [
'correctedOperations' => collect(app(InventoryReversals::class)->corrections('operation'))->map(fn ($r) => $r->data['reference']),
'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances,
'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name']),
'users' => User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']),
'operations' => StockOperation::query()->latest('id')->paginate(20),
'counts' => StockOperation::query()->where('kind', 'count')->whereNotIn('id', StockOperation::query()->whereNotNull('count_id')->select('count_id'))->latest('id')->get(),
'canOperate' => $request->user()->hasFunctions([PurchasingRole::Warehouse]),
'canOperate' => $actor->hasFunctions([PurchasingRole::Warehouse]),
'canAdjust' => app(InventoryLots::class)->allows($actor, 'inventory_adjust_review'), 'lotCounts' => $lotCounts,
'equipment' => Equipment::query()->orderBy('code')->get(['id', 'code', 'description']),
]);
}
public function store(Request $request, StockWorkflow $workflow): JsonResponse|RedirectResponse
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Warehouse]), 403);
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless($actor->hasFunctions($request->input('kind') === 'adjustment' ? [PurchasingRole::Warehouse, 'inventory_adjust_review'] : [PurchasingRole::Warehouse]), 403);
$request->merge(['reference' => Str::upper(Str::squish($request->string('reference')->toString())), 'reason' => trim($request->string('reason')->toString()), 'destination' => trim($request->string('destination')->toString())]);
$tenant = $request->user()->tenant_id;
$tenant = $actor->tenant_id;
$data = $request->validate([
'kind' => ['required', Rule::in(['issue', 'transfer', 'count', 'adjustment'])],
'reference' => 'required|string|max:100', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|max:4000',
@@ -59,10 +79,31 @@ class StockOperationController extends Controller
'quantity' => ['exclude_if:kind,adjustment', 'required', 'regex:/^\d{1,9}(\.\d{1,3})?$/', $request->input('kind') === 'count' ? 'gte:0' : 'gt:0'],
'ledger_version' => 'exclude_unless:kind,count|required|integer|min:0',
'count_id' => ['exclude_unless:kind,adjustment', 'required', 'integer', Rule::exists('stock_operations', 'id')->where('tenant_id', $tenant)->where('kind', 'count')],
'lot_version' => 'exclude_unless:kind,count|nullable|string|size:64',
'lot_counts' => 'exclude_unless:kind,count|sometimes|array|max:500',
'lot_counts.*.lot_id' => 'nullable|integer|min:1', 'lot_counts.*.new_key' => 'nullable|uuid',
'lot_counts.*.quantity' => ['required', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/'],
'lot_counts.*.code' => 'nullable|string|max:100', 'lot_counts.*.expires_on' => 'nullable|date_format:Y-m-d', 'lot_counts.*.no_expiry' => 'sometimes|boolean',
]);
if (empty($data['equipment_id'])) {
unset($data['equipment_id']);
}
if (empty($data['lot_version'])) {
unset($data['lot_version']);
}
if (empty($data['lot_counts']) && empty($data['lot_version'])) {
unset($data['lot_counts']);
}
if (isset($data['lot_counts'])) {
$data['lot_counts'] = array_map(function (array $row) {
if (! empty($row['lot_id'])) {
$row['lot_id'] = (int) $row['lot_id'];
}
$row['quantity'] = (string) BigDecimal::of($row['quantity'])->toScale(3);
return $row;
}, $data['lot_counts']);
}
foreach (['responsible_id', 'warehouse_id', 'stock_item_id', 'destination_warehouse_id', 'ledger_version', 'count_id', 'equipment_id'] as $key) {
if (isset($data[$key])) {
$data[$key] = (int) $data[$key];
@@ -70,7 +111,7 @@ class StockOperationController extends Controller
}
$data['quantity'] = isset($data['quantity']) ? (string) BigDecimal::of($data['quantity'])->toScale(3) : '0.000';
ksort($data);
$operation = $workflow->record($request->user(), $data);
$operation = $workflow->record($actor, $data);
return $request->expectsJson() ? response()->json(['id' => $operation->id, 'created' => $operation->wasRecentlyCreated], $operation->wasRecentlyCreated ? 201 : 200) : back();
}