From 26556a916970b0c0d645e72a02e0915d34b5dc7e Mon Sep 17 00:00:00 2001 From: chenxi Date: Mon, 28 Sep 2026 16:33:38 +0800 Subject: [PATCH] Add warehouse short names to inventory report Restore warehouse name mapping ledger and inventory short-name column. --- LOCAL_DEPLOY.md | 3 +- ...ryWarehouseNameMappingWorkbookImporter.php | 213 ++++++ app/Business/InventoryWarehouseShortNames.php | 35 + app/Http/Controllers/Business/Pages.php | 190 ++++- app/Jobs/Common/InventoryReportExport.php | 9 +- .../InventoryWarehouseNameMapping.php | 34 + config/business_navigation.php | 19 + config/view.php | 16 + ...eate_inventory_warehouse_name_mappings.php | 41 ++ deploy/entrypoint.sh | 18 +- deploy/local-init.sh | 1 + deploy/php-local.ini | 5 +- .../pages/partials/directory-table.blade.php | 656 +++++++++++++++++- .../finance-table-interactions.blade.php | 57 +- resources/views/business/pages/show.blade.php | 1 + routes/admin.php | 11 + tests/Feature/Business/PagesTest.php | 218 +++++- tests/Feature/Settings/NavigationTest.php | 28 +- .../Deployment/WebProcessOwnershipTest.php | 23 + 19 files changed, 1517 insertions(+), 61 deletions(-) create mode 100644 app/Business/InventoryWarehouseNameMappingWorkbookImporter.php create mode 100644 app/Business/InventoryWarehouseShortNames.php create mode 100644 app/Models/Business/InventoryWarehouseNameMapping.php create mode 100644 database/migrations/2026_09_27_000000_create_inventory_warehouse_name_mappings.php diff --git a/LOCAL_DEPLOY.md b/LOCAL_DEPLOY.md index 725ec1e8..ebb3b632 100644 --- a/LOCAL_DEPLOY.md +++ b/LOCAL_DEPLOY.md @@ -145,7 +145,8 @@ docker compose -f docker-compose.local.yml start ## 修改后的生效方式 -- PHP、配置、模块、页面模板和路由目录已经挂载到容器,本地 PHP OPcache 会在每次请求检查源码变更,刷新浏览器即可生效; +- PHP、配置、模块、页面模板和路由目录已经挂载到容器;开发环境关闭 PHP OPcache 和 Blade 编译缓存,刷新浏览器即可加载最新源码,无需重启 PHP 服务; +- 开发环境启动时只执行 `optimize:clear`,不会生成 Laravel 的配置、路由或视图缓存;生产环境仍保留原有缓存构建机制; - 本地 Compose 会同时启动 `frontend` watcher。修改 `resources/assets` 下的 Vue、JavaScript 或 SCSS 后,Laravel Mix 会通过轮询自动重新编译,Apache 使用共享静态资源卷提供最新文件;浏览器刷新即可看到结果。 - 如果 watcher 构建失败,查看 `docker compose -f docker-compose.local.yml logs -f frontend`;修复源码后它会继续监听并重新编译。 - Composer 依赖、前端依赖、Dockerfile 或构建资源发生变化时,重新执行 `bash deploy/local-init.sh`,脚本会自动重建; diff --git a/app/Business/InventoryWarehouseNameMappingWorkbookImporter.php b/app/Business/InventoryWarehouseNameMappingWorkbookImporter.php new file mode 100644 index 00000000..48f00077 --- /dev/null +++ b/app/Business/InventoryWarehouseNameMappingWorkbookImporter.php @@ -0,0 +1,213 @@ +,sheet:string,headers:list,summary:array} */ + public function preview(UploadedFile $file): array + { + $spreadsheet = IOFactory::load($file->getRealPath()); + $worksheet = $this->findWorksheet($spreadsheet); + + if ($worksheet === null) { + throw ValidationException::withMessages([ + 'import' => '未找到包含“仓储方、仓库、仓库简称”三列的工作表。Sheet2 仅是辅助仓库清单,不能直接导入。', + ]); + } + + $rows = $worksheet->toArray(null, true, false, false); + $parsed = []; + $seen = []; + $created = 0; + $same = 0; + $conflicts = 0; + $invalid = 0; + + foreach (array_slice($rows, 1) as $offset => $row) { + $rowNumber = $offset + 2; + $provider = trim((string) ($row[0] ?? '')); + $warehouse = trim((string) ($row[1] ?? '')); + $shortName = trim((string) ($row[2] ?? '')); + if ($provider === '' && $warehouse === '' && $shortName === '') { + continue; + } + + $key = $this->key($provider, $warehouse); + $item = [ + 'row_number' => $rowNumber, + 'warehouse_provider' => $provider, + 'warehouse' => $warehouse, + 'warehouse_short_name' => $shortName, + 'status' => 'new', + 'message' => '待新增', + ]; + + if ($provider === '' || $warehouse === '' || $shortName === '') { + $item['status'] = 'invalid'; + $item['message'] = '仓储方、仓库、仓库简称均为必填字段'; + $invalid++; + } elseif (isset($seen[$key])) { + $item['status'] = $seen[$key]['warehouse_short_name'] === $shortName ? 'same' : 'conflict'; + $item['message'] = $item['status'] === 'same' + ? '与文件内前一行重复,将跳过' + : '与文件内前一行的仓库简称冲突'; + $item['status'] === 'same' ? $same++ : $conflicts++; + } else { + $existing = InventoryWarehouseNameMapping::query() + ->where('warehouse_provider', $provider) + ->where('warehouse', $warehouse) + ->first(); + + if ($existing) { + $item['status'] = $existing->warehouse_short_name === $shortName ? 'same' : 'conflict'; + $item['message'] = $item['status'] === 'same' + ? '数据库已有相同映射,将跳过' + : '数据库已有不同简称,禁止静默覆盖'; + $item['status'] === 'same' ? $same++ : $conflicts++; + } else { + $created++; + } + } + + $parsed[] = $item; + if ($provider !== '' && $warehouse !== '' && $shortName !== '' && ! isset($seen[$key])) { + $seen[$key] = $item; + } + } + + return [ + 'rows' => $parsed, + 'sheet' => $worksheet->getTitle(), + 'headers' => self::HEADERS, + 'summary' => compact('created', 'same', 'conflicts', 'invalid'), + ]; + } + + /** @param array{rows:list} $preview */ + public function commit(array $preview): array + { + $rows = $preview['rows'] ?? []; + $invalid = collect($rows)->first(fn (array $row): bool => in_array($row['status'] ?? '', ['invalid', 'conflict'], true)); + if ($invalid !== null) { + throw ValidationException::withMessages([ + 'import' => '导入预览存在必填字段错误或简称冲突,请先处理标记为失败的行。', + ]); + } + + $created = 0; + $skipped = 0; + DB::transaction(function () use ($rows, &$created, &$skipped): void { + foreach ($rows as $row) { + if (($row['status'] ?? '') === 'same') { + $skipped++; + continue; + } + + $query = InventoryWarehouseNameMapping::query() + ->where('warehouse_provider', $row['warehouse_provider']) + ->where('warehouse', $row['warehouse']); + if ($query->exists()) { + throw ValidationException::withMessages([ + 'import' => "第 {$row['row_number']} 行在确认时已被其他操作占用,请重新预览。", + ]); + } + + InventoryWarehouseNameMapping::create([ + 'company_id' => company_id() ?: 1, + 'warehouse_provider' => $row['warehouse_provider'], + 'warehouse' => $row['warehouse'], + 'warehouse_short_name' => $row['warehouse_short_name'], + 'created_by' => auth()->id(), + 'updated_by' => auth()->id(), + ]); + $created++; + } + }); + + return compact('created', 'skipped'); + } + + public static function suggestion(string $provider, string $warehouse): string + { + $provider = trim($provider); + $warehouse = trim($warehouse); + $haystack = $provider . ' ' . $warehouse; + + if (str_starts_with($provider, '奇门-')) { + return '不看'; + } + + foreach ([ + '配件单仓' => '配件单仓-不看', + 'POP仓' => '京东入仓-不看', + '自动发货仓' => '虚拟仓-不看', + '京东云仓' => '京东云仓-不看', + '京东京造' => '京东京造-不看', + '苏州福利品仓' => '苏州福利品仓-不看', + '信誉订单仓' => '信誉订单仓-不看', + '代发仓' => '代发仓', + '销退仓' => '销退仓-不看', + '客退' => '客退仓-不看', + '次品仓' => '退货报废-不看', + '数量校验仓' => '数量校验仓-不看', + ] as $needle => $result) { + if (str_contains($haystack, $needle)) { + return $result; + } + } + + foreach ([ + '运营C崔桥工厂仓' => '崔桥工厂仓', + '运营C横林二仓' => '横林二仓', + '运营C睢宁' => '睢宁仓', + '运营C新苏北' => '新苏北仓', + '江西仓送货' => '南昌樵舍仓', + ] as $needle => $result) { + if (str_contains($haystack, $needle)) { + return $result; + } + } + + $short = preg_replace('/(主仓|进货仓)$/u', '', $warehouse) ?: $warehouse; + $short = preg_replace('/^(简尚-|京仓-|三方-|奇门-)/u', '', $short) ?: $short; + $providerBase = preg_replace('/^(简尚-|京仓-|三方-|奇门-)/u', '', $provider) ?: $provider; + if ($short === $providerBase || str_starts_with($short, $providerBase)) { + $short = $providerBase; + } + + return $short !== '' ? $short : $warehouse; + } + + private function findWorksheet($spreadsheet): ?object + { + foreach ($spreadsheet->getWorksheetIterator() as $worksheet) { + $row = $worksheet->toArray(null, true, false, false)[0] ?? []; + $headers = array_map(fn ($value): string => $this->normalize((string) $value), $row); + $expected = array_map(fn ($value): string => $this->normalize($value), self::HEADERS); + if (count(array_intersect($expected, $headers)) === count($expected)) { + return $worksheet; + } + } + + return null; + } + + private function normalize(string $value): string + { + return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()]+/u', '', trim($value))); + } + + private function key(string $provider, string $warehouse): string + { + return $provider . "\0" . $warehouse; + } +} diff --git a/app/Business/InventoryWarehouseShortNames.php b/app/Business/InventoryWarehouseShortNames.php new file mode 100644 index 00000000..12a8025a --- /dev/null +++ b/app/Business/InventoryWarehouseShortNames.php @@ -0,0 +1,35 @@ + */ + public function forCompany(int $companyId): array + { + return InventoryWarehouseNameMapping::allCompanies() + ->companyId($companyId) + ->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']) + ->mapWithKeys(static fn (InventoryWarehouseNameMapping $mapping): array => [ + self::key($mapping->warehouse_provider, $mapping->warehouse) => $mapping->warehouse_short_name, + ])->all(); + } + + /** @param array $names */ + public function fill(object $row, array $names): object + { + $row->warehouse_short_name = $names[self::key( + (string) ($row->warehouse_provider ?? ''), + (string) ($row->warehouse ?? ''), + )] ?? ''; + + return $row; + } + + private static function key(string $provider, string $warehouse): string + { + return $provider . "\0" . $warehouse; + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 7aa71b1d..283d62a8 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers\Business; use App\Business\BusinessTableImporter; +use App\Business\InventoryWarehouseNameMappingWorkbookImporter; +use App\Business\InventoryWarehouseShortNames; use App\Business\CourierPayableSummaryFilter; use App\Business\CourierPayableSummaryStore; use App\Business\FundForecastTemplate; @@ -18,6 +20,7 @@ use App\Models\Business\CourierQuoteSignature; use App\Models\Business\CourierQuoteVersion; use App\Models\Business\CourierSigningSubject; use App\Models\Business\InstallationDetail; +use App\Models\Business\InventoryWarehouseNameMapping; use App\Business\CourierSurchargeAuditLogRepository; use App\Business\CourierSurchargeQuoteRepository; use App\Business\CourierSurchargeTypeRepository; @@ -32,6 +35,11 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Str; use Illuminate\Database\QueryException; use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException; +use PhpOffice\PhpSpreadsheet\Cell\Coordinate; +use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Style\Alignment; +use PhpOffice\PhpSpreadsheet\Style\Fill; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx; use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository; use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\View\BillOffsetPageData; @@ -380,11 +388,20 @@ class Pages extends Controller $tabValue = null; $tabColumn = null; $upstreamError = null; + $inventoryWarehouseMappings = collect(); $agreementCourierNames = $this->agreementCourierNames($page['slug'] ?? null); $agreementCourierAccounts = $this->agreementCourierAccounts($page['slug'] ?? null, $agreementCourierNames); if (($section['slug'] ?? null) === 'product-inventory' && ($page['slug'] ?? null) === 'inventory-report') { - [$rows, $upstreamError] = $this->inventoryReportRows(); + if (request()->boolean('mapping')) { + $rows = new LengthAwarePaginator([], 0, 1, 1, [ + 'path' => request()->url(), + 'query' => request()->query(), + ]); + } else { + [$rows, $upstreamError] = $this->inventoryReportRows(); + } + $inventoryWarehouseMappings = $this->inventoryWarehouseMappingRows(); } if ($page && $content === null) { @@ -432,6 +449,7 @@ class Pages extends Controller 'agreementCourierNames' => $agreementCourierNames, 'agreementCourierAccounts' => $agreementCourierAccounts, 'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug']), + 'inventoryWarehouseMappings' => $inventoryWarehouseMappings, ]; } @@ -490,6 +508,7 @@ class Pages extends Controller 'agreementCourierNames' => $agreementCourierNames, 'agreementCourierAccounts' => $agreementCourierAccounts, 'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug'] ?? null), + 'inventoryWarehouseMappings' => $inventoryWarehouseMappings, ]; } @@ -513,8 +532,11 @@ class Pages extends Controller $perPage, ); + $shortNames = app(InventoryWarehouseShortNames::class); + $names = $shortNames->forCompany((int) (company_id() ?: 1)); + return [new LengthAwarePaginator( - $result['data'], + array_map(static fn (object $row): object => $shortNames->fill($row, $names), $result['data']), $result['total'], $perPage, $page, @@ -535,6 +557,163 @@ class Pages extends Controller ]), $error]; } + private function inventoryWarehouseMappingRows(): LengthAwarePaginator + { + $query = InventoryWarehouseNameMapping::query()->orderBy('warehouse_provider')->orderBy('warehouse'); + $provider = mb_substr(trim((string) request('mapping_warehouse_provider', '')), 0, 191); + $warehouse = mb_substr(trim((string) request('mapping_warehouse', '')), 0, 191); + + if ($provider !== '') { + $query->where('warehouse_provider', 'like', '%' . addcslashes($provider, '%_\\') . '%'); + } + if ($warehouse !== '') { + $query->where('warehouse', 'like', '%' . addcslashes($warehouse, '%_\\') . '%'); + } + + return $query->paginate(50)->withQueryString(); + } + + public function storeInventoryWarehouseMapping(Request $request) + { + $data = $request->validate([ + 'warehouse_provider' => ['required', 'string', 'max:191'], + 'warehouse' => ['required', 'string', 'max:191'], + 'warehouse_short_name' => ['nullable', 'string', 'max:191'], + ]); + $data['warehouse_provider'] = trim($data['warehouse_provider']); + $data['warehouse'] = trim($data['warehouse']); + $data['warehouse_short_name'] = trim((string) ($data['warehouse_short_name'] ?? '')) + ?: InventoryWarehouseNameMappingWorkbookImporter::suggestion($data['warehouse_provider'], $data['warehouse']); + + if (InventoryWarehouseNameMapping::query() + ->where('warehouse_provider', $data['warehouse_provider']) + ->where('warehouse', $data['warehouse']) + ->exists()) { + return back()->withInput()->withErrors(['warehouse' => '相同“仓储方 + 仓库”已存在,请使用修改功能。']); + } + + InventoryWarehouseNameMapping::create($data + [ + 'company_id' => company_id() ?: 1, + 'created_by' => auth()->id(), + 'updated_by' => auth()->id(), + ]); + + return $this->inventoryWarehouseMappingRedirect()->with('success', '仓库名称映射已新增。'); + } + + public function updateInventoryWarehouseMapping(Request $request, int $id) + { + $mapping = InventoryWarehouseNameMapping::query()->findOrFail($id); + $data = $request->validate([ + 'warehouse_provider' => ['required', 'string', 'max:191'], + 'warehouse' => ['required', 'string', 'max:191'], + 'warehouse_short_name' => ['required', 'string', 'max:191'], + ]); + $data = array_map(static fn (string $value): string => trim($value), $data); + + if (InventoryWarehouseNameMapping::query() + ->where('id', '!=', $mapping->id) + ->where('warehouse_provider', $data['warehouse_provider']) + ->where('warehouse', $data['warehouse']) + ->exists()) { + return back()->withInput()->withErrors(['warehouse' => '相同“仓储方 + 仓库”已存在,不能产生重复台账。']); + } + + $mapping->update($data + ['updated_by' => auth()->id()]); + + return $this->inventoryWarehouseMappingRedirect()->with('success', '仓库名称映射已修改。'); + } + + public function previewInventoryWarehouseMappingImport( + Request $request, + InventoryWarehouseNameMappingWorkbookImporter $importer, + ) { + $request->validate([ + 'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'], + ]); + $preview = $importer->preview($request->file('import')); + $token = Str::random(40); + session()->put('inventory_warehouse_mapping_previews.' . $token, $preview); + + return $this->inventoryWarehouseMappingRedirect() + ->with('warehouse_mapping_preview', $preview + ['token' => $token]); + } + + public function commitInventoryWarehouseMappingImport( + Request $request, + InventoryWarehouseNameMappingWorkbookImporter $importer, + ) { + $token = (string) $request->input('preview_token'); + $preview = session()->pull('inventory_warehouse_mapping_previews.' . $token); + abort_if($token === '' || ! is_array($preview), 422, '导入预览已过期,请重新上传文件。'); + $result = $importer->commit($preview); + + return $this->inventoryWarehouseMappingRedirect()->with( + 'success', + "导入完成:新增 {$result['created']} 条,跳过 {$result['skipped']} 条重复映射。", + ); + } + + public function exportInventoryWarehouseMappings(Request $request): StreamedResponse + { + $query = InventoryWarehouseNameMapping::query()->orderBy('warehouse_provider')->orderBy('warehouse'); + $provider = mb_substr(trim((string) $request->query('mapping_warehouse_provider', '')), 0, 191); + $warehouse = mb_substr(trim((string) $request->query('mapping_warehouse', '')), 0, 191); + if ($provider !== '') { + $query->where('warehouse_provider', 'like', '%' . addcslashes($provider, '%_\\') . '%'); + } + if ($warehouse !== '') { + $query->where('warehouse', 'like', '%' . addcslashes($warehouse, '%_\\') . '%'); + } + + return response()->streamDownload(function () use ($query): void { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('仓库名称映射表'); + $headers = ['仓储方', '仓库', '仓库简称']; + $sheet->fromArray($headers, null, 'A1'); + $lastColumn = Coordinate::stringFromColumnIndex(count($headers)); + $headerRange = "A1:{$lastColumn}1"; + $sheet->getStyle($headerRange)->applyFromArray([ + 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']], + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], + ]); + $sheet->getRowDimension(1)->setRowHeight(24); + $sheet->freezePane('A2'); + $sheet->setAutoFilter($headerRange); + foreach ([20, 32, 20] as $index => $width) { + $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width); + } + $rowNumber = 2; + foreach ($query->cursor() as $mapping) { + $sheet->fromArray([ + $mapping->warehouse_provider, + $mapping->warehouse, + $mapping->warehouse_short_name, + ], null, "A{$rowNumber}"); + $rowNumber++; + } + (new Xlsx($spreadsheet))->save('php://output'); + $spreadsheet->disconnectWorksheets(); + }, '仓库名称映射表_' . now()->format('Ymd_His') . '.xlsx', [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Cache-Control' => 'private, no-store, no-cache, must-revalidate', + ]); + } + + private function inventoryWarehouseMappingRedirect() + { + return redirect()->route('business.pages.show', [ + 'company_id' => company_id() ?: 1, + 'section' => 'product-inventory', + 'page' => 'inventory-report', + 'mapping' => 1, + 'mapping_warehouse_provider' => request('mapping_warehouse_provider', ''), + 'mapping_warehouse' => request('mapping_warehouse', ''), + ]); + } + /** @return array */ private function inventoryReportFilters(Request $request): array { @@ -1345,7 +1524,7 @@ class Pages extends Controller if ($scope === 'all' && should_queue()) { $fileName = Str::filename('进销存报表') . '-' . now()->format('Ymd_His') . '-' . Str::random(8) . '.csv'; - dispatch(new InventoryReportExport(user(), $filters, $columns, $fileName))->onQueue('exports'); + dispatch(new InventoryReportExport(user(), $filters, $columns, $fileName, (int) (company_id() ?: 1)))->onQueue('exports'); flash('进销存报表已提交后台生成,完成后可从通知中心下载。')->success(); return back(); @@ -1358,7 +1537,7 @@ class Pages extends Controller // download connection. The job stores the completed file and // raises the existing notification when it is ready. $fileName = Str::filename('进销存报表') . '-' . now()->format('Ymd_His') . '-' . Str::random(8) . '.csv'; - dispatch(new InventoryReportExport(user(), $filters, $columns, $fileName))->afterResponse(); + dispatch(new InventoryReportExport(user(), $filters, $columns, $fileName, (int) (company_id() ?: 1)))->afterResponse(); flash('进销存报表已提交后台生成,完成后可从通知中心下载。')->success(); return back(); @@ -1386,8 +1565,11 @@ class Pages extends Controller $rows = $scope === 'page' ? $this->thirdPartyWarehouseStorage->paginateMonthlyInventoryReport($filters, $page, $perPage, false)['data'] : $this->thirdPartyWarehouseStorage->exportMonthlyInventoryReportRows($filters); + $shortNames = app(InventoryWarehouseShortNames::class); + $names = $shortNames->forCompany((int) (company_id() ?: 1)); $written = 0; foreach ($rows as $row) { + $row = $shortNames->fill($row, $names); fputcsv($csv, array_map(function (array $column) use ($row): mixed { $value = data_get($row, $column['name']); diff --git a/app/Jobs/Common/InventoryReportExport.php b/app/Jobs/Common/InventoryReportExport.php index a23fab2c..f566f45a 100644 --- a/app/Jobs/Common/InventoryReportExport.php +++ b/app/Jobs/Common/InventoryReportExport.php @@ -3,6 +3,7 @@ namespace App\Jobs\Common; use App\Abstracts\JobShouldQueue; +use App\Business\InventoryWarehouseShortNames; use App\Notifications\Common\ExportFailed; use Illuminate\Support\Facades\File; use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository; @@ -15,16 +16,20 @@ class InventoryReportExport extends JobShouldQueue public int $tries = 1; + protected int $company_id = 1; + public function __construct( protected $user, protected array $filters, protected array $columns, protected string $file_name, + ?int $companyId = null, ) { + $this->company_id = $companyId ?? 1; $this->onQueue('exports'); } - public function handle(ThirdPartyWarehouseStorageMirrorRepository $repository): void + public function handle(ThirdPartyWarehouseStorageMirrorRepository $repository, InventoryWarehouseShortNames $shortNames): void { $path = storage_path('app/temp/' . $this->file_name); File::ensureDirectoryExists(dirname($path)); @@ -37,7 +42,9 @@ class InventoryReportExport extends JobShouldQueue fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, array_column($this->columns, 'label')); + $names = $shortNames->forCompany($this->company_id); foreach ($repository->exportMonthlyInventoryReportRows($this->filters) as $row) { + $row = $shortNames->fill($row, $names); fputcsv($csv, array_map(function (array $column) use ($row): mixed { $value = data_get($row, $column['name']); diff --git a/app/Models/Business/InventoryWarehouseNameMapping.php b/app/Models/Business/InventoryWarehouseNameMapping.php new file mode 100644 index 00000000..fda10996 --- /dev/null +++ b/app/Models/Business/InventoryWarehouseNameMapping.php @@ -0,0 +1,34 @@ + 'integer', + 'created_by' => 'integer', + 'updated_by' => 'integer', + ]; + + public static function columnLabels(): array + { + return [ + 'warehouse_provider' => '仓储方', + 'warehouse' => '仓库', + 'warehouse_short_name' => '仓库简称', + ]; + } +} diff --git a/config/business_navigation.php b/config/business_navigation.php index 2a20ea75..dadff23d 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -335,6 +335,24 @@ $navigation = [ 'pages' => [ ['slug' => 'normal-products', 'title' => '普通商品资料'], ['slug' => 'online-daifa-price', 'title' => '线上代发价格'], + [ + 'slug' => 'monthly-weighted-average-price', + 'title' => '商品月加权平均单价', + 'directory' => [ + 'icon' => 'calculate', + 'status' => '取数规则待确认', + 'description' => '商品月加权平均单价页面入口已建立;接入数据前需确认采购入库范围、金额字段和退货处理口径。', + 'empty' => '商品月加权平均单价尚未同步。确认取数规则后再接入经营分析副本库数据。', + 'fields' => [ + ['name' => 'month', 'label' => '月份', 'kind' => 'date'], + ['name' => 'product_code', 'label' => '商品编码'], + ['name' => 'product_name', 'label' => '商品名称'], + ['name' => 'purchase_quantity', 'label' => '采购数量', 'kind' => 'number'], + ['name' => 'purchase_amount', 'label' => '采购金额', 'kind' => 'number'], + ['name' => 'weighted_average_unit_price', 'label' => '月加权平均单价', 'kind' => 'number'], + ], + ], + ], [ 'slug' => 'inventory-report', 'title' => '进销存报表', @@ -348,6 +366,7 @@ $navigation = [ ['name' => 'month', 'label' => '月份', 'kind' => 'date'], ['name' => 'warehouse_provider', 'label' => '仓储方'], ['name' => 'warehouse', 'label' => '仓库'], + ['name' => 'warehouse_short_name', 'label' => '仓库简称'], ['name' => 'product_code', 'label' => '商品编码'], ['name' => 'category', 'label' => '分类'], ['name' => 'opening_quantity', 'label' => '期初数量', 'kind' => 'integer'], diff --git a/config/view.php b/config/view.php index 22b8a18d..76dca230 100644 --- a/config/view.php +++ b/config/view.php @@ -33,4 +33,20 @@ return [ realpath(storage_path('framework/views')) ), + /* + |-------------------------------------------------------------------------- + | Compiled View Cache + |-------------------------------------------------------------------------- + | + | Blade still needs a writable path to generate PHP for each request, but + | local development must not trust an existing compiled template. Setting + | the compiler cache flag to false makes Laravel recompile every view and + | leaves production's normal compiled-view behavior unchanged. + |-------------------------------------------------------------------------- + */ + + 'cache' => env('APP_ENV', 'production') === 'local' + ? false + : env('VIEW_CACHE', true), + ]; diff --git a/database/migrations/2026_09_27_000000_create_inventory_warehouse_name_mappings.php b/database/migrations/2026_09_27_000000_create_inventory_warehouse_name_mappings.php new file mode 100644 index 00000000..a5ba9a9d --- /dev/null +++ b/database/migrations/2026_09_27_000000_create_inventory_warehouse_name_mappings.php @@ -0,0 +1,41 @@ +increments('id'); + $table->unsignedInteger('company_id')->default(1); + $table->string('warehouse_provider', 191)->comment('仓储方'); + $table->string('warehouse', 191)->comment('仓库'); + $table->string('warehouse_short_name', 191)->comment('仓库简称'); + $table->unsignedInteger('created_by')->nullable(); + $table->unsignedInteger('updated_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique( + ['company_id', 'warehouse_provider', 'warehouse'], + 'inventory_warehouse_mapping_identity', + ); + $table->index( + ['company_id', 'warehouse_short_name'], + 'inventory_warehouse_mapping_short_name_index', + ); + }); + } + + public function down(): void + { + Schema::dropIfExists('business_inventory_warehouse_name_mappings'); + } +}; diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh index c4b2ace2..422e0dd2 100644 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -37,6 +37,11 @@ run_artisan_as_web() { runuser -u www-data -- php artisan "$@" } +app_environment="" +if [ -f .env ]; then + app_environment="$(sed -n 's/^APP_ENV=//p' .env | tail -n 1 | tr -d '\r' | sed 's/^"//; s/"$//; s/^'\''//; s/'\''$//')" +fi + if [ "${1:-}" = "apache2-foreground" ]; then sed -ri 's/^Listen 80$/Listen 18088/' /etc/apache2/ports.conf assert_web_writable @@ -53,8 +58,17 @@ if [ "${1:-}" = "apache2-foreground" ]; then run_artisan_as_web migrate --force fi - run_artisan_as_web config:cache - run_artisan_as_web view:cache + if [ "$app_environment" = "local" ]; then + # Keep development source live: optimize:clear above removes any + # stale bootstrap/view artifacts, while view.cache=false makes + # Blade recompile templates on every request without a restart. + run_artisan_as_web optimize:clear + else + # Production keeps the existing immutable config and compiled + # Blade caches for startup and request performance. + run_artisan_as_web config:cache + run_artisan_as_web view:cache + fi fi fi diff --git a/deploy/local-init.sh b/deploy/local-init.sh index 98d70ff9..79f8b978 100644 --- a/deploy/local-init.sh +++ b/deploy/local-init.sh @@ -81,6 +81,7 @@ calculate_local_image_fingerprint() { deploy/Dockerfile.local \ deploy/apache-vhost.conf \ deploy/php.ini \ + deploy/php-local.ini \ deploy/entrypoint.sh \ deploy/local-healthcheck.sh diff --git a/deploy/php-local.ini b/deploy/php-local.ini index 8bbe1130..812a4875 100644 --- a/deploy/php-local.ini +++ b/deploy/php-local.ini @@ -1,3 +1,6 @@ -; Local bind-mounted PHP source must be revalidated on every request. +; Local development serves bind-mounted PHP/Blade source directly. Disable +; opcode caching so a browser refresh always executes the current source. +opcache.enable=0 +opcache.enable_cli=0 opcache.validate_timestamps=1 opcache.revalidate_freq=0 diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index 0f63b7fd..a940b6ec 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -1,6 +1,11 @@ @php $directoryRows = $rows ?? collect(); $directoryFields = $directory['fields'] ?? []; + $isInventoryReportPage = ($page['slug'] ?? '') === 'inventory-report'; + $inventoryWarehouseMappings = $isInventoryReportPage + ? ($inventoryWarehouseMappings ?? collect()) + : collect(); + $warehouseMappingPreview = $isInventoryReportPage ? session('warehouse_mapping_preview') : null; // “最近三个月销量”必须是三个独立的月份列;月份按当前月向前取三个完整月份。 // 保留原字段名作为兼容契约,渲染时展开为 *_1、*_2、*_3 三个值。 $directoryFields = collect($directoryFields)->flatMap(function (array $field): array { @@ -485,6 +490,8 @@ .directory-sort-link:hover { color: var(--vip-content-accent-dark, #856632); } .directory-sort-icon { width: 1rem; height: 1rem; color: var(--vip-content-muted, #595959); font-size: 1rem; line-height: 1; } .directory-sort-link[aria-current="true"] .directory-sort-icon { color: var(--vip-content-accent-dark, #856632); } + .directory-inventory-sort { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; } + .directory-inventory-sort:hover, .directory-inventory-sort:focus-visible { color: var(--vip-content-accent-dark, #856632); } .directory-header-menu { position: relative; display: inline-flex; align-items: center; gap: 0.15rem; } .directory-header-menu summary { display: inline-flex; align-items: center; cursor: pointer; list-style: none; } .directory-header-menu summary::-webkit-details-marker { display: none; } @@ -565,6 +572,256 @@ text-decoration: underline; } + .inventory-report-tabs { + min-height: 5rem; + margin: 0; + padding: 0 2rem; + gap: 2rem; + border-color: var(--vip-border, #d8d2c7); + border-radius: 1rem; + box-shadow: none; + } + + .inventory-report-tabs [data-inventory-report-tab] { + position: relative; + display: inline-flex; + min-height: 5rem; + align-items: center; + padding: 0; + border: 0; + background: transparent; + color: var(--vip-content-muted, #595959); + font: inherit; + font-size: 1rem; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + } + + .inventory-report-tabs [data-inventory-report-tab]:hover, + .inventory-report-tabs [data-inventory-report-tab][aria-selected="true"] { + color: var(--vip-content-accent-dark, #856632); + } + + .inventory-report-tabs [data-inventory-report-tab][aria-selected="true"]::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 3px; + border-radius: 999px 999px 0 0; + background: var(--vip-content-accent, #b9954f); + content: ''; + } + + .inventory-report-tabs [data-inventory-report-tab]:focus-visible { + outline: 2px solid var(--vip-content-accent-dark, #856632); + outline-offset: 3px; + } + + .inventory-report-panel[hidden] { + display: none; + } + + .inventory-warehouse-mapping { + overflow: visible; + } + + .inventory-warehouse-mapping__table { + min-width: 52rem; + border-collapse: separate; + border-spacing: 0; + isolation: isolate; + } + + .inventory-warehouse-mapping__table thead th { + position: sticky !important; + top: 0 !important; + z-index: 20 !important; + background: var(--vip-content-accent-soft, #f7f0de); + box-shadow: 0 1px 0 var(--vip-border, #d8d2c7); + } + + .inventory-warehouse-mapping__table thead, + .inventory-warehouse-mapping__table thead tr { + position: sticky; + top: 0; + z-index: 5; + } + + .inventory-warehouse-mapping__table thead th[data-directory-fixed="start"], + .inventory-warehouse-mapping__table thead th[data-directory-fixed="end"] { + z-index: 21 !important; + background: var(--vip-content-accent-soft, #f7f0de); + } + + .inventory-warehouse-mapping__table tbody td { + position: relative !important; + z-index: 0 !important; + background: var(--vip-surface, #fff); + } + + .inventory-warehouse-mapping__table tbody tr:hover td { + background: #fbf8f0; + } + + .inventory-warehouse-mapping__empty { + padding: 3rem 1.25rem; + color: var(--vip-content-muted, #595959); + text-align: center; + } + + .inventory-warehouse-mapping__filters { + grid-template-columns: repeat(2, minmax(12rem, 1fr)) auto; + align-items: end; + border: 0; + border-bottom: 1px solid var(--vip-border, #d8d2c7); + } + + .inventory-warehouse-mapping__filters .directory-query-actions { + grid-column: auto; + justify-content: flex-start; + padding-top: 0; + } + + .inventory-warehouse-mapping__dialog input { + min-height: 2.25rem; + padding: 0.45rem 0.65rem; + border: 1px solid var(--vip-border, #d8d2c7); + border-radius: 0.5rem; + background: #fff; + color: var(--vip-content-ink, #171717); + } + + .inventory-warehouse-mapping__filters .biz-btn { + display: inline-flex; + height: 2.1rem; + align-items: center; + padding: 0 0.8rem; + text-decoration: none; + } + + .inventory-warehouse-mapping__import { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + } + + .inventory-warehouse-mapping__export, + .inventory-warehouse-mapping__file-label, + .inventory-warehouse-mapping__import button { + display: inline-flex; + height: 2.25rem; + align-items: center; + gap: 0.35rem; + padding: 0 0.875rem; + border: 1px solid var(--vip-content-accent, #b9954f); + border-radius: 0.25rem; + background: #fff; + color: var(--vip-content-accent-dark, #856632); + font: inherit; + text-decoration: none; + cursor: pointer; + } + + .inventory-warehouse-mapping__file-label input[type="file"] { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + } + + .inventory-warehouse-mapping__actions { + display: inline-flex; + gap: 0.4rem; + } + + .inventory-warehouse-mapping__actions button { + padding: 0.25rem 0.45rem; + border: 1px solid var(--vip-border, #d8d2c7); + border-radius: 0.4rem; + background: #fff; + color: var(--vip-content-accent-dark, #856632); + cursor: pointer; + } + + .inventory-warehouse-mapping__preview { + margin: 1rem 1.25rem; + overflow: auto; + border: 1px solid var(--vip-border, #d8d2c7); + border-radius: 0.5rem; + } + + .inventory-warehouse-mapping__preview summary { + padding: 0.75rem 1rem; + cursor: pointer; + color: var(--vip-content-accent-dark, #856632); + } + + .inventory-warehouse-mapping__preview table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; + } + + .inventory-warehouse-mapping__preview th, + .inventory-warehouse-mapping__preview td { + padding: 0.5rem 0.75rem; + border-top: 1px solid var(--vip-border, #d8d2c7); + text-align: left; + white-space: nowrap; + } + + .inventory-warehouse-mapping__preview .is-invalid, + .inventory-warehouse-mapping__preview .is-conflict { + color: #a33a32; + } + + .inventory-warehouse-mapping__preview .is-same { + color: var(--vip-content-muted, #595959); + } + + .inventory-warehouse-mapping__dialog { + width: min(32rem, calc(100vw - 2rem)); + padding: 0; + border: 1px solid var(--vip-border, #d8d2c7); + border-radius: 0.75rem; + box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.2); + } + + .inventory-warehouse-mapping__dialog::backdrop { + background: rgba(21, 18, 12, 0.38); + } + + .inventory-warehouse-mapping__dialog form { + display: grid; + gap: 1rem; + padding: 1.25rem; + } + + .inventory-warehouse-mapping__dialog h3 { + margin: 0; + color: var(--vip-content-ink, #171717); + font-size: 1rem; + } + + .inventory-warehouse-mapping__dialog label { + display: grid; + gap: 0.35rem; + color: var(--vip-content-muted, #595959); + font-size: 0.8rem; + } + + .inventory-warehouse-mapping__dialog__actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + } + @media (max-width: 700px) { .directory-table-head { flex-direction: column; @@ -610,18 +867,40 @@ @endif @else -
-
- -
- {{ $page['title'] ?? $section['title'] }} · 字段结构 - @if (!empty($directory['description'])) -

