CaiWuXiTong/app/Http/Controllers/Business/SystemizationTodos.php
yueyang 48b433be4d feat: 系统化待办按今日同步并优化 Excel 导入
操作记录默认从今日显示,并增强 Excel 同步的类型保护和同名分组匹配。
2026-10-28 09:37:21 +08:00

1213 lines
45 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 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 OWNER_ALIASES = [
'人参' => '人参',
'苏木' => '人参',
'李斯' => '人参',
'周生辰' => '人参',
'晨曦' => '晨曦',
'栎阳' => '晨曦',
'秋香' => '晨曦',
'常仪' => '常仪',
'玉兔' => '常仪',
'山时' => '常仪',
'林冲' => '常仪',
'杳杳' => '常仪',
'待定' => '待定',
];
public function __construct(
private readonly SystemizationTodoAuditLogger $audit,
) {
}
public function page(): array
{
$filters = $this->filters();
$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)
->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']);
$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']);
});
$items = $items->sortBy('hierarchy_order')->values();
$positionGroups = $this->positionGroups($sequenceMap);
$base = SystemizationTodo::query();
$totalCount = (clone $base)->count();
$completedCount = (clone $base)
->where('status', SystemizationTodo::STATUS_COMPLETED)
->count();
$incompleteCount = max(0, $totalCount - $completedCount);
$categories = SystemizationTodo::query()
->whereNotNull('category')
->where('category', '<>', '')
->distinct()
->orderBy('category')
->pluck('category');
$overdueCount = (clone $base)
->where('status', '<>', SystemizationTodo::STATUS_COMPLETED)
->whereNotNull('due_date')
->whereDate('due_date', '<', today())
->count();
$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,
'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' => [
'total' => $totalCount,
'incomplete' => $incompleteCount,
'completed' => $completedCount,
'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'),
'history' => route('business.systemization-todos.history'),
'historyExport' => route('business.systemization-todos.history.export'),
'columnWidths' => route('business.systemization-todos.column-widths'),
],
];
}
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);
$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,
);
}
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);
$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 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 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,
): RedirectResponse
{
$parameters = [
'section' => 'other',
'page' => 'systemization-todos',
];
$parameters = array_merge($parameters, $this->returnedFilters());
if ($focus) {
$parameters['highlight'] = $focus->id;
}
return redirect()
->route('business.pages.show', $parameters)
->with($type === 'success' ? 'success' : 'error', $message);
}
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 !== []);
}
}