Teren-app/app/Http/Controllers/Admin/DocumentTemplateController.php
2025-10-12 17:52:17 +02:00

403 lines
16 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
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreDocumentTemplateRequest;
use App\Http\Requests\UpdateDocumentTemplateRequest;
use App\Models\Action;
use App\Models\DocumentTemplate;
use App\Services\Documents\TokenScanner;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Inertia\Inertia;
class DocumentTemplateController extends Controller
{
public function index()
{
$this->ensurePermission();
$templates = DocumentTemplate::query()->orderByDesc('updated_at')->get();
$actions = Action::with(['decisions:id,name'])->orderBy('name')->get(['id', 'name']);
$actionsMapped = $actions->map(fn ($a) => [
'id' => $a->id,
'name' => $a->name,
'decisions' => $a->decisions->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])->values(),
]);
return Inertia::render('Admin/DocumentTemplates/Index', [
'templates' => $templates,
'actions' => $actionsMapped,
]);
}
public function toggleActive(DocumentTemplate $template)
{
$this->ensurePermission();
$template->active = ! $template->active;
$template->updated_by = Auth::id();
$template->save();
return redirect()->back()->with('success', 'Status predloge posodobljen.');
}
public function show(DocumentTemplate $template)
{
$this->ensurePermission();
return Inertia::render('Admin/DocumentTemplates/Show', [
'template' => $template,
]);
}
public function edit(DocumentTemplate $template)
{
$this->ensurePermission();
$actions = Action::with(['decisions:id,name'])->orderBy('name')->get(['id', 'name']);
$actionsMapped = $actions->map(fn ($a) => [
'id' => $a->id,
'name' => $a->name,
'decisions' => $a->decisions->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])->values(),
]);
return Inertia::render('Admin/DocumentTemplates/Edit', [
'template' => $template,
'actions' => $actionsMapped,
]);
}
public function updateSettings(UpdateDocumentTemplateRequest $request, DocumentTemplate $template)
{
$this->ensurePermission();
$template->fill($request->only([
'output_filename_pattern', 'date_format', 'action_id', 'decision_id', 'activity_note_template',
]));
// If both action & decision provided, ensure decision belongs to action (parity with import templates)
if ($request->filled('action_id') && $request->filled('decision_id')) {
$belongs = \DB::table('action_decision')
->where('action_id', $request->integer('action_id'))
->where('decision_id', $request->integer('decision_id'))
->exists();
if (! $belongs) {
return redirect()->back()->withErrors(['decision_id' => 'Izbrana odločitev ne pripada izbrani akciji.']);
}
} elseif ($request->filled('action_id') && ! $request->filled('decision_id')) {
// Allow clearing decision when action changes
if ($template->isDirty('action_id')) {
$template->decision_id = null;
}
}
if ($request->has('fail_on_unresolved')) {
$template->fail_on_unresolved = (bool) $request->boolean('fail_on_unresolved');
}
// Build formatting options array from discrete fields if provided
$fmt = $template->formatting_options ?? [];
$dirty = false;
foreach ([
'number_decimals', 'decimal_separator', 'thousands_separator',
'currency_symbol', 'currency_position',
] as $key) {
if ($request->filled($key)) {
$fmt[$key] = $request->input($key);
$dirty = true;
} elseif ($request->has($key) && $request->input($key) === null) {
unset($fmt[$key]);
$dirty = true;
}
}
if ($request->has('currency_space')) {
$fmt['currency_space'] = (bool) $request->boolean('currency_space');
$dirty = true;
}
if ($request->filled('default_date_format')) {
$fmt['default_date_format'] = $request->input('default_date_format');
$dirty = true;
}
if ($request->has('date_formats')) {
$fmt['date_formats'] = array_filter((array) $request->input('date_formats'), fn ($v) => $v !== null && $v !== '');
$dirty = true;
}
if ($dirty) {
$template->formatting_options = $fmt;
}
// Merge meta, including custom_defaults
if ($request->has('meta') && is_array($request->input('meta'))) {
$meta = array_filter($request->input('meta'), fn ($v) => $v !== null && $v !== '');
$template->meta = array_replace($template->meta ?? [], $meta);
}
$template->updated_by = Auth::id();
$template->save();
return redirect()->back()->with('success', 'Nastavitve predloge shranjene.');
}
public function rescanTokens(DocumentTemplate $template)
{
$this->ensurePermission();
// Best-effort: read stored DOCX from disk and re-scan tokens
$tokens = [];
try {
/** @var TokenScanner $scanner */
$scanner = app(TokenScanner::class);
$zip = new \ZipArchive;
$tmp = tempnam(sys_get_temp_dir(), 'tmpl');
// Copy file from storage to a temp path
$disk = 'public';
$stream = \Storage::disk($disk)->get($template->file_path);
file_put_contents($tmp, $stream);
if ($zip->open($tmp) === true) {
// Collect main document and header/footer parts
$parts = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$name = $stat['name'] ?? '';
if (preg_match('#^word\/(document|header\d+|footer\d+)\.xml$#i', $name)) {
$parts[] = $name;
}
}
if (empty($parts)) {
$parts = ['word/document.xml'];
}
$found = [];
foreach ($parts as $name) {
$xml = $zip->getFromName($name);
if ($xml === false) {
continue;
}
$norm = self::normalizeDocxXmlTokens($xml);
$det = $scanner->scan($norm);
if (! empty($det)) {
$found = array_merge($found, $det);
}
}
$tokens = array_values(array_unique($found));
$zip->close();
}
} catch (\Throwable $e) {
// swallow scanning errors, keep $tokens as empty
}
if (\Schema::hasColumn('document_templates', 'tokens')) {
$template->tokens = $tokens;
}
// Auto-detect custom.* tokens and ensure meta.custom_default_types has defaults
try {
$meta = is_array($template->meta) ? $template->meta : [];
$types = isset($meta['custom_default_types']) && is_array($meta['custom_default_types']) ? $meta['custom_default_types'] : [];
$defaults = isset($meta['custom_defaults']) && is_array($meta['custom_defaults']) ? $meta['custom_defaults'] : [];
foreach (($tokens ?? []) as $tok) {
if (is_string($tok) && str_starts_with($tok, 'custom.')) {
$key = substr($tok, 7);
if ($key !== '') {
if (! array_key_exists($key, $types)) {
$types[$key] = 'string';
}
if (! array_key_exists($key, $defaults)) {
$defaults[$key] = '';
}
}
}
}
if (! empty($types)) {
$meta['custom_default_types'] = $types;
}
if (! empty($defaults)) {
$meta['custom_defaults'] = $defaults;
}
if ($meta !== ($template->meta ?? [])) {
$template->meta = $meta;
}
} catch (\Throwable $e) {
// ignore meta typing/defaults failures
}
$template->updated_by = Auth::id();
$template->save();
$count = is_array($tokens) ? count($tokens) : 0;
return back()->with('success', "Tokens posodobljeni ({$count} najdenih).");
}
public function store(StoreDocumentTemplateRequest $request)
{
$this->ensurePermission();
$file = $request->file('file');
// Basic extension guard (defense in depth vs only MIME detection)
if (strtolower($file->getClientOriginalExtension()) !== 'docx') {
return redirect()->back()->withErrors(['file' => 'Datoteka mora biti DOCX.']);
}
$slug = Str::slug($request->slug);
// Determine next version if slug exists
$latest = DocumentTemplate::where('slug', $slug)->orderByDesc('version')->first();
$nextVersion = $latest ? ($latest->version + 1) : 1;
$hash = hash_file('sha256', $file->getRealPath());
$path = $file->store("document-templates/{$slug}/v{$nextVersion}", 'public');
// Scan tokens from uploaded DOCX (best effort) normalize XML to collapse Word run boundaries
$tokens = [];
try {
/** @var TokenScanner $scanner */
$scanner = app(TokenScanner::class);
$zip = new \ZipArchive;
$tmp = tempnam(sys_get_temp_dir(), 'tmpl');
copy($file->getRealPath(), $tmp);
if ($zip->open($tmp) === true) {
// Collect main document and header/footer parts
$parts = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$name = $stat['name'] ?? '';
if (preg_match('#^word\/(document|header\d+|footer\d+)\.xml$#i', $name)) {
$parts[] = $name;
}
}
if (empty($parts)) {
$parts = ['word/document.xml'];
}
$found = [];
foreach ($parts as $name) {
$xml = $zip->getFromName($name);
if ($xml === false) {
continue;
}
$norm = self::normalizeDocxXmlTokens($xml);
$det = $scanner->scan($norm);
if (! empty($det)) {
$found = array_merge($found, $det);
}
}
$tokens = array_values(array_unique($found));
$zip->close();
}
} catch (\Throwable $e) {
// swallow scanning errors
}
// (Future) Could refine allowed columns automatically based on tokens
$entities = ['contract', 'client_case', 'client', 'person', 'account'];
$columns = [
'contract' => ['reference', 'start_date', 'end_date', 'description'],
'client_case' => ['client_ref'],
'client' => [],
'person' => ['full_name', 'first_name', 'last_name', 'nu'],
// Add common account attributes; whitelist may further refine
'account' => ['reference', 'initial_amount', 'balance_amount', 'promise_date'],
];
$payload = [
'name' => $request->name,
'slug' => $slug,
'custom_name' => $request->custom_name,
'description' => $request->description,
'core_entity' => 'contract',
'entities' => $entities,
'columns' => $columns,
'version' => $nextVersion,
'engine' => 'tokens',
'file_path' => $path,
'file_hash' => $hash,
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'active' => true,
'created_by' => $latest ? $latest->created_by : Auth::id(), // preserve original author for lineage if re-upload
'updated_by' => Auth::id(),
'formatting_options' => [
'number_decimals' => 2,
'decimal_separator' => ',',
'thousands_separator' => '.',
'currency_symbol' => '€',
'currency_position' => 'after',
'currency_space' => true,
],
];
// Optional meta + activity linkage fields (parity with import templates style)
if ($request->filled('meta') && is_array($request->input('meta'))) {
$payload['meta'] = array_filter($request->input('meta'), fn ($v) => $v !== null && $v !== '');
}
if ($request->filled('action_id')) {
$payload['action_id'] = $request->integer('action_id');
}
if ($request->filled('decision_id')) {
$payload['decision_id'] = $request->integer('decision_id');
}
if ($request->filled('activity_note_template')) {
$payload['activity_note_template'] = $request->input('activity_note_template');
}
if (Schema::hasColumn('document_templates', 'tokens')) {
$payload['tokens'] = $tokens;
}
// Auto-add default string types for any detected custom.* tokens
try {
$meta = isset($payload['meta']) && is_array($payload['meta']) ? $payload['meta'] : [];
$types = isset($meta['custom_default_types']) && is_array($meta['custom_default_types']) ? $meta['custom_default_types'] : [];
$defaults = isset($meta['custom_defaults']) && is_array($meta['custom_defaults']) ? $meta['custom_defaults'] : [];
foreach (($tokens ?? []) as $tok) {
if (is_string($tok) && str_starts_with($tok, 'custom.')) {
$key = substr($tok, 7);
if ($key !== '') {
if (! array_key_exists($key, $types)) {
$types[$key] = 'string';
}
if (! array_key_exists($key, $defaults)) {
$defaults[$key] = '';
}
}
}
}
if (! empty($types)) {
$meta['custom_default_types'] = $types;
}
if (! empty($defaults)) {
$meta['custom_defaults'] = $defaults;
}
if ($meta !== ($payload['meta'] ?? [])) {
$payload['meta'] = $meta;
}
} catch (\Throwable $e) {
// ignore meta typing/defaults failures
}
$template = DocumentTemplate::create($payload);
return redirect()->back()->with('success', 'Predloga uspešno shranjena. (v'.$template->version.')')->with('template_id', $template->id);
}
private function ensurePermission(): void
{
if (Gate::denies('manage-document-templates') && Gate::denies('manage-settings')) {
abort(403);
}
}
/**
* Collapse common Word run boundaries and proofing spans so tokens like {{client.person.full_name}}
* appear contiguous in XML for scanning.
*/
private static function normalizeDocxXmlTokens(string $xml): string
{
// Remove proofing error markers
$xml = preg_replace('#<w:proofErr[^>]*/>#i', '', $xml) ?? $xml;
// Iteratively collapse boundaries between text runs, even if w:rPr is present
$patterns = [
'#</w:t>\s*</w:r>\s*(?:<w:proofErr[^>]*/>\s*)*(?:<w:r[^>]*>\s*(?:<w:rPr>.*?</w:rPr>\s*)*)?<w:t[^>]*>#is',
];
$prev = null;
while ($prev !== $xml) {
$prev = $xml;
foreach ($patterns as $pat) {
$xml = preg_replace($pat, '', $xml) ?? $xml;
}
}
// Remove zero-width and soft hyphen characters
$xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml);
return $xml;
}
}