{{ $directory['description'] }}

- @endif + @if ($isInventoryReportPage) + +
+ @else +
+
+ +
+ {{ $page['title'] ?? $section['title'] }} · 字段结构 + @if (!empty($directory['description'])) +

{{ $directory['description'] }}

+ @endif +
-
- {{ $directory['status'] ?? '尚未同步' }} -
+ {{ $directory['status'] ?? '尚未同步' }} + + @endif @if (!empty($upstreamError)) @endif
- +
- + @foreach ($directoryFields as $field) - @forelse ($directoryRows as $rowIndex => $row) - + @foreach ($directoryFields as $field) @php $source = array_key_exists('source', $field) ? $field['source'] : $field['name']; @@ -1080,7 +1366,7 @@ @endif @else - {{ filled($display) ? $display : '—' }} + {{ filled($display) ? $display : ($isInventoryReportPage && $field['name'] === 'warehouse_short_name' ? '' : '—') }} @endif @endforeach @@ -1118,6 +1404,164 @@ @endif + @if ($isInventoryReportPage) + +
## + @if ($directoryUsesFieldQuery) @php $isSorted = request('sort') === $field['name']; @@ -1031,6 +1315,8 @@ + @elseif ($isInventoryReportPage && $field['name'] === 'warehouse_short_name') + @else {{ $field['label'] }} @endif @@ -1041,7 +1327,7 @@
{{ $rowIndex + 1 + (($directoryCurrentPage - 1) * $directoryPerPage) }}{{ $rowIndex + 1 + (($directoryCurrentPage - 1) * $directoryPerPage) }}
+ + + @foreach (($warehouseMappingPreview['rows'] ?? []) as $previewRow) + + + + + + + + + @endforeach + +
行号仓储方仓库仓库简称结果原因
{{ $previewRow['row_number'] }}{{ $previewRow['warehouse_provider'] ?: '—' }}{{ $previewRow['warehouse'] ?: '—' }}{{ $previewRow['warehouse_short_name'] ?: '—' }}{{ match ($previewRow['status']) { 'new' => '待新增', 'same' => '跳过', 'conflict' => '冲突', default => '失败' } }}{{ $previewRow['message'] }}
+ @if (($previewSummary['conflicts'] ?? 0) === 0 && ($previewSummary['invalid'] ?? 0) === 0) +
+ @csrf + + +
+ @else +

