Add warehouse short names to inventory report

Restore warehouse name mapping ledger and inventory short-name column.
This commit is contained in:
chenxi 2026-09-28 16:33:38 +08:00
parent e73183f52e
commit 26556a9169
19 changed files with 1517 additions and 61 deletions

View File

@ -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 使用共享静态资源卷提供最新文件;浏览器刷新即可看到结果。 - 本地 Compose 会同时启动 `frontend` watcher。修改 `resources/assets` 下的 Vue、JavaScript 或 SCSS 后,Laravel Mix 会通过轮询自动重新编译,Apache 使用共享静态资源卷提供最新文件;浏览器刷新即可看到结果。
- 如果 watcher 构建失败,查看 `docker compose -f docker-compose.local.yml logs -f frontend`;修复源码后它会继续监听并重新编译。 - 如果 watcher 构建失败,查看 `docker compose -f docker-compose.local.yml logs -f frontend`;修复源码后它会继续监听并重新编译。
- Composer 依赖、前端依赖、Dockerfile 或构建资源发生变化时,重新执行 `bash deploy/local-init.sh`,脚本会自动重建; - Composer 依赖、前端依赖、Dockerfile 或构建资源发生变化时,重新执行 `bash deploy/local-init.sh`,脚本会自动重建;

View File

@ -0,0 +1,213 @@
<?php
namespace App\Business;
use App\Models\Business\InventoryWarehouseNameMapping;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\IOFactory;
class InventoryWarehouseNameMappingWorkbookImporter
{
private const HEADERS = ['仓储方', '仓库', '仓库简称'];
/** @return array{rows:list<array>,sheet:string,headers:list<string>,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<array>} $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;
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Business;
use App\Models\Business\InventoryWarehouseNameMapping;
class InventoryWarehouseShortNames
{
/** @return array<string,string> */
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<string,string> $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;
}
}

View File

@ -3,6 +3,8 @@
namespace App\Http\Controllers\Business; namespace App\Http\Controllers\Business;
use App\Business\BusinessTableImporter; use App\Business\BusinessTableImporter;
use App\Business\InventoryWarehouseNameMappingWorkbookImporter;
use App\Business\InventoryWarehouseShortNames;
use App\Business\CourierPayableSummaryFilter; use App\Business\CourierPayableSummaryFilter;
use App\Business\CourierPayableSummaryStore; use App\Business\CourierPayableSummaryStore;
use App\Business\FundForecastTemplate; use App\Business\FundForecastTemplate;
@ -18,6 +20,7 @@ use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion; use App\Models\Business\CourierQuoteVersion;
use App\Models\Business\CourierSigningSubject; use App\Models\Business\CourierSigningSubject;
use App\Models\Business\InstallationDetail; use App\Models\Business\InstallationDetail;
use App\Models\Business\InventoryWarehouseNameMapping;
use App\Business\CourierSurchargeAuditLogRepository; use App\Business\CourierSurchargeAuditLogRepository;
use App\Business\CourierSurchargeQuoteRepository; use App\Business\CourierSurchargeQuoteRepository;
use App\Business\CourierSurchargeTypeRepository; use App\Business\CourierSurchargeTypeRepository;
@ -32,6 +35,11 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException; 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\AlibabaAccountBalanceRepository;
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\View\BillOffsetPageData; use Modules\EcommerceFinance\View\BillOffsetPageData;
@ -380,12 +388,21 @@ class Pages extends Controller
$tabValue = null; $tabValue = null;
$tabColumn = null; $tabColumn = null;
$upstreamError = null; $upstreamError = null;
$inventoryWarehouseMappings = collect();
$agreementCourierNames = $this->agreementCourierNames($page['slug'] ?? null); $agreementCourierNames = $this->agreementCourierNames($page['slug'] ?? null);
$agreementCourierAccounts = $this->agreementCourierAccounts($page['slug'] ?? null, $agreementCourierNames); $agreementCourierAccounts = $this->agreementCourierAccounts($page['slug'] ?? null, $agreementCourierNames);
if (($section['slug'] ?? null) === 'product-inventory' && ($page['slug'] ?? null) === 'inventory-report') { if (($section['slug'] ?? null) === 'product-inventory' && ($page['slug'] ?? null) === 'inventory-report') {
if (request()->boolean('mapping')) {
$rows = new LengthAwarePaginator([], 0, 1, 1, [
'path' => request()->url(),
'query' => request()->query(),
]);
} else {
[$rows, $upstreamError] = $this->inventoryReportRows(); [$rows, $upstreamError] = $this->inventoryReportRows();
} }
$inventoryWarehouseMappings = $this->inventoryWarehouseMappingRows();
}
if ($page && $content === null) { if ($page && $content === null) {
$descriptor = TableRegistry::describe( $descriptor = TableRegistry::describe(
@ -432,6 +449,7 @@ class Pages extends Controller
'agreementCourierNames' => $agreementCourierNames, 'agreementCourierNames' => $agreementCourierNames,
'agreementCourierAccounts' => $agreementCourierAccounts, 'agreementCourierAccounts' => $agreementCourierAccounts,
'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug']), 'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug']),
'inventoryWarehouseMappings' => $inventoryWarehouseMappings,
]; ];
} }
@ -490,6 +508,7 @@ class Pages extends Controller
'agreementCourierNames' => $agreementCourierNames, 'agreementCourierNames' => $agreementCourierNames,
'agreementCourierAccounts' => $agreementCourierAccounts, 'agreementCourierAccounts' => $agreementCourierAccounts,
'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug'] ?? null), 'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug'] ?? null),
'inventoryWarehouseMappings' => $inventoryWarehouseMappings,
]; ];
} }
@ -513,8 +532,11 @@ class Pages extends Controller
$perPage, $perPage,
); );
$shortNames = app(InventoryWarehouseShortNames::class);
$names = $shortNames->forCompany((int) (company_id() ?: 1));
return [new LengthAwarePaginator( return [new LengthAwarePaginator(
$result['data'], array_map(static fn (object $row): object => $shortNames->fill($row, $names), $result['data']),
$result['total'], $result['total'],
$perPage, $perPage,
$page, $page,
@ -535,6 +557,163 @@ class Pages extends Controller
]), $error]; ]), $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<string,string> */ /** @return array<string,string> */
private function inventoryReportFilters(Request $request): array private function inventoryReportFilters(Request $request): array
{ {
@ -1345,7 +1524,7 @@ class Pages extends Controller
if ($scope === 'all' && should_queue()) { if ($scope === 'all' && should_queue()) {
$fileName = Str::filename('进销存报表') . '-' . now()->format('Ymd_His') . '-' . Str::random(8) . '.csv'; $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(); flash('进销存报表已提交后台生成,完成后可从通知中心下载。')->success();
return back(); return back();
@ -1358,7 +1537,7 @@ class Pages extends Controller
// download connection. The job stores the completed file and // download connection. The job stores the completed file and
// raises the existing notification when it is ready. // raises the existing notification when it is ready.
$fileName = Str::filename('进销存报表') . '-' . now()->format('Ymd_His') . '-' . Str::random(8) . '.csv'; $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(); flash('进销存报表已提交后台生成,完成后可从通知中心下载。')->success();
return back(); return back();
@ -1386,8 +1565,11 @@ class Pages extends Controller
$rows = $scope === 'page' $rows = $scope === 'page'
? $this->thirdPartyWarehouseStorage->paginateMonthlyInventoryReport($filters, $page, $perPage, false)['data'] ? $this->thirdPartyWarehouseStorage->paginateMonthlyInventoryReport($filters, $page, $perPage, false)['data']
: $this->thirdPartyWarehouseStorage->exportMonthlyInventoryReportRows($filters); : $this->thirdPartyWarehouseStorage->exportMonthlyInventoryReportRows($filters);
$shortNames = app(InventoryWarehouseShortNames::class);
$names = $shortNames->forCompany((int) (company_id() ?: 1));
$written = 0; $written = 0;
foreach ($rows as $row) { foreach ($rows as $row) {
$row = $shortNames->fill($row, $names);
fputcsv($csv, array_map(function (array $column) use ($row): mixed { fputcsv($csv, array_map(function (array $column) use ($row): mixed {
$value = data_get($row, $column['name']); $value = data_get($row, $column['name']);

View File

@ -3,6 +3,7 @@
namespace App\Jobs\Common; namespace App\Jobs\Common;
use App\Abstracts\JobShouldQueue; use App\Abstracts\JobShouldQueue;
use App\Business\InventoryWarehouseShortNames;
use App\Notifications\Common\ExportFailed; use App\Notifications\Common\ExportFailed;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository; use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
@ -15,16 +16,20 @@ class InventoryReportExport extends JobShouldQueue
public int $tries = 1; public int $tries = 1;
protected int $company_id = 1;
public function __construct( public function __construct(
protected $user, protected $user,
protected array $filters, protected array $filters,
protected array $columns, protected array $columns,
protected string $file_name, protected string $file_name,
?int $companyId = null,
) { ) {
$this->company_id = $companyId ?? 1;
$this->onQueue('exports'); $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); $path = storage_path('app/temp/' . $this->file_name);
File::ensureDirectoryExists(dirname($path)); File::ensureDirectoryExists(dirname($path));
@ -37,7 +42,9 @@ class InventoryReportExport extends JobShouldQueue
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($this->columns, 'label')); fputcsv($csv, array_column($this->columns, 'label'));
$names = $shortNames->forCompany($this->company_id);
foreach ($repository->exportMonthlyInventoryReportRows($this->filters) as $row) { foreach ($repository->exportMonthlyInventoryReportRows($this->filters) as $row) {
$row = $shortNames->fill($row, $names);
fputcsv($csv, array_map(function (array $column) use ($row): mixed { fputcsv($csv, array_map(function (array $column) use ($row): mixed {
$value = data_get($row, $column['name']); $value = data_get($row, $column['name']);

View File

@ -0,0 +1,34 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
class InventoryWarehouseNameMapping extends Model
{
protected $table = 'business_inventory_warehouse_name_mappings';
protected $fillable = [
'company_id',
'warehouse_provider',
'warehouse',
'warehouse_short_name',
'created_by',
'updated_by',
];
protected $casts = [
'company_id' => 'integer',
'created_by' => 'integer',
'updated_by' => 'integer',
];
public static function columnLabels(): array
{
return [
'warehouse_provider' => '仓储方',
'warehouse' => '仓库',
'warehouse_short_name' => '仓库简称',
];
}
}

View File

@ -335,6 +335,24 @@ $navigation = [
'pages' => [ 'pages' => [
['slug' => 'normal-products', 'title' => '普通商品资料'], ['slug' => 'normal-products', 'title' => '普通商品资料'],
['slug' => 'online-daifa-price', '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', 'slug' => 'inventory-report',
'title' => '进销存报表', 'title' => '进销存报表',
@ -348,6 +366,7 @@ $navigation = [
['name' => 'month', 'label' => '月份', 'kind' => 'date'], ['name' => 'month', 'label' => '月份', 'kind' => 'date'],
['name' => 'warehouse_provider', 'label' => '仓储方'], ['name' => 'warehouse_provider', 'label' => '仓储方'],
['name' => 'warehouse', 'label' => '仓库'], ['name' => 'warehouse', 'label' => '仓库'],
['name' => 'warehouse_short_name', 'label' => '仓库简称'],
['name' => 'product_code', 'label' => '商品编码'], ['name' => 'product_code', 'label' => '商品编码'],
['name' => 'category', 'label' => '分类'], ['name' => 'category', 'label' => '分类'],
['name' => 'opening_quantity', 'label' => '期初数量', 'kind' => 'integer'], ['name' => 'opening_quantity', 'label' => '期初数量', 'kind' => 'integer'],

View File

@ -33,4 +33,20 @@ return [
realpath(storage_path('framework/views')) 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),
]; ];

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('business_inventory_warehouse_name_mappings')) {
return;
}
Schema::create('business_inventory_warehouse_name_mappings', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -37,6 +37,11 @@ run_artisan_as_web() {
runuser -u www-data -- php artisan "$@" 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 if [ "${1:-}" = "apache2-foreground" ]; then
sed -ri 's/^Listen 80$/Listen 18088/' /etc/apache2/ports.conf sed -ri 's/^Listen 80$/Listen 18088/' /etc/apache2/ports.conf
assert_web_writable assert_web_writable
@ -53,9 +58,18 @@ if [ "${1:-}" = "apache2-foreground" ]; then
run_artisan_as_web migrate --force run_artisan_as_web migrate --force
fi fi
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 config:cache
run_artisan_as_web view:cache run_artisan_as_web view:cache
fi fi
fi
fi fi
exec "$@" exec "$@"

View File

@ -81,6 +81,7 @@ calculate_local_image_fingerprint() {
deploy/Dockerfile.local \ deploy/Dockerfile.local \
deploy/apache-vhost.conf \ deploy/apache-vhost.conf \
deploy/php.ini \ deploy/php.ini \
deploy/php-local.ini \
deploy/entrypoint.sh \ deploy/entrypoint.sh \
deploy/local-healthcheck.sh deploy/local-healthcheck.sh

View File

@ -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.validate_timestamps=1
opcache.revalidate_freq=0 opcache.revalidate_freq=0

View File

@ -1,6 +1,11 @@
@php @php
$directoryRows = $rows ?? collect(); $directoryRows = $rows ?? collect();
$directoryFields = $directory['fields'] ?? []; $directoryFields = $directory['fields'] ?? [];
$isInventoryReportPage = ($page['slug'] ?? '') === 'inventory-report';
$inventoryWarehouseMappings = $isInventoryReportPage
? ($inventoryWarehouseMappings ?? collect())
: collect();
$warehouseMappingPreview = $isInventoryReportPage ? session('warehouse_mapping_preview') : null;
// “最近三个月销量”必须是三个独立的月份列;月份按当前月向前取三个完整月份。 // “最近三个月销量”必须是三个独立的月份列;月份按当前月向前取三个完整月份。
// 保留原字段名作为兼容契约,渲染时展开为 *_1、*_2、*_3 三个值。 // 保留原字段名作为兼容契约,渲染时展开为 *_1、*_2、*_3 三个值。
$directoryFields = collect($directoryFields)->flatMap(function (array $field): array { $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-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-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-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 { 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 { display: inline-flex; align-items: center; cursor: pointer; list-style: none; }
.directory-header-menu summary::-webkit-details-marker { display: none; } .directory-header-menu summary::-webkit-details-marker { display: none; }
@ -565,6 +572,256 @@
text-decoration: underline; 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) { @media (max-width: 700px) {
.directory-table-head { .directory-table-head {
flex-direction: column; flex-direction: column;
@ -609,6 +866,27 @@
<span>已从共享业务镜像读取 {{ $directoryTotal }} 条数据,当前页面为只读展示。</span> <span>已从共享业务镜像读取 {{ $directoryTotal }} 条数据,当前页面为只读展示。</span>
</div> </div>
@endif @endif
@else
@if ($isInventoryReportPage)
<nav class="business-tab-bar inventory-report-tabs" data-inventory-report-tabs role="tablist" aria-label="进销存报表数据表">
<button type="button" role="tab" aria-selected="true" aria-controls="inventory-report-total-panel" id="inventory-report-total-tab" data-inventory-report-tab="total">
进销存总表
</button>
<button type="button" role="tab" aria-selected="false" aria-controls="inventory-report-mapping-panel" id="inventory-report-mapping-tab" data-inventory-report-tab="mapping" tabindex="-1">
仓库名称映射表
</button>
</nav>
<section
id="inventory-report-total-panel"
class="inventory-report-panel"
data-inventory-report-panel="total"
data-directory-table-scope
data-directory-table-key="total"
data-directory-last-page="{{ $directoryLastPage }}"
data-directory-source-url="{{ request()->fullUrlWithQuery(['page' => 1]) }}"
role="tabpanel"
aria-labelledby="inventory-report-total-tab"
>
@else @else
<header class="directory-table-head"> <header class="directory-table-head">
<div class="directory-table-title"> <div class="directory-table-title">
@ -622,6 +900,7 @@
</div> </div>
<span class="directory-table-status" data-directory-status>{{ $directory['status'] ?? '尚未同步' }}</span> <span class="directory-table-status" data-directory-status>{{ $directory['status'] ?? '尚未同步' }}</span>
</header> </header>
@endif
@if (!empty($upstreamError)) @if (!empty($upstreamError))
<div class="directory-table-notice" role="alert"> <div class="directory-table-notice" role="alert">
@ -1008,12 +1287,17 @@
</div> </div>
@endif @endif
<div class="directory-table-scroll"> <div class="directory-table-scroll">
<table class="directory-table" @if($directoryUsesFieldQuery) data-columns-ready="true" @endif> <table
id="{{ $isInventoryReportPage ? 'inventory-report-total-table' : 'directory-table-' . ($page['slug'] ?? 'page') }}"
class="directory-table"
@if($isInventoryReportPage) data-directory-table-key="total" @endif
@if($directoryUsesFieldQuery) data-columns-ready="true" @endif
>
<thead> <thead>
<tr> <tr>
<th scope="col">#</th> <th scope="col" data-directory-fixed="start">#</th>
@foreach ($directoryFields as $field) @foreach ($directoryFields as $field)
<th scope="col" data-column-key="{{ $field['name'] }}" class="{{ in_array($field['kind'] ?? null, ['number', 'integer'], true) ? 'is-number' : '' }} {{ !in_array($field['name'], $visibleDirectoryColumns, true) ? 'is-column-hidden' : '' }}"> <th scope="col" data-column-key="{{ $field['name'] }}" @if($isInventoryReportPage && $field['name'] === 'warehouse_short_name') aria-sort="none" @endif class="{{ in_array($field['kind'] ?? null, ['number', 'integer'], true) ? 'is-number' : '' }} {{ !in_array($field['name'], $visibleDirectoryColumns, true) ? 'is-column-hidden' : '' }}">
@if ($directoryUsesFieldQuery) @if ($directoryUsesFieldQuery)
@php @php
$isSorted = request('sort') === $field['name']; $isSorted = request('sort') === $field['name'];
@ -1031,6 +1315,8 @@
</div> </div>
</details> </details>
</div> </div>
@elseif ($isInventoryReportPage && $field['name'] === 'warehouse_short_name')
<button type="button" class="directory-inventory-sort" data-inventory-short-name-sort aria-label="仓库简称按当前页升序排序" title="当前页排序">{{ $field['label'] }}<span class="material-icons-outlined directory-sort-icon" aria-hidden="true">unfold_more</span></button>
@else @else
{{ $field['label'] }} {{ $field['label'] }}
@endif @endif
@ -1041,7 +1327,7 @@
<tbody> <tbody>
@forelse ($directoryRows as $rowIndex => $row) @forelse ($directoryRows as $rowIndex => $row)
<tr @if (($page['slug'] ?? '') === 'subject-operating-report') data-subject-row data-subject-details='@json($row->company_details ?? [])' tabindex="0" aria-label="查看主体详细资料" @endif> <tr @if (($page['slug'] ?? '') === 'subject-operating-report') data-subject-row data-subject-details='@json($row->company_details ?? [])' tabindex="0" aria-label="查看主体详细资料" @endif>
<td>{{ $rowIndex + 1 + (($directoryCurrentPage - 1) * $directoryPerPage) }}</td> <td data-directory-fixed="start">{{ $rowIndex + 1 + (($directoryCurrentPage - 1) * $directoryPerPage) }}</td>
@foreach ($directoryFields as $field) @foreach ($directoryFields as $field)
@php @php
$source = array_key_exists('source', $field) ? $field['source'] : $field['name']; $source = array_key_exists('source', $field) ? $field['source'] : $field['name'];
@ -1080,7 +1366,7 @@
</span> </span>
@endif @endif
@else @else
{{ filled($display) ? $display : '—' }} {{ filled($display) ? $display : ($isInventoryReportPage && $field['name'] === 'warehouse_short_name' ? '' : '—') }}
@endif @endif
</td> </td>
@endforeach @endforeach
@ -1118,6 +1404,164 @@
</footer> </footer>
@endif @endif
@if ($isInventoryReportPage)
</section>
<section
id="inventory-report-mapping-panel"
class="inventory-report-panel"
data-inventory-report-panel="mapping"
data-directory-table-scope
data-directory-table-key="warehouse-mapping"
data-directory-last-page="{{ $inventoryWarehouseMappings->lastPage() }}"
data-directory-source-url="{{ request()->fullUrlWithQuery(['mapping' => 1, 'page' => 1]) }}"
role="tabpanel"
aria-labelledby="inventory-report-mapping-tab"
hidden
>
<div class="inventory-warehouse-mapping">
<form method="GET" action="{{ request()->url() }}" class="directory-query-bar inventory-warehouse-mapping__filters" aria-label="仓库名称映射查询">
<input type="hidden" name="mapping" value="1">
<label class="directory-query-field">
<span>仓储方</span>
<input type="search" name="mapping_warehouse_provider" value="{{ request('mapping_warehouse_provider') }}" placeholder="按仓储方包含查询">
</label>
<label class="directory-query-field">
<span>仓库名</span>
<input type="search" name="mapping_warehouse" value="{{ request('mapping_warehouse') }}" placeholder="按仓库名包含查询">
</label>
<div class="directory-query-actions">
<button type="submit" class="is-primary">查询</button>
<a href="{{ request()->url() }}?mapping=1" class="biz-btn biz-btn-ghost">清除</a>
</div>
</form>
<div class="directory-table-tools" data-directory-tools>
<label class="sr-only" for="directory-search-inventory-warehouse-mapping">搜索当前表格</label>
<input id="directory-search-inventory-warehouse-mapping" type="search" placeholder="搜索当前页数据..." data-directory-search>
<div class="directory-field-picker" data-directory-field-picker>
<button type="button" aria-expanded="false" aria-controls="directory-fields-inventory-warehouse-mapping" data-directory-field-toggle>列设置</button>
<div class="directory-field-picker__panel" id="directory-fields-inventory-warehouse-mapping" role="group" aria-label="选择展示字段" hidden>
<span class="directory-field-picker__hint">可隐藏业务字段并拖动调整顺序;序号和操作列固定。</span>
@foreach ([
['name' => 'warehouse_provider', 'label' => '仓储方'],
['name' => 'warehouse', 'label' => '仓库'],
['name' => 'warehouse_short_name', 'label' => '仓库简称'],
] as $mappingField)
<label class="directory-field-picker__option" draggable="true" data-directory-field-option>
<input type="checkbox" value="{{ $mappingField['name'] }}" data-directory-field-check checked>
<span>{{ $mappingField['label'] }}</span>
</label>
@endforeach
</div>
</div>
<div class="directory-export-group" aria-label="导出范围">
<button type="button" data-directory-export="visible">当前页·显示列</button>
<button type="button" data-directory-export="all-fields">当前页·全部字段</button>
<button type="button" data-directory-export-all="visible">全部筛选结果·显示列</button>
<button type="button" data-directory-export-all="all-fields">全部筛选结果·全部字段</button>
</div>
<button type="button" data-directory-reset>恢复默认</button>
<button type="button" data-inventory-mapping-template>下载导入模板</button>
<button type="button" class="is-primary" data-inventory-mapping-create>新增仓库</button>
<a class="inventory-warehouse-mapping__export" href="{{ route('business.product-inventory.inventory-report.warehouse-mappings.export', request()->only(['mapping_warehouse_provider', 'mapping_warehouse'])) }}">导出完整表格(Excel)</a>
<form method="POST" enctype="multipart/form-data" action="{{ route('business.product-inventory.inventory-report.warehouse-mappings.import.preview') }}" class="inventory-warehouse-mapping__import">
@csrf
<input type="hidden" name="mapping_warehouse_provider" value="{{ request('mapping_warehouse_provider') }}">
<input type="hidden" name="mapping_warehouse" value="{{ request('mapping_warehouse') }}">
<label class="inventory-warehouse-mapping__file-label" for="inventory-warehouse-mapping-file">
<span>选择文件</span>
<input id="inventory-warehouse-mapping-file" type="file" name="import" accept=".xlsx,.xls,.csv" required data-inventory-mapping-import-file>
</label>
<button type="submit" class="inventory-warehouse-mapping__import-button">导入并预览</button>
</form>
</div>
@if (is_array($warehouseMappingPreview))
@php($previewSummary = $warehouseMappingPreview['summary'] ?? [])
<details class="inventory-warehouse-mapping__preview" open>
<summary>导入预览:新增 {{ $previewSummary['created'] ?? 0 }} 条,重复 {{ $previewSummary['same'] ?? 0 }} 条,冲突 {{ $previewSummary['conflicts'] ?? 0 }} 条,失败 {{ $previewSummary['invalid'] ?? 0 }} 条</summary>
<table>
<thead><tr><th>行号</th><th>仓储方</th><th>仓库</th><th>仓库简称</th><th>结果</th><th>原因</th></tr></thead>
<tbody>
@foreach (($warehouseMappingPreview['rows'] ?? []) as $previewRow)
<tr class="is-{{ $previewRow['status'] }}">
<td>{{ $previewRow['row_number'] }}</td>
<td>{{ $previewRow['warehouse_provider'] ?: '—' }}</td>
<td>{{ $previewRow['warehouse'] ?: '—' }}</td>
<td>{{ $previewRow['warehouse_short_name'] ?: '—' }}</td>
<td>{{ match ($previewRow['status']) { 'new' => '待新增', 'same' => '跳过', 'conflict' => '冲突', default => '失败' } }}</td>
<td>{{ $previewRow['message'] }}</td>
</tr>
@endforeach
</tbody>
</table>
@if (($previewSummary['conflicts'] ?? 0) === 0 && ($previewSummary['invalid'] ?? 0) === 0)
<form method="POST" action="{{ route('business.product-inventory.inventory-report.warehouse-mappings.import.commit') }}" style="padding:0.75rem 1rem;">
@csrf
<input type="hidden" name="preview_token" value="{{ $warehouseMappingPreview['token'] ?? '' }}">
<button type="submit" class="is-primary">确认导入</button>
</form>
@else
<p style="padding:0 1rem 0.75rem;margin:0;color:#a33a32;">存在失败或冲突行,系统不会写入任何记录。请修正 Excel 后重新预览。</p>
@endif
</details>
@endif
@if ($inventoryWarehouseMappings->isEmpty())
<div class="inventory-warehouse-mapping__empty">当前查询暂无仓库名称映射。可点击“新增仓库”或上传 Sheet1 导入。</div>
@else
<div class="directory-table-scroll">
<table id="inventory-warehouse-mapping-table" class="directory-table inventory-warehouse-mapping__table" data-directory-table-key="warehouse-mapping">
<thead>
<tr>
<th scope="col" data-directory-fixed="start">#</th>
<th scope="col" data-column-key="warehouse_provider">仓储方</th>
<th scope="col" data-column-key="warehouse">仓库</th>
<th scope="col" data-column-key="warehouse_short_name">仓库简称</th>
<th scope="col" data-directory-fixed="end">操作</th>
</tr>
</thead>
<tbody>
@foreach ($inventoryWarehouseMappings as $mapping)
<tr>
<td data-directory-fixed="start">{{ ($inventoryWarehouseMappings->currentPage() - 1) * $inventoryWarehouseMappings->perPage() + $loop->iteration }}</td>
<td data-column-key="warehouse_provider">{{ $mapping->warehouse_provider }}</td>
<td data-column-key="warehouse">{{ $mapping->warehouse }}</td>
<td data-column-key="warehouse_short_name">{{ $mapping->warehouse_short_name }}</td>
<td data-directory-fixed="end">
<span class="inventory-warehouse-mapping__actions">
<button type="button" data-inventory-mapping-edit data-id="{{ $mapping->id }}" data-provider="{{ $mapping->warehouse_provider }}" data-warehouse="{{ $mapping->warehouse }}" data-short-name="{{ $mapping->warehouse_short_name }}">修改</button>
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<footer class="directory-table-pagination">
<span>共 {{ $inventoryWarehouseMappings->total() }} 条 · 第 {{ $inventoryWarehouseMappings->currentPage() }} / {{ $inventoryWarehouseMappings->lastPage() }} 页</span>
<span>
@if ($inventoryWarehouseMappings->onFirstPage()) <span>上一页</span> @else <a href="{{ $inventoryWarehouseMappings->previousPageUrl() }}&mapping=1">上一页</a> @endif
·
@if ($inventoryWarehouseMappings->hasMorePages()) <a href="{{ $inventoryWarehouseMappings->nextPageUrl() }}&mapping=1">下一页</a> @else <span>下一页</span> @endif
</span>
</footer>
@endif
</div>
<dialog class="inventory-warehouse-mapping__dialog" data-inventory-mapping-dialog>
<form method="POST" action="{{ route('business.product-inventory.inventory-report.warehouse-mappings.store') }}" data-inventory-mapping-form>
@csrf
<input type="hidden" name="_method" value="POST" data-inventory-mapping-method>
<h3 data-inventory-mapping-dialog-title>新增仓库名称映射</h3>
<label>仓储方 <input name="warehouse_provider" required maxlength="191" data-inventory-mapping-provider></label>
<label>仓库 <input name="warehouse" required maxlength="191" data-inventory-mapping-warehouse></label>
<label>仓库简称 <input name="warehouse_short_name" required maxlength="191" data-inventory-mapping-short-name><small>输入仓储方和仓库后会自动预填,可按业务需要修改。</small></label>
<div class="inventory-warehouse-mapping__dialog__actions">
<button type="button" data-inventory-mapping-cancel>取消</button>
<button type="submit" class="is-primary">保存</button>
</div>
</form>
</dialog>
</section>
@endif
@if (($page['slug'] ?? '') === 'subject-operating-report') @if (($page['slug'] ?? '') === 'subject-operating-report')
<section class="directory-subject-details" data-subject-details-panel hidden aria-live="polite"> <section class="directory-subject-details" data-subject-details-panel hidden aria-live="polite">
<header class="directory-subject-details__head"> <header class="directory-subject-details__head">
@ -1345,24 +1789,58 @@
}); });
document.querySelectorAll('[data-directory-tools]').forEach((tools) => { document.querySelectorAll('[data-directory-tools]').forEach((tools) => {
const section = tools.closest('[data-directory-page]'); const section = tools.closest('[data-directory-page]');
const table = section?.querySelector('.directory-table'); const tableScope = tools.closest('[data-directory-table-scope]');
const table = tableScope?.querySelector('.directory-table') || section?.querySelector('.directory-table');
const search = tools.querySelector('[data-directory-search]'); const search = tools.querySelector('[data-directory-search]');
if (!table || !search) return; if (!table || !search) return;
table.addEventListener('click', (event) => {
const shortNameSort = event.target instanceof Element
? event.target.closest('[data-inventory-short-name-sort]')
: null;
if (!shortNameSort) return;
const header = shortNameSort.closest('th');
const ascending = header.getAttribute('aria-sort') !== 'ascending';
const rows = [...table.tBodies[0].querySelectorAll('tr:not(.is-empty)')];
rows.sort((left, right) => {
const a = left.querySelector('[data-column-key="warehouse_short_name"]')?.textContent.trim() || '';
const b = right.querySelector('[data-column-key="warehouse_short_name"]')?.textContent.trim() || '';
if (!a) return b ? 1 : 0;
if (!b) return -1;
return a.localeCompare(b, 'zh-Hans-CN') * (ascending ? 1 : -1);
}).forEach((row) => table.tBodies[0].appendChild(row));
header.setAttribute('aria-sort', ascending ? 'ascending' : 'descending');
shortNameSort.setAttribute('aria-label', `仓库简称按当前页${ascending ? '降序' : '升序'}排序`);
shortNameSort.querySelector('.directory-sort-icon').textContent = ascending ? 'arrow_upward' : 'arrow_downward';
});
const picker = tools.querySelector('[data-directory-field-picker]'); const picker = tools.querySelector('[data-directory-field-picker]');
const toggle = picker?.querySelector('[data-directory-field-toggle]'); const toggle = picker?.querySelector('[data-directory-field-toggle]');
const panel = picker?.querySelector('.directory-field-picker__panel'); const panel = picker?.querySelector('.directory-field-picker__panel');
const checks = [...(picker?.querySelectorAll('[data-directory-field-check]') || [])]; const checks = [...(picker?.querySelectorAll('[data-directory-field-check]') || [])];
const tableKey = tableScope?.dataset.directoryTableKey || section?.dataset.directoryTab || 'table';
const sourceUrl = tableScope?.dataset.directorySourceUrl || window.location.href;
const lastPage = Number(tableScope?.dataset.directoryLastPage || section?.dataset.directoryLastPage || 1);
const storageKey = [ const storageKey = [
'directory-fields', 'directory-fields',
section.dataset.directoryUser || 'guest', section.dataset.directoryUser || 'guest',
section.dataset.directorySection || 'section', section.dataset.directorySection || 'section',
section.dataset.directoryPage || 'page', section.dataset.directoryPage || 'page',
section.dataset.directoryTab || 'tab', tableKey,
].join(':'); ].join(':');
const defaultOrder = checks.map((check) => check.value); const defaultOrder = checks.map((check) => check.value);
const orderedChecks = () => [...(picker?.querySelectorAll('[data-directory-field-option]') || [])] const orderedChecks = () => [...(picker?.querySelectorAll('[data-directory-field-option]') || [])]
.map((option) => option.querySelector('[data-directory-field-check]')) .map((option) => option.querySelector('[data-directory-field-check]'))
.filter(Boolean); .filter(Boolean);
const orderedExportCells = (row, selected) => {
const cells = [...row.children];
const fixedStart = cells.filter((cell) => cell.dataset.directoryFixed === 'start');
const rank = new Map(orderedChecks().map((check, index) => [check.value, index]));
const keyed = cells
.filter((cell) => cell.dataset.columnKey && (!selected || selected.has(cell.dataset.columnKey)))
.sort((a, b) => (rank.get(a.dataset.columnKey) ?? 0) - (rank.get(b.dataset.columnKey) ?? 0));
const other = cells.filter((cell) => !cell.dataset.columnKey && !cell.dataset.directoryFixed);
return [...fixedStart, ...keyed, ...other];
};
const applyColumns = (save = true) => { const applyColumns = (save = true) => {
const ordered = orderedChecks(); const ordered = orderedChecks();
const selected = new Set(ordered.filter((check) => check.checked).map((check) => check.value)); const selected = new Set(ordered.filter((check) => check.checked).map((check) => check.value));
@ -1371,13 +1849,21 @@
table.querySelectorAll('[data-column-key]').forEach((cell) => { table.querySelectorAll('[data-column-key]').forEach((cell) => {
cell.classList.toggle('is-column-hidden', !selected.has(cell.dataset.columnKey)); cell.classList.toggle('is-column-hidden', !selected.has(cell.dataset.columnKey));
}); });
const reorderRow = (row) => {
const cells = [...row.children];
const fixedStart = cells.filter((cell) => cell.dataset.directoryFixed === 'start');
const fixedEnd = cells.filter((cell) => cell.dataset.directoryFixed === 'end');
const keyed = cells
.filter((cell) => cell.dataset.columnKey)
.sort((a, b) => (rank.get(a.dataset.columnKey) ?? 0) - (rank.get(b.dataset.columnKey) ?? 0));
const other = cells.filter((cell) => !cell.dataset.columnKey && !cell.dataset.directoryFixed);
[...fixedStart, ...keyed, ...fixedEnd, ...other].forEach((cell) => row.appendChild(cell));
};
[...table.tHead.rows].forEach((row) => { [...table.tHead.rows].forEach((row) => {
[...row.children].sort((a, b) => (rank.get(a.dataset.columnKey) ?? -1) - (rank.get(b.dataset.columnKey) ?? -1)) reorderRow(row);
.forEach((cell) => row.appendChild(cell));
}); });
table.tBodies[0]?.querySelectorAll('tr').forEach((row) => { table.tBodies[0]?.querySelectorAll('tr').forEach((row) => {
[...row.children].slice(1).sort((a, b) => (rank.get(a.dataset.columnKey) ?? -1) - (rank.get(b.dataset.columnKey) ?? -1)) reorderRow(row);
.forEach((cell) => row.appendChild(cell));
}); });
if (save) localStorage.setItem(storageKey, JSON.stringify({ order: ordered.map((check) => check.value), selected: [...selected] })); if (save) localStorage.setItem(storageKey, JSON.stringify({ order: ordered.map((check) => check.value), selected: [...selected] }));
}; };
@ -1385,6 +1871,16 @@
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null'); const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
const savedOrder = Array.isArray(saved) ? saved : saved?.order; const savedOrder = Array.isArray(saved) ? saved : saved?.order;
const savedSelected = Array.isArray(saved) ? saved : saved?.selected; const savedSelected = Array.isArray(saved) ? saved : saved?.selected;
if (table.id === 'inventory-report-total-table' && !localStorage.getItem(`${storageKey}:warehouse-short-name-v1`)) {
if (Array.isArray(savedOrder) && !savedOrder.includes('warehouse_short_name')) {
savedOrder.splice(savedOrder.indexOf('warehouse') + 1, 0, 'warehouse_short_name');
}
if (Array.isArray(savedSelected) && !savedSelected.includes('warehouse_short_name')) {
savedSelected.push('warehouse_short_name');
}
if (saved) localStorage.setItem(storageKey, JSON.stringify({ order: savedOrder, selected: savedSelected }));
localStorage.setItem(`${storageKey}:warehouse-short-name-v1`, '1');
}
if (Array.isArray(savedSelected)) checks.forEach((check) => { check.checked = savedSelected.includes(check.value); }); if (Array.isArray(savedSelected)) checks.forEach((check) => { check.checked = savedSelected.includes(check.value); });
if (Array.isArray(savedOrder)) savedOrder.forEach((key) => { if (Array.isArray(savedOrder)) savedOrder.forEach((key) => {
const option = picker.querySelector(`[data-directory-field-option] input[value="${CSS.escape(key)}"]`)?.closest('[data-directory-field-option]'); const option = picker.querySelector(`[data-directory-field-option] input[value="${CSS.escape(key)}"]`)?.closest('[data-directory-field-option]');
@ -1449,8 +1945,7 @@
tools.querySelectorAll('[data-directory-export]').forEach((button) => button.addEventListener('click', () => { tools.querySelectorAll('[data-directory-export]').forEach((button) => button.addEventListener('click', () => {
const selected = button.dataset.directoryExport === 'all-fields' ? null : new Set(orderedChecks().filter((check) => check.checked).map((check) => check.value)); const selected = button.dataset.directoryExport === 'all-fields' ? null : new Set(orderedChecks().filter((check) => check.checked).map((check) => check.value));
const rows = [...table.querySelectorAll('tr')].filter((row) => !row.hidden); const rows = [...table.querySelectorAll('tr')].filter((row) => !row.hidden);
const cellsForExport = (row) => [...row.children].filter((cell) => !cell.dataset.columnKey || !selected || selected.has(cell.dataset.columnKey)); const csv = rows.map((row) => orderedExportCells(row, selected).map((cell) => csvEscape((cell.querySelector('[data-password-status]') || cell).textContent.trim())).join(',')).join('\n');
const csv = rows.map((row) => cellsForExport(row).map((cell) => csvEscape((cell.querySelector('[data-password-status]') || cell).textContent.trim())).join(',')).join('\n');
const blob = new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8' }); const blob = new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8' });
const link = document.createElement('a'); const link = document.createElement('a');
link.href = URL.createObjectURL(blob); link.href = URL.createObjectURL(blob);
@ -1460,25 +1955,25 @@
})); }));
tools.querySelectorAll('[data-directory-export-all]').forEach((button) => button.addEventListener('click', async (event) => { tools.querySelectorAll('[data-directory-export-all]').forEach((button) => button.addEventListener('click', async (event) => {
const button = event.currentTarget; const button = event.currentTarget;
const lastPage = Number(section.dataset.directoryLastPage || 1);
const selected = button.dataset.directoryExportAll === 'all-fields' ? null : new Set(orderedChecks().filter((check) => check.checked).map((check) => check.value)); const selected = button.dataset.directoryExportAll === 'all-fields' ? null : new Set(orderedChecks().filter((check) => check.checked).map((check) => check.value));
const cellsForExport = (row) => [...row.children] const header = orderedExportCells(table.tHead.rows[0], selected).map((cell) => csvEscape(cell.textContent.trim())).join(',');
.filter((cell) => !cell.dataset.columnKey || !selected || selected.has(cell.dataset.columnKey));
const header = cellsForExport(table.tHead.rows[0]).map((cell) => csvEscape(cell.textContent.trim())).join(',');
const lines = []; const lines = [];
button.disabled = true; button.disabled = true;
const originalLabel = button.textContent; const originalLabel = button.textContent;
try { try {
for (let page = 1; page <= lastPage; page += 1) { for (let page = 1; page <= lastPage; page += 1) {
button.textContent = `导出中 ${page}/${lastPage}`; button.textContent = '导出中 ' + page + '/' + lastPage;
const url = new URL(window.location.href); const url = new URL(sourceUrl, window.location.origin);
url.searchParams.set('page', String(page)); url.searchParams.set('page', String(page));
const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (!response.ok) throw new Error(`第 ${page} 页读取失败`); if (!response.ok) throw new Error(`第 ${page} 页读取失败`);
const html = await response.text(); const html = await response.text();
const parsed = new DOMParser().parseFromString(html, 'text/html'); const parsed = new DOMParser().parseFromString(html, 'text/html');
parsed.querySelectorAll('.directory-table tbody tr:not(.is-empty)').forEach((row) => { const parsedTable = table.id
lines.push(cellsForExport(row).map((cell) => csvEscape(cell.textContent.trim())).join(',')); ? parsed.querySelector(`#${CSS.escape(table.id)}`)
: parsed.querySelector('.directory-table');
parsedTable?.querySelectorAll('tbody tr:not(.is-empty)').forEach((row) => {
lines.push(orderedExportCells(row, selected).map((cell) => csvEscape(cell.textContent.trim())).join(','));
}); });
} }
const csv = [header, ...lines].join('\n'); const csv = [header, ...lines].join('\n');
@ -1504,6 +1999,103 @@
link.click(); link.click();
URL.revokeObjectURL(link.href); URL.revokeObjectURL(link.href);
}); });
tools.querySelector('[data-inventory-mapping-template]')?.addEventListener('click', () => {
const header = ['仓储方', '仓库', '仓库简称'].map(csvEscape).join(',');
const blob = new Blob(['\ufeff' + header + '\n'], { type: 'text/csv;charset=utf-8' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = '仓库名称映射表-导入模板.csv';
link.click();
URL.revokeObjectURL(link.href);
});
});
document.querySelectorAll('[data-inventory-report-tabs]').forEach((tabs) => {
const report = tabs.closest('[data-directory-page]');
const buttons = [...tabs.querySelectorAll('[data-inventory-report-tab]')];
const panels = [...(report?.querySelectorAll('[data-inventory-report-panel]') || [])];
if (!report || !buttons.length || !panels.length) return;
const select = (name, focus = false) => {
buttons.forEach((button) => {
const selected = button.dataset.inventoryReportTab === name;
button.setAttribute('aria-selected', String(selected));
button.tabIndex = selected ? 0 : -1;
if (focus && selected) button.focus();
});
panels.forEach((panel) => {
panel.hidden = panel.dataset.inventoryReportPanel !== name;
});
};
buttons.forEach((button, index) => {
button.addEventListener('click', () => select(button.dataset.inventoryReportTab, true));
button.addEventListener('keydown', (event) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const nextIndex = event.key === 'Home' ? 0
: event.key === 'End' ? buttons.length - 1
: (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length;
select(buttons[nextIndex].dataset.inventoryReportTab, true);
});
});
select(new URLSearchParams(window.location.search).get('mapping') === '1' ? 'mapping' : 'total');
});
document.querySelectorAll('[data-inventory-mapping-dialog]').forEach((dialog) => {
const form = dialog.querySelector('[data-inventory-mapping-form]');
const method = form?.querySelector('[data-inventory-mapping-method]');
const title = dialog.querySelector('[data-inventory-mapping-dialog-title]');
const provider = form?.querySelector('[data-inventory-mapping-provider]');
const warehouse = form?.querySelector('[data-inventory-mapping-warehouse]');
const shortName = form?.querySelector('[data-inventory-mapping-short-name]');
const storeUrl = @json(route('business.product-inventory.inventory-report.warehouse-mappings.store'));
const updateUrl = @json(route('business.product-inventory.inventory-report.warehouse-mappings.update', ['id' => '__ID__']));
if (!form || !method || !title || !provider || !warehouse || !shortName) return;
const suggest = () => {
if (shortName.dataset.edited === 'true') return;
const text = `${provider.value} ${warehouse.value}`;
let value = '';
const special = [
['代发仓', '代发仓'], ['京东云仓', '京东云仓-不看'], ['销退仓', '销退仓-不看'],
['客退', '客退仓-不看'], ['次品仓', '退货报废-不看'], ['数量校验仓', '数量校验仓-不看'],
['自动发货仓', '虚拟仓-不看'], ['京东京造', '京东京造-不看'], ['POP仓', '京东入仓-不看'],
['江西仓送货', '南昌樵舍仓'], ['运营C崔桥工厂仓', '崔桥工厂仓'], ['运营C横林二仓', '横林二仓'],
['运营C睢宁', '睢宁仓'], ['运营C新苏北', '新苏北仓'],
];
for (const [needle, result] of special) if (text.includes(needle)) { value = result; break; }
if (!value) {
value = warehouse.value.replace(/(主仓|进货仓)$/u, '').replace(/^(简尚-|京仓-|三方-|奇门-)/u, '');
const providerBase = provider.value.replace(/^(简尚-|京仓-|三方-|奇门-)/u, '');
if (value === providerBase || value.startsWith(providerBase)) value = providerBase;
}
shortName.value = value;
};
provider.addEventListener('input', suggest);
warehouse.addEventListener('input', suggest);
shortName.addEventListener('input', () => { shortName.dataset.edited = 'true'; });
dialog.querySelector('[data-inventory-mapping-cancel]')?.addEventListener('click', () => dialog.close());
document.querySelector('[data-inventory-mapping-create]')?.addEventListener('click', () => {
title.textContent = '新增仓库名称映射';
form.action = storeUrl;
method.value = 'POST';
provider.value = '';
warehouse.value = '';
shortName.value = '';
shortName.dataset.edited = 'false';
dialog.showModal();
provider.focus();
});
document.querySelectorAll('[data-inventory-mapping-edit]').forEach((button) => button.addEventListener('click', () => {
title.textContent = '修改仓库名称映射';
form.action = updateUrl.replace('__ID__', button.dataset.id);
method.value = 'PATCH';
provider.value = button.dataset.provider || '';
warehouse.value = button.dataset.warehouse || '';
shortName.value = button.dataset.shortName || '';
shortName.dataset.edited = 'true';
dialog.showModal();
provider.focus();
}));
dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); });
}); });
document.querySelectorAll('[data-subject-row]').forEach((row) => { document.querySelectorAll('[data-subject-row]').forEach((row) => {

View File

@ -321,11 +321,12 @@
const keyFor = (table) => { const keyFor = (table) => {
if (table.dataset.financeTableKey) return table.dataset.financeTableKey; if (table.dataset.financeTableKey) return table.dataset.financeTableKey;
const currentContext = routeContext(); const currentContext = routeContext();
const parent = table.closest('[data-directory-page], [data-refund-page], [data-finance-table-surface], .business-content-section') || table.parentElement; const tableScope = table.closest('[data-directory-table-scope]');
const parent = tableScope || table.closest('[data-directory-page], [data-refund-page], [data-finance-table-surface], .business-content-section') || table.parentElement;
const name = table.getAttribute('aria-label') || table.dataset.financeTableName || table.className || 'table'; const name = table.getAttribute('aria-label') || table.dataset.financeTableName || table.className || 'table';
const siblings = parent ? [...parent.querySelectorAll(selector)].filter((candidate) => candidate.matches(selector)) : [table]; const siblings = parent ? [...parent.querySelectorAll(selector)].filter((candidate) => candidate.matches(selector)) : [table];
const index = Math.max(0, siblings.indexOf(table)); const index = Math.max(0, siblings.indexOf(table));
const scope = parent?.dataset.directoryPage || parent?.dataset.refundPage || name; const scope = tableScope?.dataset.directoryTableKey || parent?.dataset.directoryPage || parent?.dataset.refundPage || name;
table.dataset.financeTableKey = `${currentContext.user}:${currentContext.section}:${currentContext.page}:${currentContext.tab}:${scope}:${index}`; table.dataset.financeTableKey = `${currentContext.user}:${currentContext.section}:${currentContext.page}:${currentContext.tab}:${scope}:${index}`;
return table.dataset.financeTableKey; return table.dataset.financeTableKey;
}; };
@ -377,6 +378,8 @@
cell.dataset.financeColumnKey = cell.dataset.columnKey || cell.dataset.field || cell.dataset.exportField || `${slug(cell.textContent)}_${index}`; cell.dataset.financeColumnKey = cell.dataset.columnKey || cell.dataset.field || cell.dataset.exportField || `${slug(cell.textContent)}_${index}`;
} }
cell.dataset.financeColumnIndex = String(index); cell.dataset.financeColumnIndex = String(index);
if (!cell.dataset.financeFixed && cell.dataset.directoryFixed === 'start') cell.dataset.financeFixed = 'true';
if (!cell.dataset.financeFixed && cell.dataset.directoryFixed === 'end') cell.dataset.financeFixed = 'end';
if (!cell.dataset.financeFixed && index === 0 && (cell.querySelector('input[type="checkbox"]') || ['#', '序号'].includes(cell.textContent.trim()))) cell.dataset.financeFixed = 'true'; if (!cell.dataset.financeFixed && index === 0 && (cell.querySelector('input[type="checkbox"]') || ['#', '序号'].includes(cell.textContent.trim()))) cell.dataset.financeFixed = 'true';
}); });
const keys = headers.map((cell) => cell.dataset.financeColumnKey); const keys = headers.map((cell) => cell.dataset.financeColumnKey);
@ -555,8 +558,19 @@
}; };
try { try {
const savedVisibility = JSON.parse(localStorage.getItem(storageKey(table, 'visibility')) || 'null'); const savedVisibility = JSON.parse(localStorage.getItem(storageKey(table, 'visibility')) || 'null');
if (Array.isArray(savedVisibility)) checks.forEach((check) => { if (!check.disabled) check.checked = savedVisibility.includes(check.value); });
const savedOrder = JSON.parse(localStorage.getItem(storageKey(table, 'order')) || 'null'); const savedOrder = JSON.parse(localStorage.getItem(storageKey(table, 'order')) || 'null');
if (table.id === 'inventory-report-total-table' && !localStorage.getItem(storageKey(table, 'warehouse-short-name-v1'))) {
if (Array.isArray(savedOrder) && !savedOrder.includes('warehouse_short_name')) {
savedOrder.splice(savedOrder.indexOf('warehouse') + 1, 0, 'warehouse_short_name');
localStorage.setItem(storageKey(table, 'order'), JSON.stringify(savedOrder));
}
if (Array.isArray(savedVisibility) && !savedVisibility.includes('warehouse_short_name')) {
savedVisibility.push('warehouse_short_name');
localStorage.setItem(storageKey(table, 'visibility'), JSON.stringify(savedVisibility));
}
localStorage.setItem(storageKey(table, 'warehouse-short-name-v1'), '1');
}
if (Array.isArray(savedVisibility)) checks.forEach((check) => { if (!check.disabled) check.checked = savedVisibility.includes(check.value); });
applyOrder(Array.isArray(savedOrder) ? savedOrder : defaults, false); applyOrder(Array.isArray(savedOrder) ? savedOrder : defaults, false);
} catch (_) {} } catch (_) {}
setVisibility(table, checks, false); setVisibility(table, checks, false);
@ -596,8 +610,9 @@
} }
function ensureControls(table, headers) { function ensureControls(table, headers) {
const existingDirectoryTools = table.closest('[data-directory-page]')?.querySelector('[data-directory-tools]'); const tableScope = table.closest('[data-directory-table-scope]');
const root = table.closest('[data-finance-table-surface], .upstream-data-table-panel, .directory-table-page, .tb-panel, .ab-panel, .marketing-bills-table-wrapper, .bill-table-wrapper, .jd-wallet-table-wrap, .alibaba-bill-table-wrapper, .zhongcai-table-wrapper') || table.parentElement; const existingDirectoryTools = tableScope?.querySelector('[data-directory-tools]') || table.closest('[data-directory-page]')?.querySelector('[data-directory-tools]');
const root = tableScope || table.closest('[data-finance-table-surface], .upstream-data-table-panel, .directory-table-page, .tb-panel, .ab-panel, .marketing-bills-table-wrapper, .bill-table-wrapper, .jd-wallet-table-wrap, .alibaba-bill-table-wrapper, .zhongcai-table-wrapper') || table.parentElement;
if (!root) return; if (!root) return;
let controls = existingDirectoryTools || root.querySelector('[data-finance-controls]'); let controls = existingDirectoryTools || root.querySelector('[data-finance-controls]');
if (!controls) { if (!controls) {
@ -665,31 +680,46 @@
} }
async function exportDirectory(table, button, includeHiddenRows, allColumns) { async function exportDirectory(table, button, includeHiddenRows, allColumns) {
const tableScope = table.closest('[data-directory-table-scope]');
const page = table.closest('[data-directory-page]'); const page = table.closest('[data-directory-page]');
const search = page?.querySelector('[data-directory-search]'); const search = tableScope?.querySelector('[data-directory-search]') || page?.querySelector('[data-directory-search]');
const headers = leafHeaders(table); const headers = leafHeaders(table);
const selected = new Set([...table.querySelectorAll('thead th[data-finance-column-key]')] const exportableHeaders = headers.filter((header) => header.dataset.financeFixed !== 'end' && header.dataset.directoryFixed !== 'end');
const selected = new Set(exportableHeaders
.filter((cell) => !cell.classList.contains('is-column-hidden')) .filter((cell) => !cell.classList.contains('is-column-hidden'))
.map((cell) => cell.dataset.financeColumnKey)); .map((cell) => cell.dataset.financeColumnKey));
const order = headers.map((header) => header.dataset.financeColumnKey); const order = exportableHeaders.map((header) => header.dataset.financeColumnKey);
const exportOrder = allColumns ? order : order.filter((key) => selected.has(key)); const exportOrder = allColumns ? order : order.filter((key) => selected.has(key));
const header = exportOrder.map((key) => csvEscape(headers.find((cell) => cell.dataset.financeColumnKey === key)?.textContent.trim())).join(','); const header = exportOrder.map((key) => csvEscape(headers.find((cell) => cell.dataset.financeColumnKey === key)?.textContent.trim())).join(',');
const rows = []; const rows = [];
const lastPage = Number(page?.dataset.directoryLastPage || 1); const lastPage = Number(tableScope?.dataset.directoryLastPage || page?.dataset.directoryLastPage || 1);
const sourceUrl = tableScope?.dataset.directorySourceUrl || window.location.href;
button.disabled = true; button.disabled = true;
const originalLabel = button.textContent; const originalLabel = button.textContent;
try { try {
if (includeHiddenRows && lastPage > 1) { if (includeHiddenRows && lastPage > 1) {
for (let current = 1; current <= lastPage; current += 1) { for (let current = 1; current <= lastPage; current += 1) {
button.textContent = `导出中 ${current}/${lastPage}`; button.textContent = `导出中 ${current}/${lastPage}`;
const url = new URL(window.location.href); const url = new URL(sourceUrl, window.location.origin);
url.searchParams.set('page', String(current)); url.searchParams.set('page', String(current));
const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (!response.ok) throw new Error(`第 ${current} 页读取失败`); if (!response.ok) throw new Error(`第 ${current} 页读取失败`);
const parsed = new DOMParser().parseFromString(await response.text(), 'text/html'); const parsed = new DOMParser().parseFromString(await response.text(), 'text/html');
parsed.querySelectorAll('.directory-table tbody tr:not(.is-empty)').forEach((row) => { const parsedTable = table.id
? parsed.querySelector(`#${CSS.escape(table.id)}`)
: parsed.querySelector('.directory-table');
parsedTable?.querySelectorAll('tbody tr:not(.is-empty)').forEach((row) => {
const cells = [...row.children]; const cells = [...row.children];
const map = new Map(cells.map((cell, index) => [cell.dataset.columnKey || headers[index]?.dataset.financeColumnKey, cell])); const fixedStartKey = headers.find((header) => header.dataset.financeFixed === 'true')?.dataset.financeColumnKey;
const fixedEndKey = headers.find((header) => header.dataset.financeFixed === 'end')?.dataset.financeColumnKey;
const map = new Map(cells.map((cell, index) => {
const key = cell.dataset.financeColumnKey
|| cell.dataset.columnKey
|| (cell.dataset.directoryFixed === 'start' ? fixedStartKey : null)
|| (cell.dataset.directoryFixed === 'end' ? fixedEndKey : null)
|| headers[index]?.dataset.financeColumnKey;
return [key, cell];
}).filter(([key]) => key));
if (search?.value && !row.textContent.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase())) return; if (search?.value && !row.textContent.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase())) return;
rows.push(exportOrder.map((key) => csvEscape(cellText(map.get(key)))).join(',')); rows.push(exportOrder.map((key) => csvEscape(cellText(map.get(key)))).join(','));
}); });
@ -766,7 +796,8 @@
} }
const button = event.target.closest('[data-directory-export], [data-directory-export-all], [data-finance-export-all-fields]'); const button = event.target.closest('[data-directory-export], [data-directory-export-all], [data-finance-export-all-fields]');
if (!button || !button.closest('[data-directory-page]')) return; if (!button || !button.closest('[data-directory-page]')) return;
const table = button.closest('[data-directory-page]')?.querySelector('.directory-table'); const tableScope = button.closest('[data-directory-table-scope]');
const table = tableScope?.querySelector('.directory-table') || button.closest('[data-directory-page]')?.querySelector('.directory-table');
if (!table) return; if (!table) return;
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();

View File

@ -647,6 +647,7 @@
'page' => $page, 'page' => $page,
'rows' => $rows, 'rows' => $rows,
'upstreamError' => $upstreamError, 'upstreamError' => $upstreamError,
'inventoryWarehouseMappings' => $inventoryWarehouseMappings ?? collect(),
]) ])
@elseif ($pageView) @elseif ($pageView)
@include($pageView) @include($pageView)

View File

@ -158,6 +158,17 @@ Route::get('workspace/product-inventory/inventory-report/export', 'Business\Page
->middleware('business.page:product-inventory,inventory-report') ->middleware('business.page:product-inventory,inventory-report')
->name('business.product-inventory.inventory-report.export'); ->name('business.product-inventory.inventory-report.export');
Route::prefix('workspace/product-inventory/inventory-report/warehouse-mappings')
->middleware('business.page:product-inventory,inventory-report')
->name('business.product-inventory.inventory-report.warehouse-mappings.')
->group(function () {
Route::post('/', 'Business\Pages@storeInventoryWarehouseMapping')->name('store');
Route::patch('{id}', 'Business\Pages@updateInventoryWarehouseMapping')->whereNumber('id')->name('update');
Route::get('export', 'Business\Pages@exportInventoryWarehouseMappings')->name('export');
Route::post('import/preview', 'Business\Pages@previewInventoryWarehouseMappingImport')->middleware('import')->name('import.preview');
Route::post('import/commit', 'Business\Pages@commitInventoryWarehouseMappingImport')->middleware('import')->name('import.commit');
});
Route::prefix('workspace/supplier-payables/product-prices') Route::prefix('workspace/supplier-payables/product-prices')
->name('business.supplier-payables.product-prices.') ->name('business.supplier-payables.product-prices.')
->middleware('business.page:supplier-payables,product-prices') ->middleware('business.page:supplier-payables,product-prices')

View File

@ -3,9 +3,12 @@
namespace Tests\Feature\Business; namespace Tests\Feature\Business;
use App\Models\Auth\Permission; use App\Models\Auth\Permission;
use App\Models\Business\InventoryWarehouseNameMapping;
use App\Business\InventoryWarehouseShortNames;
use App\Jobs\Common\InventoryReportExport; use App\Jobs\Common\InventoryReportExport;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Mockery; use Mockery;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository; use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository; use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository; use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
@ -18,6 +21,12 @@ class PagesTest extends FeatureTestCase
{ {
public function testInventoryReportProvidesAFilteredExportButton(): void public function testInventoryReportProvidesAFilteredExportButton(): void
{ {
InventoryWarehouseNameMapping::query()->create([
'company_id' => 1,
'warehouse_provider' => '测试映射仓储方',
'warehouse' => '测试映射仓库',
'warehouse_short_name' => '测试映射简称',
]);
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
$repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('available')->once()->andReturnTrue();
$repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue();
@ -31,7 +40,11 @@ class PagesTest extends FeatureTestCase
1, 1,
Mockery::type('int'), Mockery::type('int'),
)->andReturn([ )->andReturn([
'data' => [(object) ['product_code' => '121630-49']], 'data' => [(object) [
'product_code' => '121630-49',
'warehouse_provider' => '测试仓储方',
'warehouse' => '测试主仓',
]],
'total' => 1, 'total' => 1,
'state' => ['status' => 'succeeded'], 'state' => ['status' => 'succeeded'],
'sync' => ['code' => 'healthy'], 'sync' => ['code' => 'healthy'],
@ -50,8 +63,77 @@ class PagesTest extends FeatureTestCase
->assertSee('data-inventory-export-scope', false) ->assertSee('data-inventory-export-scope', false)
->assertSee('data-inventory-export-fields', false) ->assertSee('data-inventory-export-fields', false)
->assertSee('data-inventory-export', false) ->assertSee('data-inventory-export', false)
->assertDontSee('<button type="button" data-directory-export-all', false) ->assertSee('进销存总表')
->assertDontSee('<button type="button" data-directory-template', false); ->assertSee('仓库名称映射表')
->assertSee('测试仓储方')
->assertSee('测试主仓')
->assertSee('data-inventory-report-panel="mapping"', false)
->assertSee('id="inventory-report-total-table"', false)
->assertSee('id="inventory-warehouse-mapping-table"', false)
->assertSee('data-directory-source-url=', false)
->assertSee('mapping=1&amp;page=1', false)
->assertSee('data-directory-table-key="warehouse-mapping"', false)
->assertSee('data-directory-field-picker', false)
->assertSee('data-directory-export-all="visible"', false)
->assertSee('data-directory-export-all="all-fields"', false)
->assertSee('data-inventory-mapping-template', false)
->assertSee('data-directory-fixed="start"', false)
->assertSee('data-directory-fixed="end"', false);
$html = $response->getContent();
$totalPanelStart = strpos($html, 'id="inventory-report-total-panel"');
$mappingPanelStart = strpos($html, 'id="inventory-report-mapping-panel"');
$noticePosition = strpos($html, '已从共享业务镜像读取');
$filterPosition = strpos($html, 'aria-label="进销存报表筛选查询栏"');
$this->assertIsInt($totalPanelStart);
$this->assertIsInt($mappingPanelStart);
$this->assertIsInt($noticePosition);
$this->assertIsInt($filterPosition);
$this->assertGreaterThan($totalPanelStart, $noticePosition);
$this->assertLessThan($mappingPanelStart, $noticePosition);
$this->assertGreaterThan($totalPanelStart, $filterPosition);
$this->assertLessThan($mappingPanelStart, $filterPosition);
}
public function testInventoryReportShowsWarehouseShortNameOnlyForExactProviderAndWarehouse(): void
{
InventoryWarehouseNameMapping::query()->create([
'company_id' => 1,
'warehouse_provider' => '测试仓储方',
'warehouse' => '测试主仓',
'warehouse_short_name' => '崔桥主仓',
]);
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
$repository->shouldReceive('available')->once()->andReturnTrue();
$repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue();
$repository->shouldReceive('paginateMonthlyInventoryReport')->once()->andReturn([
'data' => [
(object) ['warehouse_provider' => '测试仓储方', 'warehouse' => '测试主仓', 'product_code' => 'MATCHED'],
(object) ['warehouse_provider' => '其他仓储方', 'warehouse' => '测试主仓', 'product_code' => 'UNMATCHED'],
],
'total' => 2,
'state' => ['status' => 'succeeded'],
'sync' => ['code' => 'healthy'],
]);
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
$response = $this->loginAs()->get(route('business.pages.show', [
'section' => 'product-inventory', 'page' => 'inventory-report', 'month' => '2026-08',
]));
$response->assertOk()
->assertSee('data-column-key="warehouse_short_name"', false)
->assertSee('data-inventory-short-name-sort', false)
->assertSee('value="warehouse_short_name"', false)
->assertSee('崔桥主仓');
$html = $response->getContent();
$this->assertLessThan(
strpos($html, 'data-column-key="warehouse_short_name"'),
strpos($html, 'data-column-key="warehouse"'),
);
$this->assertMatchesRegularExpression('/data-column-key="warehouse_short_name"[^>]*>\s*崔桥主仓\s*<\/td>/', $html);
$this->assertMatchesRegularExpression('/data-column-key="warehouse_short_name"[^>]*>\s*<\/td>/', $html);
} }
public function testInventoryReportKeepsJulyMonthFilter(): void public function testInventoryReportKeepsJulyMonthFilter(): void
@ -127,7 +209,7 @@ class PagesTest extends FeatureTestCase
$content = $response->streamedContent(); $content = $response->streamedContent();
$this->assertSame((string) strlen($content), $response->headers->get('Content-Length')); $this->assertSame((string) strlen($content), $response->headers->get('Content-Length'));
$this->assertStringStartsWith(chr(0xEF) . chr(0xBB) . chr(0xBF), $content); $this->assertStringStartsWith(chr(0xEF) . chr(0xBB) . chr(0xBF), $content);
$this->assertStringContainsString('货主,月份,仓储方,仓库,商品编码,分类,期初数量,期末数量', $content); $this->assertStringContainsString('货主,月份,仓储方,仓库,仓库简称,商品编码,分类,期初数量,期末数量', $content);
$this->assertStringContainsString("'121630-49", $content); $this->assertStringContainsString("'121630-49", $content);
} }
@ -158,6 +240,37 @@ class PagesTest extends FeatureTestCase
$this->assertSame("\xEF\xBB\xBF商品编码,仓库,期初数量\n'121630-49,主仓,10\n", $response->streamedContent()); $this->assertSame("\xEF\xBB\xBF商品编码,仓库,期初数量\n'121630-49,主仓,10\n", $response->streamedContent());
} }
public function testInventoryReportExportIncludesMappedShortNameAndLeavesUnmatchedBlank(): void
{
InventoryWarehouseNameMapping::query()->create([
'company_id' => 1,
'warehouse_provider' => '测试仓储方',
'warehouse' => '主仓',
'warehouse_short_name' => '测试简称',
]);
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
$repository->shouldReceive('available')->once()->andReturnTrue();
$repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue();
$repository->shouldReceive('paginateMonthlyInventoryReport')->once()->andReturn([
'data' => [
(object) ['warehouse_provider' => '测试仓储方', 'warehouse' => '主仓'],
(object) ['warehouse_provider' => '其他仓储方', 'warehouse' => '主仓'],
],
]);
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
$response = $this->loginAs()->get(route('business.product-inventory.inventory-report.export', [
'month' => '2026-08', 'scope' => 'page',
'columns' => 'warehouse,warehouse_short_name,warehouse_provider',
]));
$response->assertOk()->assertDownload();
$this->assertSame(
"\xEF\xBB\xBF仓库,仓库简称,仓储方\n主仓,测试简称,测试仓储方\n主仓,,其他仓储方\n",
$response->streamedContent(),
);
}
public function testInventoryReportCurrentPageExportDoesNotReadAllFilteredRows(): void public function testInventoryReportCurrentPageExportDoesNotReadAllFilteredRows(): void
{ {
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
@ -218,6 +331,39 @@ class PagesTest extends FeatureTestCase
Queue::assertPushed(InventoryReportExport::class); Queue::assertPushed(InventoryReportExport::class);
} }
public function testInventoryReportBackgroundExportWritesWarehouseShortName(): void
{
Queue::fake();
InventoryWarehouseNameMapping::query()->create([
'company_id' => 1,
'warehouse_provider' => '后台仓储方',
'warehouse' => '后台仓库',
'warehouse_short_name' => '后台简称',
]);
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
$repository->shouldReceive('exportMonthlyInventoryReportRows')->once()->andReturn(
(static function (): \Generator {
yield (object) ['warehouse_provider' => '后台仓储方', 'warehouse' => '后台仓库'];
})(),
);
$fileName = 'inventory-short-name-test-' . uniqid() . '.csv';
$path = storage_path('app/temp/' . $fileName);
try {
(new InventoryReportExport(
$this->user,
['month' => '2026-08'],
[['name' => 'warehouse_short_name', 'label' => '仓库简称']],
$fileName,
1,
))->handle($repository, app(InventoryWarehouseShortNames::class));
$this->assertSame("\xEF\xBB\xBF仓库简称\n后台简称\n", file_get_contents($path));
} finally {
@unlink($path);
}
}
public function testInventoryReportExportRejectsFieldsOutsideThePageSchema(): void public function testInventoryReportExportRejectsFieldsOutsideThePageSchema(): void
{ {
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
@ -1194,4 +1340,68 @@ class PagesTest extends FeatureTestCase
->assertDontSee('class="business-data-page"', false) ->assertDontSee('class="business-data-page"', false)
->assertDontSee('class="business-blank-page"', false); ->assertDontSee('class="business-blank-page"', false);
} }
public function testInventoryWarehouseMappingCanBeAddedEditedAndExportedAsExcel(): void
{
$provider = '测试仓储方-' . uniqid();
$warehouse = '测试仓库-' . uniqid();
$this->loginAs()->post(route('business.product-inventory.inventory-report.warehouse-mappings.store'), [
'warehouse_provider' => $provider,
'warehouse' => $warehouse,
])->assertRedirect();
$mapping = InventoryWarehouseNameMapping::query()
->where('warehouse_provider', $provider)
->where('warehouse', $warehouse)
->firstOrFail();
$this->assertStringStartsWith('测试仓库', $mapping->warehouse_short_name);
$this->loginAs()->patch(route('business.product-inventory.inventory-report.warehouse-mappings.update', $mapping->id), [
'warehouse_provider' => $provider,
'warehouse' => $warehouse,
'warehouse_short_name' => '测试简称',
])->assertRedirect();
$response = $this->loginAs()->get(route('business.product-inventory.inventory-report.warehouse-mappings.export'));
$response->assertOk()
->assertDownload()
->assertHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$path = tempnam(sys_get_temp_dir(), 'inventory-mapping-export-');
file_put_contents($path, $response->streamedContent());
try {
$sheet = IOFactory::load($path)->getActiveSheet();
$this->assertSame(['仓储方', '仓库', '仓库简称'], $sheet->rangeToArray('A1:C1')[0]);
$this->assertSame([$provider, $warehouse, '测试简称'], $sheet->rangeToArray('A2:C2')[0]);
} finally {
@unlink($path);
}
}
public function testInventoryWarehouseMappingPageUsesTheIndependentLedger(): void
{
$this->loginAs()->get(route('business.pages.show', [
'section' => 'product-inventory',
'page' => 'inventory-report',
'mapping' => 1,
]))->assertOk()
->assertSeeText('仓储方')
->assertSeeText('仓库简称')
->assertSeeText('导入并预览')
->assertSeeText('导出完整表格(Excel)')
->assertSeeText('选择文件')
->assertSee('class="inventory-warehouse-mapping__export"', false)
->assertSee('class="inventory-warehouse-mapping__file-label"', false)
->assertSee('class="inventory-warehouse-mapping__import-button"', false)
->assertSee('data-inventory-mapping-create', false)
->assertSee('data-inventory-mapping-edit', false)
->assertSee('data-directory-table-key="warehouse-mapping"', false)
->assertSee('data-inventory-mapping-template', false)
->assertSeeText('当前页·显示列')
->assertSeeText('全部筛选结果·全部字段')
->assertSee('.inventory-warehouse-mapping__table thead,', false)
->assertSee('position: sticky;', false)
->assertSee('data-directory-fixed="start"', false)
->assertSee('data-directory-fixed="end"', false);
}
} }

View File

@ -19,6 +19,7 @@ class NavigationTest extends FeatureTestCase
->assertSee('/workspace/order-data/raw-data', false) ->assertSee('/workspace/order-data/raw-data', false)
->assertSee('/workspace/refunds/non-jingxi-refund-data', false) ->assertSee('/workspace/refunds/non-jingxi-refund-data', false)
->assertSee('/workspace/product-inventory/normal-products', false) ->assertSee('/workspace/product-inventory/normal-products', false)
->assertSee('/workspace/product-inventory/monthly-weighted-average-price', false)
->assertSee('/workspace/product-inventory/inventory-report', false); ->assertSee('/workspace/product-inventory/inventory-report', false);
} }
@ -37,12 +38,33 @@ class NavigationTest extends FeatureTestCase
'采购退货', '其它进仓', '其它退货', '销售出仓', '销售退货', '采购退货', '其它进仓', '其它退货', '销售出仓', '销售退货',
'盘点', '调拨出', '调拨入', '期初', '其它出库', '盘点', '调拨出', '调拨入', '期初', '其它出库',
]) ])
->assertSeeText('复用“应付款项 → 快递应付 → 三方仓进销存”') ->assertSeeText('进销存总表')
->assertSeeText('仓库名称映射表')
->assertSee('data-inventory-report-tabs', false)
->assertSee('data-inventory-report-tab="mapping"', false)
->assertSee('id="inventory-report-mapping-panel"', false)
->assertDontSeeText('复用“应付款项 → 快递应付 → 三方仓进销存”')
->assertSeeText('进销存共享镜像尚未建立') ->assertSeeText('进销存共享镜像尚未建立')
->assertDontSeeText('已按附件模板导入 20 条演示数据') ->assertDontSeeText('已按附件模板导入 20 条演示数据')
->assertDontSeeText('进销存报表字段和数据来源尚未定义。'); ->assertDontSeeText('进销存报表字段和数据来源尚未定义。');
} }
public function testMonthlyWeightedAveragePricePageIsAvailable(): void
{
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'product-inventory',
'page' => 'monthly-weighted-average-price',
]))
->assertOk()
->assertSeeText('商品月加权平均单价')
->assertSeeText('取数规则待确认')
->assertSeeTextInOrder([
'月份', '商品编码', '商品名称', '采购数量', '采购金额', '月加权平均单价',
])
->assertSeeText('确认取数规则后再接入经营分析副本库数据');
}
public function testFinancialRiskControlPageIsAvailableBetweenFinancialReports(): void public function testFinancialRiskControlPageIsAvailableBetweenFinancialReports(): void
{ {
$navigation = BusinessNavigation::all(); $navigation = BusinessNavigation::all();
@ -210,11 +232,11 @@ class NavigationTest extends FeatureTestCase
array_column($orderData['pages'][0]['tabs'], 'title') array_column($orderData['pages'][0]['tabs'], 'title')
); );
$this->assertSame( $this->assertSame(
['normal-products', 'online-daifa-price', 'inventory-report'], ['normal-products', 'online-daifa-price', 'monthly-weighted-average-price', 'inventory-report'],
array_column($productInventory['pages'], 'slug') array_column($productInventory['pages'], 'slug')
); );
$this->assertSame( $this->assertSame(
['普通商品资料', '线上代发价格', '进销存报表'], ['普通商品资料', '线上代发价格', '商品月加权平均单价', '进销存报表'],
array_column($productInventory['pages'], 'title') array_column($productInventory['pages'], 'title')
); );
$this->assertArrayNotHasKey('children', $orderData['pages'][0]); $this->assertArrayNotHasKey('children', $orderData['pages'][0]);

View File

@ -50,4 +50,27 @@ class WebProcessOwnershipTest extends TestCase
$initializer $initializer
); );
} }
public function test_local_entrypoint_does_not_build_persistent_laravel_caches(): void
{
$entrypoint = file_get_contents(base_path('deploy/entrypoint.sh'));
$this->assertIsString($entrypoint);
$this->assertStringContainsString('if [ "$app_environment" = "local" ]; then', $entrypoint);
$this->assertStringContainsString('run_artisan_as_web optimize:clear', $entrypoint);
$this->assertStringContainsString('run_artisan_as_web config:cache', $entrypoint);
$this->assertStringContainsString('run_artisan_as_web view:cache', $entrypoint);
}
public function test_local_php_and_view_caches_are_disabled(): void
{
$php = file_get_contents(base_path('deploy/php-local.ini'));
$view = file_get_contents(base_path('config/view.php'));
$this->assertIsString($php);
$this->assertStringContainsString('opcache.enable=0', $php);
$this->assertStringContainsString('opcache.enable_cli=0', $php);
$this->assertStringContainsString("'cache' => env('APP_ENV', 'production') === 'local'", $view);
$this->assertStringContainsString("? false", $view);
}
} }