Quellcode durchsuchen

merge history-component

moell vor 11 Monaten
Ursprung
Commit
02a88d2ac2

+ 23 - 0
app/Http/Controllers/API/ActionController.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Http\Controllers\API;
+
+use App\Http\Controllers\Controller;
+use App\Models\Enums\ActionObjectType;
+use App\Repositories\ActionRepository;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+
+class ActionController extends Controller
+{
+    public function history(string $objectType, string $objectId)
+    {
+        $actionObjectType = ActionObjectType::from($objectType);
+
+        $actionObjectType->modelBuilder()->where("company_id", Auth::user()->company_id)->findOrFail($objectId);
+
+        return $this->success([
+            'data' => ActionRepository::actionWithHistory($actionObjectType, $objectId),
+        ]);
+    }
+}

+ 24 - 5
app/Http/Controllers/API/ProjectController.php

@@ -22,6 +22,7 @@ use App\Http\Resources\API\RequirementGroupResource;
 use App\Http\Resources\API\SimplePlanResource;
 use App\Http\Resources\API\ProjectRequirementResource;
 use App\Http\Resources\API\ProjectResource;
+use App\Models\Enums\ActionObjectType;
 use App\Models\Enums\ObjectAction;
 use App\Models\Enums\ProjectStatus;
 use App\Models\Enums\TaskStatus;
@@ -35,6 +36,7 @@ use App\Models\Task;
 use App\Models\User;
 use App\Models\RequirementGroup;
 use App\Repositories\ActionRepository;
+use App\Services\History\ModelChangeDetector;
 use App\Services\Project\ProjectKanbanService;
 use App\Services\Project\ProjectGanttService;
 use App\Services\Project\ProjectTaskGroupViewService;
@@ -147,9 +149,11 @@ class ProjectController extends Controller
             'whitelist' => $request->whitelist ? sprintf(",%s", implode(',', $request->whitelist)) : null,
         ]);
 
+        $changes = ModelChangeDetector::detector(ActionObjectType::PROJECT, $project);
+
         $project->save();
 
-        ActionRepository::createByProject($project, ObjectAction::EDITED);
+        ActionRepository::createByProject($project, ObjectAction::EDITED, objectChanges: $changes);
 
         if ($request->has("assets")) {
             ProjectAsset::where('project_id', $project->id)->delete();
@@ -195,9 +199,12 @@ class ProjectController extends Controller
         $project = Project::findOrFail($id);
 
         $project->status = ProjectStatus::CLOSED->value;
+        $changes = ModelChangeDetector::detector(ActionObjectType::PROJECT, $project);
         $project->save();
 
-        ActionRepository::createByProject($project, ObjectAction::CLOSED, $request->get("comment"));
+        ActionRepository::createByProject(
+            $project, ObjectAction::CLOSED, $request->get("comment"), objectChanges: $changes
+        );
 
         return $this->noContent();
     }
@@ -207,9 +214,15 @@ class ProjectController extends Controller
         $project = Project::findOrFail($id);
 
         $project->status = ProjectStatus::DOING->value;
+        $changes = ModelChangeDetector::detector(ActionObjectType::PROJECT, $project);
         $project->save();
 
-        ActionRepository::createByProject($project, ObjectAction::STARTED, $request->get("comment"));
+        ActionRepository::createByProject(
+            $project,
+            ObjectAction::STARTED,
+            $request->get("comment"),
+            objectChanges: $changes
+        );
 
         return $this->noContent();
     }
@@ -219,9 +232,12 @@ class ProjectController extends Controller
         $project = Project::findOrFail($id);
 
         $project->status = ProjectStatus::PAUSE->value;
+        $changes = ModelChangeDetector::detector(ActionObjectType::PROJECT, $project);
         $project->save();
 
-        ActionRepository::createByProject($project, ObjectAction::PAUSED, $request->get("comment"));
+        ActionRepository::createByProject(
+            $project, ObjectAction::PAUSED, $request->get("comment"), objectChanges: $changes
+        );
 
         return $this->noContent();
     }
@@ -240,9 +256,12 @@ class ProjectController extends Controller
         $project->fill($request->only([
             'begin', 'end'
         ]));
