diff --git a/app/Business/TableRegistry.php b/app/Business/TableRegistry.php index def5f902..11d3a1d6 100644 --- a/app/Business/TableRegistry.php +++ b/app/Business/TableRegistry.php @@ -12,6 +12,7 @@ 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; @@ -170,6 +171,7 @@ 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, ]; } @@ -259,6 +261,7 @@ class TableRegistry 'store-management/store-accounts', 'store-management/store-report', 'trademark-management/information', + 'financial-operations/installation-details', 'financial-risk-control/process-statistics', ], true); } diff --git a/app/Business/UpstreamBusinessInformation.php b/app/Business/UpstreamBusinessInformation.php index c56a449b..70383efd 100644 --- a/app/Business/UpstreamBusinessInformation.php +++ b/app/Business/UpstreamBusinessInformation.php @@ -7,6 +7,7 @@ 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; @@ -22,6 +23,7 @@ class UpstreamBusinessInformation // 兼容仍按旧构造方式调用的代码;容器解析时会正常注入仓储。 private readonly ?StoreReportRepository $storeReports = null, private readonly ?StoreAccountMirrorRepository $storeAccounts = null, + private readonly ?InstallationDetailMirrorRepository $installationMirror = null, ) {} public function paginate(string $section, string $page, int $perPage = 50): LengthAwarePaginator @@ -37,6 +39,7 @@ 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], }; @@ -168,6 +171,16 @@ class UpstreamBusinessInformation } } + private function installationPage(int $page, int $perPage): array + { + $mirror = $this->installationMirror ?? app(InstallationDetailMirrorRepository::class); + $payload = $mirror->paginate($page, $perPage, InstallationDetailMirrorRepository::requestFilters()); + if (! $payload['ready']) { + throw new ServiceUnavailableHttpException(null, '安装明细镜像尚未完成首次同步'); + } + return ['data' => $payload['data'], 'total' => $payload['total']]; + } + /** * 将已登记的经营分析副本库查询还原成不经过页面缓存的固定只读查询。 * diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 0bda7d6b..8fab44ed 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -22,6 +22,7 @@ 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; @@ -55,7 +56,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\InstallationFeeSummaryRepository; +use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository; use Modules\EcommerceFinance\Repositories\InternalFlowReportRepository; use Modules\EcommerceFinance\Repositories\JdPromotionAccountBalanceRepository; use Modules\EcommerceFinance\Repositories\JdWalletBillRepository; @@ -880,7 +881,7 @@ class Pages extends Controller public function __construct( private readonly EdgeBandingRepository $edgeBanding, private readonly InventoryRepository $inventory, - private readonly InstallationFeeSummaryRepository $installationFees, + private readonly InstallationDetailMirrorRepository $installationDetails, private readonly NonJingxiRefundMirrorRepository $nonJingxiRefunds, private readonly SmallPaymentWorkOrderMirrorRepository $smallPaymentWorkOrders, private readonly ProductCatalogMirrorRepository $productCatalog, @@ -1085,6 +1086,39 @@ 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->feeDetails($filters); + if (! $payload['ready']) { + return response()->json(['message' => '安装明细镜像尚未完成首次同步'], 503); + } + + return response()->json([ + 'total' => $payload['total'], + 'rows' => $payload['rows'], + ], 200, ['Cache-Control' => 'private, no-store']); + } + /** * 账单明细导出:按页面同一套筛选条件流式导出 CSV(全部筛选结果)。 */ @@ -1252,6 +1286,49 @@ 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'); @@ -1508,9 +1585,6 @@ 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 小时缓存,避免每次开页做去重扫描), // 并把当前已选值并入,保证筛选项的文字不会因为换月而消失。 @@ -1773,34 +1847,6 @@ 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']); - } - /** * 业绩明细当前筛选条件。指标详情与页面统计共用同一组请求参数。 * diff --git a/app/Models/Business/InstallationDetail.php b/app/Models/Business/InstallationDetail.php new file mode 100644 index 00000000..cc8edd0d --- /dev/null +++ b/app/Models/Business/InstallationDetail.php @@ -0,0 +1,78 @@ + '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', + ]; + } +} diff --git a/config/business_data.php b/config/business_data.php index f3008331..348db6c9 100644 --- a/config/business_data.php +++ b/config/business_data.php @@ -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' => '供应商应付'], diff --git a/config/business_navigation.php b/config/business_navigation.php index 31bf1c44..b5ae5cb2 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -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,6 +870,7 @@ $navigation = [ ], ], ], + ['slug' => 'installation-details', 'title' => '安装明细'], ['slug' => 'promotion-expenses', 'title' => '推广费用'], ['slug' => 'after-sales-expenses', 'title' => '售后费用'], ['slug' => 'small-payment-work-orders', 'title' => '小额打款'], diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index 26b2fd1b..2da85ffc 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -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', diff --git a/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php new file mode 100644 index 00000000..3b4d7b9a --- /dev/null +++ b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php @@ -0,0 +1,340 @@ +>,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>,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]; + } + + /** + * 按客服审核时间汇总安装费;金额取安装明细子表的结算明细金额。 + * + * @return array{ready:bool,total:float,rows:list} + */ + public function feeDetails(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, 'total' => 0.0, 'rows' => []]; + } + + $from = trim((string) ($filters['customer_review_from'] ?? '')); + $to = trim((string) ($filters['customer_review_to'] ?? '')); + if ($from === '' || $to === '') { + return ['ready' => true, 'total' => 0.0, 'rows' => []]; + } + + $latest = $connection->table($dataset->targetTable()) + ->selectRaw('MAX(Id) AS id') + ->groupBy('SourceGuid'); + $main = $connection->table($dataset->targetTable() . ' AS v') + ->joinSub($latest, 'latest_install', static fn ($join) => $join->on('latest_install.id', '=', 'v.Id')) + ->select(['v.SourceGuid', 'v.shop_name', 'v.installCompanyName', 'v.costWay']); + $this->applyShipmentCutoff($main); + + $query = $connection->table($items->targetTable() . ' AS i') + ->joinSub($main, 'install_main', static fn ($join) => + $join->on('install_main.SourceGuid', '=', 'i.guid')) + ->whereNotNull('i.kfshTime') + ->where('i.kfshTime', '>=', $from) + ->where('i.kfshTime', '<', \Illuminate\Support\Carbon::parse($to)->addDay()->toDateString()); + + $rows = $query + ->selectRaw("COALESCE(NULLIF(TRIM(install_main.shop_name), ''), '未填写店铺') AS store_name") + ->selectRaw("COALESCE(NULLIF(TRIM(install_main.installCompanyName), ''), '未填写安装公司') AS installation_company") + ->selectRaw("CASE WHEN install_main.costWay IS NULL OR TRIM(install_main.costWay) = '' THEN '包安装' ELSE '不包安装' END AS payment_channel") + ->selectRaw('ROUND(SUM(CAST(i.settleItemAmount AS DECIMAL(18,2))), 2) AS amount') + ->groupByRaw( + "COALESCE(NULLIF(TRIM(install_main.shop_name), ''), '未填写店铺')," + . " COALESCE(NULLIF(TRIM(install_main.installCompanyName), ''), '未填写安装公司')," + . " CASE WHEN install_main.costWay IS NULL OR TRIM(install_main.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']] + <=> [abs($left['amount']), $right['store_name'], $right['installation_company']] + ) + ->values() + ->all(); + + return [ + 'ready' => true, + 'total' => round(array_sum(array_column($rows, 'amount')), 2), + 'rows' => $rows, + ]; + } + + 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 $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 $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 ? '已安排' : '未安排'; + } +} diff --git a/modules/EcommerceFinance/Repositories/InstallationFeeSummaryRepository.php b/modules/EcommerceFinance/Repositories/InstallationFeeSummaryRepository.php deleted file mode 100644 index a3e96dff..00000000 --- a/modules/EcommerceFinance/Repositories/InstallationFeeSummaryRepository.php +++ /dev/null @@ -1,150 +0,0 @@ -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} - */ - 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); - } -} diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index ad27c894..638ebe6c 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -1454,7 +1454,6 @@ @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], @@ -1468,39 +1467,23 @@
金额指标 - 点击任一指标查看明细汇总;安装费金额按所选月份的发货时间归集。 + 点击任一指标,查看平台、店铺与账单项目明细;明细合计会与指标总额自动核对。
@foreach ($receiptMetricCards as $metricCard) - @php - $isInstallationFee = !empty($metricCard['installation_fee']); - @endphp - @if ($isInstallationFee) - - @else - - @endif + @endforeach
@@ -1634,61 +1617,6 @@ - -
-
-

安装费金额 · 明细汇总

-

按发货时间汇总店铺、安装公司和包安装分类

-
-
-
- 当前筛选合计(元) - ¥0.00 -
- -
-
-
- - - -
-
-
- - - - - - - - - - -
店铺名称安装公司收款渠道结算明细金额
-
正在读取安装费明细…
-
-
-
- 共 0 条明细汇总 -
-
-
@@ -2732,161 +2660,6 @@ } })(); - @elseif (in_array(($page['slug'] ?? ''), ['inventory-report', 'monthly-weighted-average-price'], true))