新增运费差导入模板下载 (#209)

This commit is contained in:
renshen 2026-09-20 10:05:55 +08:00
parent 1751d8e062
commit 9846f21297
5 changed files with 282 additions and 1 deletions

View File

@ -8,6 +8,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use RuntimeException;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierPayableSummaries extends Controller
@ -45,6 +46,26 @@ class CourierPayableSummaries extends Controller
);
}
public function template(string $summaryPage): BinaryFileResponse
{
$this->authorizePage($summaryPage);
abort_unless($summaryPage === 'freight-difference', 404);
$path = resource_path('templates/courier-payables/freight-difference-import-template.xlsx');
abort_unless(is_file($path), 404);
$response = response()->download($path, 'freight_difference_import_template.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
return $response->setContentDisposition(
'attachment',
'运费差导入模板.xlsx',
'freight_difference_import_template.xlsx',
);
}
public function export(string $summaryPage): StreamedResponse|RedirectResponse
{
$this->authorizePage($summaryPage);

View File

@ -9,6 +9,12 @@
@endif
</div>
<div class="courier-summary__actions">
@if ($courierSummaryPage === 'freight-difference')
<button type="button" class="courier-summary__button" data-courier-summary-import-open>
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
<span>导入</span>
</button>
@else
<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()">
@ -17,6 +23,7 @@
<span>导入</span>
</label>
</form>
@endif
<a
href="{{ route('business.courier-payable-summaries.export', ['summaryPage' => $courierSummaryPage]) }}"
@class(['courier-summary__button', 'is-disabled' => $courierSummaryRows->total() === 0])
@ -77,6 +84,61 @@
<span>{{ $courierSummaryRows->onEachSide(1)->links() }}</span>
</footer>
@endif
@if ($courierSummaryPage === 'freight-difference')
<dialog class="courier-summary__import-dialog" data-courier-summary-import-dialog aria-labelledby="courier-summary-import-title">
<form
method="POST"
enctype="multipart/form-data"
action="{{ route('business.courier-payable-summaries.import', ['summaryPage' => $courierSummaryPage]) }}"
data-courier-summary-import-form
>
@csrf
<header class="courier-summary__import-header">
<div>
<strong id="courier-summary-import-title">导入运费差</strong>
<span>支持 .xlsx、.xls、.csv,单个文件不超过 10 MB</span>
</div>
<button type="button" class="courier-summary__icon-button" data-courier-summary-import-close aria-label="关闭导入窗口" title="关闭">
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
</header>
<div class="courier-summary__import-body">
<div class="courier-summary__template-row">
<span class="material-icons-outlined" aria-hidden="true">description</span>
<div>
<strong>运费差导入模板</strong>
<span>.xlsx · 10 个字段</span>
</div>
<a class="courier-summary__button" href="{{ route('business.courier-payable-summaries.template', ['summaryPage' => $courierSummaryPage]) }}">
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span>下载模板</span>
</a>
</div>
<label class="courier-summary__file-picker" for="courier-summary-import-{{ $courierSummaryPage }}" role="button" tabindex="0" data-courier-summary-file-picker>
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
<span data-courier-summary-file-name>选择填写完成的文件</span>
</label>
<input
id="courier-summary-import-{{ $courierSummaryPage }}"
type="file"
name="import"
accept=".xlsx,.xls,.csv"
required
hidden
data-courier-summary-file-input
>
</div>
<footer class="courier-summary__import-footer">
<button type="button" class="courier-summary__button" data-courier-summary-import-close>取消</button>
<button type="submit" class="courier-summary__button is-primary" data-courier-summary-import-submit disabled>
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
<span>导入</span>
</button>
</footer>
</form>
</dialog>
@endif
</section>
<style>
@ -91,6 +153,9 @@
.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-primary { border-color:var(--vip-content-accent-dark,#856632); background:var(--vip-content-accent-dark,#856632); color:#fff; }
.courier-summary__button.is-primary:hover { background:#6a5129; color:#fff; }
.courier-summary__button:disabled { cursor:not-allowed; opacity:.45; }
.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); }
@ -104,5 +169,63 @@
.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%; } }
.courier-summary__import-dialog { width:min(520px,calc(100vw - 32px)); padding:0; overflow:hidden; border:1px solid var(--vip-border,#d8d2c7); border-radius:8px; background:#fff; color:var(--vip-content-ink,#171717); box-shadow:0 4px 8px rgba(23,23,23,.1); }
.courier-summary__import-dialog::backdrop { background:rgba(5,5,6,.45); }
.courier-summary__import-header { display:flex; min-height:60px; align-items:center; justify-content:space-between; gap:16px; padding:12px 16px; border-bottom:1px solid var(--vip-border,#d8d2c7); background:var(--vip-content-bg,#f8f6f1); }
.courier-summary__import-header strong,.courier-summary__import-header span { display:block; }
.courier-summary__import-header strong { font-size:.875rem; }
.courier-summary__import-header div>span { margin-top:3px; color:var(--vip-content-muted,#595959); font-size:.75rem; }
.courier-summary__icon-button { display:inline-flex; width:34px; height:34px; flex:0 0 34px; align-items:center; justify-content:center; padding:0; border:0; border-radius:4px; background:transparent; color:var(--vip-content-muted,#595959); cursor:pointer; }
.courier-summary__icon-button:hover { background:var(--vip-content-accent-soft,#f7f0de); color:var(--vip-content-accent-dark,#856632); }
.courier-summary__icon-button:focus-visible { outline:0; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
.courier-summary__import-body { display:grid; gap:12px; padding:16px; }
.courier-summary__template-row { display:grid; grid-template-columns:34px minmax(0,1fr) auto; align-items:center; gap:10px; min-height:54px; padding:9px 10px; border:1px solid var(--vip-border,#d8d2c7); border-radius:8px; }
.courier-summary__template-row>.material-icons-outlined { color:var(--vip-content-accent-dark,#856632); }
.courier-summary__template-row strong,.courier-summary__template-row div>span { display:block; }
.courier-summary__template-row strong { font-size:.8125rem; }
.courier-summary__template-row div>span { margin-top:2px; color:var(--vip-content-muted,#595959); font-size:.75rem; }
.courier-summary__file-picker { display:flex; min-height:68px; align-items:center; justify-content:center; gap:8px; padding:12px; border:1px dashed var(--vip-content-accent,#b9954f); border-radius:8px; background:var(--vip-content-accent-soft,#f7f0de); color:var(--vip-content-accent-dark,#856632); font-size:.8125rem; font-weight:500; cursor:pointer; }
.courier-summary__file-picker:hover { border-color:var(--vip-content-accent-dark,#856632); }
.courier-summary__file-picker:focus-within { box-shadow:0 0 0 3px rgba(133,102,50,.2); }
.courier-summary__import-footer { display:flex; min-height:58px; align-items:center; justify-content:flex-end; gap:8px; padding:12px 16px; border-top:1px solid var(--vip-border,#d8d2c7); background:var(--vip-content-bg,#f8f6f1); }
@media (max-width:700px) { .courier-summary__toolbar { align-items:flex-start; flex-direction:column; } .courier-summary__actions { width:100%; } .courier-summary__template-row { grid-template-columns:28px minmax(0,1fr); } .courier-summary__template-row .courier-summary__button { grid-column:1/-1; justify-content:center; } }
</style>
@if ($courierSummaryPage === 'freight-difference')
<script>
(() => {
const root = document.querySelector('[data-courier-payable-summary="freight-difference"]');
if (!root) return;
const dialog = root.querySelector('[data-courier-summary-import-dialog]');
const openButton = root.querySelector('[data-courier-summary-import-open]');
const closeButtons = [...root.querySelectorAll('[data-courier-summary-import-close]')];
const filePicker = root.querySelector('[data-courier-summary-file-picker]');
const fileInput = root.querySelector('[data-courier-summary-file-input]');
const fileName = root.querySelector('[data-courier-summary-file-name]');
const submitButton = root.querySelector('[data-courier-summary-import-submit]');
const form = root.querySelector('[data-courier-summary-import-form]');
const close = () => dialog.close();
openButton.addEventListener('click', () => dialog.showModal());
closeButtons.forEach(button => button.addEventListener('click', close));
dialog.addEventListener('click', event => {
if (event.target === dialog) close();
});
filePicker.addEventListener('keydown', event => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
fileInput.click();
});
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
fileName.textContent = file ? file.name : '选择填写完成的文件';
submitButton.disabled = !file;
});
form.addEventListener('submit', () => {
submitButton.disabled = true;
submitButton.querySelector('span:last-child').textContent = '正在导入';
});
})();
</script>
@endif

View File

@ -200,6 +200,9 @@ Route::prefix('workspace/courier-payables/courier-surcharge')
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-template', [CourierPayableSummaries::class, 'template'])
->where('summaryPage', 'freight-difference')
->name('business.courier-payable-summaries.template');
Route::get('workspace/courier-payables/{summaryPage}/summary-export', [CourierPayableSummaries::class, 'export'])
->where('summaryPage', 'freight-difference|other-surcharge-summary')
->name('business.courier-payable-summaries.export');

View File

@ -6,6 +6,8 @@ use App\Business\CourierPayableSummaryStore;
use App\Models\Auth\Permission;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Tests\Feature\FeatureTestCase;
class CourierPayableSummaryPagesTest extends FeatureTestCase
@ -49,6 +51,134 @@ class CourierPayableSummaryPagesTest extends FeatureTestCase
}
}
public function test_freight_difference_import_dialog_downloads_the_supplied_xlsx_template(): void
{
$pageResponse = $this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'freight-difference',
]));
$pageResponse
->assertOk()
->assertSee('data-courier-summary-import-dialog', false)
->assertSeeText('下载模板')
->assertSee(route('business.courier-payable-summaries.template', [
'summaryPage' => 'freight-difference',
]), false);
$templateResponse = $this->loginAs()->get(route('business.courier-payable-summaries.template', [
'summaryPage' => 'freight-difference',
]));
$templateResponse
->assertOk()
->assertDownload('freight_difference_import_template.xlsx')
->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$this->assertStringContainsString(
"filename*=utf-8''" . rawurlencode('运费差导入模板.xlsx'),
(string) $templateResponse->headers->get('Content-Disposition'),
);
$spreadsheet = IOFactory::load(resource_path('templates/courier-payables/freight-difference-import-template.xlsx'));
$worksheet = $spreadsheet->getActiveSheet();
$this->assertSame([
'负责人',
'快递公司',
'快递单号',
'店铺',
'id',
'条码',
'交易单号',
'差异(结算运费-提供成本运费)',
'是否配件',
'配件成品条码',
], $worksheet->rangeToArray('A1:J1', null, false, false)[0]);
$this->assertSame('FFFFC000', $worksheet->getStyle('D1')->getFill()->getStartColor()->getARGB());
$this->assertSame('FFFFC000', $worksheet->getStyle('E1')->getFill()->getStartColor()->getARGB());
$this->assertSame('FFFFC000', $worksheet->getStyle('G1')->getFill()->getStartColor()->getARGB());
$this->assertSame('@', $worksheet->getStyle('E2')->getNumberFormat()->getFormatCode());
$this->assertSame('@', $worksheet->getStyle('G2')->getNumberFormat()->getFormatCode());
$this->assertSame('', (string) $worksheet->getCell('A2')->getValue());
$spreadsheet->disconnectWorksheets();
}
public function test_other_surcharge_page_does_not_offer_the_freight_difference_template(): void
{
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'other-surcharge-summary',
]))
->assertOk()
->assertDontSeeText('下载模板')
->assertDontSee('data-courier-summary-import-dialog', false);
}
public function test_completed_freight_difference_template_preserves_long_identifiers_on_import(): void
{
$spreadsheet = IOFactory::load(resource_path('templates/courier-payables/freight-difference-import-template.xlsx'));
$worksheet = $spreadsheet->getActiveSheet();
$worksheet->fromArray([
'苏木',
'测试快递',
'YT7632543323146',
'测试店铺',
'855226246304',
'DNZ0117123N1210V01',
'3312228147193110069',
0.1827,
'成品',
'',
], null, 'A2');
$worksheet->getCell('E2')->setValueExplicit('855226246304', DataType::TYPE_STRING);
$worksheet->getCell('G2')->setValueExplicit('3312228147193110069', DataType::TYPE_STRING);
$path = tempnam(sys_get_temp_dir(), 'freight-template-');
$this->assertNotFalse($path);
$xlsxPath = $path . '.xlsx';
try {
IOFactory::createWriter($spreadsheet, 'Xlsx')->save($xlsxPath);
$spreadsheet->disconnectWorksheets();
$file = new UploadedFile(
$xlsxPath,
'运费差导入模板.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
null,
true,
);
$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', '导入完成:1 条数据,10 个字段。');
$snapshot = app(CourierPayableSummaryStore::class)->snapshot(
(int) $this->company->id,
'freight-difference',
);
$this->assertSame('855226246304', $snapshot['rows'][0][4]);
$this->assertSame('3312228147193110069', $snapshot['rows'][0][6]);
$this->assertSame('0.1827', $snapshot['rows'][0][7]);
} finally {
if (isset($spreadsheet)) {
$spreadsheet->disconnectWorksheets();
}
if ($path !== false && is_file($path)) {
unlink($path);
}
if (isset($xlsxPath) && is_file($xlsxPath)) {
unlink($xlsxPath);
}
}
}
public function test_import_replaces_the_page_snapshot_and_export_returns_all_rows(): void
{
$file = UploadedFile::fake()->createWithContent(
@ -104,5 +234,9 @@ class CourierPayableSummaryPagesTest extends FeatureTestCase
$this->loginAs()->get(route('business.courier-payable-summaries.export', [
'summaryPage' => 'freight-difference',
]))->assertForbidden();
$this->loginAs()->get(route('business.courier-payable-summaries.template', [
'summaryPage' => 'freight-difference',
]))->assertForbidden();
}
}