+        $changes = ModelChangeDetector::detector(ActionObjectType::PROJECT, $project);
         $project->save();
 
-        ActionRepository::createByProject($project, ObjectAction::DELAY, $request->get("comment"));
+        ActionRepository::createByProject(
+            $project, ObjectAction::DELAY, $request->get("comment"), objectChanges: $changes
+        );
 
         return $this->noContent();
     }

+ 5 - 0
app/Models/Action.php

@@ -20,4 +20,9 @@ class Action extends Model
     {
         return $this->belongsTo(User::class, "created_by");
     }
+
+    public function histories()
+    {
+        return $this->hasMany(History::class);
+    }
 }

+ 10 - 0
app/Models/Enums/ActionObjectType.php

@@ -7,6 +7,8 @@ use App\Models\Plan;
 use App\Models\Project;
 use App\Models\Requirement;
 use App\Models\Task;
+use App\Services\History\Detector\DetectorContact;
+use App\Services\History\Detector\ProjectDetector;
 
 enum ActionObjectType: string
 {
@@ -38,4 +40,12 @@ enum ActionObjectType: string
             self::PLAN => "title",
         };
     }
+
+    public function detectorClassName(): ?string
+    {
+        return match ($this) {
+            ActionObjectType::PROJECT => ProjectDetector::class,
+            default => null
+        };
+    }
 }

+ 15 - 0
app/Models/History.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class History extends Model
+{
+    use HasFactory;
+
+    public $timestamps = false;
+
+    protected $guarded = ['id'];
+}

+ 85 - 16
app/Repositories/ActionRepository.php

@@ -5,8 +5,10 @@ namespace App\Repositories;
 use App\Models\Action;
 use App\Models\Enums\ActionObjectType;
 use App\Models\Enums\ObjectAction;
+use App\Models\History;
 use App\Models\Project;
 use App\Models\Requirement;
+use App\Services\History\ModelChangeDetector;
 use Carbon\Carbon;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\Auth;
@@ -19,10 +21,11 @@ class ActionRepository
         ObjectAction $action,
         int|null $projectId = null,
         string $comment = null,
-        array $extraFields = []
+        array $extraFields = [],
+        array $objectChanges = [],
     ): \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Builder
     {
-        return Action::query()->create([
+        $action = Action::query()->create([
             "object_id" => $objectId,
             "object_type" => $objectType->value,
             "action" => $action,
@@ -31,14 +34,21 @@ class ActionRepository
             "extra_fields" => $extraFields ?: null,
             "created_by" => Auth::id(),
         ]);
+
+        if ($objectChanges) {
+            History::query()->insert(array_map(fn($change) => [...$change, 'action_id' => $action->id], $objectChanges));
+        }
+
+        return $action;
     }
 
     public static function createByProject(
-    Project $project,
-    ObjectAction $action,
-    string $comment = null,
-    array $extraFields = []
-): \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Builder
+        Project $project,
+        ObjectAction $action,
+        string $comment = null,
+        array $extraFields = [],
+        array $objectChanges = [],
+    ): \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Builder
     {
         return self::create(
             $project->id,
@@ -46,24 +56,27 @@ class ActionRepository
             $action,
             $project->id,
             $comment,
-            $extraFields
+            $extraFields,
+            $objectChanges,
         );
     }
 
      public static function createRequirement(
-    Requirement $requiremen,
-    ObjectAction $action,
-    string $comment = null,
-    array $extraFields = []
-): \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Builder
+        Requirement  $requirement,
+        ObjectAction $action,
+        string       $comment = null,
+        array        $extraFields = [],
+        array        $objectChanges = [],
+    ): \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Builder
     {
         return self::create(
-            $requiremen->id,
+            $requirement->id,
             ActionObjectType::REQUIREMENT,
             $action,
-            $requiremen->id,
+            $requirement->id,
             $comment,
-            $extraFields
+            $extraFields,
+            $objectChanges,
         );
     }
 
@@ -105,6 +118,7 @@ class ActionRepository
             'object_id' => $action['object_id'],
             'object_type' => $action['object_type'],
             'object_name' => data_get($objectNames, sprintf("%s.%d", $action['object_type'], $action['object_id'])),
