Skip to content

Commit cf39c56

Browse files
Merge pull request #62 from Mes-Open/feature/inspection-plan-versioning
feat: version + publish lifecycle for inspection plans
2 parents 7696e62 + 3c9a1db commit cf39c56

21 files changed

Lines changed: 807 additions & 99 deletions

backend/app/Http/Controllers/Api/V1/InspectionPlanController.php

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44

55
use App\Http\Controllers\Controller;
66
use App\Models\InspectionPlan;
7+
use App\Services\Quality\InspectionPlanVersionService;
78
use Illuminate\Http\JsonResponse;
89
use Illuminate\Http\Request;
910

1011
class InspectionPlanController extends Controller
1112
{
13+
public function __construct(private InspectionPlanVersionService $versions) {}
14+
1215
public function index(Request $request): JsonResponse
1316
{
1417
$request->validate([
@@ -41,22 +44,51 @@ public function store(Request $request): JsonResponse
4144
{
4245
$validated = $this->validatedPayload($request);
4346

44-
$plan = InspectionPlan::create($validated);
47+
$plan = InspectionPlan::create([
48+
...$validated,
49+
'version' => 1,
50+
'published_at' => null,
51+
'root_id' => null,
52+
'is_active' => false,
53+
]);
4554

4655
return response()->json(['message' => __('Inspection plan created'), 'data' => $plan], 201);
4756
}
4857

58+
/**
59+
* Draft → update in place. Published → create the next draft version.
60+
*/
4961
public function update(Request $request, InspectionPlan $inspectionPlan): JsonResponse
5062
{
5163
$validated = $this->validatedPayload($request, $inspectionPlan->id);
5264

53-
$inspectionPlan->update($validated);
65+
if ($inspectionPlan->isDraft()) {
66+
$inspectionPlan->update($validated);
67+
68+
return response()->json(['message' => __('Inspection plan updated'), 'data' => $inspectionPlan->fresh()]);
69+
}
70+
71+
$newVersion = $this->versions->createNewVersion($inspectionPlan, $validated);
5472

55-
return response()->json(['message' => __('Inspection plan updated'), 'data' => $inspectionPlan->fresh()]);
73+
return response()->json([
74+
'message' => __('Created version :v as a draft from the published plan.', ['v' => $newVersion->version]),
75+
'data' => $newVersion,
76+
], 201);
77+
}
78+
79+
public function publish(InspectionPlan $inspectionPlan): JsonResponse
80+
{
81+
$this->versions->publish($inspectionPlan);
82+
83+
return response()->json(['message' => __('Inspection plan published'), 'data' => $inspectionPlan->fresh()]);
5684
}
5785

5886
public function destroy(InspectionPlan $inspectionPlan): JsonResponse
5987
{
88+
if ($inspectionPlan->isPublished() && $inspectionPlan->inspections()->exists()) {
89+
return response()->json(['message' => __('Cannot delete a published version that has recorded inspections.')], 422);
90+
}
91+
6092
$inspectionPlan->delete();
6193

6294
return response()->json(['message' => __('Inspection plan deleted')]);
@@ -76,7 +108,6 @@ private function validatedPayload(Request $request, ?int $id = null): array
76108
'criteria.*.unit' => 'nullable|string|max:30',
77109
'criteria.*.spec_min' => 'nullable|numeric',
78110
'criteria.*.spec_max' => 'nullable|numeric',
79-
'is_active' => 'nullable|boolean',
80111
]);
81112

82113
// Exactly-one rule: either tie to a material, a material_type, or be generic.

backend/app/Http/Controllers/Web/Admin/InspectionPlanController.php

Lines changed: 68 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
namespace App\Http\Controllers\Web\Admin;
44

55
use App\Http\Controllers\Controller;
6+
use App\Http\Requests\InspectionPlanRequest;
67
use App\Models\InspectionPlan;
78
use App\Models\Material;
89
use App\Models\MaterialType;
9-
use Illuminate\Http\Request;
10+
use App\Services\Quality\InspectionPlanVersionService;
1011
use Inertia\Inertia;
1112

1213
class InspectionPlanController extends Controller
1314
{
15+
public function __construct(private InspectionPlanVersionService $versions) {}
16+
1417
public function index()
1518
{
1619
return Inertia::render('admin/inspection-plans/Index', [
@@ -32,82 +35,95 @@ public function create()
3235
return Inertia::render('admin/inspection-plans/Create', $this->formData());
3336
}
3437

35-
public function store(Request $request)
38+
/**
39+
* Create a brand-new plan as version 1 — a draft until published.
40+
*/
41+
public function store(InspectionPlanRequest $request)
3642
{
37-
$validated = $this->validated($request);
38-
InspectionPlan::create($validated);
43+
InspectionPlan::create([
44+
...$request->payload(),
45+
'version' => 1,
46+
'published_at' => null,
47+
'root_id' => null,
48+
'is_active' => false,
49+
]);
3950

40-
return redirect()->route('admin.inspection-plans.index')->with('success', __('Inspection plan created.'));
51+
return redirect()->route('admin.inspection-plans.index')
52+
->with('success', __('Inspection plan created as a draft. Publish it to use it for inspections.'));
4153
}
4254

4355
public function edit(InspectionPlan $inspectionPlan)
4456
{
45-
// Derive scope from which target FK is set (scope isn't a stored column).
46-
$scope = $inspectionPlan->material_id ? 'material' : ($inspectionPlan->material_type_id ? 'material_type' : 'generic');
57+
$scope = $inspectionPlan->material_id ? 'material'
58+
: ($inspectionPlan->material_type_id ? 'material_type' : 'generic');
59+
60+
$history = $inspectionPlan->versionGroup()
61+
->get(['id', 'version', 'published_at', 'is_active', 'updated_at'])
62+
->map(fn ($v) => [
63+
'id' => $v->id,
64+
'version' => $v->version,
65+
'is_draft' => $v->published_at === null,
66+
'is_active' => (bool) $v->is_active,
67+
'published_at' => $v->published_at?->toIso8601String(),
68+
'updated_at' => $v->updated_at?->toIso8601String(),
69+
]);
4770

4871
return Inertia::render('admin/inspection-plans/Edit', array_merge($this->formData(), [
4972
'plan' => [
50-
...$inspectionPlan->only('id', 'name', 'description', 'material_id', 'material_type_id', 'criteria', 'is_active'),
73+
...$inspectionPlan->only('id', 'name', 'description', 'material_id', 'material_type_id', 'criteria', 'is_active', 'version'),
5174
'scope' => $scope,
75+
'is_draft' => $inspectionPlan->isDraft(),
76+
'published_at' => $inspectionPlan->published_at?->toIso8601String(),
5277
],
78+
'history' => $history,
5379
]));
5480
}
5581

56-
public function update(Request $request, InspectionPlan $inspectionPlan)
82+
/**
83+
* Draft → edit in place. Published → spawn the next draft version
84+
* (the published version stays immutable for reproducibility).
85+
*/
86+
public function update(InspectionPlanRequest $request, InspectionPlan $inspectionPlan)
5787
{
58-
$inspectionPlan->update($this->validated($request));
88+
if ($inspectionPlan->isDraft()) {
89+
$inspectionPlan->update($request->payload());
5990

60-
return redirect()->route('admin.inspection-plans.index')->with('success', __('Inspection plan updated.'));
61-
}
91+
return redirect()->route('admin.inspection-plans.index')
92+
->with('success', __('Draft updated.'));
93+
}
6294

63-
public function destroy(InspectionPlan $inspectionPlan)
64-
{
65-
$inspectionPlan->delete();
95+
$newVersion = $this->versions->createNewVersion($inspectionPlan, $request->payload());
6696

67-
return redirect()->route('admin.inspection-plans.index')->with('success', __('Inspection plan deleted.'));
97+
return redirect()->route('admin.inspection-plans.edit', $newVersion)
98+
->with('success', __('Created version :v as a draft from the published plan.', ['v' => $newVersion->version]));
6899
}
69100

70-
private function validated(Request $request): array
101+
/**
102+
* Publish a draft — makes it the live version and retires the previous one.
103+
*/
104+
public function publish(InspectionPlan $inspectionPlan)
71105
{
72-
$validated = $request->validate([
73-
'name' => 'required|string|max:150',
74-
'description' => 'nullable|string',
75-
'scope' => 'required|string|in:material,material_type,generic',
76-
'material_id' => 'nullable|integer|exists:materials,id',
77-
'material_type_id' => 'nullable|integer|exists:material_types,id',
78-
'criteria' => 'required|array|min:1',
79-
'criteria.*.name' => 'required|string|max:150',
80-
'criteria.*.type' => 'required|string|in:visual,measurement,functional,pass_fail',
81-
'criteria.*.required' => 'nullable|boolean',
82-
'criteria.*.unit' => 'nullable|string|max:30',
83-
'criteria.*.spec_min' => 'nullable|numeric',
84-
'criteria.*.spec_max' => 'nullable|numeric',
85-
'is_active' => 'nullable|boolean',
86-
]);
87-
88-
// Enforce scope coherence based on the radio choice.
89-
$scope = $validated['scope'];
90-
if ($scope === 'material') {
91-
abort_unless($validated['material_id'] ?? null, 422, 'Pick a material when scope = material.');
92-
$validated['material_type_id'] = null;
93-
} elseif ($scope === 'material_type') {
94-
abort_unless($validated['material_type_id'] ?? null, 422, 'Pick a material type when scope = material_type.');
95-
$validated['material_id'] = null;
96-
} else {
97-
$validated['material_id'] = null;
98-
$validated['material_type_id'] = null;
106+
if ($inspectionPlan->isPublished()) {
107+
return back()->with('error', __('This version is already published.'));
99108
}
100109

101-
unset($validated['scope']);
102-
$validated['is_active'] = $validated['is_active'] ?? false;
110+
$this->versions->publish($inspectionPlan);
103111

104-
// Normalize criteria booleans (HTML form sends nothing when unchecked).
105-
$validated['criteria'] = array_map(function ($c) {
106-
$c['required'] = isset($c['required']) ? (bool) $c['required'] : false;
112+
return redirect()->route('admin.inspection-plans.index')
113+
->with('success', __('Inspection plan version :v published.', ['v' => $inspectionPlan->version]));
114+
}
107115

108-
return $c;
109-
}, $validated['criteria']);
116+
public function destroy(InspectionPlan $inspectionPlan)
117+
{
118+
// Published versions that have been used by inspections must stay for
119+
// historical reproducibility.
120+
if ($inspectionPlan->isPublished() && $inspectionPlan->inspections()->exists()) {
121+
return back()->with('error', __('Cannot delete a published version that has recorded inspections.'));
122+
}
123+
124+
$inspectionPlan->delete();
110125

111-
return $validated;
126+
return redirect()->route('admin.inspection-plans.index')
127+
->with('success', __('Inspection plan deleted.'));
112128
}
113129
}

backend/app/Http/Controllers/Web/InspectionController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public function create()
5858
{
5959
return Inertia::render('inspections/Create', [
6060
'materials' => Material::orderBy('name')->get(['id', 'code', 'name']),
61-
'plans' => InspectionPlan::active()->orderBy('name')->with(['material:id,name', 'materialType:id,name'])->get(),
61+
'plans' => InspectionPlan::active()->published()->orderBy('name')->with(['material:id,name', 'materialType:id,name'])->get(),
6262
]);
6363
}
6464

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
namespace App\Http\Requests;
4+
5+
use Illuminate\Foundation\Http\FormRequest;
6+
7+
/**
8+
* Validation for the admin inspection-plan form. `scope` is a form-only control
9+
* that decides which target FK is persisted (material / material_type / generic).
10+
*/
11+
class InspectionPlanRequest extends FormRequest
12+
{
13+
public function authorize(): bool
14+
{
15+
return true; // route is behind auth + role:Admin
16+
}
17+
18+
public function rules(): array
19+
{
20+
return [
21+
'name' => ['required', 'string', 'max:150'],
22+
'description' => ['nullable', 'string'],
23+
'scope' => ['required', 'string', 'in:material,material_type,generic'],
24+
'material_id' => ['nullable', 'integer', 'exists:materials,id'],
25+
'material_type_id' => ['nullable', 'integer', 'exists:material_types,id'],
26+
'criteria' => ['required', 'array', 'min:1'],
27+
'criteria.*.name' => ['required', 'string', 'max:150'],
28+
'criteria.*.type' => ['required', 'string', 'in:visual,measurement,functional,pass_fail'],
29+
'criteria.*.required' => ['nullable', 'boolean'],
30+
'criteria.*.unit' => ['nullable', 'string', 'max:30'],
31+
'criteria.*.spec_min' => ['nullable', 'numeric'],
32+
'criteria.*.spec_max' => ['nullable', 'numeric'],
33+
'is_active' => ['nullable', 'boolean'],
34+
];
35+
}
36+
37+
public function withValidator($validator): void
38+
{
39+
$validator->after(function ($validator) {
40+
$scope = $this->input('scope');
41+
if ($scope === 'material' && ! $this->filled('material_id')) {
42+
$validator->errors()->add('material_id', __('Pick a material when scope is "material".'));
43+
}
44+
if ($scope === 'material_type' && ! $this->filled('material_type_id')) {
45+
$validator->errors()->add('material_type_id', __('Pick a material type when scope is "material type".'));
46+
}
47+
48+
foreach ($this->input('criteria', []) as $i => $c) {
49+
if (($c['type'] ?? null) === 'measurement'
50+
&& isset($c['spec_min'], $c['spec_max'])
51+
&& $c['spec_min'] !== '' && $c['spec_max'] !== ''
52+
&& (float) $c['spec_min'] > (float) $c['spec_max']) {
53+
$validator->errors()->add("criteria.$i.spec_min", __('Min cannot exceed max.'));
54+
}
55+
}
56+
});
57+
}
58+
59+
/**
60+
* Normalized, persistable payload (scope resolved to FKs, criteria booleans
61+
* coerced). Does NOT decide draft/publish state — the controller does.
62+
*/
63+
public function payload(): array
64+
{
65+
$data = $this->validated();
66+
$scope = $data['scope'];
67+
68+
if ($scope === 'material') {
69+
$data['material_type_id'] = null;
70+
} elseif ($scope === 'material_type') {
71+
$data['material_id'] = null;
72+
} else {
73+
$data['material_id'] = null;
74+
$data['material_type_id'] = null;
75+
}
76+
unset($data['scope']);
77+
78+
$data['criteria'] = array_map(function ($c) {
79+
$c['required'] = ! empty($c['required']);
80+
81+
return $c;
82+
}, $data['criteria']);
83+
84+
// is_active is owned by the publish lifecycle, never by the edit form.
85+
unset($data['is_active']);
86+
87+
return $data;
88+
}
89+
}

backend/app/Models/Inspection.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,27 @@ class Inspection extends Model
1414
use HasTenant;
1515

1616
public const STATUS_PENDING = 'pending';
17+
1718
public const STATUS_PASS = 'pass';
19+
1820
public const STATUS_FAIL = 'fail';
21+
1922
public const STATUS_CONDITIONAL = 'conditional_pass';
2023

2124
public const DISPOSITION_PENDING = 'pending';
25+
2226
public const DISPOSITION_ACCEPT = 'accept';
27+
2328
public const DISPOSITION_ACCEPT_WITH_DEVIATION = 'accept_with_deviation';
29+
2430
public const DISPOSITION_REWORK = 'rework';
31+
2532
public const DISPOSITION_SCRAP = 'scrap';
33+
2634
public const DISPOSITION_RETURN_TO_SUPPLIER = 'return_to_supplier';
35+
2736
public const DISPOSITION_QUARANTINE = 'quarantine';
37+
2838
public const DISPOSITION_REJECT = 'reject';
2939

3040
public const DISPOSITIONS = [
@@ -40,6 +50,7 @@ class Inspection extends Model
4050

4151
protected $fillable = [
4252
'inspection_plan_id',
53+
'plan_version',
4354
'material_id',
4455
'lot_number',
4556
'supplier_lot_ref',

0 commit comments

Comments
 (0)