存在失败或冲突行,系统不会写入任何记录。请修正 Excel 后重新预览。

+ @endif + + @endif + @if ($inventoryWarehouseMappings->isEmpty()) +
当前查询暂无仓库名称映射。可点击“新增仓库”或上传 Sheet1 导入。
+ @else +
+ + + + + + + + + + + + @foreach ($inventoryWarehouseMappings as $mapping) + + + + + + + + @endforeach + +
#仓储方仓库仓库简称操作
{{ ($inventoryWarehouseMappings->currentPage() - 1) * $inventoryWarehouseMappings->perPage() + $loop->iteration }}{{ $mapping->warehouse_provider }}{{ $mapping->warehouse }}{{ $mapping->warehouse_short_name }} + + + +
+
+
+ 共 {{ $inventoryWarehouseMappings->total() }} 条 · 第 {{ $inventoryWarehouseMappings->currentPage() }} / {{ $inventoryWarehouseMappings->lastPage() }} 页 + + @if ($inventoryWarehouseMappings->onFirstPage()) 上一页 @else 上一页 @endif + · + @if ($inventoryWarehouseMappings->hasMorePages()) 下一页 @else 下一页 @endif + +
+ @endif +
+ +
+ @csrf + +

新增仓库名称映射

+ + + +
+ + +
+
+
+ + @endif + @if (($page['slug'] ?? '') === 'subject-operating-report')