+            'comment' => $action['comment'],
         ];
     }
 
@@ -163,4 +177,59 @@ class ActionRepository
 
         return $objectNames;
     }
+
+    public static function actionWithHistory(ActionObjectType $actionObjectType, string $objectId): array
+    {
+        $actions = Action::query()
+            ->with(['histories', 'createdBy'])
+            ->where("object_type", $actionObjectType->value)
+            ->where("object_id", $objectId)
+            ->orderBy("created_at")
+            ->get();
+
+        $objectNames = self::objectNamesGroupByType($actions);
+
+        $items = [];
+        foreach ($actions as $action) {
+            $item = self::actionFormat($action->toArray(), $objectNames);
+            $item['histories'] = self::formatHistories($actionObjectType, $action->histories);
+
+            $items[] = $item;
+        }
+
+        return $items;
+    }
+
+    public static function formatHistories(ActionObjectType $actionObjectType, Collection $histories): array
+    {
+        $detector = $actionObjectType->detectorClassName();
+
+        $items = [];
+        foreach ($histories as $history) {
+            $labelKey = sprintf("fields.%s", $history->field);
+
+            $item = [
+                'field' => $history->field,
+                'field_label' => app('translator')->has($labelKey) ? __($labelKey) : $history->field,
+                'new' => self::coverFieldValue($detector, $history->field, $history->new),
+                'old' => self::coverFieldValue($detector, $history->field, $history->old),
+                'diff' => (string)$history->diff,
+            ];
+
+            $items[] = $item;
+        }
+
+        return $items;
+    }
+
+    protected static function coverFieldValue(string $detector, string $field, string $value): mixed
+    {
+        if (! $detector) {
+            return $value;
+        }
+
+        $converter = call_user_func([$detector, "converter"], $field);
+
+        return $converter ? $converter->handle($value) : $value;
+    }
 }

+ 8 - 0
app/Services/History/Converter/ConverterContact.php

@@ -0,0 +1,8 @@
+<?php
+
+namespace App\Services\History\Converter;
+
+interface ConverterContact
+{
+    public function handle(mixed $value);
+}

+ 16 - 0
app/Services/History/Converter/ModelEnumConverter.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Services\History\Converter;
+
+class ModelEnumConverter implements ConverterContact
+{
+    public function __construct(protected string $langFieldPath)
+    {}
+
+    public function handle(mixed $value)
+    {
+        $labelKey = sprintf("model-enums.%s.%s", $this->langFieldPath, $value);
+
+        return app('translator')->has($labelKey) ? __($labelKey) : $value;
+    }
+}

+ 21 - 0
app/Services/History/Converter/WhitelistConverter.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Services\History\Converter;
+
+use App\Models\User;
+
+class WhitelistConverter implements ConverterContact
+{
+    public function handle(mixed $value)
+    {
+        $ids = array_filter(explode(",", $value));
+
+        if (! $ids) {
+            return null;
+        }
+
+        $users = User::query()->whereIn("id", $ids)->pluck("name");
+
+        return $users ? implode(",", $users->toArray()) : null;
+    }
+}

+ 14 - 0
app/Services/History/Detector/DetectorContact.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Services\History\Detector;
+
+use App\Services\History\Converter\ConverterContact;
+
+interface DetectorContact
+{
+    public static function fields(): array;
+
+    public static function diffFields(): array;
+
+    public static function converter(string $field): ConverterContact;
+}

+ 43 - 0
app/Services/History/Detector/ProjectDetector.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Services\History\Detector;
+
+use App\Services\History\Converter\ConverterContact;
+use App\Services\History\Converter\ModelEnumConverter;
+use App\Services\History\Converter\WhitelistConverter;
+
+class ProjectDetector implements DetectorContact
+{
+    public static function fields(): array
+    {
+        return [
+            'name',
+            'code',
+            'const',
+            'status',
+            'begin',
+            'end',
+            'latitude',
+            'type',
+            'acl',
+            'whitelist',
+            'description',
+        ];
+    }
+
+    public static function diffFields(): array
+    {
+        return [
+            'description',
+        ];
+    }
+
+    public static function converter(string $field): ConverterContact
+    {
+        return match ($field) {
+            "whitelist" => new WhitelistConverter(),
+            "status" => new ModelEnumConverter("project.status"),
+            default => null
+        };
+    }
+}

