From f6f66b6ff1d0bbb91ec76de39f15e011900e0012 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 8 Sep 2026 14:44:34 +0800 Subject: [PATCH 1/5] feat: add internal flow report entry --- config/business_navigation.php | 11 +++++++++++ .../business/pages/partials/prototype-page.blade.php | 4 ++++ .../views/components/layouts/admin/menu.blade.php | 1 + tests/Feature/Business/ManagementCrudTest.php | 2 +- 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/config/business_navigation.php b/config/business_navigation.php index 91836de0..3da486dd 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -212,6 +212,17 @@ return [ ], ], ], + [ + 'slug' => 'internal-flow-report', + 'title' => '内部流转报表', + 'directory' => [ + 'icon' => 'swap_horiz', + 'status' => '报表待定义', + 'description' => '内部流转报表页面入口已建立,待补充正式字段和业务数据。', + 'empty' => '内部流转报表尚未定义或同步。', + 'fields' => [], + ], + ], [ 'slug' => 'basic-information', 'title' => '店铺基础信息', diff --git a/resources/views/business/pages/partials/prototype-page.blade.php b/resources/views/business/pages/partials/prototype-page.blade.php index 9c8a2234..d8197035 100644 --- a/resources/views/business/pages/partials/prototype-page.blade.php +++ b/resources/views/business/pages/partials/prototype-page.blade.php @@ -64,6 +64,10 @@ 'title' => '店铺维度报表', 'description' => '查看店铺销量及历史主体归属', 'columns' => ['ERP系统店铺名称', '店铺ID(唯一值)', '店铺所属利润中心', '店铺负责人', '部门负责人', '开店时间', '状态', '能否变更', '主体', '公司类型', '生效时间', '失效时间', '期间销量', '1月销量', '2月销量', '3月销量', '4月销量', '5月销量', '6月销量', '7月销量', '8月销量', '9月销量', '10月销量', '11月销量', '12月销量'], ], + 'internal-flow-report' => [ + 'title' => '内部流转报表', 'description' => '查看内部流转事项及办理状态', + 'columns' => [], + ], ]; $definition = $definitions[$slug] ?? ['title' => $page['title'] ?? '业务页面', 'description' => '', 'columns' => [], 'rows' => []]; @endphp diff --git a/resources/views/components/layouts/admin/menu.blade.php b/resources/views/components/layouts/admin/menu.blade.php index 17f1df6d..8e4bf319 100644 --- a/resources/views/components/layouts/admin/menu.blade.php +++ b/resources/views/components/layouts/admin/menu.blade.php @@ -150,6 +150,7 @@ ['title' => '经营报表', 'items' => [ ['company-management', 'subject-operating-report', '主体维度经营报表', 'domain'], ['store-management', 'store-report', '店铺维度报表', 'storefront'], + ['store-management', 'internal-flow-report', '内部流转报表', 'swap_horiz'], ['company-management', 'personnel-inventory', '人员盘点表', 'groups'], ]], ['title' => '基础资料', 'items' => [ diff --git a/tests/Feature/Business/ManagementCrudTest.php b/tests/Feature/Business/ManagementCrudTest.php index 5567d51b..e0222642 100644 --- a/tests/Feature/Business/ManagementCrudTest.php +++ b/tests/Feature/Business/ManagementCrudTest.php @@ -309,7 +309,7 @@ class ManagementCrudTest extends FeatureTestCase array_column($rulesPage['children'], 'slug') ); $this->assertSame( - ['store-report', 'basic-information', 'store-accounts', 'change-progress'], + ['store-report', 'internal-flow-report', 'basic-information', 'store-accounts', 'change-progress'], array_column($sections['store-management']['pages'], 'slug') ); $this->assertSame( -- 2.47.2 From e33f22a24ff02c8fb4bd1b44ab16c6eaf22eac68 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 8 Sep 2026 16:29:54 +0800 Subject: [PATCH 2/5] fix: preserve lifecycle effective dates --- .../Repositories/StoreReportRepository.php | 7 ++++--- .../StoreReportRepositoryTest.php | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/modules/EcommerceFinance/Repositories/StoreReportRepository.php b/modules/EcommerceFinance/Repositories/StoreReportRepository.php index 398f342a..13e7d42f 100644 --- a/modules/EcommerceFinance/Repositories/StoreReportRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreReportRepository.php @@ -187,9 +187,11 @@ class StoreReportRepository $month = $this->connection->getDriverName() === 'sqlite' ? "substr(d.occurred_on, 1, 7)" : "DATE_FORMAT(d.occurred_on, '%Y-%m')"; + // 生命周期记录的生效时间严格来自存续表;只有没有存续表记录的店铺 + // 才会在 fallbackSales() 中使用默认的 2026-07-01。 $lowerBound = $this->connection->getDriverName() === 'sqlite' - ? 'date(d.occurred_on) >= date(COALESCE(l.effective_at, ?))' - : 'd.occurred_on >= COALESCE(l.effective_at, ?)'; + ? 'date(d.occurred_on) >= date(l.effective_at)' + : 'd.occurred_on >= l.effective_at'; $upperBound = $this->connection->getDriverName() === 'sqlite' ? 'date(d.occurred_on) < date(COALESCE(l.expires_at, ?))' : 'd.occurred_on < COALESCE(l.expires_at, ?)'; @@ -209,7 +211,6 @@ class StoreReportRepository GROUP BY l.id, {$month}", [ $dailyGeneration, - self::DEFAULT_EFFECTIVE_AT, self::DEFAULT_EXPIRES_AT, $lifecycleGeneration, ...$chunk, diff --git a/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php b/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php index 148e47d3..ca11d1f4 100644 --- a/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php @@ -112,6 +112,22 @@ class StoreReportRepositoryTest extends TestCase $this->assertSame('存续中', $rows[0]['invalid_at']); } + public function test_lifecycle_effective_date_before_default_is_preserved(): void + { + $this->insertLifecycle('店铺A', '历史主体', '2026-05-15 00:00:00', '2026-06-20 00:00:00'); + $this->insertBill('店铺A', '2026-05-20 10:00:00', 18); + $this->insertBill('店铺A', '2026-07-05 10:00:00', 30); + + $rows = $this->repository->rows([ + ['erp_store_name' => '店铺A', 'company_name' => '店铺主体', 'store_status' => '0'], + ], 2026); + + $this->assertCount(1, $rows); + $this->assertSame('2026-05-15', $rows[0]['effective_at']); + $this->assertSame('2026-06-20', $rows[0]['invalid_at']); + $this->assertSame(18.0, $rows[0]['period_sales']); + } + public function test_unfinished_shared_daily_summary_is_reported_instead_of_returning_misleading_zeroes(): void { DB::connection('business_mirror')->table('store_report_import_states') -- 2.47.2 From f0b22b5a24fe96ff4c984c2e2a3f5254f1187dbc Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Wed, 9 Sep 2026 14:35:07 +0800 Subject: [PATCH 3/5] feat: unify business table interactions --- .../pages/partials/directory-table.blade.php | 82 +++++++++++++++---- tests/Feature/Business/ManagementCrudTest.php | 12 +++ 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index 73c7719a..a24817f4 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -175,6 +175,11 @@ line-height: 1.35; cursor: pointer; } + .directory-field-picker__option[draggable="true"] { cursor: grab; } + .directory-field-picker__option.is-dragging { opacity: 0.55; } + .directory-field-picker__option.is-drop-target { outline: 1px solid var(--vip-content-accent, #b9954f); background: var(--vip-content-accent-soft, #f7f0de); } + .directory-field-picker__hint { grid-column: 1 / -1; color: var(--vip-content-muted, #595959); font-size: 0.6875rem; } + .directory-table-tools .directory-export-group { display: flex; flex-wrap: wrap; gap: 0.35rem; } .directory-field-picker__option input[type="checkbox"] { flex: 0 0 auto; width: 1rem; @@ -446,16 +451,22 @@
- - +
+ + + + +
+
@@ -571,18 +582,40 @@ 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 storageKey = `directory-fields:${section.dataset.directoryPage || 'page'}`; + const userKey = @json((string) (auth()->id() ?? 'guest')); + const tabKey = new URL(window.location.href).searchParams.get('tab') || 'default'; + const storageKey = `directory-fields:${userKey}:${section.dataset.directoryPage || 'page'}:${tabKey}`; + 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 applyColumns = (save = true) => { - const selected = new Set(checks.filter((check) => check.checked).map((check) => check.value)); + const ordered = orderedChecks(); + const selected = new Set(ordered.filter((check) => check.checked).map((check) => check.value)); + const rank = new Map(ordered.map((check, index) => [check.value, index])); table.dataset.columnsReady = 'true'; table.querySelectorAll('[data-column-key]').forEach((cell) => { cell.classList.toggle('is-column-hidden', !selected.has(cell.dataset.columnKey)); }); - if (save) localStorage.setItem(storageKey, JSON.stringify([...selected])); + [...table.tHead.rows].forEach((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) => { + [...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] })); }; try { const saved = JSON.parse(localStorage.getItem(storageKey) || 'null'); - if (Array.isArray(saved)) checks.forEach((check) => { check.checked = saved.includes(check.value); }); + const savedOrder = Array.isArray(saved) ? saved : saved?.order; + const savedSelected = Array.isArray(saved) ? saved : saved?.selected; + if (Array.isArray(savedSelected)) checks.forEach((check) => { check.checked = savedSelected.includes(check.value); }); + 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]'); + if (option) picker.querySelector('.directory-field-picker__panel').appendChild(option); + }); } catch (_) {} applyColumns(false); toggle?.addEventListener('click', () => { @@ -591,28 +624,47 @@ toggle.setAttribute('aria-expanded', String(isHidden)); }); checks.forEach((check) => check.addEventListener('change', () => applyColumns())); + let dragging = null; + picker?.querySelectorAll('[data-directory-field-option]').forEach((option) => { + option.addEventListener('dragstart', () => { dragging = option; option.classList.add('is-dragging'); }); + option.addEventListener('dragend', () => { dragging = null; option.classList.remove('is-dragging'); picker.querySelectorAll('.is-drop-target').forEach((item) => item.classList.remove('is-drop-target')); applyColumns(); }); + option.addEventListener('dragover', (event) => { event.preventDefault(); if (dragging && dragging !== option) option.classList.add('is-drop-target'); }); + option.addEventListener('dragleave', () => option.classList.remove('is-drop-target')); + option.addEventListener('drop', (event) => { event.preventDefault(); if (!dragging || dragging === option) return; option.parentNode.insertBefore(dragging, option); option.classList.remove('is-drop-target'); applyColumns(); }); + }); + const resetColumns = () => { + const options = [...picker.querySelectorAll('[data-directory-field-option]')]; + const byKey = new Map(options.map((option) => [option.querySelector('[data-directory-field-check]').value, option])); + defaultOrder.forEach((key) => picker.querySelector('.directory-field-picker__panel').appendChild(byKey.get(key))); + checks.forEach((check) => { check.checked = !['sales_01', 'sales_02', 'sales_03', 'sales_04', 'sales_05', 'sales_06'].includes(check.value); }); + applyColumns(); + }; + const resetButton = tools.querySelector('[data-directory-reset]'); + if (resetButton) resetButton.onclick = resetColumns; search.addEventListener('input', () => { const query = search.value.trim().toLocaleLowerCase(); table.querySelectorAll('tbody tr').forEach((row) => { row.hidden = Boolean(query) && !row.textContent.toLocaleLowerCase().includes(query); }); }); - tools.querySelector('[data-directory-export]')?.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 rows = [...table.querySelectorAll('tr')].filter((row) => !row.hidden); - const csv = rows.map((row) => [...row.children].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); - link.download = `${section.dataset.directoryPage || 'directory'}-export.csv`; + link.download = `${section.dataset.directoryPage || 'directory'}-current-export.csv`; link.click(); URL.revokeObjectURL(link.href); - }); - tools.querySelector('[data-directory-export-all]')?.addEventListener('click', async (event) => { + })); + 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 = new Set(checks.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] - .filter((cell) => !cell.dataset.columnKey || selected.has(cell.dataset.columnKey)); + .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; @@ -643,7 +695,7 @@ button.disabled = false; button.textContent = originalLabel; } - }); + })); tools.querySelector('[data-directory-template]')?.addEventListener('click', () => { const header = [...table.querySelectorAll('thead th')].map((cell) => csvEscape(cell.textContent.trim())).join(','); const blob = new Blob([`\ufeff${header}\n`], { type: 'text/csv;charset=utf-8' }); diff --git a/tests/Feature/Business/ManagementCrudTest.php b/tests/Feature/Business/ManagementCrudTest.php index e0222642..23bbc8fb 100644 --- a/tests/Feature/Business/ManagementCrudTest.php +++ b/tests/Feature/Business/ManagementCrudTest.php @@ -402,6 +402,18 @@ class ManagementCrudTest extends FeatureTestCase $response->assertSeeText($header); } } + + $this->get(route('business.pages.show', [ + 'section' => 'store-management', + 'page' => 'store-report', + ])) + ->assertOk() + ->assertSee('data-directory-field-picker', false) + ->assertSee('data-directory-export="visible"', false) + ->assertSee('data-directory-export-all="all-fields"', false) + ->assertSee('data-directory-reset', false) + ->assertSee('position: sticky', false) + ->assertSee('overflow: auto', false); } private function mockUpstreamPage(object $row): void -- 2.47.2 From e6a30137df330fb0e3909f0b3f0536b69d63e41c Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 24 Sep 2026 00:00:26 +0800 Subject: [PATCH 4/5] feat: improve business directory filters and export --- app/Business/UpstreamBusinessInformation.php | 49 ++- app/Utilities/SidebarPermissions.php | 25 +- config/business_navigation.php | 22 + .../BusinessInformationMirrorRepository.php | 143 +++++- .../StoreAccountMirrorRepository.php | 41 +- .../pages/partials/directory-table.blade.php | 406 +++++++++++++++++- .../pages/partials/prototype-page.blade.php | 4 + .../components/layouts/admin/menu.blade.php | 1 + tests/Feature/Business/ManagementCrudTest.php | 27 +- ...usinessInformationMirrorRepositoryTest.php | 24 ++ .../StoreAccountMirrorRepositoryTest.php | 5 +- .../UpstreamBusinessInformationTest.php | 2 +- 12 files changed, 722 insertions(+), 27 deletions(-) diff --git a/app/Business/UpstreamBusinessInformation.php b/app/Business/UpstreamBusinessInformation.php index 8d6b1884..32b4caf7 100644 --- a/app/Business/UpstreamBusinessInformation.php +++ b/app/Business/UpstreamBusinessInformation.php @@ -88,10 +88,12 @@ class UpstreamBusinessInformation private function companyPage(int $page, int $perPage): array { + $filters = $this->pageFilters('company-management', 'basic-information'); return $this->page( 'company-information.read', $page, $perPage, + $filters, fn () => $this->snapshot('company-information.read', [ 'page' => $page, 'perPage' => $perPage, @@ -133,10 +135,12 @@ SQL); private function storePage(int $page, int $perPage): array { + $filters = $this->pageFilters('store-management', 'basic-information'); return $this->page( 'store-information.read', $page, $perPage, + $filters, fn () => $this->snapshot('store-information.read', [ 'page' => $page, 'perPage' => $perPage, @@ -157,7 +161,11 @@ SQL); private function storeAccountPage(int $page, int $perPage): array { try { - return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->paginate($page, $perPage); + return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->paginate( + $page, + $perPage, + $this->pageFilters('store-management', 'store-accounts'), + ); } catch (\RuntimeException $exception) { report($exception); throw new ServiceUnavailableHttpException(null, '店铺账号共享镜像尚未完成首次同步。'); @@ -471,10 +479,12 @@ SQL); private function trademarkPage(int $page, int $perPage): array { + $filters = $this->pageFilters('trademark-management', 'information'); return $this->page( 'trademark-information.read', $page, $perPage, + $filters, fn () => $this->snapshot('trademark-information.read', [ 'page' => $page, 'perPage' => $perPage, @@ -511,10 +521,14 @@ SQL); ]; } - private function page(string $scope, int $page, int $perPage, callable $resolver): array + private function page(string $scope, int $page, int $perPage, array $filters, callable $resolver): array { if ($this->mirror->ready($scope)) { - return $this->normalize($scope, $this->mirror->paginate($scope, $page, $perPage)); + return $this->normalize($scope, $this->mirror->paginate($scope, $page, $perPage, $filters)); + } + + if ($filters !== []) { + throw new ServiceUnavailableHttpException(null, '当前数据源暂不支持所选条件查询,请检查共享业务镜像状态。'); } if ((app(\App\Utilities\SystemDatabaseConfiguration::class)->current()['mode'] ?? '') === 'bootstrap' @@ -529,6 +543,34 @@ SQL); }); } + /** @return array */ + private function pageFilters(string $section, string $page): array + { + $fields = collect(config('business_navigation')) + ->firstWhere('slug', $section)['pages'] ?? []; + $pageConfig = collect($fields)->flatMap(fn (array $item) => [$item, ...($item['children'] ?? [])])->firstWhere('slug', $page); + $allowed = collect($pageConfig['directory']['fields'] ?? []) + ->reject(fn (array $field) => ($field['kind'] ?? '') === 'secret' && ($field['name'] ?? '') !== 'withdrawal_account') + ->pluck('name')->all(); + if ($section === 'company-management' && $page === 'basic-information') { + $allowed = array_values(array_unique([...$allowed, 'company_type', 'related_people'])); + } + $filters = []; + foreach ($allowed as $name) { + foreach ([$name, $name . '_from', $name . '_to'] as $key) { + $value = trim((string) request($key, '')); + if ($value !== '') $filters[$key] = mb_substr($value, 0, 191); + } + } + $sort = trim((string) request('sort', '')); + if (in_array($sort, $allowed, true)) { + $filters['_sort'] = $sort; + $filters['_direction'] = request('direction') === 'desc' ? 'desc' : 'asc'; + } + + return $filters; + } + /** @param array{data: array>, total: int} $payload */ private function normalize(string $scope, array $payload): array { @@ -550,6 +592,7 @@ SQL); $row = $this->toUtf8($row); $row['establishment_date'] = $this->date($row['establishment_date'] ?? null); $row['subscribed_at'] = $this->date($row['subscribed_at'] ?? null); + $row['city'] = RegisteredAddressCity::parse($row['registered_address'] ?? null); $row['company_status'] = match ((string) ($row['company_status'] ?? '')) { '0' => '正常', '1' => '已注销', diff --git a/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php index 0c36a020..44a7f4a6 100644 --- a/app/Utilities/SidebarPermissions.php +++ b/app/Utilities/SidebarPermissions.php @@ -50,7 +50,7 @@ class SidebarPermissions return $navigation; } - return array_values(array_filter(array_map(function (array $section) use ($allowed) { + $navigation = array_values(array_filter(array_map(function (array $section) use ($allowed) { $sectionKey = $section['slug']; if (in_array($sectionKey, $allowed, true)) { @@ -92,6 +92,29 @@ class SidebarPermissions return null; }, $navigation))); + + // 新增的公司成本安排与主体经营报表共用公司经营报表权限。 + // 对已经保存过精确权限的账号兼容旧配置,避免新增页面被旧权限集合隐藏。 + foreach ($navigation as &$section) { + if (($section['slug'] ?? null) !== 'company-management') { + continue; + } + + $hasSubjectReport = collect($section['pages'] ?? [])->contains('slug', 'subject-operating-report'); + $hasCostArrangement = collect($section['pages'] ?? [])->contains('slug', 'company-cost-arrangement'); + + if ($hasSubjectReport && ! $hasCostArrangement) { + $costPage = collect(BusinessNavigation::all()) + ->firstWhere('slug', 'company-management')['pages'] ?? []; + $costPage = collect($costPage)->firstWhere('slug', 'company-cost-arrangement'); + if ($costPage) { + $section['pages'][] = $costPage; + } + } + } + unset($section); + + return $navigation; } public static function allKeys(): array diff --git a/config/business_navigation.php b/config/business_navigation.php index 3da486dd..3536904c 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -26,6 +26,28 @@ return [ ], ], ], + [ + 'slug' => 'company-cost-arrangement', + 'title' => '公司成本安排', + 'directory' => [ + 'icon' => 'account_balance_wallet', + 'status' => '报表待定义', + 'description' => '按公司主体查看当月收入、成本缺口及成本费用安排。', + 'empty' => '公司成本安排尚未同步数据。', + 'fields' => [ + ['name' => 'company', 'label' => '公司'], + ['name' => 'company_type', 'label' => '公司类型'], + ['name' => 'invoice_type', 'label' => '发票类型'], + ['name' => 'current_month_income_forecast', 'label' => '当月收入预测', 'kind' => 'number'], + ['name' => 'historical_cost_gap', 'label' => '往期成本缺口', 'kind' => 'number'], + ['name' => 'supplier_cost_arrangement', 'label' => '供应商成本安排', 'kind' => 'number'], + ['name' => 'courier_cost_arrangement', 'label' => '快递成本安排', 'kind' => 'number'], + ['name' => 'current_month_tax_forecast', 'label' => '当月税金预测', 'kind' => 'number'], + ['name' => 'labor_cost_forecast', 'label' => '人工成本预测', 'kind' => 'number'], + ['name' => 'other_expense_forecast', 'label' => '其他费用预测', 'kind' => 'number'], + ], + ], + ], [ 'slug' => 'basic-information', 'title' => '公司基础信息', diff --git a/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php b/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php index 43b197ed..a71098a9 100644 --- a/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php @@ -31,7 +31,7 @@ class BusinessInformationMirrorRepository } /** @return array{data: array>, total: int} */ - public function paginate(string $scope, int $page, int $perPage): array + public function paginate(string $scope, int $page, int $perPage, array $filters = []): array { if (! $this->ready($scope)) { throw new RuntimeException('business_mirror_dataset_not_ready'); @@ -41,9 +41,9 @@ class BusinessInformationMirrorRepository $perPage = min(max($perPage, 1), 100); return match ($scope) { - 'company-information.read' => $this->companies($page, $perPage), - 'store-information.read' => $this->stores($page, $perPage), - 'trademark-information.read' => $this->trademarks($page, $perPage), + 'company-information.read' => $this->companies($page, $perPage, $filters), + 'store-information.read' => $this->stores($page, $perPage, $filters), + 'trademark-information.read' => $this->trademarks($page, $perPage, $filters), default => throw new InvalidArgumentException('未知的业务资料镜像范围:' . $scope), }; } @@ -162,7 +162,7 @@ SQL) } /** @return array{data: array>, total: int} */ - private function companies(int $page, int $perPage): array + private function companies(int $page, int $perPage, array $filters): array { $query = $this->connection->table('ops_fys_company_info') ->selectRaw(<<<'SQL' @@ -184,13 +184,77 @@ taxCategoryAndType AS taxpayer_category, NULL AS shareholders SQL); - return $this->page($query, $page, $perPage, 'Id'); + $this->applyFieldFilters($query, $filters, [ + 'unified_social_credit_code' => 'unifiedSocialCreditCode', 'company_name' => 'businessLicenseName', + 'establishment_date' => 'IncorporationDate', + 'registered_at' => 'IncorporationDate', + 'legal_representative' => 'legalRepresentative', 'registered_address' => 'registeredAddress', + 'city' => 'registeredAddress', + 'business_scope' => 'businessScope', 'subscribed_at' => 'registeredCapitalVaildTime', + 'financial_officer' => 'financialManager', + 'billing_officer' => 'financialManager', + 'supervisor' => 'supervisor', + ], ['subscribed_capital' => 'registeredCapital'], [ + 'company_type' => 'businessLicenseType', + 'taxpayer_type' => 'taxCategoryAndType', + 'taxpayer_category' => 'taxCategoryAndType', + ]); + $companyStatus = trim((string) ($filters['company_status'] ?? '')); + if ($companyStatus !== '') { + $statusCode = ['正常' => '0', '已注销' => '1'][$companyStatus] ?? $companyStatus; + $query->where('companyStatus', '=', $statusCode); + } + $relatedPeople = mb_substr(trim((string) ($filters['related_people'] ?? '')), 0, 191); + if ($relatedPeople !== '') { + $like = '%' . addcslashes($relatedPeople, '%_\\') . '%'; + $query->where(function ($query) use ($like): void { + $query->where('legalRepresentative', 'like', $like) + ->orWhere('supervisor', 'like', $like) + ->orWhere('financialManager', 'like', $like); + }); + } + return $this->page($query, $page, $perPage, 'Id', $filters, [ + 'unified_social_credit_code' => 'unifiedSocialCreditCode', 'company_name' => 'businessLicenseName', + 'taxpayer_type' => 'taxCategoryAndType', 'company_status' => 'companyStatus', + 'subscribed_capital' => 'registeredCapital', 'registered_at' => 'IncorporationDate', + 'legal_representative' => 'legalRepresentative', 'supervisor' => 'supervisor', + 'billing_officer' => 'financialManager', 'registered_address' => 'registeredAddress', + 'city' => 'registeredAddress', 'business_scope' => 'businessScope', + 'basic_deposit_account' => 'accountSerialNumber', 'subscribed_at' => 'registeredCapitalVaildTime', + ]); } /** @return array{data: array>, total: int} */ - private function stores(int $page, int $perPage): array + private function stores(int $page, int $perPage, array $filters): array { - return $this->page($this->storeQuery(), $page, $perPage, 's.Id'); + $query = $this->storeQuery(); + $this->applyFieldFilters($query, $filters, [ + 'store_id' => 's.sotreId', 'store_name' => 's.erpShopName', 'store_entity' => 'c.businessLicenseName', + 'trademark' => 't.trademarkName', 'trademark_number' => 's.trademarkNumber', + 'business_department' => 's.responsibleDepartment', 'store_manager' => 's.shopHead', + 'department_manager' => 's.customerDeptManager', 'opened_at' => 's.openTime', 'renewed_at' => 's.renewalTime', + ], [], ['platform' => 's.platform']); + $storeStatus = trim((string) ($filters['store_status'] ?? '')); + if ($storeStatus !== '') { + $statusCode = ['正常' => '0', '关闭中' => '3', '已关闭' => '6'][$storeStatus] ?? $storeStatus; + $query->where('s.shopState', '=', $statusCode); + } + $withdrawalAccount = trim((string) ($filters['withdrawal_account'] ?? '')); + if ($withdrawalAccount === '已配置') { + $query->whereNotNull('s.shopBankAccount')->where('s.shopBankAccount', '<>', ''); + } elseif ($withdrawalAccount === '未配置') { + $query->where(function ($query): void { + $query->whereNull('s.shopBankAccount')->orWhere('s.shopBankAccount', '=', ''); + }); + } + return $this->page($query, $page, $perPage, 's.Id', $filters, [ + 'platform' => 's.platform', 'store_id' => 's.sotreId', 'store_name' => 's.erpShopName', + 'store_entity' => 'c.businessLicenseName', 'store_status' => 's.shopState', + 'trademark' => 't.trademarkName', 'trademark_number' => 's.trademarkNumber', + 'business_department' => 's.responsibleDepartment', 'store_manager' => 's.shopHead', + 'department_manager' => 's.customerDeptManager', 'opened_at' => 's.openTime', + 'renewed_at' => 's.renewalTime', 'withdrawal_account' => 's.shopBankAccount', + ]); } private function storeQuery() @@ -244,7 +308,7 @@ SQL); } /** @return array{data: array>, total: int} */ - private function trademarks(int $page, int $perPage): array + private function trademarks(int $page, int $perPage, array $filters): array { $query = $this->connection->table('ops_fys_trademark_info') ->where('isDelete', 0) @@ -263,14 +327,69 @@ custodyDepartment AS custody_department, remark AS notes SQL); - return $this->page($query, $page, $perPage, 'Id'); + $this->applyFieldFilters($query, $filters, [ + 'trademark_number' => 'trademarkNumber', 'trademark_name' => 'trademarkName', + 'registration_date' => 'registrationDate', + 'expiry_date' => 'termOfValidity', 'international_classification' => 'internationalClassification', + 'approved_goods' => 'approvedCommodities', 'final_holder' => 'transferenceUser', + 'registered_address' => 'registrationAddress', 'custody_department' => 'custodyDepartment', + ]); + $status = trim((string) ($filters['trademark_status'] ?? '')); + if ($status !== '') { + $statusCode = ['未启用' => '0', '有效' => '1'][$status] ?? $status; + $query->where('trademarkStatus', '=', $statusCode); + } + return $this->page($query, $page, $perPage, 'Id', $filters, [ + 'trademark_name' => 'trademarkName', 'trademark_number' => 'trademarkNumber', + 'trademark_status' => 'trademarkStatus', 'registration_date' => 'registrationDate', + 'expiry_date' => 'termOfValidity', 'international_classification' => 'internationalClassification', + 'approved_goods' => 'approvedCommodities', 'final_holder' => 'transferenceUser', + 'registered_address' => 'registrationAddress', 'custody_department' => 'custodyDepartment', + ]); + } + + /** @param array $textColumns @param array $exactColumns @param array $enumColumns */ + private function applyFieldFilters($query, array $filters, array $textColumns, array $exactColumns = [], array $enumColumns = []): void + { + foreach ($textColumns as $key => $column) { + $value = mb_substr(trim((string) ($filters[$key] ?? '')), 0, 191); + if ($value !== '') { + $query->where($column, 'like', '%' . addcslashes($value, '%_\\') . '%'); + } + } + foreach ($exactColumns as $key => $column) { + $value = trim((string) ($filters[$key] ?? '')); + if ($value !== '') { + $query->where($column, '=', $value); + } + } + foreach ($enumColumns as $key => $column) { + $value = trim((string) ($filters[$key] ?? '')); + if ($value !== '') { + $query->where($column, '=', $value); + } + } + foreach (['opened_at' => 's.openTime', 'renewed_at' => 's.renewalTime', 'registration_date' => 'registrationDate', 'expiry_date' => 'termOfValidity'] as $key => $column) { + if (! array_key_exists($key, $textColumns)) continue; + foreach (['_from' => '>=', '_to' => '<='] as $suffix => $operator) { + $date = trim((string) ($filters[$key . $suffix] ?? '')); + if (preg_match('/^\\d{4}-\\d{2}-\\d{2}$/D', $date)) $query->where($column, $operator, $date); + } + } } /** @return array{data: array>, total: int} */ - private function page($query, int $page, int $perPage, string $orderColumn): array + private function page($query, int $page, int $perPage, string $orderColumn, array $filters = [], array $sortColumns = []): array { $total = (clone $query)->count(); - $rows = $query->orderByDesc($orderColumn) + $sortColumn = $sortColumns[$filters['_sort'] ?? ''] ?? null; + $direction = ($filters['_direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc'; + if ($sortColumn !== null) { + $query->orderBy($sortColumn, $direction)->orderByDesc($orderColumn); + } else { + $query->orderByDesc($orderColumn); + } + $rows = $query ->forPage($page, $perPage) ->get() ->map(static fn (object $row): array => (array) $row) diff --git a/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php index 7ec925fc..741376cc 100644 --- a/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php @@ -15,7 +15,7 @@ class StoreAccountMirrorRepository ) {} /** @return array{data:array>,total:int} */ - public function paginate(int $page, int $perPage): array + public function paginate(int $page, int $perPage, array $filters = []): array { $dataset = $this->datasets->get(self::DATASET); if (! $this->mirror->ready($dataset)) { @@ -25,9 +25,46 @@ class StoreAccountMirrorRepository $page = max($page, 1); $perPage = min(max($perPage, 1), 100); $query = $this->mirror->connection()->table($dataset->targetTable()); + $columns = [ + 'store_name' => 'storeName', 'operation_owner' => 'operationOwner', + 'operation_department_head' => 'operationDepartmentHead', 'operation_director' => 'operationDirector', + 'profit_center' => 'profitCenter', 'account' => 'account', 'bound_phone' => 'boundPhone', + 'verification_contact' => 'verificationContact', 'account_group_name' => 'accountGroupName', + ]; + foreach ($columns as $key => $column) { + $value = mb_substr(trim((string) ($filters[$key] ?? '')), 0, 191); + if ($value !== '') $query->where($column, 'like', '%' . addcslashes($value, '%_\\') . '%'); + } + $accountType = trim((string) ($filters['account_type'] ?? '')); + if ($accountType !== '') $query->where('accountType', '=', $accountType === '主账号' ? 'MAIN' : ($accountType === '子账号' ? 'SUB' : $accountType)); + $passwordState = trim((string) ($filters['password_configured'] ?? '')); + if (in_array($passwordState, ['已设置', '未设置', '状态未同步'], true)) { + if ($passwordState === '状态未同步') $query->whereNull('passwordConfigured'); + else $query->where('passwordConfigured', $passwordState === '已设置'); + } + foreach (['created_at' => 'createdAt', 'updated_at' => 'updatedAt'] as $key => $column) { + foreach (['_from' => '>=', '_to' => '<='] as $suffix => $operator) { + $date = trim((string) ($filters[$key . $suffix] ?? '')); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date)) $query->whereDate($column, $operator, $date); + } + } $total = (clone $query)->count(); + $sortColumns = [ + 'store_name' => 'storeName', 'operation_owner' => 'operationOwner', + 'operation_department_head' => 'operationDepartmentHead', 'operation_director' => 'operationDirector', + 'profit_center' => 'profitCenter', 'account_type' => 'accountType', 'account' => 'account', + 'password_configured' => 'passwordConfigured', 'bound_phone' => 'boundPhone', + 'verification_contact' => 'verificationContact', 'account_group_name' => 'accountGroupName', + 'created_at' => 'createdAt', 'updated_at' => 'updatedAt', + ]; + $sortColumn = $sortColumns[$filters['_sort'] ?? ''] ?? null; + $direction = ($filters['_direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc'; + if ($sortColumn !== null) { + $query->orderBy($sortColumn, $direction)->orderByDesc('id'); + } else { + $query->orderByDesc('id'); + } $rows = $query - ->orderByDesc('id') ->forPage($page, $perPage) ->get() ->map(fn (object $row): array => [ diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index a24817f4..6089a8b1 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -23,6 +23,19 @@ $directoryCurrentPage = $directoryIsPaginated ? $directoryRows->currentPage() : 1; $directoryLastPage = $directoryIsPaginated ? $directoryRows->lastPage() : 1; $directoryPerPage = $directoryIsPaginated ? $directoryRows->perPage() : 0; + $directoryPageKey = ($section['slug'] ?? '') . '/' . ($page['slug'] ?? ''); + $directoryUsesFieldQuery = in_array($directoryPageKey, [ + 'company-management/basic-information', + 'store-management/basic-information', + 'trademark-management/information', + 'store-management/store-accounts', + ], true); + $directoryQueryPageDescriptions = [ + 'company-management/basic-information' => '公司主体、状态和登记资料的查询与导出', + 'store-management/basic-information' => '店铺主体、平台和经营资料的查询与导出', + 'trademark-management/information' => '商标主体、状态和登记资料的查询与导出', + 'store-management/store-accounts' => '店铺账号资料的查询与安全状态导出', + ]; @endphp -
+
+ @if ($directoryUsesFieldQuery) +
+
+ {{ $page['title'] ?? $section['title'] }} +

{{ $directoryQueryPageDescriptions[$directoryPageKey] ?? '基础资料的查询与导出' }}

+
+ {{ $directory['status'] ?? '尚未同步' }} +
+ @if (!empty($upstreamError)) + + @endif + @else
@@ -387,8 +517,140 @@ {{ $directory['empty'] ?? '数据尚未同步,当前只展示已确认的字段结构。' }}
@endif + @endif @if (!empty($directoryFields)) + @php + $directoryQueryFields = $directoryPageKey === 'company-management/basic-information' + ? [ + ['name' => 'unified_social_credit_code', 'label' => '统一社会信用代码', 'placeholder' => '输入完整或部分代码'], + ['name' => 'company_name', 'label' => '公司名称', 'placeholder' => '输入公司名称关键词'], + ['name' => 'taxpayer_type', 'label' => '纳税人类型'], + ['name' => 'company_status', 'label' => '公司状态'], + ['name' => 'legal_representative', 'label' => '法人', 'placeholder' => '按姓名查询'], + ['name' => 'city', 'label' => '城市', 'placeholder' => '城市较多,使用搜索'], + ['name' => 'related_people', 'label' => '相关人员', 'placeholder' => '输入人员姓名,模糊匹配'], + ['name' => 'company_type', 'label' => '公司类型'], + ] + : $directoryFields; + $directoryExcludedQueryFields = match ($directoryPageKey) { + 'store-management/basic-information' => ['opened_at', 'renewed_at'], + 'trademark-management/information' => ['registration_date', 'expiry_date'], + default => [], + }; + if (!empty($directoryExcludedQueryFields)) { + $directoryQueryFields = collect($directoryQueryFields) + ->reject(fn (array $field): bool => in_array( + $field['name'], + $directoryExcludedQueryFields, + true, + )) + ->values() + ->all(); + } + $directorySelectFields = match (($section['slug'] ?? '') . '/' . ($page['slug'] ?? '')) { + 'company-management/basic-information' => ['taxpayer_type', 'company_status', 'company_type'], + 'store-management/basic-information' => ['platform', 'store_status', 'withdrawal_account'], + 'trademark-management/information' => ['trademark_status'], + 'store-management/store-accounts' => ['account_type', 'password_configured'], + default => [], + }; + $directoryColumnNames = collect($directoryFields)->pluck('name')->all(); + $requestedDirectoryColumns = array_values(array_intersect( + array_filter(explode(',', (string) request('columns', ''))), + $directoryColumnNames, + )); + $visibleDirectoryColumns = request()->has('columns') ? $requestedDirectoryColumns : $directoryColumnNames; + $directoryQueryFormId = 'directory-query-form-' . ($page['slug'] ?? 'page'); + @endphp + @if ($directoryUsesFieldQuery) +
+
+ 查询条件 + 类型较少的字段直接选择,内容较多的字段支持关键词搜索 +
+
+ @if (request()->has('columns')) + + @endif + @foreach ($directoryQueryFields as $field) + @php $kind = $field['kind'] ?? 'text'; @endphp + @continue($kind === 'secret' && $field['name'] !== 'withdrawal_account') + @continue(($page['slug'] ?? '') === 'store-accounts' && in_array($field['name'], [ + 'account', + 'password_configured', + 'bound_phone', + 'verification_contact', + 'account_group_name', + 'created_at', + 'updated_at', + ], true)) + + @endforeach +
+
+ 勾选字段 +
+ 勾选需要显示的表头,可重复调整。 + @foreach ($directoryFields as $field) + + @endforeach +
+
+ + +
+ 导出 Excel +
+ 默认全选,可取消不需要导出的表头。 + @foreach ($directoryFields as $field) + + @endforeach + +
+
+
+
+ @endif @if (($page['slug'] ?? '') === 'store-report')
@php @@ -445,6 +707,7 @@
@endif + @if (!in_array(($section['slug'] ?? '') . '/' . ($page['slug'] ?? ''), ['company-management/basic-information', 'store-management/basic-information', 'trademark-management/information', 'store-management/store-accounts'], true))
@@ -469,13 +732,28 @@
+ @endif
- +
@foreach ($directoryFields as $field) - + @endforeach @@ -503,7 +781,7 @@ $display = in_array($value, [true, 1, '1', '是', '✔'], true) ? '是' : '否'; } @endphp -
#{{ $field['label'] }} + @if ($directoryUsesFieldQuery) + @php + $isSorted = request('sort') === $field['name']; + $direction = $isSorted ? request('direction', 'asc') : null; + $nextDirection = $isSorted && $direction === 'asc' ? 'desc' : 'asc'; + @endphp + + {{ $field['label'] }} + + + @else + {{ $field['label'] }} + @endif +
+ @if (($page['slug'] ?? '') === 'store-accounts' && $field['name'] === 'password_configured') {{ filled($display) ? $display : '—' }} @if (\App\Utilities\SystemAccess::canOpenSettings() && $value !== false) @@ -573,6 +851,126 @@