Commit c8b79155 authored by Administrator's avatar Administrator

Update 15 files via Son of Anton

parent 216ae487
<?php
declare(strict_types=1);
namespace App\Modules\Workflow\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Workflow\Models\WorkflowDefinition;
use App\Modules\Workflow\Models\WorkflowInstance;
use App\Modules\Workflow\Models\WorkflowTransitionLog;
use App\Modules\Workflow\Services\WorkflowEngine;
class WorkflowController extends Controller
{
public function index(Request $request): Response
{
$definitions = WorkflowDefinition::allActive();
$db = App::getInstance()->db();
$stats = [];
foreach ($definitions as $def) {
$activeCount = $db->selectOne(
"SELECT COUNT(*) as cnt FROM workflow_instances WHERE workflow_code = ? AND is_completed = 0",
[$def['workflow_code']]
);
$completedCount = $db->selectOne(
"SELECT COUNT(*) as cnt FROM workflow_instances WHERE workflow_code = ? AND is_completed = 1",
[$def['workflow_code']]
);
$stats[$def['workflow_code']] = [
'active' => (int) ($activeCount['cnt'] ?? 0),
'completed' => (int) ($completedCount['cnt'] ?? 0),
];
}
return $this->view('Workflow.Views.index', [
'definitions' => $definitions,
'stats' => $stats,
]);
}
public function instance(Request $request, string $id): Response
{
$instance = WorkflowInstance::find((int) $id);
if (!$instance) {
return $this->redirect('/workflows')->withError('مثيل دورة العمل غير موجود');
}
$definition = $instance->getDefinition();
$history = WorkflowEngine::getHistory((int) $instance->id);
$available = WorkflowEngine::getAvailableTransitions((int) $instance->id);
return $this->view('Workflow.Views.instance', [
'instance' => $instance,
'definition' => $definition,
'history' => $history,
'available' => $available,
]);
}
public function transition(Request $request, string $id): Response
{
$transitionName = trim((string) $request->post('transition_name', ''));
$notes = trim((string) $request->post('notes', ''));
if ($transitionName === '') {
return $this->redirect("/workflows/instances/{$id}")->withError('يرجى تحديد الانتقال');
}
try {
WorkflowEngine::transition((int) $id, $transitionName, $notes ?: null, 'manual');
return $this->redirect("/workflows/instances/{$id}")->withSuccess('تم تنفيذ الانتقال بنجاح');
} catch (\RuntimeException $e) {
return $this->redirect("/workflows/instances/{$id}")->withError('فشل تنفيذ الانتقال: ' . $e->getMessage());
}
}
public function diagram(Request $request, string $code): Response
{
$definition = WorkflowDefinition::findByCode($code);
if (!$definition) {
return $this->redirect('/workflows')->withError('تعريف دورة العمل غير موجود');
}
return $this->view('Workflow.Views.diagram', [
'definition' => $definition,
]);
}
public function instances(Request $request, string $code): Response
{
$db = App::getInstance()->db();
$status = $request->get('status', '');
$where = "wi.workflow_code = ?";
$params = [$code];
if ($status === 'active') {
$where .= " AND wi.is_completed = 0";
} elseif ($status === 'completed') {
$where .= " AND wi.is_completed = 1";
}
$rows = $db->select(
"SELECT wi.*, wd.name_ar as definition_name_ar
FROM workflow_instances wi
JOIN workflow_definitions wd ON wd.id = wi.workflow_definition_id
WHERE {$where}
ORDER BY wi.updated_at DESC
LIMIT 100",
$params
);
$definition = WorkflowDefinition::findByCode($code);
return $this->view('Workflow.Views.index', [
'definitions' => WorkflowDefinition::allActive(),
'stats' => [],
'filteredInstances' => $rows,
'filterCode' => $code,
'filterStatus' => $status,
'currentDef' => $definition,
]);
}
public function processTimeouts(Request $request): Response
{
$processed = WorkflowEngine::processTimeouts();
$count = count($processed);
return $this->redirect('/workflows')->withSuccess("تم معالجة {$count} انتقال تلقائي");
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Workflow\Models;
use App\Core\Model;
use App\Core\App;
class WorkflowDefinition extends Model
{
protected static string $table = 'workflow_definitions';
protected static bool $softDelete = false;
protected static bool $timestamps = true;
protected static array $fillable = [
'workflow_code', 'name_ar', 'name_en', 'description_ar',
'definition_json', 'version', 'is_active',
];
public function getDefinition(): array
{
return json_decode($this->definition_json ?? '{"states":{},"transitions":[]}', true) ?? ['states' => [], 'transitions' => []];
}
public function getStates(): array
{
$def = $this->getDefinition();
return $def['states'] ?? [];
}
public function getTransitions(): array
{
$def = $this->getDefinition();
return $def['transitions'] ?? [];
}
public function getTransitionsFrom(string $state): array
{
$transitions = $this->getTransitions();
return array_filter($transitions, fn($t) => ($t['from'] ?? '') === $state);
}
public function getInitialState(): ?string
{
foreach ($this->getStates() as $key => $state) {
if (($state['type'] ?? '') === 'initial') {
return $key;
}
}
$states = $this->getStates();
return !empty($states) ? array_key_first($states) : null;
}
public function findTransition(string $name): ?array
{
foreach ($this->getTransitions() as $t) {
if (($t['name'] ?? '') === $name) {
return $t;
}
}
return null;
}
public static function findByCode(string $code): ?static
{
$db = App::getInstance()->db();
$row = $db->selectOne("SELECT * FROM workflow_definitions WHERE workflow_code = ? AND is_active = 1", [$code]);
if (!$row) {
return null;
}
$instance = new static($row);
$instance->exists = true;
return $instance;
}
public static function allActive(): array
{
$db = App::getInstance()->db();
return $db->select("SELECT * FROM workflow_definitions WHERE is_active = 1 ORDER BY name_ar");
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Workflow\Models;
use App\Core\Model;
use App\Core\App;
class WorkflowInstance extends Model
{
protected static string $table = 'workflow_instances';
protected static bool $softDelete = false;
protected static bool $timestamps = true;
protected static array $fillable = [
'workflow_definition_id', 'workflow_code', 'entity_type', 'entity_id',
'current_state', 'state_entered_at', 'state_data_json',
'is_completed', 'completed_at',
];
public function getStateData(): array
{
return json_decode($this->state_data_json ?? '{}', true) ?? [];
}
public function getDefinition(): ?WorkflowDefinition
{
return WorkflowDefinition::find((int) $this->workflow_definition_id);
}
public static function findForEntity(string $entityType, int $entityId, ?string $workflowCode = null): ?static
{
$db = App::getInstance()->db();
$sql = "SELECT * FROM workflow_instances WHERE entity_type = ? AND entity_id = ? AND is_completed = 0";
$params = [$entityType, $entityId];
if ($workflowCode !== null) {
$sql .= " AND workflow_code = ?";
$params[] = $workflowCode;
}
$sql .= " ORDER BY created_at DESC LIMIT 1";
$row = $db->selectOne($sql, $params);
if (!$row) {
return null;
}
$instance = new static($row);
$instance->exists = true;
return $instance;
}
public static function allForEntity(string $entityType, int $entityId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT wi.*, wd.name_ar as definition_name_ar FROM workflow_instances wi JOIN workflow_definitions wd ON wd.id = wi.workflow_definition_id WHERE wi.entity_type = ? AND wi.entity_id = ? ORDER BY wi.created_at DESC",
[$entityType, $entityId]
);
}
public static function getActiveByState(string $workflowCode, string $state): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM workflow_instances WHERE workflow_code = ? AND current_state = ? AND is_completed = 0",
[$workflowCode, $state]
);
}
public static function getTimedOut(string $workflowCode, string $state, int $timeoutDays): array
{
$db = App::getInstance()->db();
$cutoff = date('Y-m-d H:i:s', time() - ($timeoutDays * 86400));
return $db->select(
"SELECT * FROM workflow_instances WHERE workflow_code = ? AND current_state = ? AND is_completed = 0 AND state_entered_at <= ?",
[$workflowCode, $state, $cutoff]
);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Workflow\Models;
use App\Core\App;
class WorkflowTransitionLog
{
public static function create(array $data): int
{
$db = App::getInstance()->db();
return $db->insert('workflow_transition_log', $data);
}
public static function getForInstance(int $instanceId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT wtl.*, e.full_name_ar as employee_name FROM workflow_transition_log wtl LEFT JOIN employees e ON e.id = wtl.triggered_by_employee_id WHERE wtl.workflow_instance_id = ? ORDER BY wtl.created_at ASC",
[$instanceId]
);
}
public static function getLatest(int $instanceId): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT * FROM workflow_transition_log WHERE workflow_instance_id = ? ORDER BY created_at DESC LIMIT 1",
[$instanceId]
);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/workflows', 'Workflow\Controllers\WorkflowController@index', ['auth', 'csrf'], 'rules.view'],
['GET', '/workflows/diagram/{code}', 'Workflow\Controllers\WorkflowController@diagram', ['auth', 'csrf'], 'rules.view'],
['GET', '/workflows/instances/{code}', 'Workflow\Controllers\WorkflowController@instances', ['auth', 'csrf'], 'rules.view'],
['GET', '/workflows/instances/{id:\d+}', 'Workflow\Controllers\WorkflowController@instance', ['auth', 'csrf'], 'rules.view'],
['POST', '/workflows/instances/{id:\d+}/transition','Workflow\Controllers\WorkflowController@transition', ['auth', 'csrf'], 'rules.edit'],
['POST', '/workflows/process-timeouts', 'Workflow\Controllers\WorkflowController@processTimeouts', ['auth', 'csrf'], 'rules.edit'],
];
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Workflow\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Workflow\Models\WorkflowDefinition;
use App\Modules\Workflow\Models\WorkflowInstance;
use App\Modules\Workflow\Models\WorkflowTransitionLog;
final class WorkflowEngine
{
/**
* Create a new workflow instance for an entity.
*/
public static function createInstance(
string $workflowCode,
string $entityType,
int $entityId,
?array $stateData = null
): WorkflowInstance {
$definition = WorkflowDefinition::findByCode($workflowCode);
if (!$definition) {
throw new \RuntimeException("Workflow definition not found: {$workflowCode}");
}
$initialState = $definition->getInitialState();
if ($initialState === null) {
throw new \RuntimeException("No initial state defined for workflow: {$workflowCode}");
}
$instance = WorkflowInstance::create([
'workflow_definition_id' => (int) $definition->id,
'workflow_code' => $workflowCode,
'entity_type' => $entityType,
'entity_id' => $entityId,
'current_state' => $initialState,
'state_entered_at' => date('Y-m-d H:i:s'),
'state_data_json' => $stateData !== null ? json_encode($stateData, JSON_UNESCAPED_UNICODE) : null,
'is_completed' => 0,
]);
Logger::info("Workflow instance created", [
'workflow_code' => $workflowCode,
'entity_type' => $entityType,
'entity_id' => $entityId,
'initial_state' => $initialState,
'instance_id' => (int) $instance->id,
]);
$eventData = [
'instance_id' => (int) $instance->id,
'workflow_code' => $workflowCode,
'entity_type' => $entityType,
'entity_id' => $entityId,
'current_state' => $initialState,
];
EventBus::dispatch('workflow.created', $eventData);
return $instance;
}
/**
* Execute a transition on a workflow instance.
*/
public static function transition(
int $instanceId,
string $transitionName,
?string $notes = null,
string $triggerType = 'manual'
): bool {
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$instance = WorkflowInstance::find($instanceId);
if (!$instance) {
throw new \RuntimeException("Workflow instance not found: {$instanceId}");
}
if ($instance->is_completed) {
throw new \RuntimeException("Workflow instance is already completed");
}
$definition = $instance->getDefinition();
if (!$definition) {
throw new \RuntimeException("Workflow definition not found for instance: {$instanceId}");
}
$transition = $definition->findTransition($transitionName);
if (!$transition) {
throw new \RuntimeException("Transition not found: {$transitionName}");
}
if (($transition['from'] ?? '') !== $instance->current_state) {
throw new \RuntimeException(
"Cannot execute transition '{$transitionName}' from state '{$instance->current_state}'. Expected state: '{$transition['from']}'"
);
}
// Evaluate guards
$guards = $transition['guards'] ?? [];
$guardResults = [];
foreach ($guards as $guard) {
$result = WorkflowGuardEvaluator::evaluate($guard, $instance);
$guardResults[] = [
'type' => $guard['type'] ?? 'unknown',
'value' => $guard['value'] ?? '',
'passed' => $result,
];
if (!$result) {
Logger::warning("Workflow guard failed", [
'instance_id' => $instanceId,
'transition' => $transitionName,
'guard_type' => $guard['type'] ?? 'unknown',
'guard_value' => $guard['value'] ?? '',
]);
throw new \RuntimeException(
"Guard failed for transition '{$transitionName}': {$guard['type']}{$guard['value']}"
);
}
}
$fromState = $instance->current_state;
$toState = $transition['to'];
// Check if target state is terminal
$states = $definition->getStates();
$isTerminal = ($states[$toState]['type'] ?? '') === 'terminal';
// Begin transaction
$db->beginTransaction();
try {
// Update instance
$updateData = [
'current_state' => $toState,
'state_entered_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
];
if ($isTerminal) {
$updateData['is_completed'] = 1;
$updateData['completed_at'] = date('Y-m-d H:i:s');
}
$db->update('workflow_instances', $updateData, '`id` = ?', [$instanceId]);
// Log transition
WorkflowTransitionLog::create([
'workflow_instance_id' => $instanceId,
'from_state' => $fromState,
'to_state' => $toState,
'transition_name' => $transitionName,
'triggered_by_employee_id' => $employee ? (int) ($employee->id ?? 0) : null,
'trigger_type' => $triggerType,
'guard_results_json' => json_encode($guardResults, JSON_UNESCAPED_UNICODE),
'notes' => $notes,
'created_at' => date('Y-m-d H:i:s'),
]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
// Execute actions (after commit — these are side effects)
$actions = $transition['actions'] ?? [];
foreach ($actions as $action) {
self::executeAction($action, $instance, $toState);
}
// Dispatch workflow event
$eventData = [
'instance_id' => $instanceId,
'workflow_code' => $instance->workflow_code,
'entity_type' => $instance->entity_type,
'entity_id' => (int) $instance->entity_id,
'from_state' => $fromState,
'to_state' => $toState,
'transition_name' => $transitionName,
'trigger_type' => $triggerType,
'is_completed' => $isTerminal,
];
EventBus::dispatch('workflow.transitioned', $eventData);
Logger::info("Workflow transition executed", [
'instance_id' => $instanceId,
'transition' => $transitionName,
'from' => $fromState,
'to' => $toState,
]);
return true;
}
/**
* Get available transitions from current state.
*/
public static function getAvailableTransitions(int $instanceId): array
{
$instance = WorkflowInstance::find($instanceId);
if (!$instance || $instance->is_completed) {
return [];
}
$definition = $instance->getDefinition();
if (!$definition) {
return [];
}
$available = [];
$transitions = $definition->getTransitionsFrom($instance->current_state);
foreach ($transitions as $transition) {
$autoTrigger = $transition['trigger'] ?? 'manual';
if ($autoTrigger === 'automatic') {
continue; // Automatic transitions are not shown as available manual actions
}
$canExecute = true;
$guards = $transition['guards'] ?? [];
foreach ($guards as $guard) {
if (!WorkflowGuardEvaluator::evaluate($guard, $instance)) {
$canExecute = false;
break;
}
}
$states = $definition->getStates();
$toStateInfo = $states[$transition['to']] ?? [];
$available[] = [
'name' => $transition['name'],
'to_state' => $transition['to'],
'to_label_ar' => $toStateInfo['label_ar'] ?? $transition['to'],
'to_label_en' => $toStateInfo['label_en'] ?? $transition['to'],
'can_execute' => $canExecute,
'guards' => $guards,
];
}
return $available;
}
/**
* Get current state for an entity.
*/
public static function getCurrentState(string $entityType, int $entityId, ?string $workflowCode = null): ?string
{
$instance = WorkflowInstance::findForEntity($entityType, $entityId, $workflowCode);
return $instance ? $instance->current_state : null;
}
/**
* Get full transition history for an instance.
*/
public static function getHistory(int $instanceId): array
{
return WorkflowTransitionLog::getForInstance($instanceId);
}
/**
* Check for timed-out workflow instances across all workflows.
*/
public static function checkTimeouts(): array
{
$timedOut = [];
$definitions = WorkflowDefinition::allActive();
foreach ($definitions as $defRow) {
$definition = new WorkflowDefinition($defRow);
$definition->exists = true;
$transitions = $definition->getTransitions();
foreach ($transitions as $transition) {
if (($transition['trigger'] ?? '') !== 'automatic') {
continue;
}
$guards = $transition['guards'] ?? [];
foreach ($guards as $guard) {
if (($guard['type'] ?? '') === 'timeout' && isset($guard['days'])) {
$days = (int) $guard['days'];
$fromState = $transition['from'];
$instances = WorkflowInstance::getTimedOut($defRow['workflow_code'], $fromState, $days);
foreach ($instances as $inst) {
$timedOut[] = [
'instance_id' => (int) $inst['id'],
'workflow_code' => $inst['workflow_code'],
'entity_type' => $inst['entity_type'],
'entity_id' => (int) $inst['entity_id'],
'current_state' => $inst['current_state'],
'transition_name' => $transition['name'],
'timeout_days' => $days,
'state_entered_at' => $inst['state_entered_at'],
];
}
}
}
}
}
return $timedOut;
}
/**
* Process all timed-out transitions automatically.
*/
public static function processTimeouts(): array
{
$timedOut = self::checkTimeouts();
$processed = [];
foreach ($timedOut as $item) {
try {
self::transition(
$item['instance_id'],
$item['transition_name'],
'انتقال تلقائي بسبب انتهاء المهلة',
'timeout'
);
$processed[] = $item;
} catch (\Throwable $e) {
Logger::error("Failed to process timeout transition", [
'instance_id' => $item['instance_id'],
'transition' => $item['transition_name'],
'error' => $e->getMessage(),
]);
}
}
return $processed;
}
/**
* Execute a transition action.
*/
private static function executeAction(array $action, WorkflowInstance $instance, string $newState): void
{
$type = $action['type'] ?? '';
$value = $action['value'] ?? '';
switch ($type) {
case 'event':
$eventData = [
'workflow_instance_id' => (int) $instance->id,
'workflow_code' => $instance->workflow_code,
'entity_type' => $instance->entity_type,
'entity_id' => (int) $instance->entity_id,
'new_state' => $newState,
];
EventBus::dispatch($value, $eventData);
break;
case 'notification':
EventBus::dispatch('notification.send', [
'template' => $value,
'entity_type' => $instance->entity_type,
'entity_id' => (int) $instance->entity_id,
]);
break;
default:
Logger::warning("Unknown workflow action type: {$type}", [
'instance_id' => (int) $instance->id,
'value' => $value,
]);
break;
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Workflow\Services;
use App\Core\App;
use App\Core\Logger;
use App\Core\Registries\ActionRegistry;
use App\Modules\Workflow\Models\WorkflowInstance;
final class WorkflowGuardEvaluator
{
/**
* Evaluate a single guard condition against a workflow instance.
*/
public static function evaluate(array $guard, WorkflowInstance $instance): bool
{
$type = $guard['type'] ?? '';
$value = $guard['value'] ?? '';
return match ($type) {
'permission' => self::evaluatePermission($value),
'role' => self::evaluateRole($value),
'condition' => self::evaluateCondition($value, $instance),
'timeout' => self::evaluateTimeout($guard, $instance),
default => self::evaluateCustom($type, $value, $instance),
};
}
/**
* Check if current employee has the required permission.
*/
private static function evaluatePermission(string $permissionKey): bool
{
$employee = App::getInstance()->currentEmployee();
if (!$employee) {
return false;
}
if (method_exists($employee, 'hasPermission')) {
return $employee->hasPermission($permissionKey);
}
return false;
}
/**
* Check if current employee has the required role.
*/
private static function evaluateRole(string $roleCode): bool
{
$employee = App::getInstance()->currentEmployee();
if (!$employee) {
return false;
}
if (method_exists($employee, 'hasRole')) {
return $employee->hasRole($roleCode);
}
return false;
}
/**
* Evaluate a named condition.
* Conditions can be registered by other modules via ActionRegistry.
*/
private static function evaluateCondition(string $conditionName, WorkflowInstance $instance): bool
{
// Check if a condition handler is registered
$handler = ActionRegistry::get('guard_condition.' . $conditionName);
if ($handler !== null && isset($handler['handler']) && is_callable($handler['handler'])) {
return (bool) ($handler['handler'])([
'instance' => $instance,
'entity_type' => $instance->entity_type,
'entity_id' => (int) $instance->entity_id,
'state_data' => $instance->getStateData(),
]);
}
// Built-in conditions
return match ($conditionName) {
'has_payment_receipt' => self::checkHasPaymentReceipt($instance),
'is_subscription_paid' => self::checkSubscriptionPaid($instance),
'within_appeal_window' => self::checkWithinAppealWindow($instance),
'five_years_unpaid' => self::checkFiveYearsUnpaid($instance),
default => self::evaluateFallbackCondition($conditionName, $instance),
};
}
/**
* Evaluate timeout guard — has enough time passed since entering current state?
*/
private static function evaluateTimeout(array $guard, WorkflowInstance $instance): bool
{
$days = (int) ($guard['days'] ?? 0);
if ($days <= 0) {
return false;
}
$enteredAt = strtotime($instance->state_entered_at);
if ($enteredAt === false) {
return false;
}
$timeoutAt = $enteredAt + ($days * 86400);
return time() >= $timeoutAt;
}
/**
* Custom guard type — check ActionRegistry for a handler.
*/
private static function evaluateCustom(string $type, string $value, WorkflowInstance $instance): bool
{
$handler = ActionRegistry::get('guard_type.' . $type);
if ($handler !== null && isset($handler['handler']) && is_callable($handler['handler'])) {
return (bool) ($handler['handler'])([
'value' => $value,
'instance' => $instance,
'entity_type' => $instance->entity_type,
'entity_id' => (int) $instance->entity_id,
]);
}
Logger::warning("Unknown guard type with no registered handler: {$type}", [
'value' => $value,
'instance_id' => (int) $instance->id,
]);
return true; // Unknown guards pass by default to not block workflows
}
// ── Built-in condition implementations ──
private static function checkHasPaymentReceipt(WorkflowInstance $instance): bool
{
$db = App::getInstance()->db();
// Check if payments table exists
if (!$db->tableExists('payments')) {
// Phase 11 not yet built — check state_data for manual receipt
$stateData = $instance->getStateData();
return !empty($stateData['receipt_number']);
}
$payment = $db->selectOne(
"SELECT id FROM payments WHERE related_entity_type = ? AND related_entity_id = ? AND is_voided = 0 LIMIT 1",
[$instance->entity_type, (int) $instance->entity_id]
);
return $payment !== null;
}
private static function checkSubscriptionPaid(WorkflowInstance $instance): bool
{
$db = App::getInstance()->db();
if (!$db->tableExists('subscriptions')) {
return true; // Phase 12 not yet built
}
$currentYear = date('Y');
$unpaid = $db->selectOne(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND status NOT IN ('paid', 'exempt') LIMIT 1",
[(int) $instance->entity_id, $currentYear]
);
return $unpaid === null;
}
private static function checkWithinAppealWindow(WorkflowInstance $instance): bool
{
$enteredAt = strtotime($instance->state_entered_at);
if ($enteredAt === false) {
return false;
}
// Default 15 days appeal window — can be overridden by rules engine
$appealDays = 15;
try {
$db = App::getInstance()->db();
if ($db->tableExists('business_rules')) {
$rule = $db->selectOne(
"SELECT current_value_json FROM business_rules WHERE rule_code = 'APPEAL_WINDOW' AND is_active = 1 AND branch_id IS NULL",
[]
);
if ($rule) {
$val = json_decode($rule['current_value_json'], true);
$appealDays = (int) ($val['days'] ?? 15);
}
}
} catch (\Throwable $e) {
// Use default
}
$deadline = $enteredAt + ($appealDays * 86400);
return time() <= $deadline;
}
private static function checkFiveYearsUnpaid(WorkflowInstance $instance): bool
{
$db = App::getInstance()->db();
if (!$db->tableExists('subscriptions')) {
return false; // Phase 12 not yet built
}
$currentYear = (int) date('Y');
$unpaidYears = 0;
for ($y = $currentYear; $y >= $currentYear - 6; $y--) {
$unpaid = $db->selectOne(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND status NOT IN ('paid', 'exempt') LIMIT 1",
[(int) $instance->entity_id, (string) $y]
);
if ($unpaid) {
$unpaidYears++;
} else {
break;
}
}
return $unpaidYears >= 5;
}
private static function evaluateFallbackCondition(string $conditionName, WorkflowInstance $instance): bool
{
// Check state_data for manual condition flags
$stateData = $instance->getStateData();
if (isset($stateData[$conditionName])) {
return (bool) $stateData[$conditionName];
}
Logger::warning("Unknown condition with no handler and no state_data flag: {$conditionName}", [
'instance_id' => (int) $instance->id,
]);
return false;
}
}
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مخطط: <?= e($definition->name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/workflows" class="btn btn-outline">← العودة</a>
<a href="/workflows/instances/<?= e($definition->workflow_code) ?>" class="btn btn-outline">عرض المثيلات</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$states = $definition->getStates();
$transitions = $definition->getTransitions();
?>
<div class="card" style="margin-bottom:20px;padding:20px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<div>
<h2 style="margin:0;color:#0D7377;"><?= e($definition->name_ar) ?></h2>
<p style="color:#6B7280;margin:5px 0 0;"><?= e($definition->description_ar ?? '') ?></p>
</div>
<div style="text-align:left;">
<code style="font-size:12px;color:#9CA3AF;"><?= e($definition->workflow_code) ?></code><br>
<small style="color:#6B7280;">الإصدار <?= (int) $definition->version ?></small>
</div>
</div>
<!-- State Diagram (CSS-based flowchart) -->
<div style="display:flex;flex-wrap:wrap;gap:20px;justify-content:center;padding:30px 0;">
<?php foreach ($states as $key => $state): ?>
<?php
$type = $state['type'] ?? 'intermediate';
$bgColor = match($type) {
'initial' => '#EFF6FF',
'terminal' => '#FEF2F2',
'intermediate' => '#FFFFFF',
default => '#F9FAFB',
};
$borderColor = match($type) {
'initial' => '#0284C7',
'terminal' => '#DC2626',
'intermediate' => '#0D7377',
default => '#E5E7EB',
};
$shape = match($type) {
'initial' => 'border-radius:50%;width:120px;height:120px;',
'terminal' => 'border-radius:8px;border-width:3px;',
default => 'border-radius:8px;',
};
?>
<div id="state-<?= e($key) ?>" style="background:<?= $bgColor ?>;border:2px solid <?= $borderColor ?>;padding:15px 20px;text-align:center;min-width:140px;<?= $shape ?>display:flex;flex-direction:column;align-items:center;justify-content:center;">
<div style="font-weight:700;color:#1A1A2E;font-size:14px;"><?= e($state['label_ar'] ?? $key) ?></div>
<div style="font-size:10px;color:#9CA3AF;margin-top:4px;"><?= e($key) ?></div>
<?php if ($type === 'initial'): ?>
<div style="font-size:10px;color:#0284C7;margin-top:2px;">▶ بداية</div>
<?php elseif ($type === 'terminal'): ?>
<div style="font-size:10px;color:#DC2626;margin-top:2px;">■ نهاية</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- Transition Table -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#0D7377;">جدول الانتقالات</h3>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>اسم الانتقال</th>
<th>من</th>
<th>إلى</th>
<th>النوع</th>
<th>الشروط</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($transitions as $t): ?>
<?php
$fromState = $states[$t['from']] ?? [];
$toState = $states[$t['to']] ?? [];
$trigger = $t['trigger'] ?? 'manual';
?>
<tr>
<td style="font-weight:600;"><?= e($t['name']) ?></td>
<td>
<span style="color:#DC2626;font-size:13px;"><?= e($fromState['label_ar'] ?? $t['from']) ?></span>
</td>
<td>
<span style="color:#059669;font-size:13px;"><?= e($toState['label_ar'] ?? $t['to']) ?></span>
</td>
<td>
<span style="background:<?= $trigger === 'automatic' ? '#FFF7ED' : '#F0FDF4' ?>;color:<?= $trigger === 'automatic' ? '#D97706' : '#059669' ?>;padding:2px 8px;border-radius:4px;font-size:12px;">
<?= $trigger === 'automatic' ? 'تلقائي' : 'يدوي' ?>
</span>
</td>
<td style="font-size:12px;">
<?php $guards = $t['guards'] ?? []; ?>
<?php if (empty($guards)): ?>
<span style="color:#9CA3AF;">بدون شروط</span>
<?php else: ?>
<?php foreach ($guards as $g): ?>
<div style="margin-bottom:2px;">
<code style="font-size:11px;background:#F3F4F6;padding:1px 4px;border-radius:2px;"><?= e($g['type'] ?? '') ?></code>
<span style="color:#6B7280;"><?= e(is_array($g['value'] ?? '') ? json_encode($g['value']) : (string)($g['value'] ?? '')) ?></span>
<?php if (isset($g['days'])): ?><span style="color:#D97706;"><?= (int) $g['days'] ?> يوم</span><?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</td>
<td style="font-size:12px;">
<?php $actions = $t['actions'] ?? []; ?>
<?php if (empty($actions)): ?>
<span style="color:#9CA3AF;"></span>
<?php else: ?>
<?php foreach ($actions as $a): ?>
<div><code style="font-size:11px;"><?= e($a['type'] ?? '') ?>: <?= e($a['value'] ?? '') ?></code></div>
<?php endforeach; ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- States Summary -->
<div class="card" style="margin-top:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#0D7377;">ملخص الحالات (<?= count($states) ?>)</h3>
</div>
<div style="padding:20px;display:grid;grid-template-columns:repeat(auto-fill, minmax(200px, 1fr));gap:10px;">
<?php foreach ($states as $key => $state): ?>
<?php
$type = $state['type'] ?? 'intermediate';
$color = match($type) {
'initial' => '#0284C7',
'terminal' => '#DC2626',
default => '#0D7377',
};
?>
<div style="padding:10px 15px;border:1px solid #E5E7EB;border-right:4px solid <?= $color ?>;border-radius:6px;">
<div style="font-weight:600;color:#1A1A2E;"><?= e($state['label_ar'] ?? $key) ?></div>
<div style="font-size:11px;color:#9CA3AF;"><?= e($key) ?> · <?= e($type) ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>محرك دورات العمل<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<form method="POST" action="/workflows/process-timeouts" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-outline" onclick="return confirm('هل تريد معالجة الانتقالات التلقائية المنتهية؟')">⏱ معالجة المهلات المنتهية</button>
</form>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Workflow Definitions Overview -->
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(300px, 1fr));gap:20px;margin-bottom:30px;">
<?php foreach ($definitions as $def): ?>
<?php $s = $stats[$def['workflow_code']] ?? ['active' => 0, 'completed' => 0]; ?>
<div class="card" style="padding:20px;">
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:15px;">
<div>
<h3 style="margin:0;color:#1A1A2E;"><?= e($def['name_ar']) ?></h3>
<code style="font-size:11px;color:#9CA3AF;"><?= e($def['workflow_code']) ?></code>
</div>
<span style="background:#F3F4F6;padding:2px 8px;border-radius:4px;font-size:12px;">v<?= (int) $def['version'] ?></span>
</div>
<p style="color:#6B7280;font-size:13px;margin-bottom:15px;"><?= e($def['description_ar'] ?? '') ?></p>
<div style="display:flex;gap:15px;margin-bottom:15px;">
<div style="text-align:center;">
<div style="font-size:24px;font-weight:700;color:#0D7377;"><?= $s['active'] ?></div>
<div style="font-size:11px;color:#6B7280;">نشط</div>
</div>
<div style="text-align:center;">
<div style="font-size:24px;font-weight:700;color:#059669;"><?= $s['completed'] ?></div>
<div style="font-size:11px;color:#6B7280;">مكتمل</div>
</div>
</div>
<div style="display:flex;gap:8px;">
<a href="/workflows/diagram/<?= e($def['workflow_code']) ?>" class="btn btn-sm btn-outline">المخطط</a>
<a href="/workflows/instances/<?= e($def['workflow_code']) ?>" class="btn btn-sm btn-outline">المثيلات</a>
<a href="/workflows/instances/<?= e($def['workflow_code']) ?>?status=active" class="btn btn-sm btn-outline" style="color:#D97706;">النشطة</a>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- Filtered Instances (if viewing specific workflow) -->
<?php if (!empty($filteredInstances)): ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;color:#0D7377;">
مثيلات: <?= e($currentDef ? $currentDef->name_ar : ($filterCode ?? '')) ?>
<?php if (!empty($filterStatus)): ?>
<span style="font-size:14px;color:#6B7280;">(<?= $filterStatus === 'active' ? 'نشطة' : ($filterStatus === 'completed' ? 'مكتملة' : 'الكل') ?>)</span>
<?php endif; ?>
</h3>
<a href="/workflows" class="btn btn-sm btn-outline">← العودة</a>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>نوع الكيان</th>
<th>رقم الكيان</th>
<th>الحالة الحالية</th>
<th>دخول الحالة</th>
<th>مكتمل</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($filteredInstances as $inst): ?>
<?php
$stateLabel = $inst['current_state'];
if ($currentDef) {
$states = $currentDef->getStates();
$stateLabel = $states[$inst['current_state']]['label_ar'] ?? $inst['current_state'];
}
?>
<tr>
<td><?= (int) $inst['id'] ?></td>
<td><code style="font-size:12px;"><?= e($inst['entity_type']) ?></code></td>
<td><?= (int) $inst['entity_id'] ?></td>
<td>
<span style="background:<?= $inst['is_completed'] ? '#F0FDF4' : '#FFF7ED' ?>;color:<?= $inst['is_completed'] ? '#059669' : '#D97706' ?>;padding:3px 10px;border-radius:4px;font-size:13px;font-weight:600;">
<?= e($stateLabel) ?>
</span>
</td>
<td style="font-size:12px;"><?= e($inst['state_entered_at']) ?></td>
<td><?= $inst['is_completed'] ? '<span style="color:#059669;">✓</span>' : '<span style="color:#D97706;">—</span>' ?></td>
<td>
<a href="/workflows/instances/<?= (int) $inst['id'] ?>" class="btn btn-sm btn-outline">عرض</a>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($filteredInstances)): ?>
<tr><td colspan="7" style="text-align:center;padding:40px;color:#6B7280;">لا توجد مثيلات</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مثيل دورة العمل #<?= (int) $instance->id ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/workflows" class="btn btn-outline">← العودة</a>
<a href="/workflows/diagram/<?= e($instance->workflow_code) ?>" class="btn btn-outline">عرض المخطط</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$states = $definition ? $definition->getStates() : [];
$currentStateInfo = $states[$instance->current_state] ?? [];
?>
<div style="display:grid;grid-template-columns:1fr 350px;gap:20px;">
<div>
<!-- Instance Info -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#0D7377;">معلومات المثيل</h3>
</div>
<div style="padding:20px;">
<table style="width:100%;font-size:14px;">
<tr><td style="padding:6px 0;color:#6B7280;width:35%;">دورة العمل</td><td style="padding:6px 0;font-weight:600;"><?= e($definition ? $definition->name_ar : $instance->workflow_code) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">نوع الكيان</td><td><code><?= e($instance->entity_type) ?></code></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">رقم الكيان</td><td><?= (int) $instance->entity_id ?></td></tr>
<tr>
<td style="padding:6px 0;color:#6B7280;">الحالة الحالية</td>
<td>
<span style="background:<?= $instance->is_completed ? '#F0FDF4' : '#EFF6FF' ?>;color:<?= $instance->is_completed ? '#059669' : '#0284C7' ?>;padding:4px 12px;border-radius:6px;font-weight:700;font-size:15px;">
<?= e($currentStateInfo['label_ar'] ?? $instance->current_state) ?>
</span>
</td>
</tr>
<tr><td style="padding:6px 0;color:#6B7280;">دخول الحالة</td><td><?= e($instance->state_entered_at) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">تاريخ الإنشاء</td><td><?= e($instance->created_at) ?></td></tr>
<tr>
<td style="padding:6px 0;color:#6B7280;">مكتمل</td>
<td>
<?php if ($instance->is_completed): ?>
<span style="color:#059669;font-weight:600;">✓ نعم — <?= e($instance->completed_at) ?></span>
<?php else: ?>
<span style="color:#D97706;">جارية</span>
<?php endif; ?>
</td>
</tr>
</table>
</div>
</div>
<!-- Transition History -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#0D7377;">سجل الانتقالات</h3>
</div>
<div style="padding:20px;">
<?php if (empty($history)): ?>
<div style="text-align:center;padding:30px;color:#6B7280;">لا توجد انتقالات مسجلة</div>
<?php else: ?>
<?php foreach ($history as $i => $h): ?>
<?php
$fromInfo = $states[$h['from_state']] ?? [];
$toInfo = $states[$h['to_state']] ?? [];
?>
<div style="display:flex;gap:15px;padding:15px 0;<?= $i < count($history) - 1 ? 'border-bottom:1px solid #F3F4F6;' : '' ?>">
<div style="flex-shrink:0;width:10px;position:relative;">
<div style="width:10px;height:10px;border-radius:50%;background:#0D7377;margin-top:5px;"></div>
<?php if ($i < count($history) - 1): ?>
<div style="position:absolute;top:15px;right:4px;width:2px;height:calc(100% + 15px);background:#E5E7EB;"></div>
<?php endif; ?>
</div>
<div style="flex:1;">
<div style="display:flex;justify-content:space-between;margin-bottom:5px;">
<strong style="color:#1A1A2E;"><?= e($h['transition_name']) ?></strong>
<span style="color:#9CA3AF;font-size:12px;"><?= e($h['created_at']) ?></span>
</div>
<div style="font-size:13px;color:#6B7280;margin-bottom:4px;">
<span style="color:#DC2626;"><?= e($fromInfo['label_ar'] ?? $h['from_state']) ?></span>
<span style="color:#059669;"><?= e($toInfo['label_ar'] ?? $h['to_state']) ?></span>
</div>
<div style="font-size:12px;color:#9CA3AF;">
<?= e($h['employee_name'] ?? 'النظام') ?>
· <?= e($h['trigger_type']) ?>
</div>
<?php if ($h['notes']): ?>
<div style="font-size:13px;color:#4B5563;margin-top:4px;background:#F9FAFB;padding:6px 10px;border-radius:4px;"><?= e($h['notes']) ?></div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<!-- Available Transitions -->
<div>
<?php if (!$instance->is_completed && !empty($available)): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:12px 15px;border-bottom:1px solid #E5E7EB;">
<h4 style="margin:0;color:#1A1A2E;font-size:14px;">الانتقالات المتاحة</h4>
</div>
<div style="padding:15px;">
<?php foreach ($available as $trans): ?>
<form method="POST" action="/workflows/instances/<?= (int) $instance->id ?>/transition" style="margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid #F3F4F6;">
<?= csrf_field() ?>
<input type="hidden" name="transition_name" value="<?= e($trans['name']) ?>">
<div style="margin-bottom:8px;">
<strong style="color:#1A1A2E;font-size:14px;"><?= e($trans['name']) ?></strong>
<div style="font-size:12px;color:#6B7280;"><?= e($trans['to_label_ar']) ?></div>
</div>
<div class="form-group" style="margin-bottom:8px;">
<textarea name="notes" class="form-textarea" rows="2" placeholder="ملاحظات (اختياري)" style="font-size:12px;"></textarea>
</div>
<?php if ($trans['can_execute']): ?>
<button type="submit" class="btn btn-sm btn-primary" style="width:100%;" onclick="return confirm('هل تريد تنفيذ هذا الانتقال؟')">تنفيذ</button>
<?php else: ?>
<button type="button" class="btn btn-sm" style="width:100%;background:#E5E7EB;color:#9CA3AF;cursor:not-allowed;" disabled>غير متاح (شرط غير مستوفى)</button>
<?php endif; ?>
</form>
<?php endforeach; ?>
</div>
</div>
<?php elseif ($instance->is_completed): ?>
<div class="card" style="padding:30px;text-align:center;">
<div style="font-size:48px;margin-bottom:10px;"></div>
<strong style="color:#059669;">دورة العمل مكتملة</strong>
</div>
<?php else: ?>
<div class="card" style="padding:30px;text-align:center;color:#6B7280;">
لا توجد انتقالات يدوية متاحة
</div>
<?php endif; ?>
<!-- State Map -->
<div class="card">
<div style="padding:12px 15px;border-bottom:1px solid #E5E7EB;">
<h4 style="margin:0;color:#1A1A2E;font-size:14px;">خريطة الحالات</h4>
</div>
<div style="padding:15px;">
<?php foreach ($states as $key => $state): ?>
<div style="padding:6px 10px;margin-bottom:4px;border-radius:4px;font-size:13px;<?= $key === $instance->current_state ? 'background:#EFF6FF;border:2px solid #0284C7;font-weight:700;' : 'background:#F9FAFB;border:1px solid #E5E7EB;' ?>">
<?php if ($key === $instance->current_state): ?><?php elseif (($state['type'] ?? '') === 'terminal'): ?><?php else: ?><?php endif; ?>
<?= e($state['label_ar'] ?? $key) ?>
<?php if (($state['type'] ?? '') === 'initial'): ?><small style="color:#059669;">(بداية)</small><?php endif; ?>
<?php if (($state['type'] ?? '') === 'terminal'): ?><small style="color:#DC2626;">(نهاية)</small><?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
MenuRegistry::register('workflows', [
'label_ar' => 'دورات العمل',
'label_en' => 'Workflows',
'icon' => '🔄',
'route' => '/workflows',
'permission' => 'rules.view',
'order' => 165,
'children' => [
['label_ar' => 'نظرة عامة', 'label_en' => 'Overview', 'route' => '/workflows', 'permission' => 'rules.view', 'order' => 1],
],
]);
PermissionRegistry::register('workflows', [
'workflow.view' => ['ar' => 'عرض دورات العمل', 'en' => 'View Workflows'],
'workflow.transition' => ['ar' => 'تنفيذ الانتقالات', 'en' => 'Execute Transitions'],
'workflow.manage' => ['ar' => 'إدارة دورات العمل', 'en' => 'Manage Workflows'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `workflow_definitions` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`workflow_code` VARCHAR(50) NOT NULL,
`name_ar` VARCHAR(200) NOT NULL,
`name_en` VARCHAR(200) NULL,
`description_ar` TEXT NULL,
`definition_json` JSON NOT NULL,
`version` INT UNSIGNED NOT NULL DEFAULT 1,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
UNIQUE KEY `uq_workflow_defs_code` (`workflow_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `workflow_definitions`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `workflow_instances` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`workflow_definition_id` BIGINT UNSIGNED NOT NULL,
`workflow_code` VARCHAR(50) NOT NULL,
`entity_type` VARCHAR(100) NOT NULL,
`entity_id` BIGINT UNSIGNED NOT NULL,
`current_state` VARCHAR(50) NOT NULL,
`state_entered_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`state_data_json` JSON NULL,
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
`completed_at` TIMESTAMP NULL DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_workflow_inst_def` (`workflow_definition_id`),
INDEX `idx_workflow_inst_entity` (`entity_type`, `entity_id`),
INDEX `idx_workflow_inst_state` (`current_state`),
INDEX `idx_workflow_inst_completed` (`is_completed`),
CONSTRAINT `fk_workflow_inst_def` FOREIGN KEY (`workflow_definition_id`) REFERENCES `workflow_definitions`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `workflow_instances`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `workflow_transition_log` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`workflow_instance_id` BIGINT UNSIGNED NOT NULL,
`from_state` VARCHAR(50) NOT NULL,
`to_state` VARCHAR(50) NOT NULL,
`transition_name` VARCHAR(100) NOT NULL,
`triggered_by_employee_id` BIGINT UNSIGNED NULL,
`trigger_type` VARCHAR(30) NOT NULL DEFAULT 'manual',
`guard_results_json` JSON NULL,
`notes` TEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_wf_trans_log_inst` (`workflow_instance_id`),
INDEX `idx_wf_trans_log_date` (`created_at`),
CONSTRAINT `fk_wf_trans_log_inst` FOREIGN KEY (`workflow_instance_id`) REFERENCES `workflow_instances`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `workflow_transition_log`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$ts = date('Y-m-d H:i:s');
$definitions = [
[
'workflow_code' => 'new_membership',
'name_ar' => 'عضوية جديدة',
'name_en' => 'New Membership',
'description_ar' => 'دورة عمل تسجيل عضوية جديدة من تقديم الاستمارة حتى التفعيل',
'definition_json' => json_encode([
'states' => [
'form_submitted' => ['label_ar' => 'تم تقديم الاستمارة', 'label_en' => 'Form Submitted', 'type' => 'initial'],
'under_review' => ['label_ar' => 'تحت المراجعة', 'label_en' => 'Under Review', 'type' => 'intermediate'],
'interview_scheduled' => ['label_ar' => 'تم تحديد موعد المقابلة', 'label_en' => 'Interview Scheduled', 'type' => 'intermediate'],
'accepted' => ['label_ar' => 'مقبول', 'label_en' => 'Accepted', 'type' => 'intermediate'],
'rejected' => ['label_ar' => 'مرفوض', 'label_en' => 'Rejected', 'type' => 'terminal'],
'payment_pending' => ['label_ar' => 'في انتظار السداد', 'label_en' => 'Payment Pending', 'type' => 'intermediate'],
'active' => ['label_ar' => 'فعال', 'label_en' => 'Active', 'type' => 'terminal'],
'expired' => ['label_ar' => 'منتهي الصلاحية', 'label_en' => 'Expired', 'type' => 'terminal'],
],
'transitions' => [
[
'name' => 'start_review',
'from' => 'form_submitted',
'to' => 'under_review',
'guards' => [['type' => 'permission', 'value' => 'member.edit']],
'actions' => [],
],
[
'name' => 'schedule_interview',
'from' => 'under_review',
'to' => 'interview_scheduled',
'guards' => [['type' => 'permission', 'value' => 'interview.schedule']],
'actions' => [['type' => 'event', 'value' => 'interview.scheduled']],
],
[
'name' => 'accept',
'from' => 'interview_scheduled',
'to' => 'payment_pending',
'guards' => [['type' => 'permission', 'value' => 'interview.decide']],
'actions' => [['type' => 'event', 'value' => 'member.accepted']],
],
[
'name' => 'reject',
'from' => 'interview_scheduled',
'to' => 'rejected',
'guards' => [['type' => 'permission', 'value' => 'interview.decide']],
'actions' => [['type' => 'event', 'value' => 'member.rejected']],
],
[
'name' => 'reject_from_review',
'from' => 'under_review',
'to' => 'rejected',
'guards' => [['type' => 'permission', 'value' => 'interview.decide']],
'actions' => [['type' => 'event', 'value' => 'member.rejected']],
],
[
'name' => 'payment_completed',
'from' => 'payment_pending',
'to' => 'active',
'guards' => [['type' => 'condition', 'value' => 'has_payment_receipt']],
'actions' => [['type' => 'event', 'value' => 'member.activated']],
],
[
'name' => 'expire',
'from' => 'payment_pending',
'to' => 'expired',
'guards' => [['type' => 'timeout', 'days' => 15]],
'trigger' => 'automatic',
'actions' => [['type' => 'event', 'value' => 'member.form_expired']],
],
],
], JSON_UNESCAPED_UNICODE),
],
[
'workflow_code' => 'addition',
'name_ar' => 'إضافة (زوج/أبناء/مؤقت)',
'name_en' => 'Addition (Spouse/Children/Temporary)',
'description_ar' => 'دورة عمل إضافة أفراد للعضوية',
'definition_json' => json_encode([
'states' => [
'addition_requested' => ['label_ar' => 'طلب إضافة مقدّم', 'label_en' => 'Addition Requested', 'type' => 'initial'],
'under_review' => ['label_ar' => 'تحت المراجعة', 'label_en' => 'Under Review', 'type' => 'intermediate'],
'approved' => ['label_ar' => 'موافق عليه', 'label_en' => 'Approved', 'type' => 'intermediate'],
'rejected' => ['label_ar' => 'مرفوض', 'label_en' => 'Rejected', 'type' => 'terminal'],
'fee_pending' => ['label_ar' => 'في انتظار الرسوم', 'label_en' => 'Fee Pending', 'type' => 'intermediate'],
'fee_paid' => ['label_ar' => 'تم سداد الرسوم', 'label_en' => 'Fee Paid', 'type' => 'intermediate'],
'active' => ['label_ar' => 'فعال', 'label_en' => 'Active', 'type' => 'terminal'],
],
'transitions' => [
['name' => 'start_review', 'from' => 'addition_requested', 'to' => 'under_review', 'guards' => [['type' => 'permission', 'value' => 'member.edit']], 'actions' => []],
['name' => 'approve', 'from' => 'under_review', 'to' => 'fee_pending', 'guards' => [['type' => 'permission', 'value' => 'member.edit']], 'actions' => [['type' => 'event', 'value' => 'addition.approved']]],
['name' => 'reject', 'from' => 'under_review', 'to' => 'rejected', 'guards' => [['type' => 'permission', 'value' => 'member.edit']], 'actions' => [['type' => 'event', 'value' => 'addition.rejected']]],
['name' => 'record_payment', 'from' => 'fee_pending', 'to' => 'active', 'guards' => [['type' => 'condition', 'value' => 'has_payment_receipt']], 'actions' => [['type' => 'event', 'value' => 'addition.activated']]],
],
], JSON_UNESCAPED_UNICODE),
],
[
'workflow_code' => 'annual_subscription',
'name_ar' => 'الاشتراك السنوي',
'name_en' => 'Annual Subscription',
'description_ar' => 'دورة عمل تجديد الاشتراك السنوي وتتبع التأخير',
'definition_json' => json_encode([
'states' => [
'renewal_due' => ['label_ar' => 'مستحق التجديد', 'label_en' => 'Renewal Due', 'type' => 'initial'],
'notification_sent' => ['label_ar' => 'تم إرسال الإخطار', 'label_en' => 'Notification Sent', 'type' => 'intermediate'],
'paid' => ['label_ar' => 'مدفوع', 'label_en' => 'Paid', 'type' => 'terminal'],
'overdue' => ['label_ar' => 'متأخر', 'label_en' => 'Overdue', 'type' => 'intermediate'],
'fine_applied' => ['label_ar' => 'تم تطبيق الغرامة', 'label_en' => 'Fine Applied', 'type' => 'intermediate'],
'paid_with_fine' => ['label_ar' => 'مدفوع مع غرامة', 'label_en' => 'Paid With Fine', 'type' => 'terminal'],
'membership_dropped' => ['label_ar' => 'تم إسقاط العضوية', 'label_en' => 'Membership Dropped', 'type' => 'terminal'],
],
'transitions' => [
['name' => 'send_notification', 'from' => 'renewal_due', 'to' => 'notification_sent', 'guards' => [], 'trigger' => 'automatic', 'actions' => [['type' => 'event', 'value' => 'subscription.notification_sent']]],
['name' => 'pay', 'from' => 'notification_sent', 'to' => 'paid', 'guards' => [['type' => 'condition', 'value' => 'has_payment_receipt']], 'actions' => [['type' => 'event', 'value' => 'subscription.paid']]],
['name' => 'mark_overdue', 'from' => 'notification_sent', 'to' => 'overdue', 'guards' => [['type' => 'timeout', 'days' => 90]], 'trigger' => 'automatic', 'actions' => [['type' => 'event', 'value' => 'subscription.overdue']]],
['name' => 'apply_fine', 'from' => 'overdue', 'to' => 'fine_applied', 'guards' => [], 'trigger' => 'automatic', 'actions' => [['type' => 'event', 'value' => 'subscription.fine_applied']]],
['name' => 'pay_with_fine', 'from' => 'fine_applied', 'to' => 'paid_with_fine', 'guards' => [['type' => 'condition', 'value' => 'has_payment_receipt']], 'actions' => [['type' => 'event', 'value' => 'subscription.paid_with_fine']]],
['name' => 'drop_membership', 'from' => 'fine_applied', 'to' => 'membership_dropped','guards' => [['type' => 'condition', 'value' => 'five_years_unpaid']], 'trigger' => 'automatic', 'actions' => [['type' => 'event', 'value' => 'member.dropped']]],
],
], JSON_UNESCAPED_UNICODE),
],
[
'workflow_code' => 'transfer_separation',
'name_ar' => 'تحويل / فصل',
'name_en' => 'Transfer / Separation',
'description_ar' => 'دورة عمل تحويل أو فصل عضوية',
'definition_json' => json_encode([
'states' => [
'request_submitted' => ['label_ar' => 'تم تقديم الطلب', 'label_en' => 'Request Submitted', 'type' => 'initial'],
'under_review' => ['label_ar' => 'تحت المراجعة', 'label_en' => 'Under Review', 'type' => 'intermediate'],
'board_approved' => ['label_ar' => 'موافقة مجلس الأمناء', 'label_en' => 'Board Approved', 'type' => 'intermediate'],
'board_rejected' => ['label_ar' => 'رفض مجلس الأمناء', 'label_en' => 'Board Rejected', 'type' => 'terminal'],
'fee_calculated' => ['label_ar' => 'تم حساب الرسوم', 'label_en' => 'Fee Calculated', 'type' => 'intermediate'],
'fee_paid' => ['label_ar' => 'تم سداد الرسوم', 'label_en' => 'Fee Paid', 'type' => 'intermediate'],
'archived' => ['label_ar' => 'تم الأرشفة', 'label_en' => 'Archived', 'type' => 'intermediate'],
'new_created' => ['label_ar' => 'تم إنشاء الجديد', 'label_en' => 'New Created', 'type' => 'intermediate'],
'completed' => ['label_ar' => 'مكتمل', 'label_en' => 'Completed', 'type' => 'terminal'],
],
'transitions' => [
['name' => 'start_review', 'from' => 'request_submitted', 'to' => 'under_review', 'guards' => [['type' => 'permission', 'value' => 'transfer.initiate']], 'actions' => []],
['name' => 'board_approve', 'from' => 'under_review', 'to' => 'fee_calculated', 'guards' => [['type' => 'permission', 'value' => 'transfer.approve']], 'actions' => [['type' => 'event', 'value' => 'transfer.approved']]],
['name' => 'board_reject', 'from' => 'under_review', 'to' => 'board_rejected', 'guards' => [['type' => 'permission', 'value' => 'transfer.approve']], 'actions' => [['type' => 'event', 'value' => 'transfer.rejected']]],
['name' => 'record_payment', 'from' => 'fee_calculated', 'to' => 'fee_paid', 'guards' => [['type' => 'condition', 'value' => 'has_payment_receipt']], 'actions' => []],
['name' => 'archive_old', 'from' => 'fee_paid', 'to' => 'archived', 'guards' => [], 'actions' => [['type' => 'event', 'value' => 'transfer.archived']]],
['name' => 'create_new', 'from' => 'archived', 'to' => 'new_created', 'guards' => [], 'actions' => [['type' => 'event', 'value' => 'transfer.new_created']]],
['name' => 'complete', 'from' => 'new_created', 'to' => 'completed', 'guards' => [], 'actions' => [['type' => 'event', 'value' => 'transfer.completed']]],
],
], JSON_UNESCAPED_UNICODE),
],
[
'workflow_code' => 'violation_penalty',
'name_ar' => 'المخالفات والعقوبات',
'name_en' => 'Violation & Penalty',
'description_ar' => 'دورة عمل المخالفات والعقوبات والتظلم',
'definition_json' => json_encode([
'states' => [
'violation_reported' => ['label_ar' => 'تم الإبلاغ عن مخالفة', 'label_en' => 'Violation Reported', 'type' => 'initial'],
'under_review' => ['label_ar' => 'تحت المراجعة', 'label_en' => 'Under Review', 'type' => 'intermediate'],
'penalty_decided' => ['label_ar' => 'تم تحديد العقوبة', 'label_en' => 'Penalty Decided', 'type' => 'intermediate'],
'penalty_active' => ['label_ar' => 'العقوبة سارية', 'label_en' => 'Penalty Active', 'type' => 'intermediate'],
'appealed' => ['label_ar' => 'تم التظلم', 'label_en' => 'Appealed', 'type' => 'intermediate'],
'appeal_reviewed' => ['label_ar' => 'تم مراجعة التظلم', 'label_en' => 'Appeal Reviewed', 'type' => 'intermediate'],
'penalty_upheld' => ['label_ar' => 'تم تأييد العقوبة', 'label_en' => 'Penalty Upheld', 'type' => 'terminal'],
'penalty_modified' => ['label_ar' => 'تم تعديل العقوبة', 'label_en' => 'Penalty Modified', 'type' => 'terminal'],
'penalty_cancelled' => ['label_ar' => 'تم إلغاء العقوبة', 'label_en' => 'Penalty Cancelled', 'type' => 'terminal'],
'dismissed' => ['label_ar' => 'تم رفض البلاغ', 'label_en' => 'Dismissed', 'type' => 'terminal'],
'resolved' => ['label_ar' => 'تم الحل', 'label_en' => 'Resolved', 'type' => 'terminal'],
],
'transitions' => [
['name' => 'start_review', 'from' => 'violation_reported', 'to' => 'under_review', 'guards' => [['type' => 'permission', 'value' => 'fine.view']], 'actions' => []],
['name' => 'dismiss', 'from' => 'under_review', 'to' => 'dismissed', 'guards' => [['type' => 'permission', 'value' => 'fine.impose']], 'actions' => [['type' => 'event', 'value' => 'violation.dismissed']]],
['name' => 'decide_penalty', 'from' => 'under_review', 'to' => 'penalty_decided', 'guards' => [['type' => 'permission', 'value' => 'fine.impose']], 'actions' => [['type' => 'event', 'value' => 'penalty.decided']]],
['name' => 'activate_penalty', 'from' => 'penalty_decided', 'to' => 'penalty_active', 'guards' => [], 'actions' => [['type' => 'event', 'value' => 'penalty.activated']]],
['name' => 'submit_appeal', 'from' => 'penalty_active', 'to' => 'appealed', 'guards' => [['type' => 'condition', 'value' => 'within_appeal_window']], 'actions' => [['type' => 'event', 'value' => 'penalty.appealed']]],
['name' => 'resolve_no_appeal', 'from' => 'penalty_active', 'to' => 'resolved', 'guards' => [['type' => 'timeout', 'days' => 15]], 'trigger' => 'automatic', 'actions' => [['type' => 'event', 'value' => 'penalty.resolved']]],
['name' => 'review_appeal', 'from' => 'appealed', 'to' => 'appeal_reviewed', 'guards' => [['type' => 'permission', 'value' => 'fine.impose']], 'actions' => []],
['name' => 'uphold_penalty', 'from' => 'appeal_reviewed', 'to' => 'penalty_upheld', 'guards' => [['type' => 'permission', 'value' => 'fine.impose']], 'actions' => [['type' => 'event', 'value' => 'penalty.upheld']]],
['name' => 'modify_penalty', 'from' => 'appeal_reviewed', 'to' => 'penalty_modified', 'guards' => [['type' => 'permission', 'value' => 'fine.impose']], 'actions' => [['type' => 'event', 'value' => 'penalty.modified']]],
['name' => 'cancel_penalty', 'from' => 'appeal_reviewed', 'to' => 'penalty_cancelled','guards' => [['type' => 'permission', 'value' => 'fine.waive']], 'actions' => [['type' => 'event', 'value' => 'penalty.cancelled']]],
],
], JSON_UNESCAPED_UNICODE),
],
];
foreach ($definitions as $def) {
$existing = $db->selectOne("SELECT id FROM workflow_definitions WHERE workflow_code = ?", [$def['workflow_code']]);
if ($existing) {
continue;
}
$db->insert('workflow_definitions', [
'workflow_code' => $def['workflow_code'],
'name_ar' => $def['name_ar'],
'name_en' => $def['name_en'],
'description_ar' => $def['description_ar'],
'definition_json' => $def['definition_json'],
'version' => 1,
'is_active' => 1,
'created_at' => $ts,
'updated_at' => $ts,
]);
}
};
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment