Revert "Merge branch 'fix/chenxi-inventory-mapping-release-20260928'"
This reverts commit c328dcbe77e114d1111c22ca22870227ad280ab7, reversing changes made to bfbfe935eab814027d7d2328accc18a60171a712.
This commit is contained in:
parent
403f66b94f
commit
454919b0c7
@ -145,8 +145,7 @@ docker compose -f docker-compose.local.yml start
|
||||
|
||||
## 修改后的生效方式
|
||||
|
||||
- PHP、配置、模块、页面模板和路由目录已经挂载到容器;开发环境关闭 PHP OPcache 和 Blade 编译缓存,刷新浏览器即可加载最新源码,无需重启 PHP 服务;
|
||||
- 开发环境启动时只执行 `optimize:clear`,不会生成 Laravel 的配置、路由或视图缓存;生产环境仍保留原有缓存构建机制;
|
||||
- PHP、配置、模块、页面模板和路由目录已经挂载到容器,本地 PHP OPcache 会在每次请求检查源码变更,刷新浏览器即可生效;
|
||||
- 本地 Compose 会同时启动 `frontend` watcher。修改 `resources/assets` 下的 Vue、JavaScript 或 SCSS 后,Laravel Mix 会通过轮询自动重新编译,Apache 使用共享静态资源卷提供最新文件;浏览器刷新即可看到结果。
|
||||
- 如果 watcher 构建失败,查看 `docker compose -f docker-compose.local.yml logs -f frontend`;修复源码后它会继续监听并重新编译。
|
||||
- Composer 依赖、前端依赖、Dockerfile 或构建资源发生变化时,重新执行 `bash deploy/local-init.sh`,脚本会自动重建;
|
||||
|
||||
@ -1,213 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,6 @@
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Business\BusinessTableImporter;
|
||||
use App\Business\InventoryWarehouseNameMappingWorkbookImporter;
|
||||
use App\Business\CourierPayableSummaryFilter;
|
||||
use App\Business\CourierPayableSummaryStore;
|
||||
use App\Business\FundForecastTemplate;
|
||||
@ -19,7 +18,6 @@ use App\Models\Business\CourierQuoteSignature;
|
||||
use App\Models\Business\CourierQuoteVersion;
|
||||
use App\Models\Business\CourierSigningSubject;
|
||||
use App\Models\Business\InstallationDetail;
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
use App\Business\CourierSurchargeAuditLogRepository;
|
||||
use App\Business\CourierSurchargeQuoteRepository;
|
||||
use App\Business\CourierSurchargeTypeRepository;
|
||||
@ -34,11 +32,6 @@ use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
|
||||
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
|
||||
use Modules\EcommerceFinance\View\BillOffsetPageData;
|
||||
@ -387,20 +380,11 @@ class Pages extends Controller
|
||||
$tabValue = null;
|
||||
$tabColumn = null;
|
||||
$upstreamError = null;
|
||||
$inventoryWarehouseMappings = collect();
|
||||
$agreementCourierNames = $this->agreementCourierNames($page['slug'] ?? null);
|
||||
$agreementCourierAccounts = $this->agreementCourierAccounts($page['slug'] ?? null, $agreementCourierNames);
|
||||
|
||||
if (($section['slug'] ?? null) === 'product-inventory' && ($page['slug'] ?? null) === 'inventory-report') {
|
||||
if (request()->boolean('mapping')) {
|
||||
$rows = new LengthAwarePaginator([], 0, 1, 1, [
|
||||
'path' => request()->url(),
|
||||
'query' => request()->query(),
|
||||
]);
|
||||
} else {
|
||||
[$rows, $upstreamError] = $this->inventoryReportRows();
|
||||
}
|
||||
$inventoryWarehouseMappings = $this->inventoryWarehouseMappingRows();
|
||||
[$rows, $upstreamError] = $this->inventoryReportRows();
|
||||
}
|
||||
|
||||
if ($page && $content === null) {
|
||||
@ -448,7 +432,6 @@ class Pages extends Controller
|
||||
'agreementCourierNames' => $agreementCourierNames,
|
||||
'agreementCourierAccounts' => $agreementCourierAccounts,
|
||||
'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug']),
|
||||
'inventoryWarehouseMappings' => $inventoryWarehouseMappings,
|
||||
];
|
||||
}
|
||||
|
||||
@ -507,7 +490,6 @@ class Pages extends Controller
|
||||
'agreementCourierNames' => $agreementCourierNames,
|
||||
'agreementCourierAccounts' => $agreementCourierAccounts,
|
||||
'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug'] ?? null),
|
||||
'inventoryWarehouseMappings' => $inventoryWarehouseMappings,
|
||||
];
|
||||
}
|
||||
|
||||
@ -553,163 +535,6 @@ class Pages extends Controller
|
||||
]), $error];
|
||||
}
|
||||
|
||||
private function inventoryWarehouseMappingRows(): LengthAwarePaginator
|
||||
{
|
||||
$query = InventoryWarehouseNameMapping::query()->orderBy('warehouse_provider')->orderBy('warehouse');
|
||||
$provider = mb_substr(trim((string) request('mapping_warehouse_provider', '')), 0, 191);
|
||||
$warehouse = mb_substr(trim((string) request('mapping_warehouse', '')), 0, 191);
|
||||
|
||||
if ($provider !== '') {
|
||||
$query->where('warehouse_provider', 'like', '%' . addcslashes($provider, '%_\\') . '%');
|
||||
}
|
||||
if ($warehouse !== '') {
|
||||
$query->where('warehouse', 'like', '%' . addcslashes($warehouse, '%_\\') . '%');
|
||||
}
|
||||
|
||||
return $query->paginate(50)->withQueryString();
|
||||
}
|
||||
|
||||
public function storeInventoryWarehouseMapping(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'warehouse_provider' => ['required', 'string', 'max:191'],
|
||||
'warehouse' => ['required', 'string', 'max:191'],
|
||||
'warehouse_short_name' => ['nullable', 'string', 'max:191'],
|
||||
]);
|
||||
$data['warehouse_provider'] = trim($data['warehouse_provider']);
|
||||
$data['warehouse'] = trim($data['warehouse']);
|
||||
$data['warehouse_short_name'] = trim((string) ($data['warehouse_short_name'] ?? ''))
|
||||
?: InventoryWarehouseNameMappingWorkbookImporter::suggestion($data['warehouse_provider'], $data['warehouse']);
|
||||
|
||||
if (InventoryWarehouseNameMapping::query()
|
||||
->where('warehouse_provider', $data['warehouse_provider'])
|
||||
->where('warehouse', $data['warehouse'])
|
||||
->exists()) {
|
||||
return back()->withInput()->withErrors(['warehouse' => '相同“仓储方 + 仓库”已存在,请使用修改功能。']);
|
||||
}
|
||||
|
||||
InventoryWarehouseNameMapping::create($data + [
|
||||
'company_id' => company_id() ?: 1,
|
||||
'created_by' => auth()->id(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return $this->inventoryWarehouseMappingRedirect()->with('success', '仓库名称映射已新增。');
|
||||
}
|
||||
|
||||
public function updateInventoryWarehouseMapping(Request $request, int $id)
|
||||
{
|
||||
$mapping = InventoryWarehouseNameMapping::query()->findOrFail($id);
|
||||
$data = $request->validate([
|
||||
'warehouse_provider' => ['required', 'string', 'max:191'],
|
||||
'warehouse' => ['required', 'string', 'max:191'],
|
||||
'warehouse_short_name' => ['required', 'string', 'max:191'],
|
||||
]);
|
||||
$data = array_map(static fn (string $value): string => trim($value), $data);
|
||||
|
||||
if (InventoryWarehouseNameMapping::query()
|
||||
->where('id', '!=', $mapping->id)
|
||||
->where('warehouse_provider', $data['warehouse_provider'])
|
||||
->where('warehouse', $data['warehouse'])
|
||||
->exists()) {
|
||||
return back()->withInput()->withErrors(['warehouse' => '相同“仓储方 + 仓库”已存在,不能产生重复台账。']);
|
||||
}
|
||||
|
||||
$mapping->update($data + ['updated_by' => auth()->id()]);
|
||||
|
||||
return $this->inventoryWarehouseMappingRedirect()->with('success', '仓库名称映射已修改。');
|
||||
}
|
||||
|
||||
public function previewInventoryWarehouseMappingImport(
|
||||
Request $request,
|
||||
InventoryWarehouseNameMappingWorkbookImporter $importer,
|
||||
) {
|
||||
$request->validate([
|
||||
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'],
|
||||
]);
|
||||
$preview = $importer->preview($request->file('import'));
|
||||
$token = Str::random(40);
|
||||
session()->put('inventory_warehouse_mapping_previews.' . $token, $preview);
|
||||
|
||||
return $this->inventoryWarehouseMappingRedirect()
|
||||
->with('warehouse_mapping_preview', $preview + ['token' => $token]);
|
||||
}
|
||||
|
||||
public function commitInventoryWarehouseMappingImport(
|
||||
Request $request,
|
||||
InventoryWarehouseNameMappingWorkbookImporter $importer,
|
||||
) {
|
||||
$token = (string) $request->input('preview_token');
|
||||
$preview = session()->pull('inventory_warehouse_mapping_previews.' . $token);
|
||||
abort_if($token === '' || ! is_array($preview), 422, '导入预览已过期,请重新上传文件。');
|
||||
$result = $importer->commit($preview);
|
||||
|
||||
return $this->inventoryWarehouseMappingRedirect()->with(
|
||||
'success',
|
||||
"导入完成:新增 {$result['created']} 条,跳过 {$result['skipped']} 条重复映射。",
|
||||
);
|
||||
}
|
||||
|
||||
public function exportInventoryWarehouseMappings(Request $request): StreamedResponse
|
||||
{
|
||||
$query = InventoryWarehouseNameMapping::query()->orderBy('warehouse_provider')->orderBy('warehouse');
|
||||
$provider = mb_substr(trim((string) $request->query('mapping_warehouse_provider', '')), 0, 191);
|
||||
$warehouse = mb_substr(trim((string) $request->query('mapping_warehouse', '')), 0, 191);
|
||||
if ($provider !== '') {
|
||||
$query->where('warehouse_provider', 'like', '%' . addcslashes($provider, '%_\\') . '%');
|
||||
}
|
||||
if ($warehouse !== '') {
|
||||
$query->where('warehouse', 'like', '%' . addcslashes($warehouse, '%_\\') . '%');
|
||||
}
|
||||
|
||||
return response()->streamDownload(function () use ($query): void {
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('仓库名称映射表');
|
||||
$headers = ['仓储方', '仓库', '仓库简称'];
|
||||
$sheet->fromArray($headers, null, 'A1');
|
||||
$lastColumn = Coordinate::stringFromColumnIndex(count($headers));
|
||||
$headerRange = "A1:{$lastColumn}1";
|
||||
$sheet->getStyle($headerRange)->applyFromArray([
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||
]);
|
||||
$sheet->getRowDimension(1)->setRowHeight(24);
|
||||
$sheet->freezePane('A2');
|
||||
$sheet->setAutoFilter($headerRange);
|
||||
foreach ([20, 32, 20] as $index => $width) {
|
||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||
}
|
||||
$rowNumber = 2;
|
||||
foreach ($query->cursor() as $mapping) {
|
||||
$sheet->fromArray([
|
||||
$mapping->warehouse_provider,
|
||||
$mapping->warehouse,
|
||||
$mapping->warehouse_short_name,
|
||||
], null, "A{$rowNumber}");
|
||||
$rowNumber++;
|
||||
}
|
||||
(new Xlsx($spreadsheet))->save('php://output');
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}, '仓库名称映射表_' . now()->format('Ymd_His') . '.xlsx', [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Cache-Control' => 'private, no-store, no-cache, must-revalidate',
|
||||
]);
|
||||
}
|
||||
|
||||
private function inventoryWarehouseMappingRedirect()
|
||||
{
|
||||
return redirect()->route('business.pages.show', [
|
||||
'company_id' => company_id() ?: 1,
|
||||
'section' => 'product-inventory',
|
||||
'page' => 'inventory-report',
|
||||
'mapping' => 1,
|
||||
'mapping_warehouse_provider' => request('mapping_warehouse_provider', ''),
|
||||
'mapping_warehouse' => request('mapping_warehouse', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function inventoryReportFilters(Request $request): array
|
||||
{
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
<?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' => '仓库简称',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -335,24 +335,6 @@ $navigation = [
|
||||
'pages' => [
|
||||
['slug' => 'normal-products', 'title' => '普通商品资料'],
|
||||
['slug' => 'online-daifa-price', 'title' => '线上代发价格'],
|
||||
[
|
||||
'slug' => 'monthly-weighted-average-price',
|
||||
'title' => '商品月加权平均单价',
|
||||
'directory' => [
|
||||
'icon' => 'calculate',
|
||||
'status' => '取数规则待确认',
|
||||
'description' => '商品月加权平均单价页面入口已建立;接入数据前需确认采购入库范围、金额字段和退货处理口径。',
|
||||
'empty' => '商品月加权平均单价尚未同步。确认取数规则后再接入经营分析副本库数据。',
|
||||
'fields' => [
|
||||
['name' => 'month', 'label' => '月份', 'kind' => 'date'],
|
||||
['name' => 'product_code', 'label' => '商品编码'],
|
||||
['name' => 'product_name', 'label' => '商品名称'],
|
||||
['name' => 'purchase_quantity', 'label' => '采购数量', 'kind' => 'number'],
|
||||
['name' => 'purchase_amount', 'label' => '采购金额', 'kind' => 'number'],
|
||||
['name' => 'weighted_average_unit_price', 'label' => '月加权平均单价', 'kind' => 'number'],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'inventory-report',
|
||||
'title' => '进销存报表',
|
||||
|
||||
@ -33,20 +33,4 @@ return [
|
||||
realpath(storage_path('framework/views'))
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Compiled View Cache
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Blade still needs a writable path to generate PHP for each request, but
|
||||
| local development must not trust an existing compiled template. Setting
|
||||
| the compiler cache flag to false makes Laravel recompile every view and
|
||||
| leaves production's normal compiled-view behavior unchanged.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'cache' => env('APP_ENV', 'production') === 'local'
|
||||
? false
|
||||
: env('VIEW_CACHE', true),
|
||||
|
||||
];
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
};
|
||||
@ -37,11 +37,6 @@ run_artisan_as_web() {
|
||||
runuser -u www-data -- php artisan "$@"
|
||||
}
|
||||
|
||||
app_environment=""
|
||||
if [ -f .env ]; then
|
||||
app_environment="$(sed -n 's/^APP_ENV=//p' .env | tail -n 1 | tr -d '\r' | sed 's/^"//; s/"$//; s/^'\''//; s/'\''$//')"
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "apache2-foreground" ]; then
|
||||
sed -ri 's/^Listen 80$/Listen 18088/' /etc/apache2/ports.conf
|
||||
assert_web_writable
|
||||
@ -58,17 +53,8 @@ if [ "${1:-}" = "apache2-foreground" ]; then
|
||||
run_artisan_as_web migrate --force
|
||||
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 view:cache
|
||||
fi
|
||||
run_artisan_as_web config:cache
|
||||
run_artisan_as_web view:cache
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@ -81,7 +81,6 @@ calculate_local_image_fingerprint() {
|
||||
deploy/Dockerfile.local \
|
||||
deploy/apache-vhost.conf \
|
||||
deploy/php.ini \
|
||||
deploy/php-local.ini \
|
||||
deploy/entrypoint.sh \
|
||||
deploy/local-healthcheck.sh
|
||||
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
; 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
|
||||
; Local bind-mounted PHP source must be revalidated on every request.
|
||||
opcache.validate_timestamps=1
|
||||
opcache.revalidate_freq=0
|
||||
|
||||
@ -1,11 +1,6 @@
|
||||
@php
|
||||
$directoryRows = $rows ?? collect();
|
||||
$directoryFields = $directory['fields'] ?? [];
|
||||
$isInventoryReportPage = ($page['slug'] ?? '') === 'inventory-report';
|
||||
$inventoryWarehouseMappings = $isInventoryReportPage
|
||||
? ($inventoryWarehouseMappings ?? collect())
|
||||
: collect();
|
||||
$warehouseMappingPreview = $isInventoryReportPage ? session('warehouse_mapping_preview') : null;
|
||||
// “最近三个月销量”必须是三个独立的月份列;月份按当前月向前取三个完整月份。
|
||||
// 保留原字段名作为兼容契约,渲染时展开为 *_1、*_2、*_3 三个值。
|
||||
$directoryFields = collect($directoryFields)->flatMap(function (array $field): array {
|
||||
@ -570,256 +565,6 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.inventory-report-tabs {
|
||||
min-height: 5rem;
|
||||
margin: 0;
|
||||
padding: 0 2rem;
|
||||
gap: 2rem;
|
||||
border-color: var(--vip-border, #d8d2c7);
|
||||
border-radius: 1rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.inventory-report-tabs [data-inventory-report-tab] {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
min-height: 5rem;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--vip-content-muted, #595959);
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inventory-report-tabs [data-inventory-report-tab]:hover,
|
||||
.inventory-report-tabs [data-inventory-report-tab][aria-selected="true"] {
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
}
|
||||
|
||||
.inventory-report-tabs [data-inventory-report-tab][aria-selected="true"]::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
border-radius: 999px 999px 0 0;
|
||||
background: var(--vip-content-accent, #b9954f);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.inventory-report-tabs [data-inventory-report-tab]:focus-visible {
|
||||
outline: 2px solid var(--vip-content-accent-dark, #856632);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.inventory-report-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__table {
|
||||
min-width: 52rem;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__table thead th {
|
||||
position: sticky !important;
|
||||
top: 0 !important;
|
||||
z-index: 20 !important;
|
||||
background: var(--vip-content-accent-soft, #f7f0de);
|
||||
box-shadow: 0 1px 0 var(--vip-border, #d8d2c7);
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__table thead,
|
||||
.inventory-warehouse-mapping__table thead tr {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__table thead th[data-directory-fixed="start"],
|
||||
.inventory-warehouse-mapping__table thead th[data-directory-fixed="end"] {
|
||||
z-index: 21 !important;
|
||||
background: var(--vip-content-accent-soft, #f7f0de);
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__table tbody td {
|
||||
position: relative !important;
|
||||
z-index: 0 !important;
|
||||
background: var(--vip-surface, #fff);
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__table tbody tr:hover td {
|
||||
background: #fbf8f0;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__empty {
|
||||
padding: 3rem 1.25rem;
|
||||
color: var(--vip-content-muted, #595959);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__filters {
|
||||
grid-template-columns: repeat(2, minmax(12rem, 1fr)) auto;
|
||||
align-items: end;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--vip-border, #d8d2c7);
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__filters .directory-query-actions {
|
||||
grid-column: auto;
|
||||
justify-content: flex-start;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__dialog input {
|
||||
min-height: 2.25rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.5rem;
|
||||
background: #fff;
|
||||
color: var(--vip-content-ink, #171717);
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__filters .biz-btn {
|
||||
display: inline-flex;
|
||||
height: 2.1rem;
|
||||
align-items: center;
|
||||
padding: 0 0.8rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__import {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__export,
|
||||
.inventory-warehouse-mapping__file-label,
|
||||
.inventory-warehouse-mapping__import button {
|
||||
display: inline-flex;
|
||||
height: 2.25rem;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0.875rem;
|
||||
border: 1px solid var(--vip-content-accent, #b9954f);
|
||||
border-radius: 0.25rem;
|
||||
background: #fff;
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
font: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__file-label input[type="file"] {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__actions {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__actions button {
|
||||
padding: 0.25rem 0.45rem;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.4rem;
|
||||
background: #fff;
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__preview {
|
||||
margin: 1rem 1.25rem;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__preview summary {
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__preview table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__preview th,
|
||||
.inventory-warehouse-mapping__preview td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--vip-border, #d8d2c7);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__preview .is-invalid,
|
||||
.inventory-warehouse-mapping__preview .is-conflict {
|
||||
color: #a33a32;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__preview .is-same {
|
||||
color: var(--vip-content-muted, #595959);
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__dialog {
|
||||
width: min(32rem, calc(100vw - 2rem));
|
||||
padding: 0;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__dialog::backdrop {
|
||||
background: rgba(21, 18, 12, 0.38);
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__dialog form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__dialog h3 {
|
||||
margin: 0;
|
||||
color: var(--vip-content-ink, #171717);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__dialog label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--vip-content-muted, #595959);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.inventory-warehouse-mapping__dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.directory-table-head {
|
||||
flex-direction: column;
|
||||
@ -865,40 +610,18 @@
|
||||
</div>
|
||||
@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
|
||||
<header class="directory-table-head">
|
||||
<div class="directory-table-title">
|
||||
<span class="material-icons-outlined" aria-hidden="true">{{ $directory['icon'] ?? 'table_view' }}</span>
|
||||
<div>
|
||||
<strong>{{ $page['title'] ?? $section['title'] }} · 字段结构</strong>
|
||||
@if (!empty($directory['description']))
|
||||
<p class="directory-table-description">{{ $directory['description'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<header class="directory-table-head">
|
||||
<div class="directory-table-title">
|
||||
<span class="material-icons-outlined" aria-hidden="true">{{ $directory['icon'] ?? 'table_view' }}</span>
|
||||
<div>
|
||||
<strong>{{ $page['title'] ?? $section['title'] }} · 字段结构</strong>
|
||||
@if (!empty($directory['description']))
|
||||
<p class="directory-table-description">{{ $directory['description'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<span class="directory-table-status" data-directory-status>{{ $directory['status'] ?? '尚未同步' }}</span>
|
||||
</header>
|
||||
@endif
|
||||
</div>
|
||||
<span class="directory-table-status" data-directory-status>{{ $directory['status'] ?? '尚未同步' }}</span>
|
||||
</header>
|
||||
|
||||
@if (!empty($upstreamError))
|
||||
<div class="directory-table-notice" role="alert">
|
||||
@ -1285,15 +1008,10 @@
|
||||
</div>
|
||||
@endif
|
||||
<div class="directory-table-scroll">
|
||||
<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
|
||||
>
|
||||
<table class="directory-table" @if($directoryUsesFieldQuery) data-columns-ready="true" @endif>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" data-directory-fixed="start">#</th>
|
||||
<th scope="col">#</th>
|
||||
@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' : '' }}">
|
||||
@if ($directoryUsesFieldQuery)
|
||||
@ -1323,7 +1041,7 @@
|
||||
<tbody>
|
||||
@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>
|
||||
<td data-directory-fixed="start">{{ $rowIndex + 1 + (($directoryCurrentPage - 1) * $directoryPerPage) }}</td>
|
||||
<td>{{ $rowIndex + 1 + (($directoryCurrentPage - 1) * $directoryPerPage) }}</td>
|
||||
@foreach ($directoryFields as $field)
|
||||
@php
|
||||
$source = array_key_exists('source', $field) ? $field['source'] : $field['name'];
|
||||
@ -1400,164 +1118,6 @@
|
||||
</footer>
|
||||
@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')
|
||||
<section class="directory-subject-details" data-subject-details-panel hidden aria-live="polite">
|
||||
<header class="directory-subject-details__head">
|
||||
@ -1785,39 +1345,24 @@
|
||||
});
|
||||
document.querySelectorAll('[data-directory-tools]').forEach((tools) => {
|
||||
const section = tools.closest('[data-directory-page]');
|
||||
const tableScope = tools.closest('[data-directory-table-scope]');
|
||||
const table = tableScope?.querySelector('.directory-table') || section?.querySelector('.directory-table');
|
||||
const table = section?.querySelector('.directory-table');
|
||||
const search = tools.querySelector('[data-directory-search]');
|
||||
if (!table || !search) return;
|
||||
const picker = tools.querySelector('[data-directory-field-picker]');
|
||||
const toggle = picker?.querySelector('[data-directory-field-toggle]');
|
||||
const panel = picker?.querySelector('.directory-field-picker__panel');
|
||||
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 = [
|
||||
'directory-fields',
|
||||
section.dataset.directoryUser || 'guest',
|
||||
section.dataset.directorySection || 'section',
|
||||
section.dataset.directoryPage || 'page',
|
||||
tableKey,
|
||||
section.dataset.directoryTab || 'tab',
|
||||
].join(':');
|
||||
const defaultOrder = checks.map((check) => check.value);
|
||||
const orderedChecks = () => [...(picker?.querySelectorAll('[data-directory-field-option]') || [])]
|
||||
.map((option) => option.querySelector('[data-directory-field-check]'))
|
||||
.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 ordered = orderedChecks();
|
||||
const selected = new Set(ordered.filter((check) => check.checked).map((check) => check.value));
|
||||
@ -1826,21 +1371,13 @@
|
||||
table.querySelectorAll('[data-column-key]').forEach((cell) => {
|
||||
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) => {
|
||||
reorderRow(row);
|
||||
[...row.children].sort((a, b) => (rank.get(a.dataset.columnKey) ?? -1) - (rank.get(b.dataset.columnKey) ?? -1))
|
||||
.forEach((cell) => row.appendChild(cell));
|
||||
});
|
||||
table.tBodies[0]?.querySelectorAll('tr').forEach((row) => {
|
||||
reorderRow(row);
|
||||
[...row.children].slice(1).sort((a, b) => (rank.get(a.dataset.columnKey) ?? -1) - (rank.get(b.dataset.columnKey) ?? -1))
|
||||
.forEach((cell) => row.appendChild(cell));
|
||||
});
|
||||
if (save) localStorage.setItem(storageKey, JSON.stringify({ order: ordered.map((check) => check.value), selected: [...selected] }));
|
||||
};
|
||||
@ -1912,7 +1449,8 @@
|
||||
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 rows = [...table.querySelectorAll('tr')].filter((row) => !row.hidden);
|
||||
const csv = rows.map((row) => orderedExportCells(row, selected).map((cell) => csvEscape((cell.querySelector('[data-password-status]') || cell).textContent.trim())).join(',')).join('\n');
|
||||
const cellsForExport = (row) => [...row.children].filter((cell) => !cell.dataset.columnKey || !selected || selected.has(cell.dataset.columnKey));
|
||||
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 link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
@ -1922,25 +1460,25 @@
|
||||
}));
|
||||
tools.querySelectorAll('[data-directory-export-all]').forEach((button) => button.addEventListener('click', async (event) => {
|
||||
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 header = orderedExportCells(table.tHead.rows[0], selected).map((cell) => csvEscape(cell.textContent.trim())).join(',');
|
||||
const cellsForExport = (row) => [...row.children]
|
||||
.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 = [];
|
||||
button.disabled = true;
|
||||
const originalLabel = button.textContent;
|
||||
try {
|
||||
for (let page = 1; page <= lastPage; page += 1) {
|
||||
button.textContent = '导出中 ' + page + '/' + lastPage;
|
||||
const url = new URL(sourceUrl, window.location.origin);
|
||||
button.textContent = `导出中 ${page}/${lastPage}`;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('page', String(page));
|
||||
const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
if (!response.ok) throw new Error(`第 ${page} 页读取失败`);
|
||||
const html = await response.text();
|
||||
const parsed = new DOMParser().parseFromString(html, 'text/html');
|
||||
const parsedTable = table.id
|
||||
? 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(','));
|
||||
parsed.querySelectorAll('.directory-table tbody tr:not(.is-empty)').forEach((row) => {
|
||||
lines.push(cellsForExport(row).map((cell) => csvEscape(cell.textContent.trim())).join(','));
|
||||
});
|
||||
}
|
||||
const csv = [header, ...lines].join('\n');
|
||||
@ -1966,103 +1504,6 @@
|
||||
link.click();
|
||||
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) => {
|
||||
|
||||
@ -321,12 +321,11 @@
|
||||
const keyFor = (table) => {
|
||||
if (table.dataset.financeTableKey) return table.dataset.financeTableKey;
|
||||
const currentContext = routeContext();
|
||||
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 parent = 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 siblings = parent ? [...parent.querySelectorAll(selector)].filter((candidate) => candidate.matches(selector)) : [table];
|
||||
const index = Math.max(0, siblings.indexOf(table));
|
||||
const scope = tableScope?.dataset.directoryTableKey || parent?.dataset.directoryPage || parent?.dataset.refundPage || name;
|
||||
const scope = parent?.dataset.directoryPage || parent?.dataset.refundPage || name;
|
||||
table.dataset.financeTableKey = `${currentContext.user}:${currentContext.section}:${currentContext.page}:${currentContext.tab}:${scope}:${index}`;
|
||||
return table.dataset.financeTableKey;
|
||||
};
|
||||
@ -378,8 +377,6 @@
|
||||
cell.dataset.financeColumnKey = cell.dataset.columnKey || cell.dataset.field || cell.dataset.exportField || `${slug(cell.textContent)}_${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';
|
||||
});
|
||||
const keys = headers.map((cell) => cell.dataset.financeColumnKey);
|
||||
@ -599,9 +596,8 @@
|
||||
}
|
||||
|
||||
function ensureControls(table, headers) {
|
||||
const tableScope = table.closest('[data-directory-table-scope]');
|
||||
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;
|
||||
const existingDirectoryTools = table.closest('[data-directory-page]')?.querySelector('[data-directory-tools]');
|
||||
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;
|
||||
if (!root) return;
|
||||
let controls = existingDirectoryTools || root.querySelector('[data-finance-controls]');
|
||||
if (!controls) {
|
||||
@ -669,46 +665,31 @@
|
||||
}
|
||||
|
||||
async function exportDirectory(table, button, includeHiddenRows, allColumns) {
|
||||
const tableScope = table.closest('[data-directory-table-scope]');
|
||||
const page = table.closest('[data-directory-page]');
|
||||
const search = tableScope?.querySelector('[data-directory-search]') || page?.querySelector('[data-directory-search]');
|
||||
const search = page?.querySelector('[data-directory-search]');
|
||||
const headers = leafHeaders(table);
|
||||
const exportableHeaders = headers.filter((header) => header.dataset.financeFixed !== 'end' && header.dataset.directoryFixed !== 'end');
|
||||
const selected = new Set(exportableHeaders
|
||||
const selected = new Set([...table.querySelectorAll('thead th[data-finance-column-key]')]
|
||||
.filter((cell) => !cell.classList.contains('is-column-hidden'))
|
||||
.map((cell) => cell.dataset.financeColumnKey));
|
||||
const order = exportableHeaders.map((header) => header.dataset.financeColumnKey);
|
||||
const order = headers.map((header) => header.dataset.financeColumnKey);
|
||||
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 rows = [];
|
||||
const lastPage = Number(tableScope?.dataset.directoryLastPage || page?.dataset.directoryLastPage || 1);
|
||||
const sourceUrl = tableScope?.dataset.directorySourceUrl || window.location.href;
|
||||
const lastPage = Number(page?.dataset.directoryLastPage || 1);
|
||||
button.disabled = true;
|
||||
const originalLabel = button.textContent;
|
||||
try {
|
||||
if (includeHiddenRows && lastPage > 1) {
|
||||
for (let current = 1; current <= lastPage; current += 1) {
|
||||
button.textContent = `导出中 ${current}/${lastPage}`;
|
||||
const url = new URL(sourceUrl, window.location.origin);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('page', String(current));
|
||||
const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
if (!response.ok) throw new Error(`第 ${current} 页读取失败`);
|
||||
const parsed = new DOMParser().parseFromString(await response.text(), 'text/html');
|
||||
const parsedTable = table.id
|
||||
? parsed.querySelector(`#${CSS.escape(table.id)}`)
|
||||
: parsed.querySelector('.directory-table');
|
||||
parsedTable?.querySelectorAll('tbody tr:not(.is-empty)').forEach((row) => {
|
||||
parsed.querySelectorAll('.directory-table tbody tr:not(.is-empty)').forEach((row) => {
|
||||
const cells = [...row.children];
|
||||
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));
|
||||
const map = new Map(cells.map((cell, index) => [cell.dataset.columnKey || headers[index]?.dataset.financeColumnKey, cell]));
|
||||
if (search?.value && !row.textContent.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase())) return;
|
||||
rows.push(exportOrder.map((key) => csvEscape(cellText(map.get(key)))).join(','));
|
||||
});
|
||||
@ -785,8 +766,7 @@
|
||||
}
|
||||
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;
|
||||
const tableScope = button.closest('[data-directory-table-scope]');
|
||||
const table = tableScope?.querySelector('.directory-table') || button.closest('[data-directory-page]')?.querySelector('.directory-table');
|
||||
const table = button.closest('[data-directory-page]')?.querySelector('.directory-table');
|
||||
if (!table) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
@ -647,7 +647,6 @@
|
||||
'page' => $page,
|
||||
'rows' => $rows,
|
||||
'upstreamError' => $upstreamError,
|
||||
'inventoryWarehouseMappings' => $inventoryWarehouseMappings ?? collect(),
|
||||
])
|
||||
@elseif ($pageView)
|
||||
@include($pageView)
|
||||
|
||||
@ -158,17 +158,6 @@ Route::get('workspace/product-inventory/inventory-report/export', 'Business\Page
|
||||
->middleware('business.page:product-inventory,inventory-report')
|
||||
->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')
|
||||
->name('business.supplier-payables.product-prices.')
|
||||
->middleware('business.page:supplier-payables,product-prices')
|
||||
|
||||
@ -3,11 +3,9 @@
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
use App\Jobs\Common\InventoryReportExport;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Mockery;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
|
||||
@ -20,12 +18,6 @@ class PagesTest extends FeatureTestCase
|
||||
{
|
||||
public function testInventoryReportProvidesAFilteredExportButton(): 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();
|
||||
@ -39,11 +31,7 @@ class PagesTest extends FeatureTestCase
|
||||
1,
|
||||
Mockery::type('int'),
|
||||
)->andReturn([
|
||||
'data' => [(object) [
|
||||
'product_code' => '121630-49',
|
||||
'warehouse_provider' => '测试仓储方',
|
||||
'warehouse' => '测试主仓',
|
||||
]],
|
||||
'data' => [(object) ['product_code' => '121630-49']],
|
||||
'total' => 1,
|
||||
'state' => ['status' => 'succeeded'],
|
||||
'sync' => ['code' => 'healthy'],
|
||||
@ -62,37 +50,8 @@ class PagesTest extends FeatureTestCase
|
||||
->assertSee('data-inventory-export-scope', false)
|
||||
->assertSee('data-inventory-export-fields', false)
|
||||
->assertSee('data-inventory-export', false)
|
||||
->assertSee('进销存总表')
|
||||
->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&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);
|
||||
->assertDontSee('<button type="button" data-directory-export-all', false)
|
||||
->assertDontSee('<button type="button" data-directory-template', false);
|
||||
}
|
||||
|
||||
public function testInventoryReportKeepsJulyMonthFilter(): void
|
||||
@ -1235,68 +1194,4 @@ class PagesTest extends FeatureTestCase
|
||||
->assertDontSee('class="business-data-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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,7 +19,6 @@ class NavigationTest extends FeatureTestCase
|
||||
->assertSee('/workspace/order-data/raw-data', false)
|
||||
->assertSee('/workspace/refunds/non-jingxi-refund-data', false)
|
||||
->assertSee('/workspace/product-inventory/normal-products', false)
|
||||
->assertSee('/workspace/product-inventory/monthly-weighted-average-price', false)
|
||||
->assertSee('/workspace/product-inventory/inventory-report', false);
|
||||
}
|
||||
|
||||
@ -38,33 +37,12 @@ class NavigationTest extends FeatureTestCase
|
||||
'采购退货', '其它进仓', '其它退货', '销售出仓', '销售退货',
|
||||
'盘点', '调拨出', '调拨入', '期初', '其它出库',
|
||||
])
|
||||
->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('进销存报表字段和数据来源尚未定义。');
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$navigation = BusinessNavigation::all();
|
||||
@ -232,11 +210,11 @@ class NavigationTest extends FeatureTestCase
|
||||
array_column($orderData['pages'][0]['tabs'], 'title')
|
||||
);
|
||||
$this->assertSame(
|
||||
['normal-products', 'online-daifa-price', 'monthly-weighted-average-price', 'inventory-report'],
|
||||
['normal-products', 'online-daifa-price', 'inventory-report'],
|
||||
array_column($productInventory['pages'], 'slug')
|
||||
);
|
||||
$this->assertSame(
|
||||
['普通商品资料', '线上代发价格', '商品月加权平均单价', '进销存报表'],
|
||||
['普通商品资料', '线上代发价格', '进销存报表'],
|
||||
array_column($productInventory['pages'], 'title')
|
||||
);
|
||||
$this->assertArrayNotHasKey('children', $orderData['pages'][0]);
|
||||
|
||||
@ -50,27 +50,4 @@ class WebProcessOwnershipTest extends TestCase
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user