Merge remote-tracking branch 'origin/main' into feature/yueyang-process-details

This commit is contained in:
yueyang 2026-10-26 16:17:58 +08:00
commit 914fb0b4fe
21 changed files with 1863 additions and 498 deletions

View File

@ -0,0 +1,182 @@
<?php
namespace App\Business;
/**
* 经营报表页面顶部的小格子指标。
*
* 汇总卡片只描述当前表本身,不把其他目录的数据拼成跨表大看板。
*/
class BusinessSummaryCards
{
/**
* @param array<string,mixed> $summary
* @return list<array{label:string,value:mixed,hint:string,format:string,query:array<string,mixed>,tone:string}>
*/
public function forPage(string $key, array $summary = []): array
{
return match ($key) {
'store-management/store-report' => $summary === [] ? [] : $this->storeReportCards($summary),
'company-management/subject-operating-report' => $summary === [] ? [] : $this->subjectOperatingReportCards($summary),
'store-management/internal-flow-report' => $summary === [] ? [] : $this->internalFlowReportCards($summary),
'company-management/company-cost-arrangement' => $this->companyCostArrangementCards(),
'company-management/tax-forecast' => $this->taxForecastCards(),
'company-management/personnel-inventory' => $this->personnelInventoryCards(),
default => [],
};
}
/** @param array<string,mixed> $summary */
private function subjectOperatingReportCards(array $summary): array
{
$ready = (bool) ($summary['company_status_ready'] ?? false);
$previousLabel = (string) ($summary['previous_month_label'] ?? '上月');
$currentLabel = (string) ($summary['current_month_label'] ?? '当月');
$previousStores = (int) ($summary['previous_income_entities'] ?? 0);
$currentStores = (int) ($summary['current_income_entities'] ?? 0);
if (! $ready) {
return [
$this->card('正常存续主体', '—', '公司资料镜像未就绪,暂时无法剔除注销主体', [], 'text', 'warning'),
$this->card('上月收入', '—', '公司资料镜像未就绪', [], 'text', 'warning'),
$this->card('当月收入', '—', '公司资料镜像未就绪', [], 'text', 'warning'),
$this->card('小规模纳税人', '—', '公司资料镜像未就绪', [], 'text'),
$this->card('一般纳税人', '—', '公司资料镜像未就绪', [], 'text'),
];
}
return [
$this->card('正常存续主体', $summary['normal'] ?? 0, '只统计公司台账标签为保留的主体', ['company_status' => '正常'], 'integer', 'accent'),
$this->card($previousLabel . '收入', $summary['previous_income'] ?? 0, $previousStores . ' 个存续主体有收入', [
'company_status' => '正常',
'sort' => $summary['previous_month_key'] ?? null,
'direction' => 'desc',
], 'amount', 'success'),
$this->card($currentLabel . '收入', $summary['current_month_income'] ?? 0, $currentStores . ' 个存续主体有收入', [
'company_status' => '正常',
'sort' => $summary['current_month_key'] ?? null,
'direction' => 'desc',
], 'amount', 'warning'),
$this->card('小规模纳税人', $summary['small_taxpayer'] ?? 0, '正常存续主体中的小规模纳税人', [
'company_status' => '正常',
'tax_type' => '小规模纳税人',
]),
$this->card('一般纳税人', $summary['general_taxpayer'] ?? 0, '正常存续主体中的一般纳税人', [
'company_status' => '正常',
'tax_type' => '一般纳税人',
]),
];
}
/** @param array<string,mixed> $summary */
private function storeReportCards(array $summary): array
{
$previousLabel = (string) ($summary['previous_month_label'] ?? '上月');
$currentLabel = (string) ($summary['current_month_label'] ?? '当月');
$normalStores = (int) ($summary['normal_stores'] ?? 0);
$previousStores = (int) ($summary['previous_month_stores'] ?? 0);
$currentStores = (int) ($summary['current_month_stores'] ?? 0);
$previousShare = $normalStores > 0
? number_format($previousStores / $normalStores * 100, 1) . '%'
: '0.0%';
return [
$this->card('正常存续店铺', $normalStores, '只统计开店中店铺,不含关店和已注销', ['status' => '0'], 'integer', 'accent'),
$this->card($previousLabel . '销量', $summary['previous_month_sales'] ?? 0, $previousStores . ' 家存续店铺有销量', [
'status' => '0',
'sort' => $summary['previous_month_key'] ?? null,
'direction' => 'desc',
], 'amount', 'success'),
$this->card($currentLabel . '销量', $summary['current_month_sales'] ?? 0, $currentStores . ' 家存续店铺有销量', [
'status' => '0',
'sort' => $summary['current_month_key'] ?? null,
'direction' => 'desc',
], 'amount', 'warning'),
$this->card($previousLabel . '有销量店铺', $previousStores, '占正常存续店铺 ' . $previousShare, [
'status' => '0',
'sort' => $summary['previous_month_key'] ?? null,
'direction' => 'desc',
]),
$this->card($currentLabel . '有销量店铺', $currentStores, $currentStores === 0 ? '当前还未同步当月销量' : '正常存续店铺中当月已有销量', [
'status' => '0',
'sort' => $summary['current_month_key'] ?? null,
'direction' => 'desc',
]),
];
}
/** @param array<string,mixed> $summary */
private function internalFlowReportCards(array $summary): array
{
return [
$this->card('公司数', $summary['companies'] ?? 0, '当前筛选内内部流转公司', [], 'integer', 'accent'),
$this->card('预计销量合计', $summary['projected_sales'] ?? 0, '当前筛选内预计销量合计', [
'sort' => 'projected_sales',
'direction' => 'desc',
], 'amount'),
$this->card('已收票合计', $summary['receipt_total'] ?? 0, '供应商、关联公司与内部流转收票合计', [
'sort' => 'receipt_total',
'direction' => 'desc',
], 'amount', 'success'),
$this->card('剩余缺口合计', $summary['remaining_gap'] ?? 0, '金额越大越应优先处理', [
'sort' => 'remaining_gap',
'direction' => 'desc',
], 'amount', 'warning'),
$this->card('平均开票比例', $summary['average_invoicing_ratio'] ?? 0, '按有比例的公司简单平均', [], 'percent'),
];
}
private function companyCostArrangementCards(): array
{
return [
$this->card('数据状态', '待接入', '当前表尚无业务数据', [], 'text', 'warning'),
$this->card('完整表头', '11 列', '包含序号,收入、成本、税金、人工等字段全部保留', [], 'text'),
$this->card('核心结果', '预计结余 / 缺口', '当月收入预测减去成本与费用', [], 'text'),
$this->card('缺口提示', '负值标红', '成本或费用未覆盖收入时提醒', [], 'text', 'danger'),
$this->card('配平提示', '零或正结余标绿', '表示当月预算已覆盖', [], 'text', 'success'),
];
}
private function taxForecastCards(): array
{
return [
$this->card('数据状态', '待取数', '规则已定义,业务数据尚未接入', [], 'text', 'warning'),
$this->card('完整表头', '22 列', '包含序号、征管口径、收入、税额与备注', [], 'text'),
$this->card('计算税种', '4 类', '增值税、附加税、印花税、所得税', [], 'text'),
$this->card('必补口径', '2 项', '工商类型与征收方式', [], 'text', 'warning'),
$this->card('当前记录', '0 条', '数据接入后按完整字段展示', [], 'text'),
];
}
private function personnelInventoryCards(): array
{
return [
$this->card('数据状态', '待接入', '当前表尚无人员数据', [], 'text', 'warning'),
$this->card('完整表头', '17 列', '包含序号和全部人员、容量字段', [], 'text'),
$this->card('敏感字段', '4 项', '密码、身份证、实名手机和身份证地址只显示维护状态', [], 'text', 'danger'),
$this->card('容量规则', '3 档', '可担任公司数量按风险档位提示', [], 'text'),
$this->card('当前记录', '0 条', '接入后按正常在职人员汇总', [], 'text'),
];
}
/**
* @return array{label:string,value:mixed,hint:string,format:string,query:array<string,mixed>,tone:string}
*/
private function card(
string $label,
mixed $value,
string $hint,
array $query = [],
string $format = 'integer',
string $tone = 'neutral',
): array {
return [
'label' => $label,
'value' => $value,
'hint' => $hint,
'format' => $format,
'query' => ['page' => null, ...$query],
'tone' => $tone,
];
}
}

View File

