Document gen fixed

This commit is contained in:
2025-10-12 17:52:17 +02:00
parent e0303ece74
commit 23f2011e33
16 changed files with 1116 additions and 88 deletions
@@ -133,6 +133,94 @@ public function updateSettings(UpdateDocumentTemplateRequest $request, DocumentT
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();
@@ -152,7 +240,7 @@ public function store(StoreDocumentTemplateRequest $request)
$hash = hash_file('sha256', $file->getRealPath());
$path = $file->store("document-templates/{$slug}/v{$nextVersion}", 'public');
// Scan tokens from uploaded DOCX (best effort)
// Scan tokens from uploaded DOCX (best effort) normalize XML to collapse Word run boundaries
$tokens = [];
try {
/** @var TokenScanner $scanner */
@@ -161,10 +249,31 @@ public function store(StoreDocumentTemplateRequest $request)
$tmp = tempnam(sys_get_temp_dir(), 'tmpl');
copy($file->getRealPath(), $tmp);
if ($zip->open($tmp) === true) {
$xml = $zip->getFromName('word/document.xml');
if ($xml !== false) {
$tokens = $scanner->scan($xml);
// 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) {
@@ -224,6 +333,36 @@ public function store(StoreDocumentTemplateRequest $request)
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);
@@ -235,4 +374,29 @@ private function ensurePermission(): void
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;
}
}
@@ -1195,6 +1195,15 @@ public function show(ClientCase $clientCase)
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']))->firstOrFail(),
'client_case' => $case,
'contracts' => $contracts,
// Active document templates for contracts (latest version per slug)
'contract_doc_templates' => \App\Models\DocumentTemplate::query()
->where('active', true)
->where('core_entity', 'contract')
->orderBy('slug')
->get(['id', 'name', 'slug', 'version', 'tokens', 'meta'])
->groupBy('slug')
->map(fn ($g) => $g->sortByDesc('version')->first())
->values(),
'archive_meta' => [
'archive_segment_id' => $archiveSegmentId,
'related_tables' => $relatedArchiveTables,
@@ -23,15 +23,24 @@ public function __invoke(Request $request, Contract $contract): Response
}
$request->validate([
'template_slug' => ['required', 'string', 'exists:document_templates,slug'],
'template_version' => ['nullable', 'integer'],
'custom' => ['nullable', 'array'],
'custom.*' => ['nullable'],
]);
$template = DocumentTemplate::where('slug', $request->template_slug)
// Prefer explicitly requested version if provided and active; otherwise use latest active
$baseQuery = DocumentTemplate::query()
->where('slug', $request->template_slug)
->where('core_entity', 'contract')
->where('active', true)
->orderByDesc('version')
->firstOrFail();
->where('active', true);
if ($request->filled('template_version')) {
$template = (clone $baseQuery)->where('version', (int) $request->integer('template_version'))->first();
if (! $template) {
$template = (clone $baseQuery)->orderByDesc('version')->firstOrFail();
}
} else {
$template = $baseQuery->orderByDesc('version')->firstOrFail();
}
// Load related data minimally
$contract->load(['clientCase.client.person', 'clientCase.person', 'clientCase.client']);
@@ -47,6 +56,16 @@ public function __invoke(Request $request, Contract $contract): Response
'tokens' => $e->unresolved ?? [],
], 422);
} catch (\Throwable $e) {
try {
logger()->error('ContractDocumentGenerationController generation failed', [
'template_id' => $template->id ?? null,
'template_slug' => $template->slug ?? null,
'template_version' => $template->version ?? null,
'error' => $e->getMessage(),
]);
} catch (\Throwable $logEx) {
}
return response()->json([
'status' => 'error',
'message' => 'Generation failed.',
@@ -115,6 +134,13 @@ public function __invoke(Request $request, Contract $contract): Response
'status' => 'ok',
'document_uuid' => $doc->uuid,
'path' => $doc->path,
'stats' => $result['stats'] ?? null,
'template' => [
'id' => $template->id,
'slug' => $template->slug,
'version' => $template->version,
'file_path' => $template->file_path,
],
]);
}
}
@@ -33,7 +33,7 @@ public function rules(): array
'meta.custom_defaults' => ['nullable', 'array'],
'meta.custom_defaults.*' => ['nullable'],
'meta.custom_default_types' => ['nullable', 'array'],
'meta.custom_default_types.*' => ['nullable', 'in:string,number,date'],
'meta.custom_default_types.*' => ['nullable', 'in:string,number,date,text'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
'activity_note_template' => ['nullable', 'string'],