yueyang d9535cf9b8 业绩明细汇总拆开复杂差额规则
月度汇总先按默认差额聚合,只有实收/应付兜底行再计算完整差额原因。7月验证结果一致,汇总耗时由约3.49秒降到0.71秒。
2026-10-19 18:15:40 +08:00

8492 lines
396 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers\Business;
use App\Business\BusinessTableImporter;
use App\Business\InventoryWarehouseNameMappingWorkbookImporter;
use App\Business\InventoryWarehouseShortNames;
use App\Business\CourierPayableSummaryFilter;
use App\Business\CourierPayableSummaryStore;
use App\Business\FundForecastTemplate;
use App\Business\TableRegistry;
use App\Business\UpstreamBusinessInformation;
use App\Business\StoreReceivingAccountTemplate;
use App\Business\StoreReceivingAccountWorkbookImporter;
use App\Business\StoreViolationWorkbookImporter;
use App\Business\StoreViolationAlertCalculator;
use App\Business\WithdrawalManagementReport;
use App\Jobs\Common\InventoryReportExport;
use App\Models\Banking\MarketingAccountBill;
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\Business\CourierSurchargeAuditLogRepository;
use App\Business\CourierSurchargeQuoteRepository;
use App\Business\CourierSurchargeTypeRepository;
use App\Models\StoreReceivables\StoreZhongcaiReconciliation;
use App\Utilities\SidebarPermissions;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Illuminate\Database\QueryException;
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\View\BillOffsetPageData;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository;
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\InternalFlowReportRepository;
use Modules\EcommerceFinance\Repositories\JdPromotionAccountBalanceRepository;
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository;
use Modules\EcommerceFinance\Repositories\DingPayableWorkflowMirrorRepository;
use Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository;
use Modules\EcommerceFinance\Repositories\SmallPaymentWorkOrderMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\AccountMappingValidator;
use Modules\EcommerceFinance\Services\DingTalkApprovalAttachmentDownloader;
use Modules\EcommerceFinance\Services\StoreReportInitialPeriods;
use Modules\EcommerceFinance\Services\TaxForecastLogic;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Modules\EcommerceFinance\Services\SupplierPayableMaintenanceRegistry;
use Modules\EcommerceFinance\Services\PayablesFinalRegistry;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Modules\EcommerceFinance\View\PlatformPromotionAccountPageData;
use Modules\EcommerceFinance\View\OrderDataPageData;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Throwable;
class Pages extends Controller
{
private const EDGE_BANDING_SOURCE = 'jiansun';
private const INVENTORY_SOURCE = 'jiansun';
private const RAW_BOARD_SOURCE = 'jiansun';
/**
* 「终版」目录只提供空的表结构,数据由各自按表导入,不解析其他目录的既有页面内容。
*/
private const PAYABLES_FINAL_SECTIONS = ['supplier-payables-final', 'courier-payables-final'];
/** 业绩明细的平台筛选顺序(与店铺收款账户账单页签一致)。 */
private const RECEIPT_PLATFORM_OPTIONS = [
'淘系支付宝',
'淘系微信',
'拼多多',
'京东账单',
'京喜账单',
'京东网银钱包',
'抖音',
'得物',
];
/**
* 京东「直营补贴款」备注适用的京喜店铺(前缀)。
*
* 这些京喜自营工厂店的负差额 = 京麦结算单「非销售单」里的「直营补贴款」(费用项 50241,
* 大店售价低于供货价的差价),已逐店在结算单里逐单核实:
* 无退款的行走到结算单开票核销后收回;订单已退款的行走「取消退款」,补贴款已随退款冲回。
*/
private const JINGXI_SUBSIDY_STORE_PREFIXES = [
'京喜-出类拔萃',
'京喜-胜思创博家具',
'京喜-久伴吾家具',
'京喜-悦家家居',
'京喜-蓄势待发之高级篇',
'京喜-日进斗金',
'京喜-上彩家具',
'京喜-美栖家家具',
'京喜-荣屹家居',
'京喜-亿家达京喜自营',
];
/** 业绩明细「匹配状态」筛选项:只保留基础匹配状态。 */
private const RECEIPT_STATUS_OPTIONS = [
'已匹配',
'未匹配',
];
/** 差额按实收/应付口径兜底时使用的备注;这部分行才需要判断复杂差额原因。 */
private const RECEIPT_BASIS_FALLBACK_REMARKS = [
'订单实付按卖家实收核对',
'订单实付按应付/已付核对',
];
/** 业绩明细「异常标注」筛选项,取值与同步命令写入的标注一致。 */
private const RECEIPT_REMARK_OPTIONS = [
'账单无单号',
'单号格式异常',
'订单表无此单',
'订单无商品金额',
'无收款账单',
'店铺名不一致',
'订单侧同号多条',
'同单多笔收款',
];
/**
* 「未匹配原因」筛选项:未匹配行库里的原因标注(与同步命令写入的 remark 一致)。
*
* @return list<string>
*/
private static function unmatchedReasonOptions(): array
{
$basis = self::differenceBasis();
return [
'账单无单号',
'账单无单号(采销收款)',
'单号格式异常',
'订单表无此单',
'1688采销收款(非销售单)',
$basis['no_amount_remark'],
'无收款账单',
'订单实付按卖家实收核对',
'订单实付按应付/已付核对',
];
}
/**
* 比对基准(差额 / 匹配状态 / 差额异常备注):
* paid(默认)=订单实付金额;product=订单商品金额(旧口径)。
*/
private static function differenceBasis(): array
{
$paid = (string) config('performance_details.difference_basis', 'paid') !== 'product';
return [
'column' => $paid ? 'paid_amount' : 'product_amount',
'label' => $paid ? '订单实付' : '商品金额',
'no_amount_remark' => $paid ? '订单无实付金额' : '订单无商品金额',
];
}
/**
* 「订单实付含已退款部分」的聚水潭证据 SQL(仅抖音负差额行计算,CASE 守卫保证其它行不触发子查询)。
*
* 退款单:经订单 o_id 关联(so_id 列没有索引,订单侧走 so_id 索引、退款单侧走 o_id 索引),
* 只统计 SUCCESS 且非 Split 父单的叶子退款;分别取合计与单笔最大值。
* 取消子单:同一单号下 ops_jst_orders 状态为 Cancelled 的买家实付合计。
* 两表按 o_id / as_id 唯一(无跨代重复行),子查询保持扁平以避免 MariaDB 对 FROM 派生表引用外层的限制。
*
* @return array{guard:string, refund_sum:string, refund_max:string, cancelled_sum:string}
*/
private static function receiptRefundEvidenceSql(): array
{
$guard = "platform = '抖音' AND COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)) < -0.005";
$refundWhere = "o.so_id = store_receipt_order_match_rows.online_order_no
AND rf.refund > 0 AND rf.shop_status LIKE '%SUCCESS%' AND COALESCE(rf.status, '') NOT LIKE '%Split%'";
return [
'guard' => $guard,
'refund_sum' => "(SELECT COALESCE(SUM(rf.refund), 0)
FROM ops_jst_orders o JOIN ops_jst_return_refund_info rf ON rf.o_id = o.o_id
WHERE {$refundWhere})",
'refund_max' => "(SELECT COALESCE(MAX(rf.refund), 0)
FROM ops_jst_orders o JOIN ops_jst_return_refund_info rf ON rf.o_id = o.o_id
WHERE {$refundWhere})",
'cancelled_sum' => "(SELECT COALESCE(SUM(o.buyer_paid_amount), 0) FROM ops_jst_orders o
WHERE o.so_id = store_receipt_order_match_rows.online_order_no AND o.status = 'Cancelled')",
];
}
/**
* 得物「联合营销费未让利」证据 SQL(仅得物负差额行计算,CASE 守卫保证其它行不触发子查询)。
*
* 得物联合营销费 = 让利买家部分(整数)+ 商家自担尾数;订单实付只扣了让利部分,
* 未让利尾数 = 营销费支出 − 让利,即「营销费支出 + 差额」恰为整数;
* 对不上(差值非整)时不标注,避免误标。
*
* @return array{guard:string, marketing_expense:string}
*/
private static function receiptDewuMarketingEvidenceSql(): array
{
$guard = "platform = '得物' AND COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)) < -0.005";
return [
'guard' => $guard,
'marketing_expense' => "(SELECT COALESCE(SUM(b.expense_amount), 0)
FROM dewu_settlement_bill_rows b
WHERE b.online_order_no = store_receipt_order_match_rows.online_order_no
AND b.bill_item = '联合营销费')",
];
}
/**
* 京喜「订单实付取供货价」证据 SQL(仅京喜正差额行计算,CASE 守卫保证其它行不触发子查询)。
*
* 京喜工厂店/直营为供货模式:账单按「应收 − 直营服务费 = 供货价(净打款)」结算,
* 聚水潭「订单实付」记的是供货价(净收);差额 = 该单直营服务费规模(收/支较大一边)。
*
* @return array{guard:string, supply_fee:string}
*/
private static function receiptJingxiSupplyEvidenceSql(): array
{
$guard = "platform = '京喜账单' AND COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)) > 0.005";
// 京喜账单按「活动代」保留多代历史导入:只认与本月重叠的活动代(与同步命令同一口径)。
// 不按代过滤时,旧代 + 新代会把同一笔直营服务费算两遍(例如 3,432 被算成 6,864),
// 供货价规则整月对不上,全部落进「差额待核实」。没有活动代登记的月份保持原口径。
$generation = "AND (
NOT EXISTS (SELECT 1 FROM jd_daily_bill_periods p
WHERE p.period_start < DATE_ADD(store_receipt_order_match_rows.report_month, INTERVAL 1 MONTH)
AND p.period_end > store_receipt_order_match_rows.report_month)
OR b.generation IN (SELECT p.active_generation FROM jd_daily_bill_periods p
WHERE p.period_start < DATE_ADD(store_receipt_order_match_rows.report_month, INTERVAL 1 MONTH)
AND p.period_end > store_receipt_order_match_rows.report_month)
)";
return [
'guard' => $guard,
'supply_fee' => "(SELECT GREATEST(COALESCE(SUM(b.income), 0), COALESCE(SUM(b.expense), 0))
FROM jd_daily_bill_rows b
WHERE b.order_no = store_receipt_order_match_rows.online_order_no
AND b.bill_item = '直营服务费'
{$generation})",
'supply_net' => "(SELECT ABS(COALESCE(SUM(b.income), 0) - COALESCE(SUM(b.expense), 0))
FROM jd_daily_bill_rows b
WHERE b.order_no = store_receipt_order_match_rows.online_order_no
AND b.bill_item = '直营服务费'
{$generation})",
'gross_up_fee' => "(SELECT COALESCE(SUM(b.expense), 0)
FROM jd_daily_bill_rows b
WHERE b.order_no = store_receipt_order_match_rows.online_order_no
AND b.bill_item = '直营服务费'
{$generation})",
];
}
/**
* 「差额异常备注」原因(不落库):说明差额为什么产生,一行最多一个,按顺序取第一条命中。
* 未匹配行 → 订单实付未匹配(细分原因见「未匹配原因」列);已匹配但基准金额=0 → 订单实付取数为0;
* 基准金额没有减掉优惠/冲减时,差额正好等于对应项。差额按有效值判定(未匹配按基准金额=0)。
*
* @return array<string,string> 备注文字 => SQL 条件(已按顺序排除前面命中的行)
*/
private static function receiptDifferenceRemarkConditions(): array
{
$basis = self::differenceBasis();
$column = $basis['column'];
$label = $basis['label'];
$rules = [];
$effective = 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))';
$rules[$label . '未匹配'] = "match_status = '未匹配'";
$rules[$label . '取数为0(种菜)'] = "match_status = '已匹配' AND planting_amount IS NOT NULL AND ABS(COALESCE($column, 0)) < 0.005";
$rules[$label . '取数为0'] = "match_status = '已匹配' AND planting_amount IS NULL AND ABS(COALESCE($column, 0)) < 0.005";
// 得物「订单实付未减联合营销费」:负差额 = 联合营销费未让利尾数(营销费支出 + 差额恰为整数)才标注。
// 放在通用「未减冲减/优惠」等式之前:得物这类行的差额常恰好等于冲减(营销费)金额,需优先给出具体项备注。
$dewuEvidence = self::receiptDewuMarketingEvidenceSql();
$dewuNeat = '(' . $dewuEvidence['marketing_expense'] . ' + ' . $effective . ')';
$rules['订单实付未减联合营销费'] = 'CASE WHEN (' . $dewuEvidence['guard']
. ' AND (' . $dewuEvidence['marketing_expense'] . ') > 0.005'
. ' AND ABS(' . $dewuNeat . ' - ROUND(' . $dewuNeat . ', 0)) < 0.005) THEN 1 ELSE 0 END = 1';
// 京喜「订单实付取供货价」:正差额 = 该单直营服务费规模(供货模式的净额差)才标注。
$jingxiEvidence = self::receiptJingxiSupplyEvidenceSql();
$rules['订单实付取供货价'] = 'CASE WHEN (' . $jingxiEvidence['guard']
. ' AND (' . $jingxiEvidence['supply_fee'] . ') > 0.005'
. ' AND (ABS(' . $effective . ' - (' . $jingxiEvidence['supply_fee'] . ')) < 0.005'
. ' OR ABS(' . $effective . ' - (' . $jingxiEvidence['supply_net'] . ')) < 0.005)) THEN 1 ELSE 0 END = 1';
// 京喜正差额:账单「货款(收)」把直营服务费一起计入收入(货款 = 商品金额 + 直营服务费),
// 聚水潭「订单实付」是买家实付;差额 = 直营服务费 −(实付−商品金额)。
// 宜自然 44 行逐单对平,全京喜同形态近千行(2026-10-19 核)。
$jingxiGrossUp = "platform = '京喜账单' AND " . $effective . ' > 0.005'
. ' AND COALESCE(product_amount, 0) > 0.005'
. ' AND (' . $jingxiEvidence['gross_up_fee'] . ') > 0.005'
. ' AND ABS((COALESCE(income_amount, 0) - COALESCE(product_amount, 0)) - (' . $jingxiEvidence['gross_up_fee'] . ')) < 0.02';
$rules['差额=货款含直营服务费(实付低于商品金额)'] = $jingxiGrossUp
. ' AND COALESCE(paid_amount, 0) < COALESCE(product_amount, 0) - 0.03';
$rules['差额=货款含直营服务费(拆单/合并尾差)'] = $jingxiGrossUp;
// 京喜自营工厂店负差额:差额=京东「直营补贴款」(大店售价低于供货价的差价,见京麦费用项 50241),
// 无退款的行走到结算单开票核销后收回;订单已退款的行走「取消退款」,补贴款已随退款冲回(无应收)。
// 店铺范围:① 出类拔萃 + 9 家已逐店核验的店铺(JINGXI_SUBSIDY_STORE_PREFIXES);
// ② 其余京喜自营工厂店按「实付≈商品金额(供货价口径)」特征自动识别(含个别非「工厂店」命名的自营店),
// 避免未提供后台账号的店铺/新店再次落进「差额待核实」。
$jingxiSubsidyStoreLikes = implode(' OR ', array_map(
static fn (string $prefix): string => "store_name LIKE '" . $prefix . "%'",
self::JINGXI_SUBSIDY_STORE_PREFIXES
));
$jingxiSubsidyFactoryStores = "(store_name LIKE '京喜-%工厂店%' OR store_name LIKE '京喜-香幽竹%' OR store_name LIKE '京喜-清幽竹香%')";
$jingxiSubsidySignature = 'ABS(COALESCE(paid_amount, 0) - COALESCE(product_amount, 0)) < 0.02';
$jingxiSubsidyGuard = "platform = '京喜账单' AND ((" . $jingxiSubsidyStoreLikes . ') OR (' . $jingxiSubsidyFactoryStores . ' AND ' . $jingxiSubsidySignature . ')) AND ' . $effective . ' < -0.005';
$rules['差额=直营补贴款(待开票结算)'] = $jingxiSubsidyGuard . ' AND ABS(COALESCE(refund_amount, 0)) < 0.005';
$rules['差额=直营补贴款(订单退款已冲回)'] = $jingxiSubsidyGuard . ' AND ABS(COALESCE(refund_amount, 0)) >= 0.005';
// 京东「索菲亚客厅 / 办公 / 书房 / 功能」「亿家达全屋」「朴美家居」负差额:订单实付(聚水潭买家实付)
// 口径包含商家承担优惠(店铺东券/单品折扣/调价优惠等);差额里含国补的单再并上以旧换新国补。
// 已用京东「营销对账」导出逐单核实(索菲亚客厅/办公 131 单 + 书房/亿家达全屋/朴美 40 单 +
// 索菲亚功能 8 单)。索菲亚智能店负差不属本类(价保扣款),未纳入。
// 含国补判定:聚水潭应付(主单优先 pay_amount)− 账单收入 > 0.005——该差额恰等于营销对账里
// 未计入聚水潭优惠金额的国补等补贴类金额(171 单对照:70 个含国补单全部命中;仅办公 6 月
// 3544492014564334 的「折上折补贴」7 元属非国补但会被归入,核对记录见营销对账核验)。
$jdSofiaGuard = "platform = '京东账单' AND (store_name LIKE '京东-索菲亚客厅%' OR store_name LIKE '京东-索菲亚办公%'"
. " OR store_name LIKE '京东-索菲亚书房%' OR store_name LIKE '京东-索菲亚功能%'"
. " OR store_name LIKE '京东-亿家达全屋%' OR store_name LIKE '京东-朴美家居%')"
. ' AND ' . $effective . ' < -0.005';
$jdSofiaPay = 'COALESCE((SELECT COALESCE(MAX(CASE WHEN o.link_o_id IS NULL OR o.link_o_id = 0 THEN o.pay_amount END),'
. " MAX(CASE WHEN o.merge_so_id IS NULL OR o.merge_so_id = '' THEN o.pay_amount END),"
. ' MAX(o.pay_amount)) FROM ops_jst_orders o WHERE o.so_id = store_receipt_order_match_rows.online_order_no), 0)';
$jdSofiaGuobu = '(' . $jdSofiaPay . ' - COALESCE(income_amount, 0)) > 0.005';
$rules['差额=订单实付包含商家承担优惠和以旧换新国补'] = $jdSofiaGuard . ' AND ' . $jdSofiaGuobu;
$rules['差额=订单实付包含商家承担优惠'] = $jdSofiaGuard . ' AND NOT (' . $jdSofiaGuobu . ')';
// C 方案:这三条是"通用等式档"——口径(卖家实收 / 应付已付)能对上账单的行让位给口径兜底
// (归零 + 口径标识),只有口径也对不上时才显示这三档,避免把口径差标成一堆"未减优惠/冲减"。
$notBasisMarked = "COALESCE(remark, '') NOT IN ('订单实付按卖家实收核对', '订单实付按应付/已付核对')";
$rules[$label . '未减冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(discount_amount, 0))) < 0.005 AND " . $notBasisMarked;
$rules[$label . '未减优惠'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(offset_amount, 0))) < 0.005 AND " . $notBasisMarked;
$rules[$label . '未减优惠和冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - COALESCE(income_amount, 0)) < 0.005 AND " . $notBasisMarked;
// 「订单实付含已退款部分」:
// 抖音——负差额能在聚水潭退款单/取消子单金额里对上(≈差额,或放宽口径 ≈实付−收入=差额+优惠+冲减)才标注。
// 结算后退款会叠在退款合计上(账单里已单独记「退款」):退款合计+账单退款净额≈实付−收入 同样命中。
// 整单全退(退款合计≈实付)也视为命中:全退后收入只剩后续零星收款(如用户打款)。
// 淘系支付宝/微信——订单在企微订单表:负差额=聚水潭退款合计(仅退款/退款、已确认)时标注;
// 走「订单号 → o_id → 退款表」索引链,CASE 守卫保证其它行不触发子查询。
$refundEvidence = self::receiptRefundEvidenceSql();
$differenceAbs = 'ABS(' . $effective . ')';
$refundHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS(' . $amount . ' - ' . $differenceAbs . ') < 0.02 ELSE 0 END';
$refundGapHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS(' . $amount . ' - (COALESCE(paid_amount, 0) - COALESCE(income_amount, 0))) < 0.02 ELSE 0 END';
$refundGapNetHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS((' . $amount . ' + COALESCE(refund_amount, 0)) - (COALESCE(paid_amount, 0) - COALESCE(income_amount, 0))) < 0.02 ELSE 0 END';
$refundFullHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS(' . $amount . ' - COALESCE(paid_amount, 0)) < 0.02 ELSE 0 END';
// 用 EXISTS + HAVING 让聚合只算一次:SUM 在 HAVING 里被引用多次不会重复执行子查询,
// 条件整段在多处复用时冷热耗时都稳定(实测从 ~17s 降到 ~1.8s)。
$taoRefundSumExpr = 'SUM(CAST(qr.refund AS DECIMAL(18,2)))';
$taoRefundHit = 'CASE WHEN (platform IN (\'淘系支付宝\', \'淘系微信\') AND ' . $effective . ' < -0.005) THEN ('
. 'EXISTS (SELECT 1 FROM ops_jst_qmcust_orders qo JOIN ops_jst_qmcust_refunds qr ON qr.o_id = qo.o_id
WHERE qo.so_id = store_receipt_order_match_rows.online_order_no AND qr.status = \'Confirmed\'
HAVING ' . $taoRefundSumExpr . ' > 0.005
AND (ABS(' . $taoRefundSumExpr . ' - ' . $differenceAbs . ') < 0.02'
. ' OR ABS(' . $taoRefundSumExpr . ' - (COALESCE(paid_amount, 0) - COALESCE(income_amount, 0))) < 0.02'
. ' OR ABS(' . $taoRefundSumExpr . ' - COALESCE(paid_amount, 0)) < 0.02))) ELSE 0 END = 1';
$rules['订单实付含已退款部分'] = '(CASE WHEN (' . $refundEvidence['guard'] . ') THEN ('
. $refundHit($refundEvidence['refund_sum'])
. ' OR ' . $refundHit($refundEvidence['refund_max'])
. ' OR ' . $refundHit($refundEvidence['cancelled_sum'])
. ' OR ' . $refundGapHit($refundEvidence['refund_sum'])
. ' OR ' . $refundGapHit($refundEvidence['refund_max'])
. ' OR ' . $refundGapHit($refundEvidence['cancelled_sum'])
. ' OR ' . $refundGapNetHit($refundEvidence['refund_sum'])
. ' OR ' . $refundFullHit($refundEvidence['refund_sum'])
. ') ELSE 0 END = 1) OR ' . $taoRefundHit;
// 京喜分位尾差:具体原因都没命中时,1–3 分钱按四舍五入尾差标注。
$rules['差额=分位四舍五入尾差'] = "platform = '京喜账单' AND ABS(" . $effective . ') <= 0.0301 AND ' . $notBasisMarked;
$conditions = [];
$seen = [];
foreach ($rules as $label => $condition) {
$condition = 'ABS(' . $effective . ') > 0.005 AND (' . $condition . ')';
$conditions[$label] = $seen === [] ? $condition : $condition . ' AND NOT (' . implode(' OR ', $seen) . ')';
$seen[] = '(' . $condition . ')';
}
// 兜底档:有差额但没命中任何原因 →「差额待核实」(与页面渲染的兜底备注保持一致)。
$conditions['差额待核实'] = 'ABS(' . $effective . ') > 0.005 AND NOT (' . implode(' OR ', $seen) . ') AND ' . $notBasisMarked;
return $conditions;
}
/**
* 该单的聚水潭应付(主单优先 pay_amount)是否大于账单收入(= 含国补/补贴类差额的口径)。
*
* 与 receiptDifferenceRemarkConditions() 里的聚水潭应付款子查询同口径;按单号缓存应付金额,
* 页面渲染与导出逐行调用时不会对同一单重复查询。
*/
private static function receiptJdSubsidyPayExceedsIncome(string $orderNo, float $income): bool
{
static $payCache = [];
$orderNo = trim($orderNo);
if ($orderNo === '') {
return false;
}
if (! array_key_exists($orderNo, $payCache)) {
$row = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('ops_jst_orders')
->where('so_id', $orderNo)
->selectRaw("COALESCE(MAX(CASE WHEN link_o_id IS NULL OR link_o_id = 0 THEN pay_amount END), MAX(CASE WHEN merge_so_id IS NULL OR merge_so_id = '' THEN pay_amount END), MAX(pay_amount)) as pay")
->first();
$payCache[$orderNo] = (float) ($row->pay ?? 0);
}
return ($payCache[$orderNo] - $income) > 0.005;
}
/**
* 该单的聚水潭退款合计(仅退款/退款、已确认)。
*
* 淘系支付宝/微信订单在企微订单表:走「订单号 → o_id → 退款表」索引链;按单号缓存,
* 页面渲染与导出逐行调用时不会对同一单重复查询。与筛选条件里的子查询同口径。
*/
private static function receiptTaoRefundSum(string $orderNo): float
{
static $cache = [];
$orderNo = trim($orderNo);
if ($orderNo === '') {
return 0.0;
}
if (! array_key_exists($orderNo, $cache)) {
$row = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->selectOne(
"SELECT COALESCE(SUM(CAST(qr.refund AS DECIMAL(18,2))), 0) AS s
FROM ops_jst_qmcust_orders qo JOIN ops_jst_qmcust_refunds qr ON qr.o_id = qo.o_id
WHERE qo.so_id = ? AND qr.status = 'Confirmed'",
[$orderNo]
);
$cache[$orderNo] = (float) ($row->s ?? 0);
}
return $cache[$orderNo];
}
/** 京喜「直营补贴款」备注是否适用于该行(10 家核验店 + 工厂店按实付≈商品金额特征,与筛选条件同口径)。 */
private static function jingxiSubsidyApplies(object $row): bool
{
$storeName = (string) ($row->store_name ?? '');
foreach (self::JINGXI_SUBSIDY_STORE_PREFIXES as $prefix) {
if (str_starts_with($storeName, $prefix)) {
return true;
}
}
$factoryStore = str_contains($storeName, '工厂店')
|| str_starts_with($storeName, '京喜-香幽竹')
|| str_starts_with($storeName, '京喜-清幽竹香');
if (! $factoryStore) {
return false;
}
return abs((float) ($row->paid_amount ?? 0) - (float) ($row->product_amount ?? 0)) < 0.02;
}
/** 单行的「差额异常备注」(与筛选条件同一套口径,页面与导出共用)。 */
/**
* 页面口径的「有效差额」SQL:口径兜底行(备注为空、但能对上实收/应付)按 0 计。
* 合计、差额筛选、联动计数共用,保证三个地方的数字一致。
*/
private static function receiptEffectiveDifferenceSql(): string
{
// 注意:要把「差额待核实」这条兜底规则排除掉,否则它本身就是"都没命中",
// 会让这里永远不成立(口径兜底行就归不了零)。
$rules = self::receiptDifferenceRemarkConditions();
unset($rules['差额待核实']);
$ruleUnion = '(' . implode(' OR ', array_map(
static fn (string $condition): string => '(' . $condition . ')',
$rules
)) . ')';
return "CASE WHEN remark IN (" . self::receiptBasisFallbackSqlList() . ')'
. " AND NOT COALESCE({$ruleUnion}, 0) THEN 0"
. ' ELSE ' . self::receiptDefaultDifferenceSql() . ' END';
}
/** 未命中复杂原因时,差额的默认口径。 */
private static function receiptDefaultDifferenceSql(): string
{
return 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))';
}
/**
* 口径兜底行的差额修正量:只对「实收/应付核对」行计算复杂规则,
* 避免把京东、京喜、得物、淘系退款等关联子查询放进整月汇总的每一行。
*/
private static function receiptFallbackDifferenceAdjustmentSql(): string
{
$rules = self::receiptDifferenceRemarkConditions();
unset($rules['差额待核实']);
$ruleUnion = '(' . implode(' OR ', array_map(
static fn (string $condition): string => '(' . $condition . ')',
$rules
)) . ')';
return 'CASE WHEN NOT COALESCE(' . $ruleUnion . ', 0) THEN -('
. self::receiptDefaultDifferenceSql() . ') ELSE 0 END';
}
/** @return string SQL 的字符串字面量列表 */
private static function receiptBasisFallbackSqlList(): string
{
return implode(', ', array_map(
static fn (string $remark): string => "'" . str_replace("'", "''", $remark) . "'",
self::RECEIPT_BASIS_FALLBACK_REMARKS
));
}
private static function receiptDifferenceRemark(object $row): ?string
{
$basis = self::differenceBasis();
$label = $basis['label'];
$difference = $row->difference === null
? (float) ($row->income_amount ?? 0) + (float) ($row->discount_amount ?? 0) + (float) ($row->offset_amount ?? 0)
: (float) $row->difference;
if (abs($difference) < 0.005) {
return null;
}
if (($row->match_status ?? '') === '未匹配') {
// 细分原因放到「未匹配原因」列,这里只说明差额是怎么来的。
return $label . '未匹配';
}
$product = (float) ($row->{$basis['column']} ?? 0);
$income = (float) ($row->income_amount ?? 0);
$discount = (float) ($row->discount_amount ?? 0);
$offset = (float) ($row->offset_amount ?? 0);
if (abs($product) < 0.005) {
return ($row->planting_amount ?? null) !== null ? $label . '取数为0(种菜)' : $label . '取数为0';
}
// 得物负差额:差额与联合营销费未让利尾数吻合(营销费支出 + 差额恰为整数)→「订单实付未减联合营销费」。
// 放在通用「未减冲减/优惠」等式之前:得物这类行的差额常恰好等于冲减(营销费)金额,需优先给出具体项备注。
if (($row->platform ?? '') === '得物' && $difference < -0.005) {
$marketingExpense = (float) ($row->order_dewu_marketing_expense ?? 0);
if ($marketingExpense > 0.005) {
$neat = $marketingExpense + $difference;
if (abs($neat - round($neat)) < 0.005) {
return '订单实付未减联合营销费';
}
}
}
// 京喜正差额:差额与直营服务费规模吻合(订单实付取的是供货价/净收)→「订单实付取供货价」。
if (($row->platform ?? '') === '京喜账单' && $difference > 0.005) {
$supplyFee = (float) ($row->order_jingxi_supply_fee ?? 0);
$supplyNet = (float) ($row->order_jingxi_supply_net ?? 0);
if ($supplyFee > 0.005
&& (abs($difference - $supplyFee) < 0.005 || abs($difference - $supplyNet) < 0.005)) {
return '订单实付取供货价';
}
}
// 京喜正差额:账单货款含直营服务费(货款 = 商品金额 + 直营服务费),
// 差额 = 直营服务费 −(实付−商品金额)。实付低于商品金额的单独标注。
if (($row->platform ?? '') === '京喜账单' && $difference > 0.005) {
$productAmount = (float) ($row->product_amount ?? 0);
$supplyFee = (float) ($row->order_jingxi_gross_up_fee ?? 0);
if ($productAmount > 0.005 && $supplyFee > 0.005
&& abs(($income - $productAmount) - $supplyFee) < 0.02) {
return (float) ($row->paid_amount ?? 0) < $productAmount - 0.03
? '差额=货款含直营服务费(实付低于商品金额)'
: '差额=货款含直营服务费(拆单/合并尾差)';
}
}
// 京喜自营工厂店负差额:差额=京东「直营补贴款」(无退款→待开票结算;订单已退款→补贴款已随退款冲回)。
if (($row->platform ?? '') === '京喜账单' && $difference < -0.005
&& self::jingxiSubsidyApplies($row)) {
return abs((float) ($row->refund_amount ?? 0)) >= 0.005
? '差额=直营补贴款(订单退款已冲回)'
: '差额=直营补贴款(待开票结算)';
}
// 京东「索菲亚客厅 / 办公 / 书房 / 功能」「亿家达全屋」「朴美家居」负差额:订单实付口径包含商家承担
// 优惠;含国补(聚水潭应付−账单收入>0.005,与下方条件同口径)的再并上以旧换新国补(营销对账已逐单核实)。
if (($row->platform ?? '') === '京东账单' && $difference < -0.005
&& (str_starts_with((string) ($row->store_name ?? ''), '京东-索菲亚客厅')
|| str_starts_with((string) ($row->store_name ?? ''), '京东-索菲亚办公')
|| str_starts_with((string) ($row->store_name ?? ''), '京东-索菲亚书房')
|| str_starts_with((string) ($row->store_name ?? ''), '京东-索菲亚功能')
|| str_starts_with((string) ($row->store_name ?? ''), '京东-亿家达全屋')
|| str_starts_with((string) ($row->store_name ?? ''), '京东-朴美家居'))) {
return self::receiptJdSubsidyPayExceedsIncome((string) ($row->online_order_no ?? ''), (float) $income)
? '差额=订单实付包含商家承担优惠和以旧换新国补'
: '差额=订单实付包含商家承担优惠';
}
// C 方案:口径标识行让位给口径兜底,这三条通用等式档只对"口径也对不上"的行生效。
$basisMarked = in_array(
trim((string) ($row->remark ?? '')),
['订单实付按卖家实收核对', '订单实付按应付/已付核对'],
true
);
if (! $basisMarked && abs($product - ($income + $discount)) < 0.005) {
return $label . '未减冲减';
}
if (! $basisMarked && abs($product - ($income + $offset)) < 0.005) {
return $label . '未减优惠';
}
if (! $basisMarked && abs($product - $income) < 0.005) {
return $label . '未减优惠和冲减';
}
// 抖音负差额:实付多出来的部分能在聚水潭退款单/取消子单金额里对上
// (≈差额,或放宽口径 ≈实付−收入=差额+优惠+冲减)→「订单实付含已退款部分」。
// 结算后退款会叠在退款合计上(账单里已单独记「退款」):退款合计+账单退款净额≈实付−收入 同样命中。
// 整单全退(退款合计≈实付)也视为命中:全退后收入只剩后续零星收款(如用户打款)。
if (($row->platform ?? '') === '抖音' && $difference < 0) {
$refundGap = (float) ($row->paid_amount ?? 0) - $income;
$refunds = [$row->order_refund_sum ?? null, $row->order_refund_max ?? null, $row->order_cancelled_sum ?? null];
foreach ($refunds as $refund) {
if ($refund === null || (float) $refund <= 0.005) {
continue;
}
$refundAmount = (float) $refund;
if (abs($refundAmount - abs($difference)) < 0.02 || abs($refundAmount - $refundGap) < 0.02) {
return '订单实付含已退款部分';
}
}
$refundSum = $row->order_refund_sum ?? null;
$refundNet = (float) ($row->refund_amount ?? 0);
$refundFull = (float) ($row->paid_amount ?? 0);
if ($refundSum !== null && (float) $refundSum > 0.005
&& (abs((float) $refundSum + $refundNet - $refundGap) < 0.02 || abs((float) $refundSum - $refundFull) < 0.02)) {
return '订单实付含已退款部分';
}
}
// 淘系支付宝/微信负差额:与抖音同家族口径——差额=退款合计,或退款合计=实付−收入(收款缺口),
// 或退款合计≈实付(整单全退)→「订单实付含已退款部分」(退款取聚水潭已确认记录)。
if (in_array((string) ($row->platform ?? ''), ['淘系支付宝', '淘系微信'], true) && $difference < -0.005) {
$taoRefundSum = self::receiptTaoRefundSum((string) ($row->online_order_no ?? ''));
$refundGap = (float) ($row->paid_amount ?? 0) - $income;
if ($taoRefundSum > 0.005
&& (abs($taoRefundSum - abs($difference)) < 0.02
|| abs($taoRefundSum - $refundGap) < 0.02
|| abs($taoRefundSum - (float) ($row->paid_amount ?? 0)) < 0.02)) {
return '订单实付含已退款部分';
}
}
// 原因优先:上面的规则都没命中时,如果这行的口径(卖家实收 / 应付已付)能对上账单,
// 就按口径兜底——差额按 0 展示、口径标识在「匹配原因」列,不再当异常。
$rawRemark = trim((string) ($row->remark ?? ''));
if (in_array($rawRemark, ['订单实付按卖家实收核对', '订单实付按应付/已付核对'], true)) {
return null;
}
// 京喜分位尾差:具体原因都没命中时,1–3 分钱按四舍五入尾差标注。
if (($row->platform ?? '') === '京喜账单' && abs($difference) <= 0.0301) {
return '差额=分位四舍五入尾差';
}
// 有差额但没命中任何已知原因:标「差额待核实」,页面上不再出现"有差额但备注空白"的行。
return '差额待核实';
}
public function __construct(
private readonly EdgeBandingRepository $edgeBanding,
private readonly InventoryRepository $inventory,
private readonly InstallationDetailMirrorRepository $installationDetails,
private readonly NonJingxiRefundMirrorRepository $nonJingxiRefunds,
private readonly SmallPaymentWorkOrderMirrorRepository $smallPaymentWorkOrders,
private readonly ProductCatalogMirrorRepository $productCatalog,
private readonly ProductPriceMirrorRepository $productPrices,
private readonly CourierInformationMirrorRepository $courierInformation,
private readonly ThirdPartyWarehouseStorageMirrorRepository $thirdPartyWarehouseStorage,
private readonly RawBoardPayablesRepository $rawBoardPayables,
private readonly DingPayableWorkflowMirrorRepository $dingPayableWorkflows,
private readonly DingOnlineDaifaPriceMirrorRepository $onlineDaifaPrices,
private readonly SupplierPayableReportRepository $supplierPayableReports,
private readonly SupplierPayableOverrideStore $supplierPayableOverrides,
private readonly SupplierPayableMaintenanceRegistry $supplierPayableMaintenance,
private readonly UpstreamQueryCacheFactory $upstreamCaches,
private readonly CourierSurchargeQuoteRepository $courierSurchargeQuotes,
private readonly CourierSurchargeTypeRepository $courierSurchargeTypes,
private readonly CourierSurchargeAuditLogRepository $courierSurchargeAudits,
private readonly WithdrawalManagementReport $withdrawalManagementReport,
private readonly CourierSurchargeSettlementMirrorRepository $courierSurchargeSettlements,
private readonly ShippingOrderRepository $shippingOrders,
) {
$this->middleware('auth');
$this->middleware('company.identify');
}
/**
* 支持的营销账户平台列表
*/
protected const MARKETING_PLATFORMS = [
'taobao-alipay', 'taobao-wechat', 'pinduoduo',
'jd-wallet', 'douyin', 'dewu', 'vipshop',
'jd-promotion', 'douyin-promotion', 'dewu-promotion', 'vip-promotion',
];
/** @var list<string> 推广账户流水已合并到营销账户账单页的页签 */
protected const PROMOTION_MARKETING_TABS = [
'jd-promotion', 'douyin-promotion', 'dewu-promotion', 'vip-promotion',
];
protected const ACCOUNT_BALANCE_SECTION = 'account-balance-data';
protected const SMALL_PAYMENT_SECTION = 'financial-operations';
/** 商品成本明细的共享镜像表(由 store-receipt:product-cost:sync 生成)。 */
protected const PRODUCT_COST_TABLE = 'store_receipt_product_cost_rows';
protected const ACCOUNT_BALANCE_PAGES = [
'bank-balance',
'store-receiving-balance',
'marketing-account-balance',
'deposit-account-balance',
];
public function home()
{
$groups = \App\Utilities\SidebarMenu::groups(user());
$sections = \App\Utilities\SidebarMenu::sections(user());
if ($groups === [] && $sections === []) {
return view('business.access-pending');
}
// 登录后先落到首页导航,不再直接跳进第一个业务页面。
return view('business.pages.home', [
'groups' => $groups,
'sections' => $sections,
]);
}
public function show(string $section, ?string $page = null, ?string $tab = null)
{
if ($section === 'refunds' && $page === 'small-payment-work-orders') {
$url = route('business.pages.show', [
'section' => self::SMALL_PAYMENT_SECTION,
'page' => $page,
]);
$query = request()->getQueryString();
return redirect()->to($url . ($query === null ? '' : '?' . $query));
}
if ($section === 'billing-transactions' && in_array($page, self::ACCOUNT_BALANCE_PAGES, true)) {
$url = route('business.pages.show', array_filter([
'section' => self::ACCOUNT_BALANCE_SECTION,
'page' => $page,
'tab' => $tab,
], static fn (mixed $value): bool => $value !== null));
$query = request()->getQueryString();
return redirect()->to($url . ($query === null ? '' : '?' . $query));
}
$sectionConfig = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', $section);
abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。');
// 附加费结算已独立到快递应付页面,旧页签地址跳到新页面。
if ($section === 'courier-payables' && $page === 'courier-surcharge' && $tab === 'settlement') {
return redirect()->route('business.pages.show', array_merge(request()->query(), [
'section' => $section,
'page' => 'courier-surcharge-settlement',
]));
}
$pages = collect($sectionConfig['pages'] ?? []);
if ($pages->isEmpty()) {
abort_if($page !== null || $tab !== null, 404);
return view('business.pages.show', $this->viewData($sectionConfig));
}
if ($page === null) {
if ($section === 'courier-payables' || in_array($section, self::PAYABLES_FINAL_SECTIONS, true)) {
return view('business.pages.show', $this->viewData($sectionConfig));
}
return $this->redirectToPage($sectionConfig, $pages->first());
}
$pageConfig = $pages
->flatMap(fn (array $configuredPage) => [
$configuredPage,
...($configuredPage['children'] ?? []),
])
->firstWhere('slug', $page);
abort_if(empty($pageConfig), 404);
if ($section === 'courier-payables' && $page === 'courier-quote') {
abort_if(empty($pageConfig['children']), 403, '当前账号没有可访问的快递报价页面。');
abort_if($tab !== null, 404);
return view('business.pages.show', $this->viewData($sectionConfig, $pageConfig));
}
if (! empty($pageConfig['children']) && empty($pageConfig['tabs'])) {
return $this->redirectToPage($sectionConfig, $pageConfig['children'][0]);
}
$tabs = collect($pageConfig['tabs'] ?? []);
if ($tabs->isEmpty()) {
// Old bookmarked tab URLs should land on the now-empty parent
// directory while the receiving-account pages are being rebuilt.
if ($tab !== null) {
return redirect()->route('business.pages.show', [
'section' => $sectionConfig['slug'],
'page' => $pageConfig['slug'],
]);
}
if ($section === 'courier-payables' && $page === 'freight-reconciliation' && request()->boolean('courier_summary')) {
return response()->json([
'data' => ['count' => 0, 'freight' => 0],
'status' => 'disabled',
'message' => '原数据源已停用,本页面不再展示原运费对账数据。',
], 200, ['Cache-Control' => 'private, no-store']);
}
return view('business.pages.show', $this->viewData(
$sectionConfig,
$pageConfig,
$tabs,
null,
$this->resolveContentForSection($section, $page, null),
));
}
if ($tab === null) {
if ($page === 'courier-surcharge') {
$activeTab = $tabs->first();
return view('business.pages.show', $this->viewData(
$sectionConfig,
$pageConfig,
$tabs,
$activeTab,
$this->resolveContent($page, $activeTab['slug']),
));
}
return $this->redirectToPage($sectionConfig, $pageConfig);
}
$activeTab = $tabs->firstWhere('slug', $tab);
abort_if(empty($activeTab), 404);
return view('business.pages.show', $this->viewData(
$sectionConfig,
$pageConfig,
$tabs,
$activeTab,
$this->resolveContentForSection($section, $page, $tab),
));
}
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]);
}
/**
* 账单明细导出:按页面同一套筛选条件流式导出 CSV(全部筛选结果)。
*/
public function exportBillingDetails(Request $request): StreamedResponse
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'billing-details'), 403, '当前账号没有该页面权限。');
$pageData = app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class);
$filters = $pageData->exportFilters($request->all());
$columns = $pageData->exportColumns(array_values(array_filter(
(array) $request->get('columns', []),
static fn ($key): bool => is_string($key) && $key !== '',
)));
$cell = static function (array $row, string $key): string {
$income = (float) ($row['income'] ?? 0);
$expense = (float) ($row['expense'] ?? 0);
return match ($key) {
'amount' => number_format($income - $expense, 2, '.', ''),
'income' => $income > 0 ? number_format($income, 2, '.', '') : '',
'expense' => $expense > 0 ? number_format($expense, 2, '.', '') : '',
default => trim((string) ($row[$key] ?? '')),
};
};
$csvText = fn (mixed $value): string => $this->csvText($value);
// 导出进度:前端带 token 发起下载,导出过程中把已导出行数写进缓存,前端轮询显示进度
$token = trim((string) $request->get('token', ''));
$token = preg_match('/^[A-Za-z0-9_-]{8,64}$/', $token) === 1 ? $token : '';
$progressKey = $token === '' ? null : self::billingDetailsProgressKey($token);
$total = $pageData->exportTotal($filters);
$putProgress = static function (array $state) use ($progressKey): void {
if ($progressKey !== null) {
\Illuminate\Support\Facades\Cache::put($progressKey, $state, now()->addMinutes(30));
}
};
$putProgress(['status' => 'running', 'rows' => 0, 'total' => $total]);
return response()->streamDownload(function () use ($pageData, $filters, $columns, $cell, $csvText, $putProgress, $total): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建账单明细导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_map(static fn (array $definition): string => $definition[0], array_values($columns)));
$written = 0;
$putProgress(['status' => 'running', 'rows' => 0, 'total' => $total]);
foreach ($pageData->exportRows($filters, $columns) as $row) {
$line = [];
foreach (array_keys($columns) as $key) {
$value = $cell($row, (string) $key);
// 数值列不做公式转义,否则负数会被写成 '-0.47
$line[] = ($columns[$key][1] ?? '') === 'num' ? $value : $csvText($value);
}
fputcsv($csv, $line);
$written++;
if ($written % 5000 === 0) {
$putProgress(['status' => 'running', 'rows' => $written, 'total' => $total]);
}
}
fclose($csv);
$putProgress(['status' => 'done', 'rows' => $written, 'total' => max($total, $written)]);
}, '账单明细_' . $filters['start'] . '_' . $filters['end'] . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** 账单明细筛选联动:按当前其它条件返回各筛选项可选值与行数 */
public function billingDetailsFilterOptions(): \Illuminate\Http\JsonResponse
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'billing-details'), 403, '当前账号没有该页面权限。');
$data = app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class);
return response()->json(['facets' => $data->facets(request()->all())], 200, [
'Cache-Control' => 'private, no-store',
]);
}
/** 账单明细导出进度(前端每秒轮询一次) */
public function billingDetailsExportProgress(Request $request): \Illuminate\Http\JsonResponse
{
$token = trim((string) $request->get('token', ''));
abort_unless(preg_match('/^[A-Za-z0-9_-]{8,64}$/', $token) === 1, 400, '导出标识无效。');
$state = \Illuminate\Support\Facades\Cache::get(self::billingDetailsProgressKey($token));
return response()->json($state ?? ['status' => 'unknown', 'rows' => 0, 'total' => 0], 200, [
'Cache-Control' => 'private, no-store',
]);
}
private static function billingDetailsProgressKey(string $token): string
{
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');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'bill-offset'), 403, '当前账号没有该页面权限。');
$repository = app(BillOffsetPageData::class);
$filters = $request->query();
$result = $repository->data($filters, 1, 100);
abort_if($result['billOffsetError'] !== null, 503, $result['billOffsetError']);
$filters = $result['billOffsetFilters'];
return response()->streamDownload(function () use ($repository, $filters, $result): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建账单冲减导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
$labels = [
'internal_order_no' => '内部订单号', 'online_order_no' => '线上订单号',
'shop_name' => '店铺名称', 'shop_code' => '店铺编码', 'product_code' => '商品编码',
'style_code' => '款式编码(参考)', 'shop_product_code' => '店铺商品编码',
'shop_style_code' => '店铺款式编码', 'account_500112' => '500112冲减收入',
];
fputcsv($csv, array_values($labels));
$current = $result;
$page = 1;
do {
foreach ($current['billOffsetRows'] as $row) {
fputcsv($csv, array_map(
function (string $field) use ($row): string {
$value = data_get($row, $field);
if ($field === 'account_500112') {
return $value === null || $value === '' ? '' : number_format((float) $value, 2, '.', '');
}
return $field === 'shop_name'
? $this->csvText($value)
: $this->csvIdentifier($value);
},
array_keys($labels),
));
}
if (! $current['billOffsetRows']->hasMorePages()) break;
$current = $repository->dataForNormalizedFilters($filters, ++$page, 100);
if ($current['billOffsetError'] !== null) {
throw new \RuntimeException('账单冲减导出未完成,请重试。');
}
} while (true);
fclose($csv);
}, '账单冲减_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/**
* 商品月加权平均单价:读取已计算的月份数据,默认最新月份。
*/
protected function monthlyWeightedAveragePriceRows(): LengthAwarePaginator
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$months = DB::connection($connection)->table('product_monthly_weighted_average_states')
->where('status', 'computed')
->orderByDesc('report_month')
->pluck('report_month')
->map(static fn ($month): string => substr((string) $month, 0, 10))
->all();
$requested = substr(trim((string) request('month', '')), 0, 10);
if (preg_match('/^\d{4}-\d{2}$/', $requested) === 1) {
// 筛选栏的月份选择器提交 Y-m,这里补成当月 1 号再匹配。
$requested .= '-01';
}
// 明确指定月份时严格按该月份取数(该月没算出来就显示空表),避免筛选条件与数据不一致。
$month = $requested !== '' ? $requested : ($months[0] ?? null);
$limit = min(max((int) setting('default.list_limit', 25), 1), 50);
$page = max((int) request('page', 1), 1);
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]);
if ($month === null) {
return $empty;
}
$query = DB::connection($connection)->table('product_monthly_weighted_average_rows')
->where('report_month', $month);
$productCode = trim((string) request('product_code', ''));
if ($productCode !== '') {
// 与进销存总表一致:商品编码按精确查询。
$query->where('product_code', $productCode);
}
$provider = trim((string) request('warehouse_provider', ''));
if ($provider !== '') {
$query->where('warehouse_short_name', 'like', '%' . addcslashes($provider, '%_\\') . '%');
}
$category = trim((string) request('category', ''));
if ($category !== '') {
$query->where('product_name', 'like', '%' . addcslashes($category, '%_\\') . '%');
}
$total = (clone $query)->count();
if ($total === 0) {
return $empty;
}
$items = $query
->orderBy('warehouse_short_name')
->orderBy('product_code')
->forPage($page, $limit)
->get([
DB::raw('report_month as month'),
'barcode_warehouse',
'warehouse_short_name',
'supplier',
'product_code',
'product_name',
'opening_quantity',
'opening_unit_price',
'opening_original_amount',
DB::raw('adjusted_unit_price as opening_adjusted_price'),
'opening_amount',
'purchase_quantity',
'purchase_unit_price',
'purchase_amount',
'weighted_average_unit_price',
'pricing_status',
]);
return new LengthAwarePaginator($items, $total, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]);
}
/**
* 业绩明细 → 店铺收款匹配订单明细:以店铺收款账户账单为基数,
* 按「店铺 + 订单号」匹配订单侧商品金额;一行 = 一个店铺的一个订单。
*
* @return array{0:LengthAwarePaginator,1:?array<string,mixed>}
*/
protected function storeReceiptOrderMatchRows(): array
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
[$months, $month] = $this->receiptOrderMatchMonths();
// 每页条数:业绩明细页面可切 10 / 20 / 50,未指定时沿用系统默认(上限 50)。
$requestedLimit = (int) request('per_page', 0);
$limit = in_array($requestedLimit, [10, 20, 50], true)
? $requestedLimit
: min(max((int) setting('default.list_limit', 25), 1), 50);
$page = max((int) request('page', 1), 1);
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]);
if ($month === null) {
return [$empty, null];
}
// 平台 / 店铺与其它页面一致:可打字搜索的多选(一个都不勾=全部),同时兼容旧的单值链接。
$platformFilter = $this->receiptOrderMatchFilterValues(request('platform'));
$storeFilter = $this->receiptOrderMatchFilterValues(request('store_name'));
[$statusFilter, $remarkFilter] = $this->receiptOrderMatchStatusFilters(request('match_status'), request('remark'));
$unmatchedReasonFilter = $this->receiptOrderMatchFilterValues(request('unmatched_reason'));
$differenceState = $this->receiptOrderMatchFilterValues(request('difference_state'));
$orderNo = trim((string) request('online_order_no', ''));
// 闭包里要用 $this->applyReceiptRemarkFilter()(差异原因备注按公式判定),所以不能声明为 static
$applyFilters = function ($query) use ($platformFilter, $storeFilter, $orderNo, $statusFilter, $remarkFilter, $unmatchedReasonFilter, $differenceState): void {
if ($platformFilter !== []) {
$query->whereIn('platform', $platformFilter);
}
if ($storeFilter !== []) {
$query->whereIn('store_name', $storeFilter);
}
if ($orderNo !== '') {
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
}
// 「匹配状态」和「异常标注」是两个独立筛选,各自多选内部是「或」,两者之间是「且」。
if ($statusFilter !== []) {
$query->whereIn('match_status', $statusFilter);
}
if ($remarkFilter !== []) {
$this->applyReceiptRemarkFilter($query, $remarkFilter);
}
if ($unmatchedReasonFilter !== []) {
$this->applyReceiptUnmatchedReasonFilter($query, $unmatchedReasonFilter);
}
if ($differenceState !== []) {
$this->applyReceiptDifferenceStateFilter($query, $differenceState);
}
};
// 镜像表本身就是「一个店铺 + 一个订单号 + 一个商品金额」的行口径,直接统计即可。
$base = static fn () => DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('store_receipt_order_match_rows')
->where('report_month', $month);
// 计数与合计合并为一次全表扫描:重筛选项(如按备注筛选)下少一次全表重算。
$aggQuery = $base();
$applyFilters($aggQuery);
// 口径兜底行(备注为空、口径对上)的差额按 0 计——与页面行展示、差额筛选同一套判定。
$differenceBaseExpr = self::receiptDefaultDifferenceSql();
$fallbackSql = self::receiptBasisFallbackSqlList();
$agg = $aggQuery->selectRaw("
COUNT(*) as total,
SUM(payment_count) as payments,
SUM(income_amount) as income_total,
SUM(discount_amount) as discount_total,
SUM(offset_amount) as offset_total,
SUM(refund_amount) as refund_total,
SUM(planting_amount) as planting_total,
SUM(product_amount) as goods_total,
SUM(paid_amount) as paid_total,
SUM({$differenceBaseExpr}) as difference_base,
SUM(CASE WHEN remark IN ({$fallbackSql}) THEN 1 ELSE 0 END) as basis_fallback_rows,
SUM(CASE WHEN payment_count > 1 THEN 1 ELSE 0 END) as multi_pay,
SUM(CASE WHEN income_amount = 0 THEN 1 ELSE 0 END) as zero_income,
SUM(CASE WHEN match_status = '已匹配' THEN 1 ELSE 0 END) as matched,
SUM(CASE WHEN match_status = '未匹配' THEN 1 ELSE 0 END) as unmatched
")->first();
$total = (int) ($agg->total ?? 0);
$differenceTotal = (float) ($agg->difference_base ?? 0);
if ((int) ($agg->basis_fallback_rows ?? 0) > 0) {
$adjustmentQuery = $base();
$applyFilters($adjustmentQuery);
$adjustment = $adjustmentQuery
->whereIn('remark', self::RECEIPT_BASIS_FALLBACK_REMARKS)
->selectRaw('SUM(' . self::receiptFallbackDifferenceAdjustmentSql() . ') as adjustment')
->first();
$differenceTotal += (float) ($adjustment->adjustment ?? 0);
}
$stats = [
'month' => $month,
// 月份选择器下界:以镜像里实际有数据的月份为准,避免把已同步的账期禁掉。
'min_month' => $months === [] ? null : substr(min($months), 0, 7),
'rows' => $total,
'payments' => (int) ($agg->payments ?? 0),
'orders_matched' => (int) ($agg->matched ?? 0),
'orders_unmatched' => (int) ($agg->unmatched ?? 0),
'orders_multi_pay' => (int) ($agg->multi_pay ?? 0),
'orders_zero_income' => (int) ($agg->zero_income ?? 0),
'income_total' => round((float) ($agg->income_total ?? 0), 2),
'discount_total' => round((float) ($agg->discount_total ?? 0), 2),
'offset_total' => round((float) ($agg->offset_total ?? 0), 2),
'refund_total' => round((float) ($agg->refund_total ?? 0), 2),
'planting_total' => round((float) ($agg->planting_total ?? 0), 2),
'goods_total' => round((float) ($agg->goods_total ?? 0), 2),
'paid_total' => round((float) ($agg->paid_total ?? 0), 2),
'difference_total' => round($differenceTotal, 2),
'per_page' => $limit,
];
// 筛选下拉候选:本月镜像里实际出现的平台与店铺(按 6 小时缓存,避免每次开页做去重扫描),
// 并把当前已选值并入,保证筛选项的文字不会因为换月而消失。
$filterOptions = cache()->remember(
'performance-details-filter-options-v1:' . $month,
now()->addHours(6),
static function () use ($connection, $month): array {
$maker = static fn () => DB::connection($connection)->table('store_receipt_order_match_rows')
->where('report_month', $month);
return [
'platforms' => $maker()->whereNotNull('platform')->distinct()->orderBy('platform')->pluck('platform')->all(),
'stores' => $maker()->whereNotNull('store_name')->where('store_name', '!=', '')
->distinct()->orderBy('store_name')->pluck('store_name')->all(),
];
},
);
$stats['selected_platforms'] = $platformFilter;
$stats['selected_stores'] = $storeFilter;
$stats['platform_options'] = array_values(array_unique(array_merge(
self::RECEIPT_PLATFORM_OPTIONS,
array_map('strval', $filterOptions['platforms'] ?? []),
)));
$stats['store_options'] = array_values(array_unique(array_merge(
$storeFilter,
array_map('strval', $filterOptions['stores'] ?? []),
)));
$stats['status_options'] = self::RECEIPT_STATUS_OPTIONS;
// 差额异常备注下拉:只保留「差额为什么产生」的原因备注(按差额公式判定)
$stats['remark_options'] = array_values(array_keys(self::receiptDifferenceRemarkConditions()));
// 未匹配原因下拉:未匹配行在库里的原因标注
$stats['unmatched_reason_options'] = self::unmatchedReasonOptions();
if ($total === 0) {
return [$empty, $stats];
}
$itemsQuery = $base();
$applyFilters($itemsQuery);
$refundEvidence = self::receiptRefundEvidenceSql();
$dewuEvidence = self::receiptDewuMarketingEvidenceSql();
$jingxiEvidence = self::receiptJingxiSupplyEvidenceSql();
$items = $itemsQuery
->selectRaw('*')
->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_sum'] . ' END AS order_refund_sum')
->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_max'] . ' END AS order_refund_max')
->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['cancelled_sum'] . ' END AS order_cancelled_sum')
->selectRaw('CASE WHEN (' . $dewuEvidence['guard'] . ') THEN ' . $dewuEvidence['marketing_expense'] . ' END AS order_dewu_marketing_expense')
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_fee'] . ' END AS order_jingxi_supply_fee')
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_net'] . ' END AS order_jingxi_supply_net')
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['gross_up_fee'] . ' END AS order_jingxi_gross_up_fee')
->orderBy('occurred_date')
->orderBy('store_name')
->orderBy('online_order_no')
->forPage($page, $limit)
->get();
// 差额异常备注列:只展示「差额为什么产生」的原因备注;未匹配原因单独一列;
// 未匹配行(差额为空)按比对基准金额=0 计入差额。
$items = $items->map(static function (object $row): object {
$rawRemark = trim((string) ($row->remark ?? ''));
// 未匹配原因列:未匹配行照旧;「订单实付按卖家实收核对」这类口径标识在已匹配行同样展示。
$row->unmatched_reason = $rawRemark === ''
? null
: (((($row->match_status ?? '') === '未匹配')
|| in_array($rawRemark, ['订单实付按卖家实收核对', '订单实付按应付/已付核对'], true)) ? $rawRemark : null);
$row->remark = self::receiptDifferenceRemark($row);
if ($row->difference === null) {
$row->difference = round(
(float) ($row->income_amount ?? 0) + (float) ($row->discount_amount ?? 0) + (float) ($row->offset_amount ?? 0),
2
);
}
// 口径兜底(原因优先之后):备注为空但带口径标识 → 差额按 0 展示。
if ($row->remark === null
&& in_array($rawRemark, ['订单实付按卖家实收核对', '订单实付按应付/已付核对'], true)) {
$row->difference = 0.0;
}
return $row;
});
return [new LengthAwarePaginator($items, $total, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]), $stats];
}
/**
* 「差额异常备注」筛选:按差额原因的条件判定,多选内部是「或」。
*
* @param \Illuminate\Database\Query\Builder $query
* @param list<string> $remarkFilter
*/
private function applyReceiptRemarkFilter($query, array $remarkFilter): void
{
if ($remarkFilter === []) {
return;
}
$differenceConditions = self::receiptDifferenceRemarkConditions();
$differenceLabels = array_values(array_intersect($remarkFilter, array_keys($differenceConditions)));
if ($differenceLabels === []) {
return;
}
$query->where(function ($group) use ($differenceLabels, $differenceConditions): void {
foreach ($differenceLabels as $index => $label) {
$method = $index === 0 ? 'where' : 'orWhere';
$group->{$method}(function ($sub) use ($label, $differenceConditions): void {
$sub->whereRaw($differenceConditions[$label]);
});
}
});
}
/**
* 「未匹配原因」筛选:只看未匹配行,按库里的原因标注过滤(多选内部是「或」)。
*
* @param \Illuminate\Database\Query\Builder $query
* @param list<string> $reasonFilter
*/
private function applyReceiptUnmatchedReasonFilter($query, array $reasonFilter): void
{
$labels = array_values(array_intersect($reasonFilter, self::unmatchedReasonOptions()));
if ($labels === []) {
return;
}
// 双口径标识(卖家实收 / 应付已付)是已匹配行的口径标识,不按未匹配过滤。
$basisMarks = ['订单实付按卖家实收核对', '订单实付按应付/已付核对'];
$query->where(function ($group) use ($labels, $basisMarks): void {
$group->where(function ($unmatched) use ($labels): void {
$unmatched->where('match_status', '未匹配')->whereIn('remark', $labels);
});
$selected = array_values(array_intersect($labels, $basisMarks));
if ($selected !== []) {
$group->orWhereIn('remark', $selected);
}
});
}
/**
* 「差额(有/无)」筛选:按页面差额口径判断(未匹配行按订单实付=0 计入差额)。
* 多选内部是「或」:全选或全不选都等于不过滤。
*
* @param \Illuminate\Database\Query\Builder $query
* @param string|list<string> $state
*/
private function applyReceiptDifferenceStateFilter($query, $state): void
{
$states = is_array($state) ? $state : [$state];
$has = in_array('有差额', $states, true);
$none = in_array('无差额', $states, true);
if ($has === $none) {
return;
}
$effective = self::receiptEffectiveDifferenceSql();
$query->whereRaw($has ? 'ABS(' . $effective . ') > 0.005' : 'ABS(' . $effective . ') <= 0.005');
}
/**
* 「差额(有/无)」联动计数:应用除差额以外的筛选条件后,
* 分别统计有差额 / 无差额的行数(行数为 0 的取值不返回,前端不展示)。
*
* @param array<string,mixed> $filters
* @return array{values:list<string>,counts:array<string,int>}
*/
private function receiptDifferenceStateFacet(string $month, array $filters, string $orderNo): array
{
$query = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('store_receipt_order_match_rows')
->where('report_month', $month);
foreach ($filters as $key => $values) {
if ($key === 'difference_state' || $values === [] || $values === '') {
continue;
}
if ($key === 'remark') {
$this->applyReceiptRemarkFilter($query, (array) $values);
continue;
}
if ($key === 'unmatched_reason') {
$this->applyReceiptUnmatchedReasonFilter($query, (array) $values);
continue;
}
$query->whereIn($key, $values);
}
if ($orderNo !== '') {
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
}
$effective = self::receiptEffectiveDifferenceSql();
$row = $query->selectRaw('SUM(CASE WHEN ABS(' . $effective . ') > 0.005 THEN 1 ELSE 0 END) as has_diff, '
. 'SUM(CASE WHEN ABS(' . $effective . ') <= 0.005 THEN 1 ELSE 0 END) as none_diff')->first();
$counts = [];
if ((int) ($row->has_diff ?? 0) > 0) {
$counts['有差额'] = (int) $row->has_diff;
}
if ((int) ($row->none_diff ?? 0) > 0) {
$counts['无差额'] = (int) $row->none_diff;
}
return ['values' => ['有差额', '无差额'], 'counts' => $counts];
}
/**
* 业绩明细的多选筛选值:多选控件提交 name[](数组),同时兼容历史单值链接。
*
* @return list<string>
*/
private function receiptOrderMatchFilterValues(mixed $input): array
{
$values = is_array($input) ? $input : [$input];
$clean = [];
foreach ($values as $value) {
if (is_array($value)) {
continue;
}
$text = trim((string) $value);
if ($text !== '') {
$clean[$text] = true;
}
}
return array_keys($clean);
}
/**
* 业绩明细的账期集合与当前账期:未指定月份时取镜像里最新的月份。
*
* @return array{0:list<string>,1:?string} 账期列表(Y-m-d)与当前账期(Y-m-d)
*/
private function receiptOrderMatchMonths(): array
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$months = DB::connection($connection)->table('store_receipt_order_match_rows')
->select('report_month')
->distinct()
->orderByDesc('report_month')
->pluck('report_month')
->map(static fn ($month): string => substr((string) $month, 0, 10))
->all();
$requested = substr(trim((string) request('month', '')), 0, 10);
if (preg_match('/^\d{4}-\d{2}$/', $requested) === 1) {
// 筛选栏的月份选择器提交 Y-m,这里补成当月 1 号再匹配。
$requested .= '-01';
}
// 明确指定月份时严格按该月份取数(该月没同步就显示空表)。
$month = $requested !== '' ? $requested : ($months[0] ?? null);
return [$months, $month];
}
/**
* 归一化「匹配状态 / 异常标注」两组筛选值:状态列与标注列各自独立,
* 同时兼容历史链接把异常标注塞进 match_status 的写法。
*
* @return array{0:list<string>,1:list<string>} 状态值、异常标注值
*/
private function receiptOrderMatchStatusFilters(mixed $statusInput, mixed $remarkInput): array
{
$values = $this->receiptOrderMatchFilterValues($statusInput);
$statusValues = array_values(array_intersect($values, self::RECEIPT_STATUS_OPTIONS));
return [
$statusValues,
array_values(array_unique(array_merge(
array_diff($values, $statusValues),
$this->receiptOrderMatchFilterValues($remarkInput),
))),
];
}
/**
* 业绩明细筛选联动:按「当前其它筛选条件」统计每个筛选项的可选值与行数,
* 行数为 0 的取值不返回(前端不展示)。
*/
public function receiptOrderMatchFilterOptions()
{
[, $month] = $this->receiptOrderMatchMonths();
if ($month === null) {
return response()->json(['month' => null, 'facets' => []]);
}
[$statusValues, $remarkValues] = $this->receiptOrderMatchStatusFilters(request('match_status'), request('remark'));
$filters = [
'platform' => $this->receiptOrderMatchFilterValues(request('platform')),
'store_name' => $this->receiptOrderMatchFilterValues(request('store_name')),
'match_status' => $statusValues,
'remark' => $remarkValues,
'unmatched_reason' => $this->receiptOrderMatchFilterValues(request('unmatched_reason')),
'difference_state' => $this->receiptOrderMatchFilterValues(request('difference_state')),
];
$orderNo = trim((string) request('online_order_no', ''));
// 联动统计要跑几次全表聚合,短时间内重复请求(页面加载 + 下拉刷新)直接吃缓存
$cacheKey = 'performance-details-facets:' . md5(serialize([$month, $filters, $orderNo]));
// 数据按天同步,缓存 10 分钟:大幅降低大平台(拼多多等)的联动等待。
return response()->json(cache()->remember($cacheKey, now()->addSeconds(600), function () use ($month, $filters, $orderNo) {
return [
'month' => substr($month, 0, 7),
'facets' => [
'platform' => $this->receiptOrderMatchFacet($month, 'platform', $filters, $orderNo, self::RECEIPT_PLATFORM_OPTIONS),
'store_name' => $this->receiptOrderMatchFacet($month, 'store_name', $filters, $orderNo),
'match_status' => $this->receiptOrderMatchFacet($month, 'match_status', $filters, $orderNo, self::RECEIPT_STATUS_OPTIONS),
'remark' => $this->receiptOrderMatchFacet($month, 'remark', $filters, $orderNo, array_keys(self::receiptDifferenceRemarkConditions())),
'unmatched_reason' => $this->receiptOrderMatchFacet($month, 'unmatched_reason', $filters, $orderNo, self::unmatchedReasonOptions()),
'difference_state' => $this->receiptDifferenceStateFacet($month, $filters, $orderNo),
],
];
}));
}
/**
* 单个筛选项的可选值与行数:应用除该筛选项以外的所有条件。
*
* @param array<string,list<string>> $filters 已归一化的筛选条件(键=列名)
* @param list<string> $order 固定展示顺序,未列出的取值排在后面
* @return array{values:list<string>,counts:array<string,int>}
*/
private function receiptOrderMatchFacet(string $month, string $column, array $filters, string $orderNo, array $order = []): array
{
$query = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('store_receipt_order_match_rows')
->where('report_month', $month);
foreach ($filters as $key => $values) {
if ($key === $column || $values === [] || $values === '') {
continue;
}
// 差额异常备注按公式条件判定
if ($key === 'remark') {
$this->applyReceiptRemarkFilter($query, (array) $values);
continue;
}
// 未匹配原因:只看未匹配行、按库里的原因过滤
if ($key === 'unmatched_reason') {
$this->applyReceiptUnmatchedReasonFilter($query, (array) $values);
continue;
}
// 差额(有/无):按页面差额口径过滤
if ($key === 'difference_state') {
$this->applyReceiptDifferenceStateFilter($query, (array) $values);
continue;
}
$query->whereIn($key, $values);
}
if ($orderNo !== '') {
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
}
// 差额异常备注联动:5 条原因一次查询算完(原来是 5 次全表 count,下拉要等 1 秒多)
$differenceCounts = [];
$differenceLabels = array_keys(self::receiptDifferenceRemarkConditions());
if ($column === 'remark') {
$expressions = [];
foreach (array_values(self::receiptDifferenceRemarkConditions()) as $index => $condition) {
$expressions[] = 'SUM(CASE WHEN (' . $condition . ') THEN 1 ELSE 0 END) as c' . $index;
}
$row = (clone $query)->selectRaw(implode(', ', $expressions))->first();
foreach ($differenceLabels as $index => $label) {
$differenceCounts[$label] = (int) ($row->{'c' . $index} ?? 0);
}
}
$counts = [];
// 未匹配原因是页面级判定(只统计未匹配行,按库里的 remark 分组)
if ($column === 'unmatched_reason') {
$base = clone $query;
$rows = $query->where('match_status', '未匹配')
->select('remark')
->selectRaw('count(*) as total')
->whereNotNull('remark')
->where('remark', '!=', '')
->groupBy('remark')
->pluck('total', 'remark');
foreach ($rows as $value => $total) {
$counts[(string) $value] = (int) $total;
}
// 双口径标识打在已匹配行上,单独统计,保证下拉里能选到。
foreach (['订单实付按卖家实收核对', '订单实付按应付/已付核对'] as $basisMark) {
$marker = (clone $base)->where('remark', $basisMark)->count();
if ($marker > 0) {
$counts[$basisMark] = (int) $marker;
}
}
} elseif ($column !== 'remark') {
// 差额异常备注是页面级判定,不再按库里的标注列分组
$rows = $query->select($column)
->selectRaw('count(*) as total')
->whereNotNull($column)
->where($column, '!=', '')
->groupBy($column)
->pluck('total', $column);
foreach ($rows as $value => $total) {
$counts[(string) $value] = (int) $total;
}
}
foreach ($differenceCounts as $label => $total) {
if ((int) $total > 0) {
$counts[$label] = (int) $total;
}
}
$ordered = array_values(array_intersect($order, array_keys($counts)));
$rest = array_values(array_diff(array_keys($counts), $ordered));
sort($rest, SORT_NATURAL | SORT_FLAG_CASE);
return ['values' => array_merge($ordered, $rest), 'counts' => $counts];
}
/**
* 业绩明细 → 店铺收款匹配订单明细导出:按当前月份与筛选条件导出 CSV,列序跟随页面显示列。
*/
/**
* 财务经营报表 › 快递运费:按来源 / 成品配件 / 交易单号查询本地导入的明细。
*/
private function courierFreightRows(): LengthAwarePaginator
{
$query = \App\Models\Business\CourierFreightRow::query()
->where('company_id', company_id());
foreach (['source' => 'source', 'part' => 'part'] as $param => $column) {
$value = trim((string) request($param, ''));
if ($value !== '') {
$query->where($column, $value);
}
}
$transactionNo = trim((string) request('transaction_no', ''));
if ($transactionNo !== '') {
$query->where('transaction_no', 'like', '%' . addcslashes($transactionNo, '%_\\') . '%');
}
$requestedLimit = (int) request('per_page', 0);
$limit = in_array($requestedLimit, [10, 20, 50], true)
? $requestedLimit
: min(max((int) setting('default.list_limit', 25), 1), 50);
$rows = $query->orderByDesc('id')->paginate($limit)->withQueryString();
// 「运费差」「其他附加费汇总」「退件费」不在本表里,按交易单号去「快递应付」对应汇总快照里取。
$summaries = app(\App\Business\CourierPayableSummaryMap::class);
$differences = $summaries->map((int) company_id(), \App\Business\CourierPayableSummaryMap::PAGE_FREIGHT_DIFFERENCE);
$surcharges = $summaries->map((int) company_id(), \App\Business\CourierPayableSummaryMap::PAGE_OTHER_SURCHARGE);
$returnFees = $summaries->map((int) company_id(), \App\Business\CourierPayableSummaryMap::PAGE_RETURN_FEE);
$rows->setCollection($rows->getCollection()->map(static function ($row) use ($differences, $surcharges, $returnFees) {
$orderNo = trim((string) $row->transaction_no);
$pick = static fn (array $map): ?string => $orderNo !== '' && isset($map[$orderNo]) ? $map[$orderNo] : null;
$row->setAttribute('freight_difference', $pick($differences));
// 结算运费 = 提供成本运费 + 运费差(运费差本身就是「结算运费 − 提供成本运费」)。
$difference = $pick($differences);
$freightAmount = (float) ($row->freight_amount ?? 0);
$settled = null;
if ($difference !== null && is_numeric($difference)) {
$settled = $freightAmount + (float) $difference;
} elseif ($freightAmount !== 0.0) {
$settled = $freightAmount;
}
$row->setAttribute('settled_freight', $settled);
$row->setAttribute('other_surcharge_amount', $pick($surcharges));
$row->setAttribute('return_fee_amount', $pick($returnFees));
return $row;
}));
return $rows;
}
public function exportPerformanceDetails(Request $request): StreamedResponse
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
// [列名, 表头, 数值格式] —— 金额统一两位小数,数量取整,与页面展示一致。
$map = [
'occurred_date' => ['occurred_date', '发生时间', 'text'],
'platform' => ['platform', '平台', 'text'],
'store_name' => ['store_name', '店铺名称', 'text'],
'online_order_no' => ['online_order_no', '线上单号', 'text'],
'income_amount' => ['income_amount', '收入金额', 'money'],
'discount_amount' => ['discount_amount', '优惠金额', 'money'],
'offset_amount' => ['offset_amount', '冲减金额', 'money'],
'refund_amount' => ['refund_amount', '退款金额', 'money'],
'planting_amount' => ['planting_amount', '种菜金额', 'money'],
'product_amount' => ['product_amount', '商品金额', 'money'],
'paid_amount' => ['paid_amount', '订单实付金额', 'money'],
'difference' => ['difference', '差额', 'money'],
'match_status' => ['match_status', '匹配状态', 'text'],
'remark' => ['remark', '差额异常备注', 'text'],
];
[, $month] = $this->receiptOrderMatchMonths();
$query = DB::connection($connection)->table('store_receipt_order_match_rows')
->when($month !== null, static fn ($builder) => $builder->where('report_month', $month));
$platforms = $this->receiptOrderMatchFilterValues($request->query('platform'));
if ($platforms !== []) {
$query->whereIn('platform', $platforms);
}
$stores = $this->receiptOrderMatchFilterValues($request->query('store_name'));
if ($stores !== []) {
$query->whereIn('store_name', $stores);
}
$orderNo = trim((string) $request->query('online_order_no', ''));
if ($orderNo !== '') {
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
}
$statusFilter = $this->receiptOrderMatchFilterValues($request->query('match_status'));
[$statusValues, $remarkValues] = $this->receiptOrderMatchStatusFilters($statusFilter, $request->query('remark'));
if ($statusValues !== []) {
$query->whereIn('match_status', $statusValues);
}
if ($remarkValues !== []) {
$this->applyReceiptRemarkFilter($query, $remarkValues);
}
$unmatchedReasonValues = $this->receiptOrderMatchFilterValues($request->query('unmatched_reason'));
if ($unmatchedReasonValues !== []) {
$this->applyReceiptUnmatchedReasonFilter($query, $unmatchedReasonValues);
}
$differenceState = $this->receiptOrderMatchFilterValues($request->query('difference_state'));
if ($differenceState !== []) {
$this->applyReceiptDifferenceStateFilter($query, $differenceState);
}
// 「订单实付含已退款部分」「订单实付未减联合营销费」判定所需证据列(与页面同一口径;仅对应平台负差额行会真正计算子查询)。
$refundEvidence = self::receiptRefundEvidenceSql();
$dewuEvidence = self::receiptDewuMarketingEvidenceSql();
$jingxiEvidence = self::receiptJingxiSupplyEvidenceSql();
$query
->selectRaw('*')
->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_sum'] . ' END AS order_refund_sum')
->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_max'] . ' END AS order_refund_max')
->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['cancelled_sum'] . ' END AS order_cancelled_sum')
->selectRaw('CASE WHEN (' . $dewuEvidence['guard'] . ') THEN ' . $dewuEvidence['marketing_expense'] . ' END AS order_dewu_marketing_expense')
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_fee'] . ' END AS order_jingxi_supply_fee')
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_net'] . ' END AS order_jingxi_supply_net')
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['gross_up_fee'] . ' END AS order_jingxi_gross_up_fee');
$selected = array_values(array_intersect(
array_filter(explode(',', (string) $request->query('columns', ''))),
array_keys($map),
));
if ($selected === []) {
$selected = array_keys($map);
}
// 导出「当前页」时与页面保持一致:按页面当前的每页条数(10/20/50)取数。
$requestedSize = (int) $request->query('per_page', 0);
$size = in_array($requestedSize, [10, 20, 50], true)
? $requestedSize
: min(max((int) setting('default.list_limit', 25), 1), 50);
if ($request->query('scope') === 'page') {
$query->forPage(max((int) $request->query('page', 1), 1), $size);
}
$filename = '店铺收款匹配订单明细_' . ($month ?? 'all') . '.csv';
return response()->streamDownload(function () use ($query, $selected, $map): void {
// 单表几十万行:用游标一次查询顺序吐出行,避免 chunk 的 OFFSET 反复重跑
// (29 万行时会退化成几百次排序查询,导出要几分钟)。
@set_time_limit(0);
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, array_map(static fn (string $key): string => $map[$key][1], $selected));
$rows = $query->orderBy('occurred_date')->orderBy('store_name')->orderBy('online_order_no')->cursor();
foreach ($rows as $row) {
$line = [];
foreach ($selected as $key) {
$format = $map[$key][2];
$value = $row->{$map[$key][0]} ?? '';
// 「差额异常备注」列与页面一致:只写差额产生的原因
if ($key === 'remark') {
$value = self::receiptDifferenceRemark($row) ?? '';
}
// 未匹配行(差额为空)按比对基准金额=0 计入差额,与页面口径一致。
// 注意:上面取值用了 ?? '',空差值是空字符串而不是 null,这里两种都要认。
if ($key === 'difference' && ($value === null || $value === '')) {
$value = round(
(float) ($row->income_amount ?? 0) + (float) ($row->discount_amount ?? 0) + (float) ($row->offset_amount ?? 0),
2
);
}
if ($format === 'money') {
// 金额列空值导出成 0.00:Excel 里直接求和、做差额核对不用再补空。
$line[] = number_format((float) ($value === null || $value === '' ? 0 : $value), 2, '.', '');
continue;
}
if ($format === 'int') {
$line[] = $value === null || $value === '' ? '' : (string) (int) $value;
continue;
}
$line[] = is_string($value) ? $value : (string) $value;
}
fputcsv($out, $line);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/**
* 商品成本:以「业绩明细」的账单行为基数,按「店铺 + 线上订单号」展开订单侧商品行,
* 带出发货仓库、仓库简称(仓库映射表)、商品单价(商品月加权平均单价)与商品成本
* (商品数量 × 商品单价)。
*/
protected function contentForProductCosts(): array
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$table = self::PRODUCT_COST_TABLE;
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$page = max((int) request('page', 1), 1);
$base = static fn () => DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table(self::PRODUCT_COST_TABLE);
try {
$months = cache()->remember(
'product-costs-months-v1',
now()->addMinutes(10),
static fn () => DB::connection($connection)->table($table)
->distinct()->orderByDesc('report_month')->pluck('report_month')
->map(static fn ($value): string => substr((string) $value, 0, 7))->all(),
);
} catch (Throwable) {
$months = [];
}
$monthInput = trim((string) request('month', ''));
$month = preg_match('/^\d{4}-\d{2}$/', $monthInput) === 1
? $monthInput . '-01'
: ($months !== [] ? $months[0] . '-01' : null);
$filters = [
'month' => $month === null ? '' : substr($month, 0, 7),
'platform' => trim((string) request('platform', '')),
'store_name' => trim((string) request('store_name', '')),
'online_order_no' => trim((string) request('online_order_no', '')),
'product_code' => trim((string) request('product_code', '')),
'warehouse_short_name' => trim((string) request('warehouse_short_name', '')),
'missing_price' => request()->boolean('missing_price') ? '1' : '',
'include_excluded' => request()->boolean('include_excluded') ? '1' : '',
];
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]);
if ($month === null) {
return [
'view' => 'business.pages.financial-operations.product-costs',
'productCostRows' => $empty,
'productCostFilters' => $filters,
'productCostSummary' => [],
'productCostMonths' => [],
'productCostPlatforms' => [],
'productCostWarehouses' => [],
'productCostError' => null,
];
}
$applyFilters = static function ($query, bool $excludedWarehouseOnly = false) use ($filters): void {
$query->where('report_month', $filters['month'] . '-01');
if ($filters['platform'] !== '') {
$query->where('platform', $filters['platform']);
}
foreach (['store_name' => 'store_name', 'online_order_no' => 'online_order_no', 'product_code' => 'product_code'] as $key => $column) {
if ($filters[$key] !== '') {
$query->where($column, 'like', '%' . addcslashes($filters[$key], '%_\\') . '%');
}
}
if ($filters['warehouse_short_name'] !== '') {
$query->where('warehouse_short_name', $filters['warehouse_short_name']);
}
if ($filters['missing_price'] === '1') {
$query->whereNull('unit_price');
}
// 仓库映射表里标「不看」的仓(次品 / 销退 / 虚拟 / 信誉 / 京东云仓等)默认不展示:
// 这些仓在商品月加权平均单价里本来就没有价,展示出来只会让「未匹配单价」虚高。
if ($excludedWarehouseOnly) {
$query->where('warehouse_short_name', 'like', '%-不看%');
return;
}
if ($filters['include_excluded'] !== '1') {
$query->where(static function ($inner): void {
$inner->whereNull('warehouse_short_name')
->orWhere('warehouse_short_name', 'not like', '%-不看%');
});
}
};
$countQuery = $base();
$applyFilters($countQuery);
$total = (int) $countQuery->count();
$excludedQuery = $base();
$applyFilters($excludedQuery, true);
$excludedRows = (int) $excludedQuery->count();
$aggQuery = $base();
$applyFilters($aggQuery);
$agg = $aggQuery->selectRaw('
SUM(CASE WHEN unit_price IS NOT NULL THEN product_quantity ELSE 0 END) as quantity_total,
SUM(CASE WHEN unit_price IS NOT NULL THEN product_cost ELSE 0 END) as cost_total,
SUM(CASE WHEN unit_price IS NULL THEN 1 ELSE 0 END) as missing_price_rows,
SUM(CASE WHEN unit_price IS NULL THEN product_quantity ELSE 0 END) as missing_price_quantity,
SUM(CASE WHEN warehouse_short_name IS NULL THEN 1 ELSE 0 END) as missing_warehouse_rows,
SUM(CASE WHEN price_source LIKE \'线上代发价格%\' THEN 1 ELSE 0 END) as fallback_price_rows,
SUM(CASE WHEN remark LIKE \'%口径不一致%\' THEN 1 ELSE 0 END) as unit_mismatch_rows
')->first();
$options = cache()->remember(
'product-costs-options-v1:' . $filters['month'],
now()->addMinutes(10),
static function () use ($connection, $table, $filters): array {
$maker = static fn () => DB::connection($connection)->table($table)
->where('report_month', $filters['month'] . '-01');
return [
'platforms' => $maker()->whereNotNull('platform')->distinct()->orderBy('platform')->pluck('platform')->all(),
'warehouses' => $maker()->whereNotNull('warehouse_short_name')->where('warehouse_short_name', '!=', '')
->distinct()->orderBy('warehouse_short_name')->pluck('warehouse_short_name')->all(),
];
},
);
$itemsQuery = $base();
$applyFilters($itemsQuery);
$items = $total === 0 ? collect() : $itemsQuery
->orderBy('occurred_date')
->orderBy('store_name')
->orderBy('online_order_no')
->orderBy('warehouse_short_name')
->orderBy('product_code')
->forPage($page, $limit)
->get();
return [
'view' => 'business.pages.financial-operations.product-costs',
'productCostRows' => new LengthAwarePaginator($items, $total, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]),
'productCostFilters' => $filters,
'productCostSummary' => [
'rows' => $total,
'quantity_total' => round((float) ($agg->quantity_total ?? 0), 2),
'cost_total' => round((float) ($agg->cost_total ?? 0), 2),
'missing_price_rows' => (int) ($agg->missing_price_rows ?? 0),
'missing_price_quantity' => round((float) ($agg->missing_price_quantity ?? 0), 2),
'missing_warehouse_rows' => (int) ($agg->missing_warehouse_rows ?? 0),
'excluded_rows' => $excludedRows,
'fallback_price_rows' => (int) ($agg->fallback_price_rows ?? 0),
'unit_mismatch_rows' => (int) ($agg->unit_mismatch_rows ?? 0),
],
'productCostMonths' => $months,
'productCostPlatforms' => array_values(array_map('strval', $options['platforms'] ?? [])),
'productCostWarehouses' => array_values(array_map('strval', $options['warehouses'] ?? [])),
'productCostError' => null,
];
}
/**
* 财务经营分析表:按「时间(账期)- 平台 - 店铺」聚合。
*
* 收入 = 业绩明细里(收入金额 + 优惠金额 + 冲减金额 + 退款金额)按各字段记账符号求和
* (冲减、退款取数本身带负号,直接相加即为净收入);
* 成本 = 商品成本表的商品成本汇总;运费暂不取数,列上留空。
*/
protected function contentForFinancialAnalysis(): array
{
$limit = min(max((int) setting('default.list_limit', 25), 1), 200);
$page = max((int) request('page', 1), 1);
$months = $this->financialAnalysisMonths();
$monthInput = trim((string) request('month', ''));
$allMonths = $monthInput === 'all';
$month = preg_match('/^\d{4}-\d{2}$/', $monthInput) === 1
? $monthInput . '-01'
: ($months !== [] ? $months[0] . '-01' : null);
if ($allMonths) {
$month = null;
}
$filters = [
'month' => $allMonths ? 'all' : ($month === null ? '' : substr($month, 0, 7)),
'platform' => trim((string) request('platform', '')),
'store_name' => trim((string) request('store_name', '')),
];
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]);
if ($month === null && ! $allMonths) {
return [
'view' => 'business.pages.financial-operations.financial-analysis',
'financialAnalysisRows' => $empty,
'financialAnalysisFilters' => $filters,
'financialAnalysisSummary' => [],
'financialAnalysisMonths' => [],
'financialAnalysisPlatforms' => [],
'financialAnalysisError' => null,
];
}
$rows = $this->financialAnalysisRows($filters);
$incomeTotal = 0.0;
$costTotal = 0.0;
/** @var array<string,true> $platformOptions */
$platformOptions = [];
foreach ($rows as $row) {
$incomeTotal += (float) $row['income'];
$costTotal += (float) $row['cost'];
if ($row['platform'] !== '') {
$platformOptions[(string) $row['platform']] = true;
}
}
$platformOptions = array_keys($platformOptions);
sort($platformOptions);
$items = array_slice($rows, ($page - 1) * $limit, $limit);
return [
'view' => 'business.pages.financial-operations.financial-analysis',
'financialAnalysisRows' => new LengthAwarePaginator($items, count($rows), $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]),
'financialAnalysisFilters' => $filters,
'financialAnalysisSummary' => [
'rows' => count($rows),
'income_total' => round($incomeTotal, 2),
'cost_total' => round($costTotal, 2),
],
'financialAnalysisMonths' => $months,
'financialAnalysisPlatforms' => $platformOptions,
'financialAnalysisError' => null,
];
}
/**
* 财务经营分析表的行集合:按月(或全部月份)取聚合结果,再套平台 / 店铺筛选并按收入排序。
*
* 页面与导出共用,保证两边口径一致。
*
* @param array{month:string,platform:string,store_name:string} $filters
* @return list<array{month:string,platform:string,store_name:string,income:float,cost:float}>
*/
private function financialAnalysisRows(array $filters): array
{
$months = $this->financialAnalysisMonths();
$sourceMonths = $filters['month'] === 'all'
? array_map(static fn (string $value): string => $value . '-01', $months)
: ($filters['month'] !== '' ? [$filters['month'] . '-01'] : []);
$rows = [];
foreach ($sourceMonths as $sourceMonth) {
foreach ($this->financialAnalysisAggregates($sourceMonth) as $values) {
if ($filters['platform'] !== '' && $values['platform'] !== $filters['platform']) {
continue;
}
if ($filters['store_name'] !== ''
&& ! str_contains((string) $values['store_name'], $filters['store_name'])) {
continue;
}
$rows[] = array_merge(['month' => substr($sourceMonth, 0, 7)], $values);
}
}
usort($rows, static function (array $a, array $b): int {
return [$b['month'], -$a['income'], $a['platform'], $a['store_name']]
<=> [$a['month'], -$b['income'], $b['platform'], $b['store_name']];
});
return $rows;
}
/** 财务经营分析表可选的月份(镜像里实际有业绩明细的账期,倒序)。 */
private function financialAnalysisMonths(): array
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
try {
return cache()->remember(
'financial-analysis-months-v1',
now()->addMinutes(10),
static fn () => DB::connection($connection)->table('store_receipt_order_match_rows')
->distinct()->orderByDesc('report_month')->pluck('report_month')
->map(static fn ($value): string => substr((string) $value, 0, 7))->all(),
);
} catch (Throwable) {
return [];
}
}
/**
* 单个月份的「时间-平台-店铺」聚合:收入来自业绩明细,成本来自商品成本表。
*
* 两张表都是几十万行,聚合结果按月份缓存 5 分钟;筛选在内存里做,避免每次开页重跑分组。
*
* @return array<string,array{platform:string,store_name:string,income:float,cost:float}>
*/
private function financialAnalysisAggregates(string $reportMonth): array
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
return cache()->remember(
'financial-analysis-aggregates-v1:' . $reportMonth,
now()->addMinutes(5),
static function () use ($connection, $reportMonth): array {
$rows = [];
$incomeRows = DB::connection($connection)->table('store_receipt_order_match_rows')
->where('report_month', $reportMonth)
->selectRaw('platform, store_name, SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)) as income')
->groupBy('platform', 'store_name')
->get();
foreach ($incomeRows as $row) {
$key = ((string) $row->platform) . "\0" . ((string) $row->store_name);
$rows[$key] = [
'platform' => (string) $row->platform,
'store_name' => (string) $row->store_name,
'income' => round((float) $row->income, 2),
'cost' => 0.0,
];
}
$costRows = DB::connection($connection)->table('store_receipt_product_cost_rows')
->where('report_month', $reportMonth)
->selectRaw('platform, store_name, SUM(COALESCE(product_cost, 0)) as cost')
->groupBy('platform', 'store_name')
->get();
foreach ($costRows as $row) {
$key = ((string) $row->platform) . "\0" . ((string) $row->store_name);
$rows[$key] = [
'platform' => $rows[$key]['platform'] ?? (string) $row->platform,
'store_name' => $rows[$key]['store_name'] ?? (string) $row->store_name,
'income' => $rows[$key]['income'] ?? 0.0,
'cost' => round((float) $row->cost, 2),
];
}
return $rows;
},
);
}
/**
* 财务经营分析表导出:按当前筛选条件导出「时间 / 平台 / 店铺 / 收入 / 成本 / 运费」。
*/
public function exportFinancialAnalysis(Request $request): StreamedResponse
{
$monthInput = trim((string) $request->query('month', ''));
$months = $this->financialAnalysisMonths();
$filters = [
'month' => $monthInput === 'all'
? 'all'
: (preg_match('/^\d{4}-\d{2}$/', $monthInput) === 1
? $monthInput
: ($months[0] ?? 'all')),
'platform' => trim((string) $request->query('platform', '')),
'store_name' => trim((string) $request->query('store_name', '')),
];
$rows = $this->financialAnalysisRows($filters);
$filename = '财务经营分析表_'
. ($filters['month'] === 'all' ? '全部月份' : $filters['month'])
. '.csv';
return response()->streamDownload(function () use ($rows): void {
@set_time_limit(0);
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['时间', '平台', '店铺', '收入', '成本', '运费']);
foreach ($rows as $row) {
fputcsv($out, [
$row['month'],
$row['platform'],
$row['store_name'],
number_format((float) $row['income'], 2, '.', ''),
number_format((float) $row['cost'], 2, '.', ''),
// 运费暂不取数,先留空列,等口径确认后补。
'',
]);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/**
* 商品成本导出:按当前筛选条件流式导出 CSV,列序与页面一致。
*/
public function exportProductCosts(Request $request): StreamedResponse
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$columns = [
['occurred_date', '发生时间'],
['store_name', '店铺名称'],
['online_order_no', '线上单号'],
['warehouse', '发货仓库'],
['warehouse_short_name', '仓库简称'],
['product_code', '商品编码'],
['product_quantity', '商品数量'],
['unit_price', '商品单价'],
['product_cost', '商品成本'],
];
$monthInput = trim((string) $request->query('month', ''));
$month = preg_match('/^\d{4}-\d{2}$/', $monthInput) === 1 ? $monthInput . '-01' : null;
$query = DB::connection($connection)->table(self::PRODUCT_COST_TABLE)
->when($month !== null, static fn ($builder) => $builder->where('report_month', $month));
if ($month === null) {
$query->whereRaw('1 = 0');
}
$platform = trim((string) $request->query('platform', ''));
if ($platform !== '') {
$query->where('platform', $platform);
}
foreach (['store_name', 'online_order_no', 'product_code'] as $column) {
$value = trim((string) $request->query($column, ''));
if ($value !== '') {
$query->where($column, 'like', '%' . addcslashes($value, '%_\\') . '%');
}
}
$warehouse = trim((string) $request->query('warehouse_short_name', ''));
if ($warehouse !== '') {
$query->where('warehouse_short_name', $warehouse);
}
if ($request->boolean('missing_price')) {
$query->whereNull('unit_price');
}
if (! $request->boolean('include_excluded')) {
$query->where(static function ($inner): void {
$inner->whereNull('warehouse_short_name')
->orWhere('warehouse_short_name', 'not like', '%-不看%');
});
}
$filename = '商品成本_' . ($month === null ? 'all' : substr($month, 0, 7)) . '.csv';
return response()->streamDownload(function () use ($query, $columns): void {
@set_time_limit(0);
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, array_map(static fn (array $column): string => $column[1], $columns));
$rows = $query->orderBy('occurred_date')->orderBy('store_name')
->orderBy('online_order_no')->orderBy('warehouse_short_name')->orderBy('product_code')
->cursor();
foreach ($rows as $row) {
$line = [];
foreach ($columns as [$key]) {
$value = $row->{$key} ?? '';
if (in_array($key, ['product_quantity', 'unit_price', 'product_cost'], true)) {
$line[] = $value === null || $value === '' ? '' : number_format((float) $value, 2, '.', '');
continue;
}
$line[] = is_string($value) ? $value : (string) $value;
}
fputcsv($out, $line);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/**
* 商品月加权平均单价导出:按当前月份与筛选条件导出 CSV,列序跟随页面显示列。
*/
public function exportMonthlyWeightedAveragePrice(Request $request): StreamedResponse
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$map = [
'month' => ['report_month', '月份'],
'barcode_warehouse' => ['barcode_warehouse', '条码+仓库'],
'warehouse_short_name' => ['warehouse_short_name', '仓库简称'],
'supplier' => ['supplier', '供应商'],
'product_code' => ['product_code', '商品编码'],
'product_name' => ['product_name', '商品名称'],
'opening_quantity' => ['opening_quantity', '原始数量'],
'opening_unit_price' => ['opening_unit_price', '原始价格'],
'opening_original_amount' => ['opening_original_amount', '原始期初金额'],
'opening_adjusted_price' => ['adjusted_unit_price', '更改价格'],
'opening_amount' => ['opening_amount', '期初金额'],
'purchase_quantity' => ['purchase_quantity', '采购数量'],
'purchase_unit_price' => ['purchase_unit_price', '采购单价'],
'purchase_amount' => ['purchase_amount', '入库金额'],
'weighted_average_unit_price' => ['weighted_average_unit_price', '加权平均单价'],
'pricing_status' => ['pricing_status', '定价状态'],
];
$months = DB::connection($connection)->table('product_monthly_weighted_average_states')
->where('status', 'computed')->orderByDesc('report_month')->pluck('report_month')
->map(static fn ($month): string => substr((string) $month, 0, 10))->all();
$requested = substr(trim((string) $request->query('month', '')), 0, 10);
if (preg_match('/^\d{4}-\d{2}$/', $requested) === 1) {
// 导出时筛选栏提交的也是 Y-m,同样补成当月 1 号。
$requested .= '-01';
}
$month = $requested !== '' ? $requested : ($months[0] ?? null);
$query = DB::connection($connection)->table('product_monthly_weighted_average_rows');
if ($month !== null) {
$query->where('report_month', $month);
}
$code = trim((string) $request->query('product_code', ''));
if ($code !== '') {
$query->where('product_code', $code);
}
$provider = trim((string) $request->query('warehouse_provider', ''));
if ($provider !== '') {
$query->where('warehouse_short_name', 'like', '%' . addcslashes($provider, '%_\\') . '%');
}
$category = trim((string) $request->query('category', ''));
if ($category !== '') {
$query->where('product_name', 'like', '%' . addcslashes($category, '%_\\') . '%');
}
$selected = array_values(array_intersect(
array_filter(explode(',', (string) $request->query('columns', ''))),
array_keys($map),
));
if ($selected === []) {
$selected = array_keys($map);
}
$size = min(max((int) setting('default.list_limit', 25), 1), 50);
if ($request->query('scope') === 'page') {
$query->forPage(max((int) $request->query('page', 1), 1), $size);
}
$filename = '商品月加权平均单价_' . ($month ?? 'all') . '.csv';
return response()->streamDownload(function () use ($query, $selected, $map): void {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, array_map(static fn (string $key): string => $map[$key][1], $selected));
$query->orderBy('warehouse_short_name')->orderBy('product_code')
->chunk(1000, function ($rows) use ($out, $selected, $map): void {
foreach ($rows as $row) {
$line = [];
foreach ($selected as $key) {
$value = $row->{$map[$key][0]} ?? '';
$line[] = is_string($value) ? $value : (string) $value;
}
fputcsv($out, $line);
}
});
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/**
* 进销存报表 → 入库明细数据导出(按月份/仓库/条码筛选,列顺序与页面一致)。
*/
public function exportInventoryInboundDetail(Request $request): StreamedResponse
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$labels = [
'source_no' => '来源单号', 'document_no' => '单据编号(采购单号)', 'created_serial' => '创建时间',
'supplier' => '供应商', 'item_no' => '货号', 'barcode' => '条码', 'spec_name' => '规格名',
'quantity' => '库存变动量', 'unit_price' => '单价', 'amount' => '金额',
'estimated_unit_price' => '预估单价', 'estimated_amount' => '预估金额',
'total' => '总计',
'supplier_barcode' => '供应商条码', 'remark' => '备注', 'warehouse' => '仓库',
'warehouse_short_name' => '仓库简称', 'created_at_raw' => '创建时间(实际)',
'express_name' => '快递名称', 'express_no' => '快递单号', 'not_sent' => '未发出', 'owner_name' => '负责人',
];
$query = DB::connection($connection)->table('inventory_inbound_detail_rows');
$month = substr(trim((string) $request->query('detail_month', '')), 0, 10);
if ($month !== '') {
$query->where('report_month', $month);
}
$warehouse = trim((string) $request->query('detail_warehouse', ''));
if ($warehouse !== '') {
$like = '%' . addcslashes($warehouse, '%_\\') . '%';
$query->where(function ($inner) use ($like): void {
$inner->where('warehouse', 'like', $like)->orWhere('warehouse_short_name', 'like', $like);
});
}
$barcode = trim((string) $request->query('detail_barcode', ''));
if ($barcode !== '') {
$query->where('barcode', 'like', '%' . addcslashes($barcode, '%_\\') . '%');
}
$filename = '入库明细数据_' . ($month !== '' ? $month : 'all') . '.csv';
return response()->streamDownload(function () use ($query, $labels): void {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, array_values($labels));
$query->orderBy('row_key')->chunk(1000, function ($rows) use ($out, $labels): void {
foreach ($rows as $row) {
$line = [];
foreach (array_keys($labels) as $column) {
if ($column === 'total') {
// 总计 = 金额 + 预估金额
$line[] = number_format((float) ($row->amount ?? 0) + (float) ($row->estimated_amount ?? 0), 2);
continue;
}
$value = $row->{$column} ?? '';
$line[] = is_string($value) ? $value : (string) $value;
}
fputcsv($out, $line);
}
});
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
protected function viewData(
array $section,
?array $page = null,
$tabs = null,
?array $activeTab = null,
?array $content = null,
): array {
$descriptor = null;
$rows = collect();
$tabValue = null;
$tabColumn = null;
$upstreamError = null;
$inventoryWarehouseMappings = collect();
$receiptStats = null;
$agreementCourierNames = $this->agreementCourierNames($page['slug'] ?? null);
$agreementCourierAccounts = $this->agreementCourierAccounts($page['slug'] ?? null, $agreementCourierNames);
$agreementCourierContractParties = $this->agreementCourierContractParties($page['slug'] ?? null, $agreementCourierNames);
$agreementSupplierOptions = $this->agreementSupplierOptions($page['slug'] ?? null);
// 内部流转报表读取共享业务镜像中由工作簿导入的数据集,不走上游只读查询。
if ($page && $content === null && $this->isInternalFlowReportPage($section, $page)) {
[$rows, $upstreamError] = $this->internalFlowReportRows();
}
if (($section['slug'] ?? null) === 'product-inventory' && ($page['slug'] ?? null) === 'inventory-report') {
if (request()->boolean('mapping')) {
$rows = new LengthAwarePaginator([], 0, 1, 1, [
'path' => request()->url(),
'query' => request()->query(),
]);
} else {
[$rows, $upstreamError] = $this->inventoryReportRows();
}
$inventoryWarehouseMappings = $this->inventoryWarehouseMappingRows();
}
if (($section['slug'] ?? null) === 'product-inventory'
&& ($page['slug'] ?? null) === 'monthly-weighted-average-price') {
$rows = $this->monthlyWeightedAveragePriceRows();
}
if (($section['slug'] ?? null) === 'financial-operations'
&& ($page['slug'] ?? null) === 'performance-details') {
[$rows, $receiptStats] = $this->storeReceiptOrderMatchRows();
}
if (($section['slug'] ?? null) === 'financial-operations'
&& ($page['slug'] ?? null) === 'courier-freight') {
$rows = $this->courierFreightRows();
}
if ($page && $content === null) {
$descriptor = TableRegistry::describe(
$section['slug'],
$page['slug'],
$activeTab['slug'] ?? null,
);
if ($descriptor) {
if ($descriptor->readOnly) {
try {
$rows = app(UpstreamBusinessInformation::class)->paginate(
$section['slug'],
$page['slug'],
50,
);
} catch (ServiceUnavailableHttpException $exception) {
// 首份快照尚未生成或某个来源暂时不可用时,保留页面、
// 交代原因并允许后台刷新,而不是把整个业务页变成通用 503。
$rows = new LengthAwarePaginator([], 0, 50, 1, [
'path' => request()->url(),
'query' => request()->query(),
]);
$upstreamError = $exception->getMessage();
}
return [
'section' => $section,
'page' => $page,
'tabs' => $tabs ?? collect(),
'activeTab' => $activeTab,
'content' => $content,
'descriptor' => $descriptor,
'rows' => $rows,
'upstreamError' => $upstreamError,
'tabColumn' => null,
'tabValue' => null,
'edgeBanding' => $this->edgeBandingContent($page['slug']),
'inventoryData' => $this->inventoryContent($page['slug']),
'rawBoardData' => $this->rawBoardContent($page['slug']),
'agreementTasks' => $this->agreementTasks($page['slug']),
'agreementQuoteSignatures' => $this->agreementQuoteSignatures($page['slug']),
'agreementSubjects' => $this->agreementSubjects($page['slug']),
'agreementCourierNames' => $agreementCourierNames,
'agreementCourierAccounts' => $agreementCourierAccounts,
'agreementCourierContractParties' => $agreementCourierContractParties,
'agreementSupplierOptions' => $agreementSupplierOptions,
'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug']),
'inventoryWarehouseMappings' => $inventoryWarehouseMappings,
'receiptStats' => $receiptStats,
];
}
if (($section['slug'] ?? null) === 'product-inventory'
&& ($page['slug'] ?? null) === 'inventory-report') {
// inventoryReportRows() 已从聚水潭共享镜像按全仓口径取数。
// 这里必须保留该结果,不能再被目录表的旧演示数据覆盖。
} elseif ($this->isStoreReceivingAccountPage($section['slug'], $page['slug'])) {
try {
app(\App\Business\StoreReceivingAccountSynchronizer::class)->sync((int) (company_id() ?: 1));
} catch (Throwable $exception) {
report($exception);
$upstreamError = '店铺基础信息暂时无法同步,请稍后重试。';
}
$rows = $this->storeReceivingAccountRows($activeTab['slug'] ?? null);
$tabColumn = 'store_platform';
} else {
$query = ($descriptor->model)::query();
if ($descriptor->tabFilterColumn && $activeTab) {
$tabValue = TableRegistry::tabValue($section['slug'], $page['slug'], $activeTab['slug']);
if ($tabValue !== null) {
$query->where($descriptor->tabFilterColumn, $tabValue);
}
$tabColumn = $descriptor->tabFilterColumn;
}
foreach (TableRegistry::fixedFilters($section['slug'], $page['slug']) as $column => $value) {
$query->where($column, $value);
}
$rows = $query->orderByDesc('id')->paginate(50)->withQueryString();
}
}
}
return [
'section' => $section,
'page' => $page,
'tabs' => $tabs ?? collect(),
'activeTab' => $activeTab,
'content' => $content,
'descriptor' => $descriptor,
'rows' => $rows,
'upstreamError' => $upstreamError,
'tabColumn' => $tabColumn,
'tabValue' => $tabValue,
'edgeBanding' => $page ? $this->edgeBandingContent($page['slug']) : null,
'inventoryData' => $page ? $this->inventoryContent($page['slug']) : null,
'rawBoardData' => $page ? $this->rawBoardContent($page['slug']) : null,
'agreementTasks' => $this->agreementTasks($page['slug'] ?? null),
'agreementQuoteSignatures' => $this->agreementQuoteSignatures($page['slug'] ?? null),
'agreementSubjects' => $this->agreementSubjects($page['slug'] ?? null),
'agreementCourierNames' => $agreementCourierNames,
'agreementCourierAccounts' => $agreementCourierAccounts,
'agreementCourierContractParties' => $agreementCourierContractParties,
'agreementSupplierOptions' => $agreementSupplierOptions,
'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug'] ?? null),
'inventoryWarehouseMappings' => $inventoryWarehouseMappings,
'receiptStats' => $receiptStats,
'payablesFinal' => $this->payablesFinalData($section, $page),
];
}
/**
* 「终版」目录的按表数据:独立存放,初始为空,由页面上的导入/导出/查询/编辑维护。
*
* @return array<string,mixed>|null
*/
protected function payablesFinalData(array $section, ?array $page): ?array
{
$sectionSlug = (string) ($section['slug'] ?? '');
if (empty($page) || ! in_array($sectionSlug, [
PayablesFinalRegistry::SECTION_SUPPLIER,
PayablesFinalRegistry::SECTION_COURIER,
], true)) {
return null;
}
$dataset = (string) ($page['slug'] ?? '');
$registry = app(PayablesFinalRegistry::class);
if (! $registry->supports($sectionSlug, $dataset)) {
return null;
}
$keyword = trim((string) request('keyword', ''));
$rows = $this->supplierPayableOverrides->listRows(
PayablesFinalActions::DATASET_PREFIX
. ($sectionSlug === PayablesFinalRegistry::SECTION_COURIER ? 'courier:' : 'supplier:')
. $dataset,
(int) (company_id() ?: 1),
);
if ($keyword !== '') {
$needle = mb_strtolower($keyword);
$rows = array_values(array_filter($rows, static function (array $row) use ($needle): bool {
foreach ($row as $key => $value) {
if ($key === '_action_key' || $value === null) {
continue;
}
if (str_contains(mb_strtolower((string) $value), $needle)) {
return true;
}
}
return false;
}));
}
$perPage = 50;
$currentPage = max((int) request('page', 1), 1);
return [
'dataset' => $dataset,
'fields' => collect($registry->fields($sectionSlug, $dataset))
->map(static fn (array $definition, string $name): array => ['name' => $name] + $definition)
->values()
->all(),
'rows' => new LengthAwarePaginator(
array_slice($rows, ($currentPage - 1) * $perPage, $perPage),
count($rows),
$perPage,
$currentPage,
['path' => request()->url(), 'query' => request()->query()],
),
'keyword' => $keyword,
'actions' => url('/' . (company_id() ?: 1) . '/workspace/' . $sectionSlug . '/actions/' . $dataset),
];
}
/** @return array{0:LengthAwarePaginator,1:?string} */
private function inventoryReportRows(): array
{
$page = max(1, request()->integer('page', 1));
$perPage = min(max((int) setting('default.list_limit', 25), 1), 100);
$filters = $this->inventoryReportFilters(request());
$error = null;
try {
if (! $this->thirdPartyWarehouseStorage->available()) {
$error = '进销存共享镜像尚未建立,暂时无法读取。';
} elseif (! $this->thirdPartyWarehouseStorage->allWarehousesReady()) {
$error = '进销存全仓库共享镜像正在执行首次同步,完成前不会展示不完整的单仓数据。';
} else {
$result = $this->thirdPartyWarehouseStorage->paginateMonthlyInventoryReport(
$filters,
$page,
$perPage,
);
$shortNames = app(InventoryWarehouseShortNames::class);
$names = $shortNames->forCompany((int) (company_id() ?: 1));
return [new LengthAwarePaginator(
array_map(static fn (object $row): object => $shortNames->fill($row, $names), $result['data']),
$result['total'],
$perPage,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
), null];
}
} catch (Throwable $exception) {
report($exception);
$error = '进销存共享镜像暂时无法读取,请稍后重试。';
}
return [new LengthAwarePaginator([], 0, $perPage, $page, [
'path' => request()->url(),
'query' => request()->except('page'),
]), $error];
}
private function inventoryWarehouseMappingRows(): LengthAwarePaginator
{
$query = InventoryWarehouseNameMapping::query()->orderBy('warehouse_provider')->orderBy('warehouse');
$provider = mb_substr(trim((string) request('mapping_warehouse_provider', '')), 0, 191);
$warehouse = mb_substr(trim((string) request('mapping_warehouse', '')), 0, 191);
if ($provider !== '') {
$query->where('warehouse_provider', 'like', '%' . addcslashes($provider, '%_\\') . '%');
}
if ($warehouse !== '') {
$query->where('warehouse', 'like', '%' . addcslashes($warehouse, '%_\\') . '%');
}
return $query->paginate(50)->withQueryString();
}
public function storeInventoryWarehouseMapping(Request $request)
{
$data = $request->validate([
'warehouse_provider' => ['required', 'string', 'max:191'],
'warehouse' => ['required', 'string', 'max:191'],
'warehouse_short_name' => ['nullable', 'string', 'max:191'],
]);
$data['warehouse_provider'] = trim($data['warehouse_provider']);
$data['warehouse'] = trim($data['warehouse']);
$data['warehouse_short_name'] = trim((string) ($data['warehouse_short_name'] ?? ''))
?: InventoryWarehouseNameMappingWorkbookImporter::suggestion($data['warehouse_provider'], $data['warehouse']);
if (InventoryWarehouseNameMapping::query()
->where('warehouse_provider', $data['warehouse_provider'])
->where('warehouse', $data['warehouse'])
->exists()) {
return back()->withInput()->withErrors(['warehouse' => '相同“仓储方 + 仓库”已存在,请使用修改功能。']);
}
InventoryWarehouseNameMapping::create($data + [
'company_id' => company_id() ?: 1,
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
return $this->inventoryWarehouseMappingRedirect()->with('success', '仓库名称映射已新增。');
}
public function updateInventoryWarehouseMapping(Request $request, int $id)
{
$mapping = InventoryWarehouseNameMapping::query()->findOrFail($id);
$data = $request->validate([
'warehouse_provider' => ['required', 'string', 'max:191'],
'warehouse' => ['required', 'string', 'max:191'],
'warehouse_short_name' => ['required', 'string', 'max:191'],
]);
$data = array_map(static fn (string $value): string => trim($value), $data);
if (InventoryWarehouseNameMapping::query()
->where('id', '!=', $mapping->id)
->where('warehouse_provider', $data['warehouse_provider'])
->where('warehouse', $data['warehouse'])
->exists()) {
return back()->withInput()->withErrors(['warehouse' => '相同“仓储方 + 仓库”已存在,不能产生重复台账。']);
}
$mapping->update($data + ['updated_by' => auth()->id()]);
return $this->inventoryWarehouseMappingRedirect()->with('success', '仓库名称映射已修改。');
}
public function previewInventoryWarehouseMappingImport(
Request $request,
InventoryWarehouseNameMappingWorkbookImporter $importer,
) {
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'],
]);
$preview = $importer->preview($request->file('import'));
$token = Str::random(40);
session()->put('inventory_warehouse_mapping_previews.' . $token, $preview);
return $this->inventoryWarehouseMappingRedirect()
->with('warehouse_mapping_preview', $preview + ['token' => $token]);
}
public function commitInventoryWarehouseMappingImport(
Request $request,
InventoryWarehouseNameMappingWorkbookImporter $importer,
) {
$token = (string) $request->input('preview_token');
$preview = session()->pull('inventory_warehouse_mapping_previews.' . $token);
abort_if($token === '' || ! is_array($preview), 422, '导入预览已过期,请重新上传文件。');
$result = $importer->commit($preview);
return $this->inventoryWarehouseMappingRedirect()->with(
'success',
"导入完成:新增 {$result['created']} 条,跳过 {$result['skipped']} 条重复映射。",
);
}
public function exportInventoryWarehouseMappings(Request $request): StreamedResponse
{
$query = InventoryWarehouseNameMapping::query()->orderBy('warehouse_provider')->orderBy('warehouse');
$provider = mb_substr(trim((string) $request->query('mapping_warehouse_provider', '')), 0, 191);
$warehouse = mb_substr(trim((string) $request->query('mapping_warehouse', '')), 0, 191);
if ($provider !== '') {
$query->where('warehouse_provider', 'like', '%' . addcslashes($provider, '%_\\') . '%');
}
if ($warehouse !== '') {
$query->where('warehouse', 'like', '%' . addcslashes($warehouse, '%_\\') . '%');
}
return response()->streamDownload(function () use ($query): void {
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('仓库名称映射表');
$headers = ['仓储方', '仓库', '仓库简称'];
$sheet->fromArray($headers, null, 'A1');
$lastColumn = Coordinate::stringFromColumnIndex(count($headers));
$headerRange = "A1:{$lastColumn}1";
$sheet->getStyle($headerRange)->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
$sheet->freezePane('A2');
$sheet->setAutoFilter($headerRange);
foreach ([20, 32, 20] as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
}
$rowNumber = 2;
foreach ($query->cursor() as $mapping) {
$sheet->fromArray([
$mapping->warehouse_provider,
$mapping->warehouse,
$mapping->warehouse_short_name,
], null, "A{$rowNumber}");
$rowNumber++;
}
(new Xlsx($spreadsheet))->save('php://output');
$spreadsheet->disconnectWorksheets();
}, '仓库名称映射表_' . now()->format('Ymd_His') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store, no-cache, must-revalidate',
]);
}
private function inventoryWarehouseMappingRedirect()
{
return redirect()->route('business.pages.show', [
'company_id' => company_id() ?: 1,
'section' => 'product-inventory',
'page' => 'inventory-report',
'mapping' => 1,
'mapping_warehouse_provider' => request('mapping_warehouse_provider', ''),
'mapping_warehouse' => request('mapping_warehouse', ''),
]);
}
/** @return array<string,string> */
private function inventoryReportFilters(Request $request): array
{
$month = trim((string) $request->query('month', now()->startOfMonth()->subMonth()->format('Y-m')));
if (preg_match('/^(\d{4})-(\d{2})$/', $month, $matches) !== 1
|| ! checkdate((int) $matches[2], 1, (int) $matches[1])) {
$month = now()->startOfMonth()->subMonth()->format('Y-m');
}
$minimumMonth = substr(ThirdPartyWarehouseStorageMirrorRepository::INVENTORY_REPORT_START_DATE, 0, 7);
if ($month < $minimumMonth) {
$month = $minimumMonth;
}
return [
'month' => $month,
'product_code' => mb_substr(trim((string) $request->query('product_code', '')), 0, 100),
'warehouse_provider' => mb_substr(trim((string) $request->query('warehouse_provider', '')), 0, 191),
'category' => mb_substr(trim((string) $request->query('category', '')), 0, 191),
];
}
private function isStoreReceivingAccountPage(string $section, string $page): bool
{
return $section === 'fund-management' && $page === 'store-receiving-accounts';
}
private function isInternalFlowReportPage(array $section, array $page): bool
{
return ($section['slug'] ?? null) === 'store-management'
&& ($page['slug'] ?? null) === 'internal-flow-report';
}
/**
* 内部流转报表明细。数据来自共享业务镜像中按工作簿整份替换的导入结果,
* 尚未导入时保留空页面并说明原因,不把“未导入”当成业务数据为 0。
*
* @return array{0: LengthAwarePaginator, 1: ?string}
*/
private function internalFlowReportRows(): array
{
$perPage = 50;
$currentPage = max((int) request('page', 1), 1);
$paginate = fn (array $items, int $total): LengthAwarePaginator => new LengthAwarePaginator(
$items,
$total,
$perPage,
$currentPage,
['path' => request()->url(), 'query' => request()->query()],
);
$repository = app(InternalFlowReportRepository::class);
if (! $repository->ready()) {
return [
$paginate([], 0),
'内部流转数据尚未导入。导入《一般纳税人内部流转》工作簿后,本页会显示明细。',
];
}
$payload = $repository->paginate(
$currentPage,
$perPage,
[
'company_name' => (string) request('company_name', ''),
'tag' => (string) request('tag', ''),
],
request('sort') === null ? null : (string) request('sort'),
(string) request('direction', 'asc'),
);
return [$paginate($payload['data'], $payload['total']), null];
}
private function storeReceivingAccountRows(?string $tab): LengthAwarePaginator
{
$query = \App\Models\Business\StoreReceivingAccount::query();
$platforms = $tab === null
? ['淘宝', '阿里巴巴', '天猫', '淘宝分销', '唯品会', '京东', '京喜', '抖音', '拼多多']
: TableRegistry::tabValues('fund-management', 'store-receiving-accounts', $tab);
if ($platforms !== []) {
$query->whereIn('store_platform', $platforms);
}
$erpName = mb_substr(trim((string) request('erp_store_name', '')), 0, 191);
if ($erpName !== '') {
$query->where('store_name', 'like', '%' . addcslashes($erpName, '%_\\') . '%');
}
$rows = $query->orderByDesc('id')->get();
$shopName = mb_substr(trim((string) request('shop_name', '')), 0, 191);
$status = trim((string) request('store_status', ''));
if ($shopName !== '' || $status !== '') {
$rows = $rows->filter(function ($row) use ($shopName, $status): bool {
return ($shopName === '' || str_contains((string) $row->shop_name, $shopName))
&& ($status === '' || (string) $row->store_status === $status);
})->values();
}
$page = max((int) request('page', 1), 1);
$perPage = 50;
$total = $rows->count();
return new LengthAwarePaginator(
$rows->forPage($page, $perPage)->values(),
$total,
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()],
);
}
protected function agreementCourierNames(?string $page): Collection
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
$names = collect((array) $this->courierInformation->names());
} catch (Throwable $exception) {
// 快递镜像不可用时仍允许打开签署页,并明确展示暂无可选资料。
report($exception);
$names = collect();
}
// 财务系统入驻的快递也能直接选(不必等进工作平台)。
return $names->merge($this->onboardedCourierSigning()->keys())->filter()->unique()->values();
}
/** @return Collection<string,string> 已入驻快递:系统快递名称 => E 签宝账号 */
private function onboardedCourierSigning(): Collection
{
if (! \Illuminate\Support\Facades\Schema::hasTable('business_courier_onboardings')) {
return collect();
}
return \App\Models\Business\CourierOnboarding::query()
->whereIn('status', [\App\Models\Business\CourierOnboarding::STATUS_APPROVED, \App\Models\Business\CourierOnboarding::STATUS_ACTIVATED])
->whereNotNull('courier_name')
->latest('id')
->get(['courier_name', 'esign_account'])
->mapWithKeys(static fn ($onboarding): array => [(string) $onboarding->courier_name => (string) $onboarding->esign_account]);
}
protected function agreementSupplierOptions(?string $page): array
{
if ($page !== 'agreement-signing') return [];
try {
return app(\Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository::class)->signingOptions();
} catch (Throwable $exception) {
report($exception);
return [];
}
}
/** @return array<string,string> */
protected function agreementCourierAccounts(?string $page, ?Collection $courierNames = null): array
{
if ($page !== 'agreement-signing') {
return [];
}
try {
$accounts = (array) $this->courierInformation->signingAccountsByName(
($courierNames ?? $this->agreementCourierNames($page))->values()->all(),
);
} catch (Throwable $exception) {
// 快递镜像不可用时仍可手工填写 E 签宝账号,不能阻断签署页。
report($exception);
$accounts = [];
}
foreach ($this->onboardedCourierSigning() as $name => $account) {
if ($account !== '' && blank($accounts[$name] ?? null)) {
$accounts[$name] = $account;
}
}
return $accounts;
}
/** @return array<string,array{party_b:string,party_a:string,ambiguous:bool}> */
protected function agreementCourierContractParties(?string $page, Collection $courierNames): array
{
if ($page !== 'agreement-signing') {
return [];
}
try {
return $this->courierInformation->contractPartiesByName($courierNames->values()->all());
} catch (Throwable $exception) {
report($exception);
return [];
}
}
protected function agreementTasks(?string $page): Collection|\Illuminate\Contracts\Pagination\LengthAwarePaginator
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
$type = $this->agreementFilterType();
if ($type === 'quote-confirmation') {
return collect();
}
$query = CourierAgreementSignature::query();
$search = $this->agreementFilterSearch();
$status = $this->agreementFilterStatus();
$scope = $this->agreementFilterScope();
if ($scope === 'archived') {
$query->where('status', CourierAgreementSignature::STATUS_ARCHIVED);
} elseif ($scope === 'active') {
$query->where('status', '<>', CourierAgreementSignature::STATUS_ARCHIVED);
}
if ($search !== '') {
$like = '%' . addcslashes($search, '%_\\') . '%';
$query->where(function ($builder) use ($like): void {
$builder
->where('business_no', 'like', $like)
->orWhere('courier_name', 'like', $like)
->orWhere('organization_name', 'like', $like)
->orWhere('operator_name', 'like', $like)
->orWhere('company_full_name', 'like', $like);
});
}
if ($status !== null) {
$query->where('status', $status);
}
if ($type !== null) {
$query->where('document_type', $type);
}
return $query
->latest('id')
->paginate(50, ['*'], 'agreement_task_page')->withQueryString();
} catch (QueryException $exception) {
// 迁移尚未执行时仍允许打开页面;发送接口会明确提示数据库未就绪。
report($exception);
return collect();
}
}
protected function agreementFilterSearch(): string
{
return mb_substr(trim((string) request('agreement_search', '')), 0, 191);
}
protected function agreementFilterStatus(): ?string
{
$status = trim((string) request('agreement_status', ''));
$allowed = [
CourierAgreementSignature::STATUS_PREPARED,
CourierAgreementSignature::STATUS_SENDING,
CourierAgreementSignature::STATUS_SIGNING,
CourierAgreementSignature::STATUS_COMPLETED,
CourierAgreementSignature::STATUS_ARCHIVED,
CourierAgreementSignature::STATUS_REVOKED,
CourierAgreementSignature::STATUS_EXPIRED,
CourierAgreementSignature::STATUS_REJECTED,
CourierAgreementSignature::STATUS_FAILED,
CourierAgreementSignature::STATUS_UNKNOWN,
];
return in_array($status, $allowed, true) ? $status : null;
}
protected function agreementFilterType(): ?string
{
$type = trim((string) request('agreement_type', ''));
$allowed = array_keys(CourierAgreementSignature::DOCUMENTS);
return in_array($type, $allowed, true) ? $type : null;
}
protected function agreementFilterScope(): string
{
$default = request('agreement_status') === CourierAgreementSignature::STATUS_ARCHIVED ? 'archived' : 'active';
$scope = trim((string) request('agreement_scope', $default));
return in_array($scope, ['active', 'archived', 'all'], true) ? $scope : 'active';
}
protected function agreementSubjects(?string $page): Collection
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
return CourierSigningSubject::query()
->orderByDesc('is_default')
->orderBy('name')
->get();
} catch (QueryException $exception) {
// 主体管理迁移尚未执行时仍允许打开签署页面,默认使用当前公司名称。
report($exception);
return collect();
}
}
protected function agreementQuoteSignatures(?string $page): Collection|\Illuminate\Contracts\Pagination\LengthAwarePaginator
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
$type = $this->agreementFilterType();
if ($type !== null && $type !== 'quote-confirmation') {
return collect();
}
$query = CourierQuoteSignature::query()->with('version');
$search = $this->agreementFilterSearch();
$status = $this->agreementFilterStatus();
$scope = $this->agreementFilterScope();
if ($scope === 'archived') {
$query->where('status', CourierQuoteSignature::STATUS_ARCHIVED);
} elseif ($scope === 'active') {
$query->where('status', '<>', CourierQuoteSignature::STATUS_ARCHIVED);
}
if ($search !== '') {
$like = '%' . addcslashes($search, '%_\\') . '%';
$query->where(function ($builder) use ($like): void {
$builder
->where('organization_name', 'like', $like)
->orWhere('operator_name', 'like', $like)
->orWhereHas('version', function ($versionQuery) use ($like): void {
$versionQuery
->where('version_code', 'like', $like)
->orWhere('carrier_name', 'like', $like);
});
});
}
if ($status !== null) {
$query->where('status', $status);
}
return $query
->latest('id')
->paginate(50, ['*'], 'agreement_quote_page')->withQueryString();
} catch (QueryException $exception) {
// 报价签署迁移尚未执行时仍允许打开协议签署页。
report($exception);
return collect();
}
}
protected function agreementQuoteVersions(?string $page): Collection
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
return CourierQuoteVersion::query()
->where('status', 'approved')
->orderByDesc('effective_date')
->orderByDesc('id')
->limit(100)
->get();
} catch (QueryException $exception) {
// 报价版本迁移尚未执行时仍允许打开协议签署页,报价确认单会提示暂无可选版本。
report($exception);
return collect();
}
}
protected function resolveContentForSection(string $section, string $page, ?string $tab): ?array
{
if (in_array($section, self::PAYABLES_FINAL_SECTIONS, true)) {
return null;
}
return $this->resolveContent($page, $tab);
}
protected function resolveContent(string $page, ?string $tab): ?array
{
return match ($page) {
'marketing-account-bills' => $this->contentForMarketingAccountBills($tab),
'marketing-account-balance' => $this->contentForMarketingAccountBalance($tab),
'jingxi-refund-data' => $this->contentForJingxiRefundData(),
'non-jingxi-refund-data' => $this->contentForNonJingxiRefundData(),
'small-payment-work-orders' => $this->contentForSmallPaymentWorkOrders(),
'zhongcai-reconciliation' => $this->contentForZhongcaiReconciliation(),
'normal-products' => $this->contentForNormalProducts(),
'product-prices' => $this->contentForProductPrices(),
'courier-information' => $this->contentForCourierInformation(),
'courier-onboarding' => app(\App\Business\CourierOnboardingPageData::class)->page(),
'freight-reconciliation' => $this->contentForFreightReconciliation(),
'third-party-warehouse-storage' => $this->contentForThirdPartyWarehouseStorage(),
'warehouse-freight' => ['view' => 'business.pages.courier-payables.warehouse-freight'],
'board-deductions' => $this->contentForBoardDeductions(),
'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
'supplier-management' => app(\Modules\EcommerceFinance\View\SupplierManagementPageData::class)->page(),
'accessory-freight-deductions' => app(\Modules\EcommerceFinance\View\SupplierAccessoryFreightPageData::class)->page(),
'manufacturer-bookkeeping' => app(\Modules\EcommerceFinance\View\SupplierBookkeepingPageData::class)->page(),
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
'online-daifa-price' => $this->contentForOnlineDaifaPrice(),
'product-costs' => $this->contentForProductCosts(),
'financial-analysis' => $this->contentForFinancialAnalysis(),
'courier-surcharge' => $this->contentForCourierSurcharge($tab),
'courier-surcharge-settlement' => $this->contentForCourierSurchargeSettlementPage(),
'freight-difference', 'other-surcharge-summary', 'return-fee' => $this->contentForCourierPayableSummary($page),
'accessory-reship-deductions' => app(\Modules\EcommerceFinance\View\AccessoryReshipPageData::class)->page(),
'shipping-data' => $this->contentForShippingData(),
'return-pickup-management' => $this->contentForReturnPickupManagement(),
'withdrawal-management' => $this->contentForWithdrawalManagement(),
'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(),
'account-mapping' => $this->contentForAccountMapping(),
'ledger' => app(\Modules\EcommerceFinance\View\FinanceLedgerPageData::class)->page($tab),
'billing-details' => app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class)->page(),
'store-violation-alert' => $this->contentForStoreViolationAlerts(),
'preview-statistics' => $this->contentForPreviewStatistics(),
'planting-details' => $this->contentForPlantingDetails(),
'jst-order-labels' => $this->contentForJstOrderLabels(),
'planting-reimbursements' => app(\Modules\EcommerceFinance\View\PlantingReimbursementPageData::class)->page(),
'planting-loss' => app(\Modules\EcommerceFinance\View\PlantingLossPageData::class)->page(),
'finance-data' => app(\Modules\EcommerceFinance\View\SupplierReconciliationPageData::class)->financeData(),
'supplier-data' => app(\Modules\EcommerceFinance\View\SupplierReconciliationPageData::class)->supplierData(),
'store-report-data-logic' => $this->prepareStoreReportLogic(),
'tax-forecast-data-logic' => $this->contentForTaxForecastLogic(),
default => null,
};
}
/**
* 税金预测表逻辑配置:规则清单、数据源状态和运行参数。
*/
protected function contentForTaxForecastLogic(): array
{
return [
'view' => 'business.pages.company-management.tax-forecast-data-logic',
'taxRules' => TaxForecastLogic::rules(),
'taxSettings' => TaxForecastLogic::current(),
'taxHealth' => TaxForecastLogic::health(),
];
}
public function editTaxForecastLogic()
{
return view('business.pages.company-management.tax-forecast-data-logic-edit', [
'section' => ['slug' => 'trademark-management', 'title' => '商标管理'],
'page' => ['slug' => 'tax-forecast-data-logic', 'title' => '税金预测表逻辑配置'],
'settings' => TaxForecastLogic::current(),
'incomeForecastSources' => TaxForecastLogic::INCOME_FORECAST_SOURCES,
]);
}
public function updateTaxForecastLogic(Request $request)
{
$data = $request->validate([
'vat_small_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'vat_free_threshold' => ['required', 'numeric', 'min:0'],
'vat_general_enabled' => ['required', 'in:0,1'],
'surtax_city_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'surtax_education_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'surtax_local_education_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'surtax_halved' => ['required', 'in:0,1'],
'stamp_duty_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'stamp_duty_halved' => ['required', 'in:0,1'],
'cit_effective_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'cit_threshold' => ['required', 'numeric', 'min:0'],
'individual_halved_threshold' => ['required', 'numeric', 'min:0'],
'assessed_income_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'income_forecast_source' => ['required', 'in:' . implode(',', array_keys(TaxForecastLogic::INCOME_FORECAST_SOURCES))],
]);
TaxForecastLogic::save($data);
return redirect()
->route('business.pages.show', ['section' => 'trademark-management', 'page' => 'tax-forecast-data-logic'])
->with('success', '税金预测表逻辑配置已保存,下次预测按新参数计算。');
}
/** 首次打开店铺维度报表逻辑页时把代码里的期初数据固化成可编辑设置。 */
protected function prepareStoreReportLogic(): ?array
{
StoreReportInitialPeriods::seedFromConfig();
return null;
}
/** 店铺维度报表逻辑配置:期初主体数据编辑页。 */
public function editStoreReportLogic()
{
$periods = StoreReportInitialPeriods::current();
$shops = $periods['shops'];
uasort($shops, static fn (array $left, array $right): int => strcmp($left['changed_at'], $right['changed_at']));
return view('business.pages.trademark-management.store-report-data-logic-edit', [
'section' => ['slug' => 'trademark-management', 'title' => '商标管理'],
'page' => ['slug' => 'store-report-data-logic', 'title' => '店铺维度报表逻辑配置'],
'initialEffectiveAt' => $periods['initial_effective_at'],
'initialShops' => $shops,
]);
}
public function updateStoreReportInitialPeriod(Request $request)
{
$data = $request->validate([
'store_name' => ['required', 'string', 'max:191'],
'previous_company' => ['required', 'string', 'max:191'],
'company' => ['required', 'string', 'max:191'],
'changed_at' => ['required', 'date_format:Y-m-d'],
]);
StoreReportInitialPeriods::putShop($data['store_name'], [
'previous_company' => $data['previous_company'],
'company' => $data['company'],
'changed_at' => $data['changed_at'],
]);
return redirect()
->route('business.store-report-logic.edit')
->with('success', sprintf('店铺「%s」的期初主体已保存。', $data['store_name']));
}
public function destroyStoreReportInitialPeriod(Request $request)
{
$data = $request->validate([
'store_name' => ['required', 'string', 'max:191'],
]);
StoreReportInitialPeriods::forgetShop($data['store_name']);
return redirect()
->route('business.store-report-logic.edit')
->with('success', sprintf('店铺「%s」的期初主体已删除。', $data['store_name']));
}
/**
* 账单流水数据 › 聚水潭订单标签明细。
*
* 按标签拆行展示(一个标签一行),不做标签汇总、不对订单去重。
*/
protected function contentForJstOrderLabels(): array
{
$request = request();
$filters = $this->jstOrderLabelFilters($request);
$query = $this->jstOrderLabelQuery($filters);
$limit = min(max((int) setting('default.list_limit', 25), 10), 100);
$page = max((int) $request->query('page', 1), 1);
$hasFilters = $filters['date_from'] !== ''
|| $filters['date_to'] !== ''
|| $filters['shop_names'] !== []
|| $filters['labels'] !== []
|| $filters['keyword'] !== '';
// 无筛选时复用缓存总数,避免每次翻页都在 233 万行上重跑 COUNT(*)。
$total = $hasFilters
? (clone $query)->count()
: app(\App\Business\JstOrderLabelOptions::class)->total((int) company_id());
$records = new \Illuminate\Pagination\LengthAwarePaginator(
$query->orderByDesc('ordered_at')->orderByDesc('id')->forPage($page, $limit)->get(),
$total,
$limit,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
// 「是否种菜订单」:按(公司 + 标签)匹配财务维护的映射表;货号含 X00006 的一律为是
$plantingRules = app(\App\Business\JstOrderLabelPlantingRules::class);
$records->setCollection($records->getCollection()->map(static function ($record) use ($plantingRules) {
$record->setAttribute('is_planting_order', $plantingRules->flag((int) company_id(), $record->label, $record->item_code));
return $record;
}));
return [
'view' => 'business.pages.billing-transactions.jst-order-labels',
'records' => $records,
'filters' => $filters,
'totalRows' => $records->total(),
'filterOptions' => [
'shops' => app(\App\Business\JstOrderLabelOptions::class)->shops((int) company_id()),
'labels' => app(\App\Business\JstOrderLabelOptions::class)->labels((int) company_id()),
],
];
}
/** @return array{date_from:string,date_to:string,shop_names:list<string>,labels:list<string>,keyword:string} */
private function jstOrderLabelFilters(Request $request): array
{
return [
'date_from' => $this->dateFilter($request->query('date_from')),
'date_to' => $this->dateFilter($request->query('date_to')),
'shop_names' => $this->filterValues($request->query('shop_name'), 191),
'labels' => $this->filterValues($request->query('label'), 191),
'keyword' => mb_substr(trim((string) $request->query('keyword')), 0, 100),
];
}
/**
* 多选筛选值归一化:兼容旧的单值参数(?shop_name=A)与新的数组参数(?shop_name[]=A&shop_name[]=B)。
*
* @return list<string>
*/
// 上限与 JstOrderLabelOptions::LIMIT 对齐:下拉里能选到的值,提交时不能被悄悄截断。
private function filterValues(mixed $raw, int $maxLength, int $limit = 5000): array
{
$list = is_array($raw) ? $raw : [$raw];
$values = [];
foreach ($list as $value) {
$value = mb_substr(trim((string) $value), 0, $maxLength);
if ($value !== '' && ! in_array($value, $values, true)) {
$values[] = $value;
}
}
return array_slice($values, 0, $limit);
}
/** @param array{date_from:string,date_to:string,shop_names:list<string>,labels:list<string>,keyword:string} $filters */
private function jstOrderLabelQuery(array $filters): \Illuminate\Database\Eloquent\Builder
{
$query = \App\Models\Business\JstOrderLabel::query()->where('company_id', company_id());
if ($filters['date_from'] !== '') {
$query->where('ordered_at', '>=', $filters['date_from'] . ' 00:00:00');
}
if ($filters['date_to'] !== '') {
$query->where('ordered_at', '<', date('Y-m-d', strtotime($filters['date_to'] . ' +1 day')) . ' 00:00:00');
}
if ($filters['shop_names'] !== []) {
$query->whereIn('shop_name', $filters['shop_names']);
}
if ($filters['labels'] !== []) {
// 多选标签按「任一命中」过滤,保持与单值时一致的包含匹配语义
$query->where(function ($sub) use ($filters): void {
foreach ($filters['labels'] as $label) {
$sub->orWhere('label', 'like', '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $label) . '%');
}
});
}
if ($filters['keyword'] !== '') {
$query->where('order_no', 'like', '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['keyword']) . '%');
}
return $query;
}
/** 账单流水数据 › 聚水潭订单标签明细导出(按当前筛选,全量) */
public function exportJstOrderLabels(Request $request): StreamedResponse
{
$query = $this->jstOrderLabelQuery($this->jstOrderLabelFilters($request));
$plantingRules = app(\App\Business\JstOrderLabelPlantingRules::class);
$companyId = (int) company_id();
return response()->streamDownload(function () use ($query, $plantingRules, $companyId): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建聚水潭订单标签明细导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, ['线上订单号', '店铺名称', '下单时间', '是否种菜订单', '标签', '货号']);
$query->orderBy('id')->chunkById(2000, function ($rows) use ($csv, $plantingRules, $companyId): void {
foreach ($rows as $row) {
fputcsv($csv, [
$row->order_no,
$row->shop_name,
$row->ordered_at?->format('Y-m-d H:i:s'),
$plantingRules->flag($companyId, $row->label, $row->item_code),
$row->label,
$row->item_code,
]);
}
});
fclose($csv);
}, '聚水潭订单标签明细_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/**
* 财务经营风险管控 › 店铺违规信息预警。
*
* 六个平台页签共用统一表头;数据来自统一表 store_violation_records
* (上游抓取同步 + 手工导入),页面不直接读取上游。
*/
protected function contentForStoreViolationAlerts(): array
{
$request = request();
$platforms = (array) config('store_violations.platforms', []);
$requested = (string) $request->query('platform', (string) array_key_first($platforms));
$activePlatform = array_key_exists($requested, $platforms) ? $requested : (string) array_key_first($platforms);
$filters = $this->storeViolationFilters($request);
$repository = app(StoreViolationRecordRepository::class);
$limit = min(max((int) setting('default.list_limit', 25), 10), 100);
$error = null;
$panels = [];
if (! $repository->available()) {
$error = '店铺违规统一表尚未建立,暂时无法读取数据。';
}
foreach (array_keys($platforms) as $slug) {
$page = $slug === $activePlatform ? max((int) $request->query('page', 1), 1) : 1;
$records = Collection::make();
$total = 0;
if ($error === null) {
try {
$result = $repository->paginate((string) $slug, $filters, $page, $limit);
$records = Collection::make($result['data']);
$total = (int) $result['total'];
} catch (Throwable $exception) {
report($exception);
$error = '店铺违规数据暂时无法读取,请稍后重试。';
}
}
$panels[$slug] = [
'rows' => new LengthAwarePaginator($records, $total, $limit, $page, [
'path' => $request->url(),
'query' => array_merge($request->query(), ['platform' => (string) $slug]),
]),
'total' => $total,
];
}
return [
'view' => 'business.pages.financial-risk-control.index',
'storeViolationPlatforms' => $platforms,
'storeViolationActivePlatform' => $activePlatform,
'storeViolationPanels' => $panels,
'storeViolationFilters' => $filters,
'storeViolationFilterOptions' => $error === null
? $repository->filterOptions($activePlatform)
: ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => [], 'owners' => [], 'levels' => []],
'storeViolationPlatformCounts' => $error === null ? $repository->platformCounts() : [],
'storeViolationSync' => $repository->syncStatus(),
'storeViolationError' => $error,
'storeViolationPreview' => session('store_violation_preview'),
];
}
/** @return array<string,string> */
private function storeViolationFilters(Request $request): array
{
$owner = mb_substr(trim((string) $request->query('owner')), 0, 64);
$ownerIsMe = in_array(mb_strtolower($owner), ['me', '我的'], true);
if ($ownerIsMe) {
$owner = mb_substr(trim((string) (auth()->user()->name ?? '')), 0, 64);
}
$handlingStatus = mb_substr(trim((string) $request->query('handling_status')), 0, 32);
$view = trim((string) $request->query('view')) === 'all' ? 'all' : 'default';
$explicit = trim((string) $request->query('keyword')) !== ''
|| trim((string) $request->query('status')) !== ''
|| $handlingStatus !== ''
|| trim((string) $request->query('category')) !== ''
|| $this->dateFilter($request->query('date_from')) !== ''
|| $this->dateFilter($request->query('date_to')) !== ''
|| trim((string) $request->query('level')) !== ''
|| $owner !== ''
|| trim((string) $request->query('overdue')) === '1';
$filters = [
'keyword' => mb_substr(trim((string) $request->query('keyword')), 0, 100),
'status' => mb_substr(trim((string) $request->query('status')), 0, 64),
'handling_status' => $handlingStatus,
'category' => mb_substr(trim((string) $request->query('category')), 0, 64),
'date_from' => $this->dateFilter($request->query('date_from')),
'date_to' => $this->dateFilter($request->query('date_to')),
'level' => mb_substr(trim((string) $request->query('level')), 0, 8),
'owner' => $owner,
'owner_is_me' => $ownerIsMe ? '1' : '',
'overdue' => trim((string) $request->query('overdue')) === '1' ? '1' : '',
'view' => $view,
'has_explicit' => $explicit ? '1' : '',
];
// 默认视图:只展示「需提醒 + 未处理」,避免历史数据淹没预警;
// 其余数据仍可通过「全部数据」查看。
if ($view === 'default') {
$filters['needs_alert'] = '1';
if ($filters['handling_status'] === '') {
$filters['handling_status'] = (string) config('store_violations.default_handling_status', '未处理');
}
}
return $filters;
}
/** 财务经营风险管控 › 预览统计:顶部 4 张 KPI 卡 + 需提醒且未处理的明细预览。 */
protected function contentForPreviewStatistics(): array
{
$request = request();
$platforms = (array) config('store_violations.platforms', []);
$requested = (string) $request->query('platform', (string) array_key_first($platforms));
$activePlatform = array_key_exists($requested, $platforms) ? $requested : (string) array_key_first($platforms);
$repository = app(StoreViolationRecordRepository::class);
$limit = min(max((int) setting('default.list_limit', 25), 10), 100);
$error = null;
$stats = [];
$counts = [];
$panels = [];
if (! $repository->available()) {
$error = '店铺违规统一表尚未建立,暂时无法读取数据。';
} else {
try {
$stats = $repository->statsByPlatform();
$counts = $repository->platformCounts();
} catch (Throwable $exception) {
report($exception);
$error = '店铺违规数据暂时无法读取,请稍后重试。';
}
}
foreach (array_keys($platforms) as $slug) {
$page = $slug === $activePlatform ? max((int) $request->query('page', 1), 1) : 1;
$records = Collection::make();
$total = 0;
if ($error === null) {
try {
$result = $repository->paginate((string) $slug, [
'needs_alert' => true,
'handling_status' => (string) config('store_violations.default_handling_status', '未处理'),
], $page, $limit);
$records = Collection::make($result['data']);
$total = (int) $result['total'];
} catch (Throwable $exception) {
report($exception);
$error = '店铺违规数据暂时无法读取,请稍后重试。';
}
}
$panels[$slug] = [
'rows' => new LengthAwarePaginator($records, $total, $limit, $page, [
'path' => $request->url(),
'query' => array_merge($request->query(), ['platform' => (string) $slug]),
]),
'total' => $total,
'stats' => $stats[$slug] ?? ['new_today' => 0, 'pending' => 0, 'p0_pending' => 0, 'overdue' => 0],
];
}
return [
'view' => 'business.pages.financial-risk-control.preview-statistics',
'storeViolationPlatforms' => $platforms,
'storeViolationActivePlatform' => $activePlatform,
'storeViolationPlatformCounts' => $counts,
'storeViolationPanels' => $panels,
'storeViolationError' => $error,
];
}
/** 店铺违规信息预警:行内更新处理状态(已忽略必填原因)。 */
public function updateStoreViolation(Request $request)
{
$data = $request->validate([
'id' => ['required', 'integer'],
'handling_status' => ['required', 'string', \Illuminate\Validation\Rule::in((array) config('store_violations.handling_statuses', []))],
'ignore_reason' => ['nullable', 'string', 'max:191'],
'points_deducted' => ['nullable', 'numeric', 'min:0', 'max:9999'],
'penalty_date' => ['nullable', 'date_format:Y-m-d'],
]);
$ignoreReason = trim((string) ($data['ignore_reason'] ?? ''));
if ($data['handling_status'] === '已忽略' && $ignoreReason === '') {
throw \Illuminate\Validation\ValidationException::withMessages([
'ignore_reason' => '已忽略必须填写忽略原因。',
]);
}
$repository = app(StoreViolationRecordRepository::class);
abort_unless($repository->available(), 503, '店铺违规统一表尚未建立。');
abort_if($repository->findById((int) $data['id']) === null, 404);
$updates = [
'handling_status' => $data['handling_status'],
'ignore_reason' => $data['handling_status'] === '已忽略' ? $ignoreReason : null,
'updated_at' => now(),
];
if ($request->has('points_deducted')) {
$updates['points_deducted'] = $data['points_deducted'] ?? null;
}
if ($request->has('penalty_date')) {
$updates['penalty_date'] = $data['penalty_date'] ?? null;
}
$repository->updateFields((int) $data['id'], $updates);
return back(302, [], route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'store-violation-alert',
]))->with('success', '处理状态已更新。');
}
/** 店铺违规信息预警:群消息「去认领」签名链接,幂等置为处理中。 */
public function claimStoreViolation(Request $request, int $record)
{
abort_unless($request->hasValidSignature(), 403, '链接已过期,请从最新的提醒消息进入。');
$repository = app(StoreViolationRecordRepository::class);
abort_unless($repository->available(), 503, '店铺违规统一表尚未建立。');
$row = $repository->findById($record);
abort_if($row === null, 404);
$defaultStatus = (string) config('store_violations.default_handling_status', '未处理');
if (trim((string) ($row->handling_status ?? '')) === $defaultStatus) {
$repository->updateFields($record, [
'handling_status' => '处理中',
'updated_at' => now(),
]);
}
return redirect()
->route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'store-violation-alert',
'platform' => (string) ($row->platform ?? ''),
'keyword' => (string) ($row->violation_no ?? ''),
])
->with('success', '已认领该违规记录,处理状态已更新为“处理中”。');
}
/** 店铺违规信息预警:按当前筛选导出统一表头(Excel)。 */
public function exportStoreViolations(Request $request): StreamedResponse
{
$platforms = (array) config('store_violations.platforms', []);
$platform = (string) $request->query('platform', (string) array_key_first($platforms));
abort_unless(array_key_exists($platform, $platforms), 404);
$repository = app(StoreViolationRecordRepository::class);
abort_unless($repository->available(), 503, '店铺违规统一表尚未建立,暂时不能导出。');
$filters = $this->storeViolationFilters($request);
$columns = (array) config('store_violations.columns', []);
$rows = $repository->exportRows($platform, $filters);
$label = (string) ($platforms[$platform]['label'] ?? $platform);
return response()->streamDownload(function () use ($rows, $columns): void {
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('店铺违规信息');
$headers = array_map(static fn (array $column): string => (string) $column['label'], $columns);
$sheet->fromArray($headers, null, 'A1');
$lastColumn = Coordinate::stringFromColumnIndex(max(count($headers), 1));
$sheet->getStyle("A1:{$lastColumn}1")->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
$rowNumber = 2;
foreach ($rows as $row) {
$values = [];
foreach ($columns as $column) {
$key = (string) $column['key'];
$value = $row->{$key} ?? null;
if ($key === 'points_deducted' && $value !== null) {
$value = (float) $value;
}
if ($key === 'alert_level' && $value !== null) {
$value = (string) (config('store_violations.alert_levels.' . $value . '.label') ?? $value);
}
if ($key === 'is_overdue') {
$value = StoreViolationAlertCalculator::isOverdue(
$row->handling_status ?? null,
$row->handling_deadline ?? null,
) ? '已超时' : '未超时';
}
$values[] = $value === null ? '' : $value;
}
$sheet->fromArray($values, null, 'A' . $rowNumber);
$rowNumber++;
}
foreach (['A' => 30, 'B' => 12, 'C' => 36, 'D' => 14, 'E' => 36, 'F' => 28, 'G' => 24, 'H' => 14, 'I' => 10, 'J' => 12, 'K' => 16, 'L' => 14, 'M' => 16, 'N' => 18, 'O' => 12, 'P' => 12, 'Q' => 14, 'R' => 24] as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
$sheet->freezePane('A2');
(new Xlsx($spreadsheet))->save('php://output');
}, '店铺违规信息_' . $label . '_' . now()->format('Ymd_His') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
/** 店铺违规信息预警:导入模板(统一表头 + 填写说明)。 */
public function storeViolationTemplate(): StreamedResponse
{
$columns = (array) config('store_violations.columns', []);
$handling = implode('、', (array) config('store_violations.handling_statuses', []));
return response()->streamDownload(function () use ($columns, $handling): void {
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('店铺违规信息');
$headers = array_map(static fn (array $column): string => (string) $column['label'], $columns);
$sheet->fromArray($headers, null, 'A1');
$sheet->fromArray(
['示例·某某旗舰店', 'P1-较重', '延迟发货/缺货无货', '发货履约', '订单存在揽收超时行为', '警告', 'JD202610040001', '2026-10-04', 2, '示例·张三', '待处理', '', '2026-10-11', '2026-10-13 23:59:59', '未超时', '未推送', '未处理', ''],
null,
'A2',
);
$lastColumn = Coordinate::stringFromColumnIndex(max(count($headers), 1));
$sheet->getStyle("A1:{$lastColumn}1")->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
foreach (['A' => 30, 'B' => 12, 'C' => 36, 'D' => 14, 'E' => 36, 'F' => 28, 'G' => 24, 'H' => 14, 'I' => 10, 'J' => 12, 'K' => 16, 'L' => 14, 'M' => 16, 'N' => 18, 'O' => 12, 'P' => 12, 'Q' => 14, 'R' => 24] as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
$sheet->freezePane('A2');
$notes = $spreadsheet->createSheet();
$notes->setTitle('填写说明');
$notes->fromArray([
['字段', '说明'],
['店铺名', '必填。'],
['违规单号', '必填。同一平台内唯一;已存在的单号会按文件内容更新(导入预览会逐行提示)。'],
['违规类型', '选填。平台显示的违规内容/类型,例如“延迟发货/缺货无货”。'],
['违规分类', '选填。留空时按违规类型关键词自动归类。'],
['预警级别', '自动计算。按「平台+违规类型」匹配违规字典;可留空,导入时自动生成。'],
['责任人', '自动计算。按店铺名称关联店铺运营负责人;可留空,导入时自动生成。'],
['违规原因', '选填。平台展示的违规原因/详细说明。'],
['处置方式', '选填。平台对违规的处置/处理方式。'],
['违规日期 / 处罚日期 / 申诉截止日期', '选填。格式 YYYY-MM-DD,例如 2026-10-04。'],
['扣分', '选填。数字,例如 2 或 0.5。'],
['状态', '选填。平台状态,例如“待处理”“超期未申诉”。'],
['处理截止', '自动计算。申诉截止日期非空时取其当天 23:59:59;否则按级别(P0+1 / P1+2 / P2+3 天)计算。'],
['是否超时', '自动计算。处理状态非“处理完成/已忽略”且已过处理截止时显示“已超时”。'],
['推送状态', '系统回写。导入时忽略该列。'],
['处理状态', '选填。可选值:' . $handling . ';选“已忽略”时必须填写忽略原因;留空按“未处理”写入。'],
['忽略原因', '选填。处理状态为“已忽略”时必填。'],
['平台', '无需填写。导入时按当前页签决定平台。'],
['提示', '请保留第一行表头;示例行可直接替换为实际数据。'],
], null, 'A1');
$notes->getStyle('A1:B1')->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
]);
$notes->getColumnDimension('A')->setWidth(34);
$notes->getColumnDimension('B')->setWidth(72);
$notes->getStyle('B1:B19')->getAlignment()->setWrapText(true);
(new Xlsx($spreadsheet))->save('php://output');
}, '店铺违规信息导入模板_' . now()->format('Ymd') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
/** 店铺违规信息预警:导入预览(先校验,不写库)。 */
public function previewStoreViolationImport(Request $request, StoreViolationWorkbookImporter $importer)
{
$maxKilobytes = (int) config('store_violations.import.max_kilobytes', 10240);
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:' . $maxKilobytes],
'platform' => ['required', 'string'],
]);
$platform = (string) $request->input('platform');
abort_unless(array_key_exists($platform, (array) config('store_violations.platforms', [])), 422);
try {
$preview = $importer->preview($request->file('import'), $platform);
} catch (\Illuminate\Validation\ValidationException $exception) {
return redirect()
->route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'store-violation-alert',
'platform' => $platform,
])
->withErrors($exception->errors())
->withInput();
}
$token = Str::random(40);
session()->put('store_violation_previews.' . $token, $preview);
return redirect()
->route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'store-violation-alert',
'platform' => $platform,
])
->with('store_violation_preview', $preview + ['token' => $token]);
}
/** 店铺违规信息预警:确认导入。 */
public function commitStoreViolationImport(Request $request, StoreViolationWorkbookImporter $importer)
{
$token = (string) $request->input('preview_token');
$preview = session()->pull('store_violation_previews.' . $token);
abort_if($token === '' || ! is_array($preview), 422, '导入预览已过期,请重新上传文件。');
$result = $importer->commit($preview);
return redirect()
->route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'store-violation-alert',
'platform' => (string) ($preview['platform'] ?? array_key_first((array) config('store_violations.platforms', []))),
])
->with('success', "导入完成:新增 {$result['created']} 条,更新 {$result['updated']} 条。");
}
/**
* 账单流水数据 › 种菜明细表。
*/
protected function contentForPlantingDetails(): array
{
$request = request();
$filters = $this->plantingDetailFilters($request);
$query = $this->plantingDetailQuery($filters);
$summaryRow = (clone $query)->selectRaw(
'COUNT(*) AS rows_count, COALESCE(SUM(pieces), 0) AS pieces_total, '
. 'COALESCE(SUM(finance_selling_price), 0) AS selling_price_total, '
. 'COALESCE(SUM(finance_payment_amount), 0) AS payment_amount_total, '
. 'COALESCE(SUM(deduction), 0) AS deduction_total, '
. 'COALESCE(SUM(jingxi_price_diff), 0) AS jingxi_diff_total'
)->first();
$limit = min(max((int) setting('default.list_limit', 25), 10), 100);
$records = (clone $query)
->orderByDesc('bill_date')
->orderByDesc('id')
->paginate($limit)
->withQueryString();
return [
'view' => 'business.pages.billing-transactions.planting-details',
'records' => $records,
'filters' => $filters,
'summary' => [
'rows' => (int) ($summaryRow->rows_count ?? 0),
'pieces' => (int) ($summaryRow->pieces_total ?? 0),
'selling_price' => (float) ($summaryRow->selling_price_total ?? 0),
'payment_amount' => (float) ($summaryRow->payment_amount_total ?? 0),
'deduction' => (float) ($summaryRow->deduction_total ?? 0),
'jingxi_diff' => (float) ($summaryRow->jingxi_diff_total ?? 0),
],
'filterOptions' => [
'stores' => \App\Models\Business\PlantingDetail::query()->where('company_id', company_id())
->whereNotNull('store')->distinct()->orderBy('store')->limit(500)->pluck('store')->all(),
'owners' => \App\Models\Business\PlantingDetail::query()->where('company_id', company_id())
->whereNotNull('owner')->distinct()->orderBy('owner')->limit(500)->pluck('owner')->all(),
'types' => \App\Models\Business\PlantingDetail::query()->where('company_id', company_id())
->whereNotNull('type')->distinct()->orderBy('type')->limit(200)->pluck('type')->all(),
'tags' => \App\Models\Business\PlantingDetail::query()->where('company_id', company_id())
->whereNotNull('tag')->distinct()->orderBy('tag')->limit(200)->pluck('tag')->all(),
],
];
}
/** @return array<string,string> 种菜明细表筛选条件 */
private function plantingDetailFilters(Request $request): array
{
return [
'date_from' => $this->dateFilter($request->query('date_from')),
'date_to' => $this->dateFilter($request->query('date_to')),
'store' => mb_substr(trim((string) $request->query('store')), 0, 191),
'owner' => mb_substr(trim((string) $request->query('owner')), 0, 191),
'type' => mb_substr(trim((string) $request->query('type')), 0, 191),
'tag' => mb_substr(trim((string) $request->query('tag')), 0, 191),
'keyword' => mb_substr(trim((string) $request->query('keyword')), 0, 191),
];
}
/** @param array<string,string> $filters */
private function plantingDetailQuery(array $filters): \Illuminate\Database\Eloquent\Builder
{
$query = \App\Models\Business\PlantingDetail::query()->where('company_id', company_id());
if ($filters['date_from'] !== '') {
$query->where('bill_date', '>=', $filters['date_from']);
}
if ($filters['date_to'] !== '') {
$query->where('bill_date', '<=', $filters['date_to']);
}
foreach (['store', 'owner', 'type', 'tag'] as $field) {
if ($filters[$field] !== '') {
$query->where($field, $filters[$field]);
}
}
if ($filters['keyword'] !== '') {
$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['keyword']) . '%';
$query->where(function ($sub) use ($like): void {
$sub->where('order_no', 'like', $like)
->orWhere('remark', 'like', $like)
->orWhere('account', 'like', $like)
->orWhere('external_id', 'like', $like)
->orWhere('creator', 'like', $like);
});
}
return $query;
}
/** 账单流水数据 › 种菜明细表导出(按当前筛选,全量) */
public function exportPlantingDetails(Request $request): StreamedResponse
{
$query = $this->plantingDetailQuery($this->plantingDetailFilters($request));
return response()->streamDownload(function () use ($query): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建种菜明细导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, [
'日期', '订单编号', '财务售价', '财务运费', '财务佣金', '京喜差价', '扣款',
'财务空包费', '财务打款金额', '笔数', '备注', '正确店铺', '负责人', '账户',
'类型', '创建人', '标签', '所属利润中心', '负责平台', 'ID',
]);
$query->orderBy('id')->chunkById(2000, function ($rows) use ($csv): void {
foreach ($rows as $row) {
fputcsv($csv, [
$row->bill_date?->toDateString(),
$row->order_no,
$row->finance_selling_price,
$row->finance_freight,
$row->finance_commission,
$row->jingxi_price_diff,
$row->deduction,
$row->finance_empty_package_fee,
$row->finance_payment_amount,
$row->pieces,
$row->remark,
$row->store,
$row->owner,
$row->account,
$row->type,
$row->creator,
$row->tag,
$row->profit_center,
$row->responsible_platform,
$row->external_id,
]);
}
});
fclose($csv);
}, '种菜明细表_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/**
* 财务规则 · 科目映射表
*
* 表里存的是「科目对 + 方向基准」,不存最终借贷方向;记账时按账单实际收支代入。
* 页面支持导入追加、单条修改、新增、删除,并实时给出账套科目校验结果。
*/
protected function contentForAccountMapping(): array
{
$store = app(AccountMappingStore::class);
$validator = app(AccountMappingValidator::class);
$rows = [];
$summary = [
'total' => 0, 'error_rows' => 0, 'warning_rows' => 0, 'ok_rows' => 0,
'error_count' => 0, 'warning_count' => 0, 'tip_count' => 0,
];
$options = ['platforms' => [], 'categories' => [], 'basis' => [], 'invoices' => []];
$error = null;
try {
$rules = $store->all();
$validated = $validator->validate($rules);
foreach ($rules as $rule) {
$rows[] = $rule + ['issues' => $validated['issues'][(int) $rule['id']] ?? []];
}
$summary = $validated['summary'];
$options = $store->options();
} catch (Throwable $exception) {
$error = '映射表存储暂时读不出来,请稍后重试。';
}
return [
'view' => 'business.pages.financial-rules.account-mapping',
'amRows' => $rows,
'amSummary' => $summary,
'amOptions' => $options,
'amEndpoint' => $this->accountMappingEndpoint(),
'amError' => $error,
];
}
/** 维护接口前缀(去掉末尾的 /rules) */
protected function accountMappingEndpoint(): string
{
try {
$url = route('business.financial-rules.account-mapping.actions.rules');
} catch (Throwable) {
$url = url((int) (company_id() ?: 1) . '/workspace/financial-rules/account-mapping/actions/rules');
}
return (string) preg_replace('#/rules$#', '', $url);
}
protected function contentForCourierPayableSummary(string $page): array
{
$store = app(CourierPayableSummaryStore::class);
$snapshot = null;
$error = null;
try {
$snapshot = $store->snapshot((int) (company_id() ?: 1), $page);
} catch (RuntimeException) {
$error = '汇总数据暂时无法读取,请重新导入后再试。';
}
$headers = $snapshot['headers'] ?? [];
$allRows = collect($snapshot['rows'] ?? []);
$filter = app(CourierPayableSummaryFilter::class);
$filters = $filter->fromRequest(request());
$filteredRows = $filter->apply($allRows, $headers, $filters);
$perPage = 50;
$lastPage = max((int) ceil($filteredRows->count() / $perPage), 1);
$currentPage = min(max((int) request()->query('page', 1), 1), $lastPage);
return [
'view' => 'business.pages.courier-payables.summary-table',
'courierSummaryPage' => $page,
'courierSummaryTitle' => $store->title($page),
'courierSummaryHeaders' => $headers,
'courierSummaryRows' => new LengthAwarePaginator(
$filteredRows->forPage($currentPage, $perPage)->values(),
$filteredRows->count(),
$perPage,
$currentPage,
['path' => request()->url(), 'query' => array_filter(
$filters,
static fn (string $value): bool => $value !== '',
)],
),
'courierSummaryFilters' => $filters,
'courierSummaryHasFilters' => $filter->hasFilters($filters),
'courierSummarySourceTotal' => $allRows->count(),
'courierSummarySourceName' => $snapshot['source_name'] ?? null,
'courierSummaryImportedAt' => $snapshot['imported_at'] ?? null,
'courierSummaryError' => $error,
];
}
/**
* Consolidated, non-destructive view of the withdrawal configuration kept
* with each store receiving account. Updates remain in the source page so
* a withdrawal action cannot delete an entire store-account record.
*/
protected function contentForWithdrawalManagement(): array
{
$report = $this->withdrawalManagementReport->results(request());
$withdrawalRows = $report['rows'];
$page = max((int) request()->query('page', 1), 1);
$perPage = 50;
$withdrawalRows = new LengthAwarePaginator(
$withdrawalRows->forPage($page, $perPage)->values(),
$withdrawalRows->count(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->except('page')],
);
return [
'view' => 'business.pages.fund-management.withdrawal-management',
'withdrawalRows' => $withdrawalRows,
'withdrawalFilters' => $report['filters'],
'withdrawalPlatforms' => $report['platforms'],
'withdrawalPaths' => $report['paths'],
'withdrawalStoreEntities' => $report['storeEntities'],
'withdrawalBalances' => $report['balances'],
'withdrawalReserveStandards' => $report['reserveStandards'],
'withdrawalAmounts' => $report['withdrawalAmounts'],
'withdrawalCompliance' => $report['compliance'],
'withdrawalBalanceSummary' => $report['summary'],
'withdrawalBalanceColumnLabel' => $report['balanceColumnLabel'],
'withdrawalBalanceDate' => $report['balanceDate'],
'withdrawalBalanceDates' => $report['balanceDates'],
'withdrawalSort' => $report['sort'],
];
}
/**
* 快递附加费报价维护。
*
* 报价维护与后续附加费结算分开:本页只负责渠道、类型、结算方式、
* 计价标准和生效时间的维护展示,结算页将来根据这些规则读取业务镜像。
*/
protected function contentForShippingData(): array
{
$request = request();
$filters = $this->shippingDataFilters($request);
$mode = $request->query('mode') === 'detail' ? 'detail' : 'merged';
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => $request->url(),
'query' => $request->except('page'),
]);
$sync = ['code' => 'unavailable', 'last_succeeded_at' => null];
$error = null;
try {
$result = $this->shippingOrders->paginate($filters, $page, $limit, $mode);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => $request->url(), 'query' => $request->except('page')],
);
$sync = $this->shippingOrders->syncStatus();
} catch (Throwable $exception) {
report($exception);
$error = '发货数据暂时无法读取(可能尚未运行迁移或同步)。';
}
return [
'view' => 'business.pages.courier-payables.shipping-data',
'shippingRows' => $rows,
'shippingFilters' => $filters,
'shippingSync' => $sync,
'shippingError' => $error,
'shippingMode' => $mode,
'shippingExportUrl' => route('business.courier-payables.shipping-data.export'),
];
}
public function exportShippingData(Request $request): StreamedResponse
{
$filters = $this->shippingDataFilters($request);
$mode = $request->query('mode') === 'detail' ? 'detail' : 'merged';
$columns = $this->shippingDataColumns();
return response()->streamDownload(function () use ($filters, $columns, $mode): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建发货数据导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
foreach ($this->shippingOrders->exportRows($filters, 1000, $mode) as $row) {
fputcsv($csv, array_map(function (array $column) use ($row): mixed {
$value = data_get($row, $column['field']);
// 发货数据导出:标识列(快递单号/交易单号/商品ID/条形码)按原样文本导出,不加 ' 前缀
return match ($column['type']) {
'number' => $value === null || $value === '' ? '' : $value,
default => $this->csvText($value),
};
}, $columns));
}
fclose($csv);
}, '发货数据' . ($mode === 'detail' ? '明细' : '合并') . '_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
public function suggestShippingData(Request $request): \Illuminate\Http\JsonResponse
{
$allowed = ['supplier_name', 'shop_name', 'warehouse_name', 'express_company'];
$field = (string) $request->query('field', '');
if (! in_array($field, $allowed, true)) {
return response()->json(['items' => []]);
}
$keyword = mb_substr(trim((string) $request->query('q', '')), 0, 64);
$limit = min(max((int) $request->query('limit', 20), 1), 500);
try {
$items = $this->shippingOrders->distinctValues($field, $keyword, $limit);
} catch (Throwable $exception) {
report($exception);
$items = [];
}
return response()->json(['items' => array_values($items)]);
}
/** @return array<string,mixed> */
private function shippingDataFilters(Request $request): array
{
$text = static fn (string $key, int $length = 128): string => mb_substr(
trim((string) $request->query($key, '')),
0,
$length,
);
// 多选字段:接收 name[] 数组(也兼容单个字符串),去重、限长、限个数。
$multi = static function (string $key, int $length = 191, int $max = 50) use ($request): array {
$raw = $request->query($key, []);
if (is_string($raw)) {
$raw = $raw === '' ? [] : [$raw];
}
if (! is_array($raw)) {
$raw = [];
}
$out = [];
foreach ($raw as $value) {
$value = mb_substr(trim((string) $value), 0, $length);
if ($value !== '' && ! in_array($value, $out, true)) {
$out[] = $value;
}
if (count($out) >= $max) {
break;
}
}
return $out;
};
$date = static function (mixed $value): string {
$value = trim((string) $value);
return preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $value) === 1 ? $value : '';
};
return [
'express_no' => $text('express_no', 80),
'sourceno' => $text('sourceno', 80),
'barcode' => $text('barcode'),
'supplier_name' => $multi('supplier_name'),
'shop_name' => $multi('shop_name', 255),
'warehouse_name' => $multi('warehouse_name'),
'express_company' => $multi('express_company'),
'ship_from' => $date($request->query('ship_from', '')),
'ship_to' => $date($request->query('ship_to', '')),
];
}
/** @return list<array{field:string,label:string,type:string}> */
private function shippingDataColumns(): array
{
return [
['field' => 'express_no', 'label' => '快递单号', 'type' => 'identifier'],
['field' => 'trade_no', 'label' => '交易单号', 'type' => 'identifier'],
['field' => 'item_id', 'label' => '商品ID', 'type' => 'identifier'],
['field' => 'barcode', 'label' => '条形码', 'type' => 'identifier'],
['field' => 'product_name', 'label' => '商品名称', 'type' => 'text'],
['field' => 'qty', 'label' => '数量', 'type' => 'number'],
['field' => 'pkg_count', 'label' => '件数', 'type' => 'text'],
['field' => 'receiver_state', 'label' => '省', 'type' => 'text'],
['field' => 'buyer_nick', 'label' => '买家昵称', 'type' => 'text'],
['field' => 'receiver_name', 'label' => '收货姓名', 'type' => 'text'],
['field' => 'shop_name', 'label' => '店铺名称', 'type' => 'text'],
['field' => 'express_name1', 'label' => '快递名称1', 'type' => 'text'],
['field' => 'warehouse_name', 'label' => '仓库', 'type' => 'text'],
['field' => 'ship_time', 'label' => '发货时间', 'type' => 'text'],
['field' => 'freight', 'label' => '运费', 'type' => 'number'],
['field' => 'online_weight', 'label' => '线上重量', 'type' => 'number'],
['field' => 'actual_weight', 'label' => '实称重量', 'type' => 'number'],
['field' => 'length_cm', 'label' => '长(cm)', 'type' => 'number'],
['field' => 'width_cm', 'label' => '宽(cm)', 'type' => 'number'],
['field' => 'height_cm', 'label' => '高(cm)', 'type' => 'number'],
['field' => 'volume_cm3', 'label' => '体积(cm³)', 'type' => 'number'],
['field' => 'receiver_city', 'label' => '市', 'type' => 'text'],
['field' => 'receiver_district', 'label' => '区', 'type' => 'text'],
['field' => 'supplier_name', 'label' => '供应商', 'type' => 'text'],
['field' => 'receiver_address', 'label' => '收货详细地址', 'type' => 'text'],
['field' => 'receiver_mobile', 'label' => '手机号码', 'type' => 'identifier'],
['field' => 'receiver_phone', 'label' => '座机号码', 'type' => 'identifier'],
['field' => 'express_name2', 'label' => '快递名称2', 'type' => 'text'],
['field' => 'platform', 'label' => '平台', 'type' => 'text'],
['field' => 'buyer_memo', 'label' => '买家留言', 'type' => 'text'],
['field' => 'seller_memo', 'label' => '卖家备注', 'type' => 'text'],
['field' => 'package_no', 'label' => '配货单号', 'type' => 'identifier'],
['field' => 'amount', 'label' => '金额', 'type' => 'number'],
['field' => 'product_code', 'label' => '货号', 'type' => 'identifier'],
['field' => 'download_time', 'label' => '下载时间', 'type' => 'text'],
['field' => 'pay_time', 'label' => '付款时间', 'type' => 'text'],
['field' => 'freight_rate', 'label' => '费率', 'type' => 'number'],
['field' => 'freight_formula', 'label' => '公式', 'type' => 'text'],
['field' => 'weight_kg', 'label' => '重量(kg)', 'type' => 'number'],
['field' => 'settle_weight_kg', 'label' => '结算重量(kg)', 'type' => 'number'],
['field' => 'settle_volume_cm3', 'label' => '结算体积(cm³)', 'type' => 'number'],
['field' => 'recon_weight_kg', 'label' => '对账重量(kg)', 'type' => 'number'],
['field' => 'recon_volume_cm3', 'label' => '对账体积(cm³)', 'type' => 'number'],
['field' => 'finished_barcode', 'label' => '成品条码', 'type' => 'identifier'],
['field' => 'finished_name', 'label' => '成品名称', 'type' => 'text'],
['field' => 'finished_supplier', 'label' => '成品供应商', 'type' => 'text'],
['field' => 'settle_freight', 'label' => '结算运费', 'type' => 'number'],
['field' => 'front_no', 'label' => '换单前单号', 'type' => 'identifier'],
];
}
protected function contentForCourierSurcharge(?string $tab = null): array
{
$clean = static fn (mixed $value, int $length = 60): string => mb_substr(trim((string) $value), 0, $length);
$quoteInput = request()->query();
$filters = [
'keyword' => $clean($quoteInput['keyword'] ?? '', 40),
'courier' => $clean($quoteInput['courier'] ?? '', 40),
'type' => $clean($quoteInput['type'] ?? '', 40),
'mode' => $clean($quoteInput['mode'] ?? '', 40),
'scope' => $clean($quoteInput['scope'] ?? '', 20),
'reconciler' => $clean($quoteInput['reconciler'] ?? '', 40),
'supplier' => $clean($quoteInput['supplier'] ?? '', 191),
'status' => $clean($quoteInput['status'] ?? '', 20),
];
$data = $this->courierSurchargeQuotes->pageData($filters);
$auditReady = $this->courierSurchargeAudits->ready();
$content = [
'view' => 'business.pages.courier-payables.courier-surcharge-workspace',
'courierSurchargeActiveTab' => $tab ?? 'quotes',
'courierSurchargeRows' => $data['rows'],
'courierSurchargeFilters' => $filters,
'courierSurchargeSummary' => $data['summary'],
'courierSurchargeOptions' => $data['options'],
'courierSurchargeReady' => $data['ready'] && $auditReady,
'courierSurchargeDataReady' => $data['ready'],
'courierSurchargeAuditReady' => $auditReady,
'courierSurchargeTypeUsage' => $this->courierSurchargeTypes->usageCounts($data['options']['type_records'] ?? collect()),
'courierSurchargeAuditLogs' => $this->courierSurchargeAudits->recent(),
];
return $content;
}
/** @return array<string,mixed> */
protected function contentForCourierSurchargeSettlementPage(): array
{
return array_merge(
['view' => 'business.pages.courier-payables.courier-surcharge-settlement'],
$this->contentForCourierSurchargeSettlement(),
);
}
/** @return array<string,mixed> */
protected function contentForCourierSurchargeSettlement(): array
{
$request = request();
$filters = $this->courierSurchargeSettlementFilters($request);
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => $request->url(),
'query' => $request->except('page'),
]);
$state = null;
$sync = ['code' => 'unavailable', 'last_succeeded_at' => null];
$summary = ['count' => 0, 'settlement_amount' => 0.0];
$error = null;
$pending = false;
$quoteData = $this->courierSurchargeQuotes->pageData([]);
$quotes = Collection::make($quoteData['rows'] ?? []);
try {
if (! $this->courierSurchargeSettlements->available()) {
$error = '共享业务镜像尚未建立,附加费结算暂时无法读取。';
} elseif (! $this->courierSurchargeSettlements->ready()) {
$state = $this->courierSurchargeSettlements->state();
$sync = [
'code' => $state['code'] ?? 'pending',
'last_succeeded_at' => $state['last_succeeded_at'] ?? null,
];
$pending = ($state['code'] ?? 'pending') !== 'failed';
$error = ($state['code'] ?? null) === 'failed'
? '附加费结算镜像同步失败,请联系管理员查看失败阶段。'
: '附加费结算镜像正在完成首次同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierSurchargeSettlements->paginate($filters, $page, $limit, $quotes);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => $request->url(), 'query' => $request->except('page')],
);
$state = $result['state'] ?? null;
$sync = $result['sync'] ?? $sync;
$summary = $result['summary'] ?? $summary;
}
} catch (Throwable $exception) {
report($exception);
$error = '附加费结算数据暂时无法读取,请稍后重试。';
}
return [
'courierSurchargeSettlementRows' => $rows,
'courierSurchargeSettlementFilters' => $filters,
'courierSurchargeSettlementSummary' => $summary,
'courierSurchargeSettlementState' => $state,
'courierSurchargeSettlementSync' => $sync,
'courierSurchargeSettlementError' => $error,
'courierSurchargeSettlementPending' => $pending,
'courierSurchargeSettlementQuoteReady' => (bool) ($quoteData['ready'] ?? false),
];
}
/** @return array<string,string> */
private function courierSurchargeSettlementFilters(Request $request): array
{
$text = static fn (mixed $value, int $length = 100): string => mb_substr(trim((string) $value), 0, $length);
$date = static function (mixed $value): string {
$value = trim((string) $value);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : '';
};
return [
'courier_name' => $text($request->query('courier_name')),
'waybill' => $text($request->query('waybill')),
'platform_product_id' => $text($request->query('platform_product_id')),
'product_code' => $text($request->query('product_code')),
'shop_name' => $text($request->query('shop_name')),
'online_order_no' => $text($request->query('online_order_no')),
'inspection_type' => $text($request->query('inspection_type'), 60),
'inspection_reason' => $text($request->query('inspection_reason'), 120),
'surcharge_type' => $text($request->query('surcharge_type'), 40),
'status' => $text($request->query('status'), 40),
'created_from' => $date($request->query('created_from')),
'created_to' => $date($request->query('created_to')),
];
}
public function exportCourierSurchargeSettlement(Request $request): StreamedResponse
{
$filters = $this->courierSurchargeSettlementFilters($request);
$repository = app(\Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository::class);
$limit = 50000;
return response()->streamDownload(function () use ($filters, $repository, $limit): void {
$writer = new \App\Support\StreamingXlsxWriter('附加费结算');
$writer->appendRow([
'快递名称', '出库时间', '单号', '平台商品ID', '商品编码', '店铺名称', '线上订单号',
'长', '宽', '高', '体积', '查件类型', '查件原因', '退回单号',
'结算重量', '结算金额', '附加费类型',
]);
$exported = 0;
foreach ($repository->exportRows($filters, $limit) as $row) {
$writer->appendRow([
$this->xlsxText($row->courier_name ?? ''),
$this->xlsxText($row->shipped_at ?? ''),
$this->xlsxText($row->waybill ?? ''),
$this->xlsxText($row->platform_product_id ?? ''),
$this->xlsxText($row->product_code ?? ''),
$this->xlsxText($row->shop_name ?? ''),
$this->xlsxText($row->online_order_no ?? ''),
$row->length !== null ? (float) $row->length : null,
$row->width !== null ? (float) $row->width : null,
$row->height !== null ? (float) $row->height : null,
$row->volume !== null ? (float) $row->volume : null,
$this->xlsxText($row->inspection_type ?? ''),
$this->xlsxText($row->inspection_reason ?? ''),
$this->xlsxText($row->return_waybill_no ?? ''),
$row->settlement_weight !== null ? (float) $row->settlement_weight : null,
$row->settlement_amount !== null ? (float) $row->settlement_amount : null,
$this->xlsxText($row->surcharge_type ?? ''),
]);
$exported++;
}
if ($exported >= $limit) {
$writer->appendRow(['(已按上限截断,仅导出前 ' . $limit . ' 行,请缩小筛选范围后重试)']);
}
$output = fopen('php://output', 'wb');
if ($output === false) {
throw new RuntimeException('无法创建附加费结算导出流。');
}
$writer->writeTo($output);
fclose($output);
}, '附加费结算_' . now()->format('Ymd_His') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
public function importCourierSurchargeSettlement(Request $request): StreamedResponse|\Illuminate\Http\RedirectResponse
{
$request->validate([
'file' => ['required', 'file', 'max:10240'],
], [], ['file' => '导入文件']);
try {
$path = (string) $request->file('file')?->getRealPath();
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($path);
$sheet = $spreadsheet->getActiveSheet();
$highestRow = min($sheet->getHighestDataRow(), 5001);
$data = $sheet->rangeToArray(
'A1:' . $sheet->getHighestColumn() . $highestRow,
null,
false,
true,
false,
);
} catch (\Throwable $exception) {
return back()->with('error', '导入失败:' . $exception->getMessage());
}
$headers = [];
foreach ((array) ($data[0] ?? []) as $index => $name) {
$name = trim((string) $name);
if ($name !== '') {
$headers[$name] = (int) $index;
}
}
$waybillColumn = $this->findImportColumn($headers, ['单号', '运单号', '快递单号', 'waybill', 'express_no']);
$typeColumn = $this->findImportColumn($headers, ['附加费类型', '附加费类型名称', '类型', 'surcharge_type']);
$courierColumn = $this->findImportColumn($headers, ['快递名称', '快递公司', '物流公司', '快递', 'courier_name']);
$amountColumn = $this->findImportColumn($headers, ['金额', '导入金额', '费用金额', 'amount']);
if ($waybillColumn === null || $typeColumn === null) {
return back()->with('error', '导入失败:文件首行需包含「单号」和「附加费类型」两列。');
}
$pairs = [];
foreach (array_slice($data, 1) as $offset => $row) {
$waybill = $this->importCellText($row[$waybillColumn] ?? null);
$type = $this->importCellText($row[$typeColumn] ?? null);
if ($waybill === '' && $type === '') {
continue;
}
$pairs[] = [
'row_number' => $offset + 2,
'waybill' => mb_substr($waybill, 0, 191),
'surcharge_type' => mb_substr($type, 0, 40),
'courier_name' => $courierColumn === null
? ''
: mb_substr($this->importCellText($row[$courierColumn] ?? null), 0, 191),
'amount' => $amountColumn === null
? null
: $this->importCellAmount($row[$amountColumn] ?? null),
];
}
if ($pairs === []) {
return back()->with('error', '导入文件没有可核验的数据行。');
}
if (count($pairs) > 5000) {
return back()->with('error', '导入失败:单次最多核验 5000 行。');
}
$amountByRow = [];
foreach ($pairs as $pair) {
$amountByRow[(int) ($pair['row_number'] ?? 0)] = $pair['amount'] ?? null;
}
$verificationKey = 'courier-surcharge:verify:' . company_id() . ':' . auth()->id() . ':'
. hash('sha256', json_encode($pairs, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$results = \Illuminate\Support\Facades\Cache::store('upstream_shared')->lock($verificationKey, 300)
->get(fn () => app(CourierSurchargeSettlementMirrorRepository::class)->decideImportRows($pairs));
if ($results === false) {
return back()->with('error', '同一份数据正在核验,请等待本次核验完成后再提交。');
}
return response()->streamDownload(function () use ($results, $amountByRow): void {
$writer = new \App\Support\StreamingXlsxWriter('导入核验');
$writer->appendRow([
'序号', '单号', '附加费类型', '结算结论', '不结原因', '快递名称', '出库时间', '平台商品ID', '商品编码',
'店铺名称', '线上订单号', '长', '宽', '高', '体积', '查件类型', '查件原因', '退回单号',
'结算重量', '结算金额', '导入金额', '差异金额', '结算状态', '判定依据',
]);
foreach ($results as $row) {
$importedAmount = $amountByRow[(int) ($row['row_number'] ?? 0)] ?? null;
$systemAmount = $row['settlement_amount'] !== null && $row['settlement_amount'] !== ''
? (float) $row['settlement_amount']
: null;
$difference = $importedAmount !== null && $systemAmount !== null
? round($systemAmount - $importedAmount, 2)
: null;
$writer->appendRow([
(int) ($row['row_number'] ?? 0),
$this->xlsxText($row['waybill'] ?? ''),
$this->xlsxText($row['surcharge_type'] ?? ''),
$this->xlsxText($row['decision'] ?? ''),
$this->xlsxText($row['reason'] ?? ''),
$this->xlsxText($row['courier_name'] ?? ''),
$this->xlsxText($row['shipped_at'] ?? ''),
$this->xlsxText($row['platform_product_id'] ?? ''),
$this->xlsxText($row['product_code'] ?? ''),
$this->xlsxText($row['shop_name'] ?? ''),
$this->xlsxText($row['online_order_no'] ?? ''),
$row['length'] !== null ? (float) $row['length'] : null,
$row['width'] !== null ? (float) $row['width'] : null,
$row['height'] !== null ? (float) $row['height'] : null,
$row['volume'] !== null ? (float) $row['volume'] : null,
$this->xlsxText($row['inspection_type'] ?? ''),
$this->xlsxText($row['inspection_reason'] ?? ''),
$this->xlsxText($row['return_waybill_no'] ?? ''),
$row['settlement_weight'] !== null ? (float) $row['settlement_weight'] : null,
$systemAmount,
$importedAmount,
$difference,
$this->xlsxText($row['settlement_status'] ?? ''),
$this->xlsxText($row['evidence'] ?? ''),
]);
}
$output = fopen('php://output', 'wb');
if ($output === false) {
throw new RuntimeException('无法创建导入核验导出流。');
}
$writer->writeTo($output);
fclose($output);
}, '附加费结算_导入核验_' . now()->format('Ymd_His') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
public function downloadCourierSurchargeSettlementImportTemplate(): StreamedResponse
{
return response()->streamDownload(function (): void {
$writer = new \App\Support\StreamingXlsxWriter('导入模板');
$writer->appendRow(['单号', '附加费类型', '金额(可选)']);
$writer->appendRow(['DPK317138931411', '退回费', 20]);
$writer->appendRow(['DPK379220912936', '超长费', null]);
$output = fopen('php://output', 'wb');
if ($output === false) {
throw new RuntimeException('无法创建导入模板导出流。');
}
$writer->writeTo($output);
fclose($output);
}, '附加费结算_导入模板.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
/**
* @param array<string,int> $headers
* @param list<string> $candidates
*/
private function findImportColumn(array $headers, array $candidates): ?int
{
foreach ($headers as $name => $index) {
foreach ($candidates as $candidate) {
if (mb_strtolower($name) === mb_strtolower($candidate)) {
return $index;
}
}
}
foreach ($headers as $name => $index) {
foreach ($candidates as $candidate) {
if (str_contains(mb_strtolower($name), mb_strtolower($candidate))) {
return $index;
}
}
}
return null;
}
private function importCellText(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_int($value)) {
return (string) $value;
}
if (is_float($value)) {
return abs($value - round($value)) < 0.000001 && abs($value) >= 1e11
? number_format($value, 0, '', '')
: rtrim(rtrim(number_format($value, 6, '.', ''), '0'), '.');
}
return trim((string) $value);
}
private function importCellAmount(mixed $value): ?float
{
if ($value === null) {
return null;
}
if (is_int($value) || is_float($value)) {
return (float) $value;
}
$text = str_replace([',', ',', '¥', '¥', '元', ' '], '', trim((string) $value));
return $text !== '' && is_numeric($text) ? (float) $text : null;
}
protected function contentForFreightReconciliation(): array
{
// 原来源口径不适用;保留入口,但不读取明细、同步状态或汇总。
return [
'view' => 'business.pages.courier-payables.freight-reconciliation',
];
}
protected function contentForThirdPartyWarehouseStorage(): array
{
$request = request();
$filters = $this->thirdPartyWarehouseStorageFilters($request);
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => $request->url(),
'query' => $request->except('page'),
]);
$state = null;
$sync = ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
$summary = [];
$error = null;
$pending = false;
try {
if (! $this->thirdPartyWarehouseStorage->available()) {
$error = '共享业务镜像尚未建立,三方仓进销存暂时无法读取。';
} elseif (! $this->thirdPartyWarehouseStorage->ready()) {
$state = $this->thirdPartyWarehouseStorage->state();
$sync = $this->thirdPartyWarehouseStorage->syncStatus($state);
$pending = true;
$error = ($sync['code'] ?? null) === 'dependency_pending'
? '普通商品资料镜像正在执行首次全量同步,完成前不会展示口径不完整的商品属性。'
: '三方仓进销存正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->thirdPartyWarehouseStorage->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
[
'path' => $request->url(),
'query' => $request->except('page'),
],
);
$state = $result['state'] ?? null;
$sync = $result['sync'] ?? $sync;
$summary = $result['summary'] ?? [];
}
} catch (Throwable $exception) {
report($exception);
$message = strtolower($exception->getMessage());
$error = str_contains($message, 'max_statement_time') || str_contains($message, 'query execution was interrupted')
? '三方仓进销存查询超时,请稍后重新查询。'
: (str_contains($message, 'inventory_report_generation_busy')
? '三方仓进销存正在计算,请稍后重新查询。'
: '三方仓进销存暂时无法读取,请稍后重试。');
}
return [
'view' => 'business.pages.courier-payables.third-party-warehouse-storage',
'thirdPartyWarehouseRows' => $rows,
'thirdPartyWarehouseFilters' => $filters,
'thirdPartyWarehouseSummary' => $summary,
'thirdPartyWarehouseState' => $state,
'thirdPartyWarehouseSync' => $sync,
'thirdPartyWarehouseError' => $error,
'thirdPartyWarehousePending' => $pending,
'thirdPartyWarehouseExportUrl' => route('business.courier-payables.third-party-warehouse-storage.export'),
];
}
public function exportThirdPartyWarehouseStorage(Request $request): StreamedResponse
{
abort_unless(
$this->thirdPartyWarehouseStorage->available() && $this->thirdPartyWarehouseStorage->ready(),
503,
);
$filters = $this->thirdPartyWarehouseStorageFilters($request);
$columns = $this->filterExportColumns(
$this->thirdPartyWarehouseStorageExportColumns(),
$request->query('fields'),
);
$period = str_replace('-', '', $filters['period']);
return response()->streamDownload(function () use ($filters, $columns): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建三方仓进销存导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
foreach ($this->thirdPartyWarehouseStorage->exportRows($filters) as $row) {
fputcsv($csv, array_map(function (array $column) use ($row): mixed {
$value = data_get($row, $column['field']);
return match ($column['type'] ?? 'text') {
'number', 'money' => $value === null || $value === '' ? '' : $value,
'code' => $this->csvIdentifier($value),
default => $this->csvText($value),
};
}, $columns));
}
fclose($csv);
}, '三方仓进销存_' . $period . '_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
public function exportInventoryReport(Request $request): Response
{
abort_unless(
$this->thirdPartyWarehouseStorage->available()
&& $this->thirdPartyWarehouseStorage->allWarehousesReady(),
503,
);
$filters = $this->inventoryReportFilters($request);
$productInventory = collect(config('business_navigation'))->firstWhere('slug', 'product-inventory');
$inventoryPage = collect($productInventory['pages'] ?? [])->firstWhere('slug', 'inventory-report');
$availableColumns = collect($inventoryPage['directory']['fields'] ?? [])
->reject(static fn (array $column): bool => in_array($column['kind'] ?? '', ['secret', 'password'], true))
->keyBy('name');
$requestedColumnsParam = $request->query('columns', '');
abort_unless(is_string($requestedColumnsParam), 422);
$requestedColumns = trim($requestedColumnsParam);
$columns = $availableColumns->values()->all();
if ($requestedColumns !== '') {
$names = explode(',', $requestedColumns);
abort_if(count($names) > $availableColumns->count() || count($names) !== count(array_unique($names)), 422);
$columns = [];
foreach ($names as $name) {
abort_unless($availableColumns->has($name), 422);
$columns[] = $availableColumns->get($name);
}
}
$scope = $request->query('scope', 'all');
abort_unless(in_array($scope, ['all', 'page'], true), 422);
$page = max(1, $request->integer('page', 1));
$perPage = min(max((int) setting('default.list_limit', 25), 1), 100);
if ($scope === 'all' && should_queue()) {
$fileName = Str::filename('进销存报表') . '-' . now()->format('Ymd_His') . '-' . Str::random(8) . '.csv';
dispatch(new InventoryReportExport(user(), $filters, $columns, $fileName, (int) (company_id() ?: 1)))->onQueue('exports');
flash('进销存报表已提交后台生成,完成后可从通知中心下载。')->success();
return back();
}
if ($scope === 'all') {
// The local environment intentionally uses the sync queue and has
// no worker process. Defer the same export job until the response
// has been sent so a large CSV never depends on a live browser
// download connection. The job stores the completed file and
// raises the existing notification when it is ready.
$fileName = Str::filename('进销存报表') . '-' . now()->format('Ymd_His') . '-' . Str::random(8) . '.csv';
dispatch(new InventoryReportExport(user(), $filters, $columns, $fileName, (int) (company_id() ?: 1)))->afterResponse();
flash('进销存报表已提交后台生成,完成后可从通知中心下载。')->success();
return back();
}
// Build the complete CSV before opening the HTTP response. Streaming
// rows directly from the database keeps the browser request open for
// the whole aggregation and makes a slow client look like a failed
// export. A disk-backed file keeps memory bounded and lets the web
// server send a framed response with a known length.
set_time_limit(0);
$filePath = tempnam(sys_get_temp_dir(), 'inventory-report-');
if ($filePath === false) {
throw new RuntimeException('无法创建进销存报表导出临时文件。');
}
try {
$csv = fopen($filePath, 'wb');
if ($csv === false) {
throw new RuntimeException('无法写入进销存报表导出临时文件。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
$rows = $scope === 'page'
? $this->thirdPartyWarehouseStorage->paginateMonthlyInventoryReport($filters, $page, $perPage, false)['data']
: $this->thirdPartyWarehouseStorage->exportMonthlyInventoryReportRows($filters);
$shortNames = app(InventoryWarehouseShortNames::class);
$names = $shortNames->forCompany((int) (company_id() ?: 1));
$written = 0;
foreach ($rows as $row) {
$row = $shortNames->fill($row, $names);
fputcsv($csv, array_map(function (array $column) use ($row): mixed {
$value = data_get($row, $column['name']);
return $column['name'] === 'product_code'
? $this->csvIdentifier($value)
: (($column['kind'] ?? null) === 'integer'
? ($value === null || $value === '' ? '' : $value)
: $this->csvText($value));
}, $columns));
$written++;
if ($written % 5000 === 0) {
fflush($csv);
gc_collect_cycles();
}
}
fclose($csv);
clearstatcache(true, $filePath);
$fileSize = filesize($filePath);
if ($fileSize === false) {
throw new RuntimeException('无法读取进销存报表导出文件大小。');
}
} catch (Throwable $exception) {
if (isset($csv) && is_resource($csv)) {
fclose($csv);
}
@unlink($filePath);
throw $exception;
}
$fileName = '进销存报表_' . str_replace('-', '', $filters['month']) . '_' . now()->format('Ymd_His') . '.csv';
return response()->streamDownload(function () use ($filePath): void {
try {
set_time_limit(0);
readfile($filePath);
} finally {
@unlink($filePath);
}
}, $fileName, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
'X-Accel-Buffering' => 'no',
'Content-Length' => (string) $fileSize,
]);
}
/** @return array<string,string> */
private function thirdPartyWarehouseStorageFilters(Request $request): array
{
$dimension = in_array((string) $request->query('dimension'), ['month', 'day'], true)
? (string) $request->query('dimension')
: 'month';
$period = trim((string) $request->query(
'period',
$dimension === 'day' ? $request->query('date', '') : $request->query('month', ''),
));
if ($dimension === 'day') {
if ($this->dateFilter($period) === '') {
$period = now()->subDay()->toDateString();
}
if ($period < ThirdPartyWarehouseStorageMirrorRepository::START_DATE) {
$period = ThirdPartyWarehouseStorageMirrorRepository::START_DATE;
}
} elseif (preg_match('/^(\d{4})-(\d{2})$/', $period, $matches) !== 1
|| ! checkdate((int) $matches[2], 1, (int) $matches[1])) {
$period = now()->startOfMonth()->subMonth()->format('Y-m');
}
if ($dimension === 'month'
&& $period < substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7)) {
$period = substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7);
}
$clean = static fn (mixed $value, int $length): string => mb_substr(trim((string) $value), 0, $length);
return [
'dimension' => $dimension,
'period' => $period,
'sku_code' => $clean($request->query('sku_code', ''), 80),
'product_name' => $clean($request->query('product_name', ''), 120),
'supplier' => $clean($request->query('supplier', ''), 120),
'category' => $clean($request->query('category', ''), 120),
];
}
/** @return array<int,array{label:string,field:string,type?:string}> */
private function thirdPartyWarehouseStorageExportColumns(): array
{
return [
['field' => 'owner', 'label' => '货主'],
['field' => 'date', 'label' => '日期'],
['field' => 'sku_code', 'label' => '商品编码', 'type' => 'code'],
['field' => 'image_url', 'label' => '图片'],
['field' => 'product_name', 'label' => '商品名称'],
['field' => 'short_name', 'label' => '商品简称'],
['field' => 'supplier', 'label' => '供应商'],
['field' => 'category', 'label' => '分类'],
['field' => 'virtual_category', 'label' => '虚拟分类'],
['field' => 'supplier_style_no', 'label' => '供应商款号', 'type' => 'code'],
['field' => 'color_spec', 'label' => '颜色规格'],
['field' => 'brand', 'label' => '品牌'],
['field' => 'unit', 'label' => '单位'],
['field' => 'opening_qty', 'label' => '期初数量', 'type' => 'number'],
['field' => 'closing_qty', 'label' => '期末数量', 'type' => 'number'],
['field' => 'processing_in', 'label' => '加工进仓', 'type' => 'number'],
['field' => 'processing_out', 'label' => '加工出仓', 'type' => 'number'],
['field' => 'purchase_in', 'label' => '采购进仓', 'type' => 'number'],
['field' => 'purchase_return', 'label' => '采购退货', 'type' => 'number'],
['field' => 'other_in', 'label' => '其它进仓', 'type' => 'number'],
['field' => 'other_return', 'label' => '其它退货', 'type' => 'number'],
['field' => 'sales_out', 'label' => '销售出仓', 'type' => 'number'],
['field' => 'sales_return', 'label' => '销售退货', 'type' => 'number'],
['field' => 'stocktake', 'label' => '盘点', 'type' => 'number'],
['field' => 'transfer_out', 'label' => '调拨出', 'type' => 'number'],
['field' => 'transfer_in', 'label' => '调拨入', 'type' => 'number'],
['field' => 'opening_aux', 'label' => '期初', 'type' => 'number'],
['field' => 'other_out', 'label' => '其它出库', 'type' => 'number'],
];
}
protected function contentForDingPayableWorkflows(): array
{
$normalizeDate = static function (mixed $value): string {
$date = trim((string) $value);
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $matches) !== 1
|| ! checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1])) {
return '';
}
return $date;
};
$status = strtoupper(mb_substr(trim((string) request('status', '')), 0, 30));
$filters = [
'process_instance_id' => mb_substr(trim((string) request('process_instance_id', '')), 0, 100),
'created_from' => $normalizeDate(request('created_from', '')),
'finished_to' => $normalizeDate(request('finished_to', '')),
'status' => preg_match('/^[A-Z_]+$/', $status) === 1 ? $status : '',
];
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
try {
$result = $this->dingPayableWorkflows->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => request()->url(), 'query' => request()->except('page')],
);
return [
'view' => 'business.pages.courier-payables.payable-cloud-warehouse-vehicle-fee',
'dingPayableWorkflowRows' => $rows,
'dingPayableWorkflowFilters' => $filters,
'dingPayableWorkflowStatus' => $result['status'] ?? 'unknown',
'dingPayableWorkflowSyncedAt' => $result['last_synced_at'] ?? null,
'dingPayableWorkflowError' => null,
];
} catch (Throwable $exception) {
report($exception);
return [
'view' => 'business.pages.courier-payables.payable-cloud-warehouse-vehicle-fee',
'dingPayableWorkflowRows' => new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(), 'query' => request()->except('page'),
]),
'dingPayableWorkflowFilters' => $filters,
'dingPayableWorkflowStatus' => 'unavailable',
'dingPayableWorkflowSyncedAt' => null,
'dingPayableWorkflowError' => '钉钉应付流程镜像尚未完成同步,请稍后重试。',
];
}
}
/**
* 下载「应付云仓车费」某条审批实例的钉钉附件:
* 先校验附件确实属于该实例(镜像表单数据),再换取钉盘临时地址并透传下载。
*/
public function downloadDingPayableAttachment(Request $request): StreamedResponse
{
$processInstanceId = mb_substr(trim((string) $request->query('process_instance_id', '')), 0, 100);
$fileId = mb_substr(trim((string) $request->query('file_id', '')), 0, 64);
abort_if($processInstanceId === '' || $fileId === '', 404);
$attachment = $this->dingPayableWorkflows->attachment($processInstanceId, $fileId);
abort_if($attachment === null, 404, '附件不存在或不属于该审批实例。');
$uri = app(DingTalkApprovalAttachmentDownloader::class)->downloadUri($processInstanceId, $fileId);
abort_if($uri === null || $uri === '', 502, '暂时无法获取钉钉附件下载地址,请稍后重试。');
// 钉盘地址是带签名的 OSS 链接:请求时不要附带额外请求头,避免破坏签名。
$upstream = Http::withOptions(['stream' => true])->timeout(60)->get($uri);
abort_if(! $upstream->successful(), 502, '附件下载失败,请稍后重试。');
$fileName = $attachment['fileName'] !== '' ? $attachment['fileName'] : $fileId;
$contentType = (string) ($upstream->header('Content-Type') ?: 'application/octet-stream');
return response()->streamDownload(function () use ($upstream): void {
$body = $upstream->toPsrResponse()->getBody();
while (! $body->eof()) {
echo $body->read(8192);
}
}, $fileName, ['Content-Type' => $contentType]);
}
protected function contentForOnlineDaifaPrice(): array
{
$filters = $this->onlineDaifaPriceFilters(request());
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
try {
$result = $this->onlineDaifaPrices->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => request()->url(), 'query' => request()->except('page')],
);
return [
'view' => 'business.pages.product-inventory.online-daifa-price',
'onlineDaifaRows' => $rows,
'onlineDaifaFilters' => $filters,
'onlineDaifaStatus' => $result['status'] ?? 'unknown',
'onlineDaifaSyncedAt' => $result['last_synced_at'] ?? null,
'onlineDaifaSummary' => $result['summary'] ?? [],
'onlineDaifaSources' => $result['source_statuses'] ?? [],
'onlineDaifaError' => null,
];
} catch (Throwable $exception) {
report($exception);
return [
'view' => 'business.pages.product-inventory.online-daifa-price',
'onlineDaifaRows' => new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(), 'query' => request()->except('page'),
]),
'onlineDaifaFilters' => $filters,
'onlineDaifaStatus' => 'unavailable',
'onlineDaifaSyncedAt' => null,
'onlineDaifaSummary' => [],
'onlineDaifaSources' => [],
'onlineDaifaError' => '线上代发价格镜像尚未完成同步,请稍后重试。',
];
}
}
public function exportOnlineDaifaPrice(Request $request): StreamedResponse
{
abort_unless($this->onlineDaifaPrices->ready(), 503);
$filters = $this->onlineDaifaPriceFilters($request);
return response()->streamDownload(function () use ($filters): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建线上代发价格导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, [
'审批编号', '标题', '状态', '申请人', '收款人全称', '标签', '商品ID', '商品名称(匹配)', '店铺名称',
'代发交易单号', '店铺交易单号', '发货数量', '订单实付金额', '代发订单金额', '差价',
'供应商名称', '商品编码', '创建时间',
]);
foreach ($this->onlineDaifaPrices->exportRows($filters) as $row) {
$workflow = (array) ($row['workflow'] ?? []);
$approvalNumber = trim((string) ($workflow['businessId'] ?? '')) ?: (string) ($row['process_instance_id'] ?? '');
$originator = trim((string) ($workflow['originatorUserName'] ?? ''))
?: trim((string) ($workflow['originatorUserId'] ?? ''));
$matchedProduct = (array) ($row['matched_product'] ?? []);
$productName = ($row['source_type'] ?? 'ding') === 'purchase'
? ''
: (($matchedProduct['matched'] ?? false)
? (string) ($matchedProduct['name'] ?? '')
: '(未匹配)');
fputcsv($csv, [
$this->csvIdentifier($approvalNumber),
$this->csvText($workflow['title'] ?? ''),
$this->csvText($row['status_label'] ?? ''),
$this->csvText($originator),
$this->csvText($row['payee_name'] ?? ''),
$this->csvText($row['tag'] ?? ''),
$this->csvIdentifier($row['product_id'] ?? ''),
$this->csvText($productName),
$this->csvText($row['shop_name'] ?? ''),
$this->csvIdentifier($row['daifa_trade_no'] ?? ''),
$this->csvIdentifier($row['shop_trade_no'] ?? ''),
$row['ship_qty'] ?? null,
$row['paid_amount'] ?? null,
$row['daifa_amount'] ?? null,
$row['price_diff'] ?? null,
$this->csvText($row['supplier_name'] ?? ''),
$this->csvIdentifier($row['material_no'] ?? ''),
$this->csvText($row['workflow_created_at'] ?? ''),
]);
}
fclose($csv);
}, '线上代发价格_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<string,string> */
private function onlineDaifaPriceFilters(Request $request): array
{
$clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length);
$date = static function (mixed $value): string {
$value = trim((string) $value);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : '';
};
$status = strtoupper(mb_substr(trim((string) $request->query('status', '')), 0, 30));
return [
'keyword' => $clean($request->query('keyword', ''), 80),
'product_id' => $clean($request->query('product_id', ''), 64),
'shop_name' => $clean($request->query('shop_name', ''), 120),
'supplier_name' => $clean($request->query('supplier_name', ''), 120),
'material_no' => $clean($request->query('material_no', ''), 120),
'status' => preg_match('/^[A-Z_]+$/', $status) === 1 ? $status : '',
'created_from' => $date($request->query('created_from', '')),
'created_to' => $date($request->query('created_to', '')),
];
}
protected function contentForJingxiRefundData(): array
{
return [
'view' => 'business.pages.refunds.jingxi-refund-data',
];
}
protected function contentForNonJingxiRefundData(): array
{
$request = request();
$defaultTo = now()->toDateString();
$defaultFrom = now()->subDays(6)->toDateString();
$filters = $this->nonJingxiRefundFilters($request, $defaultFrom, $defaultTo);
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
$syncStatus = ['code' => 'unavailable', 'last_succeeded_at' => null];
$error = null;
$pending = false;
$indexPending = false;
try {
if (! $this->nonJingxiRefunds->available()) {
$error = '共享业务镜像尚未建立,非京喜退款数据暂时无法读取。';
} else {
$states = $this->nonJingxiRefunds->states();
$syncStatus = $this->nonJingxiRefunds->syncStatus($states);
if (! $this->nonJingxiRefunds->ready()) {
$pending = true;
} elseif (! $this->nonJingxiRefunds->queryReady()) {
$indexPending = true;
} else {
$result = $this->nonJingxiRefunds->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
}
}
} catch (Throwable $exception) {
report($exception);
$error = '非京喜退款数据暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.refunds.non-jingxi-refund-data',
'filters' => $filters,
'rows' => $rows,
'refundSyncStatus' => $syncStatus,
'refundError' => $error,
'refundPending' => $pending,
'refundIndexPending' => $indexPending,
];
}
public function exportNonJingxiRefunds(Request $request)
{
$filters = $this->nonJingxiRefundFilters($request);
$redirectParameters = array_filter([
'section' => 'refunds',
'page' => 'non-jingxi-refund-data',
...$filters,
], static fn (mixed $value): bool => $value !== '' && $value !== null);
try {
if (! $this->nonJingxiRefunds->available()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '共享业务镜像尚未建立,售后(退货退款)暂时不能导出。');
}
if (! $this->nonJingxiRefunds->ready()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '业务镜像首次同步尚未完成,售后(退货退款)暂时不能导出。');
}
if (! $this->nonJingxiRefunds->queryReady()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '页面查询索引尚未就绪,售后(退货退款)暂时不能导出。');
}
return $this->streamNonJingxiRefundCsv(
$this->nonJingxiRefunds->exportRows($filters),
$filters,
$redirectParameters,
$request->query('columns'),
);
} catch (Throwable $exception) {
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '售后(退货退款)导出暂时无法完成,请稍后重试。');
}
}
/** @return array{from:string,to:string,order_no:string,shop_name:string,sku_id:string} */
private function nonJingxiRefundFilters(
Request $request,
?string $defaultFrom = null,
?string $defaultTo = null,
): array {
$defaultTo ??= now()->toDateString();
$defaultFrom ??= now()->subDays(6)->toDateString();
$from = $this->dateFilter($request->query('from', $defaultFrom)) ?: $defaultFrom;
$to = $this->dateFilter($request->query('to', $defaultTo)) ?: $defaultTo;
if ($from > $to) {
[$from, $to] = [$to, $from];
}
return [
'from' => $from,
'to' => $to,
'order_no' => mb_substr(trim((string) $request->query('order_no', '')), 0, 100),
'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 191),
'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 100),
];
}
private function streamNonJingxiRefundCsv(iterable $rows, array $filters, array $redirectParameters, mixed $requestedFields = null)
{
$columns = [
['label' => '序号/主键ID', 'field' => 'Id', 'identifier' => true],
['label' => '退款日期', 'field' => 'refundDate'],
['label' => '售后单ID', 'field' => 'as_id', 'identifier' => true],
['label' => '外部售后单号', 'field' => 'outer_as_id', 'identifier' => true],
['label' => '订单ID', 'field' => 'o_id', 'identifier' => true],
['label' => '', 'field' => ''],
['label' => '子订单ID', 'field' => 'so_id', 'identifier' => true],
['label' => '类型', 'field' => 'type'],
['label' => '节点', 'field' => 'node'],
['label' => '是否自营', 'field' => 'isZC'],
['label' => '店铺名称', 'field' => 'shop_name'],
['label' => '平台', 'field' => 'platform'],
['label' => '责任部门', 'field' => 'responsibleDepartment'],
['label' => '退款总金额', 'field' => 'totalRefundMoney', 'number' => true],
['label' => '原始邮编', 'field' => 'originPostcode'],
['label' => '店铺商品ID', 'field' => 'shop_i_id', 'identifier' => true],
['label' => '店铺SKU编码', 'field' => 'shop_sku_id', 'identifier' => true],
['label' => '商品ID', 'field' => 'i_id', 'identifier' => true],
['label' => 'SKU编码', 'field' => 'sku_id', 'identifier' => true],
['label' => '买家实付金额', 'field' => 'buyer_paid_amount', 'number' => true],
['label' => '数量', 'field' => 'qty', 'number' => true],
['label' => '商品单位成本', 'field' => 'unitCostGoods', 'number' => true],
['label' => '明细退款金额', 'field' => 'detailRefundMoney', 'number' => true],
['label' => '退款类型', 'field' => 'refundType'],
['label' => '退款数量', 'field' => 'refundQty', 'number' => true],
['label' => '退款商品成本', 'field' => 'refundCostGoods', 'number' => true],
['label' => '退款回款金额', 'field' => 'RefundMoneyIncome', 'number' => true],
['label' => '售后金额', 'field' => 'AfSalesMoney', 'number' => true],
['label' => '创建时间', 'field' => 'createTime'],
['label' => '状态', 'field' => 'status'],
['label' => '店铺状态', 'field' => 'shop_status'],
['label' => '创建记录/创建时间', 'field' => 'created'],
['label' => '确认日期', 'field' => 'confirm_date'],
];
$columns = $this->filterExportColumns($columns, $requestedFields);
$csv = null;
try {
$csv = fopen('php://temp/maxmemory:5242880', 'w+b');
if ($csv === false) {
throw new \RuntimeException('无法创建售后退款导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
foreach ($rows as $row) {
fputcsv($csv, array_map(
fn (array $column): mixed => $column['field'] === ''
? ''
: (($column['number'] ?? false)
? data_get($row, $column['field'])
: (($column['identifier'] ?? false)
? $this->csvIdentifier(data_get($row, $column['field']))
: $this->csvText(data_get($row, $column['field'])))),
$columns,
));
}
rewind($csv);
} catch (Throwable $exception) {
if (is_resource($csv)) {
fclose($csv);
}
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '售后(退货退款)导出暂时无法完成,请稍后重试。');
}
return response()->streamDownload(function () use ($csv): void {
fpassthru($csv);
fclose($csv);
}, 'after_sales_refunds_' . $filters['from'] . '_' . $filters['to'] . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
protected function contentForSmallPaymentWorkOrders(): array
{
$request = request();
$filters = $this->smallPaymentWorkOrderFilters($request);
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
$syncStatus = ['code' => 'unavailable', 'last_succeeded_at' => null];
$pagePaymentAmount = '0.00';
$filteredPaymentAmount = '0.00';
$shopNames = [];
$paymentReasons = [];
$error = null;
$pending = false;
$indexPending = false;
try {
if (! $this->smallPaymentWorkOrders->available()) {
$error = '共享业务镜像尚未建立,小额打款工单暂时无法读取。';
} else {
$states = $this->smallPaymentWorkOrders->states();
$syncStatus = $this->smallPaymentWorkOrders->syncStatus($states);
if (! $this->smallPaymentWorkOrders->ready()) {
$pending = true;
} elseif (! $this->smallPaymentWorkOrders->queryReady()) {
$indexPending = true;
} else {
$result = $this->smallPaymentWorkOrders->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
$pagePaymentAmount = (string) ($result['page_payment_amount'] ?? '0.00');
$filteredPaymentAmount = (string) ($result['filtered_payment_amount'] ?? '0.00');
$filterOptions = $this->smallPaymentWorkOrders->filterOptions();
$shopNames = $filterOptions['shops'];
$paymentReasons = $filterOptions['payment_reasons'];
}
}
} catch (Throwable $exception) {
report($exception);
$error = '小额打款工单暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.refunds.small-payment-work-orders',
'filters' => $filters,
'rows' => $rows,
'smallPaymentSyncStatus' => $syncStatus,
'smallPaymentPageAmount' => $pagePaymentAmount,
'smallPaymentFilteredAmount' => $filteredPaymentAmount,
'smallPaymentShops' => $shopNames,
'smallPaymentReasons' => $paymentReasons,
'smallPaymentStatuses' => [
'待审核', '审核驳回', '待确认', '确认中', '已完成', '已作废',
'支付宝已打款', '银行卡已打款', '已打款',
],
'smallPaymentError' => $error,
'smallPaymentPending' => $pending,
'smallPaymentIndexPending' => $indexPending,
];
}
public function exportSmallPaymentWorkOrders(Request $request)
{
$filters = $this->smallPaymentWorkOrderFilters($request);
$redirectParameters = array_filter([
'section' => self::SMALL_PAYMENT_SECTION,
'page' => 'small-payment-work-orders',
...$filters,
], static fn (mixed $value): bool => $value !== '' && $value !== null);
try {
if (! $this->smallPaymentWorkOrders->available()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '共享业务镜像尚未建立,小额打款工单暂时不能导出。');
}
if (! $this->smallPaymentWorkOrders->ready()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '业务镜像首次同步尚未完成,小额打款工单暂时不能导出。');
}
if (! $this->smallPaymentWorkOrders->queryReady()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '页面查询索引尚未就绪,小额打款工单暂时不能导出。');
}
return $this->streamSmallPaymentWorkOrderCsv(
$this->smallPaymentWorkOrders->exportRows($filters),
$filters,
$redirectParameters,
$request->query('columns'),
);
} catch (Throwable $exception) {
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '小额打款工单导出暂时无法完成,请稍后重试。');
}
}
/**
* @return array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string}
*/
private function smallPaymentWorkOrderFilters(Request $request): array
{
$defaultTo = now()->format('Y-m-d') . 'T23:59:59';
$defaultFrom = now()->subDays(29)->format('Y-m-d') . 'T00:00:00';
$rawFrom = $request->query('from', $defaultFrom);
$rawTo = $request->query('to', $defaultTo);
$from = $this->smallPaymentDateTimeFilter($rawFrom, false) ?: $defaultFrom;
$to = $this->smallPaymentDateTimeFilter($rawTo, true) ?: $defaultTo;
if ($from > $to) {
$from = $this->smallPaymentDateTimeFilter($rawTo, false) ?: $defaultFrom;
$to = $this->smallPaymentDateTimeFilter($rawFrom, true) ?: $defaultTo;
}
$text = static fn (mixed $value, int $length = 100): string => mb_substr(trim((string) $value), 0, $length);
return [
'from' => $from,
'to' => $to,
'work_order_no' => $text($request->query('work_order_no', '')),
'shop_name' => $text($request->query('shop_name', ''), 191),
'transaction_no' => $text($request->query('transaction_no', '')),
'online_order_no' => $text($request->query('online_order_no', '')),
'status' => $text($request->query('status', ''), 60),
'payment_reason' => $text($request->query('payment_reason', ''), 191),
];
}
private function smallPaymentDateTimeFilter(mixed $value, bool $upperBound): string
{
$value = trim((string) $value);
if (preg_match('/^(\d{4}-\d{2}-\d{2})$/', $value, $dateMatch) === 1) {
if ($this->dateFilter($dateMatch[1]) === '') {
return '';
}
return $dateMatch[1] . ($upperBound ? 'T23:59:59' : 'T00:00:00');
}
if (preg_match(
'/^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/',
$value,
$matches,
) !== 1 || $this->dateFilter($matches[1]) === '') {
return '';
}
$hour = (int) $matches[2];
$minute = (int) $matches[3];
$second = isset($matches[4]) ? (int) $matches[4] : ($upperBound ? 59 : 0);
if ($hour > 23 || $minute > 59 || $second > 59) {
return '';
}
return sprintf('%sT%02d:%02d:%02d', $matches[1], $hour, $minute, $second);
}
private function streamSmallPaymentWorkOrderCsv(iterable $rows, array $filters, array $redirectParameters, mixed $requestedFields = null)
{
$columns = [
['label' => '工单编号', 'field' => 'work_order_no', 'identifier' => true],
['label' => '支付宝批次号', 'field' => 'alipay_batch_no', 'identifier' => true],
['label' => '工单状态', 'field' => 'work_order_status'],
['label' => '打款状态', 'field' => 'payment_status'],
['label' => '工单标题', 'field' => 'work_order_title'],
['label' => '工单内容', 'field' => 'work_order_content'],
['label' => '工单类型', 'field' => 'work_order_type'],
['label' => '优先级', 'field' => 'priority'],
['label' => '打款金额', 'field' => 'payment_amount', 'number' => true],
['label' => '催处理次数', 'field' => 'urging_count', 'number' => true],
['label' => '店铺名称', 'field' => 'shop_name'],
['label' => '线上订单号', 'field' => 'online_order_no', 'identifier' => true],
['label' => '买家账号', 'field' => 'buyer_account'],
['label' => '支付宝账号', 'field' => 'alipay_account'],
['label' => '支付宝姓名', 'field' => 'alipay_name'],
['label' => '商品编码', 'field' => 'product_code', 'identifier' => true],
['label' => 'SKU编码', 'field' => 'sku_code', 'identifier' => true],
['label' => '平台', 'field' => 'platform'],
['label' => '实际已付金额', 'field' => 'actual_paid_amount', 'number' => true],
['label' => '支付时间', 'field' => 'payment_time'],
['label' => '打款原因', 'field' => 'payment_reason'],
['label' => '创建人', 'field' => 'creator'],
['label' => '创建时间', 'field' => 'work_order_created_at'],
['label' => '完成时间', 'field' => 'completed_at'],
];
$columns = $this->filterExportColumns($columns, $requestedFields);
$csv = null;
try {
$csv = fopen('php://temp/maxmemory:5242880', 'w+b');
if ($csv === false) {
throw new \RuntimeException('无法创建小额打款工单导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
foreach ($rows as $row) {
fputcsv($csv, array_map(
fn (array $column): mixed => ($column['number'] ?? false)
? data_get($row, $column['field'])
: (($column['identifier'] ?? false)
? $this->csvIdentifier(data_get($row, $column['field']))
: $this->csvText(data_get($row, $column['field']))),
$columns,
));
}
rewind($csv);
} catch (Throwable $exception) {
if (is_resource($csv)) {
fclose($csv);
}
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '小额打款工单导出暂时无法完成,请稍后重试。');
}
$filenameFrom = str_replace(['T', ':'], ['_', '-'], $filters['from']);
$filenameTo = str_replace(['T', ':'], ['_', '-'], $filters['to']);
return response()->streamDownload(function () use ($csv): void {
fpassthru($csv);
fclose($csv);
}, 'small_payment_work_orders_' . $filenameFrom . '_' . $filenameTo . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
protected function contentForMarketingAccountBalance(?string $tab): array
{
$content = [
'view' => 'banking.marketing_account_balances.partials.embedded-table',
'platform' => $tab,
'balances' => collect(),
'error' => null,
'pending' => false,
];
if (! in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet'], true)) {
return $content;
}
$request = request();
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$month = trim((string) $request->query('month', ''));
$month = preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : '';
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'month' => $month !== '' ? $month : null,
];
$isTaobao = $tab === 'taobao-alipay';
$isAlibaba = $tab === 'taobao-wechat';
$isJd = $tab === 'jd-wallet';
$repository = match ($tab) {
'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class),
'taobao-wechat' => app(AlibabaAccountBalanceRepository::class),
'jd-wallet' => app(JdPromotionAccountBalanceRepository::class),
default => app(PinduoduoSpreadBillRepository::class),
};
$scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list';
$cache = ($isAlibaba || $isJd) ? null : $this->upstreamCaches->forSource('jiansun');
$usingMirror = $repository->usesBusinessMirror();
try {
if ($isJd && ! $usingMirror) {
throw new ServiceUnavailableHttpException(null, '京东账户余额共享业务镜像尚未完成同步,请稍后重试。');
}
$resolver = fn () => $repository->paginateMonthlyBalances(
$filters['store_name'],
$filters['month'],
$page,
$pageSize,
);
$result = ($usingMirror || $isAlibaba || $isJd)
? $resolver()
: $cache->remember(
$scope,
['view' => 'monthly-balances', 'filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$resolver,
);
} catch (UpstreamSnapshotPendingException $exception) {
$content['pending'] = true;
$result = ['rows' => [], 'total' => 0];
} catch (ServiceUnavailableHttpException $exception) {
$content['error'] = $exception->getMessage();
$result = ['rows' => [], 'total' => 0];
} catch (Throwable $exception) {
report($exception);
$platformName = $isTaobao ? '淘系' : ($isAlibaba ? '阿里巴巴' : ($isJd ? '京东' : '拼多多'));
$content['error'] = $platformName . '账户余额数据暂时无法读取,请稍后重试。';
$result = ['rows' => [], 'total' => 0];
}
try {
$resolver = fn () => $repository->storeOptions();
$storeOptions = ($usingMirror || $isAlibaba || $isJd)
? $resolver()
: $cache->remember(
$isTaobao ? 'taobao-alipay-bills.options' : 'pinduoduo-bills.options',
[],
$resolver,
);
} catch (Throwable $exception) {
report($exception);
$storeOptions = [];
}
$content['balances'] = new LengthAwarePaginator(
Collection::make($result['rows'] ?? []),
(int) ($result['total'] ?? 0),
$pageSize,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
$content['filters'] = ['store_name' => $storeName, 'month' => $month];
$content['storeOptions'] = $storeOptions;
return $content;
}
public function export(Request $request, string $section, string $page, string $tab)
{
abort_unless(in_array($section, ['billing-transactions', self::ACCOUNT_BALANCE_SECTION], true), 404);
$sectionConfig = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', $section);
abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。');
$pageConfig = collect($sectionConfig['pages'] ?? [])->firstWhere('slug', $page);
abort_if(empty($pageConfig), 403, '当前账号没有该页面权限。');
abort_unless(collect($pageConfig['tabs'] ?? [])->pluck('slug')->contains($tab), 404);
if ($page === 'marketing-account-bills') {
abort_unless($section === 'billing-transactions', 404);
if (in_array($tab, self::PROMOTION_MARKETING_TABS, true)) {
return $this->exportPlatformPromotionAccounts($request, $section, $page, $tab);
}
return $this->exportMarketingAccountBills($request, $section, $page, $tab);
}
abort_unless($section === self::ACCOUNT_BALANCE_SECTION && $page === 'marketing-account-balance', 404);
abort_unless(in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet'], true), 404);
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$month = trim((string) $request->query('month', ''));
$month = preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : '';
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'month' => $month !== '' ? $month : null,
];
$isTaobao = $tab === 'taobao-alipay';
$isAlibaba = $tab === 'taobao-wechat';
$isJd = $tab === 'jd-wallet';
$repository = match ($tab) {
'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class),
'taobao-wechat' => app(AlibabaAccountBalanceRepository::class),
'jd-wallet' => app(JdPromotionAccountBalanceRepository::class),
default => app(PinduoduoSpreadBillRepository::class),
};
$scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list';
$redirectParameters = array_filter([
'section' => $section,
'page' => $page,
'tab' => $tab,
'store_name' => $storeName,
'month' => $month,
], static fn (mixed $value): bool => $value !== '');
try {
$usingMirror = $repository->usesBusinessMirror();
if ($isJd && ! $usingMirror) {
throw new ServiceUnavailableHttpException(null, '京东账户余额共享业务镜像尚未完成同步。');
}
$resolver = fn () => $repository->paginateMonthlyBalances($filters['store_name'], $filters['month'], 1, 5000);
$result = ($usingMirror || $isAlibaba || $isJd)
? $resolver()
: $this->upstreamCaches->forSource('jiansun')->remember(
$scope,
['view' => 'monthly-balances', 'filters' => $filters, 'page' => 1, 'limit' => 5000, 'export' => true],
$resolver,
);
} catch (ServiceUnavailableHttpException) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', $isJd
? '京东账户余额共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。'
: '首份共享快照正在准备,系统已登记刷新请求;请稍后刷新页面后再导出。');
} catch (Throwable $exception) {
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '余额数据导出暂时无法完成,请稍后重试。');
}
$rows = $result['rows'] ?? [];
$filePrefix = $isTaobao ? 'taobao' : ($isAlibaba ? 'alibaba' : ($isJd ? 'jd' : 'pinduoduo'));
return response()->streamDownload(function () use ($rows): void {
$csv = fopen('php://output', 'w');
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, [
'日期', '店铺名称', '期初余额', '本期收入', '本期支出', '期末余额',
'系统期末余额', '余额差异', '差异原因', '核对状态', '备注',
]);
foreach ($rows as $row) {
fputcsv($csv, [
$row['date'], $row['store_name'], $row['opening_balance'], $row['income_total'],
$row['expense_total'], $row['closing_balance'], $row['system_closing_balance'],
$row['balance_difference'], $row['difference_reason'], $row['verification_status'], $row['notes'],
]);
}
fclose($csv);
}, $filePrefix . '_account_balances_' . ($month !== '' ? $month : 'all') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
private function exportPlatformPromotionAccounts(
Request $request,
string $section = 'billing-transactions',
string $page = 'marketing-account-bills',
?string $tab = null,
)
{
$platform = PlatformPromotionAccountPageData::platformForTab($tab);
abort_if($platform === null, 404);
$month = trim((string) $request->query('month', ''));
$month = preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : null;
$filters = [
'month' => $month,
'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 100),
'account' => mb_substr(trim((string) $request->query('account', '')), 0, 100),
];
if ($platform === 'vip') {
$filters['advertiser_id'] = mb_substr(trim((string) $request->query('advertiser_id', '')), 0, 100);
}
try {
$repository = app(\Modules\EcommerceFinance\Repositories\PlatformPromotionAccountRepository::class);
$filters['month'] = $month ?: ($repository->latestMonth() ?: now()->format('Y-m'));
$data = $repository->dashboard($filters, 5000, $platform);
} catch (Throwable $exception) {
report($exception);
return redirect()->route('business.pages.show', array_filter([
'section' => $section,
'page' => $page,
'tab' => $tab,
], static fn (mixed $value): bool => $value !== null && $value !== ''))
->with('error', '推广账户数据导出暂时无法完成,请稍后重试。');
}
return response()->streamDownload(function () use ($data, $platform): void {
$csv = fopen('php://output', 'w');
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
if ($platform === 'vip') {
fputcsv($csv, [
'平台', '营销账户', '账户名称', '广告主 ID', '广告主名称',
'报表日期', '花费(元)', '曝光量', '更新时间',
]);
foreach ($data['vipMarketingDaily'] ?? [] as $row) {
fputcsv($csv, [
'唯品会推广流水',
$row['account'] ?? '',
$row['accountName'] ?? '',
$row['advertiserId'] ?? '',
$row['advertiserTitle'] ?? '',
$row['reportDate'] ?? '',
$row['costYuan'] ?? '',
$row['impressionCount'] ?? '',
$row['updatedAt'] ?? '',
]);
}
fclose($csv);
return;
}
if ($platform === 'jd') {
fputcsv($csv, [
'平台', '店铺', '账户', '日期', '记账时间', '收支类型', '交易类型',
'金额(元)', '余额(元)', '备注',
]);
foreach ($data['jd'] ?? [] as $row) {
fputcsv($csv, [
'京东京准通',
$row['shop_name'] ?? '',
$row['account'] ?? '',
$row['show_date'] ?? '',
$row['settle_datetime'] ?? '',
$row['in_out_type_name'] ?? '',
$row['trade_type_name'] ?? '',
$row['amount'] ?? '',
$row['balance_amount'] ?? '',
$row['remark'] ?? '',
]);
}
fclose($csv);
return;
}
fputcsv($csv, ['平台', '店铺/账户', '日期', '类型', '金额(元)', '余额(元)', '备注']);
$emit = static function (string $platform, array $rows, array $map) use ($csv): void {
foreach ($rows as $row) {
fputcsv($csv, [
$platform,
$map['name']($row),
$map['date']($row),
$map['type']($row),
$map['amount']($row),
$map['balance']($row),
$map['remark']($row),
]);
}
};
if ($platform === 'douyin') {
$emit('抖音千川', $data['dy'] ?? [], ['name' => fn ($row) => $row['shopName'] . ' / ' . $row['account'] . ' / ' . $row['qianchuanAavid'], 'date' => fn ($row) => $row['statDate'] ?? '', 'type' => fn () => '花费', 'amount' => fn ($row) => $row['cost'] ?? '', 'balance' => fn ($row) => $row['balance'] ?? '', 'remark' => fn ($row) => $row['remark'] ?? '']);
} elseif ($platform === 'dewu') {
$emit('得物推广', $data['dw'] ?? [], ['name' => fn ($row) => $row['shop_name'] . ' / ' . $row['account'], 'date' => fn ($row) => $row['trade_date'] ?? $row['date_time'], 'type' => fn ($row) => $row['flow_type'] ?? '', 'amount' => fn ($row) => $row['trade_amount_yuan'] ?? '', 'balance' => fn ($row) => $row['balance_yuan'] ?? '', 'remark' => fn ($row) => $row['remark'] ?? '']);
}
fclose($csv);
}, '店铺营销账户账单_' . $platform . '_' . $filters['month'] . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
private function exportMarketingAccountBills(
Request $request,
string $section,
string $page,
string $tab,
) {
abort_unless(in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet'], true), 404);
return match ($tab) {
'taobao-alipay' => $this->exportTaobaoAccountBills($request, $section, $page, $tab),
'taobao-wechat' => $this->exportAlibabaAccountBills($request, $section, $page, $tab),
'jd-wallet' => $this->exportJdAccountBills($request, $section, $page, $tab),
default => $this->exportPinduoduoAccountBills($request, $section, $page, $tab),
};
}
private function exportTaobaoAccountBills(Request $request, string $section, string $page, string $tab)
{
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$flowType = in_array($request->query('flow_type'), ['income', 'expense'], true)
? (string) $request->query('flow_type')
: '';
$redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
'flow_type' => $flowType,
]);
$repository = app(TmallTaobaoSpreadBillRepository::class);
if (! $repository->usesBusinessMirror()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '淘系账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。');
}
return $this->streamMarketingAccountBillCsv(
$repository->exportRows(
$storeName !== '' ? $storeName : null,
$fromDate !== '' ? $fromDate : null,
$toDate !== '' ? $toDate : null,
$flowType !== '' ? $flowType : null,
),
['店铺名称', '记账日期', '交易日期', '金额(元)', '余额(元)', '备注', 'finType', '收支类型', '交易类型'],
static fn (array $row): array => [
$row['store_name'] ?? '', $row['bookkeeping_time'] ?? '', $row['transaction_date'] ?? '',
$row['amount'] ?? 0, $row['balance'] ?? 0, $row['comment'] ?? '', $row['fin_type'] ?? '',
$row['income_expense_type'] ?? '', $row['transaction_type'] ?? '',
],
'taobao_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv',
$redirectParameters,
'淘系账单导出暂时无法完成,请稍后重试。',
);
}
private function exportAlibabaAccountBills(Request $request, string $section, string $page, string $tab)
{
$shopName = mb_substr(trim((string) $request->query('shop_name', '')), 0, 191);
$productType = mb_substr(trim((string) $request->query('product_type', '')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [
'shop_name' => $shopName,
'product_type' => $productType,
'from' => $fromDate,
'to' => $toDate,
]);
$repository = app(AlibabaBillRepository::class);
if (! $repository->usesBusinessMirror()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '阿里巴巴账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。');
}
return $this->streamMarketingAccountBillCsv(
$repository->exportRows(
$shopName !== '' ? $shopName : null,
$productType !== '' ? $productType : null,
$fromDate !== '' ? $fromDate : null,
$toDate !== '' ? $toDate : null,
),
['店铺名称', '交易时间', '产品类型', '入账金额(元)', '出帐金额(元)'],
static fn (array $row): array => [
$row['shop_name'] ?? '', $row['trade_day'] ?? '', $row['product_type'] ?? '',
$row['income_amount'] ?? 0, $row['expense_amount'] ?? 0,
],
'alibaba_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv',
$redirectParameters,
'阿里巴巴账单导出暂时无法完成,请稍后重试。',
);
}
private function exportJdAccountBills(Request $request, string $section, string $page, string $tab)
{
$ledger = $request->query('jd_ledger') === 'consumption' ? 'consumption' : 'recharge';
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [
'jd_ledger' => $ledger,
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
]);
$repository = app(JdWalletBillRepository::class);
if (! $repository->usesBusinessMirror($ledger)) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '京东账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。');
}
$headers = $ledger === 'consumption'
? ['店铺名称', '金额(元)', '余额(元)', '交易类型', '投放日期', '结算日期', '流水号']
: ['店铺名称', '金额(元)', '余额(元)', '交易类型', '商户订单号', '充值时间', '到账时间', '流水号', '状态', '备注'];
$rowValues = $ledger === 'consumption'
? static fn (array $row): array => [
$row['store_name'] ?? '', $row['amount'] ?? 0, $row['balance'] ?? 0,
$row['transaction_type'] ?? '', $row['investment_date'] ?? '',
$row['settlement_date'] ?? '', $row['serial_number'] ?? '',
]
: static fn (array $row): array => [
$row['store_name'] ?? '', $row['amount'] ?? 0, $row['balance'] ?? 0,
$row['transaction_type'] ?? '', $row['order_number'] ?? '', $row['recharge_time'] ?? '',
$row['arrival_time'] ?? '', $row['serial_number'] ?? '', $row['status'] ?? '', $row['notes'] ?? '',
];
return $this->streamMarketingAccountBillCsv(
$repository->exportRows(
$ledger,
$storeName !== '' ? $storeName : null,
$fromDate !== '' ? $fromDate : null,
$toDate !== '' ? $toDate : null,
),
$headers,
$rowValues,
'jd_' . $ledger . '_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv',
$redirectParameters,
'京东账单导出暂时无法完成,请稍后重试。',
);
}
private function exportPinduoduoAccountBills(Request $request, string $section, string $page, string $tab)
{
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$flowType = in_array($request->query('flow_type'), ['income', 'expense'], true)
? (string) $request->query('flow_type')
: '';
$redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
'flow_type' => $flowType,
]);
$repository = app(PinduoduoSpreadBillRepository::class);
if (! $repository->usesBusinessMirror()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '拼多多账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。');
}
return $this->streamMarketingAccountBillCsv(
$repository->exportRows(
$storeName !== '' ? $storeName : null,
$fromDate !== '' ? $fromDate : null,
$toDate !== '' ? $toDate : null,
$flowType !== '' ? $flowType : null,
),
['店铺名称', '金额(元)', '余额(元)', '摘要', '收支类型', '资金类型', '费用时间'],
static fn (array $row): array => [
$row['store_name'] ?? '', $row['amount'] ?? 0, $row['balance'] ?? 0,
$row['summary'] ?? '', $row['income_expense_type'] ?? '',
$row['fund_type'] ?? '', $row['cost_time'] ?? '',
],
'pinduoduo_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv',
$redirectParameters,
'拼多多账单导出暂时无法完成,请稍后重试。',
);
}
private function streamMarketingAccountBillCsv(
iterable $rows,
array $headers,
callable $rowValues,
string $filename,
array $redirectParameters,
string $errorMessage,
) {
$csv = null;
try {
$csv = fopen('php://temp/maxmemory:5242880', 'w+b');
if ($csv === false) {
throw new \RuntimeException('无法创建账单导出临时文件。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, $headers);
foreach ($rows as $row) {
fputcsv($csv, $rowValues($row));
}
rewind($csv);
} catch (Throwable $exception) {
if (is_resource($csv)) {
fclose($csv);
}
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', $errorMessage);
}
return response()->streamDownload(function () use ($csv): void {
fpassthru($csv);
fclose($csv);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
private function marketingBillRedirectParameters(string $section, string $page, string $tab, array $filters): array
{
return array_filter(array_merge([
'section' => $section,
'page' => $page,
'tab' => $tab,
], $filters), static fn (mixed $value): bool => $value !== '' && $value !== null);
}
private function marketingBillRange(string $fromDate, string $toDate): string
{
return ($fromDate ?: 'all') . '_' . ($toDate ?: 'all');
}
protected function contentForBoardDeductions(): array
{
$filters = [
'stock_in_date' => $this->dateFilter(request('stock_in_date')),
'stock_out_date' => $this->dateFilter(request('stock_out_date')),
'furniture_factory' => mb_substr(trim((string) request('furniture_factory', '')), 0, 191),
'lamination_factory' => mb_substr(trim((string) request('lamination_factory', '')), 0, 191),
'deduction_type' => mb_substr(trim((string) request('deduction_type', '')), 0, 191),
'material_name' => mb_substr(trim((string) request('material_name', '')), 0, 191),
'claim_number' => mb_substr(trim((string) request('claim_number', '')), 0, 191),
];
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$cache = $this->upstreamCaches->forSource('jiansun');
try {
$result = $cache->remember(
'supplier-payables.board-deductions.list',
['filters' => $filters, 'page' => $page, 'limit' => $limit],
fn () => $this->supplierPayableReports->boardDeductions($filters, $page, $limit),
);
$rows = $this->supplierPayableOverrides->decorate(
'board-deductions',
(array) ($result['rows'] ?? []),
(int) (company_id() ?: 1),
);
return [
'boardDeductions' => $this->supplierPayablePaginator($rows, $limit, $page),
'boardDeductionFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'),
'boardDeductionStatus' => $cache->responseHeaders(),
'boardDeductionError' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return [
'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page),
'boardDeductionFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'),
'boardDeductionStatus' => [],
'boardDeductionError' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page),
'boardDeductionFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'),
'boardDeductionStatus' => [],
'boardDeductionError' => '板材扣款数据暂时无法读取,请稍后重试。',
];
}
}
protected function contentForSupplierPayablesReport(): array
{
$month = trim((string) request('month', ''));
$filters = [
'month' => preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : '',
'supplier' => mb_substr(trim((string) request('supplier', '')), 0, 191),
'checker' => mb_substr(trim((string) request('checker', '')), 0, 191),
'settlement_method' => mb_substr(trim((string) request('settlement_method', '')), 0, 191),
];
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$cache = $this->upstreamCaches->forSource('jiansun');
try {
$result = $cache->remember(
'supplier-payables.report.list',
['filters' => $filters, 'page' => $page, 'limit' => $limit],
fn () => $this->supplierPayableReports->supplierPayables($filters, $page, $limit),
);
$rows = $this->supplierPayableOverrides->decorate(
'supplier-payables-report',
(array) ($result['rows'] ?? []),
(int) (company_id() ?: 1),
);
return [
'supplierPayables' => $this->supplierPayablePaginator($rows, $limit, $page),
'supplierPayableFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'),
'supplierPayableStatus' => $cache->responseHeaders(),
'supplierPayableError' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return [
'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page),
'supplierPayableFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'),
'supplierPayableStatus' => [],
'supplierPayableError' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page),
'supplierPayableFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'),
'supplierPayableStatus' => [],
'supplierPayableError' => '供应商应付数据暂时无法读取,请稍后重试。',
];
}
}
private function supplierPayablePaginator(array $rows, int $limit, int $page): Paginator
{
return new Paginator(
Collection::make($rows)->map(static fn (array $row): object => (object) $row),
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
);
}
protected function contentForNormalProducts(): array
{
$filters = $this->normalProductFilters(request());
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 50);
try {
if (! $this->productCatalog->available()) {
return $this->emptyProductCatalogContent(
$filters,
$limit,
$page,
'共享业务镜像尚未建立,普通商品资料暂时无法读取。',
null,
['code' => 'unavailable', 'lag_minutes' => null],
);
}
$state = $this->productCatalog->state();
$syncStatus = $this->productCatalog->syncStatus($state);
if (! $this->productCatalog->ready()) {
return $this->emptyProductCatalogContent(
$filters,
$limit,
$page,
'普通商品资料正在执行首次全量同步,完成前不会把未同步数据误判为无数据。',
$state,
$syncStatus,
);
}
$result = $this->productCatalog->paginate($filters, $page, $limit);
$paginator = new LengthAwarePaginator(
$result['data'],
$result['total'],
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
);
return [
'productCatalogRows' => $paginator,
'productCatalogFilters' => $filters,
'productCatalogError' => null,
'productCatalogState' => $state,
'productCatalogSyncStatus' => $syncStatus,
'productCatalogPurchasePriceSyncStatus' => $result['purchase_price_sync_status'] ?? [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogWorkPlatformPriceSyncStatus' => $result['work_platform_price_sync_status'] ?? [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogPriceChangeSyncStatus' => $result['price_change_sync_status'] ?? [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogJushuitanCostPriceHistorySyncStatus' => $result['jushuitan_cost_price_history_sync_status'] ?? [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
];
} catch (Throwable $exception) {
report($exception);
return $this->emptyProductCatalogContent(
$filters,
$limit,
$page,
'共享业务数据暂时无法读取,请稍后重试。',
null,
['code' => 'unavailable', 'lag_minutes' => null],
);
}
}
public function exportNormalProducts(Request $request): StreamedResponse
{
abort_unless($this->productCatalog->available() && $this->productCatalog->ready(), 503);
$filters = $this->normalProductFilters($request);
return response()->streamDownload(function () use ($filters): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建普通商品资料导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, [
'ID', '款式编码', '商品编码', '商品名称', '商品简称', '规格 / 颜色',
'品牌', '类目', '供应商', '销售价', '成本价', '普通商品采购价',
'工作平台价格', '工作平台价格生效日期', '定价状态', '变价前价格', '变价后价格', '差额', '变价时间',
'数据来源', '市场价', '商品状态', '库存同步', '业务更新时间',
]);
foreach ($this->productCatalog->exportRows($filters) as $row) {
$specification = collect([$row->properties_value, $row->color])
->filter(static fn (mixed $value): bool => $value !== null && $value !== '')
->unique()
->implode(' / ');
$enabledLabel = match ((int) $row->enabled) {
1 => '已启用',
-1 => '已删除',
default => '已停用',
};
fputcsv($csv, [
$row->autoid,
$this->csvText($row->i_id),
$this->csvText($row->sku_id),
$this->csvText($row->name),
$this->csvText($row->short_name),
$this->csvText($specification),
$this->csvText($row->brand),
$this->csvText($row->category),
$this->csvText($row->supplier_name),
$row->sale_price,
$row->cost_price,
$row->purchase_price,
$row->work_platform_price ?? null,
$this->workPlatformPriceDate($row->work_platform_price_effect_time ?? null),
$row->pricing_status ?? '未定价',
$row->price_change_before_price ?? null,
$row->price_change_after_price ?? null,
$row->price_change_diff_amount ?? null,
$this->workPlatformPriceDate($row->price_change_effect_time ?? null),
implode(';', $row->data_sources),
$row->market_price,
$enabledLabel,
(int) $row->stock_disabled === 0 ? '正常同步' : '停止同步',
$row->modified,
]);
}
fclose($csv);
}, '普通商品资料_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
private function workPlatformPriceDate(mixed $value): string
{
if (! is_string($value) || trim($value) === '') {
return '';
}
try {
return Carbon::parse($value)->format('Y-m-d');
} catch (Throwable) {
return '';
}
}
public function exportCourierInformation(Request $request): StreamedResponse
{
$platformReady = $this->courierInformation->available() && $this->courierInformation->ready();
$filters = $this->courierInformationFilters($request);
$profiles = app(\App\Business\CourierProfileData::class);
$overview = app(\App\Business\CourierOverviewData::class);
return response()->streamDownload(function () use ($filters, $platformReady, $profiles, $overview): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建快递资料导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, [
'ID', '快递公司代码', '快递公司名称', '外部编码', '杉橙快递代码', '运费打款方式',
'是否启用', '可达服务', '是否快运', '自动寻号', '配货单号寻号', '数量拆单', '重量0寻号',
'停运时间', '合同主体', '合同开始时间', '合同结束时间', '签订公司', '联系人', '联系电话', '自定义1', '自定义2', '扣款方式',
'体积0寻号', '手工单号', '解密', '电子签章经办人', '最后修改',
'入驻单号', '入驻状态', '入驻主体',
...array_map(static fn (string $field): string => \App\Models\Business\CourierOnboarding::PROFILE_FIELDS[$field][0], \App\Business\CourierProfileData::APPENDED_PROFILE_FIELDS),
'财务系统已修改字段',
]);
$bool = static fn (mixed $value): string => in_array(strtolower((string) $value), ['1', 'true', 'yes', 'y'], true) || $value === true ? '是' : '否';
$date = static fn (mixed $value): string => $value === null || $value === '' ? '' : substr((string) $value, 0, 10);
// 财务系统入驻(还没有工作平台记录)的快递排最前面;所有行都用财务系统编辑后的值。
$extra = array_map([$profiles, 'onboardingRow'], $overview->expressAppend([], 1, $filters['expname'] ?? '', $filters['expcode'] ?? '')['extra']);
$platformRows = $platformReady ? $this->courierInformation->exportRows($filters) : [];
$overrides = null;
$rowsToWrite = (function () use ($extra, $platformRows) {
yield from $extra;
yield from $platformRows;
})();
foreach ($rowsToWrite as $row) {
$overrides ??= \Illuminate\Support\Facades\Schema::hasTable('business_courier_profiles')
? \App\Models\Business\CourierProfile::query()->get()->mapWithKeys(static fn ($profile): array => [(string) $profile->expcode => (array) $profile->overrides])->all()
: [];
$info = $row->_info ?? $overview->infoFor((string) ($row->expcode ?? ''), $row->expname ?? null);
$row->_info = $info;
$row = $profiles->apply($profiles->fillFromOnboarding($row), $overrides[(string) ($row->expcode ?? '')] ?? []);
$onboarding = $info['onboarding'] ?? null;
fputcsv($csv, [
$row->Id ?? '',
$this->csvText($row->expcode ?? null),
$this->csvText($row->expname ?? null),
$this->csvText($row->outcode ?? null),
$this->csvText($row->scexpcode ?? null),
$this->csvText($row->paymethod ?? null),
$bool($row->status ?? null),
$bool($row->addvalidsgn ?? null),
$bool($row->kuaiyunsgn ?? null),
$bool($row->autonosgn ?? null),
$bool($row->packnosgn ?? null),
$bool($row->skucountsplitsgn ?? null),
$bool($row->weight0sgn ?? null),
$date($row->stopdate ?? null),
$this->csvText($row->tradebody ?? null),
$date($row->tradebtime ?? null),
$date($row->tradeetime ?? null),
$this->csvText($row->signcom ?? null),
$this->csvText($row->linker ?? null),
$this->csvText($row->linkmobile ?? null),
$this->csvText($row->undefined1 ?? null),
$this->csvText($row->undefined2 ?? null),
$this->csvText($row->undefined3 ?? null),
$bool($row->volume0sgn ?? null),
$bool($row->handnosgn ?? null),
$bool($row->decryptsgn ?? null),
$this->csvText($row->dzqzjbr ?? null),
$date($row->modified ?? null),
$this->csvText($onboarding?->number),
$this->csvText($onboarding?->statusLabel()),
$this->csvText($onboarding ? $onboarding->entityLabel() . ' · ' . $onboarding->company_full_name : null),
...array_map(fn (string $field): string => $this->csvText($onboarding?->{$field}), \App\Business\CourierProfileData::APPENDED_PROFILE_FIELDS),
$this->csvText(implode('、', array_map(static fn (string $field): string => \App\Models\Business\CourierProfile::EDITABLE[$field], $row->_overridden ?? []))),
]);
}
fclose($csv);
}, '快递资料_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<string,mixed> */
private function normalProductFilters(Request $request): array
{
$filters = [
'i_id' => mb_substr(trim((string) $request->query('i_id', '')), 0, 50),
'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 50),
'keyword' => mb_substr(trim((string) $request->query('keyword', '')), 0, 100),
'brand' => mb_substr(trim((string) $request->query('brand', '')), 0, 100),
'category' => mb_substr(trim((string) $request->query('category', '')), 0, 100),
'supplier_name' => mb_substr(trim((string) $request->query('supplier_name', '')), 0, 191),
'enabled' => in_array((string) $request->query('enabled', ''), ['-1', '0', '1'], true)
? (string) $request->query('enabled')
: '',
'stock_disabled' => in_array((string) $request->query('stock_disabled', ''), ['0', '1'], true)
? (string) $request->query('stock_disabled')
: '',
'sale_price_min' => $this->decimalFilter($request->query('sale_price_min')),
'sale_price_max' => $this->decimalFilter($request->query('sale_price_max')),
];
if ($filters['sale_price_min'] !== null
&& $filters['sale_price_max'] !== null
&& (float) $filters['sale_price_min'] > (float) $filters['sale_price_max']) {
[$filters['sale_price_min'], $filters['sale_price_max']] = [
$filters['sale_price_max'],
$filters['sale_price_min'],
];
}
return $filters;
}
private function csvText(mixed $value): string
{
$text = trim((string) ($value ?? ''));
return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text;
}
/**
* XLSX 文本单元格值:空值返回 null(跳过单元格)。
* 文本单元格不会被当作公式执行,因此不需要 csvText 的公式前缀保护。
*/
private function xlsxText(mixed $value): ?string
{
$text = trim((string) ($value ?? ''));
return $text === '' ? null : $text;
}
/**
* XLSX 日期时间单元格值:去掉 SQL Server 微秒尾缀(.000000),空值返回 null。
*/
private function xlsxDateTime(mixed $value): ?string
{
$text = trim((string) ($value ?? ''));
if ($text === '') {
return null;
}
return preg_replace('/\.\d{1,6}$/', '', $text) ?? $text;
}
/**
* Keep client-selected exports within the fields declared by the page.
* An omitted or invalid selection falls back to the complete safe schema.
*
* @param array<int,array{label:string,field:string}> $columns
* @return array<int,array{label:string,field:string}>
*/
private function filterExportColumns(array $columns, mixed $requestedFields): array
{
if (! is_string($requestedFields) || trim($requestedFields) === '') {
return $columns;
}
$available = [];
foreach ($columns as $column) {
if ($column['field'] !== '') {
$available[$column['field']] = $column;
}
}
$selected = [];
foreach (explode(',', $requestedFields) as $field) {
$field = trim($field);
if ($field !== '' && isset($available[$field])) {
$selected[$field] = $available[$field];
}
}
return $selected !== [] ? array_values($selected) : $columns;
}
/**
* Export identifiers as text so Excel does not round or truncate long IDs.
* Keep the formula-injection guard from csvText for values beginning with a
* formula marker.
*/
private function csvIdentifier(mixed $value): string
{
$text = $this->csvText($value);
if ($text === '' || str_starts_with($text, "'")) {
return $text;
}
return "'" . $text;
}
private function emptyProductCatalogContent(
array $filters,
int $limit,
int $page,
string $error,
?array $state,
array $syncStatus,
): array {
return [
'productCatalogRows' => new LengthAwarePaginator(
[],
0,
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
),
'productCatalogFilters' => $filters,
'productCatalogError' => $error,
'productCatalogState' => $state,
'productCatalogSyncStatus' => $syncStatus,
'productCatalogPurchasePriceSyncStatus' => [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogWorkPlatformPriceSyncStatus' => [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogPriceChangeSyncStatus' => [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogJushuitanCostPriceHistorySyncStatus' => [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
];
}
protected function contentForCourierInformation(): array
{
$filters = $this->courierInformationFilters(request());
// 合同管理独立读取协议归档,不依赖上游快递镜像的可用性。
$tab = (string) request()->query('courier_tab', 'express');
$tab = in_array($tab, ['express', 'networks', 'accounts', 'logs', 'contracts'], true) ? $tab : 'express';
$contracts = app(\App\Business\CourierContractPageData::class)->page($tab === 'contracts');
if ($tab === 'contracts') {
return $contracts + [
'view' => 'business.pages.courier-payables.courier-information',
'courierTab' => $tab, 'courierFilters' => [], 'courierError' => null, 'courierPending' => false,
];
}
$page = max((int) request()->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(),
'query' => request()->except('page'),
]);
$error = null;
$pending = false;
$state = null;
$syncStatus = ['code' => 'unavailable', 'lag_minutes' => null];
$relatedAvailability = $this->courierInformation->relatedAvailability();
$profiles = app(\App\Business\CourierProfileData::class);
$overview = app(\App\Business\CourierOverviewData::class);
try {
// 网点 / 打款信息 / 操作日志页签也显示快递资料主表的同步状态,避免误显示「镜像未建立」。
if ($tab !== 'express' && $this->courierInformation->available()) {
$state = $this->courierInformation->state();
$syncStatus = $this->courierInformation->syncStatus($state);
}
if ($tab === 'express') {
if (! $this->courierInformation->available()) {
$error = '共享业务镜像尚未建立,快递资料暂时无法读取。';
} else {
$state = $this->courierInformation->state();
$syncStatus = $this->courierInformation->syncStatus($state);
if (! $this->courierInformation->ready()) {
$pending = true;
$error = '快递资料正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierInformation->paginate($filters, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
}
} elseif ($tab === 'networks') {
if (! ($relatedAvailability['networks'] ?? false)) {
$error = '快递网点镜像尚未建立,请先完成 wp_express_expNet 的首次同步。';
} elseif (! ($relatedAvailability['network_ready'] ?? false)) {
$pending = true;
$error = '快递网点正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierInformation->paginateNetworks($filters['expcode'] ?: null, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
} elseif ($tab === 'accounts') {
if (! ($relatedAvailability['accounts'] ?? false)) {
$error = '工作平台打款账户镜像尚未建立,下面只显示财务系统里的打款信息。';
} elseif (! ($relatedAvailability['account_ready'] ?? false)) {
$pending = true;
$error = '工作平台打款账户正在执行首次全量同步,下面先显示财务系统里的打款信息。';
} else {
$result = $this->courierInformation->paginateAccounts($filters['expcode'] ?: null, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
} else {
if (! ($relatedAvailability['logs'] ?? false)) {
$error = '工作平台操作日志镜像尚未建立,下面只显示财务系统里的操作日志。';
} elseif (! ($relatedAvailability['log_ready'] ?? false)) {
$pending = true;
$error = '工作平台操作日志正在执行首次全量同步,下面先显示财务系统里的操作日志。';
} else {
$result = $this->courierInformation->paginateLogs($filters['expcode'] ?: null, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
}
} catch (Throwable $exception) {
report($exception);
$error = '快递资料共享镜像暂时无法读取,请稍后重试。';
}
// 主信息:财务系统入驻的快递排在第一页最前面,工作平台的行右侧接上入驻信息;编辑过的字段用财务系统的值。
$displayRows = [];
$financeAccounts = [];
$financeEvents = collect();
try {
if ($tab === 'express') {
$append = $overview->expressAppend(
$rows->items(),
$page,
$filters['expname'] ?? '',
$filters['expcode'] ?? '',
);
$platformRows = array_map(static function (object $row) use ($append): object {
$row = clone $row;
$row->_info = $append['matches'][(string) ($row->Id ?? $row->expcode)] ?? null;
return $row;
}, $rows->items());
$platformRows = array_map([$profiles, 'fillFromOnboarding'], $platformRows);
$displayRows = array_merge(array_map([$profiles, 'onboardingRow'], $append['extra']), $platformRows);
$overrides = $profiles->overrides(array_map(static fn (object $row): string => (string) ($row->expcode ?? ''), $displayRows));
$displayRows = array_map(static fn (object $row): object => $profiles->apply($row, $overrides[(string) ($row->expcode ?? '')] ?? []), $displayRows);
} elseif ($tab === 'accounts' && $page === 1) {
$financeAccounts = $profiles->financeAccounts($filters['expcode'] ?: null);
} elseif ($tab === 'logs' && $page === 1) {
$financeEvents = $profiles->events($filters['expcode'] ?: null);
}
} catch (Throwable $exception) {
report($exception);
}
$detail = $tab === 'express' ? $this->courierInformationDetail($displayRows, $relatedAvailability) : null;
return [
'view' => 'business.pages.courier-payables.courier-information',
'courierTab' => $tab,
'courierFilters' => $filters,
'courierRows' => $rows,
'courierDisplayRows' => $displayRows,
'courierDetail' => $detail,
'courierFinanceAccounts' => $financeAccounts,
'courierFinanceEvents' => $financeEvents,
'courierState' => $state,
'courierSyncStatus' => $syncStatus,
'courierError' => $error,
'courierPending' => $pending,
'courierRelatedAvailability' => $relatedAvailability,
] + $contracts;
}
/** 选中一行主信息(?selected=快递公司代码)后,下方显示这家快递的网点 / 打款信息 / 操作日志。 */
private function courierInformationDetail(array $displayRows, array $availability): ?array
{
$code = mb_substr(trim((string) request()->query('selected', '')), 0, 64);
if ($code === '') {
return null;
}
$profiles = app(\App\Business\CourierProfileData::class);
$overview = app(\App\Business\CourierOverviewData::class);
$row = collect($displayRows)->first(static fn (object $item): bool => strcasecmp((string) ($item->expcode ?? ''), $code) === 0);
$row ??= $profiles->currentRow($code);
if ($row === null) {
return ['code' => $code, 'row' => null];
}
$info = $row->_info ?? $overview->infoFor($code, $row->expname ?? null);
$onboarding = $info['onboarding'] ?? null;
$networks = $platformAccounts = $platformLogs = [];
try {
if (($availability['network_ready'] ?? false)) {
$networks = $this->courierInformation->paginateNetworks($code, 1, 100)['data'] ?? [];
}
if (($availability['account_ready'] ?? false)) {
$platformAccounts = $this->courierInformation->paginateAccounts($code, 1, 100)['data'] ?? [];
}
if (($availability['log_ready'] ?? false)) {
$platformLogs = $this->courierInformation->paginateLogs($code, 1, 100)['data'] ?? [];
}
} catch (Throwable $exception) {
report($exception);
}
if ($onboarding !== null && ($network = $profiles->onboardingNetwork($onboarding)) !== null) {
array_unshift($networks, $network);
}
return [
'code' => $code,
'row' => $row,
'onboarding' => $onboarding,
'networks' => $networks,
'platform_accounts' => $platformAccounts,
'finance_accounts' => $profiles->financeAccounts($code, $onboarding),
'platform_logs' => $platformLogs,
'events' => $profiles->events($code),
'edit' => request()->boolean('edit'),
'detail_tab' => in_array(request()->query('detail_tab'), ['networks', 'accounts', 'logs'], true) ? request()->query('detail_tab') : 'accounts',
];
}
/** @return array<string,string> */
private function courierInformationFilters(Request $request): array
{
$filters = [
'expcode' => mb_substr(trim((string) $request->query('expcode', '')), 0, 64),
'expname' => mb_substr(trim((string) $request->query('expname', '')), 0, 100),
'undefined1' => mb_substr(trim((string) $request->query('undefined1', '')), 0, 100),
'undefined2' => mb_substr(trim((string) $request->query('undefined2', '')), 0, 100),
'undefined3' => mb_substr(trim((string) $request->query('undefined3', '')), 0, 100),
'tradebtime_from' => $this->dateFilter($request->query('tradebtime_from')),
'tradebtime_to' => $this->dateFilter($request->query('tradebtime_to')),
'status' => in_array((string) $request->query('status', ''), ['0', '1'], true)
? (string) $request->query('status')
: '',
];
if ($filters['tradebtime_from'] !== ''
&& $filters['tradebtime_to'] !== ''
&& $filters['tradebtime_from'] > $filters['tradebtime_to']) {
[$filters['tradebtime_from'], $filters['tradebtime_to']] = [
$filters['tradebtime_to'],
$filters['tradebtime_from'],
];
}
return $filters;
}
private function courierPaginator(array $rows, int $total, int $limit, int $page): LengthAwarePaginator
{
return new LengthAwarePaginator(
Collection::make($rows),
$total,
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
);
}
/** 详情局部刷新:点行后只返回详情面板,避免整页重载。 */
public function returnPickupManagementDetail(Request $request): \Illuminate\Contracts\View\View
{
$pickupId = max((int) $request->query('pickup_id', 0), 0);
abort_if($pickupId <= 0, 404);
$repository = app(\Modules\EcommerceFinance\Repositories\ReturnPickupManagementMirrorRepository::class);
$detail = $this->enrichReturnPickupDetail(
$repository->detail($pickupId),
$repository->warehouseDirectory(),
);
abort_if($detail === null, 404);
return view('business.pages.courier-payables.partials.return-pickup-detail', [
'detail' => $detail,
'closeUrl' => route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'return-pickup-management',
]),
]);
}
protected function contentForReturnPickupManagement(): array
{
$filters = $this->returnPickupFilters(request());
$page = max((int) request('pickup_page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$repository = app(\Modules\EcommerceFinance\Repositories\ReturnPickupManagementMirrorRepository::class);
$rows = null;
$state = null;
$syncStatus = ['code' => 'unavailable', 'lag_minutes' => null];
$error = null;
$pending = false;
$carriers = [];
$statuses = [];
$detail = null;
try {
if (! $repository->available()) {
$error = '共享业务镜像尚未建立,退货取件管理暂时无法读取。';
} else {
$state = $repository->state();
$syncStatus = $repository->syncStatus($state);
if (! $repository->ready()) {
$pending = true;
$error = '退货取件管理正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $repository->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('pickup_page'),
],
);
$carriers = $repository->carriers();
$statuses = $repository->statuses();
$detailId = (int) request('pickup_id', 0);
if ($detailId > 0) {
$detail = $this->enrichReturnPickupDetail(
$repository->detail($detailId),
$repository->warehouseDirectory(),
);
}
}
}
} catch (Throwable $exception) {
report($exception);
$error = '退货取件管理共享镜像暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.courier-payables.return-pickup-management',
'returnPickupFilters' => $filters,
'returnPickupRows' => $rows,
'returnPickupState' => $state,
'returnPickupSyncStatus' => $syncStatus,
'returnPickupError' => $error,
'returnPickupPending' => $pending,
'returnPickupCarriers' => $carriers,
'returnPickupStatuses' => $statuses,
'returnPickupDetail' => $detail,
];
}
/** @return array<string,string> */
private function returnPickupFilters(Request $request): array
{
$filters = [
'created_from' => $this->dateFilter($request->query('created_from')),
'created_to' => $this->dateFilter($request->query('created_to')),
'carrier' => array_values(array_filter(array_map(
static fn ($carrier): string => mb_substr(trim((string) $carrier), 0, 64),
(array) $request->query('carrier', []),
), static fn (string $carrier): bool => $carrier !== '')),
'status' => mb_substr(trim((string) $request->query('status', '')), 0, 64),
'shop' => mb_substr(trim((string) $request->query('shop', '')), 0, 191),
'keyword' => mb_substr(trim((string) $request->query('keyword', '')), 0, 100),
];
if ($filters['created_from'] !== ''
&& $filters['created_to'] !== ''
&& $filters['created_from'] > $filters['created_to']) {
[$filters['created_from'], $filters['created_to']] = [
$filters['created_to'],
$filters['created_from'],
];
}
return $filters;
}
/**
* 用逆向物流报价为退货取件明细补「实际运费」和「差异(预估−实际)」。
*
* @param array{main:object,items:list<object>,truth_numbers:list<string>}|null $detail
* @param array<string,string> $warehouseDirectory 发货仓编码 → 名称
* @return array<string,mixed>|null
*/
private function enrichReturnPickupDetail(?array $detail, array $warehouseDirectory = []): ?array
{
if ($detail === null) {
return null;
}
try {
$calculator = app(\App\Support\ReturnPickupFreightCalculator::class);
} catch (Throwable $exception) {
report($exception);
$calculator = null;
}
$main = $detail['main'];
$items = [];
$estimatedTotal = 0.0;
$actualTotal = 0.0;
$surchargeTotal = 0.0;
$hasActual = false;
foreach ($detail['items'] as $item) {
$result = $calculator?->calculate(
$main->express_company ?? null,
$main->province ?? null,
$item->receiverAddress ?? null,
(float) ($item->grossWeight ?? 0),
$item->length ?? null,
$item->width ?? null,
$item->height ?? null,
) ?? ['covered' => false, 'reason' => '逆向物流报价快照不可用', 'total' => null, 'surcharge' => 0.0, 'billable' => null, 'formula' => null, 'note' => null, 'surcharge_note' => null];
$estimated = $item->estimatedFreight !== null ? (float) $item->estimatedFreight : null;
$actual = $result['total'] !== null ? (float) $result['total'] : null;
$surcharge = (float) ($result['surcharge'] ?? 0);
$values = (array) $item;
$values['actual_freight'] = $actual;
$values['freight_surcharge'] = $surcharge;
$values['freight_settlement'] = $result['settlement'];
$values['freight_base_amount'] = $result['base_amount'];
$values['freight_increment_amount'] = $result['increment_amount'];
$values['freight_billable'] = $result['billable'];
$values['freight_formula'] = $result['formula'];
$values['freight_note'] = $result['covered']
? trim(($result['note'] ?? '') . ($result['surcharge_note'] !== null ? ';' . $result['surcharge_note'] : ''))
: $result['reason'];
$values['freight_process'] = $result['process'] ?? $result['reason'];
$values['freight_covered'] = $result['covered'];
$values['freight_diff'] = ($estimated !== null && $actual !== null) ? round($estimated - $actual, 2) : null;
$warehouseCode = trim((string) ($values['warehouse'] ?? ''));
$values['warehouse_name'] = $warehouseCode === ''
? null
: ($warehouseDirectory[$warehouseCode] ?? null);
$items[] = (object) $values;
if ($estimated !== null) {
$estimatedTotal += $estimated;
}
if ($actual !== null) {
$actualTotal += $actual;
$hasActual = true;
}
$surchargeTotal += $surcharge;
}
$detail['items'] = $items;
$detail['totals'] = [
'estimated' => round($estimatedTotal, 2),
'actual' => $hasActual ? round($actualTotal, 2) : null,
'surcharge' => round($surchargeTotal, 2),
'diff' => $hasActual ? round($estimatedTotal - $actualTotal, 2) : null,
];
return $detail;
}
public function exportReturnPickupManagement(Request $request): StreamedResponse
{
$filters = $this->returnPickupFilters($request);
$repository = app(\Modules\EcommerceFinance\Repositories\ReturnPickupManagementMirrorRepository::class);
$calculator = app(\App\Support\ReturnPickupFreightCalculator::class);
$limit = 50000;
return response()->streamDownload(function () use ($filters, $repository, $calculator, $limit): void {
$writer = new \App\Support\StreamingXlsxWriter('退货取件明细');
$writer->appendRow([
'订单号', '快递公司', '订单状态', '紧急程度', '店铺名称', '交易单号', '出发省', '到达省',
'应退货号', '实退货号', '条形码', '品名', '平台商品id', '平台skuid',
'应退数量', '实退数量', '待核销数量', '总件数', '长', '宽', '高',
'毛重(kg)', '体积重(kg)', '重量取大(kg)', '计费重量(kg)', '面单(元)', '费率(元/kg)',
'预估运费(元)', '实际运费(元)', '加收(元)', '差异(元)',
'明细退货进度', '发货仓', '收货人', '收货电话', '收货地址', '实退单号', '实退快递单号',
'预计取件时间', '退货进度', '付款方式', '取件类型', '发件人姓名', '备注',
'问题类型', '问题描述', '问题跟进状态', '问题跟进结果', '问题跟进说明',
'快递下单时间', '快递取件时间', '创建人', '取消人', '创建时间', '更新时间',
]);
$warehouseDirectory = $repository->warehouseDirectory();
$number = static fn (?float $value): ?float => $value === null ? null : round($value, 2);
$quantity = static fn (mixed $value): ?int => $value === null ? null : (int) $value;
$exported = 0;
foreach ($repository->exportRows($filters, $limit) as $row) {
$length = (float) ($row->length ?? 0);
$width = (float) ($row->width ?? 0);
$height = (float) ($row->height ?? 0);
$volumeWeight = ($length > 0 && $width > 0 && $height > 0)
? round($length * $width * $height / 6000, 2)
: null;
$result = $calculator->calculate(
$row->express_company ?? null,
$row->province ?? null,
$row->receiverAddress ?? null,
(float) ($row->grossWeight ?? 0),
$length,
$width,
$height,
);
$estimated = $row->estimatedFreight !== null ? round((float) $row->estimatedFreight, 2) : null;
$actual = $result['total'];
$warehouseCode = trim((string) ($row->warehouse ?? ''));
$followStatus = $this->xlsxText($row->expressFollowStatus ?? '');
$followResult = $this->xlsxText($row->expressFollowResult ?? '');
$returnProgress = $followStatus === null
? $followResult
: ($followResult === null ? $followStatus : $followStatus . '(' . $followResult . ')');
$writer->appendRow([
$this->xlsxText($row->orderId ?? ''),
$this->xlsxText($row->express_company ?? ''),
$this->xlsxText(\App\Support\ReturnPickupOrderStatus::label($row->orderStatus ?? null)),
$this->xlsxText($row->urgencyLevel ?? ''),
$this->xlsxText($row->shopName ?? ''),
$this->xlsxText($row->transactionNo ?? ''),
$this->xlsxText($row->province ?? ''),
$this->xlsxText($result['covered']
? \App\Support\ReverseLogisticsQuoteSnapshot::provinceKey(
\App\Support\ReturnPickupFreightCalculator::destinationProvince((string) ($row->receiverAddress ?? ''))
)
: ''),
$this->xlsxText($row->returnProcode ?? ''),
$this->xlsxText($row->actualReturnProcode ?? ''),
$this->xlsxText($row->barcode ?? ''),
$this->xlsxText($row->productName ?? ''),
$this->xlsxText($row->platformProductId ?? ''),
$this->xlsxText($row->platformSkuId ?? ''),
$quantity($row->returnQuantity ?? null),
$quantity($row->actualReturnQuantity ?? null),
$quantity($row->unverifiedQuantity ?? null),
$quantity($row->totalReturnQuantity ?? null),
$number($length > 0 ? $length : null),
$number($width > 0 ? $width : null),
$number($height > 0 ? $height : null),
$number($row->grossWeight !== null ? (float) $row->grossWeight : null),
$number($volumeWeight),
$number($result['settlement']),
$number($result['billable']),
$number($result['base_amount']),
$number($result['increment_amount']),
$number($estimated),
$number($actual),
$number(($result['surcharge'] ?? 0) > 0 ? (float) $result['surcharge'] : null),
$number($estimated !== null && $actual !== null ? round($estimated - $actual, 2) : null),
$this->xlsxText($row->returnProgress ?? ''),
$this->xlsxText($warehouseCode === '' ? '' : ($warehouseDirectory[$warehouseCode] ?? $warehouseCode)),
$this->xlsxText($row->receiverName ?? ''),
$this->xlsxText($row->receiverPhone ?? ''),
$this->xlsxText($row->receiverAddress ?? ''),
$this->xlsxText($row->actualReturnOrderNo ?? ''),
$this->xlsxText($row->returnExpressNo ?? ''),
$this->xlsxDateTime($row->expectedPickupTime ?? null),
$this->xlsxText($returnProgress ?? ''),
$this->xlsxText($row->paymentMethod ?? ''),
$this->xlsxText($row->pickupType ?? ''),
$this->xlsxText($row->senderName ?? ''),
$this->xlsxText($row->note ?? ''),
$this->xlsxText($row->issueType ?? ''),
$this->xlsxText($row->issueDescription ?? ''),
$this->xlsxText($row->expressFollowStatus ?? ''),
$this->xlsxText($row->expressFollowResult ?? ''),
$this->xlsxText($row->expressFollowDescription ?? ''),
$this->xlsxDateTime($row->expressOrderTime ?? null),
$this->xlsxDateTime($row->expressPickupTime ?? null),
$this->xlsxText($row->creator ?? ''),
$this->xlsxText($row->canceler ?? ''),
$this->xlsxDateTime($row->createTime ?? null),
$this->xlsxDateTime($row->updateTime ?? null),
]);
$exported++;
}
if ($exported >= $limit) {
$writer->appendRow(['(已按上限截断,仅导出前 ' . $limit . ' 行,请缩小筛选范围后重试)']);
}
$output = fopen('php://output', 'wb');
if ($output === false) {
throw new RuntimeException('无法创建退货取件管理导出流。');
}
$writer->writeTo($output);
fclose($output);
}, '退货取件管理_' . now()->format('Ymd_His') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
protected function contentForProductPrices(): array
{
$filters = [
'barcode' => mb_substr(trim((string) request('barcode', '')), 0, 191),
'completed_from' => $this->dateFilter(request('completed_from')),
'completed_to' => $this->dateFilter(request('completed_to')),
];
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
try {
if (! $this->productPrices->ready()) {
return $this->emptyProductPriceContent(
$filters,
$limit,
$page,
'共享业务库尚未完成建库,请等待负责人完成初始化。'
);
}
if (! $this->productPrices->initialized()) {
return $this->emptyProductPriceContent(
$filters,
$limit,
$page,
'商品价格正在执行首次全量同步,完成前不会把未同步数据误判为无数据。'
);
}
$paginator = $this->productPrices->paginate($filters, $page, $limit);
$paginator->appends(request()->except('page'));
return [
'productPrices' => $paginator,
'productPriceFilters' => $filters,
'productPriceError' => null,
];
} catch (Throwable $exception) {
report($exception);
return $this->emptyProductPriceContent(
$filters,
$limit,
$page,
'共享业务数据暂时无法读取,请稍后重试。'
);
}
}
private function emptyProductPriceContent(array $filters, int $limit, int $page, string $error): array
{
return [
'productPrices' => new LengthAwarePaginator(
[],
0,
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
),
'productPriceFilters' => $filters,
'productPriceError' => $error,
];
}
/**
* 封边条页面只读取上游快照;未启用或无快照时将错误交给页面显示,
* 避免工作台因上游暂不可用直接返回 500。
*/
protected function edgeBandingContent(string $page): ?array
{
$scope = match ($page) {
'edge-banding-prices' => 'edge-banding.prices',
'details' => 'edge-banding.details',
'edge-banding-payables' => 'edge-banding.payables',
default => null,
};
if ($scope === null) {
return null;
}
$filters = match ($page) {
'edge-banding-prices' => $this->edgeBandingFilters([
'raw_name', 'raw_code', 'production_code', 'supplier',
]),
'details' => $this->edgeBandingFilters([
'date', 'production_code', 'raw_name', 'return_flag',
'outbound_supplier', 'arrival_supplier', 'outbound_no',
]),
'edge-banding-payables' => $this->edgeBandingFilters(['period', 'supplier']),
default => [],
};
if ($page === 'edge-banding-payables' && ! isset($filters['period'])) {
$filters['period'] = '2026-06';
}
$pageNumber = max((int) request('data_page', 1), 1);
$limit = min(max((int) request('data_limit', 25), 1), 100);
$cache = $this->upstreamCaches->forSource(self::EDGE_BANDING_SOURCE);
try {
$result = $cache->remember(
$scope,
['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit],
fn () => match ($scope) {
'edge-banding.prices' => $this->edgeBanding->prices($filters, $pageNumber, $limit),
'edge-banding.details' => $this->edgeBanding->details($filters, $pageNumber, $limit),
'edge-banding.payables' => $this->edgeBanding->payables($filters, $pageNumber, $limit),
},
);
return [
'filters' => $filters,
'result' => $result,
'page' => $pageNumber,
'limit' => $limit,
'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1),
'status' => $cache->responseHeaders(),
'error' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return [
'filters' => $filters,
'result' => ['data' => [], 'total' => 0],
'page' => $pageNumber,
'limit' => $limit,
'last_page' => 1,
'status' => [],
'error' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'filters' => $filters,
'result' => ['data' => [], 'total' => 0],
'page' => $pageNumber,
'limit' => $limit,
'last_page' => 1,
'status' => [],
'error' => '封边条数据暂时无法读取,请稍后重试。',
];
}
}
private function edgeBandingFilters(array $fields): array
{
$filters = [];
foreach ($fields as $field) {
$value = trim((string) request($field, ''));
if ($value !== '') {
$filters[$field] = $value;
}
}
return $filters;
}
/**
* 素板价格与对账页面使用 JianSun 只读查询,并保留共享快照链路。
*/
protected function rawBoardContent(string $page): ?array
{
$scope = match ($page) {
'board-prices' => 'raw-board.prices',
'board-reconciliation' => 'raw-board.reconciliations',
default => null,
};
if ($scope === null) {
return null;
}
$filters = $scope === 'raw-board.prices'
? $this->rawBoardFilters(['raw_name', 'raw_code', 'production_code', 'supplier'])
: $this->rawBoardReconciliationFilters();
$pageNumber = max((int) request('data_page', 1), 1);
$limit = min(max((int) request('data_limit', 25), 1), 100);
try {
$cache = $this->upstreamCaches->forSource(self::RAW_BOARD_SOURCE);
$result = $cache->remember(
$scope,
['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit],
fn () => $scope === 'raw-board.prices'
? $this->rawBoardPayables->prices($filters, $pageNumber, $limit)
: $this->rawBoardPayables->reconciliations($filters, $pageNumber, $limit),
);
return [
'filters' => $filters,
'result' => $result,
'page' => $pageNumber,
'limit' => $limit,
'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1),
'status' => $cache->responseHeaders(),
'error' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return $this->emptyRawBoardContent($filters, $pageNumber, $limit, $exception->getMessage());
} catch (Throwable $exception) {
report($exception);
return $this->emptyRawBoardContent(
$filters,
$pageNumber,
$limit,
'素板应付数据暂时无法读取,请稍后重试。',
);
}
}
private function rawBoardFilters(array $fields): array
{
$filters = [];
foreach ($fields as $field) {
$value = mb_substr(trim((string) request($field, '')), 0, 100);
if ($value !== '') {
$filters[$field] = $value;
}
}
return $filters;
}
private function rawBoardReconciliationFilters(): array
{
$filters = $this->rawBoardFilters([
'production_code', 'raw_name', 'outbound_supplier',
'arrival_supplier', 'outbound_no',
]);
foreach (['date_from', 'date_to'] as $field) {
$value = $this->dateFilter(request($field));
if ($value !== '') {
$filters[$field] = $value;
}
}
$returnFlag = trim((string) request('return_flag', ''));
if (in_array($returnFlag, ['normal', 'return'], true)) {
$filters['return_flag'] = $returnFlag;
}
return $filters;
}
private function emptyRawBoardContent(array $filters, int $page, int $limit, string $error): array
{
return [
'filters' => $filters,
'result' => ['data' => [], 'total' => 0, 'totals' => []],
'page' => $page,
'limit' => $limit,
'last_page' => 1,
'status' => [],
'error' => $error,
];
}
/**
* 板材库存页面使用 JianSun 最新素板快照,并保留生产共享快照链路。
*/
protected function inventoryContent(string $page): ?array
{
if ($page !== 'board-inventory-details') {
return null;
}
$filters = $this->inventoryFilters();
$pageNumber = max((int) request('data_page', 1), 1);
$limit = min(max((int) request('data_limit', 25), 1), 100);
try {
$cache = $this->upstreamCaches->forSource(self::INVENTORY_SOURCE);
$result = $cache->remember(
'inventory.board-details',
['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit],
fn () => $this->inventory->boardInventoryDetails($filters, $pageNumber, $limit),
);
return [
'filters' => $filters,
'result' => $result,
'page' => $pageNumber,
'limit' => $limit,
'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1),
'status' => $cache->responseHeaders(),
'error' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return $this->emptyInventoryContent($filters, $pageNumber, $limit, $exception->getMessage());
} catch (Throwable $exception) {
report($exception);
return $this->emptyInventoryContent(
$filters,
$pageNumber,
$limit,
'板材库存数据暂时无法读取,请稍后重试。',
);
}
}
private function inventoryFilters(): array
{
$filters = [];
foreach (['manufacturer', 'specification', 'matching_value'] as $field) {
$value = mb_substr(trim((string) request($field, '')), 0, 100);
if ($value !== '') {
$filters[$field] = $value;
}
}
foreach (['date_from', 'date_to'] as $field) {
$value = $this->dateFilter(request($field));
if ($value !== '') {
$filters[$field] = $value;
}
}
return $filters;
}
private function emptyInventoryContent(array $filters, int $page, int $limit, string $error): array
{
return [
'filters' => $filters,
'result' => ['data' => [], 'total' => 0, 'totals' => []],
'page' => $page,
'limit' => $limit,
'last_page' => 1,
'status' => [],
'error' => $error,
];
}
/**
* 种菜应收对账 - 加载对账数据(21字段)
*/
protected function contentForZhongcaiReconciliation(): array
{
return [
'view' => 'store_receivables.zhongcai_reconciliations.partials.embedded-table',
'records' => StoreZhongcaiReconciliation::orderBy('bill_date', 'desc')
->orderBy('id', 'desc')
->paginate(setting('default.list_limit', 25)),
];
}
protected function contentForMarketingAccountBills(?string $tab): ?array
{
if (! in_array($tab, self::MARKETING_PLATFORMS, true)) {
return null;
}
if (in_array($tab, self::PROMOTION_MARKETING_TABS, true)) {
return app(PlatformPromotionAccountPageData::class)->content($tab);
}
// Alibaba 页面沿用历史 taobao-wechat 路由,数据来自 JianSun.dbo.alibaba_bill。
if ($tab === 'taobao-wechat') {
$request = request();
$shopName = mb_substr(trim((string) $request->query('shop_name')), 0, 191);
$productType = mb_substr(trim((string) $request->query('product_type')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$filters = [
'shop_name' => $shopName !== '' ? $shopName : null,
'product_type' => $productType !== '' ? $productType : null,
'from' => $fromDate !== '' ? $fromDate : null,
'to' => $toDate !== '' ? $toDate : null,
];
$repository = app(AlibabaBillRepository::class);
$cache = $this->upstreamCaches->forSource('jiansun');
$usingMirror = $repository->usesBusinessMirror();
try {
$listResolver = fn () => $repository->paginate(
$filters['shop_name'], $filters['product_type'],
$filters['from'], $filters['to'], $page, $pageSize,
);
$summaryResolver = fn () => $repository->summary(
$filters['shop_name'], $filters['product_type'], $filters['from'], $filters['to'],
);
$optionsResolver = fn () => $repository->filterOptions();
$result = $usingMirror
? $listResolver()
: $cache->remember(
'marketing-account-bills.alibaba.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$listResolver,
);
$summary = $usingMirror
? $summaryResolver()
: $cache->remember(
'marketing-account-bills.alibaba.summary',
['filters' => $filters],
$summaryResolver,
);
$options = $usingMirror
? $optionsResolver()
: $cache->remember(
'marketing-account-bills.alibaba.options',
[],
$optionsResolver,
);
} catch (ServiceUnavailableHttpException $exception) {
return [
'view' => 'banking.marketing_alibaba_bills.partials.embedded-table',
'bills' => new LengthAwarePaginator([], 0, $pageSize, $page, [
'path' => $request->url(), 'query' => $request->query(),
]),
'filters' => $filters,
'summary' => ['income_total' => 0, 'expense_total' => 0, 'net' => 0, 'count' => 0],
'options' => ['shops' => [], 'product_types' => []],
'error' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'view' => 'banking.marketing_alibaba_bills.partials.embedded-table',
'bills' => new LengthAwarePaginator([], 0, $pageSize, $page, [
'path' => $request->url(), 'query' => $request->query(),
]),
'filters' => $filters,
'summary' => ['income_total' => 0, 'expense_total' => 0, 'net' => 0, 'count' => 0],
'options' => ['shops' => [], 'product_types' => []],
'error' => 'Alibaba 账单数据暂时无法读取,请稍后重试。',
];
}
$bills = new LengthAwarePaginator(
Collection::make($result['rows'] ?? []),
(int) ($result['total'] ?? 0),
$pageSize,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
return [
'view' => 'banking.marketing_alibaba_bills.partials.embedded-table',
'bills' => $bills,
'filters' => $filters,
'summary' => $summary,
'options' => $options,
'error' => null,
];
}
// 淘系支付宝 - 从上游 JianSun.tmall_spread_bills 读取并按表1字段展示(9列)
if ($tab === 'taobao-alipay') {
$request = request();
$storeName = mb_substr(trim((string) $request->query('store_name')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$flowType = trim((string) $request->query('flow_type'));
$flowType = in_array($flowType, ['income', 'expense'], true) ? $flowType : '';
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$repository = app(TmallTaobaoSpreadBillRepository::class);
$cache = $this->upstreamCaches->forSource('jiansun');
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'from' => $fromDate !== '' ? $fromDate : null,
'to' => $toDate !== '' ? $toDate : null,
'flow_type' => $flowType !== '' ? $flowType : null,
];
$listResolver = fn () => $repository->paginate(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type'],
$page,
$pageSize
);
$summaryResolver = fn () => $repository->summary(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type']
);
$optionsResolver = fn () => $repository->storeOptions();
$usingMirror = $repository->usesBusinessMirror();
$result = $usingMirror
? $listResolver()
: $cache->remember(
'taobao-alipay-bills.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$listResolver,
);
$summary = $usingMirror
? $summaryResolver()
: $cache->remember(
'taobao-alipay-bills.summary',
['filters' => $filters],
$summaryResolver,
);
$storeOptions = $usingMirror
? $optionsResolver()
: $cache->remember(
'taobao-alipay-bills.options',
[],
$optionsResolver,
);
$bills = new LengthAwarePaginator(
Collection::make($result['rows']),
$result['total'],
$pageSize,
$page,
[
'path' => $request->url(),
'query' => $request->query(),
]
);
return [
'view' => 'banking.marketing_taobao_alipay_bills.partials.embedded-table',
'bills' => $bills,
'filters' => [
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
'flow_type' => $flowType,
'store_options' => $storeOptions,
],
'summary' => $summary,
];
}
// 拼多多 - 从上游 JianSun.pdd_spread_bills 读取并按表1字段展示(7列)
// 表1列:店铺名称 | 金额 | 余额 | 摘要 | 收支类型 | 资金类型 | 费用时间
if ($tab === 'pinduoduo') {
$request = request();
$storeName = mb_substr(trim((string) $request->query('store_name')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$flowType = trim((string) $request->query('flow_type'));
$flowType = in_array($flowType, ['income', 'expense'], true) ? $flowType : '';
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$repository = app(PinduoduoSpreadBillRepository::class);
$cache = $this->upstreamCaches->forSource('jiansun');
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'from' => $fromDate !== '' ? $fromDate : null,
'to' => $toDate !== '' ? $toDate : null,
'flow_type' => $flowType !== '' ? $flowType : null,
];
// 页面只读取共享查询快照;冷查询由统一限流和熔断链路访问上游。
$listResolver = fn () => $repository->paginate(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type'],
$page,
$pageSize
);
$summaryResolver = fn () => $repository->summary(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type']
);
$optionsResolver = fn () => $repository->storeOptions();
$usingMirror = $repository->usesBusinessMirror();
$result = $usingMirror
? $listResolver()
: $cache->remember(
'pinduoduo-bills.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$listResolver,
);
$summary = $usingMirror
? $summaryResolver()
: $cache->remember(
'pinduoduo-bills.summary',
['filters' => $filters],
$summaryResolver,
);
$storeOptions = $usingMirror
? $optionsResolver()
: $cache->remember(
'pinduoduo-bills.options',
[],
$optionsResolver,
);
// 包装成 Laravel 分页器,便于 Blade 用 $bills->links()
$bills = new LengthAwarePaginator(
Collection::make($result['rows']),
$result['total'],
$pageSize,
$page,
[
'path' => $request->url(),
'query' => $request->query(),
]
);
return [
'view' => 'banking.marketing_pinduoduo_bills.partials.embedded-table',
'bills' => $bills,
'filters' => [
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
'flow_type' => $flowType,
'store_options' => $storeOptions,
],
'summary' => $summary,
];
}
// 京东钱包 - 按附件表1分别展示充值账单和消耗账单,数据来自 JianSun 上游只读表。
if ($tab === 'jd-wallet') {
$request = request();
$ledger = $request->query('jd_ledger') === 'consumption' ? 'consumption' : 'recharge';
$storeName = mb_substr(trim((string) $request->query('store_name')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'from' => $fromDate !== '' ? $fromDate : null,
'to' => $toDate !== '' ? $toDate : null,
];
$repository = app(JdWalletBillRepository::class);
$cache = $this->upstreamCaches->forSource('jiansun');
$scopePrefix = 'jd-wallet-bills.' . $ledger;
$errors = [];
$usingMirror = $repository->usesBusinessMirror($ledger);
$remember = function (string $scope, array $parameters, callable $resolver, mixed $fallback) use ($cache, $usingMirror, &$errors): mixed {
try {
return $usingMirror ? $resolver() : $cache->remember($scope, $parameters, $resolver);
} catch (ServiceUnavailableHttpException $exception) {
$errors[] = $exception->getMessage();
} catch (Throwable $exception) {
report($exception);
$errors[] = '京东账单数据暂时无法读取,请稍后重试。';
}
return $fallback;
};
// 三项分别读取,确保生产冷启动时一次请求就能登记列表、汇总和选项,
// 同时保留已经命中的共享快照,不因另一项暂缺而清空整页。
$result = $remember(
$scopePrefix . '.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
fn () => $ledger === 'consumption'
? $repository->paginateConsumption($filters['store_name'], $filters['from'], $filters['to'], $page, $pageSize)
: $repository->paginateRecharge($filters['store_name'], $filters['from'], $filters['to'], $page, $pageSize),
['rows' => [], 'total' => 0],
);
$summary = $remember(
$scopePrefix . '.summary',
['filters' => $filters],
fn () => $ledger === 'consumption'
? $repository->summaryConsumption($filters['store_name'], $filters['from'], $filters['to'])
: $repository->summaryRecharge($filters['store_name'], $filters['from'], $filters['to']),
['amount_total' => 0, 'balance_total' => 0, 'count' => 0],
);
try {
$optionsResolver = fn () => $repository->shopOptions();
$shopOptions = $repository->usesBusinessMirrorForOptions()
? $optionsResolver()
: $cache->remember('jd-wallet-bills.options', [], $optionsResolver);
} catch (ServiceUnavailableHttpException $exception) {
$errors[] = $exception->getMessage();
$shopOptions = [];
} catch (Throwable $exception) {
report($exception);
$errors[] = '京东账单数据暂时无法读取,请稍后重试。';
$shopOptions = [];
}
return [
'view' => 'banking.marketing_jd_wallet_bills.partials.embedded-table',
'ledger' => $ledger,
'bills' => new LengthAwarePaginator(
Collection::make($result['rows'] ?? []),
(int) ($result['total'] ?? 0),
$pageSize,
$page,
['path' => $request->url(), 'query' => $request->query()],
),
'filters' => $filters,
'summary' => $summary,
'shopOptions' => $shopOptions,
'error' => $errors === []
? null
: implode(' ', array_values(array_unique(array_filter($errors)))),
];
}
return [
'view' => 'banking.marketing_account_bills.partials.embedded-table',
'platform' => $tab,
'accounts' => MarketingAccountBill::platform($tab)
->orderBy('bill_date', 'desc')
->orderBy('id', 'desc')
->paginate(setting('default.list_limit', 25)),
];
}
public function store(Request $request, string $section, string $page, ?string $tab = null)
{
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
$data = $request->validate($this->validationRules($descriptor));
if ($this->isStoreReceivingAccountPage($section, $page)) {
abort_unless(in_array($tab, ['taobao', 'jd', 'douyin', 'pinduoduo'], true), 404);
$allowedPlatforms = TableRegistry::tabValues($section, $page, $tab);
if (in_array($tab, ['douyin', 'pinduoduo'], true)) {
$data['store_platform'] = $allowedPlatforms[0];
} else {
abort_unless(in_array($data['store_platform'] ?? null, $allowedPlatforms, true), 422, '当前页签不支持该平台类型。');
}
} elseif ($descriptor->tabFilterColumn && $tab && ($tabValue = TableRegistry::tabValue($section, $page, $tab))) {
$data[$descriptor->tabFilterColumn] = $tabValue;
}
foreach (TableRegistry::fixedFilters($section, $page) as $column => $value) {
$data[$column] = $value;
}
($descriptor->model)::create($data);
return $this->redirectBack($section, $page, $tab);
}
public function import(
Request $request,
BusinessTableImporter $importer,
string $section,
string $page,
?string $tab = null,
) {
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'],
]);
if ($this->isStoreReceivingAccountPage($section, $page)) {
abort_unless(in_array($tab, ['taobao', 'jd', 'douyin', 'pinduoduo'], true), 404);
$result = app(StoreReceivingAccountWorkbookImporter::class)->import(
$request->file('import'),
$tab,
(int) (company_id() ?: 1),
);
} else {
$result = $importer->import($request->file('import'), $descriptor, $section, $page, $tab);
}
$message = "导入完成:新增 {$result['created']} 条,更新 {$result['updated']} 条。";
if (($result['skipped'] ?? 0) > 0) {
$message .= " 跳过 {$result['skipped']} 条空 ERP 店铺名称示例行。";
}
return $this->redirectBack($section, $page, $tab)->with('success', $message);
}
public function storeReceivingAccountTemplate(string $section, string $page, string $tab)
{
abort_unless($this->isStoreReceivingAccountPage($section, $page), 404);
abort_unless(in_array($tab, ['taobao', 'jd', 'douyin', 'pinduoduo'], true), 404);
return app(StoreReceivingAccountTemplate::class)->download($tab);
}
public function fundForecastTemplate(FundForecastTemplate $template): StreamedResponse
{
return $template->download();
}
public function update(Request $request, string $section, string $page, $id, ?string $tab = null)
{
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
$row = ($descriptor->model)::findOrFail($id);
$row->update($request->validate($this->validationRules($descriptor)));
return $this->redirectBack($section, $page, $tab);
}
public function destroy(string $section, string $page, $id, ?string $tab = null)
{
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
($descriptor->model)::findOrFail($id)->delete();
return $this->redirectBack($section, $page, $tab);
}
protected function validationRules($descriptor): array
{
$rules = [];
foreach ($descriptor->fields as $field) {
$rules[$field['name']] = match ($field['type']) {
'number', 'currency' => ['nullable', 'numeric'],
'boolean' => ['nullable', 'boolean'],
'date' => ['nullable', 'date'],
'select' => ['nullable', 'string', 'max:64'],
default => ['nullable', 'string', 'max:255'],
};
}
return $rules;
}
protected function redirectToPage(array $section, array $page)
{
$parameters = [
'section' => $section['slug'],
'page' => $page['slug'],
];
if (! empty($page['tabs'])) {
$parameters['tab'] = $page['tabs'][0]['slug'];
}
return redirect()->route('business.pages.show', $parameters);
}
private function dateFilter(mixed $value): string
{
$date = trim((string) $value);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
return '';
}
[$year, $month, $day] = array_map('intval', explode('-', $date));
return checkdate($month, $day, $year) ? $date : '';
}
private function decimalFilter(mixed $value): ?string
{
$value = trim((string) $value);
if ($value === '' || ! is_numeric($value)) {
return null;
}
return (string) (float) $value;
}
protected function redirectBack(string $section, string $page, ?string $tab)
{
$parameters = [
'company_id' => company_id() ?: 1,
'section' => $section,
'page' => $page,
];
if ($tab) {
$parameters['tab'] = $tab;
}
return redirect()->route('business.pages.show', $parameters)->with('success', true);
}
}