将安装费金额并入业绩明细 #587

Merged
qiuxiang merged 2 commits from feature/qiuxiang-installation-fee-summary into main 2026-10-26 13:47:03 +08:00
19 changed files with 670 additions and 1469 deletions

View File

@ -12,7 +12,6 @@ use App\Models\Business\CompanyStoreData;
use App\Models\Business\ExpressReceiving;
use App\Models\Business\FundForecast;
use App\Models\Business\InvoiceBasic;
use App\Models\Business\InstallationDetail;
use App\Models\Business\StoreChange;
use App\Models\Business\StoreChangeLog;
use App\Models\Business\StoreClosing;
@ -171,7 +170,6 @@ class TableRegistry
'fund-management/bank-accounts' => BankAccount::class,
'fund-management/shared-company-accounts' => BankAccount::class, // 重用 BankAccount,仅类型过滤
'billing-transactions/store-receiving-balance' => StoreReceivingAccount::class,
'financial-operations/installation-details' => InstallationDetail::class,
'financial-risk-control/process-statistics' => ShopInfoFlow::class,
];
}
@ -261,7 +259,6 @@ class TableRegistry
'store-management/store-accounts',
'store-management/store-report',
'trademark-management/information',
'financial-operations/installation-details',
'financial-risk-control/process-statistics',
], true);
}

View File

