feat: improve business directory filters and export

This commit is contained in:
WuYongJie 2026-09-24 00:00:26 +08:00
parent 44374faa0b
commit ea2d2f9ca1
12 changed files with 722 additions and 27 deletions

View File

@ -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, '店铺账号共享镜像尚未完成首次同步。');
@ -469,10 +477,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,
@ -509,10 +519,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'
@ -527,6 +541,34 @@ SQL);
});
}
/** @return array<string,string> */
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<int,array<string,mixed>>, total: int} $payload */
private function normalize(string $scope, array $payload): array
{
@ -548,6 +590,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' => '已注销',

View File

@ -76,7 +76,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)) {
@ -118,6 +118,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

View File

@ -26,6 +26,28 @@ $navigation = [
],
],
],
[
'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' => '公司基础信息',

View File

@ -31,7 +31,7 @@ class BusinessInformationMirrorRepository
}
/** @return array{data: array<int,array<string,mixed>>, 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<int,array<string,mixed>>, 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<int,array<string,mixed>>, 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()
@ -245,7 +309,7 @@ SQL);
}
/** @return array{data: array<int,array<string,mixed>>, 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)
@ -264,14 +328,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<string,string> $textColumns @param array<string,string> $exactColumns @param array<string,string> $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<int,array<string,mixed>>, 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)

View File

@ -15,7 +15,7 @@ class StoreAccountMirrorRepository
) {}
/** @return array{data:array<int,array<string,mixed>>,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 => [

View File

@ -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
<style>
@ -35,6 +48,37 @@
color: var(--vip-content-ink, #171717);
}
.directory-table-page.has-field-query {
margin-top: 1.5rem;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.directory-query-page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.directory-query-page-head strong {
display: block;
color: var(--vip-content-ink, #171717);
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25;
}
.directory-query-page-head p {
margin: 0.4rem 0 0;
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
line-height: 1.5;
}
.directory-table-head {
display: flex;
align-items: flex-start;
@ -115,6 +159,60 @@
background: var(--vip-content-bg, #f8f6f1);
}
.directory-query-bar {
display: grid;
grid-template-columns: repeat(4, minmax(12rem, 1fr));
gap: 1rem 1.25rem;
margin: 0;
padding: 1.25rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
border-bottom: 1px solid var(--vip-border, #d8d2c7);
background: #fff;
}
.directory-query-heading { display: flex; grid-column: 1 / -1; align-items: center; justify-content: space-between; gap: 1rem; padding-bottom: 0.125rem; color: var(--vip-content-accent-dark, #856632); }
.directory-query-heading strong { font-size: 0.8125rem; }
.directory-query-heading span { color: var(--vip-content-muted, #595959); font-size: 0.6875rem; font-weight: 400; }
.directory-query-field { position: relative; display: flex; min-width: 0; flex-direction: column; align-items: stretch; gap: 0.45rem; color: var(--vip-content-muted, #595959); font-size: 0.75rem; }
.directory-query-field > span:first-child { overflow: hidden; color: var(--vip-content-ink, #171717); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.directory-query-field input, .directory-query-field select { width: 100%; min-width: 0; height: 2.125rem; padding: 0 0.625rem; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 0.375rem; background: #fff; color: var(--vip-content-ink, #171717); font-size: 0.75rem; }
.directory-query-field input:focus, .directory-query-field select:focus { border-color: var(--vip-content-accent, #b9954f); outline: 2px solid rgba(185, 149, 79, 0.16); }
.directory-query-dates { display: grid; flex: 1 1 auto; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 0.25rem; width: 100%; min-width: 0; }
.directory-query-dates input { min-width: 0; }
.directory-query-suggestions { position: absolute; top: calc(100% + 0.25rem); right: 0; left: 0; z-index: 6; margin: 0; padding: 0.25rem; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 0.375rem; background: #fff; box-shadow: 0 5px 12px rgba(23,23,23,0.12); list-style: none; }
.directory-query-suggestions[hidden] { display: none; }
.directory-query-suggestions button { display: block; width: 100%; overflow: hidden; padding: 0.4rem 0.5rem; border: 0; border-radius: 0.25rem; background: transparent; color: var(--vip-content-ink, #171717); font: inherit; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.directory-query-suggestions button:hover, .directory-query-suggestions button:focus { background: var(--vip-content-accent-soft, #f7f0de); outline: 0; }
.directory-query-actions { display: flex; grid-column: 1 / -1; flex-wrap: wrap; align-items: flex-start; justify-content: flex-end; gap: 0.5rem; padding-top: 0.125rem; }
.directory-query-actions button { height: 2.1rem; padding: 0 0.8rem; border: 1px solid var(--vip-content-accent, #b9954f); border-radius: 0.375rem; background: #fff; color: var(--vip-content-accent-dark, #856632); cursor: pointer; }
.directory-query-actions .is-primary { background: var(--vip-content-accent-dark, #856632); color: #fff; }
.directory-query-picker, .directory-query-export { position: relative; }
.directory-query-picker summary, .directory-query-export summary { display: flex; height: 2.1rem; align-items: center; padding: 0 0.8rem; border: 1px solid var(--vip-content-accent, #b9954f); border-radius: 0.375rem; background: #fff; color: var(--vip-content-accent-dark, #856632); cursor: pointer; list-style: none; }
.directory-query-export summary { gap: 0.35rem; }
.directory-query-export summary .material-icons-outlined { font-size: 1rem; }
.directory-query-picker summary::-webkit-details-marker, .directory-query-export summary::-webkit-details-marker { display: none; }
.directory-query-picker__panel, .directory-query-export__panel { position: absolute; top: calc(100% + 0.35rem); right: 0; z-index: 8; display: grid; grid-template-columns: repeat(2, minmax(10rem, 1fr)); gap: 0.35rem 0.75rem; width: max-content; min-width: 24rem; max-width: min(34rem, calc(100vw - 2rem)); max-height: 22rem; overflow: auto; padding: 0.75rem; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 0.5rem; background: #fff; box-shadow: 0 4px 8px rgba(23, 23, 23, 0.10); }
.directory-query-picker__hint { grid-column: 1 / -1; color: var(--vip-content-muted, #595959); font-size: 0.6875rem; }
.directory-query-picker__option-form { min-width: 0; }
.directory-query-picker__option { display: flex; align-items: center; gap: 0.4rem; width: 100%; min-width: 0; padding: 0.25rem; border: 0; border-radius: 0.25rem; background: transparent; color: var(--vip-content-ink, #171717); font: inherit; font-size: 0.75rem; text-align: left; cursor: pointer; }
.directory-query-picker__option:hover, .directory-query-picker__option:focus { background: var(--vip-content-accent-soft, #f7f0de); outline: 0; }
.directory-query-picker__option input { flex: 0 0 auto; width: 1rem; height: 1rem; margin: 0; accent-color: var(--vip-content-accent, #b9954f); }
.directory-query-export__option { display: flex; align-items: center; gap: 0.4rem; min-width: 0; color: var(--vip-content-ink, #171717); font-size: 0.75rem; cursor: pointer; }
.directory-query-export__option input { flex: 0 0 auto; width: 1rem; height: 1rem; margin: 0; accent-color: var(--vip-content-accent, #b9954f); }
.directory-query-export__button { grid-column: 1 / -1; justify-self: end; }
@media (max-width: 1100px) { .directory-query-bar { grid-template-columns: repeat(2, minmax(14rem, 1fr)); } }
@media (max-width: 650px) {
.directory-query-page-head { margin-bottom: 1rem; }
.directory-query-bar { grid-template-columns: minmax(0, 1fr); padding: 1rem; }
.directory-query-heading { align-items: flex-start; flex-direction: column; gap: 0.3rem; }
.directory-query-actions { justify-content: flex-start; }
.directory-query-actions > * { flex: 1 1 calc(50% - 0.25rem); }
.directory-query-actions button,
.directory-query-picker summary,
.directory-query-export summary { justify-content: center; width: 100%; }
.directory-query-picker__panel, .directory-query-export__panel { right: auto; left: 0; grid-template-columns: minmax(0, 1fr); width: min(18rem, calc(100vw - 2rem)); min-width: 0; }
}
.directory-table-tools > [data-directory-search] {
flex: 1 1 16rem;
min-width: 12rem;
@ -193,6 +291,7 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.directory-field-picker__option label { display: block; flex: 1 1 auto; min-width: 0; cursor: pointer; }
.directory-table [data-column-key="sales_01"],
.directory-table [data-column-key="sales_02"],
.directory-table [data-column-key="sales_03"],
@ -216,6 +315,18 @@
scrollbar-width: thin;
}
.directory-table-page.has-field-query .directory-table-scroll {
margin-top: 1.25rem;
border: 1px solid var(--vip-border, #d8d2c7);
background: var(--vip-surface, #fff);
}
.directory-table-page.has-field-query .directory-table-pagination {
border: 1px solid var(--vip-border, #d8d2c7);
border-top: 0;
background: var(--vip-surface, #fff);
}
.directory-table {
width: 100%;
min-width: 68rem;
@ -257,6 +368,10 @@
font-weight: 600;
box-shadow: 0 1px 0 var(--vip-border, #d8d2c7);
}
.directory-sort-link { display: inline-flex; align-items: center; gap: 0.25rem; color: inherit; text-decoration: none; }
.directory-sort-link:hover { color: var(--vip-content-accent-dark, #856632); }
.directory-sort-icon { width: 1rem; height: 1rem; color: var(--vip-content-muted, #595959); font-size: 1rem; line-height: 1; }
.directory-sort-link[aria-current="true"] .directory-sort-icon { color: var(--vip-content-accent-dark, #856632); }
.directory-table tbody tr:hover {
background: #fbf8f0;
@ -348,7 +463,7 @@
</style>
<section
class="directory-table-page"
class="directory-table-page {{ $directoryUsesFieldQuery ? 'has-field-query' : '' }}"
data-directory-page="{{ $page['slug'] ?? '' }}"
data-directory-last-page="{{ $directoryLastPage }}"
data-directory-user="{{ auth()->id() ?? 'guest' }}"
@ -356,6 +471,21 @@
data-directory-tab="{{ $activeTab['slug'] ?? '' }}"
aria-label="{{ $page['title'] ?? $section['title'] }}"
>
@if ($directoryUsesFieldQuery)
<header class="directory-query-page-head">
<div>
<strong>{{ $page['title'] ?? $section['title'] }}</strong>
<p>{{ $directoryQueryPageDescriptions[$directoryPageKey] ?? '基础资料的查询与导出' }}</p>
</div>
<span class="sr-only" data-directory-status>{{ $directory['status'] ?? '尚未同步' }}</span>
</header>
@if (!empty($upstreamError))
<div class="directory-table-notice" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $upstreamError }}</span>
</div>
@endif
@else
<header class="directory-table-head">
<div class="directory-table-title">
<span class="material-icons-outlined" aria-hidden="true">{{ $directory['icon'] ?? 'table_view' }}</span>
@ -400,8 +530,140 @@
<span>{{ $directory['empty'] ?? '数据尚未同步,当前只展示已确认的字段结构。' }}</span>
</div>
@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)
<div class="directory-query-bar" data-directory-query aria-label="{{ $page['title'] ?? '' }}查询条件">
<div class="directory-query-heading">
<strong>查询条件</strong>
<span>类型较少的字段直接选择,内容较多的字段支持关键词搜索</span>
</div>
<form id="{{ $directoryQueryFormId }}" method="GET" action="{{ request()->url() }}"></form>
@if (request()->has('columns'))
<input type="hidden" form="{{ $directoryQueryFormId }}" name="columns" value="{{ implode(',', $visibleDirectoryColumns) }}">
@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))
<label class="directory-query-field" data-directory-query-field="{{ $field['name'] }}">
<span title="{{ $field['label'] }}">{{ $field['label'] }}</span>
@if ($kind === 'date')
<span class="directory-query-dates">
<input type="date" form="{{ $directoryQueryFormId }}" name="{{ $field['name'] }}_from" value="{{ request($field['name'] . '_from') }}" data-directory-query-from="{{ $field['name'] }}" aria-label="{{ $field['label'] }}开始">
<em>至</em>
<input type="date" form="{{ $directoryQueryFormId }}" name="{{ $field['name'] }}_to" value="{{ request($field['name'] . '_to') }}" data-directory-query-to="{{ $field['name'] }}" aria-label="{{ $field['label'] }}结束">
</span>
@elseif (in_array($field['name'], $directorySelectFields, true))
<select form="{{ $directoryQueryFormId }}" name="{{ $field['name'] }}" data-directory-query-select="{{ $field['name'] }}" aria-label="{{ $field['label'] }}">
<option value="">全部</option>
@php
$enumOptions = match ($field['name']) {
'platform' => ['淘宝', '淘宝分销', '阿里巴巴', '天猫', '唯品会', '京东', '京喜', '抖音', '拼多多', '淘工厂', '得物'],
'store_status' => ['正常', '关闭中', '已关闭'],
'withdrawal_account' => ['已配置', '未配置'],
'trademark_status' => ['未启用', '有效'],
'account_type' => ['主账号', '子账号'],
'password_configured' => ['已设置', '未设置', '状态未同步'],
'taxpayer_type' => ['一般纳税人', '小规模纳税人', '个体工商户', '无税务登记', 'CORP'],
'company_status' => ['正常', '已注销'],
'company_type' => ['个体工商户', '个体户', '二级公司', '人力资源公司', '仓储公司', '其他', '合同公司', '品牌公司', '店铺-国标公司', '店铺-国标商标', '店铺-大店公司', '店铺-普通公司', '母公司', '资产型'],
default => [],
};
@endphp
@foreach ($enumOptions as $option)
<option value="{{ $option }}" @selected((string) request($field['name']) === $option)>{{ $option }}</option>
@endforeach
</select>
@else
<input type="search" form="{{ $directoryQueryFormId }}" name="{{ $field['name'] }}" value="{{ request($field['name']) }}" autocomplete="off" placeholder="{{ $field['placeholder'] ?? ('搜索' . $field['label']) }}" data-directory-query-input="{{ $field['name'] }}" aria-label="搜索{{ $field['label'] }}">
<ul class="directory-query-suggestions" data-directory-query-suggestions="{{ $field['name'] }}" hidden></ul>
@endif
</label>
@endforeach
<div class="directory-query-actions">
<details class="directory-query-picker" data-directory-query-picker>
<summary>勾选字段</summary>
<div class="directory-query-picker__panel" role="group" aria-label="勾选展示字段">
<span class="directory-query-picker__hint">勾选需要显示的表头,可重复调整。</span>
@foreach ($directoryFields as $field)
<label class="directory-query-picker__option">
<input type="checkbox" value="{{ $field['name'] }}" data-directory-query-column @checked(in_array($field['name'], $visibleDirectoryColumns, true))>
<span>{{ $field['label'] }}</span>
</label>
@endforeach
</div>
</details>
<button type="button" data-directory-query-reset>重置</button>
<button type="submit" form="{{ $directoryQueryFormId }}" class="is-primary">查询</button>
<details class="directory-query-export" data-directory-query-export-picker>
<summary><span class="material-icons-outlined" aria-hidden="true">file_download</span>导出 Excel</summary>
<div class="directory-query-export__panel" role="group" aria-label="选择导出字段">
<span class="directory-query-picker__hint">默认全选,可取消不需要导出的表头。</span>
@foreach ($directoryFields as $field)
<label class="directory-query-export__option">
<input type="checkbox" value="{{ $field['name'] }}" checked data-directory-query-export-field>
<span>{{ $field['label'] }}</span>
</label>
@endforeach
<button type="button" class="directory-query-export__button" data-directory-query-export>导出选中字段</button>
</div>
</details>
</div>
</div>
@endif
@if (($page['slug'] ?? '') === 'store-report')
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="店铺维度报表筛选查询栏">
@php
@ -484,6 +746,7 @@
</div>
</form>
@endif
@if (!in_array(($section['slug'] ?? '') . '/' . ($page['slug'] ?? ''), ['company-management/basic-information', 'store-management/basic-information', 'trademark-management/information', 'store-management/store-accounts'], true))
<div class="directory-table-tools" data-directory-tools>
<label class="sr-only" for="directory-search-{{ $page['slug'] ?? 'page' }}">搜索当前表格</label>
<input id="directory-search-{{ $page['slug'] ?? 'page' }}" type="search" placeholder="搜索当前页数据..." data-directory-search>
@ -508,13 +771,28 @@
<button type="button" data-directory-reset>恢复默认</button>
<button type="button" data-directory-template>下载表头模板</button>
</div>
@endif
<div class="directory-table-scroll">
<table class="directory-table">
<table class="directory-table" @if($directoryUsesFieldQuery) data-columns-ready="true" @endif>
<thead>
<tr>
<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' : '' }}">{{ $field['label'] }}</th>
<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)
@php
$isSorted = request('sort') === $field['name'];
$direction = $isSorted ? request('direction', 'asc') : null;
$nextDirection = $isSorted && $direction === 'asc' ? 'desc' : 'asc';
@endphp
<a class="directory-sort-link" href="{{ request()->fullUrlWithQuery(['sort' => $field['name'], 'direction' => $nextDirection, 'page' => 1]) }}" aria-label="按{{ $field['label'] }}{{ $nextDirection === 'asc' ? '升序' : '降序' }}排列" aria-current="{{ $isSorted ? 'true' : 'false' }}">
<span data-directory-header-label>{{ $field['label'] }}</span>
<span class="material-icons-outlined directory-sort-icon" aria-hidden="true">{{ !$isSorted ? 'unfold_more' : ($direction === 'asc' ? 'arrow_upward' : 'arrow_downward') }}</span>
</a>
@else
{{ $field['label'] }}
@endif
</th>
@endforeach
</tr>
</thead>
@ -544,7 +822,7 @@
$display = in_array($value, [true, 1, '1', '是', '✔'], true) ? '是' : '否';
}
@endphp
<td data-column-key="{{ $field['name'] }}" class="{{ in_array($kind, ['number', 'integer'], true) ? 'is-number' : '' }}">
<td data-column-key="{{ $field['name'] }}" class="{{ in_array($kind, ['number', 'integer'], true) ? 'is-number' : '' }} {{ !in_array($field['name'], $visibleDirectoryColumns, true) ? 'is-column-hidden' : '' }}">
@if (($page['slug'] ?? '') === 'store-accounts' && $field['name'] === 'password_configured')
<span class="sr-only" data-password-status>{{ filled($display) ? $display : '—' }}</span>
@if ($value === false)
@ -620,6 +898,126 @@
<script>
(() => {
const csvEscape = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
document.querySelectorAll('[data-directory-query]').forEach((queryBar) => {
const section = queryBar.closest('[data-directory-page]');
const table = section?.querySelector('.directory-table');
if (!table) return;
const fields = [...queryBar.querySelectorAll('[data-directory-query-field]')];
const rows = () => [...table.querySelectorAll('tbody tr:not(.is-empty)')];
const fieldValues = (row, key) => {
if (key === 'related_people') {
return ['legal_representative', 'shareholders', 'supervisor', 'billing_officer']
.map((column) => (row.querySelector(`[data-column-key="${column}"]`)?.textContent || '').trim())
.filter((value) => value && value !== '—')
.join(' / ');
}
const cell = row.querySelector(`[data-column-key="${CSS.escape(key)}"]`);
return (cell?.textContent || '').trim();
};
const refreshSuggestions = (field, value) => {
const list = field.querySelector('[data-directory-query-suggestions]');
if (!list) return;
const key = field.dataset.directoryQueryField;
const needle = value.trim().toLocaleLowerCase();
const candidates = key === 'related_people'
? rows().flatMap((row) => ['legal_representative', 'shareholders', 'supervisor', 'billing_officer']
.map((column) => (row.querySelector(`[data-column-key="${column}"]`)?.textContent || '').trim()))
: rows().map((row) => fieldValues(row, key));
const values = [...new Set(candidates.filter((item) => item && item !== '—' && (!needle || item.toLocaleLowerCase().includes(needle))))].slice(0, 8);
list.innerHTML = values.map((item) => `<li><button type="button" data-directory-suggestion-value="${item.replaceAll('&','&amp;').replaceAll('\"','&quot;').replaceAll('<','&lt;').replaceAll('>','&gt;')}">${item.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;')}</button></li>`).join('');
list.hidden = values.length === 0;
list.querySelectorAll('button').forEach((button) => button.addEventListener('click', () => {
const input = field.querySelector('[data-directory-query-input]');
if (input) { input.value = button.dataset.directorySuggestionValue || ''; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); }
list.hidden = true;
}));
};
fields.forEach((field) => {
const input = field.querySelector('[data-directory-query-input]');
const select = field.querySelector('[data-directory-query-select]');
const key = field.dataset.directoryQueryField;
const values = [...new Set(rows().map((row) => fieldValues(row, key)).filter((item) => item && item !== '—'))].sort((a,b) => a.localeCompare(b, 'zh-Hans-CN'));
if (select && select.options.length <= 1) { values.forEach((value) => { const option = document.createElement('option'); option.value = value; option.textContent = value; select.appendChild(option); }); }
input?.addEventListener('input', () => refreshSuggestions(field, input.value));
input?.addEventListener('focus', () => refreshSuggestions(field, input.value));
input?.addEventListener('blur', () => window.setTimeout(() => { const list = field.querySelector('[data-directory-query-suggestions]'); if (list) list.hidden = true; }, 150));
});
const columnChecks = [...queryBar.querySelectorAll('[data-directory-query-column]')];
const applyVisibleColumns = () => {
const selected = columnChecks.filter((check) => check.checked).map((check) => check.value);
table.querySelectorAll('[data-column-key]').forEach((cell) => {
cell.classList.toggle('is-column-hidden', !selected.includes(cell.dataset.columnKey));
});
let hiddenColumns = queryBar.querySelector(`input[form="${CSS.escape(queryBar.querySelector('form[id]')?.id || '')}"][name="columns"]`);
if (!hiddenColumns) {
hiddenColumns = document.createElement('input');
hiddenColumns.type = 'hidden';
hiddenColumns.name = 'columns';
hiddenColumns.setAttribute('form', queryBar.querySelector('form[id]')?.id || '');
queryBar.appendChild(hiddenColumns);
}
hiddenColumns.value = selected.join(',');
const url = new URL(window.location.href);
url.searchParams.set('columns', selected.join(','));
url.searchParams.delete('page');
window.history.replaceState({}, '', url);
};
columnChecks.forEach((check) => check.addEventListener('change', applyVisibleColumns));
queryBar.querySelector('[data-directory-query-reset]')?.addEventListener('click', () => {
const url = new URL(window.location.href);
fields.forEach((field) => {
const key = field.dataset.directoryQueryField;
url.searchParams.delete(key); url.searchParams.delete(`${key}_from`); url.searchParams.delete(`${key}_to`);
});
url.searchParams.delete('page');
window.location.assign(url);
});
queryBar.querySelector('[data-directory-query-export]')?.addEventListener('click', async (event) => {
const button = event.currentTarget;
const picker = button.closest('[data-directory-query-export-picker]');
const selected = new Set([...picker.querySelectorAll('[data-directory-query-export-field]:checked')].map((input) => input.value));
if (selected.size === 0) {
window.alert('请至少勾选一个导出字段');
return;
}
const headerCells = [...table.tHead.rows[0].querySelectorAll('[data-column-key]')]
.filter((cell) => selected.has(cell.dataset.columnKey));
const header = headerCells.map((cell) => csvEscape((cell.querySelector('[data-directory-header-label]') || cell).textContent.trim())).join(',');
const lines = [];
const lastPage = Number(section.dataset.directoryLastPage || 1);
button.disabled = true;
const originalLabel = button.textContent;
try {
for (let page = 1; page <= lastPage; page += 1) {
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 parsed = new DOMParser().parseFromString(await response.text(), 'text/html');
parsed.querySelectorAll('.directory-table tbody tr:not(.is-empty)').forEach((row) => {
const values = headerCells.map((headerCell) => {
const cell = row.querySelector(`[data-column-key="${CSS.escape(headerCell.dataset.columnKey)}"]`);
return csvEscape((cell?.querySelector('[data-password-status]') || cell)?.textContent?.trim() || '');
});
lines.push(values.join(','));
});
}
const blob = new Blob([`\ufeff${[header, ...lines].join('\n')}`], { 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.click();
URL.revokeObjectURL(link.href);
picker.open = false;
} catch (error) {
window.alert(error.message || '导出失败,请稍后重试');
} finally {
button.disabled = false;
button.textContent = originalLabel;
}
});
});
document.querySelectorAll('[data-directory-tools]').forEach((tools) => {
const section = tools.closest('[data-directory-page]');
const table = section?.querySelector('.directory-table');

View File

@ -44,6 +44,10 @@
'title' => '主体维度经营报表', 'description' => '按公司主体汇总店铺经营表现',
'columns' => ['市', '主体', '公司类型', 'ERP系统店铺名称', '店铺所属利润中心', '店铺负责人', '部门负责人', '开店时间', '状态', ...$recentCompletedMonthSalesColumns, '连续12个月店铺累计销量', '连续12个月主体累计销量', '能否变更主体', '规划', '新主体(变更流程同步新主体)'],
],
'company-cost-arrangement' => [
'title' => '公司成本安排', 'description' => '按公司主体查看成本安排',
'columns' => ['公司', '公司类型', '发票类型', '当月收入预测', '往期成本缺口', '供应商成本安排', '快递成本安排', '当月税金预测', '人工成本预测', '其他费用预测'],
],
'trademark-progress' => [
'title' => '商标进度表', 'description' => '跟踪商标续展与变更业务',
'columns' => ['商标名称', '商标号', '业务', '进度', '是否完成', '完成时间', '办理人'],

View File

@ -149,6 +149,7 @@
]],
['title' => '经营报表', 'items' => [
['company-management', 'subject-operating-report', '主体维度经营报表', 'domain'],
['company-management', 'company-cost-arrangement', '公司成本安排', 'account_balance_wallet'],
['store-management', 'store-report', '店铺维度报表', 'storefront'],
['store-management', 'internal-flow-report', '内部流转报表', 'swap_horiz'],
['company-management', 'personnel-inventory', '人员盘点表', 'groups'],

View File

@ -294,11 +294,13 @@ class ManagementCrudTest extends FeatureTestCase
'shareholders' => null,
]);
$this->loginAs()
$response = $this->loginAs()
->get(route('business.pages.show', [
'section' => 'company-management',
'page' => 'basic-information',
]))
]));
$response
->assertOk()
->assertSeeText('公司基础信息')
->assertSeeText('认缴注册资本(万元)')
@ -307,6 +309,13 @@ class ManagementCrudTest extends FeatureTestCase
->assertSeeText('已配置')
->assertSeeText('测试上游公司')
->assertSeeText('既有只读上游')
->assertSee('aria-label="公司基础信息查询条件"', false)
->assertSeeText('相关人员')
->assertSeeText('勾选字段')
->assertSeeText('导出 Excel')
->assertSee('data-directory-query-export-field', false)
->assertDontSee('<div class="directory-table-tools" data-directory-tools>', false)
->assertDontSee('name="registered_at"', false)
->assertDontSeeText('导入')
->assertDontSeeText('新增');
}
@ -339,6 +348,11 @@ class ManagementCrudTest extends FeatureTestCase
->assertSeeText('店铺名称')
->assertSeeText('店铺主体')
->assertSeeText('业务部门')
->assertSee('data-column-key="opened_at"', false)
->assertSee('data-column-key="renewed_at"', false)
->assertDontSee('name="opened_at_from"', false)
->assertDontSee('name="opened_at_to"', false)
->assertDontSee('name="renewed_at"', false)
->assertSeeText('测试上游店铺')
->assertSeeText('测试关联公司')
->assertSeeText('既有只读上游')
@ -414,6 +428,12 @@ class ManagementCrudTest extends FeatureTestCase
->assertOk()
->assertSeeText('商标名称')
->assertSeeText('注册时间')
->assertSee('data-column-key="registration_date"', false)
->assertSee('data-column-key="expiry_date"', false)
->assertDontSee('name="registration_date_from"', false)
->assertDontSee('name="registration_date_to"', false)
->assertDontSee('name="expiry_date_from"', false)
->assertDontSee('name="expiry_date_to"', false)
->assertSeeText('测试上游商标')
->assertSeeText('既有只读上游')
->assertDontSeeText('导入')
@ -426,7 +446,7 @@ class ManagementCrudTest extends FeatureTestCase
$sections = collect($navigation)->keyBy('slug');
$this->assertSame(
['subject-operating-report', 'basic-information', 'personnel-inventory', 'company-progress'],
['subject-operating-report', 'company-cost-arrangement', 'basic-information', 'personnel-inventory', 'company-progress'],
array_column($sections['company-management']['pages'], 'slug')
);
$this->assertSame(
@ -512,6 +532,7 @@ class ManagementCrudTest extends FeatureTestCase
$expectedHeaders = [
['section' => 'company-management', 'page' => 'subject-operating-report', 'headers' => ['市', '主体', '最近12个月累计收入', '1月收入', '12月收入']],
['section' => 'company-management', 'page' => 'company-cost-arrangement', 'headers' => ['公司', '公司类型', '发票类型', '当月收入预测', '往期成本缺口', '供应商成本安排', '快递成本安排', '当月税金预测', '人工成本预测', '其他费用预测']],
['section' => 'company-management', 'page' => 'personnel-inventory', 'headers' => ['人员名称', '税务局密码', '可担任公司数量']],
['section' => 'company-management', 'page' => 'company-progress', 'headers' => ['公司进度表字段尚未定义']],
['section' => 'trademark-management', 'page' => 'trademark-progress', 'headers' => ['商标号', '是否完成', '办理人']],

View File

@ -58,6 +58,12 @@ class BusinessInformationMirrorRepositoryTest extends TestCase
]);
$this->markReady();
$connection->table('ops_fys_company_info')->where('Id', 1)->update(['registeredCapitalVaildTime' => '2027-01-01']);
$connection->table('ops_fys_company_info')->where('Id', 2)->update([
'businessLicenseType' => '店铺-普通公司',
'taxCategoryAndType' => '小规模纳税人',
'legalRepresentative' => '张三',
'registeredAddress' => '上海市浦东新区测试路1号',
]);
$connection->table('ops_fys_shop_info')->where('Id', 8)->update(['sotreId' => 'STORE-8', 'renewalTime' => '2027-08-14', 'shopBankAccount' => 'fixture-account', 'trademarkNumber' => 'TM-5']);
$connection->table('ops_fys_trademark_info')->where('Id', 5)->update(['trademarkNumber' => 'TM-5', 'approvedCommodities' => '家具', 'transferenceUser' => '测试持有人', 'registrationAddress' => '测试地址', 'custodyDepartment' => '资料部']);
@ -69,6 +75,18 @@ class BusinessInformationMirrorRepositoryTest extends TestCase
'store_name' => '上游店铺名',
'store_id' => 'STORE-8',
]);
$filteredStore = $this->repository->paginate('store-information.read', 1, 20, [
'platform' => '天猫', 'store_status' => '0', 'opened_at_from' => '2026-08-14',
]);
$filteredTrademark = $this->repository->paginate('trademark-information.read', 1, 20, [
'trademark_status' => '1', 'registration_date_from' => '2020-01-01',
]);
$filteredCompany = $this->repository->paginate('company-information.read', 1, 20, ['company_name' => '新公司']);
$relatedCompany = $this->repository->paginate('company-information.read', 1, 20, ['related_people' => '张']);
$typedCompany = $this->repository->paginate('company-information.read', 1, 20, [
'taxpayer_type' => '小规模纳税人', 'company_status' => '正常', 'company_type' => '店铺-普通公司',
]);
$cityCompany = $this->repository->paginate('company-information.read', 1, 20, ['city' => '上海']);
$this->assertSame(3, $companies['total']);
$this->assertSame(1, $companies['data'][0]['id']);
@ -90,6 +108,12 @@ class BusinessInformationMirrorRepositoryTest extends TestCase
$this->assertSame('TM-5', $trademarks['data'][0]['trademark_number']);
$this->assertSame('家具', $trademarks['data'][0]['approved_goods']);
$this->assertSame('测试持有人', $trademarks['data'][0]['final_holder']);
$this->assertSame(1, $filteredStore['total']);
$this->assertSame(1, $filteredTrademark['total']);
$this->assertSame('新公司', $filteredCompany['data'][0]['company_name']);
$this->assertSame('新公司', $relatedCompany['data'][0]['company_name']);
$this->assertSame(1, $typedCompany['total']);
$this->assertSame(1, $cityCompany['total']);
}
public function test_dataset_is_not_ready_until_initial_full_sync_is_marked_complete(): void

View File

@ -64,7 +64,7 @@ class StoreAccountMirrorRepositoryTest extends TestCase
foreach (['MAIN' => '主账号', 'SUB' => '子账号', '/' => null] as $type => $expected) {
DB::connection('business_mirror')->table('store_accounts')->insert([
'id' => 'account-' . md5($type),
'storeName' => '测试店铺',
'storeName' => ['MAIN' => 'B店铺', 'SUB' => 'A店铺', '/' => 'C店铺'][$type],
'accountType' => $type,
'account' => 'test-account',
'passwordConfigured' => $type === 'MAIN' ? true : ($type === 'SUB' ? false : null),
@ -79,5 +79,8 @@ class StoreAccountMirrorRepositoryTest extends TestCase
$this->assertTrue($rows['account-' . md5('MAIN')]['password_configured']);
$this->assertFalse($rows['account-' . md5('SUB')]['password_configured']);
$this->assertNull($rows['account-' . md5('/')]['password_configured']);
$sorted = $this->repository->paginate(1, 50, ['_sort' => 'store_name', '_direction' => 'asc']);
$this->assertSame(['A店铺', 'B店铺', 'C店铺'], array_column($sorted['data'], 'store_name'));
}
}

View File

@ -96,7 +96,7 @@ class UpstreamBusinessInformationTest extends TestCase
};
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
$mirror->shouldReceive('ready')->once()->with($scope)->andReturnTrue();
$mirror->shouldReceive('paginate')->once()->with($scope, 2, 25)->andReturn([
$mirror->shouldReceive('paginate')->once()->with($scope, 2, 25, [])->andReturn([
'data' => [['id' => 10]],
'total' => 51,
]);