CaiWuXiTong/app/Http/Controllers/Business/SystemizationTodos.php
yueyang c33bb7308e feat: 联动系统化待办负责人和责任人 (#697)
## 实际改动

- 提醒筛选项“无提醒”改为“已完成”,保持原有已完成筛选逻辑。
- 总负责人和责任人建立双向关联:选择总负责人后只显示对应责任人,选择责任人时自动勾选对应总负责人。
- 编辑弹窗将原生联想输入改为统一的勾选面板,并使用同一套负责人关联逻辑。
- 责任人关联同时支持固定映射和现有数据推导,惠兰等未在固定清单中的人员也能从历史数据正确归属。
- DESIGN.md 与机器设计规范新增跨设备控件准则,后续页面统一复用内置字体和自绘控件。

## 验证证据

- SystemizationTodosTest 通过:23 tests,343 assertions。
- 覆盖已完成筛选标签、固定人员映射、历史数据推导和自定义责任人控件。
- 不涉及数据库迁移、金额、权限或上游数据。

## 合并前复查

- 基于最新 origin/main(da546877)开发。
- 已自审完整 diff,未包含敏感信息或无关修改。
- 提交:fa7d2608。

Reviewed-on: #697
2026-10-27 18:10:54 +08:00

1197 lines
44 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(
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u',
'',
(string) $value,
));
}
private function hierarchyGroupKey(string $label): string
{
$normalized = preg_replace(
'/[\s\p{Z}\x{200B}\x{FEFF}]+/u',
'',
$label,
);
$normalized = (string) $normalized;
return $normalized === '' ? "\0" : mb_strtolower($normalized);
}
public function store(Request $request): RedirectResponse
{
$data = $this->validated($request);
$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
{
$events = $this->audit->history((int) company_id());
return response()->json([
'events' => $events,
'total' => count($events),
'limit' => SystemizationTodoAuditLogger::HISTORY_LIMIT,
]);
}
public function historyExport(Request $request): StreamedResponse
{
$action = trim((string) $request->query('action', ''));
$keyword = trim((string) $request->query('keyword', ''));
$rows = array_values(array_filter(
$this->audit->history((int) company_id()),
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 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 !== []);
}
}