+ 41 - 0
app/Services/History/ModelChangeDetector.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Services\History;
+
+use App\Models\Enums\ActionObjectType;
+use Illuminate\Database\Eloquent\Model;
+
+class ModelChangeDetector
+{
+    public static function detector(ActionObjectType $objectType, Model $model): array
+    {
+        $detector = $objectType->detectorClassName();
+
+        if (! $detector) {
+            return [];
+        }
+
+        $fields = call_user_func([$detector, "fields"]);
+        $diffFields = call_user_func([$detector, "diffFields"]);
+
+        if (!$fields) {
+            return [];
+        }
+
+        $items = [];
+        foreach ($fields as $field) {
+            if (! $model->isDirty($field)) {
+                continue;
+            }
+
+            $items[] = [
+                'old' => $model->getOriginal($field),
+                'field' => $field,
+                'new' => $model->$field,
+                'diff' => in_array($field, $diffFields) ? text_diff($model->getOriginal($field), $model->$field) : null,
+            ];
+        }
+
+        return $items;
+    }
+}

+ 21 - 0
app/helpers.php

@@ -28,3 +28,24 @@ if (!function_exists('make_tree')) {
         return $tree;
     }
 }
+
+if (!function_exists('text_diff')) {
+    function text_diff(string $text1, string $text2): string
+    {
+        $text1 = str_replace('&nbsp;', '', trim($text1));
+        $text2 = str_replace('&nbsp;', '', trim($text2));
+        $w  = explode("\n", $text1);
+        $o  = explode("\n", $text2);
+        $w1 = array_diff_assoc($w,$o);
+        $o1 = array_diff_assoc($o,$w);
+        $w2 = array();
+        $o2 = array();
+        foreach($w1 as $idx => $val) $w2[sprintf("%03d<",$idx)] = sprintf("%03d- ", $idx+1) . "<del>" . trim($val) . "</del>";
+        foreach($o1 as $idx => $val) $o2[sprintf("%03d>",$idx)] = sprintf("%03d+ ", $idx+1) . "<ins>" . trim($val) . "</ins>";
+        $diff = array_merge($w2, $o2);
+        ksort($diff);
+        return implode("\n", $diff);
+    }
+}
+
+

+ 31 - 0
database/migrations/2024_03_19_201950_create_histories_table.php

@@ -0,0 +1,31 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('histories', function (Blueprint $table) {
+            $table->id();
+            $table->bigInteger("action_id")->index();
+            $table->string("field", 100);
+            $table->text("old")->nullable();
+            $table->text("new")->nullable();
+            $table->mediumText("diff")->nullable();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('histories');
+    }
+};

+ 17 - 0
lang/en/model-enums.php

@@ -0,0 +1,17 @@
+<?php
+
+use \App\Models\Enums\ProjectStatus;
+
+return [
+    'project' => [
+        'status' => [
+            ProjectStatus::DOING->value => "In progress",
+            ProjectStatus::WAIT->value => "Not Submitted",
+            ProjectStatus::DONE->value => "A-Approved & B-Approved w/comment",
+            ProjectStatus::PAUSE->value => "C-Amendment & Resubmission Req’d",
+            ProjectStatus::CLOSED->value => "D-Rejected",
+            ProjectStatus::CANCEL->value => "Cancelled",
+            ProjectStatus::PENDING_REVIEW->value => "Pending for Approval",
+        ]
+    ]
+];

+ 3 - 0
routes/api.php

@@ -112,6 +112,9 @@ Route::middleware(['auth:sanctum'])->group(function () {
         Route::post("task-batch-create", [API\TaskController::class, "batchStore"])->name("task.batch-store");
 
         Route::get("project-tree/{project_id}", [API\ProjectController::class, "treeIndex"])->name("project.project-tree");
+
         Route::get("project-link-requirements-group/{project_id}",[API\ProjectController::class, "requirementsLinkGroup"])->name("project.link-requirements-group");
+
+        Route::get("action/{object_type}/history/{object_id}", [API\ActionController::class, "history"])->name("action.history");
     });
 });