parent
7a55165c78
commit
57a417de30
250
app/Business/CourierPayableSummaryStore.php
Normal file
250
app/Business/CourierPayableSummaryStore.php
Normal file
@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\StringValueBinder;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class CourierPayableSummaryStore
|
||||
{
|
||||
public const PAGES = [
|
||||
'freight-difference' => '运费差',
|
||||
'other-surcharge-summary' => '其他附加费汇总',
|
||||
];
|
||||
|
||||
private const MAX_COLUMNS = 200;
|
||||
|
||||
private const MAX_ROWS = 10000;
|
||||
|
||||
private const MAX_CELL_LENGTH = 10000;
|
||||
|
||||
public function title(string $page): string
|
||||
{
|
||||
abort_unless(isset(self::PAGES[$page]), 404);
|
||||
|
||||
return self::PAGES[$page];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{version:int,headers:list<string>,rows:list<list<string>>,source_name:string,imported_at:string,imported_by:int|null}|null
|
||||
*/
|
||||
public function snapshot(int $companyId, string $page): ?array
|
||||
{
|
||||
$this->title($page);
|
||||
$path = $this->path($companyId, $page);
|
||||
$disk = Storage::disk('uploads');
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$snapshot = json_decode($disk->get($path), true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
throw new RuntimeException('courier_payable_summary_unreadable', previous: $exception);
|
||||
}
|
||||
|
||||
if (! is_array($snapshot)
|
||||
|| ! is_array($snapshot['headers'] ?? null)
|
||||
|| ! is_array($snapshot['rows'] ?? null)) {
|
||||
throw new RuntimeException('courier_payable_summary_invalid');
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/** @return array{rows:int,columns:int} */
|
||||
public function import(UploadedFile $file, int $companyId, string $page, ?int $userId): array
|
||||
{
|
||||
$this->title($page);
|
||||
$valueBinder = Cell::getValueBinder();
|
||||
Cell::setValueBinder(new StringValueBinder());
|
||||
|
||||
try {
|
||||
$reader = IOFactory::createReaderForFile($file->getRealPath());
|
||||
$reader->setReadDataOnly(true);
|
||||
$spreadsheet = $reader->load($file->getRealPath());
|
||||
[$headers, $rows] = $this->readSpreadsheet($spreadsheet);
|
||||
} catch (ValidationException $exception) {
|
||||
throw $exception;
|
||||
} catch (Throwable $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
|
||||
]);
|
||||
} finally {
|
||||
Cell::setValueBinder($valueBinder);
|
||||
|
||||
if (isset($spreadsheet)) {
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'version' => 1,
|
||||
'headers' => $headers,
|
||||
'rows' => $rows,
|
||||
'source_name' => mb_substr($file->getClientOriginalName(), 0, 255),
|
||||
'imported_at' => now()->toIso8601String(),
|
||||
'imported_by' => $userId,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
|
||||
|
||||
$disk = Storage::disk('uploads');
|
||||
$path = $this->path($companyId, $page);
|
||||
$previousPath = $this->previousPath($companyId, $page);
|
||||
|
||||
if ($disk->exists($path)) {
|
||||
$previous = $disk->get($path);
|
||||
|
||||
if (! $disk->put($previousPath, $previous)) {
|
||||
throw new RuntimeException('无法备份当前汇总数据。');
|
||||
}
|
||||
}
|
||||
|
||||
if (! $disk->put($path, $payload)) {
|
||||
throw new RuntimeException('无法保存汇总数据。');
|
||||
}
|
||||
|
||||
return ['rows' => count($rows), 'columns' => count($headers)];
|
||||
}
|
||||
|
||||
public function path(int $companyId, string $page): string
|
||||
{
|
||||
$this->title($page);
|
||||
|
||||
return "courier-payable-summaries/{$companyId}/{$page}.json";
|
||||
}
|
||||
|
||||
private function previousPath(int $companyId, string $page): string
|
||||
{
|
||||
return "courier-payable-summaries/{$companyId}/{$page}.previous.json";
|
||||
}
|
||||
|
||||
/** @return array{0:list<string>,1:list<list<string>>} */
|
||||
private function readSpreadsheet($spreadsheet): array
|
||||
{
|
||||
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
|
||||
$rawRows = $worksheet->toArray(null, false, true, false);
|
||||
$headerIndex = $this->headerIndex($rawRows);
|
||||
|
||||
if ($headerIndex === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lastColumn = $this->lastUsedColumn($rawRows, $headerIndex);
|
||||
|
||||
if ($lastColumn >= self::MAX_COLUMNS) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '单个汇总表最多支持 ' . self::MAX_COLUMNS . ' 列。',
|
||||
]);
|
||||
}
|
||||
|
||||
$headers = [];
|
||||
for ($column = 0; $column <= $lastColumn; $column++) {
|
||||
$label = $this->cell($rawRows[$headerIndex][$column] ?? null);
|
||||
$headers[] = $label !== '' ? $label : '未命名列' . ($column + 1);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach (array_slice($rawRows, $headerIndex + 1) as $rawRow) {
|
||||
$row = [];
|
||||
$hasValue = false;
|
||||
|
||||
for ($column = 0; $column <= $lastColumn; $column++) {
|
||||
$value = $this->cell($rawRow[$column] ?? null);
|
||||
$row[] = $value;
|
||||
$hasValue = $hasValue || $value !== '';
|
||||
}
|
||||
|
||||
if (! $hasValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = $row;
|
||||
|
||||
if (count($rows) > self::MAX_ROWS) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '单次最多导入 ' . number_format(self::MAX_ROWS) . ' 条汇总数据,请拆分文件后重试。',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
throw ValidationException::withMessages(['import' => '表格中没有可导入的数据行。']);
|
||||
}
|
||||
|
||||
return [$headers, $rows];
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages(['import' => '没有找到有效的表头和数据。']);
|
||||
}
|
||||
|
||||
private function headerIndex(array $rows): ?int
|
||||
{
|
||||
$firstNonEmpty = null;
|
||||
|
||||
foreach (array_slice($rows, 0, 20, true) as $index => $row) {
|
||||
$count = count(array_filter(
|
||||
$row,
|
||||
fn (mixed $value): bool => $this->cell($value) !== '',
|
||||
));
|
||||
|
||||
if ($count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$firstNonEmpty ??= $index;
|
||||
|
||||
if ($count >= 2) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
|
||||
return $firstNonEmpty;
|
||||
}
|
||||
|
||||
private function lastUsedColumn(array $rows, int $headerIndex): int
|
||||
{
|
||||
$lastColumn = -1;
|
||||
|
||||
foreach (array_slice($rows, $headerIndex) as $row) {
|
||||
foreach ($row as $column => $value) {
|
||||
if ($this->cell($value) !== '') {
|
||||
$lastColumn = max($lastColumn, (int) $column);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return max($lastColumn, 0);
|
||||
}
|
||||
|
||||
private function cell(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? '是' : '否';
|
||||
}
|
||||
|
||||
if (! is_scalar($value) && ! $value instanceof \Stringable) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
|
||||
if (mb_strlen($value) > self::MAX_CELL_LENGTH) {
|
||||
throw ValidationException::withMessages(['import' => '单元格内容过长,请精简后重试。']);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
114
app/Http/Controllers/Business/CourierPayableSummaries.php
Normal file
114
app/Http/Controllers/Business/CourierPayableSummaries.php
Normal file
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Business\CourierPayableSummaryStore;
|
||||
use App\Utilities\SidebarPermissions;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class CourierPayableSummaries extends Controller
|
||||
{
|
||||
public function __construct(private readonly CourierPayableSummaryStore $store)
|
||||
{
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
}
|
||||
|
||||
public function import(Request $request, string $summaryPage): RedirectResponse
|
||||
{
|
||||
$this->authorizePage($summaryPage);
|
||||
$this->store->title($summaryPage);
|
||||
$request->validate([
|
||||
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->store->import(
|
||||
$request->file('import'),
|
||||
(int) (company_id() ?: 1),
|
||||
$summaryPage,
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
report($exception);
|
||||
|
||||
return $this->redirectToPage($summaryPage)->with('error', '汇总数据保存失败,请稍后重试。');
|
||||
}
|
||||
|
||||
return $this->redirectToPage($summaryPage)->with(
|
||||
'success',
|
||||
"导入完成:{$result['rows']} 条数据,{$result['columns']} 个字段。",
|
||||
);
|
||||
}
|
||||
|
||||
public function export(string $summaryPage): StreamedResponse|RedirectResponse
|
||||
{
|
||||
$this->authorizePage($summaryPage);
|
||||
$title = $this->store->title($summaryPage);
|
||||
|
||||
try {
|
||||
$snapshot = $this->store->snapshot((int) (company_id() ?: 1), $summaryPage);
|
||||
} catch (RuntimeException) {
|
||||
return $this->redirectToPage($summaryPage)->with('error', '汇总数据暂时无法读取,请重新导入后再试。');
|
||||
}
|
||||
|
||||
if ($snapshot === null) {
|
||||
return $this->redirectToPage($summaryPage)->with('error', '当前没有可导出的汇总数据。');
|
||||
}
|
||||
|
||||
return response()->streamDownload(function () use ($snapshot): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
|
||||
if ($csv === false) {
|
||||
throw new RuntimeException('无法创建汇总数据导出流。');
|
||||
}
|
||||
|
||||
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fputcsv($csv, array_map([$this, 'csvText'], $snapshot['headers']));
|
||||
|
||||
foreach ($snapshot['rows'] as $row) {
|
||||
fputcsv($csv, array_map([$this, 'csvText'], $row));
|
||||
}
|
||||
|
||||
fclose($csv);
|
||||
}, $title . '_' . now()->format('Ymd_His') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
private function redirectToPage(string $summaryPage): RedirectResponse
|
||||
{
|
||||
return redirect()->route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => $summaryPage,
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizePage(string $summaryPage): void
|
||||
{
|
||||
$section = collect(SidebarPermissions::navigationFor(user()))
|
||||
->firstWhere('slug', 'courier-payables');
|
||||
$pages = collect($section['pages'] ?? [])->flatMap(static fn (array $page): array => [
|
||||
$page,
|
||||
...($page['children'] ?? []),
|
||||
]);
|
||||
|
||||
abort_unless($pages->contains('slug', $summaryPage), 403, '当前账号没有该页面权限。');
|
||||
}
|
||||
|
||||
private function csvText(mixed $value): string
|
||||
{
|
||||
$value = (string) $value;
|
||||
|
||||
if (preg_match('/^[+-]?(?:(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|\.\d+)%?$/u', $value) === 1) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value;
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Business\BusinessTableImporter;
|
||||
use App\Business\CourierPayableSummaryStore;
|
||||
use App\Business\FundForecastTemplate;
|
||||
use App\Business\TableRegistry;
|
||||
use App\Business\UpstreamBusinessInformation;
|
||||
@ -52,6 +53,7 @@ use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
|
||||
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
|
||||
use Modules\EcommerceFinance\View\PlatformPromotionAccountPageData;
|
||||
use Modules\EcommerceFinance\View\OrderDataPageData;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
||||
use Throwable;
|
||||
@ -632,12 +634,47 @@ class Pages extends Controller
|
||||
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
|
||||
'online-daifa-price' => $this->contentForOnlineDaifaPrice(),
|
||||
'courier-surcharge' => $this->contentForCourierSurcharge(),
|
||||
'freight-difference', 'other-surcharge-summary' => $this->contentForCourierPayableSummary($page),
|
||||
'withdrawal-management' => $this->contentForWithdrawalManagement(),
|
||||
'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
protected function contentForCourierPayableSummary(string $page): array
|
||||
{
|
||||
$store = app(CourierPayableSummaryStore::class);
|
||||
$snapshot = null;
|
||||
$error = null;
|
||||
|
||||
try {
|
||||
$snapshot = $store->snapshot((int) (company_id() ?: 1), $page);
|
||||
} catch (RuntimeException) {
|
||||
$error = '汇总数据暂时无法读取,请重新导入后再试。';
|
||||
}
|
||||
|
||||
$allRows = collect($snapshot['rows'] ?? []);
|
||||
$currentPage = max((int) request()->query('page', 1), 1);
|
||||
$perPage = 50;
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.courier-payables.summary-table',
|
||||
'courierSummaryPage' => $page,
|
||||
'courierSummaryTitle' => $store->title($page),
|
||||
'courierSummaryHeaders' => $snapshot['headers'] ?? [],
|
||||
'courierSummaryRows' => new LengthAwarePaginator(
|
||||
$allRows->forPage($currentPage, $perPage)->values(),
|
||||
$allRows->count(),
|
||||
$perPage,
|
||||
$currentPage,
|
||||
['path' => request()->url(), 'query' => request()->except('page')],
|
||||
),
|
||||
'courierSummarySourceName' => $snapshot['source_name'] ?? null,
|
||||
'courierSummaryImportedAt' => $snapshot['imported_at'] ?? null,
|
||||
'courierSummaryError' => $error,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated, non-destructive view of the withdrawal configuration kept
|
||||
* with each store receiving account. Updates remain in the source page so
|
||||
|
||||
@ -506,6 +506,8 @@ $navigation = [
|
||||
['slug' => 'courier-surcharge', 'title' => '附加费报价维护'],
|
||||
['slug' => 'payable-cloud-warehouse-vehicle-fee', 'title' => '应付云仓车费'],
|
||||
['slug' => 'courier-payables-report', 'title' => '快递应付表'],
|
||||
['slug' => 'freight-difference', 'title' => '运费差'],
|
||||
['slug' => 'other-surcharge-summary', 'title' => '其他附加费汇总'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@ -0,0 +1,108 @@
|
||||
<section class="courier-summary" data-courier-payable-summary="{{ $courierSummaryPage }}" aria-label="{{ $courierSummaryTitle }}">
|
||||
<header class="courier-summary__toolbar">
|
||||
<div class="courier-summary__heading">
|
||||
<strong>{{ $courierSummaryTitle }}</strong>
|
||||
@if ($courierSummarySourceName)
|
||||
<span>{{ $courierSummarySourceName }} · 共 {{ number_format($courierSummaryRows->total()) }} 条</span>
|
||||
@else
|
||||
<span>共 0 条</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="courier-summary__actions">
|
||||
<form method="POST" enctype="multipart/form-data" action="{{ route('business.courier-payable-summaries.import', ['summaryPage' => $courierSummaryPage]) }}">
|
||||
@csrf
|
||||
<input id="courier-summary-import-{{ $courierSummaryPage }}" type="file" name="import" accept=".xlsx,.xls,.csv" required hidden onchange="this.form.submit()">
|
||||
<label class="courier-summary__button" for="courier-summary-import-{{ $courierSummaryPage }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
|
||||
<span>导入</span>
|
||||
</label>
|
||||
</form>
|
||||
<a
|
||||
href="{{ route('business.courier-payable-summaries.export', ['summaryPage' => $courierSummaryPage]) }}"
|
||||
@class(['courier-summary__button', 'is-disabled' => $courierSummaryRows->total() === 0])
|
||||
@if ($courierSummaryRows->total() === 0) aria-disabled="true" tabindex="-1" @endif
|
||||
>
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span>
|
||||
<span>导出</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (is_string(session('success')))
|
||||
<div class="courier-summary__notice is-success" role="status">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
@if (is_string(session('error')))
|
||||
<div class="courier-summary__notice is-error" role="alert">{{ session('error') }}</div>
|
||||
@endif
|
||||
|
||||
@if ($errors->has('import'))
|
||||
<div class="courier-summary__notice is-error" role="alert">{{ $errors->first('import') }}</div>
|
||||
@endif
|
||||
|
||||
@if ($courierSummaryError)
|
||||
<div class="courier-summary__notice is-error" role="alert">{{ $courierSummaryError }}</div>
|
||||
@endif
|
||||
|
||||
<div class="courier-summary__table-wrap">
|
||||
<table class="courier-summary__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="courier-summary__index">序号</th>
|
||||
@foreach ($courierSummaryHeaders as $header)
|
||||
<th>{{ $header }}</th>
|
||||
@endforeach
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($courierSummaryRows as $rowIndex => $row)
|
||||
<tr>
|
||||
<td class="courier-summary__index">{{ ($courierSummaryRows->currentPage() - 1) * $courierSummaryRows->perPage() + $rowIndex + 1 }}</td>
|
||||
@foreach ($courierSummaryHeaders as $columnIndex => $header)
|
||||
<td title="{{ $row[$columnIndex] ?? '' }}">{{ ($row[$columnIndex] ?? '') !== '' ? $row[$columnIndex] : '-' }}</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@empty
|
||||
<tr class="courier-summary__empty">
|
||||
<td colspan="{{ max(count($courierSummaryHeaders) + 1, 1) }}">暂无数据,请先点击“导入”上传汇总表。</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if ($courierSummaryRows->hasPages())
|
||||
<footer class="courier-summary__pagination">
|
||||
<span>第 {{ $courierSummaryRows->currentPage() }} / {{ $courierSummaryRows->lastPage() }} 页</span>
|
||||
<span>{{ $courierSummaryRows->onEachSide(1)->links() }}</span>
|
||||
</footer>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.courier-summary { overflow:hidden; border:1px solid var(--vip-border,#d8d2c7); border-radius:8px; background:var(--vip-surface,#fff); color:var(--vip-content-ink,#171717); }
|
||||
.courier-summary__toolbar { display:flex; min-height:52px; align-items:center; justify-content:space-between; gap:16px; padding:8px 12px; border-bottom:1px solid var(--vip-border,#d8d2c7); background:var(--vip-content-bg,#f8f6f1); }
|
||||
.courier-summary__heading { min-width:0; }
|
||||
.courier-summary__heading strong { display:block; font-size:.875rem; font-weight:600; line-height:1.25; }
|
||||
.courier-summary__heading span { display:block; overflow:hidden; margin-top:3px; color:var(--vip-content-muted,#595959); font-size:.75rem; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.courier-summary__actions { display:flex; flex:0 0 auto; align-items:center; gap:8px; }
|
||||
.courier-summary__actions form { margin:0; }
|
||||
.courier-summary__button { display:inline-flex; height:34px; align-items:center; gap:6px; padding:0 12px; border:1px solid var(--vip-content-accent,#b9954f); border-radius:8px; background:#fff; color:var(--vip-content-accent-dark,#856632); font-size:.8125rem; font-weight:500; text-decoration:none; cursor:pointer; }
|
||||
.courier-summary__button:hover { background:var(--vip-content-accent-soft,#f7f0de); color:var(--vip-content-accent-dark,#856632); text-decoration:none; }
|
||||
.courier-summary__button:focus-visible { outline:0; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
|
||||
.courier-summary__button .material-icons-outlined { font-size:17px; }
|
||||
.courier-summary__button.is-disabled { pointer-events:none; opacity:.45; }
|
||||
.courier-summary__notice { padding:10px 12px; border-bottom:1px solid var(--vip-border,#d8d2c7); font-size:.75rem; }
|
||||
.courier-summary__notice.is-success { background:#f3f8ef; color:var(--vip-success,#53793e); }
|
||||
.courier-summary__notice.is-error { background:#fff5f5; color:var(--vip-danger,#b80000); }
|
||||
.courier-summary__table-wrap { max-height:calc(100vh - 315px); min-height:180px; overflow:auto; }
|
||||
.courier-summary__table { width:max-content; min-width:100%; border-collapse:collapse; font-size:.78125rem; font-variant-numeric:tabular-nums; }
|
||||
.courier-summary__table th,.courier-summary__table td { min-width:128px; height:38px; padding:7px 10px; border-right:1px solid var(--vip-border,#d8d2c7); border-bottom:1px solid var(--vip-border,#d8d2c7); text-align:left; white-space:nowrap; }
|
||||
.courier-summary__table th { position:sticky; top:0; z-index:2; background:var(--vip-content-accent-soft,#f7f0de); font-size:.75rem; font-weight:600; }
|
||||
.courier-summary__table tr:hover td { background:#fbf8f0; }
|
||||
.courier-summary__table .courier-summary__index { left:0; z-index:3; min-width:56px; width:56px; text-align:center; }
|
||||
.courier-summary__table td.courier-summary__index { position:sticky; background:#fff; }
|
||||
.courier-summary__empty td { height:160px; color:var(--vip-content-muted,#595959); text-align:center; }
|
||||
.courier-summary__pagination { display:flex; min-height:44px; align-items:center; justify-content:space-between; gap:16px; padding:6px 12px; color:var(--vip-content-muted,#595959); font-size:.75rem; }
|
||||
@media (max-width:700px) { .courier-summary__toolbar { align-items:flex-start; flex-direction:column; } .courier-summary__actions { width:100%; } }
|
||||
</style>
|
||||
@ -3,6 +3,7 @@
|
||||
use App\Http\Controllers\Business\CourierQuoteChanges;
|
||||
use App\Http\Controllers\Business\CourierQuoteSignatures;
|
||||
use App\Http\Controllers\Business\CourierAgreementSignatures;
|
||||
use App\Http\Controllers\Business\CourierPayableSummaries;
|
||||
use App\Http\Controllers\Business\CourierSurchargeQuotes;
|
||||
use App\Http\Controllers\Business\CourierSurchargeTypes;
|
||||
use App\Http\Controllers\Business\ProductPrices;
|
||||
@ -196,6 +197,13 @@ Route::prefix('workspace/courier-payables/courier-surcharge')
|
||||
Route::post('types/{type}/status', [CourierSurchargeTypes::class, 'setStatus'])->name('types.status');
|
||||
});
|
||||
|
||||
Route::post('workspace/courier-payables/{summaryPage}/summary-import', [CourierPayableSummaries::class, 'import'])
|
||||
->where('summaryPage', 'freight-difference|other-surcharge-summary')
|
||||
->name('business.courier-payable-summaries.import');
|
||||
Route::get('workspace/courier-payables/{summaryPage}/summary-export', [CourierPayableSummaries::class, 'export'])
|
||||
->where('summaryPage', 'freight-difference|other-surcharge-summary')
|
||||
->name('business.courier-payable-summaries.export');
|
||||
|
||||
Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show')
|
||||
->name('business.pages.show');
|
||||
|
||||
|
||||
108
tests/Feature/Business/CourierPayableSummaryPagesTest.php
Normal file
108
tests/Feature/Business/CourierPayableSummaryPagesTest.php
Normal file
@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Business\CourierPayableSummaryStore;
|
||||
use App\Models\Auth\Permission;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class CourierPayableSummaryPagesTest extends FeatureTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
Storage::fake('uploads');
|
||||
}
|
||||
|
||||
public function test_final_workspace_places_both_summary_pages_after_courier_payables_report(): void
|
||||
{
|
||||
$response = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables-final',
|
||||
]));
|
||||
|
||||
$response->assertOk()->assertSeeInOrder([
|
||||
route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-payables-report']),
|
||||
route('business.pages.show', ['section' => 'courier-payables', 'page' => 'freight-difference']),
|
||||
route('business.pages.show', ['section' => 'courier-payables', 'page' => 'other-surcharge-summary']),
|
||||
], false);
|
||||
}
|
||||
|
||||
public function test_summary_pages_render_import_and_export_buttons(): void
|
||||
{
|
||||
foreach (CourierPayableSummaryStore::PAGES as $page => $title) {
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => $page,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSee("data-courier-payable-summary=\"{$page}\"", false)
|
||||
->assertSeeText($title)
|
||||
->assertSeeText('导入')
|
||||
->assertSeeText('导出')
|
||||
->assertSeeText('暂无数据,请先点击“导入”上传汇总表。');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_import_replaces_the_page_snapshot_and_export_returns_all_rows(): void
|
||||
{
|
||||
$file = UploadedFile::fake()->createWithContent(
|
||||
'运费差.csv',
|
||||
"快递公司,结算月份,运费差,备注\n测试快递,2026-08,-18.50,复核\n第二快递,2026-08,20,=待确认\n",
|
||||
);
|
||||
|
||||
$this->loginAs()->post(route('business.courier-payable-summaries.import', [
|
||||
'summaryPage' => 'freight-difference',
|
||||
]), ['import' => $file])
|
||||
->assertRedirect(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'freight-difference',
|
||||
]))
|
||||
->assertSessionHas('success', '导入完成:2 条数据,4 个字段。');
|
||||
|
||||
Storage::disk('uploads')->assertExists(
|
||||
app(CourierPayableSummaryStore::class)->path((int) $this->company->id, 'freight-difference')
|
||||
);
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'freight-difference',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('导入完成:2 条数据,4 个字段。')
|
||||
->assertSeeText('测试快递')
|
||||
->assertSeeText('-18.50')
|
||||
->assertSeeText('第二快递');
|
||||
|
||||
$response = $this->loginAs()->get(route('business.courier-payable-summaries.export', [
|
||||
'summaryPage' => 'freight-difference',
|
||||
]));
|
||||
$content = $response->streamedContent();
|
||||
|
||||
$response->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8');
|
||||
$this->assertStringContainsString('快递公司,结算月份,运费差,备注', $content);
|
||||
$this->assertStringContainsString('测试快递,2026-08,-18.50,复核', $content);
|
||||
$this->assertStringContainsString("\t=待确认", $content);
|
||||
}
|
||||
|
||||
public function test_summary_actions_require_the_matching_page_permission(): void
|
||||
{
|
||||
$this->withExceptionHandling();
|
||||
$this->user->roles()->sync([
|
||||
\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id,
|
||||
]);
|
||||
setting()->set('system.sidebar_permissions', [
|
||||
'configured' => ['user_' . $this->user->id],
|
||||
'subjects' => ['user_' . $this->user->id => ['courier-payables/courier-information']],
|
||||
]);
|
||||
|
||||
$this->loginAs()->get(route('business.courier-payable-summaries.export', [
|
||||
'summaryPage' => 'freight-difference',
|
||||
]))->assertForbidden();
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user