403 lines
16 KiB
PHP
403 lines
16 KiB
PHP
<?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;
|
||
}
|
||
}
|