新增财务系统化待办与完成截图 #548
249
app/Business/SystemizationTodoWorkbookImporter.php
Normal file
249
app/Business/SystemizationTodoWorkbookImporter.php
Normal file
@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\SystemizationTodo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Services\PayablesWorkbookStreamReader;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use RuntimeException;
|
||||
|
||||
class SystemizationTodoWorkbookImporter
|
||||
{
|
||||
private const REQUIRED_HEADERS = [
|
||||
'category' => '类型',
|
||||
'section' => '内容',
|
||||
'title' => '待办事项',
|
||||
'owner' => '总负责人',
|
||||
'assignee' => '责任人',
|
||||
'start' => '开始时间',
|
||||
'finish' => '完成时间',
|
||||
'progress' => '完成情况',
|
||||
'notes' => '备注',
|
||||
];
|
||||
|
||||
/**
|
||||
* 导入财务系统化待办。只新增系统中不存在的来源行,不覆盖日常维护后的状态与截图。
|
||||
*
|
||||
* @return array{created:int,skipped:int,sheet:string}
|
||||
*/
|
||||
public function import(string $path, int $companyId, ?int $userId = null): array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException('找不到待办 Excel 文件。');
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
if ($extension !== 'xlsx') {
|
||||
throw new RuntimeException('系统化待办仅支持 .xlsx 文件。');
|
||||
}
|
||||
|
||||
$reader = PayablesWorkbookStreamReader::forFile($path, $extension);
|
||||
foreach ($reader->sheetNames() as $sheetIndex => $sheetName) {
|
||||
$headerInfo = $this->headerMap($reader, $sheetIndex);
|
||||
if ($headerInfo === null) {
|
||||
continue;
|
||||
}
|
||||
$header = $headerInfo['columns'];
|
||||
$headerRow = $headerInfo['row'];
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
DB::transaction(function () use (
|
||||
$reader,
|
||||
$sheetIndex,
|
||||
$sheetName,
|
||||
$header,
|
||||
$headerRow,
|
||||
$companyId,
|
||||
$userId,
|
||||
&$created,
|
||||
&$skipped
|
||||
): void {
|
||||
$category = '';
|
||||
$section = '';
|
||||
foreach ($reader->rows($sheetIndex) as $rowNumber => $cells) {
|
||||
if ($rowNumber <= $headerRow) {
|
||||
continue;
|
||||
}
|
||||
$categoryValue = $this->cell($cells, $header['category'] ?? null);
|
||||
if ($categoryValue !== '') {
|
||||
$category = $categoryValue;
|
||||
}
|
||||
$sectionValue = $this->cell($cells, $header['section'] ?? null);
|
||||
if ($sectionValue !== '') {
|
||||
$section = $sectionValue;
|
||||
}
|
||||
|
||||
$title = $this->cell($cells, $header['title'] ?? null);
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$owner = $this->cell($cells, $header['owner'] ?? null);
|
||||
$assignee = $this->cell($cells, $header['assignee'] ?? null);
|
||||
$sourceKey = hash(
|
||||
'sha256',
|
||||
implode('|', [$companyId, $sheetName, $rowNumber, $category, $section, $title, $owner, $assignee])
|
||||
);
|
||||
$exists = SystemizationTodo::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->where('source_key', $sourceKey)
|
||||
->exists();
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$startText = $this->cell($cells, $header['start'] ?? null);
|
||||
$finishText = $this->cell($cells, $header['finish'] ?? null);
|
||||
[$startDate, $startNote] = $this->dateParts($startText);
|
||||
[$finishDate, $finishNote] = $this->dateParts($finishText);
|
||||
$progress = $this->cell($cells, $header['progress'] ?? null);
|
||||
$status = $this->status($progress, $startText, $finishText);
|
||||
$completedAt = $status === SystemizationTodo::STATUS_COMPLETED
|
||||
? ($finishDate ?? now()->toDateString())
|
||||
: null;
|
||||
|
||||
SystemizationTodo::withoutGlobalScopes()->create([
|
||||
'company_id' => $companyId,
|
||||
'category' => $category !== '' ? $category : null,
|
||||
'section' => $section !== '' ? $section : null,
|
||||
'title' => $title,
|
||||
'owner' => $owner !== '' ? $owner : null,
|
||||
'assignee' => $assignee !== '' ? $assignee : null,
|
||||
'start_date' => $startDate,
|
||||
'start_note' => $startNote !== '' ? $startNote : null,
|
||||
'due_date' => $finishDate,
|
||||
'due_note' => $finishNote !== '' ? $finishNote : null,
|
||||
'status' => $status,
|
||||
'completed_at' => $completedAt,
|
||||
'progress' => $progress !== '' ? $progress : null,
|
||||
'notes' => ($notes = $this->cell($cells, $header['notes'] ?? null)) !== '' ? $notes : null,
|
||||
'sort_order' => $rowNumber,
|
||||
'source_key' => $sourceKey,
|
||||
'imported_at' => now(),
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$created++;
|
||||
}
|
||||
});
|
||||
|
||||
return ['created' => $created, 'skipped' => $skipped, 'sheet' => $sheetName];
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
'未找到待办表头。请确认工作表包含:类型、内容、待办事项、总负责人、责任人、开始时间、完成时间、完成情况、备注。'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{row:int,columns:array<string,int>}|null
|
||||
*/
|
||||
private function headerMap(PayablesWorkbookStreamReader $reader, int $sheetIndex): ?array
|
||||
{
|
||||
foreach ($reader->rows($sheetIndex, null, 12) as $rowNumber => $cells) {
|
||||
$normalized = [];
|
||||
foreach ($cells as $column => $value) {
|
||||
$value = $this->normalizeHeader($this->text($value));
|
||||
if ($value !== '') {
|
||||
$normalized[$value] = $column;
|
||||
}
|
||||
}
|
||||
|
||||
$mapped = [];
|
||||
foreach (self::REQUIRED_HEADERS as $field => $label) {
|
||||
$needle = $this->normalizeHeader($label);
|
||||
foreach ($normalized as $header => $column) {
|
||||
if ($header === $needle || str_starts_with($header, $needle)) {
|
||||
$mapped[$field] = $column;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($mapped['title'])
|
||||
&& isset($mapped['owner'])
|
||||
&& isset($mapped['assignee'])) {
|
||||
return ['row' => $rowNumber, 'columns' => $mapped];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeHeader(string $value): string
|
||||
{
|
||||
return preg_replace('/[\s ()()]+/u', '', $value) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $cells
|
||||
*/
|
||||
private function cell(array $cells, ?int $column): string
|
||||
{
|
||||
return $column === null ? '' : $this->text($cells[$column] ?? null);
|
||||
}
|
||||
|
||||
private function text(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
return trim(str_replace("\u{00A0}", ' ', (string) $value));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string|null,1:string}
|
||||
*/
|
||||
private function dateParts(string $value): array
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '' || $value === '已完成') {
|
||||
return [null, ''];
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
$serial = (float) $value;
|
||||
if ($serial >= 20000 && $serial <= 80000) {
|
||||
return [ExcelDate::excelToDateTimeObject($serial)->format('Y-m-d'), ''];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['Y-m-d', 'Y/m/d', 'Y.m.d', 'Y年n月j日'] as $format) {
|
||||
try {
|
||||
$date = Carbon::createFromFormat($format, $value);
|
||||
if ($date !== false) {
|
||||
return [$date->format('Y-m-d'), ''];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// 不是标准日期,保留原值作为时间备注。
|
||||
}
|
||||
}
|
||||
|
||||
return [null, mb_substr($value, 0, 100)];
|
||||
}
|
||||
|
||||
private function status(string $progress, string $startNote, string $finishNote): string
|
||||
{
|
||||
if (str_contains($progress, '已完成')) {
|
||||
return SystemizationTodo::STATUS_COMPLETED;
|
||||
}
|
||||
if ($progress !== '') {
|
||||
return str_contains($progress, '延期')
|
||||
? SystemizationTodo::STATUS_DELAYED
|
||||
: SystemizationTodo::STATUS_IN_PROGRESS;
|
||||
}
|
||||
if (str_contains($startNote, '延期') || str_contains($finishNote, '延期')) {
|
||||
return SystemizationTodo::STATUS_DELAYED;
|
||||
}
|
||||
|
||||
return SystemizationTodo::STATUS_PENDING;
|
||||
}
|
||||
}
|
||||
41
app/Console/Commands/ImportSystemizationTodos.php
Normal file
41
app/Console/Commands/ImportSystemizationTodos.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\SystemizationTodoWorkbookImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ImportSystemizationTodos extends Command
|
||||
{
|
||||
protected $signature = 'systemization-todos:import
|
||||
{file : 待办 Excel 文件路径}
|
||||
{--company=1 : 写入的 company_id}
|
||||
{--user= : 操作人用户 ID}';
|
||||
|
||||
protected $description = '导入财务系统化待办清单';
|
||||
|
||||
public function handle(SystemizationTodoWorkbookImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('file'),
|
||||
max((int) $this->option('company'), 1),
|
||||
$this->option('user') !== null ? (int) $this->option('user') : null,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'工作表 %s:新增 %d 条,跳过已存在 %d 条。',
|
||||
$result['sheet'],
|
||||
$result['created'],
|
||||
$result['skipped'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -4186,6 +4186,7 @@ class Pages extends Controller
|
||||
'shipping-data' => $this->contentForShippingData(),
|
||||
'return-pickup-management' => $this->contentForReturnPickupManagement(),
|
||||
'withdrawal-management' => $this->contentForWithdrawalManagement(),
|
||||
'systemization-todos' => app(\App\Http\Controllers\Business\SystemizationTodos::class)->page(),
|
||||
'todos' => $this->contentForTodos(),
|
||||
'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(),
|
||||
'account-mapping' => $this->contentForAccountMapping(),
|
||||
|
||||
284
app/Http/Controllers/Business/SystemizationTodos.php
Normal file
284
app/Http/Controllers/Business/SystemizationTodos.php
Normal file
@ -0,0 +1,284 @@
|
||||
<?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\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Throwable;
|
||||
|
||||
class SystemizationTodos extends Controller
|
||||
{
|
||||
public function page(): array
|
||||
{
|
||||
$filters = $this->filters();
|
||||
$query = SystemizationTodo::query()
|
||||
->with('attachments')
|
||||
->withCount('attachments');
|
||||
|
||||
if ($filters['status'] !== '') {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
if ($filters['category'] !== '') {
|
||||
$query->where('category', $filters['category']);
|
||||
}
|
||||
if ($filters['owner'] !== '') {
|
||||
$query->where(function ($builder) use ($filters): void {
|
||||
$builder->where('owner', $filters['owner'])
|
||||
->orWhere('assignee', $filters['owner']);
|
||||
});
|
||||
}
|
||||
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
|
||||
->orderByRaw("CASE WHEN status = '已完成' THEN 1 ELSE 0 END")
|
||||
->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');
|
||||
$owners = SystemizationTodo::query()
|
||||
->whereNotNull('owner')
|
||||
->where('owner', '<>', '')
|
||||
->pluck('owner')
|
||||
->merge(
|
||||
SystemizationTodo::query()
|
||||
->whereNotNull('assignee')
|
||||
->where('assignee', '<>', '')
|
||||
->pluck('assignee')
|
||||
)
|
||||
->flatMap(static fn (string $value): array => array_map('trim', preg_split('/[、,,]/u', $value) ?: []))
|
||||
->filter()
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.systemization-todos',
|
||||
'title' => '系统化待办',
|
||||
'items' => $items,
|
||||
'todoFilters' => $filters,
|
||||
'todoCategories' => $categories,
|
||||
'todoOwners' => $owners,
|
||||
'todoStatuses' => SystemizationTodo::STATUSES,
|
||||
'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
|
||||
{
|
||||
return [
|
||||
'status' => in_array(request('status'), SystemizationTodo::STATUSES, true)
|
||||
? (string) request('status')
|
||||
: '',
|
||||
'category' => trim((string) request('category', '')),
|
||||
'owner' => trim((string) request('owner', '')),
|
||||
'keyword' => trim((string) request('keyword', '')),
|
||||
];
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
68
app/Models/Business/SystemizationTodo.php
Normal file
68
app/Models/Business/SystemizationTodo.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SystemizationTodo extends Model
|
||||
{
|
||||
public const STATUS_PENDING = '待办';
|
||||
|
||||
public const STATUS_IN_PROGRESS = '进行中';
|
||||
|
||||
public const STATUS_COMPLETED = '已完成';
|
||||
|
||||
public const STATUS_DELAYED = '已延期';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_PENDING,
|
||||
self::STATUS_IN_PROGRESS,
|
||||
self::STATUS_COMPLETED,
|
||||
self::STATUS_DELAYED,
|
||||
];
|
||||
|
||||
protected $table = 'business_systemization_todos';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'category',
|
||||
'section',
|
||||
'title',
|
||||
'owner',
|
||||
'assignee',
|
||||
'start_date',
|
||||
'start_note',
|
||||
'due_date',
|
||||
'due_note',
|
||||
'status',
|
||||
'completed_at',
|
||||
'progress',
|
||||
'notes',
|
||||
'sort_order',
|
||||
'source_key',
|
||||
'imported_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'company_id' => 'integer',
|
||||
'start_date' => 'date',
|
||||
'due_date' => 'date',
|
||||
'completed_at' => 'date',
|
||||
'sort_order' => 'integer',
|
||||
'imported_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(SystemizationTodoAttachment::class, 'systemization_todo_id')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
}
|
||||
32
app/Models/Business/SystemizationTodoAttachment.php
Normal file
32
app/Models/Business/SystemizationTodoAttachment.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SystemizationTodoAttachment extends Model
|
||||
{
|
||||
protected $table = 'business_systemization_todo_attachments';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'systemization_todo_id',
|
||||
'path',
|
||||
'original_name',
|
||||
'mime_type',
|
||||
'size',
|
||||
'uploaded_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'company_id' => 'integer',
|
||||
'systemization_todo_id' => 'integer',
|
||||
'size' => 'integer',
|
||||
];
|
||||
|
||||
public function todo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SystemizationTodo::class, 'systemization_todo_id');
|
||||
}
|
||||
}
|
||||
@ -44,6 +44,7 @@ class SidebarMenu
|
||||
['company-management', 'company-progress', '公司进度表', 'timeline'],
|
||||
['store-management', 'change-progress', '店铺变更进度表', 'swap_horiz'],
|
||||
['trademark-management', 'trademark-progress', '商标进度表', 'format_list_bulleted'],
|
||||
['other', 'systemization-todos', '系统化待办', 'checklist'],
|
||||
['workspace', 'tasks', '待办事项', 'assignment_turned_in'],
|
||||
['workspace', 'warnings', '预警页面', 'warning_amber'],
|
||||
]],
|
||||
|
||||
@ -956,6 +956,29 @@ $navigation = [
|
||||
'title' => '其他',
|
||||
'icon' => 'more_horiz',
|
||||
'pages' => [
|
||||
[
|
||||
'slug' => 'systemization-todos',
|
||||
'title' => '系统化待办',
|
||||
'directory' => [
|
||||
'icon' => 'checklist',
|
||||
'status' => '持续更新',
|
||||
'description' => '财务系统化工作的日常待办、责任人、进度和完成截图。',
|
||||
'empty' => '暂时没有系统化待办。',
|
||||
'fields' => [
|
||||
['name' => 'category', 'label' => '类型'],
|
||||
['name' => 'section', 'label' => '内容'],
|
||||
['name' => 'title', 'label' => '待办事项'],
|
||||
['name' => 'owner', 'label' => '总负责人'],
|
||||
['name' => 'assignee', 'label' => '责任人'],
|
||||
['name' => 'start_date', 'label' => '开始时间', 'kind' => 'date'],
|
||||
['name' => 'due_date', 'label' => '完成时间', 'kind' => 'date'],
|
||||
['name' => 'status', 'label' => '状态'],
|
||||
['name' => 'progress', 'label' => '完成情况'],
|
||||
['name' => 'attachments', 'label' => '完成截图'],
|
||||
['name' => 'notes', 'label' => '备注'],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'todos',
|
||||
'title' => '待办事项',
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('business_systemization_todos')) {
|
||||
Schema::create('business_systemization_todos', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedInteger('company_id')->default(1);
|
||||
$table->string('category', 100)->nullable();
|
||||
$table->string('section', 255)->nullable();
|
||||
$table->text('title');
|
||||
$table->string('owner', 255)->nullable();
|
||||
$table->string('assignee', 255)->nullable();
|
||||
$table->date('start_date')->nullable();
|
||||
$table->string('start_note', 100)->nullable();
|
||||
$table->date('due_date')->nullable();
|
||||
$table->string('due_note', 100)->nullable();
|
||||
$table->string('status', 20)->default('待办');
|
||||
$table->date('completed_at')->nullable();
|
||||
$table->text('progress')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->unsignedInteger('sort_order')->default(0);
|
||||
$table->string('source_key', 64)->nullable();
|
||||
$table->timestamp('imported_at')->nullable();
|
||||
$table->unsignedBigInteger('created_by')->nullable();
|
||||
$table->unsignedBigInteger('updated_by')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['company_id', 'status'], 'systemization_todos_company_status_index');
|
||||
$table->index(['company_id', 'category'], 'systemization_todos_company_category_index');
|
||||
$table->index(['company_id', 'due_date'], 'systemization_todos_company_due_index');
|
||||
$table->unique(['company_id', 'source_key'], 'systemization_todos_company_source_unique');
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('business_systemization_todo_attachments')) {
|
||||
Schema::create('business_systemization_todo_attachments', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedInteger('company_id')->default(1);
|
||||
$table->unsignedBigInteger('systemization_todo_id');
|
||||
$table->string('path');
|
||||
$table->string('original_name');
|
||||
$table->string('mime_type', 100);
|
||||
$table->unsignedBigInteger('size')->default(0);
|
||||
$table->unsignedBigInteger('uploaded_by')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(
|
||||
['company_id', 'systemization_todo_id'],
|
||||
'systemization_todo_attachments_company_todo_index'
|
||||
);
|
||||
$table->foreign('systemization_todo_id')
|
||||
->references('id')
|
||||
->on('business_systemization_todos')
|
||||
->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('business_systemization_todo_attachments');
|
||||
Schema::dropIfExists('business_systemization_todos');
|
||||
}
|
||||
};
|
||||
443
resources/views/business/pages/systemization-todos.blade.php
Normal file
443
resources/views/business/pages/systemization-todos.blade.php
Normal file
@ -0,0 +1,443 @@
|
||||
<section class="biz-card mt-6 sys-todo-page" aria-label="系统化待办">
|
||||
<style>
|
||||
.sys-todo-page { --sys-gold:#856632; --sys-gold-soft:#f7f0de; --sys-border:#d8d2c7; --sys-muted:#666; }
|
||||
.sys-todo-page .biz-card-head { align-items:flex-start; gap:1rem; }
|
||||
.sys-todo-page__head { display:flex; flex-wrap:wrap; align-items:flex-start; gap:1rem; width:100%; }
|
||||
.sys-todo-page__head-main { min-width:20rem; }
|
||||
.sys-todo-page__head-actions { display:flex; flex-wrap:wrap; gap:.5rem; margin-left:auto; }
|
||||
.sys-todo-page__button { display:inline-flex; align-items:center; justify-content:center; gap:.3rem; min-height:2.125rem; padding:0 .85rem; border:1px solid var(--sys-border); border-radius:.5rem; background:#fff; color:#171717; font:inherit; font-size:.8125rem; cursor:pointer; text-decoration:none; }
|
||||
.sys-todo-page__button:hover { background:var(--sys-gold-soft); border-color:#c7b687; }
|
||||
.sys-todo-page__button--primary { border-color:var(--sys-gold); background:var(--sys-gold); color:#fff; }
|
||||
.sys-todo-page__button--primary:hover { background:#6a5129; color:#fff; }
|
||||
.sys-todo-page__button .material-icons-outlined { font-size:1rem; }
|
||||
.sys-todo-page__intro { margin:.35rem 0 0; color:var(--sys-muted); font-size:.75rem; line-height:1.6; }
|
||||
.sys-todo-summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.75rem; margin:1rem 0; }
|
||||
.sys-todo-summary__item { padding:.75rem .85rem; border:1px solid #e6dfd2; border-radius:.6rem; background:#fffdf9; }
|
||||
.sys-todo-summary__label { color:var(--sys-muted); font-size:.72rem; }
|
||||
.sys-todo-summary__value { margin-top:.15rem; color:#171717; font-size:1.25rem; font-weight:700; font-variant-numeric:tabular-nums; }
|
||||
.sys-todo-summary__item.is-warning .sys-todo-summary__value { color:#935f07; }
|
||||
.sys-todo-summary__item.is-success .sys-todo-summary__value { color:#53793e; }
|
||||
.sys-todo-filter { display:grid; grid-template-columns:minmax(8rem,.8fr) minmax(10rem,1fr) minmax(10rem,1fr) minmax(18rem,2fr) auto; gap:.65rem .75rem; align-items:end; margin:0 0 1rem; padding:.85rem; border:1px solid #e6dfd2; border-radius:.65rem; background:#fcfaf5; }
|
||||
.sys-todo-filter label { display:grid; gap:.3rem; color:#595959; font-size:.75rem; }
|
||||
.sys-todo-filter input, .sys-todo-filter select { width:100%; height:2.125rem; padding:0 .6rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; font:inherit; font-size:.8125rem; }
|
||||
.sys-todo-filter__actions { display:flex; gap:.45rem; }
|
||||
.sys-todo-table-wrap { overflow:auto; border:1px solid var(--sys-border); border-radius:.65rem; background:#fff; }
|
||||
.sys-todo-table { width:100%; min-width:94rem; border-collapse:collapse; font-size:.78rem; }
|
||||
.sys-todo-table th { position:sticky; top:0; z-index:1; padding:.65rem .55rem; border-bottom:1px solid var(--sys-border); background:#f3ece0; color:#3d3a35; text-align:left; white-space:nowrap; }
|
||||
.sys-todo-table td { padding:.6rem .55rem; border-bottom:1px solid #eee7da; color:#2f2a23; vertical-align:top; }
|
||||
.sys-todo-table tbody tr:hover { background:#fdfbf6; }
|
||||
.sys-todo-table__title, .sys-todo-table__progress, .sys-todo-table__notes { min-width:14rem; max-width:28rem; white-space:pre-wrap; overflow-wrap:anywhere; line-height:1.55; }
|
||||
.sys-todo-table__title { min-width:24rem; font-weight:600; }
|
||||
.sys-todo-badge { display:inline-flex; align-items:center; padding:.1rem .45rem; border:1px solid #dfd6c5; border-radius:999px; background:#faf6ee; color:#51483c; white-space:nowrap; }
|
||||
.sys-todo-badge--progress { border-color:#dbbf7a; background:#fff7df; color:#935f07; }
|
||||
.sys-todo-badge--completed { border-color:#bad0ac; background:#f2f8ed; color:#53793e; }
|
||||
.sys-todo-badge--delayed { border-color:#e7c3c3; background:#fff3f3; color:#b80000; }
|
||||
.sys-todo-badge--overdue { border-color:#e7c3c3; color:#b80000; }
|
||||
.sys-todo-shots { display:flex; flex-wrap:wrap; gap:.3rem; max-width:13rem; }
|
||||
.sys-todo-shots a { display:block; width:4.25rem; height:3.2rem; border:1px solid #ddd4c4; border-radius:.35rem; overflow:hidden; background:#faf8f3; }
|
||||
.sys-todo-shots img { width:100%; height:100%; object-fit:cover; }
|
||||
.sys-todo-row-actions { display:flex; flex-wrap:wrap; gap:.35rem; }
|
||||
.sys-todo-row-actions button, .sys-todo-row-actions a { padding:.2rem .45rem; border:1px solid var(--sys-border); border-radius:.35rem; background:#fff; color:#51483c; font:inherit; font-size:.72rem; text-decoration:none; cursor:pointer; }
|
||||
.sys-todo-row-actions button:hover, .sys-todo-row-actions a:hover { border-color:#c7b687; background:var(--sys-gold-soft); }
|
||||
.sys-todo-empty { padding:2rem 1rem; color:#777; text-align:center; }
|
||||
.sys-todo-pagination { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.75rem; padding:.85rem 0 0; color:#595959; font-size:.75rem; }
|
||||
.sys-todo-pagination__actions { display:flex; gap:.5rem; }
|
||||
.sys-todo-pagination a, .sys-todo-pagination span { padding:.3rem .55rem; border:1px solid var(--sys-border); border-radius:.35rem; background:#fff; color:#171717; text-decoration:none; }
|
||||
.sys-todo-flash { margin:0 0 .85rem; padding:.65rem .85rem; border:1px solid #bad0ac; border-radius:.45rem; background:#f2f8ed; color:#53793e; font-size:.8125rem; }
|
||||
.sys-todo-flash--error { border-color:#e7c3c3; background:#fff3f3; color:#b80000; }
|
||||
.sys-todo-import { margin:0 0 1rem; border:1px solid #e6dfd2; border-radius:.65rem; background:#fcfaf5; }
|
||||
.sys-todo-import summary { padding:.7rem .9rem; color:#51483c; font-size:.8125rem; cursor:pointer; }
|
||||
.sys-todo-import form { display:flex; flex-wrap:wrap; align-items:end; gap:.65rem; padding:0 .9rem .9rem; }
|
||||
.sys-todo-import label { display:grid; gap:.3rem; color:#595959; font-size:.75rem; }
|
||||
.sys-todo-import input { font:inherit; font-size:.8125rem; }
|
||||
.sys-todo-dialog { width:min(64rem,calc(100vw - 2rem)); max-height:90vh; padding:0; border:1px solid var(--sys-border); border-radius:1rem; background:#fff; color:#171717; overflow:hidden; }
|
||||
.sys-todo-dialog::backdrop { background:rgba(23,23,23,.5); }
|
||||
.sys-todo-dialog__head { display:flex; align-items:center; gap:1rem; padding:1rem 1.25rem; border-bottom:1px solid #e8e0d2; background:#faf6ee; }
|
||||
.sys-todo-dialog__head h2 { margin:0; font-size:1rem; }
|
||||
.sys-todo-dialog__close { margin-left:auto; width:2rem; height:2rem; padding:0; border:1px solid var(--sys-border); border-radius:999px; background:#fff; color:#595959; font-size:1.2rem; cursor:pointer; }
|
||||
.sys-todo-dialog__body { max-height:calc(90vh - 9rem); overflow:auto; padding:1rem 1.25rem; }
|
||||
.sys-todo-form-grid { display:grid; grid-template-columns:repeat(12,minmax(0,1fr)); gap:.75rem 1rem; }
|
||||
.sys-todo-field { display:grid; gap:.3rem; min-width:0; color:#595959; font-size:.75rem; }
|
||||
.sys-todo-field--3 { grid-column:span 3; }
|
||||
.sys-todo-field--4 { grid-column:span 4; }
|
||||
.sys-todo-field--6 { grid-column:span 6; }
|
||||
.sys-todo-field--12 { grid-column:span 12; }
|
||||
.sys-todo-field input, .sys-todo-field select, .sys-todo-field textarea { width:100%; padding:.5rem .6rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; font:inherit; font-size:.8125rem; }
|
||||
.sys-todo-field input, .sys-todo-field select { min-height:2.15rem; }
|
||||
.sys-todo-field textarea { min-height:5.5rem; resize:vertical; line-height:1.55; }
|
||||
.sys-todo-field input[type="file"] { padding:.35rem; }
|
||||
.sys-todo-upload-note { color:#777; font-size:.72rem; }
|
||||
.sys-todo-field.is-dragging { outline:2px dashed var(--sys-gold); outline-offset:3px; border-radius:.55rem; background:var(--sys-gold-soft); }
|
||||
.sys-todo-existing { display:flex; flex-wrap:wrap; gap:.55rem; margin-top:.45rem; }
|
||||
.sys-todo-existing__item { display:grid; gap:.25rem; width:8rem; padding:.4rem; border:1px solid #e4dccd; border-radius:.45rem; background:#fcfaf5; font-size:.7rem; }
|
||||
.sys-todo-existing__item img { width:100%; height:5rem; object-fit:cover; border-radius:.3rem; background:#fff; }
|
||||
.sys-todo-existing__item label { display:flex; align-items:center; gap:.25rem; color:#b80000; }
|
||||
.sys-todo-dialog__foot { display:flex; justify-content:flex-end; gap:.5rem; padding:.75rem 1.25rem; border-top:1px solid #e8e0d2; background:#fcfaf5; }
|
||||
.sys-todo-dialog__foot button { min-height:2.1rem; padding:0 .9rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; font:inherit; font-size:.8125rem; cursor:pointer; }
|
||||
.sys-todo-dialog__foot button[type="submit"] { border-color:var(--sys-gold); background:var(--sys-gold); color:#fff; }
|
||||
@media (max-width:1100px) { .sys-todo-summary { grid-template-columns:repeat(3,minmax(0,1fr)); } .sys-todo-filter { grid-template-columns:repeat(2,minmax(0,1fr)); } .sys-todo-filter__actions { justify-self:end; } }
|
||||
@media (max-width:720px) { .sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .sys-todo-filter { grid-template-columns:1fr; } .sys-todo-filter__actions { justify-self:stretch; } .sys-todo-filter__actions .sys-todo-page__button { flex:1; } .sys-todo-field--3,.sys-todo-field--4,.sys-todo-field--6 { grid-column:span 12; } }
|
||||
</style>
|
||||
|
||||
<div class="biz-card-head">
|
||||
<div class="sys-todo-page__head">
|
||||
<div class="sys-todo-page__head-main">
|
||||
<div class="biz-card-title">
|
||||
<span class="material-icons-outlined" aria-hidden="true">checklist</span>
|
||||
<span>财务系统化待办</span>
|
||||
</div>
|
||||
<p class="sys-todo-page__intro">日常过会使用。新增事项、跟进责任人和完成情况,截图直接附在该事项下。</p>
|
||||
</div>
|
||||
<div class="sys-todo-page__head-actions">
|
||||
<button type="button" class="sys-todo-page__button sys-todo-page__button--primary" data-sys-todo-create>
|
||||
<span class="material-icons-outlined" aria-hidden="true">add</span>新增待办
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (is_string(session('success')) && session('success') !== '')
|
||||
<div class="sys-todo-flash">{{ session('success') }}</div>
|
||||
@endif
|
||||
@if (is_string(session('error')) && session('error') !== '')
|
||||
<div class="sys-todo-flash sys-todo-flash--error">{{ session('error') }}</div>
|
||||
@endif
|
||||
|
||||
<div class="sys-todo-summary" aria-label="系统化待办统计">
|
||||
<div class="sys-todo-summary__item"><div class="sys-todo-summary__label">全部</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['total']) }}</div></div>
|
||||
<div class="sys-todo-summary__item"><div class="sys-todo-summary__label">待办</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['pending']) }}</div></div>
|
||||
<div class="sys-todo-summary__item"><div class="sys-todo-summary__label">进行中</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['in_progress']) }}</div></div>
|
||||
<div class="sys-todo-summary__item is-success"><div class="sys-todo-summary__label">已完成</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['completed']) }}</div></div>
|
||||
<div class="sys-todo-summary__item is-warning"><div class="sys-todo-summary__label">已逾期</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['overdue']) }}</div></div>
|
||||
</div>
|
||||
|
||||
<details class="sys-todo-import">
|
||||
<summary>从 Excel 导入现有待办(只新增,不覆盖系统内已有记录)</summary>
|
||||
<form method="POST" action="{{ $todoRoutes['import'] }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<label>待办 Excel<input type="file" name="source" accept=".xlsx" required></label>
|
||||
<button type="submit" class="sys-todo-page__button">导入</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<form class="sys-todo-filter" method="GET" action="{{ request()->url() }}">
|
||||
<label>状态<select name="status">
|
||||
<option value="">全部状态</option>
|
||||
@foreach ($todoStatuses as $status)
|
||||
<option value="{{ $status }}" @selected($todoFilters['status'] === $status)>{{ $status }}</option>
|
||||
@endforeach
|
||||
</select></label>
|
||||
<label>类型<select name="category">
|
||||
<option value="">全部类型</option>
|
||||
@foreach ($todoCategories as $category)
|
||||
<option value="{{ $category }}" @selected($todoFilters['category'] === $category)>{{ $category }}</option>
|
||||
@endforeach
|
||||
</select></label>
|
||||
<label>责任人<select name="owner">
|
||||
<option value="">全部责任人</option>
|
||||
@foreach ($todoOwners as $owner)
|
||||
<option value="{{ $owner }}" @selected($todoFilters['owner'] === $owner)>{{ $owner }}</option>
|
||||
@endforeach
|
||||
</select></label>
|
||||
<label>关键词<input type="search" name="keyword" value="{{ $todoFilters['keyword'] }}" placeholder="事项、内容、责任人、进展或备注"></label>
|
||||
<div class="sys-todo-filter__actions">
|
||||
<button type="submit" class="sys-todo-page__button sys-todo-page__button--primary">筛选</button>
|
||||
<a class="sys-todo-page__button" href="{{ request()->url() }}">重置</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="sys-todo-table-wrap">
|
||||
<table class="sys-todo-table" aria-label="财务系统化待办清单">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>序号</th><th>类型</th><th>内容</th><th>待办事项</th><th>总负责人</th><th>责任人</th>
|
||||
<th>开始时间</th><th>完成时间</th><th>状态</th><th>完成情况</th><th>完成截图</th><th>备注</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($items as $item)
|
||||
@php
|
||||
$isOverdue = ! $item->isCompleted()
|
||||
&& $item->due_date !== null
|
||||
&& $item->due_date->isBefore(today());
|
||||
$statusClass = match ($item->status) {
|
||||
\App\Models\Business\SystemizationTodo::STATUS_IN_PROGRESS => 'sys-todo-badge--progress',
|
||||
\App\Models\Business\SystemizationTodo::STATUS_COMPLETED => 'sys-todo-badge--completed',
|
||||
\App\Models\Business\SystemizationTodo::STATUS_DELAYED => 'sys-todo-badge--delayed',
|
||||
default => '',
|
||||
};
|
||||
$todoPayload = [
|
||||
'id' => $item->id,
|
||||
'category' => $item->category ?? '',
|
||||
'section' => $item->section ?? '',
|
||||
'title' => $item->title,
|
||||
'owner' => $item->owner ?? '',
|
||||
'assignee' => $item->assignee ?? '',
|
||||
'start_date' => $item->start_date?->format('Y-m-d') ?? '',
|
||||
'due_date' => $item->due_date?->format('Y-m-d') ?? '',
|
||||
'status' => $item->status,
|
||||
'completed_at' => $item->completed_at?->format('Y-m-d') ?? '',
|
||||
'progress' => $item->progress ?? '',
|
||||
'notes' => $item->notes ?? '',
|
||||
'attachments' => $item->attachments->map(static fn ($attachment): array => [
|
||||
'id' => $attachment->id,
|
||||
'name' => $attachment->original_name,
|
||||
'url' => route('business.systemization-todos.attachments.show', [
|
||||
'todo' => $item->id,
|
||||
'attachment' => $attachment->id,
|
||||
]),
|
||||
])->values()->all(),
|
||||
];
|
||||
@endphp
|
||||
<tr>
|
||||
<td>{{ $items->firstItem() + $loop->index }}</td>
|
||||
<td>{{ $item->category ?: '—' }}</td>
|
||||
<td>{{ $item->section ?: '—' }}</td>
|
||||
<td class="sys-todo-table__title">{!! nl2br(e($item->title)) !!}</td>
|
||||
<td>{{ $item->owner ?: '—' }}</td>
|
||||
<td>{{ $item->assignee ?: '—' }}</td>
|
||||
<td>{{ $item->start_date?->format('Y-m-d') ?: ($item->start_note ?: '—') }}</td>
|
||||
<td>
|
||||
{{ $item->due_date?->format('Y-m-d') ?: ($item->due_note ?: '—') }}
|
||||
@if ($isOverdue)<br><span class="sys-todo-badge sys-todo-badge--overdue">已逾期</span>@endif
|
||||
</td>
|
||||
<td><span class="sys-todo-badge {{ $statusClass }}">{{ $item->status }}</span></td>
|
||||
<td class="sys-todo-table__progress">{!! nl2br(e($item->progress ?: '—')) !!}</td>
|
||||
<td>
|
||||
@if ($item->attachments->isNotEmpty())
|
||||
<div class="sys-todo-shots">
|
||||
@foreach ($item->attachments as $attachment)
|
||||
<a href="{{ route('business.systemization-todos.attachments.show', ['todo' => $item->id, 'attachment' => $attachment->id]) }}"
|
||||
target="_blank" rel="noopener" title="{{ $attachment->original_name }}">
|
||||
<img src="{{ route('business.systemization-todos.attachments.show', ['todo' => $item->id, 'attachment' => $attachment->id]) }}"
|
||||
alt="{{ $attachment->original_name }}" loading="lazy">
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
<td class="sys-todo-table__notes">{!! nl2br(e($item->notes ?: '—')) !!}</td>
|
||||
<td>
|
||||
<div class="sys-todo-row-actions">
|
||||
<button type="button" data-sys-todo-edit="{{ base64_encode(json_encode($todoPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) }}">编辑</button>
|
||||
<form method="POST" action="{{ route('business.systemization-todos.destroy', ['todo' => $item->id]) }}" onsubmit="return confirm('确定删除这条待办吗?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="13"><div class="sys-todo-empty">当前筛选条件下没有待办事项。</div></td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if ($items->hasPages())
|
||||
<div class="sys-todo-pagination">
|
||||
<span>共 {{ number_format($items->total()) }} 条 · 第 {{ $items->currentPage() }} / {{ $items->lastPage() }} 页</span>
|
||||
<span class="sys-todo-pagination__actions">
|
||||
@if ($items->onFirstPage())
|
||||
<span>上一页</span>
|
||||
@else
|
||||
<a href="{{ $items->previousPageUrl() }}">上一页</a>
|
||||
@endif
|
||||
@if ($items->hasMorePages())
|
||||
<a href="{{ $items->nextPageUrl() }}">下一页</a>
|
||||
@else
|
||||
<span>下一页</span>
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<dialog class="sys-todo-dialog" id="sysTodoDialog" aria-labelledby="sysTodoDialogTitle">
|
||||
<form method="POST" enctype="multipart/form-data" data-sys-todo-form>
|
||||
@csrf
|
||||
<input type="hidden" name="_method" value="POST" data-sys-todo-method>
|
||||
<div class="sys-todo-dialog__head">
|
||||
<h2 id="sysTodoDialogTitle">新增待办</h2>
|
||||
<button type="button" class="sys-todo-dialog__close" data-sys-todo-close aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="sys-todo-dialog__body">
|
||||
<div class="sys-todo-form-grid">
|
||||
<label class="sys-todo-field sys-todo-field--3">类型<input name="category" maxlength="100" list="sysTodoCategories"></label>
|
||||
<label class="sys-todo-field sys-todo-field--4">内容<input name="section" maxlength="255"></label>
|
||||
<label class="sys-todo-field sys-todo-field--3">状态<select name="status" required>@foreach ($todoStatuses as $status)<option value="{{ $status }}">{{ $status }}</option>@endforeach</select></label>
|
||||
<label class="sys-todo-field sys-todo-field--3">总负责人<input name="owner" maxlength="255" list="sysTodoOwners"></label>
|
||||
<label class="sys-todo-field sys-todo-field--3">责任人<input name="assignee" maxlength="255" list="sysTodoOwners"></label>
|
||||
<label class="sys-todo-field sys-todo-field--3">开始时间<input type="date" name="start_date"></label>
|
||||
<label class="sys-todo-field sys-todo-field--3">完成时间<input type="date" name="due_date"></label>
|
||||
<label class="sys-todo-field sys-todo-field--6">待办事项<textarea name="title" maxlength="5000" required></textarea></label>
|
||||
<label class="sys-todo-field sys-todo-field--6">完成情况<textarea name="progress" maxlength="10000"></textarea></label>
|
||||
<label class="sys-todo-field sys-todo-field--6">备注<textarea name="notes" maxlength="10000"></textarea></label>
|
||||
<div class="sys-todo-field sys-todo-field--6" data-sys-todo-upload>
|
||||
<label for="sysTodoScreenshots">完成截图</label>
|
||||
<input id="sysTodoScreenshots" type="file" name="screenshots[]" accept=".jpg,.jpeg,.png,.webp" multiple>
|
||||
<span class="sys-todo-upload-note" data-sys-todo-upload-note>可选择、拖入或直接粘贴截图,每张不超过 10 MB。</span>
|
||||
<span class="sys-todo-existing" data-sys-todo-existing></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sys-todo-dialog__foot">
|
||||
<button type="button" data-sys-todo-close>取消</button>
|
||||
<button type="submit">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<datalist id="sysTodoCategories">@foreach ($todoCategories as $category)<option value="{{ $category }}">@endforeach</datalist>
|
||||
<datalist id="sysTodoOwners">@foreach ($todoOwners as $owner)<option value="{{ $owner }}">@endforeach</datalist>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var dialog = document.getElementById('sysTodoDialog');
|
||||
var form = dialog.querySelector('[data-sys-todo-form]');
|
||||
var methodInput = dialog.querySelector('[data-sys-todo-method]');
|
||||
var title = document.getElementById('sysTodoDialogTitle');
|
||||
var existing = dialog.querySelector('[data-sys-todo-existing]');
|
||||
var uploadField = dialog.querySelector('[data-sys-todo-upload]');
|
||||
var screenshotInput = document.getElementById('sysTodoScreenshots');
|
||||
var uploadNote = dialog.querySelector('[data-sys-todo-upload-note]');
|
||||
var routes = @json($todoRoutes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
var currentTodoId = null;
|
||||
|
||||
function decodePayload(value) {
|
||||
var binary = atob(value);
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return JSON.parse(new TextDecoder().decode(bytes));
|
||||
}
|
||||
|
||||
function setValue(name, value) {
|
||||
var field = form.querySelector('[name="' + name + '"]');
|
||||
if (field) field.value = value || '';
|
||||
}
|
||||
|
||||
function appendScreenshotFiles(files) {
|
||||
var images = Array.prototype.slice.call(files || []).filter(function (file) {
|
||||
return /^image\/(jpeg|png|webp)$/.test(file.type);
|
||||
});
|
||||
if (!images.length) return;
|
||||
|
||||
var transfer = new DataTransfer();
|
||||
Array.prototype.slice.call(screenshotInput.files || []).forEach(function (file) {
|
||||
transfer.items.add(file);
|
||||
});
|
||||
images.forEach(function (file) {
|
||||
transfer.items.add(file);
|
||||
});
|
||||
screenshotInput.files = transfer.files;
|
||||
uploadNote.textContent = '已选择 ' + screenshotInput.files.length + ' 张截图,保存后上传。';
|
||||
}
|
||||
|
||||
function openDialog(payload) {
|
||||
currentTodoId = payload && payload.id ? payload.id : null;
|
||||
form.reset();
|
||||
methodInput.value = currentTodoId ? 'PATCH' : 'POST';
|
||||
form.action = currentTodoId ? routes.update.replace('__TODO__', currentTodoId) : routes.store;
|
||||
title.textContent = currentTodoId ? '编辑待办' : '新增待办';
|
||||
if (payload) {
|
||||
setValue('category', payload.category);
|
||||
setValue('section', payload.section);
|
||||
setValue('title', payload.title);
|
||||
setValue('owner', payload.owner);
|
||||
setValue('assignee', payload.assignee);
|
||||
setValue('start_date', payload.start_date);
|
||||
setValue('due_date', payload.due_date);
|
||||
setValue('status', payload.status);
|
||||
setValue('completed_at', payload.completed_at);
|
||||
setValue('progress', payload.progress);
|
||||
setValue('notes', payload.notes);
|
||||
} else {
|
||||
setValue('status', '待办');
|
||||
}
|
||||
|
||||
existing.replaceChildren();
|
||||
((payload && payload.attachments) || []).forEach(function (attachment) {
|
||||
var item = document.createElement('span');
|
||||
item.className = 'sys-todo-existing__item';
|
||||
var link = document.createElement('a');
|
||||
link.href = attachment.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
var image = document.createElement('img');
|
||||
image.src = attachment.url;
|
||||
image.alt = attachment.name;
|
||||
link.appendChild(image);
|
||||
item.appendChild(link);
|
||||
var label = document.createElement('label');
|
||||
var checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.name = 'remove_attachments[]';
|
||||
checkbox.value = attachment.id;
|
||||
label.appendChild(checkbox);
|
||||
label.appendChild(document.createTextNode('删除截图'));
|
||||
item.appendChild(label);
|
||||
existing.appendChild(item);
|
||||
});
|
||||
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||||
else dialog.setAttribute('open', '');
|
||||
uploadNote.textContent = '可选择、拖入或直接粘贴截图,每张不超过 10 MB。';
|
||||
window.setTimeout(function () {
|
||||
var titleField = form.querySelector('[name="title"]');
|
||||
if (titleField) titleField.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
if (typeof dialog.close === 'function') dialog.close();
|
||||
else dialog.removeAttribute('open');
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var create = event.target.closest('[data-sys-todo-create]');
|
||||
if (create) {
|
||||
openDialog(null);
|
||||
return;
|
||||
}
|
||||
var edit = event.target.closest('[data-sys-todo-edit]');
|
||||
if (edit) {
|
||||
openDialog(decodePayload(edit.getAttribute('data-sys-todo-edit')));
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-sys-todo-close]')) closeDialog();
|
||||
});
|
||||
dialog.addEventListener('click', function (event) {
|
||||
if (event.target === dialog) closeDialog();
|
||||
});
|
||||
dialog.addEventListener('paste', function (event) {
|
||||
var files = Array.prototype.slice.call(event.clipboardData?.items || [])
|
||||
.filter(function (item) { return item.kind === 'file'; })
|
||||
.map(function (item) { return item.getAsFile(); })
|
||||
.filter(Boolean);
|
||||
if (files.length) {
|
||||
event.preventDefault();
|
||||
appendScreenshotFiles(files);
|
||||
}
|
||||
});
|
||||
uploadField.addEventListener('dragover', function (event) {
|
||||
event.preventDefault();
|
||||
uploadField.classList.add('is-dragging');
|
||||
});
|
||||
uploadField.addEventListener('dragleave', function () {
|
||||
uploadField.classList.remove('is-dragging');
|
||||
});
|
||||
uploadField.addEventListener('drop', function (event) {
|
||||
event.preventDefault();
|
||||
uploadField.classList.remove('is-dragging');
|
||||
appendScreenshotFiles(event.dataTransfer?.files || []);
|
||||
});
|
||||
screenshotInput.addEventListener('change', function () {
|
||||
if (screenshotInput.files.length) {
|
||||
uploadNote.textContent = '已选择 ' + screenshotInput.files.length + ' 张截图,保存后上传。';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
@ -14,6 +14,7 @@ use App\Http\Controllers\Business\ProductPrices;
|
||||
use App\Http\Controllers\Business\SupplierPayableActions;
|
||||
use App\Http\Controllers\Business\PayablesFinalActions;
|
||||
use App\Http\Controllers\Business\SupplierReconciliation as SupplierReconciliationActions;
|
||||
use App\Http\Controllers\Business\SystemizationTodos;
|
||||
use App\Http\Controllers\Business\WithdrawalManagement;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@ -758,6 +759,26 @@ Route::post('workspace/financial-ledger/accounts/keep-todo', [\App\Http\Controll
|
||||
->middleware('business.page:financial-operations,ledger')
|
||||
->name('business.financial-ledger.accounts.keep-todo');
|
||||
|
||||
Route::prefix('workspace/other/systemization-todos')
|
||||
->name('business.systemization-todos.')
|
||||
->middleware('business.page:other,systemization-todos')
|
||||
->group(function (): void {
|
||||
Route::post('import', [SystemizationTodos::class, 'importWorkbook'])
|
||||
->middleware('import')
|
||||
->name('import');
|
||||
Route::post('/', [SystemizationTodos::class, 'store'])->name('store');
|
||||
Route::patch('{todo}', [SystemizationTodos::class, 'update'])
|
||||
->whereNumber('todo')
|
||||
->name('update');
|
||||
Route::delete('{todo}', [SystemizationTodos::class, 'destroy'])
|
||||
->whereNumber('todo')
|
||||
->name('destroy');
|
||||
Route::get('{todo}/attachments/{attachment}', [SystemizationTodos::class, 'attachment'])
|
||||
->whereNumber('todo')
|
||||
->whereNumber('attachment')
|
||||
->name('attachments.show');
|
||||
});
|
||||
|
||||
Route::get('workspace/financial-ledger/{page?}/{tab?}', function (Request $request, ?string $page = null, ?string $tab = null) {
|
||||
abort_unless($page === null || $page === 'ledger', 404);
|
||||
|
||||
|
||||
@ -636,7 +636,7 @@ class ManagementCrudTest extends FeatureTestCase
|
||||
array_column($sections['store-management']['pages'], 'slug')
|
||||
);
|
||||
$this->assertSame(
|
||||
['todos', 'warnings'],
|
||||
['systemization-todos', 'todos', 'warnings'],
|
||||
array_column($sections['other']['pages'], 'slug')
|
||||
);
|
||||
|
||||
@ -711,6 +711,7 @@ class ManagementCrudTest extends FeatureTestCase
|
||||
['section' => 'trademark-management', 'page' => 'trademark-progress', 'headers' => ['商标号', '是否完成', '办理人']],
|
||||
['section' => 'store-management', 'page' => 'store-report', 'headers' => ['ERP系统店铺名称', '店铺名称', '店铺ID(唯一值)', '1月销量', '12月销量']],
|
||||
['section' => 'store-management', 'page' => 'change-progress', 'headers' => ['接替公司', '完成时间', '办理人']],
|
||||
['section' => 'other', 'page' => 'systemization-todos', 'headers' => ['类型', '内容', '待办事项', '总负责人', '责任人', '状态', '完成截图']],
|
||||
['section' => 'other', 'page' => 'todos', 'headers' => ['待办编号', '是否逾期', '关闭日期']],
|
||||
['section' => 'other', 'page' => 'warnings', 'headers' => ['风险等级', '影响金额', '处理证据']],
|
||||
];
|
||||
|
||||
152
tests/Feature/Business/SystemizationTodosTest.php
Normal file
152
tests/Feature/Business/SystemizationTodosTest.php
Normal file
@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Business\SystemizationTodoWorkbookImporter;
|
||||
use App\Models\Business\SystemizationTodo;
|
||||
use App\Models\Business\SystemizationTodoAttachment;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class SystemizationTodosTest extends FeatureTestCase
|
||||
{
|
||||
public function test_page_supports_daily_items_completion_progress_and_screenshots(): void
|
||||
{
|
||||
Storage::fake('uploads');
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', [
|
||||
'section' => 'other',
|
||||
'page' => 'systemization-todos',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('财务系统化待办')
|
||||
->assertSeeText('新增待办')
|
||||
->assertSeeText('从 Excel 导入现有待办')
|
||||
->assertSee('data-sys-todo-create', false);
|
||||
|
||||
$this->post(route('business.systemization-todos.store'), [
|
||||
'category' => '收入',
|
||||
'section' => '平台收入核对',
|
||||
'title' => '核对淘系收入与平台账单差异',
|
||||
'owner' => '晨曦',
|
||||
'assignee' => '栎阳',
|
||||
'start_date' => '2026-10-25',
|
||||
'due_date' => '2026-10-28',
|
||||
'status' => SystemizationTodo::STATUS_IN_PROGRESS,
|
||||
'progress' => '已拉取账单,待核对订单',
|
||||
'notes' => '周会跟进',
|
||||
'screenshots' => [
|
||||
$this->pngUpload('first.png'),
|
||||
$this->pngUpload('existing.png'),
|
||||
],
|
||||
])->assertRedirect(route('business.pages.show', [
|
||||
'section' => 'other',
|
||||
'page' => 'systemization-todos',
|
||||
]));
|
||||
|
||||
$todo = SystemizationTodo::query()->firstOrFail();
|
||||
$this->assertSame('收入', $todo->category);
|
||||
$this->assertSame(SystemizationTodo::STATUS_IN_PROGRESS, $todo->status);
|
||||
$this->assertSame(2, $todo->attachments()->count());
|
||||
$firstAttachment = $todo->attachments()->firstOrFail();
|
||||
$existingAttachment = $todo->attachments()->reorder()->orderByDesc('id')->firstOrFail();
|
||||
Storage::disk('uploads')->assertExists($firstAttachment->path);
|
||||
Storage::disk('uploads')->assertExists($existingAttachment->path);
|
||||
$this->get(route('business.systemization-todos.attachments.show', [
|
||||
'todo' => $todo->id,
|
||||
'attachment' => $firstAttachment->id,
|
||||
]))->assertOk()->assertHeader('content-type', 'image/png');
|
||||
|
||||
$this->patch(route('business.systemization-todos.update', ['todo' => $todo->id]), [
|
||||
'category' => '收入',
|
||||
'section' => '平台收入核对',
|
||||
'title' => '核对淘系收入与平台账单差异',
|
||||
'owner' => '晨曦',
|
||||
'assignee' => '栎阳',
|
||||
'start_date' => '2026-10-25',
|
||||
'due_date' => '2026-10-27',
|
||||
'status' => SystemizationTodo::STATUS_COMPLETED,
|
||||
'progress' => '已完成核对并留档',
|
||||
'notes' => '周会确认关闭',
|
||||
'remove_attachments' => [$firstAttachment->id],
|
||||
'screenshots' => [$this->pngUpload('completed.png')],
|
||||
])->assertRedirect();
|
||||
|
||||
$todo->refresh();
|
||||
$this->assertSame(SystemizationTodo::STATUS_COMPLETED, $todo->status);
|
||||
$this->assertSame('2026-10-27', $todo->completed_at?->format('Y-m-d'));
|
||||
Storage::disk('uploads')->assertMissing($firstAttachment->path);
|
||||
$this->assertTrue(
|
||||
Storage::disk('uploads')->exists($existingAttachment->path),
|
||||
'保留截图丢失:first=' . $firstAttachment->path . ' existing=' . $existingAttachment->path,
|
||||
);
|
||||
$this->assertSame(2, $todo->attachments()->count());
|
||||
$this->assertTrue(
|
||||
$todo->attachments()->pluck('original_name')->contains('completed.png')
|
||||
);
|
||||
|
||||
$this->delete(route('business.systemization-todos.destroy', ['todo' => $todo->id]))
|
||||
->assertRedirect();
|
||||
$this->assertSoftDeleted('business_systemization_todos', ['id' => $todo->id]);
|
||||
}
|
||||
|
||||
public function test_excel_import_preserves_hierarchy_and_does_not_overwrite_existing_rows(): void
|
||||
{
|
||||
$path = sys_get_temp_dir() . '/systemization-todos-' . uniqid() . '.xlsx';
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->fromArray([
|
||||
['类型', '内容', '待办事项(具体工作计划)', '总负责人', '责任人', '开始时间', '完成时间', '完成情况(放截图)', '备注'],
|
||||
['收入', '平台收入核对', '核对淘系账单', '晨曦', '栎阳', '2026-10-20', '2026-10-21', '已完成', '首轮导入'],
|
||||
[null, null, '核对拼多多账单', '晨曦', '秋香', '待定', '11月', '第一步已开始', null],
|
||||
]);
|
||||
(new Xlsx($spreadsheet))->save($path);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
|
||||
try {
|
||||
$this->loginAs()->post(route('business.systemization-todos.import'), [
|
||||
'source' => new UploadedFile(
|
||||
$path,
|
||||
'10.19工作梳理.xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
null,
|
||||
true,
|
||||
),
|
||||
])->assertRedirect();
|
||||
|
||||
$this->assertSame(2, SystemizationTodo::query()->count());
|
||||
$first = SystemizationTodo::query()->orderBy('sort_order')->firstOrFail();
|
||||
$second = SystemizationTodo::query()->orderByDesc('sort_order')->firstOrFail();
|
||||
$this->assertSame('收入', $first->category);
|
||||
$this->assertSame('平台收入核对', $first->section);
|
||||
$this->assertSame(SystemizationTodo::STATUS_COMPLETED, $first->status);
|
||||
$this->assertSame('收入', $second->category);
|
||||
$this->assertSame('平台收入核对', $second->section);
|
||||
$this->assertSame('待定', $second->start_note);
|
||||
$this->assertSame('11月', $second->due_note);
|
||||
$this->assertSame(SystemizationTodo::STATUS_IN_PROGRESS, $second->status);
|
||||
|
||||
$result = app(SystemizationTodoWorkbookImporter::class)
|
||||
->import($path, (int) company_id(), user_id());
|
||||
$this->assertSame(0, $result['created']);
|
||||
$this->assertSame(2, $result['skipped']);
|
||||
|
||||
$this->assertSame(2, SystemizationTodo::query()->count());
|
||||
$this->assertSame(0, SystemizationTodoAttachment::query()->count());
|
||||
} finally {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function pngUpload(string $name): UploadedFile
|
||||
{
|
||||
return UploadedFile::fake()->createWithContent(
|
||||
$name,
|
||||
base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user