报价管理私有工作簿导入与查看 #38
197
app/Support/QuoteManagementSnapshot.php
Normal file
197
app/Support/QuoteManagementSnapshot.php
Normal file
@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use JsonException;
|
||||
|
||||
final class QuoteManagementSnapshot
|
||||
{
|
||||
public const RELATIVE_PATH = 'app/private/quote-management/quotes.json';
|
||||
|
||||
public function __construct(private readonly ?string $path = null)
|
||||
{
|
||||
}
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
$path = $this->path ?? storage_path(self::RELATIVE_PATH);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return $this->emptySnapshot('missing', '尚未导入本机报价文件。');
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return $this->emptySnapshot('invalid', '本机报价快照无法读取,请重新导入源文件。');
|
||||
}
|
||||
|
||||
if (! is_array($payload) || ! is_array($payload['sheets'] ?? null)) {
|
||||
return $this->emptySnapshot('invalid', '本机报价快照结构不完整,请重新导入源文件。');
|
||||
}
|
||||
|
||||
$quotes = [];
|
||||
|
||||
foreach (array_values($payload['sheets']) as $position => $sheet) {
|
||||
if (! is_array($sheet) || trim((string) ($sheet['name'] ?? '')) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$quotes[] = $this->normalizeSheet($sheet, $position);
|
||||
}
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'state' => 'ready',
|
||||
'message' => null,
|
||||
'source_file' => basename(str_replace('\\', '/', (string) ($payload['sourcePath'] ?? '报价单合并.xlsx'))),
|
||||
'quotes' => $quotes,
|
||||
'summary' => [
|
||||
'quote_count' => count($quotes),
|
||||
'carrier_count' => count(array_unique(array_column($quotes, 'carrier'))),
|
||||
'dated_count' => count(array_filter($quotes, fn (array $quote) => $quote['effective_date'] !== null)),
|
||||
'formula_count' => array_sum(array_column($quotes, 'formula_count')),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeSheet(array $sheet, int $position): array
|
||||
{
|
||||
$name = trim((string) $sheet['name']);
|
||||
$values = $this->normalizeMatrix($sheet['values'] ?? []);
|
||||
$formulas = $this->normalizeMatrix($sheet['formulas'] ?? []);
|
||||
$columnCount = max(array_map('count', $values ?: [[]]));
|
||||
|
||||
return [
|
||||
'id' => sprintf('QB-%03d', $position + 1),
|
||||
'sheet_index' => (int) ($sheet['index'] ?? $position),
|
||||
'sheet_name' => $name,
|
||||
'company_name' => $this->inferCompanyName($name),
|
||||
'carrier' => $this->inferCarrier($name),
|
||||
'quote_type' => $this->inferQuoteType($name),
|
||||
'effective_date' => $this->extractEffectiveDate($values),
|
||||
'source_range' => trim((string) ($sheet['address'] ?? '')),
|
||||
'row_count' => count($values),
|
||||
'column_count' => $columnCount,
|
||||
'formula_count' => $this->formulaCount($formulas),
|
||||
'status' => $this->extractEffectiveDate($values) === null ? '未标执行日期' : '已标执行日期',
|
||||
'signature_status' => 'Excel 未提供',
|
||||
'values' => $values,
|
||||
'formulas' => $formulas,
|
||||
];
|
||||
}
|
||||
|
||||
private function inferCompanyName(string $name): string
|
||||
{
|
||||
$companyName = trim((string) preg_replace('/(?:报价表|报价)$/u', '', $name));
|
||||
|
||||
return $companyName !== '' ? $companyName : $name;
|
||||
}
|
||||
|
||||
private function normalizeMatrix(mixed $matrix): array
|
||||
{
|
||||
if (! is_array($matrix)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(function ($row) {
|
||||
if (! is_array($row)) {
|
||||
return [$this->normalizeCell($row)];
|
||||
}
|
||||
|
||||
return array_map(fn ($cell) => $this->normalizeCell($cell), array_values($row));
|
||||
}, array_values($matrix));
|
||||
}
|
||||
|
||||
private function normalizeCell(mixed $cell): string|int|float|bool|null
|
||||
{
|
||||
if ($cell === null || is_string($cell) || is_int($cell) || is_float($cell) || is_bool($cell)) {
|
||||
return $cell;
|
||||
}
|
||||
|
||||
return (string) $cell;
|
||||
}
|
||||
|
||||
private function inferCarrier(string $name): string
|
||||
{
|
||||
foreach ([
|
||||
'中通快运', '顺心捷达', '安能快运', '京广', 'EMS', '中通', '圆通', '德邦',
|
||||
'申通', '极兔', '安能', '邮政', '韵达', '顺丰',
|
||||
] as $carrier) {
|
||||
if (str_contains($name, $carrier)) {
|
||||
return $carrier;
|
||||
}
|
||||
}
|
||||
|
||||
return '其他快递';
|
||||
}
|
||||
|
||||
private function inferQuoteType(string $name): string
|
||||
{
|
||||
foreach ([
|
||||
'其他配件' => '其他配件报价',
|
||||
'配件' => '配件报价',
|
||||
'超长' => '超长报价',
|
||||
'上楼' => '上楼报价',
|
||||
'大件' => '大件报价',
|
||||
'小件' => '小件报价',
|
||||
'其他' => '其他报价',
|
||||
'快运' => '快运报价',
|
||||
] as $keyword => $type) {
|
||||
if (str_contains($name, $keyword)) {
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
|
||||
return '标准报价';
|
||||
}
|
||||
|
||||
private function extractEffectiveDate(array $values): ?string
|
||||
{
|
||||
foreach ($values as $row) {
|
||||
foreach ($row as $cell) {
|
||||
if (! is_string($cell)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/(20\d{2})[.\/\-年]\s*(\d{1,2})[.\/\-月]\s*(\d{1,2})日?/u', $cell, $match) === 1) {
|
||||
return sprintf('%04d-%02d-%02d', (int) $match[1], (int) $match[2], (int) $match[3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function formulaCount(array $formulas): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
foreach ($formulas as $row) {
|
||||
foreach ($row as $formula) {
|
||||
if (is_string($formula) && trim($formula) !== '') {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function emptySnapshot(string $state, string $message): array
|
||||
{
|
||||
return [
|
||||
'available' => false,
|
||||
'state' => $state,
|
||||
'message' => $message,
|
||||
'source_file' => '报价单合并.xlsx',
|
||||
'quotes' => [],
|
||||
'summary' => [
|
||||
'quote_count' => 0,
|
||||
'carrier_count' => 0,
|
||||
'dated_count' => 0,
|
||||
'formula_count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -202,6 +202,7 @@ return [
|
||||
'title' => '快递应付',
|
||||
'icon' => 'local_shipping',
|
||||
'pages' => [
|
||||
['slug' => 'quote-management', 'title' => '报价管理'],
|
||||
['slug' => 'freight-reconciliation', 'title' => '运费对账'],
|
||||
['slug' => 'bookkeeping-details', 'title' => '记账明细'],
|
||||
['slug' => 'compensation-details', 'title' => '赔偿明细'],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
170
tests/Feature/Business/QuoteManagementPageTest.php
Normal file
170
tests/Feature/Business/QuoteManagementPageTest.php
Normal file
@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Support\QuoteManagementSnapshot;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class QuoteManagementPageTest extends FeatureTestCase
|
||||
{
|
||||
private string $fixturePath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->fixturePath = storage_path('framework/testing/quote-management-' . uniqid('', true) . '.json');
|
||||
file_put_contents($this->fixturePath, json_encode([
|
||||
'sourcePath' => 'C:/private/报价单合并.xlsx',
|
||||
'sheets' => [
|
||||
[
|
||||
'index' => 0,
|
||||
'name' => '新北中通报价',
|
||||
'address' => 'A1:D4',
|
||||
'values' => [
|
||||
['省份', '面单', '费率', "备注:\n执行时间:2025.12.01"],
|
||||
[null, null, null, null],
|
||||
['江苏省', 3.8, 0.5, null],
|
||||
['安徽省', 3.8, 0.5, null],
|
||||
],
|
||||
'formulas' => [[], [], ['', '', '=B3/2', ''], []],
|
||||
],
|
||||
[
|
||||
'index' => 1,
|
||||
'name' => '常州圆通-上楼',
|
||||
'address' => 'A1:H2',
|
||||
'values' => [
|
||||
['报价表', '常州圆通'],
|
||||
['江苏省', 4.2],
|
||||
],
|
||||
'formulas' => [[], []],
|
||||
],
|
||||
[
|
||||
'index' => 2,
|
||||
'name' => '新德邦快递超长报价',
|
||||
'address' => 'A1:F2',
|
||||
'values' => [
|
||||
['0-1kg', '1kg以上'],
|
||||
['江苏省', 5.5],
|
||||
],
|
||||
'formulas' => [[], []],
|
||||
],
|
||||
[
|
||||
'index' => 3,
|
||||
'name' => '新北中通其他报价',
|
||||
'address' => 'A1:D2',
|
||||
'values' => [
|
||||
['省份', '面单', '费率', '备注'],
|
||||
['江苏省', 4.1, 0.6, null],
|
||||
],
|
||||
'formulas' => [[], []],
|
||||
],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
|
||||
app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot($this->fixturePath));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (isset($this->fixturePath) && is_file($this->fixturePath)) {
|
||||
unlink($this->fixturePath);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testQuoteManagementPageRendersImportedWorkbookVersions(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'quote-management',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('报价管理')
|
||||
->assertSeeText('报价单合并.xlsx')
|
||||
->assertSeeText('报价名称')
|
||||
->assertSeeText('快递公司')
|
||||
->assertSeeText('资料状态')
|
||||
->assertSeeText('报价历史')
|
||||
->assertSeeText('发起报价变更')
|
||||
->assertSee('data-quote-change-open', false)
|
||||
->assertSee('data-quote-change-modal', false)
|
||||
->assertSeeText('变更类型')
|
||||
->assertSeeText('变更说明')
|
||||
->assertSeeText('生成变更草稿')
|
||||
->assertDontSee('>draft</span>', false)
|
||||
->assertSee('<option value="新北中通">新北中通</option>', false)
|
||||
->assertSee('<option value="新北中通其他">新北中通其他</option>', false)
|
||||
->assertSee('<option value="新德邦快递超长">新德邦快递超长</option>', false)
|
||||
->assertSee('"id":"QB-001"', false)
|
||||
->assertSee('"company_name":"新北中通"', false)
|
||||
->assertSee('"company_name":"新北中通其他"', false)
|
||||
->assertSee('新北中通报价', false)
|
||||
->assertSee('常州圆通-上楼', false)
|
||||
->assertSee('2025-12-01', false)
|
||||
->assertSee('=B3/2', false)
|
||||
->assertSeeText('原始报价规则保留,当前页面不重新计算金额')
|
||||
->assertDontSee('报价列表正在配置中');
|
||||
|
||||
$quotes = app(QuoteManagementSnapshot::class)->load()['quotes'];
|
||||
|
||||
$this->assertSame(['QB-001', 'QB-002', 'QB-003', 'QB-004'], array_column($quotes, 'id'));
|
||||
$this->assertSame(
|
||||
['新北中通', '常州圆通-上楼', '新德邦快递超长', '新北中通其他'],
|
||||
array_column($quotes, 'company_name')
|
||||
);
|
||||
}
|
||||
|
||||
public function testQuoteManagementPageKeepsTheShellWhenThePrivateSnapshotIsMissing(): void
|
||||
{
|
||||
app()->instance(
|
||||
QuoteManagementSnapshot::class,
|
||||
new QuoteManagementSnapshot(storage_path('framework/testing/missing-quote-snapshot.json'))
|
||||
);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'quote-management',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('尚未导入本机报价文件。')
|
||||
->assertSeeText('报价页面仍可访问')
|
||||
->assertDontSee('class="business-blank-page"', false);
|
||||
}
|
||||
|
||||
public function testQuoteManagementPageRejectsUsersWithoutItsSidebarPermission(): void
|
||||
{
|
||||
$subject = 'user_' . $this->user->id;
|
||||
|
||||
setting()->set('system.sidebar_permissions', json_encode([
|
||||
'configured' => [$subject],
|
||||
'subjects' => [
|
||||
$subject => ['courier-payables/freight-reconciliation'],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
try {
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'quote-management',
|
||||
]));
|
||||
$this->fail('缺少报价管理页面权限的用户不应访问该页面。');
|
||||
} catch (HttpException|HttpResponseException $exception) {
|
||||
$status = method_exists($exception, 'getStatusCode')
|
||||
? $exception->getStatusCode()
|
||||
: $exception->getResponse()->getStatusCode();
|
||||
|
||||
$this->assertSame(404, $status);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user