Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions ProcessMaker/ImportExport/AssignmentRulesReconciliation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
<?php

namespace ProcessMaker\ImportExport;

use DOMElement;
use ProcessMaker\Models\Group;
use ProcessMaker\Models\User;
use ProcessMaker\Providers\WorkflowServiceProvider;

/**
* Import-only reconciliation for pm:assignmentRules (rule_expression tasks).
*
* The numeric assignee in an exported rule is a source-tenant ID and is never
* trusted on its own. Rules already carry a display identity in
* assignmentName, so import rebinds by that identity when it uniquely matches a
* target record; otherwise the rule is marked unresolved and its stale
* assignee is cleared.
*/
class AssignmentRulesReconciliation
{
public const ATTRIBUTE = 'assignmentRules';

/** Set on a rule when import could not bind a target user/group. */
public const KEY_UNRESOLVED = 'unresolved';

private const HUMAN_TASK_TAGS = ['task', 'userTask', 'manualTask'];

/**
* @return array<int, object|array<string, mixed>>
*/
public static function decodeRules(?string $json): array
{
if ($json === null || $json === '') {
return [];
}

$list = json_decode($json);
if (!is_array($list)) {
return [];
}

return $list;
}

/**
* @param array<int, object|array<string, mixed>> $rules
*/
public static function encodeRules(array $rules): string
{
return json_encode(array_values($rules), JSON_UNESCAPED_UNICODE);
}

/**
* @param array<int, object|array<string, mixed>> $rules
* @return array<int, object|array<string, mixed>>
*/
public static function reconcileRulesAfterImport(array $rules): array
{
$reconciled = [];
foreach ($rules as $rule) {
$reconciled[] = self::reconcileRuleAfterImport($rule);
}

return $reconciled;
}

/**
* @param object|array<string, mixed> $rule
* @return object|array<string, mixed>
*/
public static function reconcileRuleAfterImport(object|array $rule): object|array
{
$type = self::ruleGet($rule, 'type');
$assignee = null;

if ($type === 'user') {
$username = self::usernameFromAssignmentName(self::ruleGet($rule, 'assignmentName'));
// username is unique in the users table and is the same key
// UserExporter's fallback matching already trusts on import.
$assignee = $username ? User::where('username', $username)->value('id') : null;
} elseif ($type === 'group') {
$name = self::ruleGet($rule, 'assignmentName');
$name = is_string($name) ? trim($name) : '';
// Group names are not unique in the schema, so only an
// unambiguous match may be rebound.
$groups = $name !== '' ? Group::where('name', $name)->get() : collect();
$assignee = $groups->count() === 1 ? $groups->first()->id : null;
} else {
return $rule;
}

return $assignee === null
? self::markRuleUnresolved($rule)
: self::markRuleResolved($rule, $assignee);
}

/**
* Extract the username from "Full Name (username)", tolerating the
* " - Inactive" suffix SelectUserGroup appends for inactive users.
*/
public static function usernameFromAssignmentName(mixed $assignmentName): ?string
{
if (!is_string($assignmentName) || !preg_match('/\(([^()]*)\)\s*$/', trim($assignmentName), $matches)) {
return null;
}

$username = trim(preg_replace('/\s+-\s+Inactive$/i', '', trim($matches[1])));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Handle translated inactive-user suffixes

The picker generates the inactive suffix using $t('Inactive'), but this parser strips only the English text. Confirmed with the Spanish formatter output: Name (username - inactivo) is parsed as username - inactivo, causing an existing target user to be marked unresolved and its assignee cleared. Please support the localized label format generated by the picker.


return $username !== '' ? $username : null;
}

/**
* @param object|array<string, mixed> $rule
* @return object|array<string, mixed>
*/
public static function markRuleUnresolved(object|array $rule): object|array
{
$rule = self::ruleSet($rule, 'assignee', null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Prevent unresolved rules from bypassing the default assignment

When an imported rule is marked unresolved, its expression remains executable with assignee=null. A matching unresolved user rule caused the task to be assigned to the Process Manager instead of the valid Default Assignment. Please skip unresolved rules during evaluation or prevent execution until they are resolved.

$rule = self::ruleSet($rule, self::KEY_UNRESOLVED, true);

return $rule;
}

/**
* @return iterable<int, array{element: DOMElement, path: string, rules: array<int, object|array<string, mixed>>}>
*/
public static function collectAssignmentRulesFromDocument(\DOMDocument $document): iterable
{
$ns = WorkflowServiceProvider::PROCESS_MAKER_NS;
foreach (self::HUMAN_TASK_TAGS as $tag) {
foreach ($document->getElementsByTagName($tag) as $element) {
if (!$element instanceof DOMElement) {
continue;
}
if ($element->getAttributeNS($ns, 'assignment') !== 'rule_expression') {
continue;
}
$raw = $element->getAttributeNS($ns, self::ATTRIBUTE);
if ($raw === '') {
continue;
}
yield [
'element' => $element,
'path' => $element->getNodePath(),
'rules' => self::decodeRules($raw),
];
}
}
}

public static function writeRulesOnElement(DOMElement $element, array $rules): void
{
$ns = WorkflowServiceProvider::PROCESS_MAKER_NS;
$element->setAttributeNS($ns, self::ATTRIBUTE, self::encodeRules($rules));
}

/**
* Reconcile every rule_expression assignmentRules block on a BPMN document.
*/
public static function reconcileAssignmentRulesInDocument(\DOMDocument $document): bool
{
$updated = false;

foreach (self::collectAssignmentRulesFromDocument($document) as $item) {
$reconciled = self::reconcileRulesAfterImport($item['rules']);
self::writeRulesOnElement($item['element'], $reconciled);
$updated = true;
}

return $updated;
}

/**
* @param object|array<string, mixed> $rule
* @return object|array<string, mixed>
*/
private static function markRuleResolved(object|array $rule, mixed $assignee): object|array
{
$rule = self::ruleSet($rule, 'assignee', $assignee);

if (is_array($rule)) {
unset($rule[self::KEY_UNRESOLVED]);

return $rule;
}

if (property_exists($rule, self::KEY_UNRESOLVED)) {
unset($rule->{self::KEY_UNRESOLVED});
}

return $rule;
}

/**
* @param object|array<string, mixed> $rule
*/
private static function ruleGet(object|array $rule, string $key): mixed
{
if (is_array($rule)) {
return $rule[$key] ?? null;
}

return $rule->$key ?? null;
}

/**
* @param object|array<string, mixed> $rule
* @return object|array<string, mixed>
*/
private static function ruleSet(object|array $rule, string $key, mixed $value): object|array
{
if (is_array($rule)) {
$rule[$key] = $value;

return $rule;
}

$rule->$key = $value;

return $rule;
}
}
11 changes: 11 additions & 0 deletions ProcessMaker/ImportExport/Exporters/ProcessExporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use DOMXPath;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use ProcessMaker\ImportExport\AssignmentRulesReconciliation;
use ProcessMaker\ImportExport\DependentType;
use ProcessMaker\ImportExport\Psudomodels\Signal;
use ProcessMaker\ImportExport\Utils;
Expand Down Expand Up @@ -314,6 +315,15 @@ private function exportAssignments()
}
}

