Document gen fixed
This commit is contained in:
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user