2171 lines
81 KiB
PHP
2171 lines
81 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Business;
|
||
|
||
use App\Business\SystemizationTodoAuditLogger;
|
||
use App\Business\SystemizationTodoWorkbookImporter;
|
||
use App\Models\Business\SystemizationTodo;
|
||
use App\Models\Business\SystemizationTodoAttachment;
|
||
use Illuminate\Http\JsonResponse;
|
||
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__';
|
||
private const REMINDER_OPTIONS = [
|
||
'overdue' => '已逾期',
|
||
'today' => '今天到期',
|
||
'upcoming' => '剩余天数',
|
||
'pending' => '时间待定',
|
||
'none' => '已完成',
|
||
];
|
||
private const OWNER_OPTIONS = ['人参', '常仪', '晨曦', '待定'];
|
||
private const ASSIGNEE_OPTIONS = [
|
||
'人参',
|
||
'苏木',
|
||
'李斯',
|
||
'周生辰',
|
||
'常仪',
|
||
'玉兔',
|
||
'山时',
|
||
'杳杳',
|
||
'林冲',
|
||
'惠兰',
|
||
'晨曦',
|
||
'栎阳',
|
||
'秋香',
|
||
'待定',
|
||
];
|
||
private const COLUMN_WIDTHS_SETTING = 'business.systemization_todos.column_widths';
|
||
private const COLUMN_WIDTH_KEYS = [
|
||
'type',
|
||
'section',
|
||
'sequence',
|
||
'title',
|
||
'owner',
|
||
'assignee',
|
||
'start_date',
|
||
'due_date',
|
||
'reminder',
|
||
'status',
|
||
'progress_notes',
|
||
];
|
||
private const GRID_EDITABLE_FIELDS = [
|
||
'category',
|
||
'section',
|
||
'title',
|
||
'owner',
|
||
'assignee',
|
||
'start_date',
|
||
'due_date',
|
||
'status',
|
||
'progress',
|
||
];
|
||
private const FILTER_QUERY_KEYS = [
|
||
'status' => 'status',
|
||
'reminder' => 'reminder',
|
||
'category' => 'category',
|
||
'owners' => 'owner',
|
||
'assignees' => 'assignee',
|
||
'start_date' => 'start_date',
|
||
'due_date' => 'due_date',
|
||
'keyword' => 'keyword',
|
||
];
|
||
private const OWNER_ALIASES = [
|
||
'人参' => '人参',
|
||
'苏木' => '人参',
|
||
'李斯' => '人参',
|
||
'周生辰' => '人参',
|
||
'晨曦' => '晨曦',
|
||
'栎阳' => '晨曦',
|
||
'秋香' => '晨曦',
|
||
'常仪' => '常仪',
|
||
'玉兔' => '常仪',
|
||
'山时' => '常仪',
|
||
'林冲' => '常仪',
|
||
'杳杳' => '常仪',
|
||
'待定' => '待定',
|
||
];
|
||
|
||
public function __construct(
|
||
private readonly SystemizationTodoAuditLogger $audit,
|
||
) {
|
||
}
|
||
|
||
public function page(): array
|
||
{
|
||
$filters = $this->filters();
|
||
$sequenceMap = $this->hierarchySequenceMap();
|
||
$items = $this->decorateGridItems($this->filteredItems($filters), $sequenceMap);
|
||
$positionGroups = $this->positionGroups($sequenceMap);
|
||
|
||
$summary = $this->todoSummary();
|
||
$categories = SystemizationTodo::query()
|
||
->whereNotNull('category')
|
||
->where('category', '<>', '')
|
||
->distinct()
|
||
->orderBy('category')
|
||
->pluck('category');
|
||
$statusOptions = collect();
|
||
foreach (SystemizationTodo::STATUSES as $status) {
|
||
$statusOptions->push([
|
||
'value' => $status,
|
||
'label' => $status,
|
||
]);
|
||
}
|
||
$todoAssignees = $this->peopleOptions('assignee');
|
||
|
||
return [
|
||
'view' => 'business.pages.systemization-todos',
|
||
'title' => '系统化待办',
|
||
'items' => $items,
|
||
'todoPositionGroups' => $positionGroups,
|
||
'todoFilters' => $filters,
|
||
'todoCategories' => $categories,
|
||
'todoContentOptions' => $this->contentOptions(),
|
||
'todoOwners' => collect(self::OWNER_OPTIONS),
|
||
'todoAssignees' => $todoAssignees,
|
||
'todoAssigneeOptions' => collect(self::ASSIGNEE_OPTIONS),
|
||
'todoAssigneeOwners' => $this->assigneeOwnerMap($todoAssignees),
|
||
'todoStatuses' => SystemizationTodo::STATUSES,
|
||
'todoStatusOptions' => $statusOptions,
|
||
'todoReminderOptions' => collect(self::REMINDER_OPTIONS)
|
||
->map(static fn (string $label, string $value): array => [
|
||
'value' => $value,
|
||
'label' => $label,
|
||
])
|
||
->values(),
|
||
'todoReturnFilters' => base64_encode(json_encode(
|
||
$filters,
|
||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
|
||
)),
|
||
'todoColumnWidths' => $this->columnWidths(),
|
||
'todoSummary' => $summary,
|
||
'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'),
|
||
'history' => route('business.systemization-todos.history'),
|
||
'historyExport' => route('business.systemization-todos.history.export'),
|
||
'columnWidths' => route('business.systemization-todos.column-widths'),
|
||
'gridCells' => route('business.systemization-todos.grid-cells'),
|
||
'gridMove' => route('business.systemization-todos.grid-move', ['todo' => '__TODO__']),
|
||
'gridInsert' => route('business.systemization-todos.grid-insert', ['todo' => '__TODO__']),
|
||
'gridCreate' => route('business.systemization-todos.grid-create'),
|
||
'gridUpload' => route('business.systemization-todos.grid-upload', ['todo' => '__TODO__']),
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $filters
|
||
* @return Collection<int, SystemizationTodo>
|
||
*/
|
||
private function filteredItems(array $filters): Collection
|
||
{
|
||
$query = SystemizationTodo::query()
|
||
->with('attachments')
|
||
->withCount('attachments');
|
||
|
||
if ($filters['status'] === self::OVERDUE_FILTER) {
|
||
$query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED)
|
||
->whereNotNull('due_date')
|
||
->whereDate('due_date', '<', today());
|
||
} elseif ($filters['status'] === SystemizationTodo::STATUS_INCOMPLETE) {
|
||
$query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED);
|
||
} elseif ($filters['status'] === SystemizationTodo::STATUS_COMPLETED) {
|
||
$query->where('status', $filters['status']);
|
||
}
|
||
if ($filters['reminder'] !== '') {
|
||
if ($filters['reminder'] === 'overdue') {
|
||
$query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED)
|
||
->whereNotNull('due_date')
|
||
->whereDate('due_date', '<', today());
|
||
} elseif ($filters['reminder'] === 'today') {
|
||
$query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED)
|
||
->whereDate('due_date', today());
|
||
} elseif ($filters['reminder'] === 'upcoming') {
|
||
$query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED)
|
||
->whereDate('due_date', '>', today());
|
||
} elseif ($filters['reminder'] === 'pending') {
|
||
$query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED)
|
||
->whereNull('due_date');
|
||
} elseif ($filters['reminder'] === 'none') {
|
||
$query->where('status', SystemizationTodo::STATUS_COMPLETED);
|
||
}
|
||
}
|
||
if ($filters['category'] !== '') {
|
||
$query->where('category', $filters['category']);
|
||
}
|
||
if ($filters['owners'] !== []) {
|
||
$query->where(function ($builder) use ($filters): void {
|
||
foreach ($filters['owners'] as $owner) {
|
||
$this->applyOwnerFilter($builder, $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);
|
||
});
|
||
}
|
||
|
||
return $query
|
||
->select($query->qualifyColumn('*'))
|
||
->orderBy('sort_order')
|
||
->orderBy('id')
|
||
->get();
|
||
}
|
||
|
||
/**
|
||
* @param Collection<int, SystemizationTodo> $items
|
||
* @param array<int, array<string, mixed>> $sequenceMap
|
||
* @return Collection<int, SystemizationTodo>
|
||
*/
|
||
private function decorateGridItems(Collection $items, array $sequenceMap): Collection
|
||
{
|
||
$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']);
|
||
$todo->setAttribute('status_label', $todo->statusLabel());
|
||
$todo->setAttribute('owner_display', implode('、', $this->ownerNames($todo->owner)));
|
||
$reminder = $todo->reminder();
|
||
$todo->setAttribute('reminder_label', $reminder['label']);
|
||
$todo->setAttribute('reminder_tone', $reminder['tone']);
|
||
});
|
||
|
||
return $items->sortBy('hierarchy_order')->values();
|
||
}
|
||
|
||
private function todoSummary(): array
|
||
{
|
||
$counts = SystemizationTodo::query()
|
||
->selectRaw(
|
||
'COUNT(*) AS total, '
|
||
. 'COALESCE(SUM(CASE WHEN status = ? THEN 1 ELSE 0 END), 0) AS completed, '
|
||
. 'COALESCE(SUM(CASE WHEN status <> ? AND due_date IS NOT NULL AND due_date < ? THEN 1 ELSE 0 END), 0) AS overdue',
|
||
[
|
||
SystemizationTodo::STATUS_COMPLETED,
|
||
SystemizationTodo::STATUS_COMPLETED,
|
||
today()->toDateString(),
|
||
],
|
||
)
|
||
->first();
|
||
$total = (int) ($counts->total ?? 0);
|
||
$completed = (int) ($counts->completed ?? 0);
|
||
|
||
return [
|
||
'total' => $total,
|
||
'incomplete' => max(0, $total - $completed),
|
||
'completed' => $completed,
|
||
'overdue' => (int) ($counts->overdue ?? 0),
|
||
];
|
||
}
|
||
|
||
public function saveColumnWidths(Request $request): JsonResponse
|
||
{
|
||
$ruleKeys = implode(',', self::COLUMN_WIDTH_KEYS);
|
||
$data = $request->validate([
|
||
'widths' => ['required', 'array:' . $ruleKeys],
|
||
'widths.*' => ['required', 'integer', 'min:64', 'max:720'],
|
||
]);
|
||
$widths = [];
|
||
foreach (self::COLUMN_WIDTH_KEYS as $key) {
|
||
$widths[$key] = (int) $data['widths'][$key];
|
||
}
|
||
|
||
setting([
|
||
self::COLUMN_WIDTHS_SETTING => json_encode(
|
||
$widths,
|
||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
|
||
),
|
||
])->save();
|
||
|
||
return response()->json(['ok' => true, 'widths' => $widths]);
|
||
}
|
||
|
||
private function columnWidths(): ?array
|
||
{
|
||
$encoded = setting(self::COLUMN_WIDTHS_SETTING);
|
||
$widths = is_string($encoded) ? json_decode($encoded, true) : $encoded;
|
||
if (! is_array($widths)) {
|
||
return null;
|
||
}
|
||
|
||
$normalized = [];
|
||
foreach (self::COLUMN_WIDTH_KEYS as $key) {
|
||
$width = $widths[$key] ?? null;
|
||
if (! is_int($width) && ! ctype_digit((string) $width)) {
|
||
return null;
|
||
}
|
||
$width = (int) $width;
|
||
if ($width < 64 || $width > 720) {
|
||
return null;
|
||
}
|
||
$normalized[$key] = $width;
|
||
}
|
||
|
||
return $normalized;
|
||
}
|
||
|
||
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(
|
||
'/(^|\R)\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;))]\s*/u',
|
||
'$1',
|
||
(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);
|
||
$this->normalizePendingDates($request, $data);
|
||
$data['progress'] = SystemizationTodo::mergeNotesIntoProgress(
|
||
$data['progress'] ?? null,
|
||
$data['notes'] ?? null,
|
||
);
|
||
$data['notes'] = null;
|
||
$this->normalizeOwnerData($data);
|
||
$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);
|
||
|
||
$todo = SystemizationTodo::query()->create([
|
||
...$data,
|
||
'sort_order' => $insertOrder,
|
||
]);
|
||
$this->audit->record(
|
||
'create',
|
||
(int) company_id(),
|
||
(int) $todo->id,
|
||
null,
|
||
$this->audit->snapshot($todo),
|
||
user_id(),
|
||
null,
|
||
['source' => 'manual'],
|
||
);
|
||
|
||
return $todo;
|
||
});
|
||
$this->storeAttachments($request, $todo);
|
||
|
||
return $this->backToPage(
|
||
'待办事项已新增为当前内容第 ' . $insertedSequence . ' 条并定位。',
|
||
'success',
|
||
$todo,
|
||
true,
|
||
);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
$todos = SystemizationTodo::query()
|
||
->whereIn('id', $ids)
|
||
->lockForUpdate()
|
||
->get();
|
||
$auditEvents = [];
|
||
foreach ($todos as $todo) {
|
||
$before = $this->audit->snapshot($todo);
|
||
$todo->fill([
|
||
...$data,
|
||
'updated_by' => user_id(),
|
||
])->save();
|
||
$auditEvents[] = [
|
||
'company_id' => (int) company_id(),
|
||
'todo_id' => (int) $todo->id,
|
||
'action' => 'update',
|
||
'before' => $before,
|
||
'after' => $this->audit->snapshot($todo),
|
||
'user_id' => user_id(),
|
||
'meta' => ['source' => 'manual', 'scope' => 'group'],
|
||
];
|
||
}
|
||
$this->audit->recordMany($auditEvents);
|
||
}
|
||
|
||
public function update(Request $request, SystemizationTodo $todo): RedirectResponse
|
||
{
|
||
$before = $this->audit->snapshot($todo, true);
|
||
$data = $this->validated($request);
|
||
$this->normalizePendingDates($request, $data);
|
||
$data['progress'] = SystemizationTodo::mergeNotesIntoProgress(
|
||
array_key_exists('progress', $data) ? $data['progress'] : $todo->progress,
|
||
array_key_exists('notes', $data) ? $data['notes'] : $todo->notes,
|
||
);
|
||
$data['notes'] = null;
|
||
$this->normalizeOwnerData($data);
|
||
$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);
|
||
$todo->refresh();
|
||
$after = $this->audit->snapshot($todo, true);
|
||
if ($before !== $after) {
|
||
$this->audit->record(
|
||
'update',
|
||
(int) company_id(),
|
||
(int) $todo->id,
|
||
$before,
|
||
$after,
|
||
user_id(),
|
||
null,
|
||
['source' => 'manual'],
|
||
);
|
||
}
|
||
|
||
return $this->backToPage('待办事项已更新并定位。', 'success', $todo);
|
||
}
|
||
|
||
public function destroy(SystemizationTodo $todo): RedirectResponse
|
||
{
|
||
DB::transaction(function () use ($todo): void {
|
||
$before = $this->audit->snapshot($todo, true);
|
||
$todo->update(['updated_by' => user_id()]);
|
||
$todo->delete();
|
||
$this->audit->record(
|
||
'delete',
|
||
(int) company_id(),
|
||
(int) $todo->id,
|
||
$before,
|
||
null,
|
||
user_id(),
|
||
null,
|
||
['source' => 'manual'],
|
||
);
|
||
});
|
||
|
||
return $this->backToPage('待办事项已删除。');
|
||
}
|
||
|
||
public function batchUpdateCells(Request $request): JsonResponse
|
||
{
|
||
$data = $request->validate([
|
||
'changes' => ['required', 'array', 'min:1', 'max:1000'],
|
||
'changes.*.id' => ['required', 'integer'],
|
||
'changes.*.field' => ['required', Rule::in(self::GRID_EDITABLE_FIELDS)],
|
||
'changes.*.value' => ['present', 'nullable', 'string', 'max:10000'],
|
||
'changes.*.original' => ['nullable', 'string', 'max:10000'],
|
||
'changes.*.scope' => ['nullable', Rule::in(['row', 'group'])],
|
||
]);
|
||
|
||
$result = DB::transaction(function () use ($data): array {
|
||
$todos = SystemizationTodo::query()
|
||
->lockForUpdate()
|
||
->get()
|
||
->keyBy(static fn (SystemizationTodo $todo): int => (int) $todo->id);
|
||
$affectedIds = [];
|
||
$changes = [];
|
||
foreach (array_values($data['changes']) as $change) {
|
||
$todo = $todos->get((int) $change['id']);
|
||
if (! $todo instanceof SystemizationTodo) {
|
||
return ['ok' => false, 'status' => 404, 'message' => '待办不存在。'];
|
||
}
|
||
|
||
$field = (string) $change['field'];
|
||
$scope = (string) ($change['scope'] ?? 'row');
|
||
if (! $this->gridFieldSupportsGroupScope($field)) {
|
||
$scope = 'row';
|
||
}
|
||
$original = $change['original'] ?? null;
|
||
if ($original !== null && ! $this->gridOriginalStillMatches($todo, $field, (string) $original)) {
|
||
return [
|
||
'ok' => false,
|
||
'status' => 409,
|
||
'message' => '待办已被其他人修改,请刷新后重试。',
|
||
];
|
||
}
|
||
|
||
$targetIds = [(int) $todo->id];
|
||
if ($scope === 'group') {
|
||
$targetIds = $this->gridGroupIds($todos, $todo, $field);
|
||
}
|
||
foreach ($targetIds as $targetId) {
|
||
$affectedIds[$targetId] = true;
|
||
}
|
||
$changes[] = [
|
||
'field' => $field,
|
||
'value' => $change['value'],
|
||
'scope' => $scope,
|
||
'todo' => $todo,
|
||
];
|
||
}
|
||
|
||
$before = [];
|
||
foreach (array_keys($affectedIds) as $todoId) {
|
||
$before[$todoId] = $this->audit->snapshot($todos->get($todoId));
|
||
}
|
||
|
||
foreach ($changes as $change) {
|
||
$targets = $change['scope'] === 'group'
|
||
? $this->gridGroupIds($todos, $change['todo'], $change['field'])
|
||
: [(int) $change['todo']->id];
|
||
foreach ($targets as $targetId) {
|
||
$todo = $todos->get($targetId);
|
||
if (! $todo instanceof SystemizationTodo) {
|
||
continue;
|
||
}
|
||
$error = $this->applyGridCellValue($todo, $change['field'], $change['value']);
|
||
if ($error !== null) {
|
||
return ['ok' => false, 'status' => 422, 'message' => $error];
|
||
}
|
||
}
|
||
}
|
||
|
||
$changedTodos = [];
|
||
foreach (array_keys($affectedIds) as $todoId) {
|
||
$todo = $todos->get($todoId);
|
||
if (! $todo instanceof SystemizationTodo) {
|
||
continue;
|
||
}
|
||
$error = $this->normalizeGridTodo($todo, $todoId);
|
||
if ($error !== null) {
|
||
return ['ok' => false, 'status' => 422, 'message' => $error];
|
||
}
|
||
if (! $todo->isDirty()) {
|
||
continue;
|
||
}
|
||
$changedTodos[$todoId] = $todo;
|
||
}
|
||
|
||
$events = [];
|
||
foreach ($changedTodos as $todoId => $todo) {
|
||
$todo->updated_by = user_id();
|
||
$todo->save();
|
||
$events[] = [
|
||
'company_id' => (int) company_id(),
|
||
'todo_id' => (int) $todo->id,
|
||
'action' => 'update',
|
||
'before' => $before[$todoId],
|
||
'after' => $this->audit->snapshot($todo),
|
||
'user_id' => user_id(),
|
||
'meta' => ['source' => 'grid'],
|
||
];
|
||
}
|
||
$this->audit->recordMany($events);
|
||
|
||
return [
|
||
'ok' => true,
|
||
'updated_cells' => count($changes),
|
||
'updated_rows' => count($changedTodos),
|
||
'audit_events' => count($events),
|
||
];
|
||
});
|
||
|
||
if (($result['ok'] ?? false) !== true) {
|
||
return response()->json(
|
||
['message' => (string) ($result['message'] ?? '保存失败。')],
|
||
(int) ($result['status'] ?? 422),
|
||
);
|
||
}
|
||
|
||
$result['grid'] = $this->gridRefreshPayload();
|
||
|
||
return response()->json($result);
|
||
}
|
||
|
||
private function gridRefreshPayload(): array
|
||
{
|
||
$filters = $this->filters();
|
||
$sequenceMap = $this->hierarchySequenceMap();
|
||
$items = $this->decorateGridItems($this->filteredItems($filters), $sequenceMap);
|
||
$categorySpans = $this->gridGroupRowspans(
|
||
$items,
|
||
static fn (SystemizationTodo $todo): string => (string) ((int) $todo->type_sequence),
|
||
);
|
||
$sectionSpans = $this->gridGroupRowspans(
|
||
$items,
|
||
static fn (SystemizationTodo $todo): string => (int) $todo->type_sequence
|
||
. "\0"
|
||
. (int) $todo->content_sequence,
|
||
);
|
||
|
||
return [
|
||
'rows' => $items->values()->map(
|
||
static fn (SystemizationTodo $todo, int $index): array => [
|
||
'id' => (int) $todo->id,
|
||
'position' => (int) $todo->item_sequence,
|
||
'content_count' => (int) $todo->content_count,
|
||
'type_sequence' => (int) $todo->type_sequence,
|
||
'content_sequence' => (int) $todo->content_sequence,
|
||
'category' => $todo->category ?? '',
|
||
'category_display' => $todo->category_display ?: '',
|
||
'category_span' => $categorySpans[$index] ?? 0,
|
||
'section' => $todo->section_display ?? '',
|
||
'section_span' => $sectionSpans[$index] ?? 0,
|
||
'title' => trim((string) preg_replace(
|
||
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u',
|
||
'',
|
||
(string) $todo->title,
|
||
)),
|
||
'owner' => $todo->owner_display ?? '',
|
||
'assignee' => $todo->assignee ?? '',
|
||
'start_date' => $todo->start_date?->format('Y-m-d') ?? '',
|
||
'start_note' => $todo->start_note ?? '',
|
||
'due_date' => $todo->due_date?->format('Y-m-d') ?? '',
|
||
'due_note' => $todo->due_note ?? '',
|
||
'reminder_label' => $todo->reminder_label,
|
||
'reminder_tone' => $todo->reminder_tone,
|
||
'status' => $todo->status_label,
|
||
'completed_at' => $todo->completed_at?->format('Y-m-d') ?? '',
|
||
'progress' => $todo->progress ?? '',
|
||
'progress_display' => $todo->displayProgress(),
|
||
],
|
||
)->all(),
|
||
'summary' => $this->todoSummary(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param Collection<int, SystemizationTodo> $items
|
||
* @return array<int, int>
|
||
*/
|
||
private function gridGroupRowspans(Collection $items, callable $key): array
|
||
{
|
||
$spans = array_fill(0, $items->count(), 0);
|
||
$items = $items->values();
|
||
$start = 0;
|
||
$previous = null;
|
||
|
||
$applySpan = static function (int $from, int $to) use (&$spans): void {
|
||
if ($to < $from || $to < 0) {
|
||
return;
|
||
}
|
||
$spans[$from] = $to - $from + 1;
|
||
};
|
||
|
||
foreach ($items as $index => $item) {
|
||
$current = $key($item);
|
||
if ($index === 0) {
|
||
$previous = $current;
|
||
continue;
|
||
}
|
||
if ($current === $previous) {
|
||
continue;
|
||
}
|
||
$applySpan($start, $index - 1);
|
||
$start = $index;
|
||
$previous = $current;
|
||
}
|
||
if ($items->isNotEmpty()) {
|
||
$applySpan($start, $items->count() - 1);
|
||
}
|
||
|
||
return $spans;
|
||
}
|
||
|
||
public function moveGridRow(Request $request, SystemizationTodo $todo): JsonResponse
|
||
{
|
||
$data = $request->validate([
|
||
'direction' => ['required', Rule::in(['up', 'down'])],
|
||
]);
|
||
|
||
$result = DB::transaction(function () use ($todo, $data): array {
|
||
$lockedTodo = SystemizationTodo::query()
|
||
->lockForUpdate()
|
||
->findOrFail($todo->id);
|
||
$siblings = $this->contentSiblings($lockedTodo->category, $lockedTodo->section);
|
||
$currentIndex = $siblings->search(
|
||
static fn (SystemizationTodo $item): bool => (int) $item->id === (int) $lockedTodo->id
|
||
);
|
||
if ($currentIndex === false) {
|
||
return ['ok' => false, 'status' => 404, 'message' => '待办不存在于当前内容中。'];
|
||
}
|
||
$targetIndex = $data['direction'] === 'up' ? $currentIndex - 1 : $currentIndex + 1;
|
||
if ($targetIndex < 0) {
|
||
return ['ok' => false, 'status' => 422, 'message' => '当前待办已经是首行。'];
|
||
}
|
||
if ($targetIndex >= $siblings->count()) {
|
||
return ['ok' => false, 'status' => 422, 'message' => '当前待办已经是末行。'];
|
||
}
|
||
|
||
/** @var SystemizationTodo $target */
|
||
$target = $siblings->get($targetIndex);
|
||
$beforeTodo = $this->audit->snapshot($lockedTodo);
|
||
$beforeTarget = $this->audit->snapshot($target);
|
||
$todoOrder = (int) $lockedTodo->sort_order;
|
||
$targetOrder = (int) $target->sort_order;
|
||
|
||
if ($todoOrder === $targetOrder) {
|
||
return [
|
||
'ok' => false,
|
||
'status' => 409,
|
||
'message' => '当前内容存在重复排序位置,请先通过编辑窗口调整顺序。',
|
||
];
|
||
}
|
||
|
||
$lockedTodo->sort_order = $targetOrder;
|
||
$target->sort_order = $todoOrder;
|
||
$lockedTodo->updated_by = user_id();
|
||
$target->updated_by = user_id();
|
||
$lockedTodo->save();
|
||
$target->save();
|
||
$this->audit->recordMany([
|
||
[
|
||
'company_id' => (int) company_id(),
|
||
'todo_id' => (int) $lockedTodo->id,
|
||
'action' => 'update',
|
||
'before' => $beforeTodo,
|
||
'after' => $this->audit->snapshot($lockedTodo),
|
||
'user_id' => user_id(),
|
||
'meta' => ['source' => 'grid', 'operation' => 'move_' . $data['direction']],
|
||
],
|
||
[
|
||
'company_id' => (int) company_id(),
|
||
'todo_id' => (int) $target->id,
|
||
'action' => 'update',
|
||
'before' => $beforeTarget,
|
||
'after' => $this->audit->snapshot($target),
|
||
'user_id' => user_id(),
|
||
'meta' => ['source' => 'grid', 'operation' => 'move_' . $data['direction']],
|
||
],
|
||
]);
|
||
|
||
return ['ok' => true, 'direction' => $data['direction']];
|
||
});
|
||
|
||
if (($result['ok'] ?? false) !== true) {
|
||
return response()->json(
|
||
['message' => (string) ($result['message'] ?? '移动失败。')],
|
||
(int) ($result['status'] ?? 422),
|
||
);
|
||
}
|
||
|
||
return response()->json($result);
|
||
}
|
||
|
||
public function insertGridRowAfter(Request $request, SystemizationTodo $todo): JsonResponse
|
||
{
|
||
$filters = $this->filters();
|
||
$filterDefaults = $this->gridFilterDefaults($filters);
|
||
$createdId = DB::transaction(function () use ($todo, $filterDefaults): int {
|
||
$lockedTodo = SystemizationTodo::query()
|
||
->lockForUpdate()
|
||
->findOrFail($todo->id);
|
||
$siblings = $this->contentSiblings($lockedTodo->category, $lockedTodo->section);
|
||
$currentIndex = $siblings->search(
|
||
static fn (SystemizationTodo $item): bool => (int) $item->id === (int) $lockedTodo->id
|
||
);
|
||
if ($currentIndex === false) {
|
||
return 0;
|
||
}
|
||
|
||
$next = $siblings->get($currentIndex + 1);
|
||
$insertOrder = $next instanceof SystemizationTodo
|
||
? (int) $next->sort_order
|
||
: (int) $siblings->max('sort_order') + 1;
|
||
if ($next instanceof SystemizationTodo && $insertOrder <= (int) $lockedTodo->sort_order) {
|
||
$insertOrder = (int) $lockedTodo->sort_order + 1;
|
||
}
|
||
SystemizationTodo::query()
|
||
->where('sort_order', '>=', $insertOrder)
|
||
->increment('sort_order');
|
||
|
||
$created = SystemizationTodo::query()->create([
|
||
'company_id' => (int) company_id(),
|
||
'category' => $lockedTodo->category,
|
||
'section' => $lockedTodo->section,
|
||
'title' => '新待办',
|
||
...$filterDefaults,
|
||
'start_note' => null,
|
||
'due_note' => null,
|
||
'progress' => null,
|
||
'notes' => null,
|
||
'sort_order' => $insertOrder,
|
||
'created_by' => user_id(),
|
||
'updated_by' => user_id(),
|
||
]);
|
||
$this->audit->record(
|
||
'create',
|
||
(int) company_id(),
|
||
(int) $created->id,
|
||
null,
|
||
$this->audit->snapshot($created),
|
||
user_id(),
|
||
null,
|
||
['source' => 'grid', 'operation' => 'insert_after'],
|
||
);
|
||
|
||
return (int) $created->id;
|
||
});
|
||
|
||
if ($createdId <= 0) {
|
||
return response()->json(['message' => '无法在当前内容中插入新行。'], 422);
|
||
}
|
||
$created = SystemizationTodo::query()->findOrFail($createdId);
|
||
$clearFilters = $this->conflictingFilterQueryKeys($created, $filters);
|
||
|
||
return response()->json([
|
||
'ok' => true,
|
||
'id' => $createdId,
|
||
'clear_filters' => $clearFilters,
|
||
'message' => '已插入空白待办行。',
|
||
]);
|
||
}
|
||
|
||
public function createGridRow(Request $request): JsonResponse
|
||
{
|
||
$data = $request->validate([
|
||
'mode' => ['required', Rule::in(['type', 'content', 'todo'])],
|
||
'category' => ['nullable', 'string', 'max:100'],
|
||
'section' => ['nullable', 'string', 'max:255'],
|
||
]);
|
||
|
||
$filters = $this->filters();
|
||
$filterDefaults = $this->gridFilterDefaults($filters);
|
||
$createdId = DB::transaction(function () use ($data, $filterDefaults): int {
|
||
$mode = (string) $data['mode'];
|
||
$category = trim((string) ($data['category'] ?? ''));
|
||
$section = trim((string) ($data['section'] ?? ''));
|
||
if ($mode === 'type') {
|
||
$categories = SystemizationTodo::query()
|
||
->whereNotNull('category')
|
||
->pluck('category')
|
||
->map(fn (mixed $value): string => $this->hierarchyLabel((string) $value));
|
||
$category = $this->nextUniqueGridLabel($categories, '新类型');
|
||
$section = $this->nextUniqueGridLabel(collect(), '新内容');
|
||
} elseif ($mode === 'content') {
|
||
$categoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($category));
|
||
$sections = SystemizationTodo::query()
|
||
->whereNotNull('section')
|
||
->get(['category', 'section'])
|
||
->filter(fn (SystemizationTodo $todo): bool =>
|
||
$this->hierarchyGroupKey($this->hierarchyLabel($todo->category)) === $categoryKey
|
||
)
|
||
->pluck('section')
|
||
->map(fn (mixed $value): string => $this->hierarchyLabel((string) $value));
|
||
$section = $this->nextUniqueGridLabel($sections, '新内容');
|
||
} elseif ($mode === 'todo') {
|
||
$section = trim((string) ($data['section'] ?? ''));
|
||
}
|
||
$category = $category === '' ? null : $category;
|
||
$section = $section === '' ? null : $section;
|
||
|
||
if ($mode === 'todo') {
|
||
$siblings = $this->contentSiblings($category, $section);
|
||
$insertOrder = $siblings->isEmpty()
|
||
? (int) SystemizationTodo::query()->max('sort_order') + 1
|
||
: (int) $siblings->max('sort_order') + 1;
|
||
} else {
|
||
$insertOrder = (int) SystemizationTodo::query()->max('sort_order') + 1;
|
||
}
|
||
if ($mode === 'todo') {
|
||
SystemizationTodo::query()
|
||
->where('sort_order', '>=', $insertOrder)
|
||
->increment('sort_order');
|
||
}
|
||
|
||
$created = SystemizationTodo::query()->create([
|
||
'company_id' => (int) company_id(),
|
||
'category' => $category,
|
||
'section' => $section,
|
||
'title' => '新待办',
|
||
...$filterDefaults,
|
||
'start_note' => null,
|
||
'due_note' => null,
|
||
'progress' => null,
|
||
'notes' => null,
|
||
'sort_order' => $insertOrder,
|
||
'created_by' => user_id(),
|
||
'updated_by' => user_id(),
|
||
]);
|
||
$this->audit->record(
|
||
'create',
|
||
(int) company_id(),
|
||
(int) $created->id,
|
||
null,
|
||
$this->audit->snapshot($created),
|
||
user_id(),
|
||
null,
|
||
['source' => 'grid', 'operation' => 'create_' . $mode],
|
||
);
|
||
|
||
return (int) $created->id;
|
||
});
|
||
|
||
$created = SystemizationTodo::query()->findOrFail($createdId);
|
||
|
||
return response()->json([
|
||
'ok' => true,
|
||
'id' => $createdId,
|
||
'clear_filters' => $this->conflictingFilterQueryKeys($created, $filters),
|
||
'message' => '已新增空白待办行。',
|
||
]);
|
||
}
|
||
|
||
private function gridFilterDefaults(array $filters): array
|
||
{
|
||
$status = match (true) {
|
||
$filters['status'] === SystemizationTodo::STATUS_COMPLETED,
|
||
$filters['reminder'] === 'none' => SystemizationTodo::STATUS_COMPLETED,
|
||
default => SystemizationTodo::STATUS_INCOMPLETE,
|
||
};
|
||
$startDate = $filters['start_date'] !== '' ? $filters['start_date'] : null;
|
||
$dueDate = $filters['due_date'] !== '' ? $filters['due_date'] : match ($filters['reminder']) {
|
||
'overdue' => today()->subDay()->toDateString(),
|
||
'today' => today()->toDateString(),
|
||
'upcoming' => today()->addDay()->toDateString(),
|
||
default => null,
|
||
};
|
||
|
||
return [
|
||
'owner' => $filters['owners'][0] ?? '待定',
|
||
'assignee' => $filters['assignees'][0] ?? null,
|
||
'start_date' => $startDate,
|
||
'due_date' => $dueDate,
|
||
'status' => $status,
|
||
'completed_at' => $status === SystemizationTodo::STATUS_COMPLETED
|
||
? ($dueDate ?: today()->toDateString())
|
||
: null,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array<int, string>
|
||
*/
|
||
private function conflictingFilterQueryKeys(SystemizationTodo $todo, array $filters): array
|
||
{
|
||
$conflicts = [];
|
||
foreach (self::FILTER_QUERY_KEYS as $filterKey => $queryKey) {
|
||
if ($filters[$filterKey] === '' || $filters[$filterKey] === []) {
|
||
continue;
|
||
}
|
||
$singleFilter = $this->emptyFilters();
|
||
$singleFilter[$filterKey] = $filters[$filterKey];
|
||
if (! $this->todoMatchesFilters($todo, $singleFilter)) {
|
||
$conflicts[] = $queryKey;
|
||
}
|
||
}
|
||
|
||
if ($conflicts === [] && ! $this->todoMatchesFilters($todo, $filters)) {
|
||
foreach (self::FILTER_QUERY_KEYS as $filterKey => $queryKey) {
|
||
if ($filters[$filterKey] !== '' && $filters[$filterKey] !== []) {
|
||
$conflicts[] = $queryKey;
|
||
}
|
||
}
|
||
}
|
||
|
||
return array_values(array_unique($conflicts));
|
||
}
|
||
|
||
private function todoMatchesFilters(SystemizationTodo $todo, array $filters): bool
|
||
{
|
||
$completed = $todo->statusLabel() === SystemizationTodo::STATUS_COMPLETED;
|
||
$overdue = ! $completed
|
||
&& $todo->due_date !== null
|
||
&& $todo->due_date->lt(today()->startOfDay());
|
||
|
||
if ($filters['status'] === self::OVERDUE_FILTER && ! $overdue) {
|
||
return false;
|
||
}
|
||
if (
|
||
in_array($filters['status'], [
|
||
SystemizationTodo::STATUS_COMPLETED,
|
||
SystemizationTodo::STATUS_INCOMPLETE,
|
||
], true)
|
||
&& $todo->statusLabel() !== $filters['status']
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
$reminderMatches = match ($filters['reminder']) {
|
||
'overdue' => $overdue,
|
||
'today' => ! $completed && $todo->due_date?->isSameDay(today()) === true,
|
||
'upcoming' => ! $completed && $todo->due_date?->gt(today()->startOfDay()) === true,
|
||
'pending' => ! $completed && $todo->due_date === null,
|
||
'none' => $completed,
|
||
default => true,
|
||
};
|
||
if (! $reminderMatches) {
|
||
return false;
|
||
}
|
||
|
||
if ($filters['category'] !== '' && (string) $todo->category !== $filters['category']) {
|
||
return false;
|
||
}
|
||
if (
|
||
$filters['owners'] !== []
|
||
&& array_intersect($this->ownerNames($todo->owner), $filters['owners']) === []
|
||
) {
|
||
return false;
|
||
}
|
||
if ($filters['assignees'] !== []) {
|
||
$assignee = (string) ($todo->assignee ?? '');
|
||
$matchedAssignee = collect($filters['assignees'])->contains(
|
||
static fn (string $name): bool => mb_stripos($assignee, $name, 0, 'UTF-8') !== false,
|
||
);
|
||
if (! $matchedAssignee) {
|
||
return false;
|
||
}
|
||
}
|
||
if (
|
||
$filters['start_date'] !== ''
|
||
&& $todo->start_date?->format('Y-m-d') !== $filters['start_date']
|
||
) {
|
||
return false;
|
||
}
|
||
if (
|
||
$filters['due_date'] !== ''
|
||
&& $todo->due_date?->format('Y-m-d') !== $filters['due_date']
|
||
) {
|
||
return false;
|
||
}
|
||
if ($filters['keyword'] !== '') {
|
||
$haystack = implode(' ', [
|
||
(string) ($todo->title ?? ''),
|
||
(string) ($todo->category ?? ''),
|
||
(string) ($todo->section ?? ''),
|
||
(string) ($todo->owner ?? ''),
|
||
(string) ($todo->assignee ?? ''),
|
||
(string) ($todo->progress ?? ''),
|
||
]);
|
||
if (mb_stripos($haystack, $filters['keyword'], 0, 'UTF-8') === false) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private function emptyFilters(): array
|
||
{
|
||
return [
|
||
'status' => '',
|
||
'reminder' => '',
|
||
'category' => '',
|
||
'owners' => [],
|
||
'assignees' => [],
|
||
'start_date' => '',
|
||
'due_date' => '',
|
||
'keyword' => '',
|
||
];
|
||
}
|
||
|
||
public function uploadGridAttachments(Request $request, SystemizationTodo $todo): JsonResponse
|
||
{
|
||
$request->validate([
|
||
'screenshots' => ['required', 'array', 'min:1', 'max:12'],
|
||
'screenshots.*' => ['file', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
|
||
]);
|
||
$before = $this->audit->snapshot($todo, true);
|
||
DB::transaction(function () use ($request, $todo): void {
|
||
$this->storeAttachments($request, $todo);
|
||
$todo->update(['updated_by' => user_id()]);
|
||
});
|
||
$todo->refresh();
|
||
$after = $this->audit->snapshot($todo, true);
|
||
$this->audit->record(
|
||
'update',
|
||
(int) company_id(),
|
||
(int) $todo->id,
|
||
$before,
|
||
$after,
|
||
user_id(),
|
||
null,
|
||
['source' => 'grid', 'operation' => 'upload_attachments'],
|
||
);
|
||
|
||
return response()->json([
|
||
'ok' => true,
|
||
'attachment_count' => $after['attachment_count'] ?? 0,
|
||
]);
|
||
}
|
||
|
||
public function destroyGridAttachment(
|
||
SystemizationTodo $todo,
|
||
SystemizationTodoAttachment $attachment,
|
||
): JsonResponse {
|
||
abort_unless((int) $attachment->systemization_todo_id === (int) $todo->id, 404);
|
||
abort_unless((int) $attachment->company_id === (int) company_id(), 404);
|
||
|
||
$before = $this->audit->snapshot($todo, true);
|
||
DB::transaction(function () use ($todo, $attachment): void {
|
||
Storage::disk('uploads')->delete($attachment->path);
|
||
$attachment->delete();
|
||
$todo->update(['updated_by' => user_id()]);
|
||
});
|
||
$todo->refresh();
|
||
$after = $this->audit->snapshot($todo, true);
|
||
$this->audit->record(
|
||
'update',
|
||
(int) company_id(),
|
||
(int) $todo->id,
|
||
$before,
|
||
$after,
|
||
user_id(),
|
||
null,
|
||
['source' => 'grid', 'operation' => 'delete_attachment'],
|
||
);
|
||
|
||
return response()->json([
|
||
'ok' => true,
|
||
'attachment_count' => $after['attachment_count'] ?? 0,
|
||
]);
|
||
}
|
||
|
||
public function history(): JsonResponse
|
||
{
|
||
$from = $this->historyStartAt();
|
||
$events = $this->audit->history(
|
||
(int) company_id(),
|
||
SystemizationTodoAuditLogger::HISTORY_LIMIT,
|
||
$from,
|
||
);
|
||
|
||
return response()->json([
|
||
'events' => $events,
|
||
'total' => count($events),
|
||
'limit' => SystemizationTodoAuditLogger::HISTORY_LIMIT,
|
||
'from' => $from,
|
||
]);
|
||
}
|
||
|
||
public function historyExport(Request $request): StreamedResponse
|
||
{
|
||
$action = trim((string) $request->query('action', ''));
|
||
$keyword = trim((string) $request->query('keyword', ''));
|
||
$from = $this->historyStartAt();
|
||
$rows = array_values(array_filter(
|
||
$this->audit->history(
|
||
(int) company_id(),
|
||
SystemizationTodoAuditLogger::HISTORY_LIMIT,
|
||
$from,
|
||
),
|
||
fn (array $row): bool => $this->historyMatches($row, $action, $keyword),
|
||
));
|
||
|
||
return response()->streamDownload(function () use ($rows): void {
|
||
$handle = fopen('php://output', 'w');
|
||
fwrite($handle, "\xEF\xBB\xBF");
|
||
fputcsv($handle, ['时间', '操作', '类型', '内容', '待办事项', '变更内容', '操作人']);
|
||
foreach ($rows as $row) {
|
||
fputcsv($handle, [
|
||
$row['at'],
|
||
$row['action_label'],
|
||
$row['category'],
|
||
$row['section'],
|
||
$row['title'],
|
||
implode(';', $row['changes']),
|
||
$row['actor'],
|
||
]);
|
||
}
|
||
fclose($handle);
|
||
}, '系统化待办_操作记录.csv', [
|
||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||
'Cache-Control' => 'private, no-store',
|
||
]);
|
||
}
|
||
|
||
private function historyStartAt(): string
|
||
{
|
||
return $this->audit->platformNow()->startOfDay()->toDateTimeString();
|
||
}
|
||
|
||
private function historyMatches(array $row, string $action, string $keyword): bool
|
||
{
|
||
if ($action !== '' && (string) ($row['action'] ?? '') !== $action) {
|
||
return false;
|
||
}
|
||
if ($keyword === '') {
|
||
return true;
|
||
}
|
||
|
||
$haystack = implode(' ', [
|
||
(string) ($row['category'] ?? ''),
|
||
(string) ($row['section'] ?? ''),
|
||
(string) ($row['title'] ?? ''),
|
||
(string) ($row['actor'] ?? ''),
|
||
implode(' ', (array) ($row['changes'] ?? [])),
|
||
]);
|
||
|
||
return mb_stripos($haystack, $keyword, 0, 'UTF-8') !== false;
|
||
}
|
||
|
||
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(),
|
||
false,
|
||
$data['source']->getClientOriginalName(),
|
||
);
|
||
} 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
|
||
{
|
||
return [
|
||
'status' => $this->normalizeStatusValue(request('status')),
|
||
'reminder' => $this->normalizeReminderValue(request('reminder')),
|
||
'category' => trim((string) request('category', '')),
|
||
'owners' => $this->normalizeOwnerFilterValues($this->peopleFilter('owner')),
|
||
'assignees' => $this->peopleFilter('assignee'),
|
||
'start_date' => $this->normalizeDateValue(request('start_date')),
|
||
'due_date' => $this->normalizeDateValue(request('due_date')),
|
||
'keyword' => trim((string) request('keyword', '')),
|
||
];
|
||
}
|
||
|
||
private function normalizeStatusValue(mixed $value): string
|
||
{
|
||
$status = trim((string) $value);
|
||
if (in_array($status, SystemizationTodo::LEGACY_INCOMPLETE_STATUSES, true)) {
|
||
return SystemizationTodo::STATUS_INCOMPLETE;
|
||
}
|
||
|
||
return $status === self::OVERDUE_FILTER
|
||
|| in_array($status, SystemizationTodo::STATUSES, true)
|
||
? $status
|
||
: '';
|
||
}
|
||
|
||
private function normalizeReminderValue(mixed $value): string
|
||
{
|
||
$reminder = trim((string) $value);
|
||
|
||
return array_key_exists($reminder, self::REMINDER_OPTIONS) ? $reminder : '';
|
||
}
|
||
|
||
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 assigneeOwnerMap(Collection $assignees): array
|
||
{
|
||
$derived = [];
|
||
SystemizationTodo::query()
|
||
->whereNotNull('assignee')
|
||
->where('assignee', '<>', '')
|
||
->get(['owner', 'assignee'])
|
||
->each(function (SystemizationTodo $todo) use (&$derived): void {
|
||
$owners = $this->ownerNames($todo->owner);
|
||
foreach ($this->personNames($todo->assignee) as $assignee) {
|
||
foreach ($owners as $owner) {
|
||
$derived[$assignee][$owner] = true;
|
||
}
|
||
}
|
||
});
|
||
|
||
$map = [];
|
||
foreach ($assignees as $assignee) {
|
||
if (isset(self::OWNER_ALIASES[$assignee])) {
|
||
$map[$assignee] = [self::OWNER_ALIASES[$assignee]];
|
||
continue;
|
||
}
|
||
$ownerSet = $derived[$assignee] ?? [];
|
||
$map[$assignee] = array_values(array_filter(
|
||
self::OWNER_OPTIONS,
|
||
static fn (string $owner): bool => isset($ownerSet[$owner]),
|
||
));
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
private function personNames(?string $value): array
|
||
{
|
||
return array_values(array_unique(array_filter(
|
||
array_map('trim', preg_split('/[、,,;;\/]+/u', trim((string) $value)) ?: []),
|
||
static fn (string $name): bool => $name !== '' && $name !== '—',
|
||
)));
|
||
}
|
||
|
||
private function normalizeOwnerFilterValues(array $owners): array
|
||
{
|
||
return collect($owners)
|
||
->map(static fn (string $owner): ?string => self::OWNER_ALIASES[$owner] ?? null)
|
||
->filter()
|
||
->unique()
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
private function applyOwnerFilter($builder, string $owner): void
|
||
{
|
||
$aliases = array_keys(array_filter(
|
||
self::OWNER_ALIASES,
|
||
static fn (string $canonical): bool => $canonical === $owner,
|
||
));
|
||
|
||
if ($aliases !== []) {
|
||
$builder->orWhere(function ($aliasQuery) use ($aliases): void {
|
||
foreach ($aliases as $alias) {
|
||
$aliasQuery->orWhere(
|
||
'owner',
|
||
'like',
|
||
'%' . addcslashes($alias, '%_\\') . '%',
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
if ($owner !== '待定') {
|
||
return;
|
||
}
|
||
|
||
$knownAliases = array_values(array_diff(array_keys(self::OWNER_ALIASES), ['待定']));
|
||
$builder->orWhere(function ($pendingQuery) use ($knownAliases): void {
|
||
$pendingQuery->whereNull('owner')->orWhere('owner', '');
|
||
if ($knownAliases === []) {
|
||
return;
|
||
}
|
||
|
||
$pendingQuery->orWhere(function ($unknownQuery) use ($knownAliases): void {
|
||
foreach ($knownAliases as $alias) {
|
||
$unknownQuery->where(
|
||
'owner',
|
||
'not like',
|
||
'%' . addcslashes($alias, '%_\\') . '%',
|
||
);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
private function ownerNames(?string $value): array
|
||
{
|
||
$names = collect(preg_split('/[、,,;;\/]+/u', trim((string) $value)) ?: [])
|
||
->map(static fn (string $name): string => trim($name))
|
||
->filter(static fn (string $name): bool => $name !== '' && $name !== '—')
|
||
->map(static fn (string $name): string => self::OWNER_ALIASES[$name] ?? '待定')
|
||
->unique()
|
||
->values()
|
||
->all();
|
||
|
||
return $names === [] ? ['待定'] : $names;
|
||
}
|
||
|
||
private function normalizeOwnerData(array &$data): void
|
||
{
|
||
if (! array_key_exists('owner', $data)) {
|
||
return;
|
||
}
|
||
|
||
$data['owner'] = implode('、', $this->ownerNames($data['owner']));
|
||
}
|
||
|
||
private function contentOptions(): array
|
||
{
|
||
$options = [];
|
||
$todos = SystemizationTodo::query()
|
||
->whereNotNull('section')
|
||
->where('section', '<>', '')
|
||
->get(['category', 'section']);
|
||
foreach ($todos as $todo) {
|
||
$category = $this->hierarchyLabel($todo->category);
|
||
$section = $this->hierarchyLabel($todo->section);
|
||
if ($category !== '' && $section !== '') {
|
||
$options[$category][$section] = true;
|
||
}
|
||
}
|
||
|
||
foreach ($options as $category => $sections) {
|
||
$labels = array_keys($sections);
|
||
usort($labels, 'strnatcasecmp');
|
||
$options[$category] = $labels;
|
||
}
|
||
|
||
return $options;
|
||
}
|
||
|
||
private function normalizeDateValue(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 gridFieldSupportsGroupScope(string $field): bool
|
||
{
|
||
return in_array($field, ['category', 'section'], true);
|
||
}
|
||
|
||
/**
|
||
* @param Collection<int, string> $existing
|
||
*/
|
||
private function nextUniqueGridLabel(Collection $existing, string $base): string
|
||
{
|
||
$used = $existing
|
||
->map(fn (mixed $value): string => $this->hierarchyGroupKey($this->hierarchyLabel((string) $value)))
|
||
->filter()
|
||
->flip();
|
||
for ($index = 1; $index <= 10000; $index++) {
|
||
$candidate = $base . ' ' . $index;
|
||
if (! $used->has($this->hierarchyGroupKey($candidate))) {
|
||
return $candidate;
|
||
}
|
||
}
|
||
|
||
return $base . ' ' . Str::lower(Str::random(8));
|
||
}
|
||
|
||
private function gridOriginalStillMatches(
|
||
SystemizationTodo $todo,
|
||
string $field,
|
||
string $original,
|
||
): bool {
|
||
$current = match ($field) {
|
||
'category', 'section', 'title' => $this->hierarchyLabel((string) ($todo->{$field} ?? '')),
|
||
'owner' => implode('、', $this->ownerNames($todo->owner)),
|
||
'start_date' => $todo->start_date?->format('Y-m-d') ?? (string) ($todo->start_note ?? ''),
|
||
'due_date' => $todo->due_date?->format('Y-m-d') ?? (string) ($todo->due_note ?? ''),
|
||
'status' => $todo->statusLabel(),
|
||
default => (string) ($todo->{$field} ?? ''),
|
||
};
|
||
$expected = in_array($field, ['category', 'section', 'title'], true)
|
||
? $this->hierarchyLabel($original)
|
||
: $original;
|
||
|
||
return $current === $expected;
|
||
}
|
||
|
||
/**
|
||
* @param Collection<int, SystemizationTodo> $todos
|
||
* @return array<int, int>
|
||
*/
|
||
private function gridGroupIds(
|
||
Collection $todos,
|
||
SystemizationTodo $todo,
|
||
string $field,
|
||
): array {
|
||
$categoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->category));
|
||
$sectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->section));
|
||
|
||
return $todos
|
||
->filter(function (SystemizationTodo $candidate) use (
|
||
$field,
|
||
$categoryKey,
|
||
$sectionKey,
|
||
): bool {
|
||
$sameCategory = $this->hierarchyGroupKey(
|
||
$this->hierarchyLabel($candidate->category)
|
||
) === $categoryKey;
|
||
|
||
return $sameCategory
|
||
&& ($field === 'category'
|
||
|| $this->hierarchyGroupKey(
|
||
$this->hierarchyLabel($candidate->section)
|
||
) === $sectionKey);
|
||
})
|
||
->keys()
|
||
->map(static fn (mixed $id): int => (int) $id)
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
private function applyGridCellValue(
|
||
SystemizationTodo $todo,
|
||
string $field,
|
||
?string $value,
|
||
): ?string {
|
||
$value = str_replace(["\r\n", "\r"], "\n", (string) ($value ?? ''));
|
||
if (in_array($field, ['category', 'section', 'title', 'owner', 'assignee'], true)) {
|
||
$value = trim($value);
|
||
}
|
||
|
||
if (in_array($field, ['start_date', 'due_date'], true)) {
|
||
$dateField = $field;
|
||
$noteField = $field === 'start_date' ? 'start_note' : 'due_note';
|
||
$value = trim($value);
|
||
if ($value === '') {
|
||
$todo->{$dateField} = null;
|
||
$todo->{$noteField} = null;
|
||
return null;
|
||
}
|
||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||
if ($date instanceof \DateTimeImmutable && $date->format('Y-m-d') === $value) {
|
||
$todo->{$dateField} = $value;
|
||
$todo->{$noteField} = null;
|
||
return null;
|
||
}
|
||
if (preg_match('/^\d{4}-\d{1,2}-\d{1,2}$/', $value) === 1) {
|
||
return $field === 'start_date' ? '开始时间日期格式无效。' : '完成时间日期格式无效。';
|
||
}
|
||
$todo->{$dateField} = null;
|
||
$todo->{$noteField} = $value;
|
||
return null;
|
||
}
|
||
|
||
if ($field === 'status') {
|
||
$status = trim($value);
|
||
if (in_array($status, SystemizationTodo::LEGACY_INCOMPLETE_STATUSES, true)) {
|
||
$status = SystemizationTodo::STATUS_INCOMPLETE;
|
||
}
|
||
if (! in_array($status, SystemizationTodo::STATUSES, true)) {
|
||
return '状态只能填写“未完成”或“已完成”。';
|
||
}
|
||
$todo->status = $status;
|
||
return null;
|
||
}
|
||
|
||
$todo->{$field} = $value === '' ? null : $value;
|
||
|
||
return null;
|
||
}
|
||
|
||
private function normalizeGridTodo(SystemizationTodo $todo, int $todoId): ?string
|
||
{
|
||
$title = trim((string) ($todo->title ?? ''));
|
||
if ($title === '') {
|
||
return '待办事项不能为空。';
|
||
}
|
||
$limits = [
|
||
'category' => 100,
|
||
'section' => 255,
|
||
'title' => 5000,
|
||
'owner' => 255,
|
||
'assignee' => 255,
|
||
'progress' => 10000,
|
||
'notes' => 10000,
|
||
'start_note' => 50,
|
||
'due_note' => 50,
|
||
];
|
||
foreach ($limits as $field => $limit) {
|
||
if (mb_strlen((string) ($todo->{$field} ?? '')) > $limit) {
|
||
return '第 ' . $todoId . ' 条待办的字段内容过长。';
|
||
}
|
||
}
|
||
|
||
$todo->title = $title;
|
||
$todo->owner = implode('、', $this->ownerNames($todo->owner));
|
||
$assignees = $this->personNames($todo->assignee);
|
||
$todo->assignee = $assignees === [] ? null : implode('、', $assignees);
|
||
$todo->progress = SystemizationTodo::mergeNotesIntoProgress($todo->progress, $todo->notes);
|
||
$todo->notes = null;
|
||
|
||
$startDate = $todo->start_date?->format('Y-m-d');
|
||
$dueDate = $todo->due_date?->format('Y-m-d');
|
||
if ($startDate !== null && $dueDate !== null && $dueDate < $startDate) {
|
||
return '第 ' . $todoId . ' 条待办的完成时间不能早于开始时间。';
|
||
}
|
||
|
||
if ($todo->statusLabel() === SystemizationTodo::STATUS_COMPLETED) {
|
||
$todo->status = SystemizationTodo::STATUS_COMPLETED;
|
||
$todo->completed_at = $todo->completed_at?->format('Y-m-d')
|
||
?: ($dueDate ?: now()->toDateString());
|
||
} else {
|
||
$todo->status = SystemizationTodo::STATUS_INCOMPLETE;
|
||
$todo->completed_at = null;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
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'],
|
||
'start_note' => ['nullable', 'string', 'max:50'],
|
||
'start_pending' => ['nullable', 'boolean'],
|
||
'due_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
|
||
'due_note' => ['nullable', 'string', 'max:50'],
|
||
'due_pending' => ['nullable', 'boolean'],
|
||
'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 normalizePendingDates(Request $request, array &$data): void
|
||
{
|
||
foreach (['start', 'due'] as $prefix) {
|
||
if ($request->boolean($prefix . '_pending')) {
|
||
$data[$prefix . '_date'] = null;
|
||
$data[$prefix . '_note'] = '待定';
|
||
} elseif (!empty($data[$prefix . '_date'])) {
|
||
$data[$prefix . '_note'] = null;
|
||
}
|
||
unset($data[$prefix . '_pending']);
|
||
}
|
||
}
|
||
|
||
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,
|
||
bool $clearConflictingFilters = false,
|
||
): RedirectResponse
|
||
{
|
||
$parameters = [
|
||
'section' => 'other',
|
||
'page' => 'systemization-todos',
|
||
];
|
||
$filters = $this->returnedFilters();
|
||
$parameters = array_merge($parameters, $filters);
|
||
if ($focus) {
|
||
$parameters['highlight'] = $focus->id;
|
||
if ($clearConflictingFilters) {
|
||
$conflicts = $this->conflictingFilterQueryKeys(
|
||
$focus,
|
||
$this->filtersFromQuery($filters),
|
||
);
|
||
foreach ($conflicts as $conflict) {
|
||
unset($parameters[$conflict]);
|
||
}
|
||
if ($conflicts !== []) {
|
||
$message = '新行已创建,为显示它已暂时清除冲突筛选';
|
||
}
|
||
}
|
||
}
|
||
|
||
return redirect()
|
||
->route('business.pages.show', $parameters)
|
||
->with($type === 'success' ? 'success' : 'error', $message);
|
||
}
|
||
|
||
private function filtersFromQuery(array $filters): array
|
||
{
|
||
return [
|
||
'status' => $this->normalizeStatusValue($filters['status'] ?? null),
|
||
'reminder' => $this->normalizeReminderValue($filters['reminder'] ?? null),
|
||
'category' => trim((string) ($filters['category'] ?? '')),
|
||
'owners' => $this->normalizeOwnerFilterValues((array) ($filters['owner'] ?? [])),
|
||
'assignees' => collect((array) ($filters['assignee'] ?? []))
|
||
->filter(static fn (mixed $value): bool => is_string($value))
|
||
->map(static fn (string $value): string => trim($value))
|
||
->filter()
|
||
->unique()
|
||
->take(50)
|
||
->values()
|
||
->all(),
|
||
'start_date' => $this->normalizeDateValue($filters['start_date'] ?? null),
|
||
'due_date' => $this->normalizeDateValue($filters['due_date'] ?? null),
|
||
'keyword' => trim((string) ($filters['keyword'] ?? '')),
|
||
];
|
||
}
|
||
|
||
private function returnedFilters(): array
|
||
{
|
||
$encoded = request('return_filters');
|
||
if (!is_string($encoded) || $encoded === '') {
|
||
return [];
|
||
}
|
||
|
||
$json = base64_decode($encoded, true);
|
||
$filters = $json === false ? null : json_decode($json, true);
|
||
if (!is_array($filters)) {
|
||
return [];
|
||
}
|
||
|
||
$category = is_string($filters['category'] ?? null)
|
||
? trim($filters['category'])
|
||
: '';
|
||
$keyword = is_string($filters['keyword'] ?? null)
|
||
? trim($filters['keyword'])
|
||
: '';
|
||
$owners = collect((array) ($filters['owners'] ?? []))
|
||
->filter(static fn (mixed $value): bool => is_string($value))
|
||
->map(static fn (string $value): string => trim($value))
|
||
->filter()
|
||
->unique()
|
||
->take(50)
|
||
->values()
|
||
->all();
|
||
$owners = $this->normalizeOwnerFilterValues($owners);
|
||
$assignees = collect((array) ($filters['assignees'] ?? []))
|
||
->filter(static fn (mixed $value): bool => is_string($value))
|
||
->map(static fn (string $value): string => trim($value))
|
||
->filter()
|
||
->unique()
|
||
->take(50)
|
||
->values()
|
||
->all();
|
||
|
||
return array_filter([
|
||
'status' => $this->normalizeStatusValue($filters['status'] ?? null),
|
||
'reminder' => $this->normalizeReminderValue($filters['reminder'] ?? null),
|
||
'category' => $category,
|
||
'owner' => $owners,
|
||
'assignee' => $assignees,
|
||
'start_date' => $this->normalizeDateValue($filters['start_date'] ?? null),
|
||
'due_date' => $this->normalizeDateValue($filters['due_date'] ?? null),
|
||
'keyword' => $keyword,
|
||
], static fn (mixed $value): bool => $value !== '' && $value !== []);
|
||
}
|
||
}
|