@ -45,7 +45,7 @@ class UpstreamBusinessInformation
};
request()->attributes->set(
'business-page-summary-cards',
$this->businessSummaryCards($key, $payload['summary'] ?? []),
app(BusinessSummaryCards::class)->forPage($key, $payload['summary'] ?? []),
);
return new LengthAwarePaginator(
@ -60,97 +60,6 @@ class UpstreamBusinessInformation
);
}
private function businessSummaryCards(string $key, array $summary): array
{
if ($summary === []) {
return [];
}
return match ($key) {
'store-management/store-report' => [
$this->summaryCard('店铺总数', $summary['stores'] ?? 0, '当前筛选内唯一店铺,明细按期间拆分'),
$this->summaryCard('正常店铺', $summary['normal'] ?? 0, '店铺状态为正常(开店中)', ['status' => '0']),
$this->summaryCard('关店中', $summary['closing'] ?? 0, '店铺状态为关闭中', ['status' => '3']),
$this->summaryCard('已关闭', $summary['closed'] ?? 0, '店铺状态为已关闭', ['status' => '6']),
$this->summaryCard('期间销量', $summary['period_sales'] ?? 0, '收入 + 优惠 + 冲减 + 退款', [
'sort' => 'period_sales',
'direction' => 'desc',
], 'money'),
],
'company-management/subject-operating-report' => [
$this->summaryCard('主体总数', $summary['total'] ?? 0, '当前筛选内唯一主体'),
($summary['company_status_ready'] ?? false)
? $this->summaryCard('有效主体', $summary['normal'] ?? 0, '经营状态为正常', ['company_status' => '正常'])
: $this->summaryCard('有效主体', '—', '公司资料镜像未就绪', [], 'text'),
($summary['company_status_ready'] ?? false)
? $this->summaryCard('已注销主体', $summary['deregistered'] ?? 0, '最新公司资料状态为已注销', ['company_status' => '已注销'])
: $this->summaryCard('已注销主体', '—', '公司资料镜像未就绪', [], 'text'),
$this->summaryCard('最近12个月收入', $summary['rolling_income'] ?? 0, '最近 12 个完整月账单收入', [
'sort' => 'rolling_12m_income',
'direction' => 'desc',
], 'money'),
$this->summaryCard('本月收入', $summary['current_month_income'] ?? 0, (string) ($summary['current_month_label'] ?? '本月') . '账单收入', [
'sort' => 'income_' . ($summary['current_month_number'] ?? now()->format('m')),
'direction' => 'desc',
], 'money'),
],
'store-management/basic-information' => [
$this->summaryCard('店铺总数', $summary['total'] ?? 0, '当前筛选内店铺档案'),
$this->summaryCard('正常店铺', $summary['normal'] ?? 0, '店铺状态为正常', ['store_status' => '正常']),
$this->summaryCard('关闭中', $summary['closing'] ?? 0, '店铺状态为关闭中', ['store_status' => '关闭中']),
$this->summaryCard('已关闭', $summary['closed'] ?? 0, '店铺状态为已关闭', ['store_status' => '已关闭']),
$this->summaryCard('平台数', $summary['platforms'] ?? 0, '按店铺档案平台去重', [
'sort' => 'platform',
'direction' => 'asc',
]),
],
'store-management/store-accounts' => [
$this->summaryCard('账号总数', $summary['total'] ?? 0, '当前筛选内账号记录'),
$this->summaryCard('主账号', $summary['main_accounts'] ?? 0, '账号类型为 MAIN', ['account_type' => '主账号']),
$this->summaryCard('子账号', $summary['sub_accounts'] ?? 0, '账号类型为 SUB', ['account_type' => '子账号']),
$this->summaryCard('密码已设置', $summary['password_set'] ?? 0, '仅统计是否设置,不展示密码', ['password_configured' => '已设置']),
$this->summaryCard('密码未设置', $summary['password_missing'] ?? 0, '需店铺负责人补充', ['password_configured' => '未设置']),
],
'company-management/basic-information' => [
$this->summaryCard('公司总数', $summary['total'] ?? 0, '当前筛选内公司档案'),
$this->summaryCard('正常公司', $summary['normal'] ?? 0, '公司状态为正常', ['company_status' => '正常']),
$this->summaryCard('已注销公司', $summary['deregistered'] ?? 0, '公司状态为已注销', ['company_status' => '已注销']),
$this->summaryCard('一般纳税人', $summary['general_taxpayer'] ?? 0, '纳税人类型为一般纳税人', ['taxpayer_type' => '一般纳税人']),
$this->summaryCard('小规模纳税人', $summary['small_taxpayer'] ?? 0, '纳税人类型为小规模纳税人', ['taxpayer_type' => '小规模纳税人']),
],
'trademark-management/information' => [
$this->summaryCard('商标总数', $summary['total'] ?? 0, '当前筛选内商标档案'),
$this->summaryCard('有效商标', $summary['enabled'] ?? 0, '商标状态为有效', ['trademark_status' => '有效']),
$this->summaryCard('未启用', $summary['disabled'] ?? 0, '商标状态为未启用', ['trademark_status' => '未启用']),
$this->summaryCard('最终持有人', $summary['holders'] ?? 0, '按非空最终持有人去重', [
'sort' => 'final_holder',
'direction' => 'asc',
]),
$this->summaryCard('保管部门', $summary['departments'] ?? 0, '按非空保管部门去重', [
'sort' => 'custody_department',
'direction' => 'asc',
]),
],
default => [],
};
}
private function summaryCard(
string $label,
mixed $value,
string $hint,
array $query = [],
string $format = 'integer',
): array {
return [
'label' => $label,
'value' => $value,
'hint' => $hint,
'format' => $format,
'query' => ['page' => null, ...$query],
];
}
/**
* 财务经营风险管控 › 流程统计:店铺申请流程(开店 / 变更 / 关店)只读镜像。
*
@ -410,12 +319,6 @@ SQL);
$filters = StoreReportRepository::requestFilters();
$rows = $this->storeReportRows($filters);
// 所有店铺该月都没有业绩的月份列默认隐藏;「筛选字段」中仍保留勾选项,可手动选出来。
request()->attributes->set(
'store-report.default_hidden_columns',
$this->columnsWithoutData($rows, 'sales_', zeroCountsAsData: false),
);
$offset = ($page - 1) * $perPage;
return [
@ -428,9 +331,21 @@ SQL);
/** @param list<array<string,mixed>> $rows */
private function storeReportSummary(array $rows): array
{
$stores = [];
$periodSales = 0.0;
$normalStores = [];
$previousMonth = now()->startOfMonth()->subMonthNoOverflow();
$currentMonth = now()->startOfMonth();
$previousMonthKey = 'sales_' . $previousMonth->format('m');
$currentMonthKey = 'sales_' . $currentMonth->format('m');
$previousSales = 0.0;
$currentSales = 0.0;
$previousStores = [];
$currentStores = [];
foreach ($rows as $row) {
if (trim((string) ($row['status'] ?? '')) !== '开店中') {
continue;
}
$key = trim((string) ($row['erp_store_name'] ?? ''));
if ($key === '') {
$key = trim((string) ($row['store_id'] ?? $row['id'] ?? ''));
@ -438,18 +353,32 @@ SQL);
if ($key === '') {
continue;
}
$stores[$key] = (string) ($row['status'] ?? '');
$periodSales += (float) ($row['period_sales'] ?? 0);
$normalStores[$key] = true;
$previousValue = (float) ($row[$previousMonthKey] ?? 0);
$currentValue = (float) ($row[$currentMonthKey] ?? 0);
$previousSales += $previousValue;
$currentSales += $currentValue;
if ($previousValue > 0) {
$previousStores[$key] = true;
}
if ($currentValue > 0) {
$currentStores[$key] = true;
}
}
$statusCounts = array_count_values($stores);
return [
'stores' => count($stores),
'normal' => (int) ($statusCounts['开店中'] ?? 0),
'closing' => (int) ($statusCounts['关店中'] ?? 0),
'closed' => (int) ($statusCounts['关店'] ?? 0),
'period_sales' => round($periodSales, 2),
'normal_stores' => count($normalStores),
'previous_month_sales' => round($previousSales, 2),
'current_month_sales' => round($currentSales, 2),
'previous_month_stores' => count($previousStores),
'current_month_stores' => count($currentStores),
'previous_month_key' => $previousMonthKey,
'current_month_key' => $currentMonthKey,
'previous_month_label' => $previousMonth->isSameYear($currentMonth)
? '上月(' . $previousMonth->format('n月') . ')'
: '上月(' . $previousMonth->format('Y年n月') . ')',
'current_month_label' => '当月(' . $currentMonth->format('n月') . ')',
];
}
@ -601,21 +530,26 @@ SQL);
$row['city'] = $this->cityFromDetails($row['company_details']);
$row['company_status'] = $row['company_details'][0]['company_status'] ?? null;
$classification = $classifications[$row['entity']] ?? [];
$row['tax_type'] = $classification['tax_type']
$taxType = $classification['tax_type']
?? $row['company_details'][0]['taxpayer_category']
?? null;
$row['business_registration_type'] = $classification['business_registration_type']
$businessType = $classification['business_registration_type']
?? $row['company_details'][0]['business_registration_type']
?? null;
$classificationStatus = trim((string) ($classification['status'] ?? ''));
$retained = $classificationStatus !== ''
? $classificationStatus === '保留'
: $row['company_status'] === '正常';
$row['company_status'] = $retained ? '正常' : '已注销';
$row['tax_type'] = $retained
? ($this->normalizeTaxType($taxType) ?? '待维护')
: '/';
$row['business_registration_type'] = $retained
? (trim((string) $businessType) !== '' ? trim((string) $businessType) : '待维护')
: '/';
}
unset($row);
// 所有主体该月收入均为空时,该月份列在页面上默认隐藏;「筛选字段」中仍保留勾选项,可手动选出来。
request()->attributes->set(
'subject-operating-report.default_hidden_columns',
$this->columnsWithoutData($rows, 'income_', zeroCountsAsData: true),
);
$rows = array_values(array_filter($rows, fn (array $row): bool => $this->subjectMatchesFilters($row, $filters)));
$summary = $this->subjectSummary($rows, $companyStatusReady);
$this->sortSubjectRows($rows, $filters);
@ -630,9 +564,17 @@ SQL);
{
$normal = 0;
$deregistered = 0;
$rollingIncome = 0.0;
$currentMonthKey = 'income_' . now()->format('m');
$smallTaxpayers = 0;
$generalTaxpayers = 0;
$previousMonth = now()->startOfMonth()->subMonthNoOverflow();
$currentMonth = now()->startOfMonth();
$previousMonthKey = 'income_' . $previousMonth->format('m');
$currentMonthKey = 'income_' . $currentMonth->format('m');
$previousIncome = 0.0;
$currentMonthIncome = 0.0;
$previousIncomeEntities = 0;
$currentIncomeEntities = 0;
foreach ($rows as $row) {
$status = trim((string) ($row['company_status'] ?? ''));
if ($status === '正常') {
@ -640,11 +582,28 @@ SQL);
} elseif ($status === '已注销') {
$deregistered++;
}
if (($row['rolling_12m_income'] ?? null) !== null) {
$rollingIncome += (float) $row['rolling_12m_income'];
if ($status !== '正常') {
continue;
}
if (($row[$currentMonthKey] ?? null) !== null) {
$currentMonthIncome += (float) $row[$currentMonthKey];
$taxType = (string) ($row['tax_type'] ?? '');
if (str_contains($taxType, '小规模')) {
$smallTaxpayers++;
}
if (str_contains($taxType, '一般纳税人')) {
$generalTaxpayers++;
}
$previousValue = (float) ($row[$previousMonthKey] ?? 0);
$currentValue = (float) ($row[$currentMonthKey] ?? 0);
$previousIncome += $previousValue;
$currentMonthIncome += $currentValue;
if ($previousValue > 0) {
$previousIncomeEntities++;
}
if ($currentValue > 0) {
$currentIncomeEntities++;
}
}
@ -652,45 +611,34 @@ SQL);
'total' => count($rows),
'normal' => $normal,
'deregistered' => $deregistered,
'rolling_income' => round($rollingIncome, 2),
'small_taxpayer' => $smallTaxpayers,
'general_taxpayer' => $generalTaxpayers,
'previous_income' => round($previousIncome, 2),
'current_month_income' => round($currentMonthIncome, 2),
'current_month_number' => now()->format('m'),
'current_month_label' => now()->format('Y年n月'),
'previous_income_entities' => $previousIncomeEntities,
'current_income_entities' => $currentIncomeEntities,
'previous_month_key' => $previousMonthKey,
'current_month_key' => $currentMonthKey,
'previous_month_label' => $previousMonth->isSameYear($currentMonth)
? '上月(' . $previousMonth->format('n月') . ')'
: '上月(' . $previousMonth->format('Y年n月') . ')',
'current_month_label' => '当月(' . $currentMonth->format('n月') . ')',
'company_status_ready' => $companyStatusReady,
];
}
/**
* 没有数据的月份列:所有行该月都没有数值时返回该列名,页面据此默认隐藏。
*
* 收入(income_)以 null/空字符串表示无账单,金额 0 也算有账单;
* 销量(sales_)没有空值标记,数值 0 视为当月没有业绩。
*
* @param array<int,array<string,mixed>> $rows
* @return array<int,string>
*/
private function columnsWithoutData(array $rows, string $prefix, bool $zeroCountsAsData): array
private function normalizeTaxType(mixed $value): ?string
{
$columns = [];
for ($month = 1; $month <= 12; $month++) {
$key = sprintf('%s%02d', $prefix, $month);
foreach ($rows as $row) {
$value = $row[$key] ?? null;
if ($value === null || $value === '') {
continue;
}
if (! $zeroCountsAsData && (float) $value === 0.0) {
continue;
}
continue 2;
}
$columns[] = $key;
$taxType = trim((string) $value);
if ($taxType === '') {
return null;
}
return $columns;
return match ($taxType) {
'小规模' => '小规模纳税人',
'一般纳税人', '小规模纳税人', '个体工商户', '无税务登记' => $taxType,
default => $taxType,
};
}
/** @param array<string,mixed> $row @param array<string,string> $filters */

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Business;
use App\Business\BusinessTableImporter;
use App\Business\BusinessSummaryCards;
use App\Business\InventoryInboundDetailImporter;
use App\Business\InventoryWarehouseNameMappingWorkbookImporter;
use App\Business\InventoryWarehouseShortNames;
@ -1116,6 +1117,10 @@ class Pages extends Controller
return response()->json([
'total' => $payload['total'],
'rows' => $payload['rows'],
'previous_total' => $payload['previous_total'] ?? null,
'previous_from' => $payload['previous_from'] ?? null,
'previous_to' => $payload['previous_to'] ?? null,
'change_rate' => $payload['change_rate'] ?? null,
], 200, ['Cache-Control' => 'private, no-store']);
}
@ -3764,9 +3769,25 @@ class Pages extends Controller
$agreementCourierContractParties = $this->agreementCourierContractParties($page['slug'] ?? null, $agreementCourierNames);
$agreementSupplierOptions = $this->agreementSupplierOptions($page['slug'] ?? null);
$staticSummaryKey = (string) ($section['slug'] ?? '') . '/' . (string) ($page['slug'] ?? '');
if (in_array($staticSummaryKey, [
'company-management/company-cost-arrangement',
'company-management/tax-forecast',
'company-management/personnel-inventory',
], true)) {
request()->attributes->set(
'business-page-summary-cards',
app(BusinessSummaryCards::class)->forPage($staticSummaryKey),
);
}
// 内部流转报表读取共享业务镜像中由工作簿导入的数据集,不走上游只读查询。
if ($page && $content === null && $this->isInternalFlowReportPage($section, $page)) {
[$rows, $upstreamError] = $this->internalFlowReportRows();
[$rows, $upstreamError, $flowSummary] = $this->internalFlowReportRows();
request()->attributes->set(
'business-page-summary-cards',
app(BusinessSummaryCards::class)->forPage('store-management/internal-flow-report', $flowSummary),
);
}
if (($section['slug'] ?? null) === 'product-inventory' && ($page['slug'] ?? null) === 'inventory-report') {
@ -4216,7 +4237,7 @@ class Pages extends Controller
* 内部流转报表明细。数据来自共享业务镜像中按工作簿整份替换的导入结果,
* 尚未导入时保留空页面并说明原因,不把“未导入”当成业务数据为 0。
*
* @return array{0: LengthAwarePaginator, 1: ?string}
* @return array{0: LengthAwarePaginator, 1: ?string, 2: array<string,int|float>}
*/
private function internalFlowReportRows(): array
{
@ -4236,6 +4257,7 @@ class Pages extends Controller
return [
$paginate([], 0),
'内部流转数据尚未导入。导入《一般纳税人内部流转》工作簿后,本页会显示明细。',
[],
];
}
@ -4250,7 +4272,11 @@ class Pages extends Controller
(string) request('direction', 'asc'),
);
return [$paginate($payload['data'], $payload['total']), null];
return [
$paginate($payload['data'], $payload['total']),
null,
(array) ($payload['summary'] ?? []),
];
}
private function storeReceivingAccountRows(?string $tab): LengthAwarePaginator

View File

