user()->can('manageCosts', $project), 403); $data = $request->validate([ 'revision_id' => ['required', 'integer'], 'basis' => ['required', Rule::in([ProjectCostSnapshot::BASIS_ESTIMATE, ProjectCostSnapshot::BASIS_ACTUAL])], 'currency' => ['required', 'string', 'size:3', Rule::in(ProjectCosting::ALLOWED_CURRENCIES)], 'batch_units' => ['required', 'integer', 'min:1', 'max:100000'], 'lines.*.confirmed_reference' => ['nullable', 'string', 'max:255'], 'source_reference' => ['nullable', 'string', 'max:255'], 'notes' => ['nullable', 'string'], 'lines' => ['required', 'array', 'min:1'], 'lines.*.category' => ['required', Rule::in(ProjectCostLine::CATEGORIES)], 'lines.*.description' => ['required', 'string', 'max:255'], 'lines.*.quantity' => ['required', 'string'], 'lines.*.quantity_unit' => ['required', 'string', 'max:16'], 'lines.*.unit_rate' => ['required', 'string'], 'lines.*.waste_quantity' => ['sometimes', 'string'], 'lines.*.units_per_parcel' => ['required_if:lines.*.category,shipping', 'nullable', 'integer', 'min:1'], 'lines.*.source_price_date' => ['nullable', 'date'], 'lines.*.source_reference' => ['nullable', 'string', 'max:255'], ]); if ($data['currency'] !== $project->currency) { throw ValidationException::withMessages(['currency' => 'Use the project currency; no currency conversion is implied.']); } $revision = $project->revisions()->find($data['revision_id']); abort_unless($revision, 404); try { $linesWithTotals = array_map(function (array $line) use ($data): array { $line['line_total'] = ProjectCosting::lineTotal( $line['category'], (string) $line['quantity'], $line['quantity_unit'], (string) $line['unit_rate'], (string) ($line['waste_quantity'] ?? '0'), ); if ($line['category'] === ProjectCostLine::CATEGORY_SHIPPING) { if ($data['basis'] === 'actual' && empty($line['source_reference'])) { throw new \InvalidArgumentException('Actual shipping needs a confirmed shipment or invoice reference.'); } $scaled = FixedPoint::toScaled($line['line_total'], 4); $allocated = FixedPoint::divideByInt( FixedPoint::multiply($scaled, (int) $data['batch_units']), (int) $line['units_per_parcel'], ); $line['line_total'] = FixedPoint::format($allocated, 4); } return $line; }, $data['lines']); $totals = ProjectCosting::aggregate($linesWithTotals); } catch (Throwable $exception) { throw ValidationException::withMessages(['lines' => $exception->getMessage()]); } $snapshot = DB::transaction(function () use ($request, $project, $revision, $data, $linesWithTotals, $totals): ProjectCostSnapshot { $snapshot = ProjectCostSnapshot::create([ 'project_id' => $project->id, 'revision_id' => $revision->id, 'currency' => $data['currency'], 'basis' => $data['basis'], 'batch_units' => $data['batch_units'], 'filament_total' => $totals['filament_total'], 'packaging_total' => $totals['packaging_total'], 'shipping_total' => $totals['shipping_total'], 'grand_total' => $totals['grand_total'], 'source_reference' => $data['source_reference'] ?? null, 'notes' => $data['notes'] ?? null, 'recorded_by' => $request->user()->id, 'recorded_at' => now(), ]); foreach ($linesWithTotals as $line) { ProjectCostLine::create([ 'cost_snapshot_id' => $snapshot->id, 'category' => $line['category'], 'description' => $line['description'], 'quantity' => $line['quantity'], 'quantity_unit' => $line['quantity_unit'], 'unit_rate' => $line['unit_rate'], 'waste_quantity' => $line['waste_quantity'] ?? '0', 'units_per_parcel' => $line['units_per_parcel'] ?? null, 'line_total' => $line['line_total'], 'source_price_date' => $line['source_price_date'] ?? null, 'source_reference' => $line['source_reference'] ?? null, ]); } ProjectEvent::record($project->id, 'cost_snapshot_recorded', $request->user()->id, ProjectCostSnapshot::class, $snapshot->id, [ 'basis' => $data['basis'], 'grand_total' => $totals['grand_total'], ]); return $snapshot; }); return redirect()->route('projects.show', $project)->with('status', 'Cost snapshot recorded.'); } }