@ -7,7 +7,6 @@ use InvalidArgumentException;
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReportRepository;
use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
@ -20,7 +19,6 @@ class UpstreamBusinessInformation
public function __construct(
private readonly UpstreamQueryCacheFactory $caches,
private readonly BusinessInformationMirrorRepository $mirror,
private readonly InstallationDetailMirrorRepository $installationMirror,
// 兼容仍按旧构造方式调用的代码;容器解析时会正常注入仓储。
private readonly ?StoreReportRepository $storeReports = null,
private readonly ?StoreAccountMirrorRepository $storeAccounts = null,
@ -39,7 +37,6 @@ class UpstreamBusinessInformation
'store-management/store-report' => $this->storeReportPage($currentPage, $perPage),
'company-management/subject-operating-report' => $this->subjectOperatingReportPage($currentPage, $perPage),
'trademark-management/information' => $this->trademarkPage($currentPage, $perPage),
'financial-operations/installation-details' => $this->installationPage($currentPage, $perPage),
'financial-risk-control/process-statistics' => $this->shopInfoFlowPage($currentPage, $perPage),
default => ['data' => [], 'total' => 0],
};
@ -76,15 +73,6 @@ class UpstreamBusinessInformation
}
}
private function installationPage(int $page, int $perPage): array
{
$payload = $this->installationMirror->paginate($page, $perPage, InstallationDetailMirrorRepository::requestFilters());
if (! $payload['ready']) {
throw new ServiceUnavailableHttpException(null, '安装明细镜像尚未完成首次同步');
}
return ['data' => $payload['data'], 'total' => $payload['total']];
}
/**
* 将已登记的经营分析副本库查询还原成不经过页面缓存的固定只读查询。
*

View File

@ -22,7 +22,6 @@ use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Models\Business\CourierSigningSubject;
use App\Models\Business\InstallationDetail;
use App\Models\Business\InventoryWarehouseNameMapping;
use App\Models\Business\Todo;
use App\Business\CourierSurchargeAuditLogRepository;
@ -56,7 +55,7 @@ use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\InventoryRepository;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\InstallationFeeSummaryRepository;
use Modules\EcommerceFinance\Repositories\InternalFlowReportRepository;
use Modules\EcommerceFinance\Repositories\JdPromotionAccountBalanceRepository;
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
@ -881,7 +880,7 @@ class Pages extends Controller
public function __construct(
private readonly EdgeBandingRepository $edgeBanding,
private readonly InventoryRepository $inventory,
private readonly InstallationDetailMirrorRepository $installationDetails,
private readonly InstallationFeeSummaryRepository $installationFees,
private readonly NonJingxiRefundMirrorRepository $nonJingxiRefunds,
private readonly SmallPaymentWorkOrderMirrorRepository $smallPaymentWorkOrders,
private readonly ProductCatalogMirrorRepository $productCatalog,
@ -1086,42 +1085,6 @@ class Pages extends Controller
));
}
public function installationDetails(string $guid)
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'installation-details'), 403, '当前账号没有该页面权限。');
$payload = $this->installationDetails->details($guid);
if (! $payload['ready']) {
return response()->json(['message' => '安装明细镜像尚未完成首次同步'], 503);
}
return response()->json(['data' => $payload['data'], 'has_more' => $payload['has_more'] ?? false]);
}
/** 安装明细页:按客服审核时间汇总安装公司的结算、收款明细金额。 */
public function installationDetailsSummary(): \Illuminate\Http\JsonResponse
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'installation-details'), 403, '当前账号没有该页面权限。');
$filters = InstallationDetailMirrorRepository::requestFilters();
if (empty($filters['customer_review_from']) || empty($filters['customer_review_to'])) {
return response()->json(['message' => '请选择完整的客服审核起止日期'], 422);
}
$payload = $this->installationDetails->companySummary($filters);
if (! $payload['ready']) {
return response()->json(['message' => '安装明细镜像尚未完成首次同步'], 503);
}
return response()->json([
'data' => $payload['data'],
'totals' => [
'settled_amount' => $payload['total_settled'],
'received_amount' => $payload['total_received'],
],
], 200, ['Cache-Control' => 'private, no-store']);
}
/**
* 账单明细导出:按页面同一套筛选条件流式导出 CSV(全部筛选结果)。
*/
@ -1289,49 +1252,6 @@ class Pages extends Controller
return 'billing-details-export:' . (int) user()->id . ':' . $token;
}
public function exportInstallationDetails(Request $request): StreamedResponse
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'installation-details'), 403, '当前账号没有该页面权限。');
$filters = InstallationDetailMirrorRepository::requestFilters();
$firstPage = $this->installationDetails->paginate(1, 100, $filters);
abort_if(! $firstPage['ready'], 503, '安装明细镜像尚未完成首次同步');
$labels = InstallationDetail::columnLabels();
return response()->streamDownload(function () use ($filters, $firstPage, $labels): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建安装明细导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_values($labels));
$page = 1;
$result = $firstPage;
do {
foreach ($result['data'] as $row) {
fputcsv($csv, array_map(
fn (string $name): string => $this->csvText($row[$name] ?? ''),
array_keys($labels),
));
}
$page++;
$hasMore = ($page - 1) * 100 < $result['total'];
if ($hasMore) {
$result = $this->installationDetails->paginate($page, 100, $filters);
}
} while ($hasMore);
fclose($csv);
}, '安装明细_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
public function exportBillOffset(Request $request): StreamedResponse
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'order-data');
@ -1588,6 +1508,9 @@ class Pages extends Controller
'difference_total' => round($differenceTotal, 2),
'per_page' => $limit,
];
$installationFee = $this->installationFees->summary(substr($month, 0, 7));
$stats['installation_fee_total'] = $installationFee['total'];
$stats['installation_fee_ready'] = $installationFee['ready'];
// 筛选下拉候选:本月镜像里实际出现的平台与店铺(按 6 小时缓存,避免每次开页做去重扫描),
// 并把当前已选值并入,保证筛选项的文字不会因为换月而消失。
@ -1850,6 +1773,34 @@ class Pages extends Controller
]);
}
/** 业绩明细「安装费金额」明细:按发货月份汇总店铺、安装公司和包安装分类。 */
public function installationFeeMetricDetails(): \Illuminate\Http\JsonResponse
{
[, $month] = $this->receiptOrderMatchMonths();
if ($month === null) {
return response()->json([
'label' => '安装费金额',
'month' => null,
'total' => 0,
'rows' => [],
], 200, ['Cache-Control' => 'private, no-store']);
}
$payload = $this->installationFees->details(substr($month, 0, 7));
if (! $payload['ready']) {
return response()->json([
'message' => '安装费镜像尚未完成首次同步,请稍后重试。',
], 503, ['Cache-Control' => 'private, no-store']);
}
return response()->json([
'label' => '安装费金额',
'month' => $payload['month'],
'total' => $payload['total'],
'rows' => $payload['rows'],
], 200, ['Cache-Control' => 'private, no-store']);
}
/**
* 业绩明细当前筛选条件。指标详情与页面统计共用同一组请求参数。
*

View File

@ -1,78 +0,0 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
class InstallationDetail extends Model
{
protected $table = 'business_installation_details';
protected $fillable = [
'company_id', 'installation_no', 'delete_status', 'quote_status', 'quote_deadline',
'installation_company', 'submitted_at', 'shipped_at', 'buyer_nickname', 'store_name',
'installation_type', 'pre_push_verification', 'push_status', 'push_result', 'arranged_status',
'order_no', 'item_quantity', 'buyer_name', 'buyer_phone', 'notes', 'province', 'city', 'district',
'address', 'requested_amount', 'settled_amount', 'image_urls', 'completed_status',
'customer_service_review', 'customer_service_reviewed_at', 'finance_review', 'finance_reviewed_at',
'actual_install_quantity', 'actual_install_fee', 'actual_install_product_name', 'installer_phone',
'installation_company_notes', 'installation_fee_payer', 'payment_channel', 'payment_transaction_no',
'payment_voucher', 'payment_amount', 'creator', 'related_guid', 'product_id', 'id_owner',
'department_owner', 'auxiliary_spu', 'product_store_code', 'item_no', 'barcode', 'product_name',
'supplier_name', 'detail_requested_amount', 'detail_requested_quantity', 'detail_settled_amount',
'detail_received_amount', 'change_reason', 'source_created_at', 'source_modified_at',
'product_name_1',
];
protected $casts = [
'company_id' => 'integer',
'item_quantity' => 'integer',
'actual_install_quantity' => 'integer',
'detail_requested_quantity' => 'integer',
'requested_amount' => 'decimal:2',
'settled_amount' => 'decimal:2',
'actual_install_fee' => 'decimal:2',
'payment_amount' => 'decimal:2',
'detail_requested_amount' => 'decimal:2',
'detail_settled_amount' => 'decimal:2',
'detail_received_amount' => 'decimal:2',
];
public static function columnLabels(): array
{
return [
'installation_no' => '安装单号', 'delete_status' => '删除状态', 'quote_status' => '报价状态',
'quote_deadline' => '报价截至时间', 'installation_company' => '安装公司', 'submitted_at' => '提交时间',
'shipped_at' => '发货时间', 'buyer_nickname' => '买家昵称', 'store_name' => '店铺名称',
'installation_type' => '类型', 'pre_push_verification' => '推送前验证', 'push_status' => '推送状态',
'push_result' => '推送结果', 'arranged_status' => '是否安排', 'order_no' => '订单编号',
'item_quantity' => '商品数量', 'buyer_name' => '买家姓名', 'buyer_phone' => '买家号码', 'notes' => '备注',
'province' => '省', 'city' => '市', 'district' => '区', 'address' => '地址',
'requested_amount' => '申请总金额', 'settled_amount' => '结算总金额', 'image_urls' => '图片地址',
'completed_status' => '是否完工', 'customer_service_review' => '客服审核',
'customer_service_reviewed_at' => '客服审核时间', 'finance_review' => '财务审核',
'finance_reviewed_at' => '财务审核时间', 'actual_install_quantity' => '实际安装数量',
'actual_install_fee' => '实际安装费用', 'actual_install_product_name' => '实际安装商品名称',
'installer_phone' => '安装师傅电话', 'installation_company_notes' => '安装公司备注',
'installation_fee_payer' => '安装费承担方', 'payment_channel' => '收款渠道',
'payment_transaction_no' => '收款交易单号', 'payment_voucher' => '收款凭证', 'payment_amount' => '收款金额',
'creator' => '创建人', 'related_guid' => '关联guid', 'product_id' => '商品id', 'id_owner' => 'ID负责人',
'department_owner' => '部门负责人', 'auxiliary_spu' => '辅助码Spu', 'product_store_code' => '商品店铺编码',
'item_no' => '货号', 'barcode' => '条形码', 'product_name' => '商品名称', 'supplier_name' => '供应商名称',
'detail_requested_amount' => '申请明细金额', 'detail_requested_quantity' => '申请明细数量',
'detail_settled_amount' => '结算明细金额', 'detail_received_amount' => '收款明细金额',
'change_reason' => '变更原因', 'source_created_at' => '创建时间', 'source_modified_at' => '修改时间',
'product_name_1' => '商品名称(1)',
];
}
public static function fieldTypes(): array
{
return [
'item_quantity' => 'number', 'actual_install_quantity' => 'number', 'detail_requested_quantity' => 'number',
'requested_amount' => 'currency', 'settled_amount' => 'currency', 'actual_install_fee' => 'currency',
'payment_amount' => 'currency', 'detail_requested_amount' => 'currency',
'detail_settled_amount' => 'currency', 'detail_received_amount' => 'currency',
];
}
}

View File

@ -126,8 +126,8 @@ return [
'jiansun.dbo.alibaba_bill' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '阿里巴巴账单'],
'jiansun.dbo.jd_spreadBills' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '京东推广账单'],
'jiansun.dbo.jd_costBills' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '京东钱包账单'],
'jiansun.dbo.fys_Ticketing_install_view' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '安装明细'],
'jiansun.dbo.fys_Ticketing_install_item' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '安装明细详情'],
'jiansun.dbo.fys_Ticketing_install_view' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '业绩明细-安装费金额'],
'jiansun.dbo.fys_Ticketing_install_item' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '业绩明细-安装费明细'],
'jiansun.dbo.finance_purchaseReturn' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '供应商退货对账'],
'jiansun.dbo.finance_invoiceDeduction' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '供应商发票扣款'],
'jiansun.dbo.fys_supplierPaymentSummary' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '供应商应付'],

View File

@ -822,7 +822,7 @@ $navigation = [
'directory' => [
'icon' => 'receipt_long',
'status' => '共享镜像',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」「违约赔付(冲减收入)」分别取对应总账备注的账单净额,不计入收入金额;「退款金额」只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额,只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额+违约赔付(冲减收入))−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的抵减项会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定;同一平台、店铺、交易单号在相邻月份合并收入/优惠/冲减/违约赔付后与订单实付对平的,优先标「差额=跨月收款(合并已对平)」,未对平仍标「差额待核实」;其余规则均未命中且相关金额在 2 分钱内精确对平时,退款及其与抵减项的组合复用「订单实付含已退款部分」,优惠、冲减与违约赔付组合复用「订单实付未减优惠和冲减」;没有可复用的原备注时继续标「差额待核实」。都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」「违约赔付(冲减收入)」分别取对应总账备注的账单净额,不计入收入金额;「退款金额」只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额,只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额+违约赔付(冲减收入))−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的抵减项会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定;同一平台、店铺、交易单号在相邻月份合并收入/优惠/冲减/违约赔付后与订单实付对平的,优先标「差额=跨月收款(合并已对平)」,未对平仍标「差额待核实」;其余规则均未命中且相关金额在 2 分钱内精确对平时,退款及其与抵减项的组合复用「订单实付含已退款部分」,优惠、冲减与违约赔付组合复用「订单实付未减优惠和冲减」;没有可复用的原备注时继续标「差额待核实」。都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。安装费金额按所选月份的发货时间汇总安装明细的结算明细金额;收款渠道为空归为包安装,有值归为不包安装。',
'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。',
'fields' => [
['name' => 'platform', 'label' => '平台'],
@ -870,7 +870,6 @@ $navigation = [
],
],
],
['slug' => 'installation-details', 'title' => '安装明细'],
['slug' => 'promotion-expenses', 'title' => '推广费用'],
['slug' => 'after-sales-expenses', 'title' => '售后费用'],
['slug' => 'small-payment-work-orders', 'title' => '小额打款'],

View File

@ -675,7 +675,7 @@ return [
'jiansun.dbo.alibaba_bill' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'alibaba_bill', 'target' => 'js_alibaba_bill', 'keys' => ['id']],
'jiansun.dbo.jd_spreadBills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jd_spreadBills', 'target' => 'js_jd_spread_bills', 'keys' => ['id'], 'watermark' => 'updateTime'],
'jiansun.dbo.jd_costBills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jd_costBills', 'target' => 'js_jd_cost_bills', 'keys' => ['id'], 'watermark' => 'creatTime'],
// 安装明细页面使用已整理的只读视图,避免在 HTTP 请求中临时 JOIN
// 业绩明细「安装费金额」使用已整理的只读视图,避免在 HTTP 请求中临时 JOIN
// 安装主表和明细表;视图中的安装字段与 Excel 模板字段保持稳定别名。
'jiansun.dbo.fys_Ticketing_install_view' => [
'source_key' => 'jiansun',

View File

@ -1,371 +0,0 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
/** 只读安装明细镜像;页面不得回退直连 JianSun。 */
class InstallationDetailMirrorRepository
{
public const DATASET = 'jiansun.dbo.fys_Ticketing_install_view';
public const ITEM_DATASET = 'jiansun.dbo.fys_Ticketing_install_item';
private bool $minimumShipTimeResolved = false;
private ?string $resolvedMinimumShipTime = null;
public function __construct(
private readonly GenericBusinessMirrorRepository $mirror,
private readonly BusinessMirrorDatasetRegistry $datasets,
)
{
}
/** @return array{data:list<array<string,mixed>>,total:int,ready:bool} */
public function paginate(int $page, int $perPage, array $filters = []): array
{
$connection = $this->mirror->connection();
$dataset = $this->datasets->get(self::DATASET);
$items = $this->datasets->get(self::ITEM_DATASET);
if (! $this->ready($dataset, $items, $connection->getName())) {
return ['data' => [], 'total' => 0, 'ready' => false];
}
$perPage = min(max($perPage, 1), 100);
// 主视图按商品行展开,同一安装单会出现多行;主表只保留每个
// SourceGuid 的最新 Id,商品行通过 details() 单独展开。
$latest = $connection->table($dataset->targetTable())
->selectRaw('MAX(Id) AS id')
->groupBy('SourceGuid');
$this->applyShipmentCutoff($latest);
$query = $connection->table($dataset->targetTable() . ' AS v')
->joinSub($latest, 'latest', static fn ($join) => $join->on('latest.id', '=', 'v.Id'))
->select('v.*');
$this->applyFilters($query, $filters, $items->targetTable());
$total = (clone $query)->count();
$rows = $query->orderByDesc('v.Id')->forPage(max($page, 1), $perPage)->get();
return ['data' => $rows->map(fn (object $row): array => $this->map((array) $row))->all(), 'total' => $total, 'ready' => true];
}
/** @return array{data:list<array<string,mixed>>,ready:bool} */
public function details(string $guid): array
{
$dataset = $this->datasets->get(self::DATASET);
$items = $this->datasets->get(self::ITEM_DATASET);
$connection = $this->mirror->connection();
if (! $this->ready($dataset, $items, $connection->getName())) {
return ['data' => [], 'ready' => false];
}
$mainExists = $connection->table($dataset->targetTable())
->where('SourceGuid', $guid);
$this->applyShipmentCutoff($mainExists);
$mainExists = $mainExists->exists();
if (! $mainExists) {
return ['data' => [], 'ready' => true];
}
$transactionNo = $connection->table($dataset->targetTable())
->where('SourceGuid', $guid)
->orderByDesc('Id')->value('OrderNum');
$rows = $connection->table($items->targetTable())
->where('guid', $guid)
->orderBy('Id')
->limit(101)
->get();
return ['data' => $rows->take(100)->map(fn (object $row): array => $this->mapItem((array) $row, $transactionNo))->all(), 'ready' => true, 'has_more' => $rows->count() > 100];
}
private function ready($dataset, $items, string $connection): bool
{
return $this->mirror->ready($dataset) && $this->mirror->ready($items)
&& Schema::connection($connection)->hasTable($dataset->targetTable())
&& Schema::connection($connection)->hasTable($items->targetTable());
}
private function applyShipmentCutoff($query): void
{
$minimumShipTime = $this->minimumShipTime();
if ($minimumShipTime !== null) {
$query->whereNotNull('ShipTIme')->where('ShipTIme', '>=', $minimumShipTime);
}
}
private function minimumShipTime(): ?string
{
if (! $this->minimumShipTimeResolved) {
$this->resolvedMinimumShipTime = $this->datasets->minimumDatetime(self::DATASET)['value'] ?? null;
$this->minimumShipTimeResolved = true;
}
return $this->resolvedMinimumShipTime;
}
/** @return array{rows:int,requested:float,settled:float,paid:float} */
public function summary(array $filters = []): array
{
$dataset = $this->datasets->get(self::DATASET);
$connection = $this->mirror->connection();
if (! $this->mirror->ready($dataset) || ! Schema::connection($connection->getName())->hasTable($dataset->targetTable())) {
return ['rows' => 0, 'requested' => 0.0, 'settled' => 0.0, 'paid' => 0.0];
}
$latest = $connection->table($dataset->targetTable())
->selectRaw('MAX(Id) AS id')
->groupBy('SourceGuid');
$this->applyShipmentCutoff($latest);
$query = $connection->table($dataset->targetTable() . ' AS v')
->joinSub($latest, 'latest', static fn ($join) => $join->on('latest.id', '=', 'v.Id'));
$this->applyFilters($query, $filters, $this->datasets->get(self::ITEM_DATASET)->targetTable());
$row = $query
->selectRaw('COUNT(*) AS row_count, COALESCE(SUM(v.installcost),0) AS requested, COALESCE(SUM(v.settleAmount),0) AS settled, COALESCE(SUM(v.acceptAmount),0) AS paid')
->first();
return ['rows' => (int) $row->row_count, 'requested' => (float) $row->requested, 'settled' => (float) $row->settled, 'paid' => (float) $row->paid];
}
/**
* 按客服审核时间直接从安装明细子表汇总,并用 SourceGuid 取得最新主表的安装公司。
* 不能先按主表 kfshTime 过滤,否则主表日期更新滞后时会漏掉已同步的明细。
*
* @return array{ready:bool,data:list<array{installation_company:string,settled_amount:float,received_amount:float}>,total_settled:float,total_received:float}
*/
public function companySummary(array $filters = []): array
{
$dataset = $this->datasets->get(self::DATASET);
$items = $this->datasets->get(self::ITEM_DATASET);
$connection = $this->mirror->connection();
if (! $this->ready($dataset, $items, $connection->getName())) {
return ['ready' => false, 'data' => [], 'total_settled' => 0.0, 'total_received' => 0.0];
}
$from = trim((string) ($filters['customer_review_from'] ?? ''));
$to = trim((string) ($filters['customer_review_to'] ?? ''));
if ($from === '' && $to === '') {
return ['ready' => true, 'data' => [], 'total_settled' => 0.0, 'total_received' => 0.0];
}
$query = $connection->table($items->targetTable() . ' AS i')
->whereNotNull('i.kfshTime');
$minimumShipTime = $this->minimumShipTime();
if ($minimumShipTime !== null) {
$mainTable = $dataset->targetTable();
$query->whereExists(function ($sub) use ($mainTable, $minimumShipTime): void {
$sub->selectRaw('1')
->from($mainTable . ' AS retained_main')
->whereColumn('retained_main.SourceGuid', 'i.guid')
->whereNotNull('retained_main.ShipTIme')
->where('retained_main.ShipTIme', '>=', $minimumShipTime);
});
}
if ($from !== '') {
$query->where('i.kfshTime', '>=', $from);
}
if ($to !== '') {
$query->where('i.kfshTime', '<', \Illuminate\Support\Carbon::parse($to)->addDay()->toDateString());
}
$itemTotals = $query
->selectRaw('i.guid AS guid')
->selectRaw('COALESCE(SUM(CAST(i.settleItemAmount AS DECIMAL(18,2))), 0) AS settled_amount')
->selectRaw('COALESCE(SUM(CAST(i.acceptItemAmount AS DECIMAL(18,2))), 0) AS received_amount')
->groupBy('i.guid')
->get();
$companyByGuid = [];
$guids = $itemTotals->pluck('guid')
->filter(static fn (mixed $guid): bool => trim((string) $guid) !== '')
->unique()
->values()
->all();
foreach (array_chunk($guids, 1000) as $guidChunk) {
$companyQuery = $connection->table($dataset->targetTable())
->whereIn('SourceGuid', $guidChunk)
->select(['Id', 'SourceGuid', 'installCompanyName'])
->orderBy('Id');
$this->applyShipmentCutoff($companyQuery);
$companyQuery
->get()
->each(static function (object $row) use (&$companyByGuid): void {
$companyByGuid[(string) $row->SourceGuid] = trim((string) ($row->installCompanyName ?? ''));
});
}
$companyTotals = [];
foreach ($itemTotals as $item) {
$company = $companyByGuid[(string) $item->guid] ?? '';
$company = $company !== '' ? $company : '未填写安装公司';
$companyTotals[$company] ??= ['settled_amount' => 0.0, 'received_amount' => 0.0];
$companyTotals[$company]['settled_amount'] += (float) $item->settled_amount;
$companyTotals[$company]['received_amount'] += (float) $item->received_amount;
}
$rows = [];
foreach ($companyTotals as $company => $amounts) {
$rows[] = [
'installation_company' => $company,
'settled_amount' => round($amounts['settled_amount'], 2),
'received_amount' => round($amounts['received_amount'], 2),
];
}
usort($rows, static fn (array $left, array $right): int =>
[$right['settled_amount'], $left['installation_company']]
<=> [$left['settled_amount'], $right['installation_company']]
);
return [
'ready' => true,
'data' => $rows,
'total_settled' => round(array_sum(array_column($rows, 'settled_amount')), 2),
'total_received' => round(array_sum(array_column($rows, 'received_amount')), 2),
];
}
public static function requestFilters(): array
{
$rules = [];
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) {
$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();
}
private function applyFilters($query, array $filters, string $itemTable): void
{
foreach (['created'=>'SubmitTime', 'customer_review'=>'kfshTime', 'finance_review'=>'cwshTime'] as $prefix=>$column) {
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) {
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) {
if (!isset($filters[$key]) || $filters[$key] === '') continue;
if ((string) $filters[$key] === '1') {
$query->where('v.'.$column, 1);
} else {
$query->where(function ($sub) use ($column): void {
$sub->whereNull('v.'.$column)->orWhere('v.'.$column, 0);
});
}
}
if (isset($filters['arranged_status']) && $filters['arranged_status'] !== '') {
if ($filters['arranged_status'] === '1') {
$query->where('v.StatusId', '>', 0);
} else {
$query->where(function ($sub): void {
$sub->whereNull('v.StatusId')->orWhere('v.StatusId', '<=', 0);
});
}
}
if (!empty($filters['product_id'])) {
$query->whereExists(function ($sub) use ($itemTable, $filters) {
$sub->selectRaw('1')->from($itemTable.' AS filter_item')
->whereColumn('filter_item.guid', 'v.SourceGuid')->where('filter_item.shopproid', $filters['product_id']);
});
}
}
/** @param array<string,mixed> $row */
private function map(array $row): array
{
$value = static function (array $row, string ...$names): mixed {
foreach ($names as $name) {
foreach ($row as $key => $value) if (strcasecmp((string) $key, $name) === 0) return $value;
}
return null;
};
$mapped = [
'id' => $value($row, 'detail_id', 'Id'), 'installation_no' => $value($row, 'ordernum_id', 'OrderNum'),
'delete_status' => $value($row, 'Delete'), 'quote_status' => $value($row, 'BidStatus'), 'quote_deadline' => $value($row, 'BidEndTime'),
'installation_company' => $value($row, 'installCompanyName'), 'submitted_at' => $value($row, 'SubmitTime'),
'shipped_at' => $value($row, 'ShipTIme'), 'buyer_nickname' => $value($row, 'BuyersNick'),
'store_name' => $value($row, 'shop_name', 'shopname'), 'installation_type' => $value($row, 'TypeDto'),
'pre_push_verification' => $value($row, 'checkMsg'), 'push_status' => $value($row, 'apistatus'),
'push_result' => $value($row, 'apiresult'), 'arranged_status' => $this->arranged($value($row, 'StatusId')),
'order_no' => $value($row, 'OrderNum'),
'item_quantity' => $value($row, 'Num'), 'buyer_name' => $value($row, 'BuyersName'), 'buyer_phone' => $value($row, 'Mobile'),
'notes' => $value($row, 'Nots'), 'province' => $value($row, 'Province'), 'city' => $value($row, 'City'),
'district' => $value($row, 'Area'), 'address' => $value($row, 'Address'), 'requested_amount' => $value($row, 'installcost'),
'settled_amount' => $value($row, 'settleAmount'), 'image_urls' => $value($row, 'Picture'),
'completed_status' => $this->yesNo($value($row, 'installComplete')),
'customer_service_review' => $this->review($value($row, 'kfsh')), 'customer_service_reviewed_at' => $value($row, 'kfshTime'),
'finance_review' => $this->review($value($row, 'cwsh')), 'finance_reviewed_at' => $value($row, 'cwshTime'),
'actual_install_quantity' => $value($row, 'installNum'), 'actual_install_fee' => $value($row, 'install_Cost'),
'actual_install_product_name' => $value($row, 'installGoodsName'), 'installer_phone' => $value($row, 'installTel'),
'installation_company_notes' => $value($row, 'installNote'), 'installation_fee_payer' => $value($row, 'costtaker'),
'payment_channel' => $value($row, 'costWay'), 'payment_transaction_no' => $value($row, 'actporderNum'),
'payment_voucher' => $value($row, 'costWayVoucher'), 'payment_amount' => $value($row, 'acceptAmount'),
'creator' => $value($row, 'Operation'), 'related_guid' => $value($row, 'detail_guid', 'SourceGuid'),
'product_id' => $value($row, 'shopproid'), 'auxiliary_spu' => $value($row, 'Spu'),
'product_store_code' => $value($row, 'shopskucode'), 'item_no' => $value($row, 'proCode'),
'barcode' => $value($row, 'barCode'), 'product_name' => $value($row, 'skuName'), 'supplier_name' => $value($row, 'scmname'),
'detail_requested_amount' => $value($row, 'applyItemAmount'), 'detail_requested_quantity' => $value($row, 'installcount'),
'detail_settled_amount' => $value($row, 'settleItemAmount'), 'detail_received_amount' => $value($row, 'acceptItemAmount'),
'change_reason' => $value($row, 'remark'), 'source_created_at' => $value($row, 'item_created_at'),
'source_modified_at' => $value($row, 'item_modified_at'),
];
// 页面按模板的完整 61 列渲染;视图暂未提供的字段必须显式为 null,
// 不能让 Blade 读取未定义属性。
return array_replace(array_fill_keys(array_keys(\App\Models\Business\InstallationDetail::columnLabels()), null), $mapped);
}
/** @param array<string,mixed> $row */
private function mapItem(array $row, mixed $transactionNo = null): array
{
$value = static function (array $row, string ...$names): mixed {
foreach ($names as $name) {
foreach ($row as $key => $value) {
if (strcasecmp((string) $key, $name) === 0) {
return $value;
}
}
}
return null;
};
return [
'transaction_no' => $value($row, 'Sourceno', 'source_no', 'orderNum', 'ordernum') ?? $transactionNo,
'product_id' => $value($row, 'shopproid'),
'id_owner' => $value($row, 'id_owner', 'idOwner'),
'department_owner' => $value($row, 'department_owner', 'departmentOwner'),
'auxiliary_spu' => $value($row, 'Spu'),
'product_store_code' => $value($row, 'shopskucode'),
'item_no' => $value($row, 'proCode'),
'barcode' => $value($row, 'barCode'),
'product_name' => $value($row, 'skuName'),
'supplier_name' => $value($row, 'scmname'),
'detail_requested_amount' => $value($row, 'applyItemAmount'),
'detail_requested_quantity' => $value($row, 'installcount'),
'detail_settled_amount' => $value($row, 'settleItemAmount'),
'detail_received_amount' => $value($row, 'acceptItemAmount'),
'change_reason' => $value($row, 'remark'),
'source_created_at' => $value($row, 'createTime'),
'source_modified_at' => $value($row, 'modifyTime'),
];
}
private function yesNo(mixed $value): string
{
return (int) $value === 1 ? '是' : '否';
}
private function review(mixed $value): string
{
return (int) $value === 1 ? '已审核' : '未审核';
}
private function arranged(mixed $value): string
{
return (int) $value > 0 ? '已安排' : '未安排';
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use InvalidArgumentException;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
/** 业绩明细「安装费金额」:按发货月份汇总安装结算明细。 */
class InstallationFeeSummaryRepository
{
public const MAIN_DATASET = 'jiansun.dbo.fys_Ticketing_install_view';
public const ITEM_DATASET = 'jiansun.dbo.fys_Ticketing_install_item';
private const CACHE_MINUTES = 5;
public function __construct(
private readonly GenericBusinessMirrorRepository $mirror,
private readonly BusinessMirrorDatasetRegistry $datasets,
) {
}
/** @return array{ready:bool,month:string,total:float,row_count:int} */
public function summary(string $month): array
{
[$dataset, $items] = $this->datasetsForPeriod($month);
if (! $this->ready($dataset, $items)) {
return ['ready' => false, 'month' => $month, 'total' => 0.0, 'row_count' => 0];
}
return Cache::remember(
'installation-fee-summary:v1:' . $month,
now()->addMinutes(self::CACHE_MINUTES),
function () use ($dataset, $items, $month): array {
[$from, $to] = $this->monthRange($month);
$row = $this->baseQuery($dataset->targetTable(), $items->targetTable(), $from, $to)
->selectRaw('COALESCE(SUM(CAST(i.settleItemAmount AS DECIMAL(18,2))), 0) AS total')
->selectRaw('COUNT(*) AS row_count')
->first();
return [
'ready' => true,
'month' => $month,
'total' => round((float) ($row->total ?? 0), 2),
'row_count' => (int) ($row->row_count ?? 0),
];
},
);
}
/**
* @return array{ready:bool,month:string,total:float,rows:list<array{store_name:string,installation_company:string,payment_channel:string,amount:float}>}
*/
public function details(string $month): array
{
[$dataset, $items] = $this->datasetsForPeriod($month);
if (! $this->ready($dataset, $items)) {
return ['ready' => false, 'month' => $month, 'total' => 0.0, 'rows' => []];
}
return Cache::remember(
'installation-fee-details:v1:' . $month,
now()->addMinutes(self::CACHE_MINUTES),
function () use ($dataset, $items, $month): array {
[$from, $to] = $this->monthRange($month);
$rows = $this->baseQuery($dataset->targetTable(), $items->targetTable(), $from, $to)
->selectRaw("COALESCE(NULLIF(TRIM(v.shop_name), ''), '未填写店铺') AS store_name")
->selectRaw("COALESCE(NULLIF(TRIM(v.installCompanyName), ''), '未填写安装公司') AS installation_company")
->selectRaw("CASE WHEN v.costWay IS NULL OR TRIM(v.costWay) = '' THEN '包安装' ELSE '不包安装' END AS payment_channel")
->selectRaw('ROUND(SUM(CAST(i.settleItemAmount AS DECIMAL(18,2))), 2) AS amount')
->groupByRaw(
"COALESCE(NULLIF(TRIM(v.shop_name), ''), '未填写店铺'),"
. " COALESCE(NULLIF(TRIM(v.installCompanyName), ''), '未填写安装公司'),"
. " CASE WHEN v.costWay IS NULL OR TRIM(v.costWay) = '' THEN '包安装' ELSE '不包安装' END"
)
->havingRaw('ABS(SUM(CAST(i.settleItemAmount AS DECIMAL(18,2)))) > 0.004')
->get()
->map(static fn (object $row): array => [
'store_name' => trim((string) ($row->store_name ?? '')),
'installation_company' => trim((string) ($row->installation_company ?? '')),
'payment_channel' => (string) ($row->payment_channel ?? '包安装'),
'amount' => round((float) ($row->amount ?? 0), 2),
])
->sort(static fn (array $left, array $right): int =>
[abs($right['amount']), $left['store_name'], $left['installation_company'], $left['payment_channel']]
<=> [abs($left['amount']), $right['store_name'], $right['installation_company'], $right['payment_channel']]
)
->values()
->all();
return [
'ready' => true,
'month' => $month,
'total' => round(array_sum(array_column($rows, 'amount')), 2),
'rows' => $rows,
];
},
);
}
/** @return array{0:\Modules\EcommerceFinance\Contracts\BusinessMirrorDataset,1:\Modules\EcommerceFinance\Contracts\BusinessMirrorDataset} */
private function datasetsForPeriod(string $month): array
{
$this->monthRange($month);
return [
$this->datasets->get(self::MAIN_DATASET),
$this->datasets->get(self::ITEM_DATASET),
];
}
/** @return array{0:string,1:string} */
private function monthRange(string $month): array
{
$from = Carbon::createFromFormat('!Y-m', $month)->startOfMonth();
if ($from === false || $from->format('Y-m') !== $month) {
throw new InvalidArgumentException('安装费月份格式无效。');
}
return [$from->toDateTimeString(), $from->copy()->addMonth()->toDateTimeString()];
}
private function ready($dataset, $items): bool
{
$connection = $this->mirror->connection();
$schema = Schema::connection($connection->getName());
return $this->mirror->ready($dataset)
&& $this->mirror->ready($items)
&& $schema->hasTable($dataset->targetTable())
&& $schema->hasTable($items->targetTable());
}
private function baseQuery(string $mainTable, string $itemTable, string $from, string $to)
{
$connection = $this->mirror->connection();
$latest = $connection->table($mainTable)
->selectRaw('MAX(Id) AS id')
->groupBy('SourceGuid');
return $connection->table($mainTable . ' AS v')
->joinSub($latest, 'latest_install', static fn ($join) => $join->on('latest_install.id', '=', 'v.Id'))
->join($itemTable . ' AS i', 'i.guid', '=', 'v.SourceGuid')
->whereNotNull('v.ShipTIme')
->where('v.ShipTIme', '>=', $from)
->where('v.ShipTIme', '<', $to);
}
}

View File

@ -1454,6 +1454,7 @@
@php
$receiptMetricCards = [
['metric' => 'income_amount', 'label' => '账单收入', 'value' => $receiptStats['income_total']],
['metric' => 'installation_fee_amount', 'label' => '安装费金额', 'value' => $receiptStats['installation_fee_total'] ?? 0, 'installation_fee' => true],
['metric' => 'discount_amount', 'label' => '优惠收入', 'value' => $receiptStats['discount_total'] ?? 0],
['metric' => 'offset_amount', 'label' => '冲减收入', 'value' => $receiptStats['offset_total'] ?? 0],
['metric' => 'default_compensation_amount', 'label' => '违约赔付', 'value' => $receiptStats['default_compensation_total'] ?? 0],
@ -1467,23 +1468,39 @@
<div class="receipt-metric-board__head">
<div>
<strong>金额指标</strong>
<span>点击任一指标,查看平台、店铺与账单项目明细;明细合计会与指标总额自动核对。</span>
<span>点击任一指标查看明细汇总;安装费金额按所选月份的发货时间归集。</span>
</div>
<button type="button" class="receipt-metric-rules" data-receipt-rules-open>口径说明</button>
</div>
<div class="receipt-metric-grid">
@foreach ($receiptMetricCards as $metricCard)
<button
type="button"
class="receipt-metric-card"
data-receipt-metric="{{ $metricCard['metric'] }}"
data-receipt-metric-label="{{ $metricCard['label'] }}"
aria-label="查看{{ $metricCard['label'] }}明细"
>
<span class="receipt-metric-card__label">{{ $metricCard['label'] }}</span>
<strong class="receipt-metric-card__value">{{ number_format((float) $metricCard['value'] / 10000, 2) }}<small>万</small></strong>
<span class="receipt-metric-card__action">查看明细 →</span>
</button>
@php
$isInstallationFee = !empty($metricCard['installation_fee']);
@endphp
@if ($isInstallationFee)
<button
type="button"
class="receipt-metric-card"
data-installation-fee-open
aria-label="查看{{ $metricCard['label'] }}明细"
>
<span class="receipt-metric-card__label">{{ $metricCard['label'] }}</span>
<strong class="receipt-metric-card__value">{{ number_format((float) $metricCard['value'] / 10000, 2) }}<small>万</small></strong>
<span class="receipt-metric-card__action">查看明细 →</span>
</button>
@else
<button
type="button"
class="receipt-metric-card"
data-receipt-metric="{{ $metricCard['metric'] }}"
data-receipt-metric-label="{{ $metricCard['label'] }}"
aria-label="查看{{ $metricCard['label'] }}明细"
>
<span class="receipt-metric-card__label">{{ $metricCard['label'] }}</span>
<strong class="receipt-metric-card__value">{{ number_format((float) $metricCard['value'] / 10000, 2) }}<small>万</small></strong>
<span class="receipt-metric-card__action">查看明细 →</span>
</button>
@endif
@endforeach
</div>
</section>
@ -1617,6 +1634,61 @@
</div>
</dialog>
<dialog class="receipt-metric-dialog" id="installation-fee-dialog" aria-labelledby="installation-fee-dialog-title">
<div class="receipt-metric-dialog__head">
<div>
<h2 id="installation-fee-dialog-title">安装费金额 · 明细汇总</h2>
<p data-installation-fee-subtitle>按发货时间汇总店铺、安装公司和包安装分类</p>
</div>
<div class="receipt-metric-dialog__head-side">
<div class="receipt-metric-dialog__total">
<span>当前筛选合计(元)</span>
<strong data-installation-fee-total>¥0.00</strong>
</div>
<button type="button" class="receipt-dialog-close" data-installation-fee-close aria-label="关闭安装费明细">×</button>
</div>
</div>
<div class="receipt-metric-filters" aria-label="安装费明细筛选">
<label class="receipt-metric-filter">
<span>店铺名称</span>
<select data-installation-fee-store aria-label="筛选店铺名称">
<option value="">全部店铺</option>
</select>
</label>
<label class="receipt-metric-filter">
<span>安装公司</span>
<select data-installation-fee-company aria-label="筛选安装公司">
<option value="">全部安装公司</option>
</select>
</label>
<label class="receipt-metric-filter">
<span>收款渠道</span>
<select data-installation-fee-channel aria-label="筛选收款渠道">
<option value="">全部渠道</option>
</select>
</label>
</div>
<div class="receipt-metric-dialog__scroll">
<section class="receipt-metric-panel">
<table class="receipt-metric-table">
<thead>
<tr>
<th scope="col">店铺名称</th>
<th scope="col">安装公司</th>
<th scope="col">收款渠道</th>
<th scope="col" class="is-number">结算明细金额</th>
</tr>
</thead>
<tbody data-installation-fee-rows></tbody>
</table>
<div class="receipt-metric-dialog__empty" data-installation-fee-empty>正在读取安装费明细…</div>
</section>
</div>
<div class="receipt-metric-dialog__foot">
<span data-installation-fee-count>共 0 条明细汇总</span>
</div>
</dialog>
<dialog class="receipt-rules-dialog" id="receipt-rules-dialog" aria-labelledby="receipt-rules-dialog-title">
<div class="receipt-rules-dialog__head">
<div>
@ -2660,6 +2732,161 @@
}
})();
</script>
<script>
(function () {
var card = document.querySelector('[data-installation-fee-open]');
var dialog = document.getElementById('installation-fee-dialog');
if (!card || !dialog) { return; }
var endpoint = @json(route('business.financial-operations.performance-details.installation-fee-details'));
var subtitle = dialog.querySelector('[data-installation-fee-subtitle]');
var totalEl = dialog.querySelector('[data-installation-fee-total]');
var storeFilter = dialog.querySelector('[data-installation-fee-store]');
var companyFilter = dialog.querySelector('[data-installation-fee-company]');
var channelFilter = dialog.querySelector('[data-installation-fee-channel]');
var rowsEl = dialog.querySelector('[data-installation-fee-rows]');
var emptyEl = dialog.querySelector('[data-installation-fee-empty]');
var countEl = dialog.querySelector('[data-installation-fee-count]');
var allRows = [];
function money(value) {
return '¥' + Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
function openDialog() {
if (typeof dialog.showModal === 'function') {
dialog.showModal();
} else {
dialog.setAttribute('open', '');
}
}
function closeDialog() {
if (typeof dialog.close === 'function') {
dialog.close();
} else {
dialog.removeAttribute('open');
}
}
function fillFilter(select, placeholder, values) {
select.replaceChildren();
var all = document.createElement('option');
all.value = '';
all.textContent = placeholder;
select.appendChild(all);
values.forEach(function (value) {
var option = document.createElement('option');
option.value = value;
option.textContent = value;
select.appendChild(option);
});
select.value = '';
}
function distinct(key) {
return Array.from(new Set(allRows.map(function (row) {
return String(row[key] || '').trim();
}).filter(Boolean))).sort(function (left, right) {
return left.localeCompare(right, 'zh-CN');
});
}
function render() {
var store = storeFilter.value;
var company = companyFilter.value;
var channel = channelFilter.value;
var rows = allRows.filter(function (row) {
return (!store || row.store_name === store)
&& (!company || row.installation_company === company)
&& (!channel || row.payment_channel === channel);
});
var total = rows.reduce(function (sum, row) {
return sum + Number(row.amount || 0);
}, 0);
rowsEl.replaceChildren();
rows.forEach(function (row) {
var tr = document.createElement('tr');
[row.store_name || '—', row.installation_company || '—', row.payment_channel || '—'].forEach(function (value) {
var td = document.createElement('td');
td.textContent = value;
tr.appendChild(td);
});
var amount = document.createElement('td');
amount.className = 'is-number';
amount.textContent = money(row.amount);
tr.appendChild(amount);
rowsEl.appendChild(tr);
});
totalEl.textContent = money(total);
countEl.textContent = '共 ' + rows.length.toLocaleString('zh-CN') + ' 条明细汇总';
emptyEl.hidden = rows.length > 0;
emptyEl.textContent = allRows.length
? '当前筛选条件下没有安装费明细。'
: '当前月份按发货时间没有安装费明细。';
}
function load() {
openDialog();
dialog.setAttribute('aria-busy', 'true');
allRows = [];
rowsEl.replaceChildren();
emptyEl.hidden = false;
emptyEl.textContent = '正在读取安装费明细…';
totalEl.textContent = '…';
countEl.textContent = '正在读取安装费明细';
var url = new URL(endpoint, window.location.origin);
new URLSearchParams(window.location.search).forEach(function (value, key) {
if (key === 'month') { url.searchParams.set(key, value); }
});
window.fetch(url.toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.json().catch(function () { return {}; }).then(function (data) {
throw new Error(data.message || '安装费明细读取失败。');
});
}
return response.json();
}).then(function (data) {
allRows = Array.isArray(data.rows) ? data.rows : [];
subtitle.textContent = data.month
? data.month + ' · 按发货时间汇总店铺、安装公司和包安装分类'
: '按发货时间汇总店铺、安装公司和包安装分类';
fillFilter(storeFilter, '全部店铺', distinct('store_name'));
fillFilter(companyFilter, '全部安装公司', distinct('installation_company'));
fillFilter(channelFilter, '全部渠道', distinct('payment_channel'));
render();
dialog.removeAttribute('aria-busy');
}).catch(function (error) {
allRows = [];
rowsEl.replaceChildren();
emptyEl.hidden = false;
emptyEl.textContent = error.message || '安装费明细读取失败,请稍后重试。';
totalEl.textContent = '—';
countEl.textContent = '未能读取安装费明细';
dialog.removeAttribute('aria-busy');
});
}
card.addEventListener('click', load);
[storeFilter, companyFilter, channelFilter].forEach(function (select) {
select.addEventListener('change', render);
});
dialog.querySelector('[data-installation-fee-close]').addEventListener('click', closeDialog);
dialog.addEventListener('click', function (event) {
if (event.target === dialog) { closeDialog(); }
});
})();
</script>
@elseif (in_array(($page['slug'] ?? ''), ['inventory-report', 'monthly-weighted-average-price'], true))
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="进销存报表筛选查询栏">
<label class="installation-filter-field">

