CaiWuXiTong/app/Http/Controllers/Business/SystemizationTodos.php

352 lines
14 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\SystemizationTodoWorkbookImporter;
use App\Models\Business\SystemizationTodo;
use App\Models\Business\SystemizationTodoAttachment;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class SystemizationTodos extends Controller
{
private const OVERDUE_FILTER = '__overdue__';
public function page(): array
{
$filters = $this->filters();
$query = SystemizationTodo::query()
->with('attachments')
->withCount('attachments');
if ($filters['status'] === self::OVERDUE_FILTER) {
$query->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED])
->whereNotNull('due_date')
->whereDate('due_date', '<', today());
} elseif ($filters['status'] !== '') {
$query->where('status', $filters['status']);
}
if ($filters['category'] !== '') {
$query->where('category', $filters['category']);
}
if ($filters['owner'] !== '') {
$query->where('owner', 'like', '%' . addcslashes($filters['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('*'))
->selectRaw(
"ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(category, ''), '__uncategorized__')
ORDER BY
CASE WHEN status = '已完成' THEN 1 ELSE 0 END,
CASE WHEN section IS NULL OR section = '' THEN 1 ELSE 0 END,
section,
CASE WHEN due_date IS NULL THEN 1 ELSE 0 END,
due_date,
sort_order,
id
) AS category_sequence"
)
->orderByRaw("CASE WHEN status = '已完成' THEN 1 ELSE 0 END")
->orderByRaw("CASE WHEN category IS NULL OR category = '' THEN 1 ELSE 0 END")
->orderBy('category')
->orderByRaw("CASE WHEN section IS NULL OR section = '' THEN 1 ELSE 0 END")
->orderBy('section')
->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END')
->orderBy('due_date')
->orderBy('sort_order')
->orderBy('id')
->paginate(50)
->withQueryString();
$base = SystemizationTodo::query();
$categories = SystemizationTodo::query()
->whereNotNull('category')
->where('category', '<>', '')
->distinct()
->orderBy('category')
->pluck('category');
return [
'view' => 'business.pages.systemization-todos',
'title' => '系统化待办',
'items' => $items,
'todoFilters' => $filters,
'todoCategories' => $categories,
'todoOwners' => $this->peopleOptions('owner'),
'todoAssignees' => $this->peopleOptions('assignee'),
'todoStatuses' => SystemizationTodo::STATUSES,
'todoOverdueFilter' => self::OVERDUE_FILTER,
'todoSummary' => [
'total' => (clone $base)->count(),
'pending' => (clone $base)->where('status', SystemizationTodo::STATUS_PENDING)->count(),
'in_progress' => (clone $base)->where('status', SystemizationTodo::STATUS_IN_PROGRESS)->count(),
'completed' => (clone $base)->where('status', SystemizationTodo::STATUS_COMPLETED)->count(),
'overdue' => (clone $base)
->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED])
->whereNotNull('due_date')
->whereDate('due_date', '<', today())
->count(),
],
'todoRoutes' => [
'store' => route('business.systemization-todos.store'),
'update' => route('business.systemization-todos.update', ['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'),
],
];
}
public function store(Request $request): RedirectResponse
{
$data = $this->validated($request);
$data['company_id'] = (int) company_id();
$data['sort_order'] = (int) SystemizationTodo::query()->max('sort_order') + 1;
$data['created_by'] = user_id();
$data['updated_by'] = user_id();
$this->normalizeCompletionDates($data);
$todo = SystemizationTodo::query()->create($data);
$this->storeAttachments($request, $todo);
return $this->backToPage('待办事项已新增。');
}
public function update(Request $request, SystemizationTodo $todo): RedirectResponse
{
$data = $this->validated($request);
$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();
}
}
$todo->update($data);
$this->storeAttachments($request, $todo);
return $this->backToPage('待办事项已更新。');
}
public function destroy(SystemizationTodo $todo): RedirectResponse
{
$todo->update(['updated_by' => user_id()]);
$todo->delete();
return $this->backToPage('待办事项已删除。');
}
public function attachment(SystemizationTodo $todo, SystemizationTodoAttachment $attachment): StreamedResponse
{
abort_unless((int) $attachment->systemization_todo_id === (int) $todo->id, 404);
abort_unless((int) $attachment->company_id === (int) company_id(), 404);
$disk = Storage::disk('uploads');
abort_unless($disk->exists($attachment->path), 404, '截图不存在。');
return $disk->response($attachment->path, $attachment->original_name, [
'Cache-Control' => 'private, no-store',
'Content-Type' => $attachment->mime_type,
'X-Content-Type-Options' => 'nosniff',
]);
}
public function importWorkbook(
Request $request,
SystemizationTodoWorkbookImporter $importer,
): RedirectResponse {
$data = $request->validate([
'source' => ['required', 'file', 'mimes:xlsx', 'max:20480'],
]);
try {
$result = $importer->import(
$data['source']->getRealPath(),
(int) company_id(),
user_id(),
);
} catch (Throwable $exception) {
report($exception);
return $this->backToPage('导入失败,请确认工作簿表头、文件格式和内容后重试。', 'error');
}
return $this->backToPage(sprintf(
'导入完成:%s,新增 %d 条,跳过已存在 %d 条。',
$result['sheet'],
$result['created'],
$result['skipped'],
));
}
private function filters(): array
{
$status = (string) request('status', '');
$assignees = collect((array) request('assignee', []))
->filter(static fn (mixed $value): bool => is_string($value))
->map(static fn (string $value): string => trim($value))
->filter()
->unique()
->take(50)
->values()
->all();
return [
'status' => $status === self::OVERDUE_FILTER
|| in_array($status, SystemizationTodo::STATUSES, true)
? $status
: '',
'category' => trim((string) request('category', '')),
'owner' => trim((string) request('owner', '')),
'assignees' => $assignees,
'start_date' => $this->normalizeDateFilter(request('start_date')),
'due_date' => $this->normalizeDateFilter(request('due_date')),
'keyword' => trim((string) request('keyword', '')),
];
}
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 normalizeDateFilter(mixed $value): string
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
return $date instanceof \DateTimeImmutable && $date->format('Y-m-d') === $value
? $value
: '';
}
private function validated(Request $request): array
{
return $request->validate([
'category' => ['nullable', 'string', 'max:100'],
'section' => ['nullable', 'string', 'max:255'],
'title' => ['required', 'string', 'max:5000'],
'owner' => ['nullable', 'string', 'max:255'],
'assignee' => ['nullable', 'string', 'max:255'],
'start_date' => ['nullable', 'date_format:Y-m-d'],
'due_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
'status' => ['required', Rule::in(SystemizationTodo::STATUSES)],
'completed_at' => ['nullable', 'date_format:Y-m-d'],
'progress' => ['nullable', 'string', 'max:10000'],
'notes' => ['nullable', 'string', 'max:10000'],
'screenshots' => ['nullable', 'array', 'max:12'],
'screenshots.*' => ['file', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
'remove_attachments' => ['nullable', 'array'],
'remove_attachments.*' => ['integer'],
]);
}
private function normalizeCompletionDates(array &$data): void
{
$completed = ($data['status'] ?? '') === SystemizationTodo::STATUS_COMPLETED;
$data['completed_at'] = $completed
? (($data['completed_at'] ?? null) ?: ($data['due_date'] ?? null) ?: now()->toDateString())
: null;
}
private function storeAttachments(Request $request, SystemizationTodo $todo): void
{
foreach ($request->file('screenshots', []) as $file) {
$extension = strtolower($file->getClientOriginalExtension());
$fileName = (string) Str::uuid() . ($extension !== '' ? '.' . $extension : '');
$path = Storage::disk('uploads')->putFileAs(
'systemization-todos/' . (int) company_id() . '/' . $todo->id,
$file,
$fileName,
);
if ($path === false) {
throw new \RuntimeException('完成截图保存失败。');
}
SystemizationTodoAttachment::query()->create([
'company_id' => (int) company_id(),
'systemization_todo_id' => $todo->id,
'path' => $path,
'original_name' => mb_substr(basename($file->getClientOriginalName()), 0, 240),
'mime_type' => $file->getMimeType() ?: 'application/octet-stream',
'size' => $file->getSize() ?: 0,
'uploaded_by' => user_id(),
]);
}
}
private function backToPage(string $message, string $type = 'success'): RedirectResponse
{
return redirect()
->route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
])
->with($type === 'success' ? 'success' : 'error', $message);
}
}