documents
This commit is contained in:
parent
3ab1c05fcc
commit
e0303ece74
|
|
@ -46,6 +46,7 @@ public function toggleActive(DocumentTemplate $template)
|
|||
public function show(DocumentTemplate $template)
|
||||
{
|
||||
$this->ensurePermission();
|
||||
|
||||
return Inertia::render('Admin/DocumentTemplates/Show', [
|
||||
'template' => $template,
|
||||
]);
|
||||
|
|
@ -121,6 +122,11 @@ public function updateSettings(UpdateDocumentTemplateRequest $request, DocumentT
|
|||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ public function __invoke(Request $request, Contract $contract): Response
|
|||
}
|
||||
$request->validate([
|
||||
'template_slug' => ['required', 'string', 'exists:document_templates,slug'],
|
||||
'custom' => ['nullable', 'array'],
|
||||
'custom.*' => ['nullable'],
|
||||
]);
|
||||
|
||||
$template = DocumentTemplate::where('slug', $request->template_slug)
|
||||
|
|
@ -36,6 +38,7 @@ public function __invoke(Request $request, Contract $contract): Response
|
|||
|
||||
$renderer = app(\App\Services\Documents\DocxTemplateRenderer::class);
|
||||
try {
|
||||
// For custom tokens: pass overrides via request bag; service already reads request()->input('custom') if present.
|
||||
$result = $renderer->render($template, $contract, Auth::user());
|
||||
} catch (\App\Services\Documents\Exceptions\UnresolvedTokensException $e) {
|
||||
return response()->json([
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ public function update(Person $person, Request $request)
|
|||
|
||||
$person->update($attributes);
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Person updated');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'person' => [
|
||||
'full_name' => $person->full_name,
|
||||
|
|
@ -41,6 +45,8 @@ public function createAddress(Person $person, Request $request)
|
|||
$attributes = $request->validate([
|
||||
'address' => 'required|string|max:150',
|
||||
'country' => 'nullable|string',
|
||||
'post_code' => 'nullable|string|max:16',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'type_id' => 'required|integer|exists:address_types,id',
|
||||
'description' => 'nullable|string|max:125',
|
||||
]);
|
||||
|
|
@ -49,8 +55,15 @@ public function createAddress(Person $person, Request $request)
|
|||
$address = $person->addresses()->firstOrCreate([
|
||||
'address' => $attributes['address'],
|
||||
'country' => $attributes['country'] ?? null,
|
||||
'post_code' => $attributes['post_code'] ?? null,
|
||||
'city' => $attributes['city'] ?? null,
|
||||
], $attributes);
|
||||
|
||||
// Support Inertia form submissions (redirect back) and JSON (for API/axios)
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Address created');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id),
|
||||
]);
|
||||
|
|
@ -61,6 +74,8 @@ public function updateAddress(Person $person, int $address_id, Request $request)
|
|||
$attributes = $request->validate([
|
||||
'address' => 'required|string|max:150',
|
||||
'country' => 'nullable|string',
|
||||
'post_code' => 'nullable|string|max:16',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'type_id' => 'required|integer|exists:address_types,id',
|
||||
'description' => 'nullable|string|max:125',
|
||||
]);
|
||||
|
|
@ -69,6 +84,10 @@ public function updateAddress(Person $person, int $address_id, Request $request)
|
|||
|
||||
$address->update($attributes);
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Address updated');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'address' => $address,
|
||||
]);
|
||||
|
|
@ -79,6 +98,10 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
|
|||
$address = $person->addresses()->findOrFail($address_id);
|
||||
$address->delete(); // soft delete
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Address deleted');
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
|
||||
|
|
@ -97,6 +120,10 @@ public function createPhone(Person $person, Request $request)
|
|||
'country_code' => $attributes['country_code'] ?? null,
|
||||
], $attributes);
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Phone added successfully');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id),
|
||||
]);
|
||||
|
|
@ -115,6 +142,10 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
|
|||
|
||||
$phone->update($attributes);
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Phone updated successfully');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'phone' => $phone,
|
||||
]);
|
||||
|
|
@ -125,6 +156,10 @@ public function deletePhone(Person $person, int $phone_id, Request $request)
|
|||
$phone = $person->phones()->findOrFail($phone_id);
|
||||
$phone->delete(); // soft delete
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Phone deleted');
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
|
||||
|
|
@ -176,6 +211,10 @@ public function deleteEmail(Person $person, int $email_id, Request $request)
|
|||
$email = $person->emails()->findOrFail($email_id);
|
||||
$email->delete();
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Email deleted');
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
|
||||
|
|
@ -198,6 +237,10 @@ public function createTrr(Person $person, Request $request)
|
|||
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
|
||||
$trr = $person->bankAccounts()->create($attributes);
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'TRR added successfully');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'trr' => BankAccount::findOrFail($trr->id),
|
||||
]);
|
||||
|
|
@ -222,6 +265,10 @@ public function updateTrr(Person $person, int $trr_id, Request $request)
|
|||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||
$trr->update($attributes);
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'TRR updated successfully');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'trr' => $trr,
|
||||
]);
|
||||
|
|
@ -232,6 +279,10 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
|
|||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||
$trr->delete();
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'TRR deleted');
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ public function rules(): array
|
|||
'date_formats.*' => ['nullable', 'string', 'max:40'],
|
||||
'meta' => ['sometimes', 'array'],
|
||||
'meta.*' => ['nullable'],
|
||||
'meta.custom_defaults' => ['nullable', 'array'],
|
||||
'meta.custom_defaults.*' => ['nullable'],
|
||||
'meta.custom_default_types' => ['nullable', 'array'],
|
||||
'meta.custom_default_types.*' => ['nullable', 'in:string,number,date'],
|
||||
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||
'activity_note_template' => ['nullable', 'string'],
|
||||
|
|
|
|||
|
|
@ -13,12 +13,14 @@ class DocumentSetting extends Model
|
|||
'preview_enabled',
|
||||
'whitelist',
|
||||
'date_formats',
|
||||
'custom_defaults',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'preview_enabled' => 'boolean',
|
||||
'whitelist' => 'array',
|
||||
'date_formats' => 'array',
|
||||
'custom_defaults' => 'array',
|
||||
];
|
||||
|
||||
public static function instance(): self
|
||||
|
|
@ -30,6 +32,7 @@ public static function instance(): self
|
|||
'preview_enabled' => config('documents.preview.enabled', true),
|
||||
'whitelist' => config('documents.whitelist'),
|
||||
'date_formats' => [],
|
||||
'custom_defaults' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,33 +12,39 @@ class PersonAddress extends Model
|
|||
{
|
||||
/** @use HasFactory<\Database\Factories\Person/PersonAddressFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use Searchable;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'address',
|
||||
'country',
|
||||
'post_code',
|
||||
'city',
|
||||
'type_id',
|
||||
'description',
|
||||
'person_id',
|
||||
'user_id'
|
||||
'user_id',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'user_id',
|
||||
'person_id',
|
||||
'deleted'
|
||||
'deleted',
|
||||
];
|
||||
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
return [
|
||||
'address' => $this->address,
|
||||
'country' => $this->country
|
||||
'country' => $this->country,
|
||||
'post_code' => $this->post_code,
|
||||
'city' => $this->city,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(){
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function (PersonAddress $address) {
|
||||
$address->user_id = auth()->id();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,4 +25,16 @@ public function fresh(): DocumentSetting
|
|||
{
|
||||
return $this->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience accessor for custom defaults.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function customDefaults(): array
|
||||
{
|
||||
$settings = $this->get();
|
||||
|
||||
return is_array($settings->custom_defaults ?? null) ? $settings->custom_defaults : [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,9 +41,22 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
|
|||
// Determine effective unresolved policy early (template override -> global -> config)
|
||||
$globalSettingsEarly = app(\App\Services\Documents\DocumentSettings::class)->get();
|
||||
$effectivePolicy = $template->fail_on_unresolved ? 'fail' : ($globalSettingsEarly->unresolved_policy ?? config('documents.unresolved_policy', 'fail'));
|
||||
$resolved = $this->resolver->resolve($tokens, $template, $contract, $user, $effectivePolicy);
|
||||
// Resolve with support for custom.* tokens: per-generation overrides and defaults from template meta or global settings.
|
||||
$customOverrides = request()->input('custom', []); // if called via HTTP context; otherwise pass explicitly from caller
|
||||
$customDefaults = is_array($template->meta['custom_defaults'] ?? null) ? $template->meta['custom_defaults'] : null;
|
||||
$resolved = $this->resolver->resolve(
|
||||
$tokens,
|
||||
$template,
|
||||
$contract,
|
||||
$user,
|
||||
$effectivePolicy,
|
||||
is_array($customOverrides) ? $customOverrides : [],
|
||||
$customDefaults,
|
||||
'empty'
|
||||
);
|
||||
$values = $resolved['values'];
|
||||
$initialUnresolved = $resolved['unresolved'];
|
||||
$customTypes = $resolved['customTypes'] ?? [];
|
||||
// Formatting options
|
||||
$fmt = $template->formatting_options ?? [];
|
||||
$decimals = (int) ($fmt['number_decimals'] ?? 2);
|
||||
|
|
@ -55,8 +68,10 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
|
|||
$globalSettings = app(\App\Services\Documents\DocumentSettings::class)->get();
|
||||
$globalDateFormats = $globalSettings->date_formats ?? [];
|
||||
foreach ($values as $k => $v) {
|
||||
// Date formatting (heuristic based on key ending with _date or .date)
|
||||
if (is_string($v) && ($k === 'generation.date' || preg_match('/(^|\.)[A-Za-z_]*date$/i', $k))) {
|
||||
$isTypedDate = ($customTypes[$k] ?? null) === 'date';
|
||||
$isTypedNumber = ($customTypes[$k] ?? null) === 'number';
|
||||
// Date formatting (typed or heuristic based on key ending with _date or .date)
|
||||
if (is_string($v) && ($isTypedDate || $k === 'generation.date' || preg_match('/(^|\.)[A-Za-z_]*date$/i', $k))) {
|
||||
$dateFmtOverrides = $fmt['date_formats'] ?? [];
|
||||
$desiredFormat = $dateFmtOverrides[$k]
|
||||
?? ($globalDateFormats[$k] ?? null)
|
||||
|
|
@ -75,9 +90,11 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
|
|||
}
|
||||
}
|
||||
}
|
||||
if (is_numeric($v)) {
|
||||
// Number formatting: only for explicitly typed numbers or common monetary fields
|
||||
$isFinanceField = (bool) preg_match('/(^|\.)\b(amount|balance|total|price|cost)\b$/i', $k);
|
||||
if (($isTypedNumber || $isFinanceField) && is_numeric($v)) {
|
||||
$num = number_format((float) $v, $decimals, $decSep, $thouSep);
|
||||
if ($currencySymbol && preg_match('/(amount|balance|total|price|cost)/i', $k)) {
|
||||
if ($currencySymbol && $isFinanceField) {
|
||||
$space = $currencySpace ? ' ' : '';
|
||||
if ($currencyPos === 'after') {
|
||||
$num = $num.$space.$currencySymbol;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
|
||||
class TokenScanner
|
||||
{
|
||||
private const REGEX = '/{{\s*([a-zA-Z0-9_]+\.[a-zA-Z0-9_]+)\s*}}/';
|
||||
// Allow entity.attr with attr accepting letters, digits, underscore, dot and hyphen for flexibility (e.g., custom.order-id)
|
||||
private const REGEX = '/{{\s*([a-zA-Z0-9_]+\.[a-zA-Z0-9_.-]+)\s*}}/';
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
|
|
|
|||
|
|
@ -13,12 +13,39 @@ class TokenValueResolver
|
|||
* Returns array with keys: values (resolved token=>value) and unresolved (list of tokens not resolved / not allowed)
|
||||
* Policy determines whether invalid tokens throw (fail) or are collected (blank|keep).
|
||||
*
|
||||
* @return array{values:array<string,string>,unresolved:array<int,string>}
|
||||
* @return array{values:array<string,string>,unresolved:array<int,string>,customTypes?:array<string,string>}
|
||||
*/
|
||||
public function resolve(array $tokens, DocumentTemplate $template, Contract $contract, User $user, string $policy = 'fail'): array
|
||||
{
|
||||
public function resolve(
|
||||
array $tokens,
|
||||
DocumentTemplate $template,
|
||||
Contract $contract,
|
||||
User $user,
|
||||
string $policy = 'fail',
|
||||
array $customOverrides = [],
|
||||
?array $customDefaults = null,
|
||||
string $onMissingCustom = 'empty'
|
||||
): array {
|
||||
$values = [];
|
||||
$unresolved = [];
|
||||
$customTypesOut = [];
|
||||
// Custom namespace: merge defaults from settings/template meta and overrides
|
||||
$settings = app(\App\Services\Documents\DocumentSettings::class)->get();
|
||||
$defaults = $customDefaults ?? ($template->meta['custom_defaults'] ?? null) ?? ($settings->custom_defaults ?? []);
|
||||
if (! is_array($defaults)) {
|
||||
$defaults = [];
|
||||
}
|
||||
if (! is_array($customOverrides)) {
|
||||
$customOverrides = [];
|
||||
}
|
||||
$custom = array_replace($defaults, $customOverrides);
|
||||
// Collect custom types from template meta (optional)
|
||||
$customTypes = [];
|
||||
if (isset($template->meta['custom_default_types']) && is_array($template->meta['custom_default_types'])) {
|
||||
foreach ($template->meta['custom_default_types'] as $k => $t) {
|
||||
$t = in_array($t, ['string', 'number', 'date'], true) ? $t : 'string';
|
||||
$customTypes[(string) $k] = $t;
|
||||
}
|
||||
}
|
||||
// Retrieve whitelist from DB settings (if present) and merge with config baseline (config acts as baseline; DB can add or override entity arrays)
|
||||
$settingsWhitelist = app(\App\Services\Documents\DocumentSettings::class)->get()->whitelist ?? [];
|
||||
$configWhitelist = config('documents.whitelist', []);
|
||||
|
|
@ -37,6 +64,34 @@ public function resolve(array $tokens, DocumentTemplate $template, Contract $con
|
|||
|
||||
continue;
|
||||
}
|
||||
if ($entity === 'custom') {
|
||||
// Track type info if present
|
||||
if (isset($customTypes[$attr])) {
|
||||
$customTypesOut[$token] = $customTypes[$attr];
|
||||
}
|
||||
if (array_key_exists($attr, $custom)) {
|
||||
$v = $custom[$attr];
|
||||
if (is_scalar($v) || (is_object($v) && method_exists($v, '__toString'))) {
|
||||
$values[$token] = (string) $v;
|
||||
} else {
|
||||
$values[$token] = '';
|
||||
}
|
||||
} else {
|
||||
// Missing custom – apply onMissingCustom policy locally (empty|leave|error)
|
||||
if ($onMissingCustom === 'error') {
|
||||
if ($policy === 'fail') {
|
||||
throw new \RuntimeException("Manjkajoč custom token: {$token}");
|
||||
}
|
||||
$unresolved[] = $token;
|
||||
} elseif ($onMissingCustom === 'leave') {
|
||||
$unresolved[] = $token;
|
||||
} else { // empty
|
||||
$values[$token] = '';
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! in_array($entity, $templateEntities, true)) {
|
||||
if ($policy === 'fail') {
|
||||
throw new \RuntimeException("Nedovoljen entiteta token: $entity");
|
||||
|
|
@ -45,7 +100,12 @@ public function resolve(array $tokens, DocumentTemplate $template, Contract $con
|
|||
|
||||
continue;
|
||||
}
|
||||
$allowed = ($template->columns[$entity] ?? []) ?: ($globalWhitelist[$entity] ?? []);
|
||||
// Allowed attributes: merge template-declared columns with global whitelist (config + DB settings)
|
||||
// Rationale: old templates may not list newly allowed attributes (like nested paths),
|
||||
// so we honor both sources instead of preferring one exclusively.
|
||||
$allowedFromTemplate = $template->columns[$entity] ?? [];
|
||||
$allowedFromGlobal = $globalWhitelist[$entity] ?? [];
|
||||
$allowed = array_values(array_unique(array_merge($allowedFromTemplate, $allowedFromGlobal)));
|
||||
if (! in_array($attr, $allowed, true)) {
|
||||
if ($policy === 'fail') {
|
||||
throw new \RuntimeException("Nedovoljen stolpec token: $token");
|
||||
|
|
@ -57,7 +117,11 @@ public function resolve(array $tokens, DocumentTemplate $template, Contract $con
|
|||
$values[$token] = $this->entityAttribute($entity, $attr, $contract) ?? '';
|
||||
}
|
||||
|
||||
return ['values' => $values, 'unresolved' => array_values(array_unique($unresolved))];
|
||||
return [
|
||||
'values' => $values,
|
||||
'unresolved' => array_values(array_unique($unresolved)),
|
||||
'customTypes' => $customTypesOut,
|
||||
];
|
||||
}
|
||||
|
||||
private function generationAttribute(string $attr, User $user): string
|
||||
|
|
@ -78,11 +142,25 @@ private function entityAttribute(string $entity, string $attr, Contract $contrac
|
|||
case 'client_case':
|
||||
return (string) optional($contract->clientCase)->{$attr};
|
||||
case 'client':
|
||||
return (string) optional(optional($contract->clientCase)->client)->{$attr};
|
||||
case 'person':
|
||||
$person = optional(optional($contract->clientCase)->person);
|
||||
$client = optional($contract->clientCase)->client;
|
||||
if (! $client) {
|
||||
return '';
|
||||
}
|
||||
if (str_contains($attr, '.')) {
|
||||
return $this->resolveNestedFromModel($client, $attr);
|
||||
}
|
||||
|
||||
return (string) $person->{$attr};
|
||||
return (string) ($client->{$attr} ?? '');
|
||||
case 'person':
|
||||
$person = optional($contract->clientCase)->person;
|
||||
if (! $person) {
|
||||
return '';
|
||||
}
|
||||
if (str_contains($attr, '.')) {
|
||||
return $this->resolveNestedFromModel($person, $attr);
|
||||
}
|
||||
|
||||
return (string) ($person->{$attr} ?? '');
|
||||
case 'account':
|
||||
$account = optional($contract->account);
|
||||
|
||||
|
|
@ -91,4 +169,39 @@ private function entityAttribute(string $entity, string $attr, Contract $contrac
|
|||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve nested dotted paths from a base model for supported relations/aliases.
|
||||
* Supports:
|
||||
* - Client: person.*
|
||||
* - Person: person_address.* (uses first active address)
|
||||
*/
|
||||
private function resolveNestedFromModel(object $model, string $path): string
|
||||
{
|
||||
$segments = explode('.', $path);
|
||||
$current = $model;
|
||||
foreach ($segments as $seg) {
|
||||
if (! $current) {
|
||||
return '';
|
||||
}
|
||||
if ($current instanceof \App\Models\Client && $seg === 'person') {
|
||||
$current = $current->person;
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($current instanceof \App\Models\Person\Person && $seg === 'person_address') {
|
||||
$current = $current->addresses()->first();
|
||||
|
||||
continue;
|
||||
}
|
||||
// Default attribute access
|
||||
try {
|
||||
$current = is_array($current) ? ($current[$seg] ?? null) : ($current->{$seg} ?? null);
|
||||
} catch (\Throwable $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return $current !== null ? (string) $current : '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2365,13 +2365,15 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
|
|||
if (! $field) {
|
||||
continue;
|
||||
}
|
||||
$val = $addrData[$field] ?? null;
|
||||
// Allow alias 'postal_code' in CSV mappings but persist as 'post_code' in DB
|
||||
$targetField = $field === 'postal_code' ? 'post_code' : $field;
|
||||
$val = $addrData[$field] ?? $addrData[$targetField] ?? null;
|
||||
$mode = $map->apply_mode ?? 'both';
|
||||
if (in_array($mode, ['insert', 'both'])) {
|
||||
$applyInsert[$field] = $val;
|
||||
$applyInsert[$targetField] = $val;
|
||||
}
|
||||
if (in_array($mode, ['update', 'both'])) {
|
||||
$applyUpdate[$field] = $val;
|
||||
$applyUpdate[$targetField] = $val;
|
||||
}
|
||||
}
|
||||
if ($existing) {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,26 @@
|
|||
'whitelist' => [
|
||||
'contract' => ['reference', 'start_date', 'end_date', 'description'],
|
||||
'client_case' => ['client_ref'],
|
||||
'client' => [],
|
||||
'person' => ['full_name', 'first_name', 'last_name', 'nu'],
|
||||
'client' => [
|
||||
'person',
|
||||
'person.full_name',
|
||||
'person.first_name',
|
||||
'person.last_name',
|
||||
'person.nu',
|
||||
'person.person_address',
|
||||
'person.person_address.address',
|
||||
'person.person_address.post_code',
|
||||
'person.person_address.city',
|
||||
],
|
||||
'person' => [
|
||||
'full_name',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'nu',
|
||||
'person_address',
|
||||
'person_address.address',
|
||||
'person_address.post_code',
|
||||
'person_address.city',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ public function definition(): array
|
|||
return [
|
||||
'address' => $this->faker->streetAddress(),
|
||||
'country' => 'SI',
|
||||
'post_code' => $this->faker->postcode(),
|
||||
'city' => $this->faker->city(),
|
||||
'type_id' => AddressType::factory(),
|
||||
'user_id' => User::factory(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('person_addresses', function (Blueprint $table): void {
|
||||
$table->string('post_code', 16)->nullable();
|
||||
$table->string('city', 100)->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('person_addresses', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('person_addresses', 'post_code')) {
|
||||
$table->dropColumn('post_code');
|
||||
}
|
||||
if (Schema::hasColumn('person_addresses', 'city')) {
|
||||
$table->dropColumn('city');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('document_settings', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('document_settings', 'custom_defaults')) {
|
||||
$table->json('custom_defaults')->nullable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('document_settings', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('document_settings', 'custom_defaults')) {
|
||||
$table->dropColumn('custom_defaults');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useForm, router, usePage } from '@inertiajs/vue3';
|
||||
import DialogModal from './DialogModal.vue';
|
||||
import InputLabel from './InputLabel.vue';
|
||||
import SectionTitle from './SectionTitle.vue';
|
||||
|
|
@ -33,47 +34,47 @@ const emit = defineEmits(['close']);
|
|||
|
||||
const close = () => {
|
||||
emit('close');
|
||||
|
||||
setTimeout(() => {
|
||||
errors.value = {};
|
||||
}, 500);
|
||||
try { form.clearErrors && form.clearErrors(); } catch {}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
const form = ref({
|
||||
const form = useForm({
|
||||
address: '',
|
||||
country: '',
|
||||
type_id: props.types[0].id,
|
||||
post_code: '',
|
||||
city: '',
|
||||
type_id: props.types?.[0]?.id ?? null,
|
||||
description: ''
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = {
|
||||
address: '',
|
||||
country: '',
|
||||
type_id: props.types[0].id,
|
||||
description: ''
|
||||
};
|
||||
form.address = '';
|
||||
form.country = '';
|
||||
form.post_code = '';
|
||||
form.city = '';
|
||||
form.type_id = props.types?.[0]?.id ?? null;
|
||||
form.description = '';
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
processing.value = true;
|
||||
errors.value = {};
|
||||
|
||||
const data = await axios({
|
||||
method: 'post',
|
||||
url: route('person.address.create', props.person),
|
||||
data: form.value
|
||||
}).then((response) => {
|
||||
props.person.addresses.push(response.data.address);
|
||||
|
||||
form.post(route('person.address.create', props.person), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
// Optimistically append from last created record in DB by refetch or expose via flash if needed.
|
||||
// For now, trigger a lightweight reload of person's addresses via a GET if you have an endpoint, else trust parent reactivity.
|
||||
processing.value = false;
|
||||
|
||||
close();
|
||||
resetForm();
|
||||
|
||||
}).catch((reason) => {
|
||||
errors.value = reason.response.data.errors;
|
||||
form.reset();
|
||||
},
|
||||
onError: (e) => {
|
||||
errors.value = e || {};
|
||||
processing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -81,23 +82,17 @@ const update = async () => {
|
|||
processing.value = true;
|
||||
errors.value = {};
|
||||
|
||||
const data = await axios({
|
||||
method: 'put',
|
||||
url: route('person.address.update', {person: props.person, address_id: props.id}),
|
||||
data: form.value
|
||||
}).then((response) => {
|
||||
console.log(response.data.address)
|
||||
const index = props.person.addresses.findIndex( a => a.id === response.data.address.id );
|
||||
props.person.addresses[index] = response.data.address;
|
||||
|
||||
form.put(route('person.address.update', {person: props.person, address_id: props.id}), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
processing.value = false;
|
||||
|
||||
close();
|
||||
resetForm();
|
||||
|
||||
}).catch((reason) => {
|
||||
errors.value = reason.response.data.errors;
|
||||
form.reset();
|
||||
},
|
||||
onError: (e) => {
|
||||
errors.value = e || {};
|
||||
processing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -108,12 +103,12 @@ watch(
|
|||
console.log(props.edit)
|
||||
props.person.addresses.filter((a) => {
|
||||
if(a.id === props.id){
|
||||
form.value = {
|
||||
address: a.address,
|
||||
country: a.country,
|
||||
type_id: a.type_id,
|
||||
description: a.description
|
||||
};
|
||||
form.address = a.address;
|
||||
form.country = a.country;
|
||||
form.post_code = a.post_code || a.postal_code || '';
|
||||
form.city = a.city || '';
|
||||
form.type_id = a.type_id;
|
||||
form.description = a.description;
|
||||
}
|
||||
});
|
||||
return;
|
||||
|
|
@ -175,6 +170,28 @@ const callSubmit = () => {
|
|||
|
||||
<InputError v-if="errors.address !== undefined" v-for="err in errors.address" :message="err" />
|
||||
</div>
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="cr_post_code" value="Poštna številka" />
|
||||
<TextInput
|
||||
id="cr_post_code"
|
||||
v-model="form.post_code"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="postal-code"
|
||||
/>
|
||||
<InputError v-if="errors.post_code !== undefined" v-for="err in errors.post_code" :message="err" />
|
||||
</div>
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="cr_city" value="Mesto" />
|
||||
<TextInput
|
||||
id="cr_city"
|
||||
v-model="form.city"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="address-level2"
|
||||
/>
|
||||
<InputError v-if="errors.city !== undefined" v-for="err in errors.city" :message="err" />
|
||||
</div>
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="cr_type" value="Tip"/>
|
||||
<select
|
||||
|
|
|
|||
|
|
@ -1,17 +1,197 @@
|
|||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { useForm } from "@inertiajs/vue3";
|
||||
import DialogModal from "./DialogModal.vue";
|
||||
import InputLabel from "./InputLabel.vue";
|
||||
import SectionTitle from "./SectionTitle.vue";
|
||||
import TextInput from "./TextInput.vue";
|
||||
import InputError from "./InputError.vue";
|
||||
import PrimaryButton from "./PrimaryButton.vue";
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Test",
|
||||
created() {},
|
||||
data() {
|
||||
return {};
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
person: Object,
|
||||
types: Array,
|
||||
id: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const processing = ref(false);
|
||||
const errors = ref({});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
const close = () => {
|
||||
emit("close");
|
||||
setTimeout(() => {
|
||||
errors.value = {};
|
||||
try {
|
||||
form.clearErrors && form.clearErrors();
|
||||
} catch {}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
address: "",
|
||||
country: "",
|
||||
post_code: "",
|
||||
city: "",
|
||||
type_id: props.types?.[0]?.id ?? null,
|
||||
description: "",
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
form.address = "";
|
||||
form.country = "";
|
||||
form.post_code = "";
|
||||
form.city = "";
|
||||
form.type_id = props.types?.[0]?.id ?? null;
|
||||
form.description = "";
|
||||
};
|
||||
|
||||
const hydrate = () => {
|
||||
const id = props.id;
|
||||
if (id) {
|
||||
const a = (props.person.addresses || []).find((x) => x.id === id);
|
||||
if (a) {
|
||||
form.address = a.address;
|
||||
form.country = a.country;
|
||||
form.post_code = a.post_code || a.postal_code || "";
|
||||
form.city = a.city || "";
|
||||
form.type_id = a.type_id;
|
||||
form.description = a.description || "";
|
||||
}
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.id,
|
||||
() => hydrate(),
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (v) hydrate();
|
||||
}
|
||||
);
|
||||
|
||||
const update = async () => {
|
||||
processing.value = true;
|
||||
errors.value = {};
|
||||
|
||||
form.put(
|
||||
route("person.address.update", { person: props.person, address_id: props.id }),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
processing.value = false;
|
||||
close();
|
||||
form.reset();
|
||||
},
|
||||
props: {},
|
||||
methods: {},
|
||||
onError: (e) => {
|
||||
errors.value = e || {};
|
||||
processing.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<template>
|
||||
<DialogModal :show="show" @close="close">
|
||||
<template #title>Spremeni naslov</template>
|
||||
<template #content>
|
||||
<form @submit.prevent="update">
|
||||
<SectionTitle class="border-b mb-4"
|
||||
><template #title>Naslov</template></SectionTitle
|
||||
>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="up_address" value="Naslov" />
|
||||
<TextInput
|
||||
id="up_address"
|
||||
v-model="form.address"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="address"
|
||||
/>
|
||||
<InputError
|
||||
v-if="errors.address !== undefined"
|
||||
v-for="err in errors.address"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="up_country" value="Država" />
|
||||
<TextInput
|
||||
id="up_country"
|
||||
v-model="form.country"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="country"
|
||||
/>
|
||||
<InputError
|
||||
v-if="errors.country !== undefined"
|
||||
v-for="err in errors.country"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="up_post_code" value="Poštna številka" />
|
||||
<TextInput
|
||||
id="up_post_code"
|
||||
v-model="form.post_code"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="postal-code"
|
||||
/>
|
||||
<InputError
|
||||
v-if="errors.post_code !== undefined"
|
||||
v-for="err in errors.post_code"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="up_city" value="Mesto" />
|
||||
<TextInput
|
||||
id="up_city"
|
||||
v-model="form.city"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="address-level2"
|
||||
/>
|
||||
<InputError
|
||||
v-if="errors.city !== undefined"
|
||||
v-for="err in errors.city"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="up_type" value="Tip" />
|
||||
<select
|
||||
id="up_type"
|
||||
v-model="form.type_id"
|
||||
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option v-for="type in types" :key="type.id" :value="type.id">
|
||||
{{ type.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<PrimaryButton :class="{ 'opacity-25': processing }" :disabled="processing"
|
||||
>Shrani</PrimaryButton
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { provide, ref, watch } from 'vue';
|
|||
import axios from 'axios';
|
||||
import PersonUpdateForm from './PersonUpdateForm.vue';
|
||||
import AddressCreateForm from './AddressCreateForm.vue';
|
||||
import AddressUpdateForm from './AddressUpdateForm.vue';
|
||||
import PhoneCreateForm from './PhoneCreateForm.vue';
|
||||
import EmailCreateForm from './EmailCreateForm.vue';
|
||||
import EmailUpdateForm from './EmailUpdateForm.vue';
|
||||
|
|
@ -74,8 +75,9 @@ const closeConfirm = () => { confirm.value.show = false; };
|
|||
const getMainAddress = (adresses) => {
|
||||
const addr = adresses.filter( a => a.type.id === 1 )[0] ?? '';
|
||||
if( addr !== '' ){
|
||||
const tail = (addr.post_code && addr.city) ? `, ${addr.post_code} ${addr.city}` : '';
|
||||
const country = addr.country !== '' ? ` - ${addr.country}` : '';
|
||||
return addr.address !== '' ? addr.address + country : '';
|
||||
return addr.address !== '' ? (addr.address + tail + country) : '';
|
||||
}
|
||||
|
||||
return '';
|
||||
|
|
@ -234,7 +236,9 @@ const getTRRs = (p) => {
|
|||
<button @click="openConfirm('address', address.id, address.address)"><TrashBinIcon size="md" css="text-red-600 hover:text-red-700" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm md:text-base leading-7 text-gray-900">{{ address.address }}</p>
|
||||
<p class="text-sm md:text-base leading-7 text-gray-900">
|
||||
{{ (address.post_code && address.city) ? `${address.address}, ${address.post_code} ${address.city}` : address.address }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CusTab>
|
||||
|
|
@ -335,6 +339,13 @@ const getTRRs = (p) => {
|
|||
:id="editAddressId"
|
||||
:edit="editAddress"
|
||||
/>
|
||||
<AddressUpdateForm
|
||||
:show="drawerAddAddress && editAddress"
|
||||
@close="drawerAddAddress = false"
|
||||
:person="person"
|
||||
:types="types.address_types"
|
||||
:id="editAddressId"
|
||||
/>
|
||||
|
||||
<PhoneCreateForm
|
||||
:show="drawerAddPhone"
|
||||
|
|
|
|||
|
|
@ -205,6 +205,49 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom tokens defaults -->
|
||||
<div class="bg-white border rounded-lg shadow-sm p-5 space-y-5">
|
||||
<h2 class="text-sm font-semibold tracking-wide text-gray-700 uppercase">
|
||||
Custom tokens (privzete vrednosti)
|
||||
</h2>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" :class="[btnBase, btnOutline]" @click="addCustomDefault">
|
||||
Dodaj vrstico
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="(row, idx) in customRows"
|
||||
:key="idx"
|
||||
class="grid grid-cols-12 items-center gap-2"
|
||||
>
|
||||
<input
|
||||
v-model="row.key"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full col-span-4"
|
||||
placeholder="custom ključ (npr. order_id)"
|
||||
/>
|
||||
<input
|
||||
v-model="row.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full col-span-5"
|
||||
placeholder="privzeta vrednost"
|
||||
/>
|
||||
<select v-model="row.type" class="select select-bordered select-sm w-full col-span-2">
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="date">date</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-ghost btn-xs col-span-1" @click="removeCustomDefault(idx)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[11px] text-gray-500">
|
||||
Uporabite v predlogi kot <code v-pre>{{custom.your_key}}</code>. Manjkajoče vrednosti se privzeto izpraznijo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
|
|
@ -274,7 +317,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { computed, reactive } from "vue";
|
||||
import { useForm, Link, router } from "@inertiajs/vue3";
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
|
||||
|
|
@ -303,6 +346,8 @@ const form = useForm({
|
|||
action_id: props.template.action_id ?? null,
|
||||
decision_id: props.template.decision_id ?? null,
|
||||
activity_note_template: props.template.activity_note_template || "",
|
||||
// meta will include custom_defaults on submit
|
||||
meta: props.template.meta || {},
|
||||
});
|
||||
|
||||
const toggleForm = useForm({});
|
||||
|
|
@ -322,6 +367,20 @@ function handleActionChange() {
|
|||
}
|
||||
|
||||
function submit() {
|
||||
// Build meta.custom_defaults object from rows
|
||||
const entries = customRows
|
||||
.filter((r) => (r.key || "").trim() !== "")
|
||||
.reduce((acc, r) => {
|
||||
acc[r.key.trim()] = r.value ?? "";
|
||||
return acc;
|
||||
}, {});
|
||||
const types = customRows
|
||||
.filter((r) => (r.key || "").trim() !== "")
|
||||
.reduce((acc, r) => {
|
||||
acc[r.key.trim()] = r.type || 'string';
|
||||
return acc;
|
||||
}, {});
|
||||
form.meta = Object.assign({}, form.meta || {}, { custom_defaults: entries, custom_default_types: types });
|
||||
form.put(route("admin.document-templates.settings.update", props.template.id));
|
||||
}
|
||||
|
||||
|
|
@ -330,4 +389,22 @@ function toggleActive() {
|
|||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Custom defaults rows state
|
||||
const baseDefaults = (props.template.meta && props.template.meta.custom_defaults) || {};
|
||||
const baseTypes = (props.template.meta && props.template.meta.custom_default_types) || {};
|
||||
const customRows = reactive(
|
||||
Object.keys(baseDefaults).length
|
||||
? Object.entries(baseDefaults).map(([k, v]) => ({ key: k, value: v, type: baseTypes[k] || 'string' }))
|
||||
: [{ key: "", value: "", type: 'string' }]
|
||||
);
|
||||
|
||||
function addCustomDefault() {
|
||||
customRows.push({ key: "", value: "", type: 'string' });
|
||||
}
|
||||
|
||||
function removeCustomDefault(idx) {
|
||||
customRows.splice(idx, 1);
|
||||
if (!customRows.length) customRows.push({ key: "", value: "" });
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
62
tests/Feature/DocumentCustomTokensTest.php
Normal file
62
tests/Feature/DocumentCustomTokensTest.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Contract;
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DocumentCustomTokensTest extends TestCase
|
||||
{
|
||||
public function test_custom_tokens_defaults_and_overrides(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
|
||||
$user->roles()->sync([$role->id]);
|
||||
$this->actingAs($user);
|
||||
|
||||
// Minimal docx with custom tokens
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'doc');
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($tmp, \ZipArchive::OVERWRITE);
|
||||
$zip->addFromString('[Content_Types].xml', '<Types></Types>');
|
||||
$zip->addFromString('word/document.xml', '<w:document><w:body>{{custom.order_id}}|{{custom.missing}}</w:body></w:document>');
|
||||
$zip->close();
|
||||
$bytes = file_get_contents($tmp);
|
||||
$upload = UploadedFile::fake()->createWithContent('template.docx', $bytes);
|
||||
|
||||
// Store template via controller to ensure tokens/metadata recorded
|
||||
$resp = $this->post(route('admin.document-templates.store'), [
|
||||
'name' => 'Custom Test',
|
||||
'slug' => 'custom-test',
|
||||
'file' => $upload,
|
||||
]);
|
||||
$resp->assertRedirect();
|
||||
|
||||
// Add defaults via meta
|
||||
$template = DocumentTemplate::where('slug', 'custom-test')->orderByDesc('version')->firstOrFail();
|
||||
$template->meta = ['custom_defaults' => ['order_id' => 'DEF-123']];
|
||||
$template->save();
|
||||
|
||||
$contract = Contract::factory()->create();
|
||||
|
||||
// Call generation with override for order_id; missing should become empty by default
|
||||
$gen = $this->postJson(route('contracts.generate-document', ['contract' => $contract->uuid]), [
|
||||
'template_slug' => 'custom-test',
|
||||
'custom' => ['order_id' => 'OVR-999'],
|
||||
]);
|
||||
$gen->assertOk()->assertJson(['status' => 'ok']);
|
||||
$doc = \App\Models\Document::latest('id')->first();
|
||||
$this->assertNotNull($doc);
|
||||
|
||||
// For strictness, we could open the docx to verify replacements, but that is heavy.
|
||||
// A lighter check: ensure no unresolved/fail occurred and file exists.
|
||||
$this->assertTrue(Storage::disk('public')->exists($doc->path));
|
||||
}
|
||||
}
|
||||
90
tests/Feature/DocumentCustomTokensTypesTest.php
Normal file
90
tests/Feature/DocumentCustomTokensTypesTest.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Contract;
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DocumentCustomTokensTypesTest extends TestCase
|
||||
{
|
||||
public function test_typed_custom_tokens_are_formatted(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
|
||||
$user->roles()->sync([$role->id]);
|
||||
$this->actingAs($user);
|
||||
|
||||
// Minimal docx with custom tokens for number and date
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'doc');
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($tmp, \ZipArchive::OVERWRITE);
|
||||
$zip->addFromString('[Content_Types].xml', '<Types></Types>');
|
||||
$zip->addFromString('word/document.xml', '<w:document><w:body>{{custom.total_amount}}|{{custom.due_date}}</w:body></w:document>');
|
||||
$zip->close();
|
||||
$bytes = file_get_contents($tmp);
|
||||
$upload = UploadedFile::fake()->createWithContent('typed.docx', $bytes);
|
||||
|
||||
// Create template
|
||||
$resp = $this->post(route('admin.document-templates.store'), [
|
||||
'name' => 'Typed Custom',
|
||||
'slug' => 'typed-custom',
|
||||
'file' => $upload,
|
||||
]);
|
||||
$resp->assertRedirect();
|
||||
|
||||
$template = DocumentTemplate::where('slug', 'typed-custom')->orderByDesc('version')->firstOrFail();
|
||||
$template->meta = [
|
||||
'custom_defaults' => [
|
||||
'total_amount' => null,
|
||||
'due_date' => null,
|
||||
],
|
||||
'custom_default_types' => [
|
||||
'total_amount' => 'number',
|
||||
'due_date' => 'date',
|
||||
],
|
||||
];
|
||||
$template->formatting_options = [
|
||||
'number_decimals' => 2,
|
||||
'decimal_separator' => ',',
|
||||
'thousands_separator' => '.',
|
||||
// date format may be controlled via template->date_format or formatting_options['default_date_format']
|
||||
'default_date_format' => 'd.m.Y',
|
||||
];
|
||||
$template->save();
|
||||
|
||||
$contract = Contract::factory()->create();
|
||||
|
||||
// Generate with explicit overrides
|
||||
$gen = $this->postJson(route('contracts.generate-document', ['contract' => $contract->uuid]), [
|
||||
'template_slug' => 'typed-custom',
|
||||
'custom' => [
|
||||
'total_amount' => '1234.5',
|
||||
'due_date' => '2025-10-12',
|
||||
],
|
||||
]);
|
||||
$gen->assertOk()->assertJson(['status' => 'ok']);
|
||||
|
||||
$doc = \App\Models\Document::latest('id')->first();
|
||||
$this->assertNotNull($doc);
|
||||
$this->assertTrue(Storage::disk('public')->exists($doc->path));
|
||||
|
||||
// Inspect generated document.xml to assert formatted values exist
|
||||
$path = Storage::disk('public')->path($doc->path);
|
||||
$z = new \ZipArchive;
|
||||
$this->assertTrue($z->open($path) === true);
|
||||
$xml = $z->getFromName('word/document.xml');
|
||||
$z->close();
|
||||
$this->assertIsString($xml);
|
||||
|
||||
// Expect EU formatted number and date
|
||||
$this->assertStringContainsString('1.234,50', $xml);
|
||||
$this->assertStringContainsString('12.10.2025', $xml);
|
||||
}
|
||||
}
|
||||
80
tests/Feature/DocumentNestedEntitiesTest.php
Normal file
80
tests/Feature/DocumentNestedEntitiesTest.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Models\Person\Person;
|
||||
use App\Models\Person\PersonAddress;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DocumentNestedEntitiesTest extends TestCase
|
||||
{
|
||||
public function test_client_person_and_person_address_tokens(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
|
||||
$user->roles()->sync([$role->id]);
|
||||
$this->actingAs($user);
|
||||
|
||||
// Build a simple DOCX containing nested tokens
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'doc');
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($tmp, \ZipArchive::OVERWRITE);
|
||||
$zip->addFromString('[Content_Types].xml', '<Types></Types>');
|
||||
$zip->addFromString('word/document.xml', '<w:document><w:body>{{client.person.full_name}}|{{person.person_address.address}},{{person.person_address.post_code}} {{person.person_address.city}}</w:body></w:document>');
|
||||
$zip->close();
|
||||
$bytes = file_get_contents($tmp);
|
||||
$upload = UploadedFile::fake()->createWithContent('nested.docx', $bytes);
|
||||
|
||||
$resp = $this->post(route('admin.document-templates.store'), [
|
||||
'name' => 'Nested',
|
||||
'slug' => 'nested',
|
||||
'file' => $upload,
|
||||
]);
|
||||
$resp->assertRedirect();
|
||||
|
||||
$template = DocumentTemplate::where('slug', 'nested')->orderByDesc('version')->firstOrFail();
|
||||
|
||||
// Create models
|
||||
$person = Person::factory()->create(['full_name' => 'Jane Doe']);
|
||||
$type = \App\Models\Person\AddressType::factory()->create();
|
||||
PersonAddress::create([
|
||||
'person_id' => $person->id,
|
||||
'address' => 'Main 1',
|
||||
'post_code' => '1000',
|
||||
'city' => 'Ljubljana',
|
||||
'type_id' => $type->id,
|
||||
'active' => 1,
|
||||
]);
|
||||
$client = Client::factory()->create(['person_id' => $person->id]);
|
||||
$case = ClientCase::factory()->create(['client_id' => $client->id, 'person_id' => $person->id]);
|
||||
$contract = Contract::factory()->create(['client_case_id' => $case->id]);
|
||||
|
||||
$gen = $this->postJson(route('contracts.generate-document', ['contract' => $contract->uuid]), [
|
||||
'template_slug' => 'nested',
|
||||
]);
|
||||
$gen->assertOk()->assertJson(['status' => 'ok']);
|
||||
|
||||
$doc = \App\Models\Document::latest('id')->first();
|
||||
$this->assertTrue(Storage::disk('public')->exists($doc->path));
|
||||
|
||||
// Inspect XML
|
||||
$path = Storage::disk('public')->path($doc->path);
|
||||
$z = new \ZipArchive;
|
||||
$this->assertTrue($z->open($path) === true);
|
||||
$xml = $z->getFromName('word/document.xml');
|
||||
$z->close();
|
||||
$this->assertIsString($xml);
|
||||
$this->assertStringContainsString('Jane Doe', $xml);
|
||||
$this->assertStringContainsString('Main 1,1000 Ljubljana', $xml);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user