View File

@ -238,129 +238,11 @@
font-size: 0.75rem;
}
.installation-date-filter {
display: flex;
grid-column: span 2;
align-items: center;
gap: 0.4rem;
min-width: 0;
color: #595959;
font-size: 0.75rem;
white-space: nowrap;
}
.installation-date-filter__label {
flex: 0 0 auto;
}
.installation-date-filter__range {
flex: 1 1 auto;
min-width: 0;
}
.installation-date-filter .upstream-data-date input {
height: 2.1rem;
font-size: 0.75rem;
}
.installation-date-range-error {
grid-column: 1 / -1;
margin: 0;
color: #b80000;
font-size: 0.75rem;
}
.installation-date-range-error[hidden] {
display: none;
}
.installation-summary {
grid-column: span 3;
min-width: 0;
align-self: stretch;
overflow: hidden;
border: 1px solid #d8d2c7;
border-radius: 0.5rem;
background: #ffffff;
}
.installation-summary__table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
}
.installation-summary__table th,
.installation-summary__table td {
padding: 0.32rem 0.55rem;
border-bottom: 1px solid #eee8dd;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.installation-summary__table thead th {
background: #f7f0de;
color: #595959;
font-weight: 600;
text-align: left;
}
.installation-summary__table th:not(:first-child),
.installation-summary__table td:not(:first-child) {
text-align: right;
}
.installation-summary__table tbody td {
color: #171717;
}
.installation-summary__table tfoot th,
.installation-summary__table tfoot td {
background: #fcfbf8;
color: #171717;
font-weight: 700;
}
.installation-summary__table tfoot td {
text-align: right;
}
.installation-summary__table tbody tr:last-child td,
.installation-summary__table tfoot tr:last-child th {
border-bottom: 0;
}
.installation-summary__empty {
color: #7a746b;
text-align: center !important;
}
.installation-summary__table[aria-busy="true"] tbody {
opacity: 0.56;
}
.installation-filter-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (max-width: 1100px) {
.installation-filter-bar { grid-template-columns: repeat(2, minmax(14rem, 1fr)); }
.installation-date-filter { grid-column: 1 / -1; }
.installation-summary { grid-column: 1 / -1; }
}
@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-summary { grid-column: 1 / -1; }
}
.biz-card-head {
@ -420,113 +302,6 @@
background: #f9fafb;
}
.installation-main-row {
cursor: pointer;
}
.installation-details-table-wrap {
overflow-x: auto;
}
.installation-details-table {
width: max-content;
min-width: 100%;
table-layout: auto;
font-size: 0.625rem;
}
.installation-details-table thead th {
height: auto;
padding: 0.55rem 0.4rem;
writing-mode: horizontal-tb;
vertical-align: middle;
line-height: 1.25;
letter-spacing: 0;
white-space: nowrap;
}
.installation-details-table tbody td {
max-width: 8rem;
padding: 0.35rem 0.4rem;
overflow: hidden;
vertical-align: middle;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.installation-details-table .biz-badge {
padding: 0.05rem 0.15rem;
border-radius: 0.2rem;
font-size: 0.5625rem;
line-height: 1.2;
white-space: normal;
}
.installation-details-table th:first-child,
.installation-details-table td:first-child {
min-width: 7rem;
width: auto;
}
.installation-expand {
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid #d8d2c7;
border-radius: 0.375rem;
background: #ffffff;
color: #856632;
cursor: pointer;
}
.installation-detail-row td {
padding: 0.75rem 1rem 1rem 3rem;
background: #fbfaf7;
}
.installation-detail-table {
width: max-content;
min-width: 100%;
table-layout: auto;
border-collapse: collapse;
font-size: 0.625rem;
}
.installation-detail-table th,
.installation-detail-table td {
padding: 0.35rem 0.4rem;
border: 1px solid #e5e0d7;
white-space: nowrap;
vertical-align: middle;
text-align: left;
}
.installation-detail-table th {
background: #f3efe6;
color: #595959;
font-weight: 600;
height: auto;
writing-mode: horizontal-tb;
line-height: 1.25;
}
.installation-detail-table td {
max-width: 9rem;
overflow: hidden;
text-overflow: ellipsis;
}
.installation-detail-empty,
.installation-detail-error {
color: #595959;
padding: 0.5rem 0;
}
.installation-detail-error {
color: #b80000;
}
.biz-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
@ -841,265 +616,6 @@
@include($pageView)
@elseif ($descriptor)
<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>
<div
class="installation-summary"
data-installation-summary
data-url="{{ route('business.installation-details.summary') }}"
aria-label="客服审核时间金额汇总"
>
<table class="installation-summary__table" aria-busy="false">
<colgroup>
<col style="width: 38%">
<col style="width: 31%">
<col style="width: 31%">
</colgroup>
<thead>
<tr>
<th scope="col">安装公司</th>
<th scope="col">结算明细金额</th>
<th scope="col">收款明细金额</th>
</tr>
</thead>
<tbody data-installation-summary-body aria-live="polite">
<tr><td colspan="3" class="installation-summary__empty">选择客服审核时间后自动汇总</td></tr>
</tbody>
<tfoot data-installation-summary-total hidden>
<tr><th scope="row">合计</th><td data-summary-settled>¥0.00</td><td data-summary-received>¥0.00</td></tr>
</tfoot>
</table>
</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']
];
var summary = form.querySelector('[data-installation-summary]');
var summaryTable = summary && summary.querySelector('.installation-summary__table');
var summaryBody = summary && summary.querySelector('[data-installation-summary-body]');
var summaryTotal = summary && summary.querySelector('[data-installation-summary-total]');
var summarySettled = summary && summary.querySelector('[data-summary-settled]');
var summaryReceived = summary && summary.querySelector('[data-summary-received]');
var customerReviewFrom = form.elements.namedItem('customer_review_from');
var customerReviewTo = form.elements.namedItem('customer_review_to');
var summaryTimer = null;
var summaryController = null;
var summaryRequestId = 0;
var amountFormatter = new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
function setSummaryMessage(message) {
if (!summaryBody || !summaryTotal) { return; }
summaryBody.replaceChildren();
var row = document.createElement('tr');
var cell = document.createElement('td');
cell.colSpan = 3;
cell.className = 'installation-summary__empty';
cell.textContent = message;
row.appendChild(cell);
summaryBody.appendChild(row);
summaryTotal.hidden = true;
}
function formatSummaryAmount(value) {
return '¥' + amountFormatter.format(Number(value || 0));
}
function renderSummary(payload) {
if (!summaryBody || !summaryTotal || !summarySettled || !summaryReceived) { return; }
var rows = Array.isArray(payload.data) ? payload.data : [];
summaryBody.replaceChildren();
if (!rows.length) {
setSummaryMessage('当前客服审核时间内没有可汇总明细');
return;
}
rows.forEach(function (item) {
var row = document.createElement('tr');
var company = document.createElement('td');
var settled = document.createElement('td');
var received = document.createElement('td');
company.textContent = item.installation_company || '未填写安装公司';
settled.textContent = formatSummaryAmount(item.settled_amount);
received.textContent = formatSummaryAmount(item.received_amount);
row.append(company, settled, received);
summaryBody.appendChild(row);
});
summarySettled.textContent = formatSummaryAmount(payload.totals && payload.totals.settled_amount);
summaryReceived.textContent = formatSummaryAmount(payload.totals && payload.totals.received_amount);
summaryTotal.hidden = false;
}
function refreshSummary() {
if (!summary || !summaryTable || !customerReviewFrom || !customerReviewTo) { return; }
var from = customerReviewFrom.value;
var to = customerReviewTo.value;
if (!from && !to) {
summaryRequestId++;
if (summaryController) { summaryController.abort(); }
summaryTable.setAttribute('aria-busy', 'false');
setSummaryMessage('选择客服审核时间后自动汇总');
return;
}
if (!from || !to) {
summaryRequestId++;
if (summaryController) { summaryController.abort(); }
summaryTable.setAttribute('aria-busy', 'false');
setSummaryMessage('请选择完整的客服审核起止日期');
return;
}
if (from && to && from > to) {
summaryRequestId++;
if (summaryController) { summaryController.abort(); }
summaryTable.setAttribute('aria-busy', 'false');
setSummaryMessage('结束日期不能早于开始日期');
return;
}
if (summaryController) { summaryController.abort(); }
summaryController = new AbortController();
var requestId = ++summaryRequestId;
var url = new URL(summary.dataset.url, window.location.origin);
url.searchParams.set('customer_review_from', from);
url.searchParams.set('customer_review_to', to);
summaryTable.setAttribute('aria-busy', 'true');
setSummaryMessage('正在汇总…');
fetch(url.toString(), {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
signal: summaryController.signal
})
.then(function (response) {
return response.json().catch(function () { return {}; }).then(function (body) {
if (!response.ok) {
throw new Error(body.message || '金额汇总加载失败');
}
return body;
});
})
.then(function (payload) {
if (requestId !== summaryRequestId) { return; }
renderSummary(payload);
})
.catch(function (error) {
if (error && error.name === 'AbortError') { return; }
if (requestId !== summaryRequestId) { return; }
setSummaryMessage(error.message || '金额汇总加载失败,请稍后重试');
})
.finally(function () {
if (requestId === summaryRequestId) {
summaryTable.setAttribute('aria-busy', 'false');
}
});
}
function scheduleSummary() {
window.clearTimeout(summaryTimer);
summaryTimer = window.setTimeout(refreshSummary, 250);
}
[customerReviewFrom, customerReviewTo].forEach(function (input) {
if (input) { input.addEventListener('change', scheduleSummary); }
});
if (customerReviewFrom && customerReviewTo && (customerReviewFrom.value || customerReviewTo.value)) {
refreshSummary();
}
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>
@endif
<div class="biz-card-head">
<div class="biz-card-title">
<span class="material-icons-outlined" aria-hidden="true">table_view</span>
@ -1171,13 +687,13 @@
@endif
</form>
@endif
<div class="biz-table-wrap @if ($page['slug'] === 'installation-details') installation-details-table-wrap @endif">
<table class="biz-table @if ($page['slug'] === 'installation-details') installation-details-table @endif">
<div class="biz-table-wrap">
<table class="biz-table">
<thead>
<tr>
@if ($page['slug'] !== 'installation-details')<th>序号</th>@endif
<th>序号</th>
@foreach ($descriptor->fields as $field)
<th class="@if ($field['currency']) num @endif" @if ($page['slug'] === 'installation-details') title="{{ $field['label'] }}" @endif>{{ $field['label'] }}</th>
<th class="@if ($field['currency']) num @endif">{{ $field['label'] }}</th>
@endforeach
@unless ($descriptor->readOnly)
<th class="actions-cell" data-finance-fixed="end">操作</th>
@ -1186,13 +702,13 @@
</thead>
<tbody>
@forelse ($rows as $row)
<tr @if ($page['slug'] === 'installation-details') class="installation-main-row" data-installation-row data-guid="{{ $row->related_guid ?? '' }}" tabindex="0" aria-label="查看安装商品明细" @endif>
@if ($page['slug'] !== 'installation-details')<td>{{ $row->id }}</td>@endif
<tr>
<td>{{ $row->id }}</td>
@foreach ($descriptor->fields as $field)
@php
$val = $row->{$field['name']};
@endphp
<td class="@if ($field['currency']) num @endif" @if ($page['slug'] === 'installation-details' && is_scalar($val)) title="{{ $val }}" @endif>
<td class="@if ($field['currency']) num @endif">
@if ($field['type'] === 'boolean')
@if ($val)
<span class="biz-badge biz-badge-yes">是</span>
@ -1230,7 +746,7 @@
</tr>
@empty
<tr>
<td colspan="{{ count($descriptor->fields) + ($page['slug'] === 'installation-details' ? 0 : 1) + ($descriptor->readOnly ? 0 : 1) }}">
<td colspan="{{ count($descriptor->fields) + 1 + ($descriptor->readOnly ? 0 : 1) }}">
<div class="biz-empty">
<span class="material-icons-outlined" aria-hidden="true">inbox</span>
<div>{{ $descriptor->readOnly ? '暂无工作平台数据' : '暂无数据,点击右上角"新增"开始录入' }}</div>
@ -1258,65 +774,6 @@
</span>
</div>
@if ($page['slug'] === 'installation-details')
<section class="installation-detail-panel" data-installation-detail-panel aria-label="明细子表">
<div class="biz-card-head"><div class="biz-card-title">明细子表</div></div>
<div class="biz-table-wrap installation-details-table-wrap" data-installation-detail-content>
<div class="installation-detail-empty">请点击上方主表记录查看商品明细</div>
</div>
</section>
@endif
@if ($page['slug'] === 'installation-details')
<script>
(() => {
const detailUrl = @json(route('business.installation-details.details', ['guid' => '__GUID__']));
const columns = [
['transaction_no', '交易单号'], ['product_id', '商品id'], ['id_owner', 'ID负责人'],
['department_owner', '部门负责人'], ['auxiliary_spu', '辅助码Spu'], ['product_store_code', '商品店铺编码'],
['item_no', '货号'], ['barcode', '条形码'], ['product_name', '商品名称'], ['supplier_name', '供应商名称'],
['detail_requested_amount', '申请明细金额'], ['detail_requested_quantity', '申请明细数量'],
['detail_settled_amount', '结算明细金额'], ['detail_received_amount', '收款明细金额'],
['change_reason', '变更原因'], ['source_created_at', '创建时间'], ['source_modified_at', '修改时间'],
];
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[char]));
const renderDetails = (items) => {
if (!items.length) return '<div class="installation-detail-empty">暂无商品明细</div>';
const head = columns.map(([, label]) => `<th>${escapeHtml(label)}</th>`).join('');
const body = items.map((item) => `<tr>${columns.map(([key]) => {
const value = escapeHtml(item[key]);
return `<td title="${value}">${value}</td>`;
}).join('')}</tr>`).join('');
return `<table class="installation-detail-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
};
const detailPanel = document.querySelector('[data-installation-detail-panel]');
const detailContent = detailPanel?.querySelector('[data-installation-detail-content]');
let detailRequest = 0;
document.querySelectorAll('[data-installation-row]').forEach((row) => {
row.addEventListener('keydown', (event) => {
if (event.target === row && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); row.click(); }
});
row.addEventListener('click', async () => {
if (!detailContent) return;
const requestId = ++detailRequest;
detailContent.innerHTML = '<div>正在加载明细…</div>';
try {
const response = await fetch(detailUrl.replace('__GUID__', encodeURIComponent(row.dataset.guid)), {headers: {'Accept': 'application/json'}});
if (!response.ok) throw new Error('明细加载失败');
const payload = await response.json();
if (requestId !== detailRequest) return;
detailContent.innerHTML = renderDetails(payload.data || []);
if (payload.has_more) detailContent.insertAdjacentHTML('beforeend', '<div class="installation-detail-empty">当前仅展示前 100 条商品明细,不代表全部。</div>');
} catch (error) {
if (requestId !== detailRequest) return;
detailContent.innerHTML = '<div class="installation-detail-error">明细加载失败,请稍后重试</div>';
}
});
});
})();
</script>
@endif
</div>
@unless ($descriptor->readOnly)

View File

@ -268,6 +268,10 @@ Route::get('workspace/product-inventory/monthly-weighted-average-price/export',
->middleware('business.page:financial-operations,performance-details')
->name('business.financial-operations.performance-details.metric-details');
Route::get('workspace/financial-operations/performance-details/installation-fee-details', 'Business\Pages@installationFeeMetricDetails')
->middleware('business.page:financial-operations,performance-details')
->name('business.financial-operations.performance-details.installation-fee-details');
Route::get('workspace/financial-operations/product-costs/export', 'Business\Pages@exportProductCosts')
->middleware('business.page:financial-operations,product-costs')
->name('business.financial-operations.product-costs.export');
@ -578,16 +582,6 @@ Route::get('workspace/fund-management/fund-forecast/template', 'Business\Pages@f
->middleware('business.page:fund-management,fund-forecast')
->name('business.fund-forecast.template');
Route::get('workspace/financial-operations/installation-details/summary', 'Business\Pages@installationDetailsSummary')
->name('business.installation-details.summary');
Route::get('workspace/financial-operations/installation-details/details/{guid}', 'Business\Pages@installationDetails')
->where('guid', '[A-Za-z0-9-]+')
->name('business.installation-details.details');
Route::get('workspace/financial-operations/installation-details/export', 'Business\Pages@exportInstallationDetails')
->name('business.installation-details.export');
Route::get('workspace/trademark-management/store-report-data-logic/edit', 'Business\Pages@editStoreReportLogic')
->middleware('business.page:trademark-management,store-report-data-logic')
->name('business.store-report-logic.edit');

View File

@ -5,14 +5,12 @@ namespace Tests\Feature\Business;
use App\Business\UpstreamBusinessInformation;
use App\Business\StoreReceivingAccountSynchronizer;
use App\Models\Auth\Permission;
use App\Models\Business\InstallationDetail;
use App\Models\Business\StoreReceivingAccount;
use App\Utilities\BusinessNavigation;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Mockery;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Tests\Feature\FeatureTestCase;
@ -222,167 +220,6 @@ class ManagementCrudTest extends FeatureTestCase
->assertSee('value="提现测试主体" selected', false);
}
public function testInstallationDetailsRendersWorkbookHeadersAndImportedRows(): 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' => 1, 'installation_no' => 'INSTALL-TEST-001', 'installation_company' => '测试安装公司',
'store_name' => '测试店铺', 'requested_amount' => '120.50', 'settled_amount' => '100.00', 'item_quantity' => 2,
'related_guid' => 'guid-install-test',
])],
]);
$this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'installation-details',
]))
->assertOk()
->assertSeeText('安装明细 · 数据列表')
->assertSeeText('安装单号')
->assertSeeText('申请总金额')
->assertSeeText('商品名称(1)')
->assertSeeText('创建日期:')
->assertSeeText('客服审核时间:')
->assertSeeText('财务审核时间:')
->assertSeeText('推送状态:')
->assertSeeText('选择店铺:')
->assertSeeText('选择删除:')
->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('data-date-picker', false)
->assertSee('data-date-calendar', false)
->assertSee("window.CaiWuDatePicker.setup(form)", false)
->assertSee('结束日期不能早于开始日期。')
->assertSee('data-installation-summary', false)
->assertSeeText('安装公司')
->assertSeeText('结算明细金额')
->assertSeeText('收款明细金额')
->assertSeeText('选择客服审核时间后自动汇总')
->assertSee('refreshSummary()', false)
->assertSee("dispatchEvent(new Event('change'", false)
->assertSee('installation-details-table-wrap', false)
->assertSee('installation-details-table', false)
->assertSee('writing-mode: horizontal-tb', false)
->assertSee('text-overflow: ellipsis', false)
->assertSeeText('INSTALL-TEST-001')
->assertSeeText('测试安装公司')
->assertSeeText('120.50')
->assertSeeText('共 1 条');
}
public function testInstallationDetailsExportUsesFiltersAndMirrorRows(): void
{
$row = array_fill_keys(array_keys(InstallationDetail::columnLabels()), null);
$row['installation_no'] = 'INSTALL-EXPORT-001';
$mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
$mirror->shouldReceive('paginate')->once()->with(1, 100, ['created_from' => '2026-08-01'])->andReturn([
'ready' => true,
'total' => 1,
'data' => [$row],
]);
$this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
$response = $this->loginAs()->get(route('business.installation-details.export', [
'created_from' => '2026-08-01',
]));
$response->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8');
$this->assertStringContainsString('INSTALL-EXPORT-001', $response->streamedContent());
}
public function testInstallationDetailsEndpointReturnsChildItemsForMainRecord(): void
{
$mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
$mirror->shouldReceive('details')->once()->with('guid-install-test')->andReturn([
'ready' => true,
'data' => [[
'transaction_no' => 'INSTALL-TEST-001',
'product_id' => '10035070813217',
'product_name' => '测试商品',
'detail_requested_amount' => '53.59',
'detail_requested_quantity' => 1,
]],
]);
$this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
$this->loginAs()
->get(route('business.installation-details.details', ['guid' => 'guid-install-test']))
->assertOk()
->assertJsonPath('data.0.transaction_no', 'INSTALL-TEST-001')
->assertJsonPath('data.0.product_name', '测试商品');
}
public function testInstallationSummaryEndpointReturnsCompanyAmountsForCustomerReviewRange(): void
{
$mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
$mirror->shouldReceive('companySummary')->once()->with([
'customer_review_from' => '2026-09-01',
'customer_review_to' => '2026-09-30',
])->andReturn([
'ready' => true,
'data' => [
['installation_company' => '万师傅', 'settled_amount' => 1250.50, 'received_amount' => 1000.00],
['installation_company' => '鲁班到家', 'settled_amount' => 750.00, 'received_amount' => 600.00],
],
'total_settled' => 2000.50,
'total_received' => 1600.00,
]);
$this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
$this->loginAs()
->getJson(route('business.installation-details.summary', [
'customer_review_from' => '2026-09-01',
'customer_review_to' => '2026-09-30',
]))
->assertOk()
->assertJsonPath('data.0.installation_company', '万师傅')
->assertJsonPath('data.0.settled_amount', 1250.5)
->assertJsonPath('data.0.received_amount', 1000)
->assertJsonPath('totals.settled_amount', 2000.5)
->assertJsonPath('totals.received_amount', 1600);
}
public function testInstallationSummaryRequiresCompleteCustomerReviewRange(): void
{
$mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
$mirror->shouldNotReceive('companySummary');
$this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
$this->loginAs()
->getJson(route('business.installation-details.summary', [
'customer_review_from' => '2026-09-01',
]))
->assertUnprocessable()
->assertJsonPath('message', '请选择完整的客服审核起止日期');
}
public function testInstallationChildEndpointRequiresTheSameSidebarPermission(): void
{
$this->withExceptionHandling();
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', ['configured'=>['user_'.$this->user->id], 'subjects'=>['user_'.$this->user->id=>['store-management/basic-information']]]);
$mirror=Mockery::mock(InstallationDetailMirrorRepository::class);
$mirror->shouldNotReceive('details');
$mirror->shouldNotReceive('companySummary');
$this->app->instance(InstallationDetailMirrorRepository::class,$mirror);
$this->loginAs()->getJson(route('business.installation-details.details',['guid'=>'guid-install-test']))->assertForbidden();
$this->loginAs()->getJson(route('business.installation-details.summary', [
'customer_review_from' => '2026-09-01',
'customer_review_to' => '2026-09-30',
]))->assertForbidden();
}
public function testCompanyInformationUsesWorkbookHeadersAndReadOnlyUpstreamRows(): void
{
$this->mockUpstreamPage((object) [

View File

@ -165,6 +165,84 @@ class PerformanceDetailsMetricDetailsTest extends FeatureTestCase
], $response->json('rows'));
}
public function test_installation_fee_metric_uses_shipment_month_and_channel_category(): void
{
$mirror = DB::connection('business_mirror');
foreach ([
'jiansun.dbo.fys_Ticketing_install_view',
'jiansun.dbo.fys_Ticketing_install_item',
] as $dataset) {
$mirror->table('business_mirror_table_states')->insert([
'dataset_key' => $dataset,
'initial_completed_at' => '2026-09-01 00:00:00',
]);
}
$mirror->table('js_fys_ticketing_install_view')->insert([
[
'Id' => 1,
'SourceGuid' => 'install-a',
'ShipTIme' => '2026-09-10 12:00:00',
'shop_name' => '店铺A',
'installCompanyName' => '万师傅',
'costWay' => null,
],
[
'Id' => 2,
'SourceGuid' => 'install-b',
'ShipTIme' => '2026-09-11 12:00:00',
'shop_name' => '店铺A',
'installCompanyName' => '万师傅',
'costWay' => '银行转账',
],
[
'Id' => 3,
'SourceGuid' => 'install-old',
'ShipTIme' => '2026-08-31 23:59:59',
'shop_name' => '店铺旧',
'installCompanyName' => '鲁班到家',
'costWay' => null,
],
]);
$mirror->table('js_fys_ticketing_install_item')->insert([
['Id' => 1, 'guid' => 'install-a', 'settleItemAmount' => 120],
['Id' => 2, 'guid' => 'install-b', 'settleItemAmount' => 80],
['Id' => 3, 'guid' => 'install-old', 'settleItemAmount' => 999],
]);
$response = $this->loginAs()->getJson(route(
'business.financial-operations.performance-details.installation-fee-details',
['month' => '2026-09'],
));
$response
->assertOk()
->assertJsonPath('label', '安装费金额')
->assertJsonPath('month', '2026-09')
->assertJsonPath('total', 200)
->assertJsonPath('rows.0.store_name', '店铺A')
->assertJsonPath('rows.0.installation_company', '万师傅')
->assertJsonPath('rows.0.payment_channel', '包安装')
->assertJsonPath('rows.0.amount', 120)
->assertJsonPath('rows.1.payment_channel', '不包安装')
->assertJsonPath('rows.1.amount', 80);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'performance-details',
'month' => '2026-09',
]))
->assertOk()
->assertSeeText('安装费金额')
->assertSee('data-installation-fee-open', false)
->assertSee('id="installation-fee-dialog"', false)
->assertSeeText('店铺名称')
->assertSeeText('安装公司')
->assertSeeText('收款渠道')
->assertSeeText('结算明细金额')
->assertSee('installation-fee-details', false);
}
private function row(string $rowKey, string $storeName, float $offset, string $platform = '京东账单'): array
{
return [
@ -214,6 +292,23 @@ class PerformanceDetailsMetricDetailsTest extends FeatureTestCase
private function createMirrorTables(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->dateTime('initial_completed_at')->nullable();
});
$schema->create('js_fys_ticketing_install_view', function (Blueprint $table): void {
$table->integer('Id');
$table->string('SourceGuid');
$table->dateTime('ShipTIme')->nullable();
$table->string('shop_name')->nullable();
$table->string('installCompanyName')->nullable();
$table->string('costWay')->nullable();
});
$schema->create('js_fys_ticketing_install_item', function (Blueprint $table): void {
$table->integer('Id');
$table->string('guid');
$table->decimal('settleItemAmount', 18, 4)->nullable();
});
$schema->create('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->date('report_month');
$table->string('platform');

View File

@ -134,6 +134,21 @@ class NavigationTest extends FeatureTestCase
->assertSeeText('店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。');
}
public function testStandaloneInstallationDetailsPageIsRemoved(): void
{
$this->assertFalse(\Illuminate\Support\Facades\Route::has('business.installation-details.details'));
$this->assertFalse(\Illuminate\Support\Facades\Route::has('business.installation-details.summary'));
$this->assertFalse(\Illuminate\Support\Facades\Route::has('business.installation-details.export'));
$this->withExceptionHandling();
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'installation-details',
]))
->assertRedirect();
}
public function testExpenseDetailsPageIsAvailableAndForcedToExpenseCategory(): void
{
$this->loginAs()

View File

@ -1,172 +0,0 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class InstallationDetailMirrorRepositoryTest extends TestCase
{
private InstallationDetailMirrorRepository $repository;
protected function setUp(): void
{
parent::setUp();
config(['database.connections.business_mirror'=>['driver'=>'sqlite','database'=>':memory:','prefix'=>''], 'upstream_mirrors.connection'=>'business_mirror']);
DB::purge('business_mirror');
$definitions=[];
foreach ([InstallationDetailMirrorRepository::DATASET=>'fixture_install',InstallationDetailMirrorRepository::ITEM_DATASET=>'fixture_items'] as $key=>$table) {
$definitions[$key]=['source_key'=>'jiansun','schema'=>'dbo','object'=>explode('.',$key)[2],'target'=>$table,'keys'=>['Id']];
}
$definitions[InstallationDetailMirrorRepository::DATASET]['minimum_datetime'] = [
'column' => 'ShipTIme',
'value' => '2026-01-01 00:00:00',
];
config(['upstream_mirrors.datasets'=>$definitions]);
$schema=Schema::connection('business_mirror');
$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','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);
});
$schema->create('fixture_items',function(Blueprint $t){
$t->integer('Id');
$t->string('guid');
$t->string('shopproid');
$t->string('Sourceno')->nullable();
$t->dateTime('kfshTime')->nullable();
$t->decimal('settleItemAmount',14,2)->default(0);
$t->decimal('acceptItemAmount',14,2)->default(0);
});
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'=>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],
['Id'=>6,'SourceGuid'=>'E','OrderNum'=>'NULL-SHIP','shop_name'=>'测试店','SubmitTime'=>'2026-08-21','ShipTIme'=>null,'installcost'=>999],
]);
DB::connection('business_mirror')->table('fixture_items')->insert([
['Id'=>1,'guid'=>'A','shopproid'=>'PRODUCT-A'],['Id'=>2,'guid'=>'A','shopproid'=>'PRODUCT-A'],
]);
$this->repository=new InstallationDetailMirrorRepository(new GenericBusinessMirrorRepository(app('db')),new BusinessMirrorDatasetRegistry());
}
public function test_filters_apply_to_deduplicated_list_count_and_summary(): void
{
$filters=['shop_name'=>'测试店'];
$page=$this->repository->paginate(2,1,$filters);
$this->assertSame(2,$page['total']);
$this->assertSame(2,$page['data'][0]['id']);
$this->assertSame(60.0,$this->repository->summary($filters)['requested']);
$filters['created_to']='2026-08-20';
$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']);
}
public function test_product_filter_does_not_multiply_main_rows(): void
{
$filters=['product_id'=>'PRODUCT-A'];
$this->assertSame(1,$this->repository->paginate(1,50,$filters)['total']);
$this->assertSame(20.0,$this->repository->summary($filters)['requested']);
$this->assertSame('NEW',$this->repository->details('A')['data'][0]['transaction_no']);
$this->assertSame([],$this->repository->details('missing')['data']);
}
public function test_shipment_cutoff_hides_old_and_null_records_from_list_summary_and_details(): void
{
DB::connection('business_mirror')->table('fixture_items')->insert([
['Id'=>30,'guid'=>'D','shopproid'=>'OLD-PRODUCT'],
['Id'=>31,'guid'=>'E','shopproid'=>'NULL-PRODUCT'],
]);
$this->assertSame(3,$this->repository->paginate(1,50)['total']);
$this->assertSame(3,$this->repository->summary()['rows']);
$this->assertSame([],$this->repository->details('D')['data']);
$this->assertSame([],$this->repository->details('E')['data']);
}
public function test_status_filters_and_child_limit_are_explicit(): void
{
DB::connection('business_mirror')->table('fixture_install')->where('Id',2)->update(['kfsh'=>'1','Delete'=>'0','StatusId'=>2]);
$this->assertSame(1,$this->repository->paginate(1,50,['customer_review'=>'1','delete_status'=>'0','arranged_status'=>'1'])['total']);
$rows=[];for($i=1;$i<=101;$i++)$rows[]=['Id'=>$i+10,'guid'=>'B','shopproid'=>'P'.$i];
DB::connection('business_mirror')->table('fixture_items')->insert($rows);
$result=$this->repository->details('B');
$this->assertCount(100,$result['data']);
$this->assertTrue($result['has_more']);
}
public function test_unset_status_filters_include_null_values_and_render_explicit_labels(): void
{
DB::connection('business_mirror')->table('fixture_install')->where('Id',3)->update(['StatusId'=>null]);
foreach (['customer_review','finance_review','completed_status','delete_status','arranged_status'] as $key) {
$this->assertSame(3,$this->repository->paginate(1,50,[$key=>'0'])['total'],$key);
}
$other=$this->repository->paginate(1,50,['shop_name'=>'其他店'])['data'][0];
$this->assertSame('未审核',$other['customer_service_review']);
$this->assertSame('未审核',$other['finance_review']);
$this->assertSame('否',$other['completed_status']);
$this->assertSame('未安排',$other['arranged_status']);
DB::connection('business_mirror')->table('fixture_install')->where('Id',2)->update([
'kfsh'=>1,
'cwsh'=>1,
'installComplete'=>1,
'Delete'=>0,
'StatusId'=>1,
]);
foreach (['customer_review','finance_review','completed_status','arranged_status'] as $key) {
$this->assertSame(1,$this->repository->paginate(1,50,[$key=>'1'])['total'],$key);
}
}
public function test_company_summary_uses_item_review_time_and_latest_main_company(): void
{
DB::connection('business_mirror')->table('fixture_install')->where('Id',1)->update(['installCompanyName'=>'旧公司']);
DB::connection('business_mirror')->table('fixture_install')->where('Id',2)->update(['installCompanyName'=>'万师傅']);
DB::connection('business_mirror')->table('fixture_install')->where('Id',3)->update(['installCompanyName'=>'鲁班到家']);
DB::connection('business_mirror')->table('fixture_install')->where('Id',4)->update(['installCompanyName'=>'万师傅']);
DB::connection('business_mirror')->table('fixture_items')->insert([
['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],
['Id'=>23,'guid'=>'C','shopproid'=>'P4','Sourceno'=>'C-1','kfshTime'=>'2026-10-01 00:00:00','settleItemAmount'=>500.00,'acceptItemAmount'=>500.00],
['Id'=>24,'guid'=>'D','shopproid'=>'OLD','Sourceno'=>'D-1','kfshTime'=>'2026-09-10 10:00:00','settleItemAmount'=>999.00,'acceptItemAmount'=>999.00],
['Id'=>25,'guid'=>'E','shopproid'=>'NULL','Sourceno'=>'E-1','kfshTime'=>'2026-09-10 10:00:00','settleItemAmount'=>999.00,'acceptItemAmount'=>999.00],
]);
$result=$this->repository->companySummary([
'customer_review_from'=>'2026-09-01',
'customer_review_to'=>'2026-09-30',
]);
$this->assertTrue($result['ready']);
$this->assertSame([
['installation_company'=>'万师傅','settled_amount'=>100.0,'received_amount'=>40.0],
['installation_company'=>'鲁班到家','settled_amount'=>80.0,'received_amount'=>70.0],
],$result['data']);
$this->assertSame(180.0,$result['total_settled']);
$this->assertSame(110.0,$result['total_received']);
}
public function test_end_date_alone_is_valid_and_invalid_status_is_rejected(): void
{
request()->merge(['created_to'=>'2026-08-20']);
$this->assertSame(['created_to'=>'2026-08-20'],InstallationDetailMirrorRepository::requestFilters());
request()->merge(['arranged_status'=>'unexpected']);
$this->expectException(\Illuminate\Validation\ValidationException::class);
InstallationDetailMirrorRepository::requestFilters();
}
}

View File

@ -0,0 +1,119 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\InstallationFeeSummaryRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class InstallationFeeSummaryRepositoryTest extends TestCase
{
private InstallationFeeSummaryRepository $repository;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'cache.default' => 'array',
'upstream_mirrors.datasets' => [
InstallationFeeSummaryRepository::MAIN_DATASET => [
'source_key' => 'jiansun',
'schema' => 'dbo',
'object' => 'install_view',
'target' => 'fixture_install_view',
'keys' => ['Id'],
],
InstallationFeeSummaryRepository::ITEM_DATASET => [
'source_key' => 'jiansun',
'schema' => 'dbo',
'object' => 'install_item',
'target' => 'fixture_install_item',
'keys' => ['Id'],
],
],
]);
DB::purge('business_mirror');
Cache::flush();
$schema = Schema::connection('business_mirror');
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->dateTime('initial_completed_at')->nullable();
});
$schema->create('fixture_install_view', function (Blueprint $table): void {
$table->integer('Id');
$table->string('SourceGuid');
$table->dateTime('ShipTIme')->nullable();
$table->string('shop_name')->nullable();
$table->string('installCompanyName')->nullable();
$table->string('costWay')->nullable();
});
$schema->create('fixture_install_item', function (Blueprint $table): void {
$table->integer('Id');
$table->string('guid');
$table->decimal('settleItemAmount', 18, 4)->nullable();
});
foreach ([InstallationFeeSummaryRepository::MAIN_DATASET, InstallationFeeSummaryRepository::ITEM_DATASET] as $dataset) {
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
'dataset_key' => $dataset,
'initial_completed_at' => '2026-09-01 00:00:00',
]);
}
$mirror = DB::connection('business_mirror');
$mirror->table('fixture_install_view')->insert([
['Id' => 1, 'SourceGuid' => 'A', 'ShipTIme' => '2026-09-01 10:00:00', 'shop_name' => '店铺A', 'installCompanyName' => '万师傅', 'costWay' => null],
['Id' => 2, 'SourceGuid' => 'A', 'ShipTIme' => '2026-09-02 10:00:00', 'shop_name' => '店铺A', 'installCompanyName' => '万师傅', 'costWay' => null],
['Id' => 3, 'SourceGuid' => 'B', 'ShipTIme' => '2026-09-03 10:00:00', 'shop_name' => '店铺A', 'installCompanyName' => '万师傅', 'costWay' => '银行转账'],
['Id' => 4, 'SourceGuid' => 'C', 'ShipTIme' => '2026-08-31 23:59:59', 'shop_name' => '店铺C', 'installCompanyName' => '鲁班到家', 'costWay' => null],
['Id' => 5, 'SourceGuid' => 'D', 'ShipTIme' => '2026-09-04 10:00:00', 'shop_name' => null, 'installCompanyName' => ' ', 'costWay' => ' '],
]);
$mirror->table('fixture_install_item')->insert([
['Id' => 10, 'guid' => 'A', 'settleItemAmount' => 100],
['Id' => 11, 'guid' => 'A', 'settleItemAmount' => 25],
['Id' => 12, 'guid' => 'B', 'settleItemAmount' => 50],
['Id' => 13, 'guid' => 'C', 'settleItemAmount' => 999],
['Id' => 14, 'guid' => 'D', 'settleItemAmount' => 10],
]);
$this->repository = new InstallationFeeSummaryRepository(
new GenericBusinessMirrorRepository(app('db')),
new BusinessMirrorDatasetRegistry(),
);
}
public function test_summary_and_details_use_shipment_month_and_settlement_detail_amount(): void
{
$summary = $this->repository->summary('2026-09');
$this->assertTrue($summary['ready']);
$this->assertSame(185.0, $summary['total']);
$this->assertSame(4, $summary['row_count']);
$details = $this->repository->details('2026-09');
$this->assertTrue($details['ready']);
$this->assertSame(185.0, $details['total']);
$this->assertSame([
['store_name' => '店铺A', 'installation_company' => '万师傅', 'payment_channel' => '包安装', 'amount' => 125.0],
['store_name' => '店铺A', 'installation_company' => '万师傅', 'payment_channel' => '不包安装', 'amount' => 50.0],
['store_name' => '未填写店铺', 'installation_company' => '未填写安装公司', 'payment_channel' => '包安装', 'amount' => 10.0],
], $details['rows']);
}
public function test_invalid_month_is_rejected(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->repository->summary('2026-13');
}
}

View File

@ -8,7 +8,6 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReportRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
@ -275,7 +274,6 @@ class StoreReportRepositoryTest extends TestCase
$service = new UpstreamBusinessInformation(
Mockery::mock(UpstreamQueryCacheFactory::class),
$mirror,
Mockery::mock(InstallationDetailMirrorRepository::class),
$this->repository,
$accounts,
);
@ -304,7 +302,6 @@ class StoreReportRepositoryTest extends TestCase
$service = new UpstreamBusinessInformation(
Mockery::mock(UpstreamQueryCacheFactory::class),
$mirror,
Mockery::mock(InstallationDetailMirrorRepository::class),
$this->repository,
$accounts,
);
@ -339,7 +336,6 @@ class StoreReportRepositoryTest extends TestCase
$service = new UpstreamBusinessInformation(
Mockery::mock(UpstreamQueryCacheFactory::class),
$mirror,
Mockery::mock(InstallationDetailMirrorRepository::class),
$this->repository,
$accounts,
);

View File

@ -6,7 +6,6 @@ use App\Business\UpstreamBusinessInformation;
use Closure;
use Mockery;
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReportRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCache;
@ -53,7 +52,7 @@ class UpstreamBusinessInformationTest extends TestCase
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
$mirror->shouldReceive('ready')->once()->with($scope)->andReturnFalse();
$information = new class($caches, $mirror, Mockery::mock(InstallationDetailMirrorRepository::class)) extends UpstreamBusinessInformation {
$information = new class($caches, $mirror) extends UpstreamBusinessInformation {
public array $snapshots = [];
public function snapshot(string $scope, array $parameters): array
@ -104,7 +103,7 @@ class UpstreamBusinessInformationTest extends TestCase
]);
$this->app['request']->merge(['page' => 2]);
$paginator = (new UpstreamBusinessInformation($caches, $mirror, Mockery::mock(InstallationDetailMirrorRepository::class)))
$paginator = (new UpstreamBusinessInformation($caches, $mirror))
->paginate($section, $page, 25);
$this->assertSame(2, $paginator->currentPage());
@ -147,7 +146,6 @@ class UpstreamBusinessInformationTest extends TestCase
$paginator = (new UpstreamBusinessInformation(
Mockery::mock(UpstreamQueryCacheFactory::class),
$mirror,
Mockery::mock(InstallationDetailMirrorRepository::class),
null,
$accounts,
))->paginate('store-management', 'basic-information', 50);
@ -167,7 +165,7 @@ class UpstreamBusinessInformationTest extends TestCase
$mirror->shouldReceive('ready')->once()->with('store-information.read')->andReturnFalse();
$this->expectException(\Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException::class);
$this->expectExceptionMessage('无需另配上游 SQL Server');
(new UpstreamBusinessInformation($caches, $mirror, Mockery::mock(InstallationDetailMirrorRepository::class)))
(new UpstreamBusinessInformation($caches, $mirror))
->paginate('store-management', 'basic-information');
}
@ -203,7 +201,6 @@ class UpstreamBusinessInformationTest extends TestCase
$information = new UpstreamBusinessInformation(
Mockery::mock(UpstreamQueryCacheFactory::class),
$mirror,
Mockery::mock(InstallationDetailMirrorRepository::class),
$storeReports,
$storeAccounts,
);