753 lines
28 KiB
PHP
753 lines
28 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Business;
|
||
|
||
use App\Business\SystemizationTodoWorkbookImporter;
|
||
use App\Models\Business\SystemizationTodo;
|
||
use App\Models\Business\SystemizationTodoAttachment;
|
||
use Illuminate\Http\RedirectResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Routing\Controller;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\Validation\Rule;
|
||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||
use Throwable;
|
||
|
||
class SystemizationTodos extends Controller
|
||
{
|
||
private const OVERDUE_FILTER = '__overdue__';
|
||
|
||
public function page(): array
|
||
{
|
||
$filters = $this->filters();
|
||
$query = SystemizationTodo::query()
|
||
->with('attachments')
|
||
->withCount('attachments');
|
||
|
||
if ($filters['status'] === self::OVERDUE_FILTER) {
|
||
$query->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED])
|
||
->whereNotNull('due_date')
|
||
->whereDate('due_date', '<', today());
|
||
} elseif ($filters['status'] !== '') {
|
||
$query->where('status', $filters['status']);
|
||
}
|
||
if ($filters['category'] !== '') {
|
||
$query->where('category', $filters['category']);
|
||
}
|
||
if ($filters['owners'] !== []) {
|
||
$query->where(function ($builder) use ($filters): void {
|
||
foreach ($filters['owners'] as $owner) {
|
||
$builder->orWhere(
|
||
'owner',
|
||
'like',
|
||
'%' . addcslashes($owner, '%_\\') . '%',
|
||
);
|
||
}
|
||
});
|
||
}
|
||
if ($filters['assignees'] !== []) {
|
||
$query->where(function ($builder) use ($filters): void {
|
||
foreach ($filters['assignees'] as $assignee) {
|
||
$builder->orWhere(
|
||
'assignee',
|
||
'like',
|
||
'%' . addcslashes($assignee, '%_\\') . '%',
|
||
);
|
||
}
|
||
});
|
||
}
|
||
if ($filters['start_date'] !== '') {
|
||
$query->whereDate('start_date', $filters['start_date']);
|
||
}
|
||
if ($filters['due_date'] !== '') {
|
||
$query->whereDate('due_date', $filters['due_date']);
|
||
}
|
||
if ($filters['keyword'] !== '') {
|
||
$like = '%' . addcslashes($filters['keyword'], '%_\\') . '%';
|
||
$query->where(function ($builder) use ($like): void {
|
||
$builder->where('title', 'like', $like)
|
||
->orWhere('category', 'like', $like)
|
||
->orWhere('section', 'like', $like)
|
||
->orWhere('owner', 'like', $like)
|
||
->orWhere('assignee', 'like', $like)
|
||
->orWhere('progress', 'like', $like)
|
||
->orWhere('notes', 'like', $like);
|
||
});
|
||
}
|
||
|
||
$items = $query
|
||
->select($query->qualifyColumn('*'))
|
||
->orderBy('sort_order')
|
||
->orderBy('id')
|
||
->get();
|
||
$sequenceMap = $this->hierarchySequenceMap();
|
||
$items->each(function (SystemizationTodo $todo) use ($sequenceMap): void {
|
||
$sequence = $sequenceMap[$todo->id] ?? [
|
||
'type_sequence' => 0,
|
||
'content_sequence' => 0,
|
||
'item_sequence' => 0,
|
||
'order' => PHP_INT_MAX,
|
||
'category_display' => '',
|
||
'section_display' => '',
|
||
'type_count' => 0,
|
||
'content_count' => 0,
|
||
];
|
||
$todo->setAttribute('type_sequence', $sequence['type_sequence']);
|
||
$todo->setAttribute('content_sequence', $sequence['content_sequence']);
|
||
$todo->setAttribute('item_sequence', $sequence['item_sequence']);
|
||
$todo->setAttribute('hierarchy_order', $sequence['order']);
|
||
$todo->setAttribute('category_display', $sequence['category_display']);
|
||
$todo->setAttribute('section_display', $sequence['section_display']);
|
||
$todo->setAttribute('type_count', $sequence['type_count']);
|
||
$todo->setAttribute('content_count', $sequence['content_count']);
|
||
});
|
||
$items = $items->sortBy('hierarchy_order')->values();
|
||
$positionGroups = $this->positionGroups($sequenceMap);
|
||
|
||
$base = SystemizationTodo::query();
|
||
$categories = SystemizationTodo::query()
|
||
->whereNotNull('category')
|
||
->where('category', '<>', '')
|
||
->distinct()
|
||
->orderBy('category')
|
||
->pluck('category');
|
||
$statusCounts = (clone $base)
|
||
->selectRaw('status, COUNT(*) AS aggregate')
|
||
->groupBy('status')
|
||
->pluck('aggregate', 'status');
|
||
$overdueCount = (clone $base)
|
||
->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED])
|
||
->whereNotNull('due_date')
|
||
->whereDate('due_date', '<', today())
|
||
->count();
|
||
$statusOptions = collect();
|
||
if ($overdueCount > 0) {
|
||
$statusOptions->push([
|
||
'value' => self::OVERDUE_FILTER,
|
||
'label' => '已逾期',
|
||
]);
|
||
}
|
||
foreach (SystemizationTodo::STATUSES as $status) {
|
||
if ((int) $statusCounts->get($status, 0) > 0) {
|
||
$statusOptions->push([
|
||
'value' => $status,
|
||
'label' => $status,
|
||
]);
|
||
}
|
||
}
|
||
|
||
return [
|
||
'view' => 'business.pages.systemization-todos',
|
||
'title' => '系统化待办',
|
||
'items' => $items,
|
||
'todoPositionGroups' => $positionGroups,
|
||
'todoFilters' => $filters,
|
||
'todoCategories' => $categories,
|
||
'todoContentOptions' => $this->contentOptions(),
|
||
'todoOwners' => $this->peopleOptions('owner'),
|
||
'todoAssignees' => $this->peopleOptions('assignee'),
|
||
'todoStatuses' => SystemizationTodo::STATUSES,
|
||
'todoStatusOptions' => $statusOptions,
|
||
'todoSummary' => [
|
||
'total' => (clone $base)->count(),
|
||
'pending' => (int) $statusCounts->get(SystemizationTodo::STATUS_PENDING, 0),
|
||
'in_progress' => (int) $statusCounts->get(SystemizationTodo::STATUS_IN_PROGRESS, 0),
|
||
'completed' => (int) $statusCounts->get(SystemizationTodo::STATUS_COMPLETED, 0),
|
||
'overdue' => $overdueCount,
|
||
],
|
||
'todoRoutes' => [
|
||
'store' => route('business.systemization-todos.store'),
|
||
'update' => route('business.systemization-todos.update', ['todo' => '__TODO__']),
|
||
'category' => route('business.systemization-todos.category', ['todo' => '__TODO__']),
|
||
'section' => route('business.systemization-todos.section', ['todo' => '__TODO__']),
|
||
'destroy' => route('business.systemization-todos.destroy', ['todo' => '__TODO__']),
|
||
'attachment' => route('business.systemization-todos.attachments.show', [
|
||
'todo' => '__TODO__',
|
||
'attachment' => '__ATTACHMENT__',
|
||
]),
|
||
'import' => route('business.systemization-todos.import'),
|
||
],
|
||
];
|
||
}
|
||
|
||
private function hierarchySequenceMap(): array
|
||
{
|
||
$todos = SystemizationTodo::query()
|
||
->orderBy('sort_order')
|
||
->orderBy('id')
|
||
->get(['id', 'category', 'section', 'sort_order']);
|
||
$rows = $todos->map(function (SystemizationTodo $todo): array {
|
||
$category = $this->hierarchyLabel($todo->category);
|
||
$section = $this->hierarchyLabel($todo->section);
|
||
|
||
return [
|
||
'category_key' => $this->hierarchyGroupKey($category),
|
||
'category_label' => $category,
|
||
'section_key' => $this->hierarchyGroupKey($section),
|
||
'section_label' => $section,
|
||
'sort_order' => (int) $todo->sort_order,
|
||
'id' => (int) $todo->id,
|
||
];
|
||
})->all();
|
||
|
||
$categoryOrder = [];
|
||
$categoryLabels = [];
|
||
$sectionOrder = [];
|
||
$sectionLabels = [];
|
||
$groupedRows = [];
|
||
foreach ($rows as $row) {
|
||
$categoryKey = $row['category_key'];
|
||
$sectionKey = $row['section_key'];
|
||
$categoryOrder[$categoryKey] = min(
|
||
$categoryOrder[$categoryKey] ?? PHP_INT_MAX,
|
||
$row['sort_order'],
|
||
);
|
||
$categoryLabels[$categoryKey] ??= $row['category_label'];
|
||
$sectionOrder[$categoryKey][$sectionKey] = min(
|
||
$sectionOrder[$categoryKey][$sectionKey] ?? PHP_INT_MAX,
|
||
$row['sort_order'],
|
||
);
|
||
$sectionLabels[$categoryKey][$sectionKey] ??= $row['section_label'];
|
||
$groupedRows[$categoryKey][$sectionKey][] = $row;
|
||
}
|
||
|
||
$categoryKeys = array_keys($categoryOrder);
|
||
usort($categoryKeys, function (string $left, string $right) use (
|
||
$categoryOrder,
|
||
$categoryLabels,
|
||
): int {
|
||
return $this->compareHierarchyGroups(
|
||
$left,
|
||
$right,
|
||
$categoryOrder,
|
||
$categoryLabels,
|
||
);
|
||
});
|
||
|
||
$sequenceMap = [];
|
||
$hierarchyOrder = 0;
|
||
foreach ($categoryKeys as $categoryIndex => $categoryKey) {
|
||
$typeCount = array_sum(array_map(
|
||
static fn (array $rows): int => count($rows),
|
||
$groupedRows[$categoryKey],
|
||
));
|
||
$contentKeys = array_keys($sectionOrder[$categoryKey]);
|
||
usort($contentKeys, function (string $left, string $right) use (
|
||
$categoryKey,
|
||
$sectionOrder,
|
||
$sectionLabels,
|
||
): int {
|
||
return $this->compareHierarchyGroups(
|
||
$left,
|
||
$right,
|
||
$sectionOrder[$categoryKey],
|
||
$sectionLabels[$categoryKey],
|
||
);
|
||
});
|
||
|
||
foreach ($contentKeys as $contentIndex => $contentKey) {
|
||
$contentRows = $groupedRows[$categoryKey][$contentKey];
|
||
usort($contentRows, static fn (array $left, array $right): int =>
|
||
[$left['sort_order'], $left['id']] <=> [$right['sort_order'], $right['id']]
|
||
);
|
||
|
||
foreach ($contentRows as $itemIndex => $row) {
|
||
$sequenceMap[$row['id']] = [
|
||
'type_sequence' => $categoryIndex + 1,
|
||
'content_sequence' => $contentIndex + 1,
|
||
'item_sequence' => $itemIndex + 1,
|
||
'order' => $hierarchyOrder++,
|
||
'category_display' => $row['category_label'],
|
||
'section_display' => $row['section_label'],
|
||
'type_count' => $typeCount,
|
||
'content_count' => count($contentRows),
|
||
];
|
||
}
|
||
}
|
||
}
|
||
|
||
return $sequenceMap;
|
||
}
|
||
|
||
private function positionGroups(array $sequenceMap): array
|
||
{
|
||
$groups = [];
|
||
$todos = SystemizationTodo::query()
|
||
->orderBy('sort_order')
|
||
->orderBy('id')
|
||
->get(['id', 'category', 'section', 'sort_order']);
|
||
foreach ($todos as $todo) {
|
||
$sequence = $sequenceMap[$todo->id] ?? null;
|
||
if ($sequence === null) {
|
||
continue;
|
||
}
|
||
$key = $sequence['type_sequence'] . ':' . $sequence['content_sequence'];
|
||
$groups[$key] ??= [
|
||
'category' => (string) ($todo->category ?? ''),
|
||
'section' => $sequence['section_display'],
|
||
'count' => 0,
|
||
];
|
||
$groups[$key]['count']++;
|
||
}
|
||
|
||
return array_values($groups);
|
||
}
|
||
|
||
private function compareHierarchyGroups(
|
||
string $left,
|
||
string $right,
|
||
array $sortOrders,
|
||
array $labels,
|
||
): int {
|
||
$comparison = ($sortOrders[$left] ?? PHP_INT_MAX) <=> ($sortOrders[$right] ?? PHP_INT_MAX);
|
||
|
||
return $comparison !== 0
|
||
? $comparison
|
||
: strnatcasecmp($labels[$left] ?? '', $labels[$right] ?? '');
|
||
}
|
||
|
||
private function hierarchyLabel(?string $value): string
|
||
{
|
||
return trim((string) preg_replace(
|
||
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u',
|
||
'',
|
||
(string) $value,
|
||
));
|
||
}
|
||
|
||
private function hierarchyGroupKey(string $label): string
|
||
{
|
||
$normalized = preg_replace(
|
||
'/[\s\p{Z}\x{200B}\x{FEFF}]+/u',
|
||
'',
|
||
$label,
|
||
);
|
||
$normalized = (string) $normalized;
|
||
|
||
return $normalized === '' ? "\0" : mb_strtolower($normalized);
|
||
}
|
||
|
||
public function store(Request $request): RedirectResponse
|
||
{
|
||
$data = $this->validated($request);
|
||
$position = max(1, (int) ($data['position'] ?? 1));
|
||
unset($data['position']);
|
||
$data['company_id'] = (int) company_id();
|
||
$data['created_by'] = user_id();
|
||
$data['updated_by'] = user_id();
|
||
$this->normalizeCompletionDates($data);
|
||
$insertedSequence = $position;
|
||
$todo = DB::transaction(function () use ($data, $position, &$insertedSequence): SystemizationTodo {
|
||
$siblings = $this->contentSiblings($data['category'] ?? null, $data['section'] ?? null);
|
||
$nextOrder = (int) SystemizationTodo::query()->max('sort_order') + 1;
|
||
if ($siblings->isEmpty()) {
|
||
$insertOrder = $nextOrder;
|
||
} elseif ($position <= $siblings->count()) {
|
||
$insertOrder = (int) $siblings[$position - 1]->sort_order;
|
||
} else {
|
||
$insertOrder = (int) $siblings->max('sort_order') + 1;
|
||
}
|
||
|
||
SystemizationTodo::query()
|
||
->where('sort_order', '>=', $insertOrder)
|
||
->increment('sort_order');
|
||
$insertedSequence = $siblings->isEmpty()
|
||
? 1
|
||
: min($position, $siblings->count() + 1);
|
||
|
||
return SystemizationTodo::query()->create([
|
||
...$data,
|
||
'sort_order' => $insertOrder,
|
||
]);
|
||
});
|
||
$this->storeAttachments($request, $todo);
|
||
|
||
return $this->backToPage(
|
||
'待办事项已新增为当前内容第 ' . $insertedSequence . ' 条并定位。',
|
||
'success',
|
||
$todo,
|
||
);
|
||
}
|
||
|
||
public function updateCategory(Request $request, SystemizationTodo $todo): RedirectResponse
|
||
{
|
||
$data = $request->validate([
|
||
'category' => ['required', 'string', 'max:100'],
|
||
]);
|
||
$ids = [];
|
||
DB::transaction(function () use ($todo, $data, &$ids): void {
|
||
$ids = $this->groupTodoIds($todo, false);
|
||
$this->updateGroupField($ids, ['category' => trim($data['category'])]);
|
||
});
|
||
|
||
return $this->backToPage(
|
||
'类型已更新,当前类型下的 ' . count($ids) . ' 条待办已同步调整。',
|
||
'success',
|
||
$todo,
|
||
);
|
||
}
|
||
|
||
public function updateSection(Request $request, SystemizationTodo $todo): RedirectResponse
|
||
{
|
||
$data = $request->validate([
|
||
'section' => ['required', 'string', 'max:255'],
|
||
]);
|
||
$ids = [];
|
||
DB::transaction(function () use ($todo, $data, &$ids): void {
|
||
$ids = $this->groupTodoIds($todo, true);
|
||
$this->updateGroupField($ids, ['section' => trim($data['section'])]);
|
||
});
|
||
|
||
return $this->backToPage(
|
||
'内容已更新,当前内容下的 ' . count($ids) . ' 条待办已同步调整。',
|
||
'success',
|
||
$todo,
|
||
);
|
||
}
|
||
|
||
private function contentSiblings(
|
||
?string $category,
|
||
?string $section,
|
||
?int $excludeId = null,
|
||
): Collection
|
||
{
|
||
$categoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($category));
|
||
$sectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($section));
|
||
|
||
return SystemizationTodo::query()
|
||
->orderBy('sort_order')
|
||
->orderBy('id')
|
||
->lockForUpdate()
|
||
->get()
|
||
->filter(fn (SystemizationTodo $todo): bool =>
|
||
(int) $todo->id !== (int) $excludeId
|
||
&& $this->hierarchyGroupKey($this->hierarchyLabel($todo->category)) === $categoryKey
|
||
&& $this->hierarchyGroupKey($this->hierarchyLabel($todo->section)) === $sectionKey
|
||
)
|
||
->values();
|
||
}
|
||
|
||
private function positionForUpdate(
|
||
SystemizationTodo $todo,
|
||
int $targetPosition,
|
||
array &$data,
|
||
): void {
|
||
$currentCategoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->category));
|
||
$currentSectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->section));
|
||
$targetCategoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($data['category'] ?? null));
|
||
$targetSectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($data['section'] ?? null));
|
||
$sameGroup = $currentCategoryKey === $targetCategoryKey
|
||
&& $currentSectionKey === $targetSectionKey;
|
||
|
||
if ($sameGroup) {
|
||
$currentSiblings = $this->contentSiblings($todo->category, $todo->section);
|
||
$currentIndex = $currentSiblings->search(
|
||
static fn (SystemizationTodo $item): bool => (int) $item->id === (int) $todo->id
|
||
);
|
||
if ($currentIndex !== false && $targetPosition === ((int) $currentIndex + 1)) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
$siblings = $this->contentSiblings(
|
||
$data['category'] ?? null,
|
||
$data['section'] ?? null,
|
||
(int) $todo->id,
|
||
);
|
||
if ($siblings->isEmpty()) {
|
||
$insertOrder = (int) SystemizationTodo::query()->max('sort_order') + 1;
|
||
} elseif ($targetPosition <= $siblings->count()) {
|
||
$insertOrder = (int) $siblings[$targetPosition - 1]->sort_order;
|
||
} else {
|
||
$insertOrder = (int) $siblings->max('sort_order') + 1;
|
||
}
|
||
|
||
SystemizationTodo::query()
|
||
->where('sort_order', '>=', $insertOrder)
|
||
->increment('sort_order');
|
||
$data['sort_order'] = $insertOrder;
|
||
}
|
||
|
||
private function groupTodoIds(SystemizationTodo $todo, bool $includeSection): array
|
||
{
|
||
$categoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->category));
|
||
$sectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->section));
|
||
|
||
return SystemizationTodo::query()
|
||
->lockForUpdate()
|
||
->get(['id', 'category', 'section'])
|
||
->filter(fn (SystemizationTodo $candidate): bool =>
|
||
$this->hierarchyGroupKey($this->hierarchyLabel($candidate->category)) === $categoryKey
|
||
&& (! $includeSection
|
||
|| $this->hierarchyGroupKey($this->hierarchyLabel($candidate->section)) === $sectionKey)
|
||
)
|
||
->pluck('id')
|
||
->map(static fn (mixed $id): int => (int) $id)
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
private function updateGroupField(array $ids, array $data): void
|
||
{
|
||
if ($ids === []) {
|
||
return;
|
||
}
|
||
SystemizationTodo::query()
|
||
->whereIn('id', $ids)
|
||
->update([
|
||
...$data,
|
||
'updated_by' => user_id(),
|
||
]);
|
||
}
|
||
|
||
public function update(Request $request, SystemizationTodo $todo): RedirectResponse
|
||
{
|
||
$data = $this->validated($request);
|
||
$targetPosition = max(0, (int) ($data['position'] ?? 0));
|
||
unset($data['position']);
|
||
$data['updated_by'] = user_id();
|
||
$this->normalizeCompletionDates($data);
|
||
|
||
$removeIds = collect($request->input('remove_attachments', []))
|
||
->map(static fn (mixed $id): int => (int) $id)
|
||
->filter()
|
||
->unique()
|
||
->values();
|
||
if ($removeIds->isNotEmpty()) {
|
||
$attachments = $todo->attachments()
|
||
->where('company_id', (int) company_id())
|
||
->whereIn('id', $removeIds)
|
||
->get();
|
||
foreach ($attachments as $attachment) {
|
||
Storage::disk('uploads')->delete($attachment->path);
|
||
$attachment->delete();
|
||
}
|
||
}
|
||
|
||
DB::transaction(function () use ($todo, $data, $targetPosition): void {
|
||
$lockedTodo = SystemizationTodo::query()
|
||
->lockForUpdate()
|
||
->findOrFail($todo->id);
|
||
if ($targetPosition > 0) {
|
||
$this->positionForUpdate($lockedTodo, $targetPosition, $data);
|
||
}
|
||
$lockedTodo->update($data);
|
||
$todo->setRawAttributes($lockedTodo->getAttributes(), true);
|
||
});
|
||
$this->storeAttachments($request, $todo);
|
||
|
||
return $this->backToPage('待办事项已更新并定位。', 'success', $todo);
|
||
}
|
||
|
||
public function destroy(SystemizationTodo $todo): RedirectResponse
|
||
{
|
||
$todo->update(['updated_by' => user_id()]);
|
||
$todo->delete();
|
||
|
||
return $this->backToPage('待办事项已删除。');
|
||
}
|
||
|
||
public function attachment(SystemizationTodo $todo, SystemizationTodoAttachment $attachment): StreamedResponse
|
||
{
|
||
abort_unless((int) $attachment->systemization_todo_id === (int) $todo->id, 404);
|
||
abort_unless((int) $attachment->company_id === (int) company_id(), 404);
|
||
|
||
$disk = Storage::disk('uploads');
|
||
abort_unless($disk->exists($attachment->path), 404, '截图不存在。');
|
||
|
||
return $disk->response($attachment->path, $attachment->original_name, [
|
||
'Cache-Control' => 'private, no-store',
|
||
'Content-Type' => $attachment->mime_type,
|
||
'X-Content-Type-Options' => 'nosniff',
|
||
]);
|
||
}
|
||
|
||
public function importWorkbook(
|
||
Request $request,
|
||
SystemizationTodoWorkbookImporter $importer,
|
||
): RedirectResponse {
|
||
$data = $request->validate([
|
||
'source' => ['required', 'file', 'mimes:xlsx', 'max:20480'],
|
||
]);
|
||
|
||
try {
|
||
$result = $importer->sync(
|
||
$data['source']->getRealPath(),
|
||
(int) company_id(),
|
||
user_id(),
|
||
);
|
||
} catch (Throwable $exception) {
|
||
report($exception);
|
||
|
||
return $this->backToPage('导入失败,请确认工作簿表头、文件格式和内容后重试。', 'error');
|
||
}
|
||
|
||
return $this->backToPage(sprintf(
|
||
'同步完成:%s,新增 %d 条,更新 %d 条,保留工作簿外事项 %d 条。',
|
||
$result['sheet'],
|
||
$result['created'],
|
||
$result['updated'],
|
||
$result['retained'],
|
||
));
|
||
}
|
||
|
||
private function filters(): array
|
||
{
|
||
$status = (string) request('status', '');
|
||
|
||
return [
|
||
'status' => $status === self::OVERDUE_FILTER
|
||
|| in_array($status, SystemizationTodo::STATUSES, true)
|
||
? $status
|
||
: '',
|
||
'category' => trim((string) request('category', '')),
|
||
'owners' => $this->peopleFilter('owner'),
|
||
'assignees' => $this->peopleFilter('assignee'),
|
||
'start_date' => $this->normalizeDateFilter(request('start_date')),
|
||
'due_date' => $this->normalizeDateFilter(request('due_date')),
|
||
'keyword' => trim((string) request('keyword', '')),
|
||
];
|
||
}
|
||
|
||
private function peopleFilter(string $key): array
|
||
{
|
||
return collect((array) request($key, []))
|
||
->filter(static fn (mixed $value): bool => is_string($value))
|
||
->map(static fn (string $value): string => trim($value))
|
||
->filter()
|
||
->unique()
|
||
->take(50)
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
private function peopleOptions(string $column): Collection
|
||
{
|
||
return SystemizationTodo::query()
|
||
->whereNotNull($column)
|
||
->where($column, '<>', '')
|
||
->pluck($column)
|
||
->flatMap(static fn (string $value): array => array_map('trim', preg_split('/[、,,]/u', $value) ?: []))
|
||
->filter()
|
||
->unique()
|
||
->sort()
|
||
->values();
|
||
}
|
||
|
||
private function contentOptions(): array
|
||
{
|
||
$options = [];
|
||
$todos = SystemizationTodo::query()
|
||
->whereNotNull('section')
|
||
->where('section', '<>', '')
|
||
->get(['category', 'section']);
|
||
foreach ($todos as $todo) {
|
||
$category = trim((string) ($todo->category ?? ''));
|
||
$section = $this->hierarchyLabel($todo->section);
|
||
if ($section !== '') {
|
||
$options[$category][$section] = true;
|
||
}
|
||
}
|
||
|
||
foreach ($options as $category => $sections) {
|
||
$labels = array_keys($sections);
|
||
usort($labels, 'strnatcasecmp');
|
||
$options[$category] = $labels;
|
||
}
|
||
|
||
return $options;
|
||
}
|
||
|
||
private function normalizeDateFilter(mixed $value): string
|
||
{
|
||
$value = trim((string) $value);
|
||
if ($value === '') {
|
||
return '';
|
||
}
|
||
|
||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||
|
||
return $date instanceof \DateTimeImmutable && $date->format('Y-m-d') === $value
|
||
? $value
|
||
: '';
|
||
}
|
||
|
||
private function validated(Request $request): array
|
||
{
|
||
return $request->validate([
|
||
'category' => ['nullable', 'string', 'max:100'],
|
||
'section' => ['nullable', 'string', 'max:255'],
|
||
'title' => ['required', 'string', 'max:5000'],
|
||
'position' => ['nullable', 'integer', 'min:1'],
|
||
'owner' => ['nullable', 'string', 'max:255'],
|
||
'assignee' => ['nullable', 'string', 'max:255'],
|
||
'start_date' => ['nullable', 'date_format:Y-m-d'],
|
||
'due_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
|
||
'status' => ['required', Rule::in(SystemizationTodo::STATUSES)],
|
||
'completed_at' => ['nullable', 'date_format:Y-m-d'],
|
||
'progress' => ['nullable', 'string', 'max:10000'],
|
||
'notes' => ['nullable', 'string', 'max:10000'],
|
||
'screenshots' => ['nullable', 'array', 'max:12'],
|
||
'screenshots.*' => ['file', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
|
||
'remove_attachments' => ['nullable', 'array'],
|
||
'remove_attachments.*' => ['integer'],
|
||
]);
|
||
}
|
||
|
||
private function normalizeCompletionDates(array &$data): void
|
||
{
|
||
$completed = ($data['status'] ?? '') === SystemizationTodo::STATUS_COMPLETED;
|
||
$data['completed_at'] = $completed
|
||
? (($data['completed_at'] ?? null) ?: ($data['due_date'] ?? null) ?: now()->toDateString())
|
||
: null;
|
||
}
|
||
|
||
private function storeAttachments(Request $request, SystemizationTodo $todo): void
|
||
{
|
||
foreach ($request->file('screenshots', []) as $file) {
|
||
$extension = strtolower($file->getClientOriginalExtension());
|
||
$fileName = (string) Str::uuid() . ($extension !== '' ? '.' . $extension : '');
|
||
$path = Storage::disk('uploads')->putFileAs(
|
||
'systemization-todos/' . (int) company_id() . '/' . $todo->id,
|
||
$file,
|
||
$fileName,
|
||
);
|
||
if ($path === false) {
|
||
throw new \RuntimeException('完成截图保存失败。');
|
||
}
|
||
|
||
SystemizationTodoAttachment::query()->create([
|
||
'company_id' => (int) company_id(),
|
||
'systemization_todo_id' => $todo->id,
|
||
'path' => $path,
|
||
'original_name' => mb_substr(basename($file->getClientOriginalName()), 0, 240),
|
||
'mime_type' => $file->getMimeType() ?: 'application/octet-stream',
|
||
'size' => $file->getSize() ?: 0,
|
||
'uploaded_by' => user_id(),
|
||
]);
|
||
}
|
||
}
|
||
|
||
private function backToPage(
|
||
string $message,
|
||
string $type = 'success',
|
||
?SystemizationTodo $focus = null,
|
||
): RedirectResponse
|
||
{
|
||
$parameters = [
|
||
'section' => 'other',
|
||
'page' => 'systemization-todos',
|
||
];
|
||
if ($focus) {
|
||
$parameters['highlight'] = $focus->id;
|
||
}
|
||
|
||
return redirect()
|
||
->route('business.pages.show', $parameters)
|
||
->with($type === 'success' ? 'success' : 'error', $message);
|
||
}
|
||
}
|