Files
autocrm/database/migrations/2026_05_27_140000_create_warehouse_tables.php
Vasyka 426156fe45 Stage 5.1 — Warehouse ERP: batches + FIFO + reservations + multi-warehouse
Schema:
- warehouses (multi-warehouse, code unique per company, is_default)
- part_batches (lot per receipt, qty_in/qty_remaining, buy_price, FIFO-indexed)
- warehouse_events (immutable ledger: opening/receipt/issue/transfer/adjustment/write_off)
- part_reservations (per-WO allocations from specific batches, active/consumed/released)
- companies.default_warehouse_id + parts.qty_reserved

Backfill: 1 default warehouse + 1 opening batch per existing part per company.

WarehouseService:
- receive / issue (FIFO) / reserve / release / consume / transfer / adjust
- DB::transaction + lockForUpdate on batch rows
- InsufficientStockException with requested + available context
- Auto-syncs parts.qty as aggregate cache (source of truth = sum(qty_remaining))

WO integration:
- WorkOrderPart created/updated → reserve from FIFO batches
- WorkOrderPart deleted → release
- WorkOrder status=done → consume reservations into issue events
- WorkOrder status=cancelled → release reservations

Filament:
- WarehouseResource (CRUD)
- BatchesRelationManager on PartResource (FIFO list with qty_remaining + cost)
- "Recepție" action on parts list → calls WarehouseService::receive
- qty_reserved column added on parts list

Tests (8 new, all pass):
- receipt creates batch + event
- FIFO order verified across 3 batches with different received_at
- InsufficientStockException on over-issue
- Reservations block other reservations but don't deplete on-hand
- WO done consumes; WO cancelled releases
- Batches tenant-isolated
- Transfer between warehouses with weighted-avg cost

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:29:19 +00:00

164 lines
7.1 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use App\Models\Central\Company;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('warehouses', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
$t->string('code', 32);
$t->string('name', 120);
$t->string('address')->nullable();
$t->boolean('is_default')->default(false);
$t->boolean('is_active')->default(true);
$t->timestamps();
$t->softDeletes();
$t->unique(['company_id', 'code']);
$t->index(['company_id', 'is_active']);
});
Schema::create('part_batches', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
$t->foreignId('part_id')->constrained()->cascadeOnDelete();
$t->foreignId('warehouse_id')->constrained()->cascadeOnDelete();
$t->foreignId('supplier_id')->nullable()->constrained()->nullOnDelete();
$t->string('batch_ref', 64)->nullable(); // PO number / invoice ref
$t->decimal('qty_in', 12, 3); // initial intake
$t->decimal('qty_remaining', 12, 3); // current available
$t->decimal('buy_price', 12, 2); // unit cost for this batch
$t->dateTime('received_at');
$t->text('notes')->nullable();
$t->timestamps();
// FIFO consume = ORDER BY received_at ASC, id ASC
$t->index(['company_id', 'part_id', 'warehouse_id', 'received_at']);
$t->index(['company_id', 'qty_remaining']);
});
Schema::create('warehouse_events', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
$t->foreignId('part_id')->constrained()->cascadeOnDelete();
$t->foreignId('batch_id')->nullable()->constrained('part_batches')->nullOnDelete();
$t->foreignId('warehouse_id')->constrained()->cascadeOnDelete();
$t->string('type', 24); // opening / receipt / issue / transfer_out / transfer_in / adjustment / write_off / return
$t->decimal('qty_delta', 12, 3); // signed: + intake, issue
$t->decimal('unit_cost', 12, 2)->nullable();
$t->nullableMorphs('ref'); // ref_type/ref_id — link la WorkOrder/Purchase/Transfer/Inventory
$t->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$t->dateTime('occurred_at');
$t->text('notes')->nullable();
$t->timestamps();
$t->index(['company_id', 'part_id', 'occurred_at']);
$t->index(['company_id', 'warehouse_id', 'occurred_at']);
$t->index(['company_id', 'type']);
});
Schema::create('part_reservations', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
$t->foreignId('work_order_id')->constrained()->cascadeOnDelete();
$t->foreignId('work_order_part_id')->nullable()->constrained('wo_parts')->cascadeOnDelete();
$t->foreignId('part_id')->constrained()->cascadeOnDelete();
$t->foreignId('batch_id')->constrained('part_batches')->cascadeOnDelete();
$t->decimal('qty', 12, 3);
$t->string('status', 16)->default('active'); // active / consumed / released
$t->dateTime('reserved_at');
$t->dateTime('consumed_at')->nullable();
$t->timestamps();
$t->index(['company_id', 'work_order_id']);
$t->index(['company_id', 'part_id', 'status']);
$t->index(['company_id', 'batch_id', 'status']);
});
Schema::table('companies', function (Blueprint $t) {
$t->foreignId('default_warehouse_id')->nullable()->after('plan_id')
->constrained('warehouses')->nullOnDelete();
});
Schema::table('parts', function (Blueprint $t) {
$t->decimal('qty_reserved', 12, 3)->default(0)->after('qty');
});
// ── Backfill ──────────────────────────────────────────────────
// For each existing company: create 1 default warehouse + open
// one batch per part with the current parts.qty as opening stock.
$now = now();
foreach (Company::withoutGlobalScopes()->cursor() as $company) {
$whId = DB::table('warehouses')->insertGetId([
'company_id' => $company->id,
'code' => 'MAIN',
'name' => 'Depozit principal',
'is_default' => true,
'is_active' => true,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('companies')->where('id', $company->id)
->update(['default_warehouse_id' => $whId]);
$parts = DB::table('parts')
->where('company_id', $company->id)
->whereNull('deleted_at')
->where('qty', '>', 0)
->get();
foreach ($parts as $p) {
$batchId = DB::table('part_batches')->insertGetId([
'company_id' => $company->id,
'part_id' => $p->id,
'warehouse_id' => $whId,
'qty_in' => $p->qty,
'qty_remaining' => $p->qty,
'buy_price' => $p->buy_price,
'received_at' => $p->created_at ?: $now,
'notes' => 'Stoc inițial (migrare)',
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('warehouse_events')->insert([
'company_id' => $company->id,
'part_id' => $p->id,
'batch_id' => $batchId,
'warehouse_id' => $whId,
'type' => 'opening',
'qty_delta' => $p->qty,
'unit_cost' => $p->buy_price,
'occurred_at' => $p->created_at ?: $now,
'notes' => 'Stoc inițial la migrare',
'created_at' => $now,
'updated_at' => $now,
]);
}
}
}
public function down(): void
{
Schema::table('parts', function (Blueprint $t) {
$t->dropColumn('qty_reserved');
});
Schema::table('companies', function (Blueprint $t) {
$t->dropForeign(['default_warehouse_id']);
$t->dropColumn('default_warehouse_id');
});
Schema::dropIfExists('part_reservations');
Schema::dropIfExists('warehouse_events');
Schema::dropIfExists('part_batches');
Schema::dropIfExists('warehouses');
}
};