You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
szkp-map-service/app/Support/StudyTourDeclarationExporte...

650 lines
21 KiB

2 days ago
<?php
namespace App\Support;
use App\Models\DictItem;
use App\Models\StudyTour;
use App\Models\Venue;
use PhpOffice\PhpWord\IOFactory;
use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\SimpleType\Jc;
use PhpOffice\PhpWord\SimpleType\TblWidth;
use PhpOffice\PhpWord\SimpleType\VerticalJc;
use ZipArchive;
class StudyTourDeclarationExporter
{
public const MAX_BATCH = 50;
private const SEASON_FALLBACK = [
['value' => 'spring', 'label' => '春季'],
['value' => 'summer', 'label' => '夏季'],
['value' => 'autumn', 'label' => '秋季'],
['value' => 'winter', 'label' => '冬季'],
];
private const AUDIENCE_FALLBACK = [
['value' => 'kindergarten', 'label' => '幼儿园'],
['value' => 'primary', 'label' => '小学'],
['value' => 'junior', 'label' => '初中'],
['value' => 'high', 'label' => '高中'],
['value' => 'all', 'label' => '全学段'],
];
/**
* @param iterable<int, StudyTour> $tours
* @return array{path: string, filename: string, mime: string}
*/
public static function writeDownload(iterable $tours): array
{
$list = [];
foreach ($tours as $tour) {
if ($tour instanceof StudyTour) {
$list[] = $tour;
}
}
if ($list === []) {
throw new \RuntimeException('未找到可导出的线路');
}
if (count($list) === 1) {
$tour = $list[0];
$path = self::writeDocxFile(self::payloadFromTour($tour));
return [
'path' => $path,
'filename' => self::docxFilename($tour),
'mime' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
}
return [
'path' => self::writeZipFile($list),
'filename' => '研学线路申报表-'.date('Ymd-His').'.zip',
'mime' => 'application/zip',
];
}
/**
* @param array<string, mixed> $payload
*/
public static function writeDocxFile(array $payload): string
{
$phpWord = self::buildDocument($payload);
$base = tempnam(sys_get_temp_dir(), 'st-docx-');
if ($base === false) {
throw new \RuntimeException('无法创建临时文件');
}
$path = $base.'.docx';
@unlink($base);
$writer = IOFactory::createWriter($phpWord, 'Word2007');
$writer->save($path);
return $path;
}
/**
* @param array<int, StudyTour> $tours
*/
public static function writeZipFile(array $tours): string
{
$base = tempnam(sys_get_temp_dir(), 'st-zip-');
if ($base === false) {
throw new \RuntimeException('无法创建临时文件');
}
$zipPath = $base.'.zip';
@unlink($base);
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('无法创建压缩包');
}
$usedNames = [];
$tempFiles = [];
try {
foreach ($tours as $tour) {
$payload = self::payloadFromTour($tour);
$docx = self::writeDocxFile($payload);
$tempFiles[] = $docx;
$entry = self::uniqueZipEntryName(self::docxFilename($tour), $usedNames);
$usedNames[$entry] = true;
$zip->addFile($docx, $entry);
}
$zip->close();
} catch (\Throwable $e) {
$zip->close();
foreach ($tempFiles as $file) {
if (is_string($file) && is_file($file)) {
@unlink($file);
}
}
if (is_file($zipPath)) {
@unlink($zipPath);
}
throw $e;
}
foreach ($tempFiles as $file) {
if (is_string($file) && is_file($file)) {
@unlink($file);
}
}
return $zipPath;
}
/**
* @return array<string, mixed>
*/
public static function payloadFromTour(StudyTour $tour): array
{
return [
'name' => (string) ($tour->name ?? ''),
'org_name' => (string) ($tour->org_name ?? ''),
'seasons' => array_values($tour->seasons ?? []),
'venue_names' => self::venueNames($tour),
'suitable_count' => (string) ($tour->suitable_count ?? ''),
'suitable_audience' => (string) ($tour->suitable_audience ?? ''),
'duration' => (string) ($tour->duration ?? ''),
'contact_person' => (string) ($tour->contact_person ?? ''),
'contact_phones' => (string) ($tour->contact_phones ?? ''),
'cover_image_path' => self::resolvePublicImagePath($tour->cover_image),
'intro_html' => (string) ($tour->intro_html ?? ''),
'route_plans' => array_values($tour->route_plans ?? []),
'courses' => array_values($tour->courses ?? []),
'fee_html' => (string) ($tour->fee_html ?? ''),
'implementation_html' => (string) ($tour->implementation_html ?? ''),
'season_options' => self::dictOptions('study_tour_season', self::SEASON_FALLBACK),
'audience_options' => self::dictOptions('study_tour_grade_level', self::AUDIENCE_FALLBACK),
];
}
/**
* @param array<string, mixed> $payload
*/
public static function buildDocument(array $payload): PhpWord
{
$phpWord = new PhpWord();
$phpWord->setDefaultFontName('宋体');
$phpWord->setDefaultFontSize(11);
$phpWord->addTitleStyle(1, ['name' => '黑体', 'size' => 16, 'bold' => true], [
'spaceBefore' => 360,
'spaceAfter' => 160,
]);
$section = $phpWord->addSection([
'paperSize' => 'A4',
'marginTop' => 1134,
'marginBottom' => 1134,
'marginLeft' => 1134,
'marginRight' => 1134,
]);
$section->addText('研学线路申报表', ['name' => '黑体', 'size' => 18, 'bold' => true], [
'alignment' => Jc::CENTER,
'spaceAfter' => 240,
]);
$section->addTitle('一、线路基本情况', 1);
self::addBasicTable($section, $payload);
self::addCoverImage($section, $payload);
$section->addTitle('二、线路简介', 1);
self::addHtmlAsParagraphs($section, (string) ($payload['intro_html'] ?? ''));
$section->addTitle('三、线路规划', 1);
self::addRouteTable($section, is_array($payload['route_plans'] ?? null) ? $payload['route_plans'] : []);
$section->addTitle('四、研学课程', 1);
self::addCourseTable($section, is_array($payload['courses'] ?? null) ? $payload['courses'] : []);
$section->addTitle('五、线路收费标准', 1);
self::addHtmlAsParagraphs($section, (string) ($payload['fee_html'] ?? ''));
$section->addTitle('六、线路计划实施情况', 1);
self::addHtmlAsParagraphs($section, (string) ($payload['implementation_html'] ?? ''));
return $phpWord;
}
public static function htmlToPlainText(string $html): string
{
$html = trim($html);
if ($html === '') {
return '';
}
$html = preg_replace('/<(br|BR)\s*\/?>/u', "\n", $html) ?? $html;
$html = preg_replace('/<\/(p|div|li|h[1-6]|tr)[^>]*>/iu', "\n", $html) ?? $html;
$html = preg_replace('/<li[^>]*>/iu', "· ", $html) ?? $html;
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return StudyTourPayload::compactMultilineText($text);
}
public static function sanitizeFilename(string $name): string
{
$name = StudyTourPayload::compactText($name);
$name = preg_replace('/[\\\\\/:\*\?"<>\|\r\n]+/u', '_', $name) ?? $name;
$name = trim($name, ' ._');
if ($name === '') {
$name = '研学线路';
}
return mb_substr($name, 0, 80);
}
public static function resolvePublicImagePath(?string $raw): ?string
{
$raw = trim((string) $raw);
if ($raw === '') {
return null;
}
$path = $raw;
if (preg_match('#^https?://#i', $raw)) {
$path = (string) (parse_url($raw, PHP_URL_PATH) ?: '');
}
$path = rawurldecode($path);
$path = str_replace('\\', '/', $path);
if (str_starts_with($path, '/storage/')) {
$rel = ltrim(substr($path, strlen('/storage/')), '/');
} elseif (str_starts_with($path, 'storage/')) {
$rel = ltrim(substr($path, strlen('storage/')), '/');
} else {
$rel = ltrim($path, '/');
}
if ($rel === '' || str_contains($rel, '..')) {
return null;
}
$full = storage_path('app/public/'.$rel);
$real = realpath($full);
$root = realpath(storage_path('app/public'));
if ($real === false || $root === false || ! is_file($real)) {
return null;
}
if ($real !== $root && ! str_starts_with($real, $root.DIRECTORY_SEPARATOR)) {
return null;
}
return $real;
}
private static function docxFilename(StudyTour $tour): string
{
$stem = self::sanitizeFilename((string) ($tour->name ?: '研学线路-'.$tour->id));
return $stem.'.docx';
}
/**
* @param array<string, true> $usedNames
*/
private static function uniqueZipEntryName(string $filename, array $usedNames): string
{
if (! isset($usedNames[$filename])) {
return $filename;
}
$stem = preg_replace('/\.docx$/i', '', $filename) ?? $filename;
$i = 2;
do {
$candidate = $stem.'-'.$i.'.docx';
$i++;
} while (isset($usedNames[$candidate]));
return $candidate;
}
/**
* @return array<int, string>
*/
private static function venueNames(StudyTour $tour): array
{
$items = StudyTourPayload::venueItemsForRecord($tour);
$systemIds = collect($items)
->filter(fn ($item) => ($item['type'] ?? '') === 'system')
->map(fn ($item) => (int) ($item['venue_id'] ?? 0))
->filter(fn ($id) => $id > 0)
->values()
->all();
$venueMap = $systemIds === []
? collect()
: Venue::query()->whereIn('id', $systemIds)->get(['id', 'name'])->keyBy('id');
$names = [];
foreach ($items as $item) {
if (($item['type'] ?? '') === 'system') {
$id = (int) ($item['venue_id'] ?? 0);
$name = trim((string) ($venueMap->get($id)?->name ?? ''));
if ($name !== '') {
$names[] = $name;
}
continue;
}
$name = StudyTourPayload::compactText((string) ($item['name'] ?? ''));
if ($name !== '') {
$names[] = $name;
}
}
return $names;
}
/**
* @param array<int, array{value: string, label: string}> $fallback
* @return array<int, array{value: string, label: string}>
*/
private static function dictOptions(string $dictType, array $fallback): array
{
try {
$options = DictItem::activeOptions($dictType);
if ($options !== []) {
return $options;
}
} catch (\Throwable) {
// tests or missing table: use fallback
}
return $fallback;
}
/**
* @param array<string, mixed> $payload
*/
private static function addBasicTable($section, array $payload): void
{
$table = $section->addTable(self::tableStyle());
$rows = [
['组织单位名称', (string) ($payload['org_name'] ?? '')],
['线路名称', (string) ($payload['name'] ?? '')],
['对应季节(可多选)', self::checkboxLine(
is_array($payload['season_options'] ?? null) ? $payload['season_options'] : self::SEASON_FALLBACK,
is_array($payload['seasons'] ?? null) ? $payload['seasons'] : []
)],
['线路点位', implode('、', array_values($payload['venue_names'] ?? []))],
['适宜人数', (string) ($payload['suitable_count'] ?? '')],
['适配学段(可多选)', self::audienceLine($payload)],
['研学时长', (string) ($payload['duration'] ?? '')],
['线路联络人', (string) ($payload['contact_person'] ?? '')],
['咨询电话', (string) ($payload['contact_phones'] ?? '')],
];
foreach ($rows as [$label, $value]) {
$table->addRow();
self::addLabelCell($table, $label);
self::addValueCell($table, $value);
}
}
/**
* @param array<string, mixed> $payload
*/
private static function addCoverImage($section, array $payload): void
{
$path = $payload['cover_image_path'] ?? null;
$section->addText('封面图', ['bold' => true], ['spaceBefore' => 200, 'spaceAfter' => 80]);
if (! is_string($path) || $path === '' || ! is_file($path)) {
$section->addText('(未设置封面图)', ['color' => '86909C'], ['spaceAfter' => 200]);
return;
}
$size = self::coverImageSize($path);
try {
$section->addImage($path, [
'width' => $size['width'],
'height' => $size['height'],
'wrappingStyle' => 'inline',
]);
} catch (\Throwable) {
$section->addText('(封面图无法嵌入文档)', ['color' => '86909C']);
}
$section->addTextBreak(1);
}
/**
* @return array{width: int, height: int}
*/
private static function coverImageSize(string $path): array
{
$maxW = 360;
$maxH = 240;
$info = @getimagesize($path);
$w = is_array($info) ? (int) ($info[0] ?? 0) : 0;
$h = is_array($info) ? (int) ($info[1] ?? 0) : 0;
if ($w <= 0 || $h <= 0) {
return ['width' => $maxW, 'height' => 180];
}
$scale = min($maxW / $w, $maxH / $h, 1);
return [
'width' => (int) max(1, round($w * $scale)),
'height' => (int) max(1, round($h * $scale)),
];
}
private static function addHtmlAsParagraphs($section, string $html): void
{
$text = self::htmlToPlainText($html);
if ($text === '') {
$section->addText('(暂无)', ['color' => '86909C'], ['spaceAfter' => 160]);
return;
}
foreach (preg_split('/\R/u', $text) ?: [] as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$section->addText($line, [], ['spaceAfter' => 80]);
}
}
/**
* @param array<int, mixed> $plans
*/
private static function addRouteTable($section, array $plans): void
{
$table = $section->addTable(self::tableStyle());
$table->addRow();
foreach (['日期', '时间', '行程安排', '地点'] as $header) {
self::addHeaderCell($table, $header);
}
$hasRow = false;
foreach ($plans as $plan) {
if (! is_array($plan)) {
continue;
}
$dateLabel = StudyTourPayload::compactText((string) ($plan['date_label'] ?? ''));
$items = is_array($plan['items'] ?? null) ? $plan['items'] : [];
if ($items === []) {
$table->addRow();
self::addValueCell($table, $dateLabel, 1800);
self::addValueCell($table, '', 1400);
self::addValueCell($table, '', 3600);
self::addValueCell($table, '', 2200);
$hasRow = true;
continue;
}
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$table->addRow();
self::addValueCell($table, $dateLabel, 1800);
self::addValueCell($table, (string) ($item['time'] ?? ''), 1400);
self::addValueCell($table, (string) ($item['activity'] ?? ''), 3600);
self::addValueCell($table, (string) ($item['location'] ?? ''), 2200);
$hasRow = true;
}
}
if (! $hasRow) {
$table->addRow();
self::addValueCell($table, '', 1800);
self::addValueCell($table, '', 1400);
self::addValueCell($table, '', 3600);
self::addValueCell($table, '', 2200);
}
}
/**
* @param array<int, mixed> $courses
*/
private static function addCourseTable($section, array $courses): void
{
$table = $section->addTable(self::tableStyle());
$table->addRow();
foreach (['序号', '课程名称', '课程内容'] as $header) {
self::addHeaderCell($table, $header);
}
$rows = [];
foreach ($courses as $idx => $course) {
if (! is_array($course)) {
continue;
}
$rows[] = [
(string) ((int) ($course['sort'] ?? ($idx + 1))),
(string) ($course['name'] ?? ''),
(string) ($course['content'] ?? ''),
];
}
if ($rows === []) {
$rows[] = ['', '', ''];
}
foreach ($rows as [$sort, $name, $content]) {
$table->addRow();
self::addValueCell($table, $sort, 900);
self::addValueCell($table, $name, 2800);
self::addValueCell($table, $content, 5300);
}
}
/**
* @return array<string, mixed>
*/
private static function tableStyle(): array
{
return [
'borderSize' => 6,
'borderColor' => '000000',
'cellMargin' => 60,
'width' => 5000,
'unit' => TblWidth::PERCENT,
];
}
private static function addLabelCell($table, string $text): void
{
$cell = $table->addCell(2200, [
'bgColor' => 'F2F3F5',
'valign' => VerticalJc::CENTER,
]);
$cell->addText($text, ['bold' => true]);
}
private static function addHeaderCell($table, string $text): void
{
$cell = $table->addCell(null, [
'bgColor' => 'F2F3F5',
'valign' => VerticalJc::CENTER,
]);
$cell->addText($text, ['bold' => true], ['alignment' => Jc::CENTER]);
}
private static function addValueCell($table, string $text, ?int $width = 6800): void
{
$cell = $table->addCell($width ?? 6800, ['valign' => VerticalJc::CENTER]);
$text = str_replace(["\r\n", "\r"], "\n", $text);
$lines = preg_split('/\n/u', $text) ?: [$text];
$wrote = false;
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
}
$cell->addText($line);
$wrote = true;
}
if (! $wrote) {
$cell->addText('');
}
}
/**
* @param array<int, array{value: string, label: string}> $options
* @param array<int, mixed> $selected
*/
private static function checkboxLine(array $options, array $selected): string
{
$set = [];
foreach ($selected as $value) {
$set[trim((string) $value)] = true;
}
$parts = [];
foreach ($options as $opt) {
$value = (string) ($opt['value'] ?? '');
$label = (string) ($opt['label'] ?? $value);
if ($label === '') {
continue;
}
$mark = isset($set[$value]) ? '☑' : '☐';
$parts[] = $mark.$label;
}
return implode(' ', $parts);
}
/**
* @param array<string, mixed> $payload
*/
private static function audienceLine(array $payload): string
{
$raw = StudyTourPayload::compactText((string) ($payload['suitable_audience'] ?? ''));
$options = is_array($payload['audience_options'] ?? null) ? $payload['audience_options'] : self::AUDIENCE_FALLBACK;
if ($raw === '') {
return self::checkboxLine($options, []);
}
$parts = preg_split('/[,、;\s]+/u', $raw) ?: [];
$parts = array_values(array_filter(array_map('trim', $parts), fn ($p) => $p !== ''));
$valueSet = [];
foreach ($options as $opt) {
$valueSet[(string) $opt['value']] = true;
$valueSet[(string) $opt['label']] = (string) $opt['value'];
}
$selected = [];
$unmatched = [];
foreach ($parts as $part) {
if (isset($valueSet[$part]) && $valueSet[$part] === true) {
$selected[] = $part;
continue;
}
if (isset($valueSet[$part]) && is_string($valueSet[$part])) {
$selected[] = $valueSet[$part];
continue;
}
$unmatched[] = $part;
}
if ($selected === [] && $unmatched !== []) {
return $raw;
}
$line = self::checkboxLine($options, $selected);
if ($unmatched !== []) {
$extra = implode('', $unmatched);
$line = $line === '' ? $extra : $line.' '.$extra;
}
return $line;
}
}