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))
+
+ error_outline
+ {{ $upstreamError }}
+
+ @endif
+ @else
{{ $directory['icon'] ?? 'table_view' }}
@@ -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
+
+
+
+
+
+ file_download导出 Excel
+
+ 默认全选,可取消不需要导出的表头。
+ @foreach ($directoryFields as $field)
+
+ @endforeach
+
+
+
+
+
+ @endif
@if (($page['slug'] ?? '') === 'store-report')
@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