private function importAssignmentRules(): void
{
$document = $this->model->getDefinitions(true);

if (AssignmentRulesReconciliation::reconcileAssignmentRulesInDocument($document)) {
$this->model->bpmn = $document->saveXML();
}
}

private function exportScreens()
{
$tags = [
Expand Down Expand Up @@ -480,6 +490,7 @@ private function importAssetsByMode(): void
$this->importScripts();
$this->importSubprocesses();
$this->importAssignments();
$this->importAssignmentRules();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reconcile assignment rules in the model-only template editor

This call runs only for saveAllAssets, so opening a saveModelOnly template preserves source assignee IDs. Confirmed with an ID collision: the expression row displayed the exported user, while its edit picker loaded a different target user. Creating a process from that template subsequently reconciled the reference, so the confirmed issue is limited to template editing. Please reconcile assignment rules independently of the asset-import mode and cover this template editing path.

$this->importProcessLaunchpad();
$this->importElementDestination();
}
Expand Down
4 changes: 4 additions & 0 deletions ProcessMaker/Jobs/ImportProcess.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use ProcessMaker\Events\ImportedScreenSaved;
use ProcessMaker\ImportExport\AssignmentRulesReconciliation;
use ProcessMaker\Managers\ExportManager;
use ProcessMaker\Models\AnonymousUser;
use ProcessMaker\Models\Process;
Expand Down Expand Up @@ -518,6 +519,7 @@ protected function saveScreen($screen)
return $new;
} catch (\Exception $e) {
Log::error('Import Screen: ' . $e->getMessage());

return false;
}
}
Expand Down Expand Up @@ -781,6 +783,8 @@ private function validatePackages($process)
*/
private function saveBpmn($process)
{
AssignmentRulesReconciliation::reconcileAssignmentRulesInDocument($this->definitions);

$this->new['process']->bpmn = $this->definitions->saveXML();
if (isset($process->cancel_screen_id)) {
$this->new['process']->cancel_screen_id = $process->cancel_screen_id;
Expand Down
54 changes: 40 additions & 14 deletions resources/js/components/SelectUserGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -165,45 +165,71 @@
this.loading = true;
let results = [];

// showAlert: false + local catch: a stale/missing assignee must not
// surface as a global 404 toast or leave the picker loading forever.
let usersPromise = Promise.all(
value.users.map(item => {
let id = null;
if (typeof item == 'number' || typeof item == 'string') {
return ProcessMaker.apiClient.get("users/" + item);
} else {
if (item.assignee) {
let id = item.assignee;
return ProcessMaker.apiClient.get("users/" + id);
}
id = item;
} else if (item && item.assignee) {
id = item.assignee;
}
if (!id) {
return Promise.resolve(null);
}
return ProcessMaker.apiClient.get("users/" + id, { showAlert: false })
.then(response => this.addUsernameToFullName(response.data))
.catch(error => {
if (error?.config) {
error.config._defaultErrorShown = true;
}
return null;
});
})
)
.then(items => {
items.forEach(item => {
results.push(this.addUsernameToFullName(item.data));
if (item) {
results.push(item);
}
});
});

let groupsPromise = Promise.all(
value.groups.map(item => {
let id = null;
if (typeof item == 'number' || typeof item == 'string' ) {
return ProcessMaker.apiClient.get("groups/" + item);
} else {
if (item.assignee) {
let id = this.unformatGroup(item.assignee);
return ProcessMaker.apiClient.get("groups/" + id);
}
id = item;
} else if (item && item.assignee) {
id = this.unformatGroup(item.assignee);
}
if (!id) {
return Promise.resolve(null);
}
return ProcessMaker.apiClient.get("groups/" + id, { showAlert: false })
.then(response => this.formatGroup(response.data))
.catch(error => {
if (error?.config) {
error.config._defaultErrorShown = true;
}
return null;
});
})
)
.then(items => {
items.forEach(item => {
results.push(this.formatGroup(item.data));
if (item) {
results.push(item);
}
});
});

Promise.all([usersPromise, groupsPromise])
.then(() => {
this.content = results;
})
.finally(() => {
this.loading = false;
});
}
Expand Down
Loading
Loading