@ -17,9 +17,16 @@ $navigation = [
'fields' => [
['name' => 'city', 'label' => '市'],
['name' => 'entity', 'label' => '主体'],
['name' => 'company_type', 'label' => '公司类型'],
['name' => 'tax_type', 'label' => '税务类型'],
['name' => 'business_registration_type', 'label' => '工商类型'],
['name' => 'company_type', 'label' => '公司类型', 'kind' => 'badge'],
['name' => 'tax_type', 'label' => '税务类型', 'kind' => 'badge', 'tones' => [
'小规模纳税人' => 'info',
'一般纳税人' => 'accent',
'个体工商户' => 'success',
'无税务登记' => 'warning',
'待维护' => 'warning',
'/' => 'muted',
]],
['name' => 'business_registration_type', 'label' => '工商类型', 'kind' => 'badge'],
['name' => 'rolling_12m_income', 'label' => '最近12个月累计收入', 'kind' => 'number'],
...array_map(static fn (int $month): array => ['name' => sprintf('income_%02d', $month), 'label' => $month . '月收入', 'kind' => 'number'], range(1, 12)),
['name' => 'can_change_entity', 'label' => '能否变更主体'],
@ -38,8 +45,11 @@ $navigation = [
'empty' => '公司成本安排尚未同步数据。',
'fields' => [
['name' => 'company', 'label' => '公司'],
['name' => 'company_type', 'label' => '公司类型'],
['name' => 'invoice_type', 'label' => '发票类型'],
['name' => 'company_type', 'label' => '公司类型', 'kind' => 'badge'],
['name' => 'invoice_type', 'label' => '发票类型', 'kind' => 'badge', 'tones' => [
'专票' => 'accent',
'普票' => 'info',
]],
['name' => 'current_month_income_forecast', 'label' => '当月收入预测', 'kind' => 'number'],
['name' => 'historical_cost_gap', 'label' => '往期成本缺口', 'kind' => 'number'],
['name' => 'supplier_cost_arrangement', 'label' => '供应商成本安排', 'kind' => 'number'],
@ -61,9 +71,21 @@ $navigation = [
'empty' => '税金预测表尚未接入取数:工商类型、征收方式(查账征收/核定征收)确认并补齐数据来源后开始计算。',
'fields' => [
['name' => 'company_name', 'label' => '公司名称'],
['name' => 'taxpayer_type', 'label' => '公司税务类型'],
['name' => 'business_registration_type', 'label' => '工商类型'],
['name' => 'collection_method', 'label' => '征收方式'],
['name' => 'taxpayer_type', 'label' => '公司税务类型', 'kind' => 'badge', 'tones' => [
'小规模纳税人' => 'info',
'一般纳税人' => 'accent',
'个体工商户' => 'success',
'无税务登记' => 'warning',
'待维护' => 'warning',
'/' => 'muted',
]],
['name' => 'business_registration_type', 'label' => '工商类型', 'kind' => 'badge'],
['name' => 'collection_method', 'label' => '征收方式', 'kind' => 'badge', 'tones' => [
'查账征收' => 'info',
'核定征收' => 'accent',
'待维护' => 'warning',
'/' => 'muted',
]],
['name' => 'assessed_income_rate', 'label' => '核定应税所得率(%)', 'kind' => 'number'],
['name' => 'forecast_month', 'label' => '预测月份'],
['name' => 'revenue_incl_tax', 'label' => '当月含税收入预测', 'kind' => 'number'],
@ -95,9 +117,16 @@ $navigation = [
'fields' => [
['name' => 'unified_social_credit_code', 'label' => '统一社会信用代码'],
['name' => 'company_name', 'label' => '公司名称'],
['name' => 'taxpayer_type', 'label' => '税务类型', 'source' => 'taxpayer_category'],
['name' => 'business_registration_type', 'label' => '工商类型'],
['name' => 'company_status', 'label' => '公司状态'],
['name' => 'taxpayer_type', 'label' => '税务类型', 'source' => 'taxpayer_category', 'kind' => 'badge', 'tones' => [
'小规模纳税人' => 'info',
'一般纳税人' => 'accent',
'个体工商户' => 'success',
'无税务登记' => 'warning',
'待维护' => 'warning',
'/' => 'muted',
]],
['name' => 'business_registration_type', 'label' => '工商类型', 'kind' => 'badge'],
['name' => 'company_status', 'label' => '公司状态', 'kind' => 'badge'],
['name' => 'subscribed_capital', 'label' => '认缴注册资本(万元)', 'kind' => 'number'],
['name' => 'registered_at', 'label' => '注册时间', 'kind' => 'date', 'source' => 'establishment_date'],
['name' => 'legal_representative', 'label' => '法人'],
@ -123,8 +152,8 @@ $navigation = [
'fields' => [
['name' => 'person_name', 'label' => '人员名称'],
['name' => 'alias', 'label' => '花名'],
['name' => 'position', 'label' => '职位'],
['name' => 'tag', 'label' => '标签'],
['name' => 'position', 'label' => '职位', 'kind' => 'badge'],
['name' => 'tag', 'label' => '标签', 'kind' => 'badge'],
['name' => 'tax_account', 'label' => '税务局账号'],
['name' => 'tax_password', 'label' => '税务局密码', 'kind' => 'secret'],
['name' => 'department', 'label' => '部门'],
@ -133,7 +162,7 @@ $navigation = [
['name' => 'verified_mobile', 'label' => '实名手机号', 'kind' => 'secret'],
['name' => 'sim_card_keeper', 'label' => '电话卡保管人'],
['name' => 'id_address', 'label' => '身份证地址', 'kind' => 'secret'],
['name' => 'company_type', 'label' => '公司类型'],
['name' => 'company_type', 'label' => '公司类型', 'kind' => 'badge'],
['name' => 'total_company_count', 'label' => '合计担任公司数量', 'kind' => 'number'],
['name' => 'retained_company_count', 'label' => '保留担任公司数量', 'kind' => 'number'],
['name' => 'available_company_count', 'label' => '可担任公司数量', 'kind' => 'number'],
@ -169,7 +198,7 @@ $navigation = [
'fields' => [
['name' => 'trademark_name', 'label' => '商标名称'],
['name' => 'trademark_number', 'label' => '商标号'],
['name' => 'trademark_status', 'label' => '商标状态'],
['name' => 'trademark_status', 'label' => '商标状态', 'kind' => 'badge'],
['name' => 'registration_date', 'label' => '注册时间', 'kind' => 'date'],
['name' => 'expiry_date', 'label' => '有效期', 'kind' => 'date'],
['name' => 'international_classification', 'label' => '国际分类', 'source' => 'trademark_type'],
@ -265,10 +294,10 @@ $navigation = [
['name' => 'store_manager', 'label' => '店铺负责人'],
['name' => 'department_manager', 'label' => '部门负责人'],
['name' => 'opened_at', 'label' => '开店时间', 'kind' => 'date'],
['name' => 'status', 'label' => '状态'],
['name' => 'status', 'label' => '状态', 'kind' => 'badge'],
['name' => 'can_change', 'label' => '能否变更'],
['name' => 'entity', 'label' => '主体'],
['name' => 'company_type', 'label' => '公司类型'],
['name' => 'company_type', 'label' => '公司类型', 'kind' => 'badge'],
['name' => 'effective_at', 'label' => '生效时间', 'kind' => 'date'],
['name' => 'invalid_at', 'label' => '失效时间', 'kind' => 'date'],
['name' => 'period_sales', 'label' => '期间销量', 'kind' => 'number'],
@ -290,7 +319,11 @@ $navigation = [
'empty' => '内部流转数据尚未导入。导入工作簿后这里会显示明细。',
'fields' => [
['name' => 'company_name', 'label' => '抬头'],
['name' => 'tag', 'label' => '标签'],
['name' => 'tag', 'label' => '标签', 'kind' => 'badge', 'tones' => [
'1' => 'accent',
'2' => 'warning',
'3' => 'info',
]],
['name' => 'projected_sales', 'label' => '预计9月销量', 'kind' => 'number'],
['name' => 'invoiced_to_buyer', 'label' => '开票-给买家开票', 'kind' => 'number'],
['name' => 'internal_invoiced', 'label' => '开票-内部开票', 'kind' => 'number'],
@ -316,11 +349,11 @@ $navigation = [
'description' => '优先读取共享业务镜像中的店铺资料、店铺 ID 和续签时间;提现账户仅显示配置状态。',
'empty' => '尚未读取到店铺基础资料。请检查共享镜像或上游同步状态。',
'fields' => [
['name' => 'platform', 'label' => '平台'],
['name' => 'platform', 'label' => '平台', 'kind' => 'badge'],
['name' => 'store_id', 'label' => '店铺ID'],
['name' => 'store_name', 'label' => '店铺名称', 'source' => 'erp_store_name'],
['name' => 'store_entity', 'label' => '店铺主体', 'source' => 'company_name'],
['name' => 'store_status', 'label' => '店铺状态'],
['name' => 'store_status', 'label' => '店铺状态', 'kind' => 'badge'],
['name' => 'trademark', 'label' => '商标', 'source' => 'trademark_name'],
['name' => 'trademark_number', 'label' => '商标号'],
['name' => 'business_department', 'label' => '业务部门', 'source' => 'profit_center'],
@ -346,16 +379,32 @@ $navigation = [
['name' => 'operation_department_head', 'label' => '店铺运营部门负责人'],
['name' => 'operation_director', 'label' => '店铺运营总监'],
['name' => 'profit_center', 'label' => '店铺所属利润中心'],
['name' => 'account_type', 'label' => '账号类型'],
['name' => 'account_type', 'label' => '账号类型', 'kind' => 'badge', 'tones' => [
'主账号' => 'accent',
'子账号' => 'info',
]],
['name' => 'account', 'label' => '账号'],
['name' => 'password_configured', 'label' => '密码', 'kind' => 'secret_status'],
['name' => 'bound_phone', 'label' => '绑定手机号'],
['name' => 'verification_contact', 'label' => '手机号验证码对接人'],
['name' => 'account_group_name', 'label' => '账号分组名称'],
['name' => 'authorization_status', 'label' => '授权状态'],
['name' => 'store_status', 'label' => '店铺状态'],
['name' => 'flow_type', 'label' => '流程类型'],
['name' => 'approval_status', 'label' => '审批状态'],
['name' => 'authorization_status', 'label' => '授权状态', 'kind' => 'badge', 'tones' => [
'已授权' => 'success',
'已过期' => 'warning',
'未授权' => 'danger',
]],
['name' => 'store_status', 'label' => '店铺状态', 'kind' => 'badge'],
['name' => 'flow_type', 'label' => '流程类型', 'kind' => 'badge'],
['name' => 'approval_status', 'label' => '审批状态', 'kind' => 'badge', 'tones' => [
'完成' => 'success',
'已签署' => 'success',
'拒绝' => 'danger',
'撤回' => 'muted',
'流程中' => 'info',
'待审批' => 'warning',
'暂存' => 'warning',
'待签署' => 'warning',
]],
['name' => 'created_at', 'label' => '店铺创建时间', 'kind' => 'date'],
['name' => 'updated_at', 'label' => '店铺修改时间', 'kind' => 'date'],
],

View File

@ -175,7 +175,7 @@ SQL)
->all();
}
/** @return array<string,array{tax_type:?string,business_registration_type:?string}> */
/** @return array<string,array{status:?string,tax_type:?string,business_registration_type:?string}> */
public function companyClassificationsByNames(array $names): array
{
$names = array_values(array_unique(array_filter(array_map('trim', $names))));
@ -184,13 +184,15 @@ SQL)
}
return $this->connection->table('fin_companies')
->select(['name', 'tax_type', 'business_type'])
->select(['name', 'status', 'tax_type', 'business_type'])
->whereIn('name', $names)
->get()
->mapWithKeys(static function (object $row): array {
$businessType = trim((string) ($row->business_type ?? ''));
$status = trim((string) ($row->status ?? ''));
return [trim((string) $row->name) => [
'status' => $status !== '' ? $status : null,
'tax_type' => trim((string) ($row->tax_type ?? '')) ?: null,
'business_registration_type' => in_array($businessType, ['', '/'], true) ? null : $businessType,
]];

View File

@ -136,29 +136,32 @@ class InstallationDetailMirrorRepository
$items = $this->datasets->get(self::ITEM_DATASET);
$connection = $this->mirror->connection();
if (! $this->ready($dataset, $items, $connection->getName())) {
return ['ready' => false, 'total' => 0.0, 'rows' => []];
return [
'ready' => false,
'total' => 0.0,
'rows' => [],
'previous_total' => null,
'previous_from' => null,
'previous_to' => null,
'change_rate' => null,
];
}
$from = trim((string) ($filters['customer_review_from'] ?? ''));
$to = trim((string) ($filters['customer_review_to'] ?? ''));
if ($from === '' || $to === '') {
return ['ready' => true, 'total' => 0.0, 'rows' => []];
return [
'ready' => true,
'total' => 0.0,
'rows' => [],
'previous_total' => null,
'previous_from' => null,
'previous_to' => null,
'change_rate' => null,
];
}
$latest = $connection->table($dataset->targetTable())
->selectRaw('MAX(Id) AS id')
->groupBy('SourceGuid');
$main = $connection->table($dataset->targetTable() . ' AS v')
->joinSub($latest, 'latest_install', static fn ($join) => $join->on('latest_install.id', '=', 'v.Id'))
->select(['v.SourceGuid', 'v.shop_name', 'v.installCompanyName', 'v.costWay']);
$this->applyShipmentCutoff($main);
$query = $connection->table($items->targetTable() . ' AS i')
->joinSub($main, 'install_main', static fn ($join) =>
$join->on('install_main.SourceGuid', '=', 'i.guid'))
->whereNotNull('i.kfshTime')
->where('i.kfshTime', '>=', $from)
->where('i.kfshTime', '<', \Illuminate\Support\Carbon::parse($to)->addDay()->toDateString());
$query = $this->feeDetailsQuery($connection, $dataset, $items, $from, $to);
$platformSql = "CASE"
. " WHEN COALESCE(TRIM(install_main.shop_name), '') LIKE '%阿里巴巴%'"
@ -202,26 +205,119 @@ class InstallationDetailMirrorRepository
->values()
->all();
$total = round(array_sum(array_column($rows, 'amount')), 2);
$previousRange = $this->previousMonthRange($from, $to);
$previousTotal = null;
$changeRate = null;
if ($previousRange !== null) {
$previousQuery = $this->feeDetailsQuery(
$connection,
$dataset,
$items,
$previousRange['from'],
$previousRange['to'],
);
$previousTotal = round((float) ($previousQuery
->selectRaw('COALESCE(SUM(CAST(i.settleItemAmount AS DECIMAL(18,2))), 0) AS total')
->first()->total ?? 0), 2);
if ($previousTotal > 0) {
$changeRate = round((($total - $previousTotal) / $previousTotal) * 100, 1);
}
}
return [
'ready' => true,
'total' => round(array_sum(array_column($rows, 'amount')), 2),
'total' => $total,
'rows' => $rows,
'previous_total' => $previousTotal,
'previous_from' => $previousRange['from'] ?? null,
'previous_to' => $previousRange['to'] ?? null,
'change_rate' => $changeRate,
];
}
private function feeDetailsQuery(
$connection,
$dataset,
$items,
string $from,
string $to,
) {
$latest = $connection->table($dataset->targetTable())
->selectRaw('MAX(Id) AS id')
->groupBy('SourceGuid');
$main = $connection->table($dataset->targetTable() . ' AS v')
->joinSub($latest, 'latest_install', static fn ($join) => $join->on('latest_install.id', '=', 'v.Id'))
->select(['v.SourceGuid', 'v.shop_name', 'v.installCompanyName', 'v.costWay']);
$this->applyShipmentCutoff($main);
return $connection->table($items->targetTable() . ' AS i')
->joinSub($main, 'install_main', static fn ($join) =>
$join->on('install_main.SourceGuid', '=', 'i.guid'))
->whereNotNull('i.kfshTime')
->where('i.kfshTime', '>=', $from)
->where('i.kfshTime', '<', \Illuminate\Support\Carbon::parse($to)->addDay()->toDateString());
}
/** @return array{from:string,to:string}|null */
private function previousMonthRange(string $from, string $to): ?array
{
$start = \Illuminate\Support\Carbon::parse($from)->startOfDay();
$end = \Illuminate\Support\Carbon::parse($to)->startOfDay();
if ($end->lt($start)) {
return null;
}
if ($start->day === 1 && $end->isSameDay($end->copy()->endOfMonth())) {
$previousStart = $start->copy()->subMonthNoOverflow()->startOfMonth();
$previousEnd = $previousStart->copy()->endOfMonth();
} else {
$previousStart = $start->copy()->subMonthNoOverflow();
$previousEnd = $end->copy()->subMonthNoOverflow();
}
return [
'from' => $previousStart->toDateString(),
'to' => $previousEnd->toDateString(),
];
}
public static function requestFilters(): array
{
$rules = [];
$rules['time_scope'] = ['nullable', 'in:created,customer_review,finance_review'];
$rules['date_from'] = ['nullable', 'date_format:Y-m-d'];
$rules['date_to'] = ['nullable', 'date_format:Y-m-d'];
if (request()->filled('date_from')) $rules['date_to'][] = 'after_or_equal:date_from';
foreach (['created', 'customer_review', 'finance_review'] as $prefix) {
$rules[$prefix . '_from'] = ['nullable', 'date_format:Y-m-d'];
$rules[$prefix . '_to'] = ['nullable', 'date_format:Y-m-d'];
if (request()->filled($prefix . '_from')) $rules[$prefix . '_to'][] = 'after_or_equal:' . $prefix . '_from';
}
foreach (['push_status','order_no','customer_review','finance_review','quote_status','arranged_status','fee_payer','completed_status','shop_name','delete_status','installation_company','installation_no','order_type','product_id'] as $key) {
foreach (['push_status','order_no','customer_review','finance_review','quote_status','arranged_status','fee_payer','completed_status','shop_name','shop_code','delete_status','installation_company','installation_no','order_type','product_id'] as $key) {
$rules[$key] = ['nullable', 'string', 'max:191'];
}
foreach (['customer_review', 'finance_review', 'arranged_status', 'completed_status', 'delete_status'] as $key) $rules[$key][] = 'in:0,1';
return validator(request()->only(array_keys($rules)), $rules)->validate();
$filters = validator(request()->only(array_keys($rules)), $rules)->validate();
if (! array_key_exists('time_scope', $filters)
&& ! array_key_exists('date_from', $filters)
&& ! array_key_exists('date_to', $filters)) {
return $filters;
}
$scope = (string) ($filters['time_scope'] ?? 'customer_review');
$prefix = match ($scope) {
'created' => 'created',
'finance_review' => 'finance_review',
default => 'customer_review',
};
foreach (['created', 'customer_review', 'finance_review'] as $legacyPrefix) {
unset($filters[$legacyPrefix . '_from'], $filters[$legacyPrefix . '_to']);
}
$filters[$prefix . '_from'] = $filters['date_from'] ?? null;
$filters[$prefix . '_to'] = $filters['date_to'] ?? null;
return $filters;
}
private function applyFilters($query, array $filters, string $itemTable): void
@ -230,7 +326,7 @@ class InstallationDetailMirrorRepository
if (!empty($filters[$prefix.'_from'])) $query->where('v.'.$column, '>=', $filters[$prefix.'_from']);
if (!empty($filters[$prefix.'_to'])) $query->where('v.'.$column, '<', \Illuminate\Support\Carbon::parse($filters[$prefix.'_to'])->addDay()->toDateString());
}
foreach (['push_status'=>'apistatus','order_no'=>'OrderNum','quote_status'=>'BidStatus','fee_payer'=>'costtaker','shop_name'=>'shop_name','installation_company'=>'installCompanyName','installation_no'=>'ordernum_id','order_type'=>'TypeDto'] as $key=>$column) {
foreach (['push_status'=>'apistatus','order_no'=>'OrderNum','quote_status'=>'BidStatus','fee_payer'=>'costtaker','shop_name'=>'shop_name','shop_code'=>'shopcode','installation_company'=>'installCompanyName','installation_no'=>'ordernum_id','order_type'=>'TypeDto'] as $key=>$column) {
if (isset($filters[$key]) && $filters[$key] !== '') $query->where('v.'.$column, $filters[$key]);
}
foreach (['customer_review'=>'kfsh','finance_review'=>'cwsh','completed_status'=>'installComplete','delete_status'=>'Delete'] as $key=>$column) {

View File

@ -47,12 +47,22 @@ class InternalFlowReportRepository
/**
* @param array{company_name?:string,tag?:string} $filters
* @return array{data: list<array<string,mixed>>, total: int}
* @return array{data: list<array<string,mixed>>, total: int, summary: array<string,int|float>}
*/
public function paginate(int $page, int $perPage, array $filters = [], ?string $sort = null, string $direction = 'asc'): array
{
if (! $this->ready()) {
return ['data' => [], 'total' => 0];
return [
'data' => [],
'total' => 0,
'summary' => [
'companies' => 0,
'projected_sales' => 0.0,
'receipt_total' => 0.0,
'remaining_gap' => 0.0,
'average_invoicing_ratio' => 0.0,
],
];
}
$query = $this->connection->table(self::TABLE);
@ -68,6 +78,30 @@ class InternalFlowReportRepository
}
$total = (clone $query)->count();
$totals = (clone $query)->selectRaw(
'COUNT(*) AS companies, '
. 'COALESCE(SUM(projected_sales), 0) AS projected_sales, '
. 'COALESCE(SUM(receipt_total), 0) AS receipt_total, '
. 'COALESCE(SUM(remaining_gap), 0) AS remaining_gap',
)->first();
$ratios = (clone $query)
->whereNotNull('invoicing_ratio')
->pluck('invoicing_ratio')
->map(static function (mixed $value): ?float {
$text = str_replace([',', ' ', '%'], '', trim((string) $value));
if ($text === '' || $text === '/' || ! is_numeric($text)) {
return null;
}
$ratio = (float) $text;
if (str_contains((string) $value, '%') || $ratio > 1) {
$ratio /= 100;
}
return $ratio >= 0 ? $ratio : null;
})
->filter(static fn (?float $value): bool => $value !== null)
->values();
$column = in_array($sort, self::SORTABLE, true) ? $sort : null;
$direction = strtolower($direction) === 'desc' ? 'desc' : 'asc';
@ -84,6 +118,16 @@ class InternalFlowReportRepository
->map(static fn (object $row): array => (array) $row)
->all();
return ['data' => $rows, 'total' => $total];
return [
'data' => $rows,
'total' => $total,
'summary' => [
'companies' => (int) ($totals->companies ?? $total),
'projected_sales' => round((float) ($totals->projected_sales ?? 0), 2),
'receipt_total' => round((float) ($totals->receipt_total ?? 0), 2),
'remaining_gap' => round((float) ($totals->remaining_gap ?? 0), 2),
'average_invoicing_ratio' => round((float) ($ratios->avg() ?? 0) * 100, 2),
],
];
}
}

View File

@ -32,6 +32,49 @@
background: #fbf7ec;
}
.business-summary-card[data-tone="accent"] {
border-color: #cdb985;
background: #fbf7ec;
}
.business-summary-card[data-tone="success"] {
border-color: #b9c9ad;
background: #f3f8ef;
}
.business-summary-card[data-tone="warning"] {
border-color: #ddc79e;
background: #fff8ea;
}
.business-summary-card[data-tone="danger"] {
border-color: #dfbbbb;
background: #fff4f4;
}
.business-summary-card[data-tone]::before {
position: absolute;
top: 0.7rem;
bottom: 0.7rem;
left: 0;
width: 3px;
border-radius: 0 999px 999px 0;
background: var(--vip-content-accent-dark, #856632);
content: "";
}
.business-summary-card[data-tone="success"]::before {
background: #53793e;
}
.business-summary-card[data-tone="warning"]::before {
background: #935f07;
}
.business-summary-card[data-tone="danger"]::before {
background: #b80000;
}
.business-summary-card:hover,
.business-summary-card:focus-visible,
.business-summary-card[aria-current="true"] {
@ -107,23 +150,28 @@
$format = $card['format'] ?? 'integer';
$formattedValue = match ($format) {
'money' => '¥' . number_format((float) ($card['value'] ?? 0), 2),
'amount' => number_format((float) ($card['value'] ?? 0), 2),
'percent' => number_format((float) ($card['value'] ?? 0), 2) . '%',
'text' => (string) ($card['value'] ?? '—'),
default => number_format((int) ($card['value'] ?? 0)),
};
$tone = (string) ($card['tone'] ?? 'neutral');
$isAmount = in_array($format, ['money', 'amount'], true);
$activeQuery = collect($query)
->filter(fn ($value, string $key): bool => $key !== 'page' && $value !== null && $value !== '');
$isActive = $activeQuery->isNotEmpty()
&& $activeQuery->every(fn ($value, string $key): bool => (string) request($key) === (string) $value);
@endphp
<a
class="business-summary-card{{ $format === 'money' ? ' business-summary-card--money' : '' }}"
class="business-summary-card{{ $isAmount ? ' business-summary-card--money' : '' }}"
href="{{ rtrim(request()->fullUrlWithQuery($query), '?') }}"
data-business-summary-card="{{ $card['label'] ?? '' }}"
data-tone="{{ $tone }}"
aria-label="{{ $card['label'] ?? '' }}:{{ $formattedValue }},查看明细"
@if ($isActive) aria-current="true" @endif
>
<span class="business-summary-card__label">{{ $card['label'] ?? '' }}</span>
<strong class="business-summary-card__value{{ $format === 'money' ? ' business-summary-card__value--money' : '' }}">{{ $formattedValue }}</strong>
<strong class="business-summary-card__value{{ $isAmount ? ' business-summary-card__value--money' : '' }}">{{ $formattedValue }}</strong>
<span class="business-summary-card__hint" title="{{ $card['hint'] ?? '' }}">{{ $card['hint'] ?? '' }}</span>
<span class="material-icons-outlined business-summary-card__open" aria-hidden="true">arrow_outward</span>
</a>

View File

@ -286,6 +286,7 @@
});
input.addEventListener('click', open);
input.addEventListener('input', syncClear);
input.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
event.preventDefault();

View File

@ -584,6 +584,61 @@
text-align: right;
}
.directory-value-badge {
display: inline-flex;
max-width: 12rem;
min-height: 1.45rem;
align-items: center;
justify-content: center;
padding: 0.15rem 0.48rem;
border: 1px solid #d8d2c7;
border-radius: 999px;
background: #f4f1eb;
color: #595959;
font-size: 0.6875rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
.directory-value-badge[data-tone="accent"] {
border-color: #dcc99d;
background: #f7f0de;
color: #6a5129;
}
.directory-value-badge[data-tone="success"] {
border-color: #bfd1b3;
background: #f1f7ed;
color: #53793e;
}
.directory-value-badge[data-tone="warning"] {
border-color: #e1ca9c;
background: #fff7e7;
color: #935f07;
}
.directory-value-badge[data-tone="danger"] {
border-color: #e5bcbc;
background: #fff1f1;
color: #b80000;
}
.directory-value-badge[data-tone="info"] {
border-color: #b7ceda;
background: #eef5f8;
color: #006395;
}
.directory-value-badge[data-tone="muted"] {
border-color: #ddd7ce;
background: #f3f1ed;
color: #68635c;
}
/* 快递运费:这几列是从来源表/快递应付带出来的金额与计算列,按业务要求表头和数值都靠左展示。 */
.directory-table-page .directory-table th.is-number[data-column-key="freight_amount"],
.directory-table-page .directory-table td.is-number[data-column-key="freight_amount"],
@ -1367,6 +1422,12 @@
>
@if ($directoryUsesFieldQuery)
<span class="sr-only" data-directory-status>{{ $directory['status'] ?? '尚未同步' }}</span>
<div class="directory-query-page-head">
<div>
<strong>{{ $page['title'] ?? $section['title'] }}</strong>
<p>{{ $directoryQueryPageDescriptions[$directoryPageKey] ?? '按当前字段查询、筛选并导出业务数据' }}</p>
</div>
</div>
@if (!empty($upstreamError))
<div class="directory-table-notice" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
@ -2733,7 +2794,7 @@
<span class="directory-field-picker__hint">{{ $directoryHasGroupedHeader ? '两层合并表头按固定列顺序渲染,仅可切换显示字段。' : '拖动字段调整顺序;固定序号列不可隐藏。' }}</span>
@foreach ($directoryFields as $field)
<label class="directory-field-picker__option" draggable="true" data-directory-field-option>
<input type="checkbox" value="{{ $field['name'] }}" data-directory-field-check @checked(!in_array($field['name'], ['sales_01', 'sales_02', 'sales_03', 'sales_04', 'sales_05', 'sales_06'], true))>
<input type="checkbox" value="{{ $field['name'] }}" data-directory-field-check @checked(in_array($field['name'], $visibleDirectoryColumns, true))>
<span>{{ $field['label'] }}</span>
</label>
@endforeach
@ -2865,6 +2926,20 @@
} elseif ($kind === 'boolean' && $value !== null && $value !== '') {
$display = in_array($value, [true, 1, '1', '是', '✔'], true) ? '是' : '否';
}
$badgeTone = null;
if ($kind === 'badge') {
$badgeText = trim((string) ($display ?? ''));
$badgeTone = (string) (($field['tones'] ?? [])[$badgeText] ?? 'neutral');
if ($badgeTone === 'neutral') {
$badgeTone = match (true) {
in_array($badgeText, ['正常', '开店中', '存续中', '有效', '已完成', '已通过', '是'], true) => 'success',
in_array($badgeText, ['已注销', '已关闭', '关店', '拒绝', '已拒绝'], true) => 'danger',
in_array($badgeText, ['待维护', '关店中', '关闭中', '未启用', '未设置', '待处理', '待审批', '暂存'], true) => 'warning',
in_array($badgeText, ['/'], true) => 'muted',
default => 'neutral',
};
}
}
@endphp
<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'] ?? '') === 'courier-freight' && in_array($field['name'], ['transaction_no', 'express_no', 'barcode', 'shop_name'], true) && filled($display)) title="{{ $display }}"@endif>
@if (($page['slug'] ?? '') === 'store-accounts' && $field['name'] === 'password_configured')
@ -2882,7 +2957,11 @@
</span>
@endif
@else
{{ filled($display) ? $display : ($isInventoryReportPage && $field['name'] === 'warehouse_short_name' ? '' : '—') }}
@if ($kind === 'badge' && filled($display))
<span class="directory-value-badge" data-tone="{{ $badgeTone }}">{{ $display }}</span>
@else
{{ filled($display) ? $display : ($isInventoryReportPage && $field['name'] === 'warehouse_short_name' ? '' : '—') }}
@endif
@endif
</td>
@endforeach

View File

@ -1,47 +1,21 @@
<style>
.installation-fee-board {
margin: 1rem 1.5rem;
padding: 1rem;
border: 1px solid #d8d2c7;
border-radius: 0.75rem;
background: #fcfbf8;
}
.installation-fee-board__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.installation-fee-board__head strong {
display: block;
color: #171717;
font-size: 0.8125rem;
}
.installation-fee-board__head span {
display: block;
margin-top: 0.2rem;
color: #595959;
font-size: 0.6875rem;
line-height: 1.45;
}
.installation-fee-card {
display: flex;
width: min(100%, 15rem);
min-height: 6.5rem;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 0.45rem;
padding: 0.8rem 0.9rem;
position: relative;
display: block;
width: min(100%, 52rem);
min-height: 9.25rem;
padding: 0.95rem 1.15rem 0.8rem 1.25rem;
border: 1px solid #d8d2c7;
border-radius: 0.5rem;
border-left: 3px solid #006395;
border-radius: 0.75rem;
background: #ffffff;
color: #171717;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
font: inherit;
text-align: left;
cursor: pointer;
}
@ -49,6 +23,7 @@
.installation-fee-card:hover,
.installation-fee-card:focus-visible {
border-color: #b9954f;
border-left-color: #006395;
background: #fffdf8;
outline: none;
}
@ -57,32 +32,125 @@
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2);
}
.installation-fee-card__label {
color: #595959;
.installation-fee-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.installation-fee-card__title {
flex: 0 0 auto;
color: #171717;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.35;
}
.installation-fee-status {
max-width: 31rem;
color: #595959;
font-size: 0.6875rem;
line-height: 1.45;
text-align: right;
}
.installation-fee-status.is-error {
color: #b80000;
}
.installation-fee-card__main {
display: flex;
align-items: baseline;
gap: 0.35rem;
min-width: 0;
margin: 0.35rem 0 0.58rem;
}
.installation-fee-card__value {
color: #171717;
font-size: 1.25rem;
font-size: 2.625rem;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.035em;
font-variant-numeric: tabular-nums;
}
.installation-fee-card__value small {
margin-left: 0.15rem;
.installation-fee-card__unit {
color: #595959;
font-size: 0.75rem;
font-size: 0.875rem;
font-weight: 500;
}
.installation-fee-card__action {
color: #856632;
font-size: 0.6875rem;
.installation-fee-card__trend {
margin-left: 0.4rem;
color: #595959;
font-size: 0.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.installation-fee-status {
.installation-fee-card__trend.is-up {
color: #935f07;
}
.installation-fee-card__trend.is-down {
color: #53793e;
}
.installation-fee-card__trend-note {
color: #595959;
font-size: 0.6875rem;
font-weight: 400;
}
.installation-fee-card__split {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
padding-top: 0.55rem;
border-top: 1px solid #e5e0d7;
color: #171717;
font-size: 0.75rem;
line-height: 1.45;
font-variant-numeric: tabular-nums;
}
.installation-fee-card__split strong {
font-weight: 600;
}
.installation-fee-card__split-percent {
margin-left: 0.1rem;
color: #595959;
font-weight: 400;
}
.installation-fee-card__split-sep {
color: #d8d2c7;
font-weight: 400;
}
@media (max-width: 720px) {
.installation-fee-card {
min-height: 0;
padding: 0.9rem 0.95rem 0.8rem 1rem;
}
.installation-fee-card__top {
flex-direction: column;
gap: 0.25rem;
}
.installation-fee-status {
max-width: none;
text-align: left;
}
.installation-fee-card__main {
flex-wrap: wrap;
}
.installation-fee-card__value {
font-size: 2.625rem;
}
}
.installation-fee-dialog {
@ -283,23 +351,29 @@
</style>
<section class="installation-fee-board" aria-label="安装费金额汇总">
<div class="installation-fee-board__head">
<div>
<strong>安装费金额</strong>
<span>按客服审核时间汇总安装明细的结算明细金额;收款渠道为空归为包安装,有值归为不包安装。</span>
</div>
<span class="installation-fee-status" data-installation-fee-status>请选择完整的客服审核起止日期</span>
</div>
<button
type="button"
class="installation-fee-card"
data-installation-fee-open
data-url="{{ route('business.installation-details.summary') }}"
aria-label="查看安装费金额明细"
title="查看安装费金额明细"
>
<span class="installation-fee-card__label">安装费金额</span>
<strong class="installation-fee-card__value"><span data-installation-fee-total>0.00</span><small>万</small></strong>
<span class="installation-fee-card__action">查看明细 →</span>
<span class="installation-fee-card__top">
<strong class="installation-fee-card__title">安装费金额</strong>
<span class="installation-fee-status" data-installation-fee-status aria-live="polite">请选择完整的客服审核起止日期</span>
</span>
<span class="installation-fee-card__main">
<strong class="installation-fee-card__value" data-installation-fee-total>0.00</strong>
<span class="installation-fee-card__unit">万元</span>
<span class="installation-fee-card__trend" data-installation-fee-trend>—</span>
<span class="installation-fee-card__trend-note">较上月</span>
</span>
<span class="installation-fee-card__split">
<span>包安装 <strong data-installation-fee-package>0.00</strong><span class="installation-fee-card__split-percent" data-installation-fee-package-percent>(0%)</span></span>
<span class="installation-fee-card__split-sep" aria-hidden="true">|</span>
<span>不包安装 <strong data-installation-fee-non-package>0.00</strong><span class="installation-fee-card__split-percent" data-installation-fee-non-package-percent>(0%)</span></span>
</span>
</button>
</section>
@ -381,10 +455,15 @@
var dialog = document.getElementById('installation-fee-dialog');
if (!form || !card || !dialog) { return; }
var fromInput = form.elements.namedItem('customer_review_from');
var toInput = form.elements.namedItem('customer_review_to');
var fromInput = form.elements.namedItem('date_from');
var toInput = form.elements.namedItem('date_to');
var cardTotal = card.querySelector('[data-installation-fee-total]');
var status = document.querySelector('[data-installation-fee-status]');
var status = card.querySelector('[data-installation-fee-status]');
var trend = card.querySelector('[data-installation-fee-trend]');
var packageAmount = card.querySelector('[data-installation-fee-package]');
var packagePercent = card.querySelector('[data-installation-fee-package-percent]');
var nonPackageAmount = card.querySelector('[data-installation-fee-non-package]');
var nonPackagePercent = card.querySelector('[data-installation-fee-non-package-percent]');
var subtitle = dialog.querySelector('[data-installation-fee-subtitle]');
var currentTotal = dialog.querySelector('[data-installation-fee-current-total]');
var platformFilter = dialog.querySelector('[data-installation-fee-platform]');
@ -414,6 +493,78 @@
});
}
function amount(value) {
return Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
function captionRange(value) {
var matchFrom = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.from);
var matchTo = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.to);
if (!matchFrom || !matchTo) {
return value.from + ' 至 ' + value.to;
}
var end = matchFrom[1] === matchTo[1]
? matchTo[2] + '-' + matchTo[3]
: value.to;
return value.from + ' 至 ' + end;
}
function setTrend(changeRate) {
trend.className = 'installation-fee-card__trend';
if (changeRate === null || changeRate === undefined || !Number.isFinite(Number(changeRate))) {
trend.textContent = '—';
return;
}
var rate = Number(changeRate);
if (rate > 0) {
trend.textContent = '↑' + Math.abs(rate).toLocaleString('zh-CN', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + '%';
trend.classList.add('is-up');
} else if (rate < 0) {
trend.textContent = '↓' + Math.abs(rate).toLocaleString('zh-CN', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + '%';
trend.classList.add('is-down');
} else {
trend.textContent = '持平';
}
}
function setCardValue(total, rows, value, data) {
var packageTotal = 0;
var nonPackageTotal = 0;
rows.forEach(function (row) {
if (row.payment_channel === '包安装') {
packageTotal += Number(row.amount || 0);
} else {
nonPackageTotal += Number(row.amount || 0);
}
});
var packageRate = total > 0 ? Math.round((packageTotal / total) * 100) : 0;
var nonPackageRate = total > 0 ? 100 - packageRate : 0;
cardTotal.textContent = wan(total);
packageAmount.textContent = amount(packageTotal);
nonPackageAmount.textContent = amount(nonPackageTotal);
packagePercent.textContent = '(' + packageRate + '%)';
nonPackagePercent.textContent = '(' + nonPackageRate + '%)';
setTrend(data.change_rate);
status.classList.remove('is-error');
status.textContent = '按客服审核时间 · ' + captionRange(value) + ' · ' + rows.length + ' 单';
}
function resetCard(message) {
cardTotal.textContent = '0.00';
packageAmount.textContent = '0.00';
nonPackageAmount.textContent = '0.00';
packagePercent.textContent = '(0%)';
nonPackagePercent.textContent = '(0%)';
setTrend(null);
status.textContent = message;
status.classList.toggle('is-error', message.indexOf('失败') >= 0 || message.indexOf('不能') >= 0);
}
function range() {
return {
from: String(fromInput?.value || ''),
@ -542,14 +693,12 @@
function refresh(force) {
var value = range();
if (!value.from || !value.to) {
cardTotal.textContent = '0.00';
status.textContent = '请选择完整的客服审核起止日期';
resetCard('请选择完整的客服审核起止日期');
resetDialog('请选择完整的客服审核起止日期');
return Promise.resolve(false);
}
if (value.from > value.to) {
cardTotal.textContent = '0.00';
status.textContent = '客服审核结束日期不能早于开始日期';
resetCard('客服审核结束日期不能早于开始日期');
resetDialog('客服审核结束日期不能早于开始日期');
return Promise.resolve(false);
}
@ -561,7 +710,8 @@
if (controller) { controller.abort(); }
controller = new AbortController();
dialog.setAttribute('aria-busy', 'true');
status.textContent = '正在汇总安装费…';
status.classList.remove('is-error');
status.textContent = '按客服审核时间 · 正在汇总…';
cardTotal.textContent = '…';
var url = new URL(card.dataset.url, window.location.origin);
url.searchParams.set('customer_review_from', value.from);
@ -581,8 +731,7 @@
}).then(function (data) {
allRows = Array.isArray(data.rows) ? data.rows : [];
loadedSignature = currentSignature;
cardTotal.textContent = wan(data.total);
status.textContent = value.from + ' 至 ' + value.to + ' · ' + allRows.length + ' 条汇总';
setCardValue(Number(data.total || 0), allRows, value, data);
subtitle.textContent = value.from + ' 至 ' + value.to + ' · 按客服审核时间汇总';
fillFilter(platformFilter, '全部平台', distinct('platform'));
fillFilter(storeFilter, '全部店铺', distinct('store_name'));
@ -593,8 +742,7 @@
return true;
}).catch(function (error) {
if (error && error.name === 'AbortError') { return false; }
cardTotal.textContent = '0.00';
status.textContent = error.message || '安装费汇总读取失败';
resetCard(error.message || '安装费汇总读取失败');
resetDialog(error.message || '安装费汇总读取失败,请稍后重试。');
dialog.removeAttribute('aria-busy');
return false;

View File

@ -0,0 +1,320 @@
@php
$installationTimeScopes = [
'created' => '创建时间',
'customer_review' => '客服审核时间',
'finance_review' => '财务审核时间',
];
$installationTimePrefixes = [
'created' => 'created',
'customer_review' => 'customer_review',
'finance_review' => 'finance_review',
];
$usesUnifiedTime = request()->hasAny(['time_scope', 'date_from', 'date_to']);
$installationTimeScope = (string) request('time_scope', '');
if (! array_key_exists($installationTimeScope, $installationTimeScopes)) {
$installationTimeScope = request()->filled('created_from') || request()->filled('created_to')
? 'created'
: (request()->filled('finance_review_from') || request()->filled('finance_review_to')
? 'finance_review'
: 'customer_review');
}
$installationTimePrefix = $installationTimePrefixes[$installationTimeScope];
$installationDateFrom = $usesUnifiedTime
? (string) request('date_from', '')
: (string) request($installationTimePrefix . '_from', '');
$installationDateTo = $usesUnifiedTime
? (string) request('date_to', '')
: (string) request($installationTimePrefix . '_to', '');
$installationStore = (string) request('shop_name', '');
$installationCompleted = (string) request('completed_status', '');
$installationMoreFields = [
'shop_code' => ['label' => '店铺 ID', 'placeholder' => '按店铺编码精确查询'],
'order_no' => ['label' => '订单编号', 'placeholder' => '按原值精确查询'],
'installation_no' => ['label' => '安装单号', 'placeholder' => '按原值精确查询'],
'installation_company' => ['label' => '安装公司', 'placeholder' => '按原值精确查询'],
'push_status' => ['label' => '推送状态', 'placeholder' => '按原值精确查询'],
'quote_status' => ['label' => '报价状态', 'placeholder' => '按原值精确查询'],
'fee_payer' => ['label' => '安装费承担方', 'placeholder' => '按原值精确查询'],
'order_type' => ['label' => '订单类型', 'placeholder' => '按原值精确查询'],
'product_id' => ['label' => '商品 ID', 'placeholder' => '按原值精确查询'],
'customer_review' => ['label' => '客服审核', 'options' => ['0' => '未审核', '1' => '已审核']],
'finance_review' => ['label' => '财务审核', 'options' => ['0' => '未审核', '1' => '已审核']],
'arranged_status' => ['label' => '是否安排', 'options' => ['0' => '未安排', '1' => '已安排']],
'delete_status' => ['label' => '删除状态', 'options' => ['0' => '未删除', '1' => '已删除']],
];
$installationActiveMore = collect($installationMoreFields)
->filter(static fn (array $field, string $name): bool => request()->filled($name))
->map(static fn (array $field, string $name): array => $field + [
'name' => $name,
'value' => (string) request($name, ''),
])
->all();
$installationMoreCount = count($installationActiveMore);
$installationQuery = request()->query();
$installationRemoveUrl = static function (array $keys) use ($installationQuery): string {
unset($installationQuery['page']);
foreach ($keys as $key) {
unset($installationQuery[$key]);
}
$query = \Illuminate\Support\Arr::query($installationQuery);
return request()->url() . ($query !== '' ? '?' . $query : '');
};
$installationDateChip = trim($installationDateFrom . ' 至 ' . $installationDateTo, ' 至');
@endphp
<form method="GET" id="installationFilterForm" action="{{ request()->url() }}" class="installation-filter-bar installation-filter-compact" aria-label="安装明细筛选查询栏" autocomplete="off">
<div class="installation-filter-primary">
<div class="installation-filter-time">
<label class="installation-filter-control">
<span>时间口径</span>
<select id="installationTimeScope" name="time_scope">
@foreach ($installationTimeScopes as $value => $label)
<option value="{{ $value }}" @selected($installationTimeScope === $value)>{{ $label }}</option>
@endforeach
</select>
</label>
<div class="installation-filter-control installation-filter-range">
<span>日期区间</span>
<div class="upstream-data-date-range">
<span class="upstream-data-date" data-date-picker>
<input id="date_from" type="text" name="date_from" value="{{ $installationDateFrom }}" readonly autocomplete="off" placeholder="开始日期" aria-label="开始日期" aria-describedby="installationDateRangeError" data-date-input>
<button type="button" class="upstream-data-date__clear" data-date-clear hidden aria-label="清除开始日期">
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
<button type="button" class="upstream-data-date__pick" data-date-toggle aria-label="选择开始日期">
<span class="material-icons-outlined" aria-hidden="true">calendar_today</span>
</button>
<div class="upstream-data-calendar" role="dialog" aria-label="开始日期日历" data-date-calendar hidden></div>
</span>
<b class="upstream-data-date-range__sep">至</b>
<span class="upstream-data-date" data-date-picker>
<input id="date_to" type="text" name="date_to" value="{{ $installationDateTo }}" readonly autocomplete="off" placeholder="结束日期" aria-label="结束日期" aria-describedby="installationDateRangeError" data-date-input>
<button type="button" class="upstream-data-date__clear" data-date-clear hidden aria-label="清除结束日期">
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
<button type="button" class="upstream-data-date__pick" data-date-toggle aria-label="选择结束日期">
<span class="material-icons-outlined" aria-hidden="true">calendar_today</span>
</button>
<div class="upstream-data-calendar" role="dialog" aria-label="结束日期日历" data-date-calendar hidden></div>
</span>
</div>
</div>
</div>
<label class="installation-filter-control">
<span>店铺</span>
<input id="installationShopName" type="search" name="shop_name" value="{{ $installationStore }}" placeholder="输入完整店铺名称">
</label>
<label class="installation-filter-control">
<span>安装状态</span>
<select name="completed_status">
<option value="">全部</option>
<option value="0" @selected($installationCompleted === '0')>未完工</option>
<option value="1" @selected($installationCompleted === '1')>已完工</option>
</select>
</label>
<div class="installation-filter-actions">
<button type="submit" class="biz-btn biz-btn-primary">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<button type="button" class="biz-btn biz-btn-ghost installation-more-toggle" data-installation-more-toggle aria-expanded="{{ $installationMoreCount > 0 ? 'true' : 'false' }}" aria-controls="installationMoreFilters">
<span class="material-icons-outlined" aria-hidden="true">tune</span>
<span>更多筛选</span>
@if ($installationMoreCount > 0)
<b data-installation-more-count>{{ $installationMoreCount }}</b>
@endif
</button>
<a href="{{ request()->url() }}" class="biz-btn biz-btn-ghost">清除</a>
<a href="{{ route('business.installation-details.export', request()->query()) }}" class="biz-btn biz-btn-ghost">
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span>导出</span>
</a>
</div>
</div>
<div class="installation-filter-context">
<div class="installation-quick-ranges" role="group" aria-label="快捷日期区间">
<span>快捷区间</span>
@foreach (['today' => '今天', 'this_week' => '本周', 'this_month' => '本月', 'last_month' => '上月', 'last_30_days' => '近30天'] as $value => $label)
<button type="button" class="installation-quick-range" data-installation-quick-range="{{ $value }}">{{ $label }}</button>
@endforeach
</div>
<div class="installation-active-filters" aria-label="当前生效的筛选条件">
@if ($installationDateFrom !== '' || $installationDateTo !== '')
<a class="installation-filter-chip" href="{{ $installationRemoveUrl(['time_scope', 'date_from', 'date_to', 'created_from', 'created_to', 'customer_review_from', 'customer_review_to', 'finance_review_from', 'finance_review_to']) }}" aria-label="删除「{{ $installationTimeScopes[$installationTimeScope] }}」条件">
<span>{{ $installationTimeScopes[$installationTimeScope] }}</span>
<strong>{{ $installationDateChip }}</strong>
<span class="material-icons-outlined" aria-hidden="true">close</span>
</a>
@endif
@if ($installationStore !== '')
<a class="installation-filter-chip" href="{{ $installationRemoveUrl(['shop_name']) }}" aria-label="删除「店铺」条件">
<span>店铺</span><strong>{{ $installationStore }}</strong>
<span class="material-icons-outlined" aria-hidden="true">close</span>
</a>
@endif
@if ($installationCompleted !== '')
<a class="installation-filter-chip" href="{{ $installationRemoveUrl(['completed_status']) }}" aria-label="删除「安装状态」条件">
<span>安装状态</span><strong>{{ $installationCompleted === '1' ? '已完工' : '未完工' }}</strong>
<span class="material-icons-outlined" aria-hidden="true">close</span>
</a>
@endif
@foreach ($installationActiveMore as $field)
@php
$displayValue = isset($field['options'])
? ($field['options'][$field['value']] ?? $field['value'])
: $field['value'];
@endphp
<a class="installation-filter-chip" href="{{ $installationRemoveUrl([$field['name']]) }}" aria-label="删除「{{ $field['label'] }}」条件">
<span>{{ $field['label'] }}</span><strong>{{ $displayValue }}</strong>
<span class="material-icons-outlined" aria-hidden="true">close</span>
</a>
@endforeach
</div>
</div>
<p id="installationDateRangeError" class="installation-date-range-error" role="alert" hidden>结束日期不能早于开始日期。</p>
<div id="installationMoreFilters" class="installation-more-panel" @if ($installationMoreCount === 0) hidden @endif>
<div class="installation-more-head">
<strong>更多筛选</strong>
<span>低频条件仅在展开后显示,已生效条件会保留在下方标签中。</span>
</div>
<div class="installation-more-grid">
@foreach ($installationMoreFields as $name => $field)
<label class="installation-filter-control">
<span>{{ $field['label'] }}</span>
@if (isset($field['options']))
<select name="{{ $name }}">
<option value="">全部</option>
@foreach ($field['options'] as $value => $label)
<option value="{{ $value }}" @selected((string) request($name) === (string) $value)>{{ $label }}</option>
@endforeach
</select>
@else
<input id="installation-more-{{ $name }}" type="text" name="{{ $name }}" value="{{ request($name) }}" placeholder="{{ $field['placeholder'] }}">
@endif
</label>
@endforeach
</div>
</div>
</form>
<script>
(function () {
var form = document.getElementById('installationFilterForm');
if (!form) { return; }
if (window.CaiWuDatePicker) {
window.CaiWuDatePicker.setup(form);
}
var fromInput = form.elements.namedItem('date_from');
var toInput = form.elements.namedItem('date_to');
var error = document.getElementById('installationDateRangeError');
var moreToggle = form.querySelector('[data-installation-more-toggle]');
var morePanel = document.getElementById('installationMoreFilters');
var quickButtons = Array.from(form.querySelectorAll('[data-installation-quick-range]'));
function pad2(number) {
return (number < 10 ? '0' : '') + number;
}
function isoDate(date) {
return date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate());
}
function startOfWeek(date) {
var start = new Date(date.getFullYear(), date.getMonth(), date.getDate());
var offset = (start.getDay() + 6) % 7;
start.setDate(start.getDate() - offset);
return start;
}
function quickRange(name) {
var today = new Date();
today = new Date(today.getFullYear(), today.getMonth(), today.getDate());
var from;
var to = today;
if (name === 'this_week') {
from = startOfWeek(today);
} else if (name === 'this_month') {
from = new Date(today.getFullYear(), today.getMonth(), 1);
} else if (name === 'last_month') {
from = new Date(today.getFullYear(), today.getMonth() - 1, 1);
to = new Date(today.getFullYear(), today.getMonth(), 0);
} else if (name === 'last_30_days') {
from = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29);
} else {
from = today;
}
return { from: isoDate(from), to: isoDate(to) };
}
function syncQuickState() {
quickButtons.forEach(function (button) {
var value = quickRange(button.getAttribute('data-installation-quick-range'));
var selected = fromInput.value === value.from && toInput.value === value.to;
button.classList.toggle('is-active', selected);
button.setAttribute('aria-pressed', selected ? 'true' : 'false');
});
}
function setRange(from, to) {
fromInput.value = from;
toInput.value = to;
[fromInput, toInput].forEach(function (input) {
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
});
syncQuickState();
form.querySelector('button[type="submit"]')?.focus();
}
moreToggle?.addEventListener('click', function () {
var open = morePanel.hasAttribute('hidden');
morePanel.toggleAttribute('hidden', !open);
moreToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
quickButtons.forEach(function (button) {
button.addEventListener('click', function () {
var value = quickRange(button.getAttribute('data-installation-quick-range'));
setRange(value.from, value.to);
});
});
[fromInput, toInput].forEach(function (input) {
input.addEventListener('change', syncQuickState);
input.addEventListener('input', syncQuickState);
});
form.addEventListener('submit', function (event) {
var invalid = Boolean(fromInput.value && toInput.value && fromInput.value > toInput.value);
fromInput.setAttribute('aria-invalid', invalid ? 'true' : 'false');
toInput.setAttribute('aria-invalid', invalid ? 'true' : 'false');
error.hidden = !invalid;
if (invalid) {
event.preventDefault();
toInput.focus();
return;
}
Array.from(form.querySelectorAll('input[name], select[name]')).forEach(function (control) {
if (!control.disabled && String(control.value || '').trim() === '') {
control.disabled = true;
}
});
});
syncQuickState();
})();
</script>

View File

@ -203,12 +203,10 @@
}
.installation-filter-bar {
display: grid;
grid-template-columns: repeat(4, minmax(12rem, 1fr));
gap: 0.65rem 1rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid #f3f4f6;
background: #fcfbf8;
padding: 0.7rem 1.25rem 0.75rem;
border-bottom: 1px solid #e8e2d8;
background: #fffdf8;
color: #595959;
}
.installation-filter-field {
@ -238,34 +236,207 @@
font-size: 0.75rem;
}
.installation-date-filter {
display: flex;
grid-column: span 2;
.installation-filter-compact {
display: block;
}
.installation-filter-primary {
display: grid;
grid-template-columns:
minmax(28rem, 1.55fr)
minmax(10.5rem, 0.62fr)
minmax(8.5rem, 0.42fr)
auto;
gap: 0.6rem;
align-items: center;
gap: 0.4rem;
}
.installation-filter-time {
display: grid;
grid-template-columns: minmax(7rem, 0.45fr) minmax(17rem, 1.4fr);
gap: 0.55rem;
min-width: 0;
}
.installation-filter-control {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.22rem;
color: #595959;
font-size: 0.75rem;
}
.installation-filter-control > span {
height: 0.95rem;
overflow: hidden;
color: #595959;
font-size: 0.6875rem;
font-weight: 500;
line-height: 0.95rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.installation-date-filter__label {
flex: 0 0 auto;
}
.installation-date-filter__range {
flex: 1 1 auto;
.installation-filter-control input,
.installation-filter-control select {
width: 100%;
min-width: 0;
}
.installation-date-filter .upstream-data-date input {
height: 2.1rem;
height: 2rem;
padding: 0 0.55rem;
border: 1px solid #d8d2c7;
border-radius: 0.45rem;
background: #ffffff;
color: #171717;
font: inherit;
font-size: 0.75rem;
}
.installation-filter-control input:focus,
.installation-filter-control select:focus {
border-color: #856632;
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.16);
outline: none;
}
.installation-filter-range .upstream-data-date-range {
gap: 0.35rem;
}
.installation-filter-range .upstream-data-date input {
height: 2rem;
border: 1px solid #d8d2c7;
font-size: 0.75rem;
}
.installation-filter-range .upstream-data-date__clear,
.installation-filter-range .upstream-data-date__pick {
height: 2rem;
}
.installation-filter-actions {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 0.35rem;
white-space: nowrap;
}
.installation-filter-actions .biz-btn {
height: 2rem;
gap: 0.25rem;
padding: 0 0.62rem;
font-size: 0.75rem;
}
.installation-filter-actions .material-icons-outlined {
font-size: 1rem;
}
.installation-more-toggle b {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.05rem;
height: 1.05rem;
padding: 0 0.2rem;
border-radius: 999px;
background: #856632;
color: #ffffff;
font-size: 0.625rem;
font-weight: 600;
}
.installation-more-toggle b[hidden] {
display: none;
}
.installation-filter-context {
display: flex;
min-height: 1.65rem;
align-items: center;
gap: 0.55rem;
flex-wrap: wrap;
margin-top: 0.55rem;
}
.installation-quick-ranges,
.installation-active-filters {
display: flex;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
}
.installation-quick-ranges > span {
color: #595959;
font-size: 0.6875rem;
}
.installation-quick-range {
height: 1.55rem;
padding: 0 0.55rem;
border: 1px solid #ddd6ca;
border-radius: 999px;
background: #ffffff;
color: #595959;
cursor: pointer;
font: inherit;
font-size: 0.6875rem;
line-height: 1;
}
.installation-quick-range:hover,
.installation-quick-range:focus-visible,
.installation-quick-range.is-active {
border-color: #b9954f;
background: #f7f0de;
color: #856632;
outline: none;
}
.installation-active-filters {
min-width: 0;
margin-left: auto;
}
.installation-filter-chip {
display: inline-flex;
max-width: 24rem;
min-height: 1.55rem;
align-items: center;
gap: 0.28rem;
padding: 0.08rem 0.35rem 0.08rem 0.55rem;
border: 1px solid #e2d6bb;
border-radius: 999px;
background: #f7f0de;
color: #6a5129;
font-size: 0.6875rem;
line-height: 1.2;
text-decoration: none;
}
.installation-filter-chip:hover,
.installation-filter-chip:focus-visible {
border-color: #b9954f;
background: #f2e7c5;
outline: none;
}
.installation-filter-chip strong {
overflow: hidden;
max-width: 15rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.installation-filter-chip .material-icons-outlined {
font-size: 0.9rem;
}
.installation-date-range-error {
grid-column: 1 / -1;
margin: 0;
margin: 0.4rem 0 0;
color: #b80000;
font-size: 0.75rem;
}
@ -274,22 +445,92 @@
display: none;
}
.installation-filter-actions {
display: flex;
align-items: center;
gap: 0.5rem;
.installation-more-panel {
margin-top: 0.6rem;
padding: 0.65rem 0.75rem 0.75rem;
border: 1px solid #e7dfd1;
border-radius: 0.55rem;
background: #ffffff;
}
@media (max-width: 1100px) {
.installation-filter-bar { grid-template-columns: repeat(2, minmax(14rem, 1fr)); }
.installation-date-filter { grid-column: 1 / -1; }
.installation-more-panel[hidden] {
display: none;
}
.installation-more-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.55rem;
}
.installation-more-head strong {
color: #171717;
font-size: 0.75rem;
font-weight: 600;
}
.installation-more-head span {
color: #595959;
font-size: 0.6875rem;
}
.installation-more-grid {
display: grid;
grid-template-columns: repeat(4, minmax(9rem, 1fr));
gap: 0.55rem 0.75rem;
}
@media (max-width: 1280px) {
.installation-filter-primary {
grid-template-columns: minmax(27rem, 1fr) minmax(10rem, 0.55fr) minmax(8rem, 0.4fr);
}
.installation-filter-actions {
grid-column: 1 / -1;
justify-content: flex-start;
}
.installation-more-grid {
grid-template-columns: repeat(3, minmax(9rem, 1fr));
}
}
@media (max-width: 980px) {
.installation-filter-primary {
grid-template-columns: minmax(0, 1fr) minmax(11rem, 0.55fr);
}
.installation-filter-time {
grid-column: 1 / -1;
}
.installation-filter-actions {
grid-column: 1 / -1;
}
.installation-more-grid {
grid-template-columns: repeat(2, minmax(9rem, 1fr));
}
}
@media (max-width: 650px) {
.installation-filter-bar { grid-template-columns: minmax(0, 1fr); padding: 1rem; }
.installation-date-filter {
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
.installation-filter-bar {
padding: 0.75rem;
}
.installation-filter-primary,
.installation-filter-time {
grid-template-columns: minmax(0, 1fr);
}
.installation-filter-time,
.installation-filter-actions {
grid-column: auto;
}
.installation-filter-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.installation-active-filters {
margin-left: 0;
}
.installation-more-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@ -773,102 +1014,7 @@
<div class="biz-card mt-6">
@if ($page['slug'] === 'installation-details')
@include('business.pages.partials.date-range-picker')
<form method="GET" id="installationFilterForm" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="安装明细筛选查询栏" autocomplete="off">
@php
$dateRangeFilters = [
['from' => 'created_from', 'to' => 'created_to', 'label' => '创建日期:', 'aria' => '创建日期'],
['from' => 'customer_review_from', 'to' => 'customer_review_to', 'label' => '客服审核时间:', 'aria' => '客服审核时间'],
['from' => 'finance_review_from', 'to' => 'finance_review_to', 'label' => '财务审核时间:', 'aria' => '财务审核时间'],
];
$filterFields = [
['name' => 'push_status', 'label' => '推送状态:'], ['name' => 'order_no', 'label' => '订单编号:'],
['name' => 'customer_review', 'label' => '客服审核:', 'options' => ['0'=>'未审核','1'=>'已审核']], ['name' => 'finance_review', 'label' => '财务审核:', 'options' => ['0'=>'未审核','1'=>'已审核']],
['name' => 'quote_status', 'label' => '报价状态:'], ['name' => 'arranged_status', 'label' => '是否安排:', 'options' => ['0'=>'未安排','1'=>'已安排']],
['name' => 'fee_payer', 'label' => '安装费承担方:'], ['name' => 'completed_status', 'label' => '是否完工:', 'options' => ['0'=>'未完工','1'=>'已完工']],
['name' => 'shop_name', 'label' => '选择店铺:'], ['name' => 'delete_status', 'label' => '选择删除:', 'options' => ['0'=>'未删除','1'=>'已删除']],
['name' => 'installation_company', 'label' => '安装公司:'], ['name' => 'installation_no', 'label' => '安装单号:'],
['name' => 'order_type', 'label' => '订单类型:'], ['name' => 'product_id', 'label' => '商品ID:'],
];
@endphp
@foreach ($dateRangeFilters as $dateRange)
<div class="installation-date-filter">
<span class="installation-date-filter__label">{{ $dateRange['label'] }}</span>
<div class="upstream-data-date-range installation-date-filter__range">
<span class="upstream-data-date" data-date-picker>
<input id="{{ $dateRange['from'] }}" type="text" name="{{ $dateRange['from'] }}" value="{{ request($dateRange['from']) }}" readonly autocomplete="off" placeholder="开始日期" aria-label="{{ $dateRange['aria'] }}开始日期" aria-describedby="installationDateRangeError" data-date-input>
<button type="button" class="upstream-data-date__clear" data-date-clear hidden aria-label="清除{{ $dateRange['aria'] }}开始日期">
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
<button type="button" class="upstream-data-date__pick" data-date-toggle aria-label="选择{{ $dateRange['aria'] }}开始日期">
<span class="material-icons-outlined" aria-hidden="true">calendar_today</span>
</button>
<div class="upstream-data-calendar" role="dialog" aria-label="{{ $dateRange['aria'] }}开始日期日历" data-date-calendar hidden></div>
</span>
<b class="upstream-data-date-range__sep">至</b>
<span class="upstream-data-date" data-date-picker>
<input id="{{ $dateRange['to'] }}" type="text" name="{{ $dateRange['to'] }}" value="{{ request($dateRange['to']) }}" readonly autocomplete="off" placeholder="结束日期" aria-label="{{ $dateRange['aria'] }}结束日期" aria-describedby="installationDateRangeError" data-date-input>
<button type="button" class="upstream-data-date__clear" data-date-clear hidden aria-label="清除{{ $dateRange['aria'] }}结束日期">
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
<button type="button" class="upstream-data-date__pick" data-date-toggle aria-label="选择{{ $dateRange['aria'] }}结束日期">
<span class="material-icons-outlined" aria-hidden="true">calendar_today</span>
</button>
<div class="upstream-data-calendar" role="dialog" aria-label="{{ $dateRange['aria'] }}结束日期日历" data-date-calendar hidden></div>
</span>
</div>
</div>
@endforeach
@foreach ($filterFields as $filter)
<label class="installation-filter-field">
<span>{{ $filter['label'] }}</span>
@if (isset($filter['options']))
<select name="{{ $filter['name'] }}"><option value="">全部</option>@foreach ($filter['options'] as $value => $label)<option value="{{ $value }}" @selected((string) request($filter['name']) === (string) $value)>{{ $label }}</option>@endforeach</select>
@else
<input type="{{ $filter['type'] ?? 'text' }}" name="{{ $filter['name'] }}" value="{{ request($filter['name']) }}" @if (($filter['type'] ?? '') !== 'date') placeholder="按原值精确查询" @endif>
@endif
</label>
@endforeach
<p id="installationDateRangeError" class="installation-date-range-error" role="alert" hidden>结束日期不能早于开始日期。</p>
<div class="installation-filter-actions">
<button type="submit" class="biz-btn biz-btn-primary">查询</button>
<a href="{{ request()->url() }}" class="biz-btn biz-btn-ghost">清除</a>
<a href="{{ route('business.installation-details.export', request()->query()) }}" class="biz-btn biz-btn-ghost">导出</a>
</div>
</form>
<script>
(function () {
var form = document.getElementById('installationFilterForm');
if (!form) { return; }
if (window.CaiWuDatePicker) {
window.CaiWuDatePicker.setup(form);
}
var error = document.getElementById('installationDateRangeError');
var ranges = [
['created_from', 'created_to'],
['customer_review_from', 'customer_review_to'],
['finance_review_from', 'finance_review_to']
];
form.addEventListener('submit', function (event) {
var invalidEnd = null;
ranges.forEach(function (names) {
var from = form.elements.namedItem(names[0]);
var to = form.elements.namedItem(names[1]);
var invalid = Boolean(from && to && from.value && to.value && from.value > to.value);
if (from) { from.setAttribute('aria-invalid', invalid ? 'true' : 'false'); }
if (to) { to.setAttribute('aria-invalid', invalid ? 'true' : 'false'); }
if (invalid && !invalidEnd) { invalidEnd = to; }
});
error.hidden = !invalidEnd;
if (invalidEnd) {
event.preventDefault();
invalidEnd.focus();
}
});
})();
</script>
@include('business.pages.partials.installation-filters')
@include('business.pages.partials.installation-fee-summary')
@endif
<div class="biz-card-head">

View File

@ -33,6 +33,13 @@ class InternalFlowReportPageTest extends FeatureTestCase
'invoicing_ratio' => '1',
]],
'total' => 1,
'summary' => [
'companies' => 1,
'projected_sales' => 1200000.0,
'receipt_total' => 0.0,
'remaining_gap' => 0.0,
'average_invoicing_ratio' => 100.0,
],
]);
$this->app->instance(InternalFlowReportRepository::class, $repository);
@ -49,6 +56,11 @@ class InternalFlowReportPageTest extends FeatureTestCase
->assertSeeText('收票-内部流转(来源)1')
->assertSeeText('剩余缺口')
->assertSeeText('开票比例')
->assertSeeText('公司数')
->assertSeeText('预计销量合计')
->assertSeeText('已收票合计')
->assertSeeText('剩余缺口合计')
->assertSeeText('平均开票比例')
->assertSee('data-directory-query-picker', false)
->assertSee('data-directory-query-export', false)
->assertSee('data-directory-query-field="company_name"', false)

View File

@ -244,30 +244,51 @@ class ManagementCrudTest extends FeatureTestCase
->assertSeeText('安装单号')
->assertSeeText('申请总金额')
->assertSeeText('商品名称(1)')
->assertSeeText('创建日期:')
->assertSeeText('客服审核时间:')
->assertSeeText('财务审核时间:')
->assertSeeText('推送状态:')
->assertSeeText('选择店铺:')
->assertSeeText('选择删除:')
->assertSeeText('订单类型:')
->assertSeeText('商品ID:')
->assertSeeText('时间口径')
->assertSeeText('日期区间')
->assertSeeText('店铺')
->assertSeeText('安装状态')
->assertSeeText('更多筛选')
->assertSeeText('今天')
->assertSeeText('本周')
->assertSeeText('本月')
->assertSeeText('上月')
->assertSeeText('近30天')
->assertSeeText('推送状态')
->assertSeeText('订单编号')
->assertSeeText('店铺 ID')
->assertSeeText('安装公司')
->assertSeeText('商品 ID')
->assertSeeText('导出')
->assertSee('id="installationFilterForm"', false)
->assertSee('name="created_from"', false)
->assertSee('name="created_to"', false)
->assertSee('name="customer_review_from"', false)
->assertSee('name="customer_review_to"', false)
->assertSee('name="finance_review_from"', false)
->assertSee('name="finance_review_to"', false)
->assertSee('name="time_scope"', false)
->assertSee('name="date_from"', false)
->assertSee('name="date_to"', false)
->assertSee('name="shop_name"', false)
->assertSee('name="completed_status"', false)
->assertSee('id="installationMoreFilters"', false)
->assertSee('id="installationMoreFilters" class="installation-more-panel" hidden', false)
->assertSee('data-installation-more-toggle', false)
->assertSee('data-installation-quick-range="today"', false)
->assertSee('data-installation-quick-range="last_30_days"', false)
->assertSee('data-date-picker', false)
->assertSee('data-date-calendar', false)
->assertSee("window.CaiWuDatePicker.setup(form)", false)
->assertSee("input.addEventListener('input', syncClear)", false)
->assertSee('结束日期不能早于开始日期。')
->assertSee('data-installation-fee-open', false)
->assertSee('id="installation-fee-dialog"', false)
->assertSeeText('安装费金额')
->assertSeeText('按客服审核时间汇总安装明细的结算明细金额')
->assertSeeText('较上月')
->assertSeeText('包安装')
->assertSeeText('不包安装')
->assertSee('data-installation-fee-trend', false)
->assertSee('data-installation-fee-package', false)
->assertSee('data-installation-fee-non-package', false)
->assertSee('border-left: 3px solid #006395', false)
->assertSee('font-size: 2.625rem', false)
->assertSee('function captionRange(value)', false)
->assertSee('function setCardValue(total, rows, value, data)', false)
->assertSeeText('平台')
->assertSeeText('店铺名称')
->assertSeeText('安装公司')
@ -307,6 +328,58 @@ class ManagementCrudTest extends FeatureTestCase
$this->assertStringContainsString('INSTALL-EXPORT-001', $response->streamedContent());
}
public function testInstallationDetailsUnifiedFiltersRenderActiveChipsAndKeepLowFrequencyFieldsInMorePanel(): void
{
$mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
$mirror->shouldReceive('paginate')->once()->andReturn([
'ready' => true,
'total' => 1,
'data' => [array_replace(array_fill_keys(array_keys(InstallationDetail::columnLabels()), null), [
'id' => 9,
'installation_no' => 'INSTALL-CHIP-001',
'installation_company' => '测试安装公司',
'store_name' => '测试店铺',
'requested_amount' => '120.50',
'settled_amount' => '100.00',
'related_guid' => 'guid-install-chip',
])],
]);
$this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
$url = route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'installation-details',
]) . '?' . http_build_query([
'time_scope' => 'customer_review',
'date_from' => '2026-09-01',
'date_to' => '2026-09-30',
'shop_name' => '测试店铺',
'completed_status' => '1',
'shop_code' => 'SHOP-001',
'push_status' => 'PUSHED',
'order_no' => 'ORDER-CHIP-001',
]);
$this->loginAs()
->get($url)
->assertOk()
->assertSeeText('客服审核时间')
->assertSeeText('2026-09-01 至 2026-09-30')
->assertSeeText('测试店铺')
->assertSeeText('已完工')
->assertSeeText('SHOP-001')
->assertSeeText('PUSHED')
->assertSeeText('ORDER-CHIP-001')
->assertSee('aria-label="删除「客服审核时间」条件"', false)
->assertSee('aria-label="删除「店铺」条件"', false)
->assertSee('aria-label="删除「安装状态」条件"', false)
->assertSee('aria-label="删除「店铺 ID」条件"', false)
->assertSee('aria-label="删除「推送状态」条件"', false)
->assertSee('aria-label="删除「订单编号」条件"', false)
->assertSee('data-installation-more-count>3</b>', false)
->assertDontSee('id="installationMoreFilters" class="installation-more-panel" hidden', false);
}
public function testInstallationDetailsEndpointReturnsChildItemsForMainRecord(): void
{
$mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
@ -342,6 +415,10 @@ class ManagementCrudTest extends FeatureTestCase
['platform' => '淘系', 'store_name' => '店铺A', 'installation_company' => '万师傅', 'payment_channel' => '包安装', 'amount' => 1250.50],
['platform' => '京东', 'store_name' => '店铺B', 'installation_company' => '鲁班到家', 'payment_channel' => '不包安装', 'amount' => 750.00],
],
'previous_total' => 1600.40,
'previous_from' => '2026-08-01',
'previous_to' => '2026-08-31',
'change_rate' => 25.0,
]);
$this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
@ -355,7 +432,9 @@ class ManagementCrudTest extends FeatureTestCase
->assertJsonPath('rows.0.platform', '淘系')
->assertJsonPath('rows.0.store_name', '店铺A')
->assertJsonPath('rows.0.payment_channel', '包安装')
->assertJsonPath('rows.1.payment_channel', '不包安装');
->assertJsonPath('rows.1.payment_channel', '不包安装')
->assertJsonPath('previous_total', 1600.4)
->assertJsonPath('change_rate', 25.0);
}
public function testInstallationSummaryRequiresCompleteCustomerReviewRange(): void

View File

@ -46,11 +46,13 @@ class PrototypePageTest extends FeatureTestCase
->assertSeeText('主账号')
->assertSeeText('已设置')
->assertSeeText('13800000000')
->assertSee('directory-value-badge', false)
->assertSee('data-tone="accent"', false)
->assertDontSeeText('MAIN')
->assertDontSeeText('共享镜像中暂未读取到店铺账号数据');
}
public function testSharedBusinessPagesRenderClickableSummaryCards(): void
public function testBaseInformationPagesDoNotRenderSummaryCards(): void
{
$rows = new LengthAwarePaginator([(object) [
'id' => 'account-summary',
@ -64,15 +66,7 @@ class PrototypePageTest extends FeatureTestCase
->shouldReceive('paginate')
->once()
->with('store-management', 'store-accounts', 50)
->andReturnUsing(function () use ($rows): LengthAwarePaginator {
request()->attributes->set('business-page-summary-cards', [
['label' => '账号总数', 'value' => 958, 'hint' => '当前筛选内账号记录', 'format' => 'integer', 'query' => ['page' => null]],
['label' => '主账号', 'value' => 640, 'hint' => '账号类型为 MAIN', 'format' => 'integer', 'query' => ['page' => null, 'account_type' => '主账号']],
['label' => '子账号', 'value' => 318, 'hint' => '账号类型为 SUB', 'format' => 'integer', 'query' => ['page' => null, 'account_type' => '子账号']],
]);
return $rows;
});
->andReturn($rows);
$this->loginAs()
->get(route('business.pages.show', [
@ -81,16 +75,56 @@ class PrototypePageTest extends FeatureTestCase
'account_type' => '主账号',
]))
->assertOk()
->assertSee('data-business-summary-cards', false)
->assertSee('data-business-summary-card="账号总数"', false)
->assertSee('data-business-summary-card="主账号"', false)
->assertSee('data-business-summary-card="子账号"', false)
->assertSee('account_type=', false)
->assertSee('aria-current="true"', false)
->assertSeeText('958')
->assertSeeText('640')
->assertSeeText('318')
->assertSee('查看明细', false);
->assertDontSee('data-business-summary-cards', false);
}
public function testOperatingReportPagesRenderTableSpecificSummaryCards(): void
{
$pages = [
['company-cost-arrangement', ['数据状态', '完整表头', '11 列', '核心结果', '缺口提示', '配平提示']],
['tax-forecast', ['数据状态', '完整表头', '22 列', '计算税种', '必补口径', '当前记录']],
['personnel-inventory', ['数据状态', '完整表头', '17 列', '敏感字段', '容量规则', '当前记录']],
];
foreach ($pages as [$page, $labels]) {
$response = $this->loginAs()->get(route('business.pages.show', [
'section' => 'company-management',
'page' => $page,
]));
$response->assertOk()->assertSee('data-business-summary-cards', false);
foreach ($labels as $label) {
$response->assertSeeText($label);
}
}
}
public function testOperatingReportFieldContractsKeepAllOriginalHeaders(): void
{
$navigation = require config_path('business_navigation.php');
$pages = collect($navigation)
->flatMap(static fn (array $section) => collect($section['pages'] ?? [])
->map(static fn (array $page): array => [
'key' => $section['slug'] . '/' . $page['slug'],
'fields' => $page['directory']['fields'] ?? [],
]))
->keyBy('key');
$expectedFieldCounts = [
'company-management/subject-operating-report' => 21,
'company-management/company-cost-arrangement' => 10,
'company-management/tax-forecast' => 21,
'company-management/personnel-inventory' => 16,
'store-management/store-report' => 26,
'store-management/internal-flow-report' => 15,
];
foreach ($expectedFieldCounts as $key => $count) {
$fields = $pages[$key]['fields'];
$names = array_column($fields, 'name');
$this->assertCount($count, $fields, $key . ' 的字段数量不应减少');
$this->assertCount($count, array_unique($names), $key . ' 的字段名不应重复');
}
}
public function testNavigationUsesConfiguredPermissionsAndTitles(): void
@ -150,6 +184,8 @@ class PrototypePageTest extends FeatureTestCase
->assertSeeText('测试主体')
->assertSeeText('小规模纳税人')
->assertSeeText('个人独资企业')
->assertSee('directory-value-badge', false)
->assertSee('data-tone="info"', false)
->assertSeeText('共 1 条')
->assertSeeText('已从共享业务镜像读取')
->assertDontSee('<div class="directory-table-tools" data-directory-tools>', false)
@ -191,16 +227,11 @@ class PrototypePageTest extends FeatureTestCase
->assertSeeTextInOrder(['最近12个月累计收入', '1月收入', '12月收入']);
}
public function testSubjectOperatingReportHidesMonthsWithoutDataAndKeepsThemSelectable(): void
public function testSubjectOperatingReportShowsAllMonthsByDefaultAndStillSupportsColumnSelection(): void
{
$service = Mockery::mock(UpstreamBusinessInformation::class);
$service->shouldReceive('paginate')->with('company-management', 'subject-operating-report', 50)
->andReturnUsing(function (): LengthAwarePaginator {
request()->attributes->set(
'subject-operating-report.default_hidden_columns',
['income_01', 'income_02'],
);
return new LengthAwarePaginator([(object) [
'entity' => '测试主体',
'city' => '常州市',
@ -224,14 +255,13 @@ class PrototypePageTest extends FeatureTestCase
$response->assertOk();
$html = $response->getContent();
// 没有数据的月份默认隐藏,但表头和「筛选字段」勾选项仍在页面中。
$this->assertMatchesRegularExpression('/<th[^>]*data-column-key="income_01"[^>]*is-column-hidden/', $html);
$this->assertMatchesRegularExpression('/<th[^>]*data-column-key="income_02"[^>]*is-column-hidden/', $html);
// 所有原始月份表头默认显示,避免新增汇总卡后缩减原表字段。
$this->assertDoesNotMatchRegularExpression('/<th[^>]*data-column-key="income_01"[^>]*is-column-hidden/', $html);
$this->assertDoesNotMatchRegularExpression('/<th[^>]*data-column-key="income_02"[^>]*is-column-hidden/', $html);
$this->assertDoesNotMatchRegularExpression('/<th[^>]*data-column-key="income_03"[^>]*is-column-hidden/', $html);
$this->assertStringContainsString('value="income_01" data-directory-query-column', $html);
$this->assertStringNotContainsString('value="income_01" data-directory-query-column checked', $html);
$this->assertStringContainsString('value="income_01" data-directory-query-column checked', $html);
$this->assertStringContainsString('value="income_03" data-directory-query-column checked', $html);
$this->assertStringContainsString('筛选字段 19/21', $html);
// 手动勾选(columns 参数)后,对应月份列恢复显示。
$restored = $this->get(route('business.pages.show', [
@ -247,16 +277,11 @@ class PrototypePageTest extends FeatureTestCase
$this->assertMatchesRegularExpression('/<th[^>]*data-column-key="income_04"[^>]*is-column-hidden/', $restoredHtml);
}
public function testStoreReportHidesSalesMonthsWithoutDataAndKeepsThemSelectable(): void
public function testStoreReportShowsAllMonthsByDefaultAndStillSupportsColumnSelection(): void
{
$service = Mockery::mock(UpstreamBusinessInformation::class);
$service->shouldReceive('paginate')->with('store-management', 'store-report', 50)
->andReturnUsing(function (): LengthAwarePaginator {
request()->attributes->set(
'store-report.default_hidden_columns',
['sales_01', 'sales_08'],
);
return new LengthAwarePaginator([(object) [
'erp_store_name' => 'ERP测试店铺',
'store_name' => '前台测试店铺',
@ -280,14 +305,13 @@ class PrototypePageTest extends FeatureTestCase
$response->assertOk();
$html = $response->getContent();
// 没有业绩的月份默认隐藏,但表头和「筛选字段」勾选项仍在页面中。
$this->assertMatchesRegularExpression('/<th[^>]*data-column-key="sales_01"[^>]*is-column-hidden/', $html);
$this->assertMatchesRegularExpression('/<th[^>]*data-column-key="sales_08"[^>]*is-column-hidden/', $html);
// 所有原始月份表头默认显示,避免新增汇总卡后缩减原表字段。
$this->assertDoesNotMatchRegularExpression('/<th[^>]*data-column-key="sales_01"[^>]*is-column-hidden/', $html);
$this->assertDoesNotMatchRegularExpression('/<th[^>]*data-column-key="sales_08"[^>]*is-column-hidden/', $html);
$this->assertDoesNotMatchRegularExpression('/<th[^>]*data-column-key="sales_07"[^>]*is-column-hidden/', $html);
$this->assertStringContainsString('value="sales_01" data-directory-query-column', $html);
$this->assertStringNotContainsString('value="sales_01" data-directory-query-column checked', $html);
$this->assertStringContainsString('value="sales_01" data-directory-query-column checked', $html);
$this->assertStringContainsString('value="sales_07" data-directory-query-column checked', $html);
$this->assertStringContainsString('筛选字段 24/26', $html);
// 手动勾选(columns 参数)后,对应月份列恢复显示。
$restored = $this->get(route('business.pages.show', [

View File

@ -117,6 +117,11 @@ class InternalFlowReportImportTest extends TestCase
$all = $repository->paginate(1, 50);
$this->assertSame(3, $all['total']);
$this->assertSame('示例甲供应链有限公司', $all['data'][0]['company_name']);
$this->assertSame(3, $all['summary']['companies']);
$this->assertSame(3669135.68, $all['summary']['projected_sales']);
$this->assertSame(1420000.61, $all['summary']['receipt_total']);
$this->assertEqualsWithDelta(1401086.9311, $all['summary']['remaining_gap'], 0.005);
$this->assertEqualsWithDelta(71.0, $all['summary']['average_invoicing_ratio'], 0.000001);
$filtered = $repository->paginate(1, 50, ['company_name' => '示例乙']);
$this->assertSame(1, $filtered['total']);

View File

@ -136,6 +136,8 @@ class BusinessInformationMirrorRepositoryTest extends TestCase
$this->assertSame(1, $businessTypedCompany['total']);
$this->assertSame('个人独资企业', $businessTypedCompany['data'][0]['business_registration_type']);
$this->assertSame(1, $cityCompany['total']);
$this->assertSame('保留', $companyClassifications['新公司']['status']);
$this->assertSame('待注销', $companyClassifications['其他公司']['status']);
$this->assertSame('小规模纳税人', $companyClassifications['新公司']['tax_type']);
$this->assertSame('个人独资企业', $companyClassifications['新公司']['business_registration_type']);
$this->assertNull($companyClassifications['旧公司']['business_registration_type']);

View File

@ -32,7 +32,7 @@ class InstallationDetailMirrorRepositoryTest extends TestCase
$schema->create('business_mirror_table_states',function(Blueprint $t){$t->string('dataset_key')->primary();$t->dateTime('initial_completed_at')->nullable();});
$schema->create('fixture_install',function(Blueprint $t){
$t->integer('Id');
foreach(['SourceGuid','OrderNum','ordernum_id','shop_name','SubmitTime','ShipTIme','kfshTime','cwshTime','apistatus','kfsh','cwsh','BidStatus','costtaker','costWay','installComplete','Delete','installCompanyName','TypeDto'] as $c) $t->string($c)->nullable();
foreach(['SourceGuid','OrderNum','ordernum_id','shop_name','shopcode','SubmitTime','ShipTIme','kfshTime','cwshTime','apistatus','kfsh','cwsh','BidStatus','costtaker','costWay','installComplete','Delete','installCompanyName','TypeDto'] as $c) $t->string($c)->nullable();
$t->integer('StatusId')->nullable()->default(0);
foreach(['installcost','settleAmount','acceptAmount'] as $c) $t->decimal($c,14,2)->default(0);
});
@ -48,7 +48,7 @@ class InstallationDetailMirrorRepositoryTest extends TestCase
foreach(array_keys($definitions) as $key) DB::connection('business_mirror')->table('business_mirror_table_states')->insert(['dataset_key'=>$key,'initial_completed_at'=>'2026-08-01']);
DB::connection('business_mirror')->table('fixture_install')->insert([
['Id'=>1,'SourceGuid'=>'A','OrderNum'=>'OLD','shop_name'=>'旧店','SubmitTime'=>'2026-08-01','ShipTIme'=>'2026-08-02','installcost'=>500],
['Id'=>2,'SourceGuid'=>'A','OrderNum'=>'NEW','shop_name'=>'测试店','SubmitTime'=>'2026-08-20 23:59:59','ShipTIme'=>'2026-08-21','installcost'=>20],
['Id'=>2,'SourceGuid'=>'A','OrderNum'=>'NEW','shop_name'=>'测试店','shopcode'=>'SHOP-A','SubmitTime'=>'2026-08-20 23:59:59','ShipTIme'=>'2026-08-21','installcost'=>20],
['Id'=>3,'SourceGuid'=>'B','OrderNum'=>'OTHER','shop_name'=>'其他店','SubmitTime'=>'2026-08-20','ShipTIme'=>'2026-08-21','installcost'=>30],
['Id'=>4,'SourceGuid'=>'C','OrderNum'=>'NEXT','shop_name'=>'测试店','SubmitTime'=>'2026-08-21','ShipTIme'=>'2026-08-22','installcost'=>40],
['Id'=>5,'SourceGuid'=>'D','OrderNum'=>'PRE-2026','shop_name'=>'测试店','SubmitTime'=>'2026-08-21','ShipTIme'=>'2025-12-31 23:59:59','installcost'=>999],
@ -71,6 +71,7 @@ class InstallationDetailMirrorRepositoryTest extends TestCase
$this->assertSame(1,$this->repository->paginate(1,50,$filters)['total']);
$this->assertSame(20.0,$this->repository->summary($filters)['requested']);
$this->assertSame(0,$this->repository->paginate(1,50,['shop_name'=>"' OR 1=1 --"])['total']);
$this->assertSame(1,$this->repository->paginate(1,50,['shop_code'=>'SHOP-A'])['total']);
}
public function test_product_filter_does_not_multiply_main_rows(): void
@ -139,6 +140,7 @@ class InstallationDetailMirrorRepositoryTest extends TestCase
DB::connection('business_mirror')->table('fixture_install')->where('Id',3)->update(['installCompanyName'=>'鲁班到家','costWay'=>'银行转账']);
DB::connection('business_mirror')->table('fixture_install')->where('Id',4)->update(['installCompanyName'=>'万师傅']);
DB::connection('business_mirror')->table('fixture_items')->insert([
['Id'=>19,'guid'=>'A','shopproid'=>'P0','Sourceno'=>'A-0','kfshTime'=>'2026-08-15 10:00:00','settleItemAmount'=>90.00,'acceptItemAmount'=>80.00],
['Id'=>20,'guid'=>'A','shopproid'=>'P1','Sourceno'=>'A-1','kfshTime'=>'2026-09-01 09:00:00','settleItemAmount'=>60.10,'acceptItemAmount'=>40.00],
['Id'=>21,'guid'=>'A','shopproid'=>'P2','Sourceno'=>'A-1','kfshTime'=>'2026-09-30 18:00:00','settleItemAmount'=>39.90,'acceptItemAmount'=>0.00],
['Id'=>22,'guid'=>'B','shopproid'=>'P3','Sourceno'=>'B-1','kfshTime'=>'2026-09-15 10:00:00','settleItemAmount'=>80.00,'acceptItemAmount'=>70.00],
@ -158,6 +160,10 @@ class InstallationDetailMirrorRepositoryTest extends TestCase
['platform'=>'其他','store_name'=>'其他店','installation_company'=>'鲁班到家','payment_channel'=>'不包安装','amount'=>80.0],
],$result['rows']);
$this->assertSame(180.0,$result['total']);
$this->assertSame(90.0,$result['previous_total']);
$this->assertSame('2026-08-01',$result['previous_from']);
$this->assertSame('2026-08-31',$result['previous_to']);
$this->assertSame(100.0,$result['change_rate']);
}
public function test_fee_details_classifies_platform_by_store_name_rules(): void
@ -223,4 +229,27 @@ class InstallationDetailMirrorRepositoryTest extends TestCase
$this->expectException(\Illuminate\Validation\ValidationException::class);
InstallationDetailMirrorRepository::requestFilters();
}
public function test_unified_time_scope_maps_to_the_selected_legacy_date_range(): void
{
request()->merge([
'time_scope' => 'finance_review',
'date_from' => '2026-08-01',
'date_to' => '2026-08-31',
'created_from' => '2026-07-01',
'created_to' => '2026-07-31',
'shop_name' => '测试店',
]);
$filters = InstallationDetailMirrorRepository::requestFilters();
$this->assertSame('finance_review', $filters['time_scope']);
$this->assertSame('2026-08-01', $filters['date_from']);
$this->assertSame('2026-08-31', $filters['date_to']);
$this->assertSame('2026-08-01', $filters['finance_review_from']);
$this->assertSame('2026-08-31', $filters['finance_review_to']);
$this->assertSame('测试店', $filters['shop_name']);
$this->assertArrayNotHasKey('created_from', $filters);
$this->assertArrayNotHasKey('created_to', $filters);
}
}

View File

@ -256,6 +256,7 @@ class StoreReportRepositoryTest extends TestCase
public function test_upstream_business_information_exposes_shared_store_report_rows(): void
{
Carbon::setTestNow('2026-07-15 10:00:00');
$this->insertBill('店铺A', '2026-07-05', 60);
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
$mirror->shouldReceive('ready')->with('store-information.read')->andReturnTrue();
@ -287,14 +288,14 @@ class StoreReportRepositoryTest extends TestCase
$this->assertSame('部门负责人', $paginator->items()[0]->department_manager);
$this->assertSame('运营中心A', $paginator->items()[0]->profit_center);
$summaryCards = collect($this->app['request']->attributes->get('business-page-summary-cards'))->keyBy('label');
$this->assertSame(1, $summaryCards['店铺总数']['value']);
$this->assertSame(1, $summaryCards['正常店铺']['value']);
$this->assertSame(0, $summaryCards['关店中']['value']);
$this->assertSame(0, $summaryCards['已关闭']['value']);
$this->assertSame(60.0, (float) $summaryCards['期间销量']['value']);
$this->assertSame(1, $summaryCards['正常存续店铺']['value']);
$this->assertSame(0.0, (float) $summaryCards['上月(6月)销量']['value']);
$this->assertSame(60.0, (float) $summaryCards['当月(7月)销量']['value']);
$this->assertSame(0, $summaryCards['上月(6月)有销量店铺']['value']);
$this->assertSame(1, $summaryCards['当月(7月)有销量店铺']['value']);
}
public function test_store_report_hides_sales_months_without_data_by_default(): void
public function test_store_report_keeps_all_sales_months_visible_by_default(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$this->insertBill('店铺A', '2026-07-05', 60);
@ -314,15 +315,44 @@ class StoreReportRepositoryTest extends TestCase
$paginator = $service->paginate('store-management', 'store-report', 50);
// 只有 7 月有业绩,其余月份列默认隐藏。
$this->assertSame(
['sales_01', 'sales_02', 'sales_03', 'sales_04', 'sales_05', 'sales_06', 'sales_08', 'sales_09', 'sales_10', 'sales_11', 'sales_12'],
$this->assertNull(
$this->app['request']->attributes->get('store-report.default_hidden_columns'),
);
$this->assertSame(1, $paginator->total());
$this->assertSame(60.0, (float) $paginator->items()[0]->sales_07);
}
public function test_store_cards_exclude_closed_store_but_keep_its_detail_row(): void
{
Carbon::setTestNow('2026-07-15 10:00:00');
$this->insertBill('正常店铺', '2026-07-05', 10);
$this->insertBill('已关店铺', '2026-07-06', 90);
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
$mirror->shouldReceive('ready')->with('store-information.read')->andReturnTrue();
$mirror->shouldReceive('allStores')->andReturn([
['erp_store_name' => '正常店铺', 'company_name' => '主体甲', 'store_status' => '0'],
['erp_store_name' => '已关店铺', 'company_name' => '主体乙', 'store_status' => '6'],
]);
$accounts = Mockery::mock(StoreAccountMirrorRepository::class);
$accounts->shouldReceive('ownershipByStoreName')->andReturn([]);
$service = new UpstreamBusinessInformation(
Mockery::mock(UpstreamQueryCacheFactory::class),
$mirror,
$this->repository,
$accounts,
);
$paginator = $service->paginate('store-management', 'store-report', 50);
$summaryCards = collect($this->app['request']->attributes->get('business-page-summary-cards'))->keyBy('label');
// 已关店铺明细继续保留,只从“正常存续”汇总卡中剔除。
$this->assertSame(2, $paginator->total());
$this->assertSame(1, $summaryCards['正常存续店铺']['value']);
$this->assertSame(10.0, (float) $summaryCards['当月(7月)销量']['value']);
$this->assertSame(1, $summaryCards['当月(7月)有销量店铺']['value']);
}
public function test_subject_operating_report_aggregates_income_by_entity(): void
{
Carbon::setTestNow('2026-09-01 10:00:00');
@ -371,10 +401,12 @@ class StoreReportRepositoryTest extends TestCase
$this->assertSame('小规模纳税人', $rows[0]->tax_type);
$this->assertSame('个人独资企业', $rows[0]->business_registration_type);
$summaryCards = collect($this->app['request']->attributes->get('business-page-summary-cards'))->keyBy('label');
$this->assertSame(1, $summaryCards['主体总数']['value']);
$this->assertSame(1, $summaryCards['有效主体']['value']);
$this->assertSame(0, $summaryCards['已注销主体']['value']);
$this->assertSame(90.0, (float) $summaryCards['最近12个月收入']['value']);
$this->assertSame(1, $summaryCards['正常存续主体']['value']);
$this->assertSame(70.0, (float) $summaryCards['上月(8月)收入']['value']);
$this->assertSame(0.0, (float) $summaryCards['当月(9月)收入']['value']);
$this->assertSame(1, $summaryCards['小规模纳税人']['value']);
$this->assertSame(0, $summaryCards['一般纳税人']['value']);
$this->assertArrayNotHasKey('最近12个月收入', $summaryCards);
request()->merge(['income_08' => '70.00']);
$this->assertSame(1, $service->paginate('company-management', 'subject-operating-report', 50)->total());
@ -392,6 +424,110 @@ class StoreReportRepositoryTest extends TestCase
$this->assertSame(0, $service->paginate('company-management', 'subject-operating-report', 50)->total());
}
public function test_subject_cards_exclude_deregistered_entities_and_show_slash_for_their_types(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$this->insertBill('店铺正常', '2026-08-05', 30);
$this->insertBill('店铺注销', '2026-08-06', 70);
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
$mirror->shouldReceive('ready')->with('store-information.read')->andReturnTrue();
$mirror->shouldReceive('ready')->with('company-information.read')->andReturnTrue();
$mirror->shouldReceive('allStores')->andReturn([
['erp_store_name' => '店铺正常', 'company_name' => '存续主体', 'store_status' => '0'],
['erp_store_name' => '店铺注销', 'company_name' => '注销主体', 'store_status' => '0'],
]);
$mirror->shouldReceive('companiesByNames')->andReturn([
'存续主体' => [[
'Id' => 2,
'businessLicenseName' => '存续主体',
'companyStatus' => '0',
'taxCategoryAndType' => '小规模',
'business_registration_type' => null,
]],
'注销主体' => [[
'Id' => 1,
'businessLicenseName' => '注销主体',
'companyStatus' => '1',
'taxCategoryAndType' => '一般纳税人',
'business_registration_type' => '有限公司',
]],
]);
$mirror->shouldReceive('companyClassificationsByNames')->andReturn([]);
$accounts = Mockery::mock(StoreAccountMirrorRepository::class);
$accounts->shouldReceive('ownershipByStoreName')->andReturn([]);
$service = new UpstreamBusinessInformation(
Mockery::mock(UpstreamQueryCacheFactory::class),
$mirror,
$this->repository,
$accounts,
);
$rows = collect($service->paginate('company-management', 'subject-operating-report', 50)->items())->keyBy('entity');
$summaryCards = collect($this->app['request']->attributes->get('business-page-summary-cards'))->keyBy('label');
$this->assertSame('小规模纳税人', $rows['存续主体']->tax_type);
$this->assertSame('待维护', $rows['存续主体']->business_registration_type);
$this->assertSame('/', $rows['注销主体']->tax_type);
$this->assertSame('/', $rows['注销主体']->business_registration_type);
$this->assertSame(1, $summaryCards['正常存续主体']['value']);
$this->assertSame(30.0, (float) $summaryCards['上月(8月)收入']['value']);
$this->assertSame(1, $summaryCards['小规模纳税人']['value']);
$this->assertSame(0, $summaryCards['一般纳税人']['value']);
}
public function test_subject_uses_company_ledger_retained_tag_for_operating_population(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$this->insertBill('保留店铺', '2026-08-05', 30);
$this->insertBill('待注销店铺', '2026-08-06', 70);
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
$mirror->shouldReceive('ready')->with('store-information.read')->andReturnTrue();
$mirror->shouldReceive('ready')->with('company-information.read')->andReturnTrue();
$mirror->shouldReceive('allStores')->andReturn([
['erp_store_name' => '保留店铺', 'company_name' => '保留主体', 'store_status' => '0'],
['erp_store_name' => '待注销店铺', 'company_name' => '待注销主体', 'store_status' => '0'],
]);
$mirror->shouldReceive('companiesByNames')->andReturn([
'保留主体' => [[
'Id' => 1,
'businessLicenseName' => '保留主体',
'companyStatus' => '0',
'taxCategoryAndType' => '小规模纳税人',
'business_registration_type' => '/',
]],
'待注销主体' => [[
'Id' => 2,
'businessLicenseName' => '待注销主体',
'companyStatus' => '0',
'taxCategoryAndType' => '一般纳税人',
'business_registration_type' => '核定',
]],
]);
$mirror->shouldReceive('companyClassificationsByNames')->andReturn([
'保留主体' => ['status' => '保留', 'tax_type' => '小规模纳税人', 'business_registration_type' => null],
'待注销主体' => ['status' => '待注销', 'tax_type' => '一般纳税人', 'business_registration_type' => '核定'],
]);
$accounts = Mockery::mock(StoreAccountMirrorRepository::class);
$accounts->shouldReceive('ownershipByStoreName')->andReturn([]);
$service = new UpstreamBusinessInformation(
Mockery::mock(UpstreamQueryCacheFactory::class),
$mirror,
$this->repository,
$accounts,
);
$rows = collect($service->paginate('company-management', 'subject-operating-report', 50)->items())->keyBy('entity');
$summaryCards = collect($this->app['request']->attributes->get('business-page-summary-cards'))->keyBy('label');
$this->assertSame('待维护', $rows['保留主体']->business_registration_type);
$this->assertSame('/', $rows['待注销主体']->tax_type);
$this->assertSame('/', $rows['待注销主体']->business_registration_type);
$this->assertSame(1, $summaryCards['正常存续主体']['value']);
$this->assertSame(30.0, (float) $summaryCards['上月(8月)收入']['value']);
}
private function insertBill(string $store, string $occurredOn, float $income): void
{
$this->insertIncome($store, $occurredOn, $income);

View File

@ -160,14 +160,10 @@ class UpstreamBusinessInformationTest extends TestCase
$this->assertNotSame('运营总监', $paginator->items()[0]->department_manager);
$this->assertSame('正常', $paginator->items()[0]->store_status);
$summaryCards = collect($this->app['request']->attributes->get('business-page-summary-cards'))->keyBy('label');
$this->assertSame(1, $summaryCards['店铺总数']['value']);
$this->assertSame(1, $summaryCards['正常店铺']['value']);
$this->assertSame(0, $summaryCards['关闭中']['value']);
$this->assertSame(0, $summaryCards['已关闭']['value']);
$this->assertSame(1, $summaryCards['平台数']['value']);
$this->assertSame([], $summaryCards->all());
}
public function test_company_and_trademark_mirror_summaries_become_clickable_cards(): void
public function test_base_information_pages_do_not_render_summary_cards(): void
{
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
$mirror->shouldReceive('ready')->times(2)->andReturnUsing(
@ -203,16 +199,11 @@ class UpstreamBusinessInformationTest extends TestCase
);
$information->paginate('company-management', 'basic-information', 50);
$companyCards = collect($this->app['request']->attributes->get('business-page-summary-cards'))->keyBy('label');
$this->assertSame(818, $companyCards['公司总数']['value']);
$this->assertSame(['page' => null, 'company_status' => '正常'], $companyCards['正常公司']['query']);
$this->assertSame(['page' => null, 'taxpayer_type' => '一般纳税人'], $companyCards['一般纳税人']['query']);
$this->assertSame([], $companyCards->all());
$information->paginate('trademark-management', 'information', 50);
$trademarkCards = collect($this->app['request']->attributes->get('business-page-summary-cards'))->keyBy('label');
$this->assertSame(593, $trademarkCards['商标总数']['value']);
$this->assertSame(45, $trademarkCards['未启用']['value']);
$this->assertSame(['page' => null, 'trademark_status' => '有效'], $trademarkCards['有效商标']['query']);
$this->assertSame('final_holder', $trademarkCards['最终持有人']['query']['sort']);
$this->assertSame([], $trademarkCards->all());
}
public function test_bootstrap_without_mirror_explains_shared_connection_instead_of_requesting_upstream_credentials(): void
@ -228,7 +219,7 @@ class UpstreamBusinessInformationTest extends TestCase
->paginate('store-management', 'basic-information');
}
public function test_subject_operating_report_hides_income_months_without_data_by_default(): void
public function test_subject_operating_report_keeps_all_income_months_visible_by_default(): void
{
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
$mirror->shouldReceive('ready')->with('store-information.read')->andReturnTrue();
@ -267,9 +258,7 @@ class UpstreamBusinessInformationTest extends TestCase
$paginator = $information->paginate('company-management', 'subject-operating-report', 50);
// income_02 有金额、income_03 为 0 都算有数据,其余空月份默认隐藏。
$this->assertSame(
['income_01', 'income_04', 'income_05', 'income_06', 'income_07', 'income_08', 'income_09', 'income_10', 'income_11', 'income_12'],
$this->assertNull(
$this->app['request']->attributes->get('subject-operating-report.default_hidden_columns'),
);
$this->assertSame(1, $paginator->total());