diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index e0e96ec9..3946330b 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -47,6 +47,7 @@ use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\View\BillOffsetPageData; use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository; use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository; +use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository; use Modules\EcommerceFinance\Repositories\ShippingOrderRepository; use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; @@ -241,6 +242,8 @@ class Pages extends Controller private readonly CourierSurchargeTypeRepository $courierSurchargeTypes, private readonly CourierSurchargeAuditLogRepository $courierSurchargeAudits, private readonly WithdrawalManagementReport $withdrawalManagementReport, + private readonly JushuitanShopBalanceRepository $jushuitanShopBalances, + private readonly CourierSurchargeSettlementMirrorRepository $courierSurchargeSettlements, private readonly ShippingOrderRepository $shippingOrders, ) { $this->middleware('auth'); @@ -3682,7 +3685,7 @@ class Pages extends Controller $data = $this->courierSurchargeQuotes->pageData($filters); $auditReady = $this->courierSurchargeAudits->ready(); - return [ + $content = [ 'view' => 'business.pages.courier-payables.courier-surcharge-workspace', 'courierSurchargeActiveTab' => $tab ?? 'quotes', 'courierSurchargeRows' => $data['rows'], @@ -3695,6 +3698,100 @@ class Pages extends Controller 'courierSurchargeTypeUsage' => $this->courierSurchargeTypes->usageCounts($data['options']['type_records'] ?? collect()), 'courierSurchargeAuditLogs' => $this->courierSurchargeAudits->recent(), ]; + + if ($tab === 'settlement') { + $content += $this->contentForCourierSurchargeSettlement(); + } + + return $content; + } + + /** @return array */ + 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 */ + 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')), + ]; } private function freightReconciliationFilters(): array diff --git a/config/business_data.php b/config/business_data.php index da05a165..5b85eb08 100644 --- a/config/business_data.php +++ b/config/business_data.php @@ -135,6 +135,12 @@ return [ 'jiansun.dbo.pm_unOrderDetails' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '未定价明细'], 'jiansun.dbo.finance_sampleDeduction' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '样品扣款'], 'jiansun.dbo.fys_express_courier_settlement' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '代发快递'], + 'jiansun.dbo.ekf_Bookkeeping' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], + 'jiansun.dbo.express_Original' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], + 'jiansun.dbo.ekf_BookkeepingConfig' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], + 'jiansun.dbo.ekf_rCheck_Piece' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], + 'jiansun.dbo.ekf_rCheckPieceConfig' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], + 'jiansun.dbo.ekf_ReturnPickupManagement' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], 'jiansun.dbo.jst_order_express_details' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账物流明细'], 'jiansun.dbo.Jst_SendData_View_extension' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账商品与收货区域'], 'jiansun.dbo.jst_salesOutboundInfo' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账出库运费'], diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index 5cbe08e6..95579053 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -167,6 +167,15 @@ return [ 'stale_after_minutes' => (int) env('RETURN_PICKUP_MIRROR_STALE_MINUTES', 180), ], + // 快递附加费结算要求所有必需镜像都完成首次同步;页面按最旧数据集 + // 的成功时间判断新鲜度,不把尚未核对的审核文本扩大为已通过数据。 + 'courier_surcharge_settlement' => [ + 'stale_after_minutes' => (int) env('COURIER_SURCHARGE_SETTLEMENT_MIRROR_STALE_MINUTES', 60), + // 聚水潭现有审核文本使用“同意”和“同意记账”;“通过”保留给 + // 后续上游版本,不能把任意非空文本当作审核通过。 + 'approved_values' => ['通过', '同意', '同意记账'], + ], + // 工作平台采购价格按 SKU 补充到普通商品资料;页面只读取共享镜像。 'work_platform_prices' => [ 'dataset_key' => 'jiansun.dbo.pm_priceAdjustment', @@ -241,6 +250,60 @@ return [ 'indexes' => [['scmname', 'Id']], ], // 经营分析副本库:组织资料、订单、退款和聚水潭基础数据。 + // 快递附加费结算:只同步上游审核字段已核对的“通过/同意/同意记账”明细; + // 页面通过共享镜像读取,不在请求中直连聚水潭。 + 'jiansun.dbo.ekf_Bookkeeping' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_Bookkeeping', + 'target' => 'js_ekf_bookkeeping_surcharge', 'keys' => ['Id'], 'watermark' => 'CreationTime', + 'columns' => [ + 'Id', 'CreationTime', 'OrderNum', 'ExpressNum', 'ExpressName', 'ShopCode', + 'Province', 'City', 'District', 'Address', 'SupplierName', 'SkuName', 'SkuCode', 'Result', 'UserName', + 'Payfee', 'SecodaryCause', 'ReasonId', 'State', 'Delete', 'IsAgreeReason', + 'ProcessingState', 'BookkeepingName', 'PayTime', 'Svolume', 'Skuweight', + 'IfReturn', 'IfBuckle', 'FuZhuMain', 'FuZhuMinor', 'ShipExpressName', + 'expressCause', 'receivingDepartment', 'returnCost', 'shopproid', + ], + 'fixed_filters' => ['IsAgreeReason' => ['通过', '同意', '同意记账']], + 'indexes' => [['ExpressNum', 'Id'], ['OrderNum', 'Id'], ['shopproid', 'Id'], ['CreationTime', 'Id']], + ], + 'jiansun.dbo.express_Original' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'express_Original', + 'target' => 'js_express_original', 'keys' => ['Id'], 'watermark' => 'DeliveryTime', + 'columns' => [ + 'Id', 'ExpressName', 'OrderNum', 'DeliveryTime', 'Weight', 'TotalFreight', + 'Lenght', 'Width', 'Height', 'Volume', 'SkuCode', 'JiaoYiNum', 'SkuName', + 'ShopName', 'SetMoney', 'SetWeight', 'ProCode', 'Reason', 'shopproid', 'paytime', + ], + 'indexes' => [['OrderNum', 'Id'], ['JiaoYiNum', 'Id'], ['shopproid', 'Id']], + ], + 'jiansun.dbo.ekf_BookkeepingConfig' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_BookkeepingConfig', + 'target' => 'js_ekf_bookkeeping_config', 'keys' => ['Id'], 'watermark' => 'CreateTime', + 'columns' => ['Id', 'Name', 'ParentId', 'CreateTime'], + 'indexes' => [['ParentId', 'Id']], + ], + 'jiansun.dbo.ekf_rCheck_Piece' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheck_Piece', + 'target' => 'js_ekf_rcheck_piece', 'keys' => ['Id'], 'watermark' => 'UpdateTime', + 'columns' => [ + 'Id', 'CheckType', 'ExpressNum', 'OrderNum', 'CheckSDType', 'CheckReason', + 'State', 'Accounting', 'ReturnState', 'CreationTime', 'UpdateTime', 'Delete', + 'OmsSDName', 'DHLName', 'SkuCode', 'shopproid', + ], + 'indexes' => [['ExpressNum', 'OrderNum', 'Id'], ['shopproid', 'Id']], + ], + 'jiansun.dbo.ekf_rCheckPieceConfig' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheckPieceConfig', + 'target' => 'js_ekf_rcheck_piece_config', 'keys' => ['Id'], 'watermark' => 'CreateTime', + 'columns' => ['Id', 'ParentId', 'Name', 'Code', 'Type', 'Delete', 'CreateTime'], + 'indexes' => [['ParentId', 'Id']], + ], + 'jiansun.dbo.ekf_ReturnPickupManagement' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_ReturnPickupManagement', + 'target' => 'js_ekf_return_pickup_management', 'keys' => ['Id'], 'watermark' => 'updateTime', + 'columns' => ['Id', 'orderId', 'returnExpressNo', 'transactionNo', 'updateTime'], + 'indexes' => [['transactionNo', 'Id'], ['orderId', 'Id'], ['returnExpressNo', 'Id']], + ], 'operations_replica.rpt.fys_companyInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_companyInfo', 'target' => 'ops_fys_company_info', 'keys' => ['Id'], 'watermark' => 'updateTIme'], 'operations_replica.rpt.fys_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_shopInfo', 'target' => 'ops_fys_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], 'operations_replica.rpt.fys_TrademarkInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_TrademarkInfo', 'target' => 'ops_fys_trademark_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], @@ -642,7 +705,10 @@ return [ 'jiansun.dbo.jst_order_express_details' => [ 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_order_express_details', 'target' => 'js_jst_order_express_details', 'keys' => ['id'], 'watermark' => 'updatedAt', - 'indexes' => [['send_time', 'id'], ['inner_order_id', 'id'], ['id', 'logistic_id']], + 'indexes' => [ + ['send_time', 'id'], ['inner_order_id', 'id'], ['id', 'logistic_id'], + ['logistic_id', 'so_id(64)', 'id'], ['logistic_id', 'id'], + ], 'columns' => [ 'id', 'updatedAt', 'inner_order_id', 'so_id', 'logistic_id', 'logistic_company_name', 'logistic_status_name', 'platform_name', 'shop_name', 'warehouse_name', 'send_time', diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 587ff703..c2d7991f 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -17,6 +17,7 @@ | 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价。线上代发价格页面另读取 `Confirmed` / `Finished` 采购明细,以 `source_o_id` 关联 `ops_jst_orders.o_id`,展示线上单号、店铺、供应商、商品编码、数量、单价并标记“采购单管理”;页面请求不直连上游。 | | 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 | | 本次接入 | 工作平台“客服系统 → 退款数据 → 退货取件管理”读取 `jiansun/dbo.ekf_ReturnPickupManagement`(主表)、`dbo.ekf_ReturnPickupManagement_Item`(明细分录)与 `dbo.ekf_ReturnPickupManagement_TruthReExpressNumber`(实退快递单号映射),分别镜像为 `js_ekf_return_pickup_management`、`js_ekf_return_pickup_management_item`、`js_ekf_return_pickup_truth_express`;主表按 `Id` + `updateTime`、明细按 `id` + `updatedTime`、映射按 `Id` + `createTime` 增量,逆向物流运费核算与退件跟踪只读共享镜像,不在页面请求中直连工作平台。2026-10-02 已完成首次全量(296,707 / 378,705 / 173,346 行)。 明细 `warehouse` 是混合编码:4 位工作平台仓码(`dbo.wp_warehouse.code` / `dbo.wp_warehouseConfig.whCode`)镜像为 `js_wp_warehouse`(992 行,`id` + `modified`)与 `js_wp_warehouse_config`(132 行,无更新时间按主键全量校准),8 位聚水潭 `wms_co_id` 继续读 `ops_jst_warehouse_info`;页面与导出按名称展示,未命中时保留原始编码。 | +| 本次接入 | 快递附加费结算读取 `jiansun.dbo.ekf_Bookkeeping` 中审核字段为固定白名单“通过/同意/同意记账”的明细,并关联 `jiansun.dbo.express_Original` 的物流公司、尺寸、商品、订单和结算字段,以及记账原因、查件配置、物流明细、发货扩展、出库重量和退件管理镜像;查件类型/查件原因只取 `ekf_rCheckPieceConfig` 的一级/二级选项,记账次要原因不作回退。页面固定限制九种附加费类型,按附加费报价维护匹配并计算。首次同步完成前只显示镜像状态,不把空结果当成无数据。当前上游严格值“通过”没有记录,“同意/同意记账”是否等价于审核通过仍需业务确认;实际样本只直接证明退回/拦截标记,其余类型依赖原因文本关键词,正式上线前需补充类型字段口径。 | | 本次接入 | 聚水潭聚工单协同工单登记 `work_order_main`、`work_order_sub`、`work_order_form_config`、`work_order_form_value` 四张表;工单 API 固定筛选类型“仓内未发”,首次同步完成前返回同步未完成状态。 | | 待首次同步 | 聚水潭聚工单“小额打款”读取 `jiansun.dbo.jst_workOrderInfo` 与已解析映射 `dbo.jst_small_payment_order_map`;财务经营报表的小额打款页面固定筛选工单类型 `82D94F5C54544EE3833CD8A3AB8B9F73`,页面只读共享镜像,首次同步完成前明确显示待同步状态。 | | 本次建立 | `config/upstream_mirrors.php` 中登记全部当前代码引用的原表;通用表级同步框架负责建表、稳定游标、断点续传、增量重叠和全量校准。 | @@ -77,6 +78,7 @@ | 采购单价 | `dbo.jst_purchase`、`dbo.jst_purchaseItem`(受控字段联表镜像,不复制其他采购业务字段) | | 工作平台采购价格 | `dbo.pm_priceAdjustment`(`Id` 稳定键;按 `skuCode` 匹配 `jst_sku.sku_id`,页面取当前已生效的最新 `purchasePrice` / `effectTime`) | | 快递资料 | `dbo.wp_express`、`dbo.wp_express_expNet`、`dbo.wp_express_account`、`dbo.wp_express_extension` | +| 快递附加费结算 | `dbo.ekf_Bookkeeping`、`dbo.express_Original`、`dbo.ekf_BookkeepingConfig`、`dbo.ekf_rCheck_Piece`、`dbo.ekf_rCheckPieceConfig`、既有物流镜像 `dbo.jst_order_express_details` / `dbo.Jst_SendData_View_extension` / `dbo.jst_salesOutboundInfo`、`dbo.ekf_ReturnPickupManagement`(审核字段固定为“通过/同意/同意记账”;查件原因只读二级选项;页面只读 `caiwu_business_mirror`) | | 聚水潭小额打款工单 | `dbo.jst_workOrderInfo`(稳定键 `Id`、增量字段 `updateTime`)与 `dbo.jst_small_payment_order_map`(稳定键 `woNo`、增量字段 `updated_at`) | | 拼多多货款账户账单 | `dbo.jst_originalBillInfo`(固定 `channel = PinduoduoImport`,镜像 `js_jst_pdd_receiving_bill_rows`) | | 得物完整账户账单 | 聚胜算平台原始账单(新)XLSX(受控导入 `dewu_settlement_bill_rows`;保留得物、退货退款订单、扣减其他费用明细) | diff --git a/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php b/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php new file mode 100644 index 00000000..0707bfd7 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php @@ -0,0 +1,611 @@ + */ + private const REQUIRED_DATASETS = [ + self::PRIMARY_DATASET, + self::EXPRESS_ORIGINAL_DATASET, + self::BOOKKEEPING_CONFIG_DATASET, + self::CHECK_DATASET, + self::CHECK_CONFIG_DATASET, + self::SHIPPING_DETAILS_DATASET, + self::SHIPPING_EXTENSION_DATASET, + self::OUTBOUND_DATASET, + self::RETURN_DATASET, + ]; + + /** @var list */ + public const SURCHARGE_TYPES = [ + '退回费', '拦截费', '改地址', '上楼费', '送货费', '超区费', '安装费', '保费', '进仓费', + ]; + + /** + * 上游审核字段是自由文本;只有这些明确的已批准值允许进入页面。 + * 其它说明、退回原因、空值都不能被当作审核通过。 + * + * @var list + */ + private const APPROVED_VALUES = ['通过', '同意', '同意记账']; + + private readonly ConnectionInterface $connection; + + public function __construct( + DatabaseManager $database, + private readonly BusinessMirrorDatasetRegistry $datasets, + private readonly GenericBusinessMirrorRepository $mirror, + ) { + $this->connection = $database->connection((string) config('upstream_mirrors.connection', 'business_mirror')); + } + + public function available(): bool + { + $schema = Schema::connection($this->connection->getName()); + + if (! $schema->hasTable('business_mirror_table_states')) { + return false; + } + + foreach (self::REQUIRED_DATASETS as $key) { + if (! $schema->hasTable($this->table($key))) { + return false; + } + } + + return true; + } + + public function ready(): bool + { + foreach (self::REQUIRED_DATASETS as $key) { + $dataset = $this->datasets->get($key); + if (! $this->mirror->ready($dataset)) { + return false; + } + } + + return true; + } + + /** @return array */ + public function state(): array + { + $states = []; + foreach (self::REQUIRED_DATASETS as $key) { + $dataset = $this->datasets->get($key); + $states[$key] = $this->mirror->state($dataset); + } + + $required = array_intersect_key($states, array_flip(self::REQUIRED_DATASETS)); + $failed = collect($required)->first(fn (?array $state): bool => ($state['status'] ?? null) === 'failed'); + $pending = collect($required)->first(fn (?array $state): bool => ($state['initial_completed_at'] ?? null) === null); + $running = collect($required)->first(fn (?array $state): bool => ($state['status'] ?? null) === 'running'); + // The page is only as fresh as its oldest required dataset. Using the + // newest timestamp here would hide a lagging lookup table behind a + // recently completed fact-table sync. + $lastSucceeded = collect($required) + ->map(fn (?array $state): ?string => $state['last_succeeded_at'] ?? null) + ->filter() + ->sort() + ->first(); + $staleMinutes = max((int) config('upstream_mirrors.courier_surcharge_settlement.stale_after_minutes', 60), 1); + $code = $failed !== null + ? 'failed' + : ($pending !== null ? 'pending' : ($running !== null ? 'syncing' : 'fresh')); + if ($code === 'fresh' && $lastSucceeded !== null && now()->diffInMinutes($lastSucceeded) > $staleMinutes) { + $code = 'stale'; + } + + return [ + 'code' => $code, + 'last_succeeded_at' => $lastSucceeded, + 'failure_stage' => $failed === null ? null : $this->datasetKeyForState($required, $failed), + 'error_code' => $failed['last_error_code'] ?? null, + 'required' => $required, + ]; + } + + /** + * @param array $filters + * @param Collection $quotes + * @return array{data:Collection>,has_more:bool,total:int,summary:array,sync:array,state:array} + */ + public function paginate(array $filters, int $page, int $limit, Collection $quotes): array + { + $state = $this->state(); + if (! $this->ready()) { + return [ + 'data' => collect(), 'has_more' => false, 'total' => 0, + 'summary' => ['count' => 0, 'settlement_amount' => 0.0], + 'sync' => $state, 'state' => $state, + ]; + } + + $query = $this->baseQuery(); + $this->applyFilters($query, $filters); + $limit = min(max($limit, 1), 100); + if (($filters['status'] ?? '') !== '') { + // 结算状态来自本地报价匹配,不能下推到镜像 SQL。按稳定顺序 + // 流式读取并只保留当前页,避免大镜像表一次性 get() 造成内存峰值。 + $offset = (max($page, 1) - 1) * $limit; + $matched = collect(); + $total = 0; + foreach ($query->orderBy('b.CreationTime')->orderBy('b.Id')->cursor() as $sourceRow) { + $row = $this->mapRow($sourceRow, $quotes); + if ($row['settlement_status'] !== $filters['status']) { + continue; + } + + if ($total >= $offset && $matched->count() < $limit) { + $matched->push($row); + } + $total++; + } + $rows = $matched; + } else { + $total = (clone $query)->count('b.Id'); + $rows = $query + ->orderBy('b.CreationTime') + ->orderBy('b.Id') + ->forPage(max($page, 1), $limit + 1) + ->get() + ->map(fn (object $row): array => $this->mapRow($row, $quotes)) + ->values(); + } + $hasMore = ($filters['status'] ?? '') !== '' + ? $total > ((max($page, 1) - 1) * $limit) + $limit + : $rows->count() > $limit; + $rows = $rows->take($limit)->values(); + + return [ + 'data' => $rows, + 'has_more' => $hasMore, + 'total' => $total, + 'summary' => [ + 'count' => $total, + 'settlement_amount' => round((float) $rows->sum(fn (array $row): float => (float) ($row['settlement_amount'] ?? 0)), 2), + ], + 'sync' => $state, + 'state' => $state, + ]; + } + + private function baseQuery(): Builder + { + $bookkeeping = $this->table(self::PRIMARY_DATASET); + $bookkeepingConfig = $this->table(self::BOOKKEEPING_CONFIG_DATASET); + $check = $this->table(self::CHECK_DATASET); + $checkConfig = $this->table(self::CHECK_CONFIG_DATASET); + + $query = $this->connection->table($bookkeeping . ' as b') + ->leftJoin($bookkeepingConfig . ' as bm', 'bm.Id', '=', 'b.ReasonId') + ->leftJoin($bookkeepingConfig . ' as bs', 'bs.Id', '=', 'b.SecodaryCause') + ->leftJoinSub($this->latestCheck(), 'r', function ($join): void { + $join->on('r.ExpressNum', '=', 'b.ExpressNum') + ->on('r.OrderNum', '=', 'b.OrderNum'); + }) + ->leftJoin($checkConfig . ' as rt', 'rt.Id', '=', 'r.CheckType') + ->leftJoin($checkConfig . ' as rr', function ($join): void { + $join->on('rr.Id', '=', 'r.CheckReason') + // 查件原因必须是所选查件类型的二级选项,避免同名配置串层。 + ->on('rr.ParentId', '=', 'r.CheckType'); + }); + + // 镜像同步使用同一固定条件;这里再次约束,避免旧镜像中残留未审核行。 + $query->whereIn('b.IsAgreeReason', $this->approvedValues()); + + // 物流公司、商品、店铺、尺寸和重量复用已经上线的物流业务镜像。 + // 先按单号+线上订单号精确关联,再用单号的最新记录补齐历史数据, + // 避免新增依赖旧的 fys_omsDataDay 快照表。 + $query->leftJoinSub($this->latestShippingDetailByOrder(), 'd', function ($join): void { + $join->on('d.logistic_id', '=', 'b.ExpressNum') + ->on('d.so_id', '=', 'b.OrderNum'); + })->leftJoinSub($this->latestShippingDetailByWaybill(), 'd0', function ($join): void { + $join->on('d0.logistic_id', '=', 'b.ExpressNum'); + })->leftJoinSub($this->latestShippingExtension(), 'x', function ($join): void { + $join->on('x.so_id', '=', 'b.OrderNum'); + })->leftJoinSub($this->latestOutbound(), 'o', function ($join): void { + $join->on('o.io_id', '=', 'x.io_id'); + })->leftJoinSub($this->latestExpressOriginal(), 'e', function ($join): void { + // express_Original.OrderNum 是附加费记录的物流单号;保留记账表 + // 字段作为后备,避免历史记录没有对应原始物流行时整行消失。 + $join->on('e.OrderNum', '=', 'b.ExpressNum'); + }); + + if ($this->hasTable(self::RETURN_DATASET)) { + $query->leftJoinSub($this->latestReturn(), 'ret', function ($join): void { + $join->where(function ($condition): void { + $condition->on('ret.transactionNo', '=', 'b.OrderNum') + ->orOn('ret.orderId', '=', 'b.OrderNum') + ->orOn('ret.returnExpressNo', '=', 'b.ExpressNum'); + }); + }); + } + + $type = $this->surchargeTypeExpression(); + $select = [ + 'b.Id as source_id', 'b.CreationTime as created_at', + $this->connection->raw("COALESCE(NULLIF(TRIM(r.OmsSDName), ''), NULLIF(TRIM(e.ExpressName), ''), NULLIF(TRIM(d.logistic_company_name), ''), NULLIF(TRIM(d0.logistic_company_name), ''), NULLIF(TRIM(b.ExpressName), ''), b.ShipExpressName) as courier_name"), + $this->connection->raw("COALESCE(NULLIF(TRIM(e.OrderNum), ''), b.ExpressNum) as waybill"), + $this->connection->raw("COALESCE(NULLIF(TRIM(e.shopproid), ''), NULLIF(TRIM(b.shopproid), ''), NULLIF(TRIM(r.shopproid), ''), NULLIF(TRIM(x.shop_i_id), '')) as platform_product_id"), + $this->connection->raw("COALESCE(NULLIF(TRIM(e.SkuCode), ''), NULLIF(TRIM(b.SkuCode), ''), NULLIF(TRIM(r.SkuCode), ''), NULLIF(TRIM(x.sku_ids), '')) as product_code"), + $this->connection->raw("COALESCE(NULLIF(TRIM(e.ShopName), ''), NULLIF(TRIM(x.shop_name), ''), NULLIF(TRIM(d.shop_name), ''), NULLIF(TRIM(d0.shop_name), ''), b.ShopCode) as shop_name"), + $this->connection->raw("COALESCE(NULLIF(TRIM(e.JiaoYiNum), ''), b.OrderNum) as online_order_no"), + $this->connection->raw("COALESCE(e.Lenght, x.l) as length"), + $this->connection->raw("COALESCE(e.Width, x.w) as width"), + $this->connection->raw("COALESCE(e.Height, x.h) as height"), + $this->connection->raw("COALESCE(e.Volume, NULLIF(o.f_volume, ''), NULLIF(o.volume, ''), NULLIF(x.volume, ''), b.Svolume) as volume"), + $this->connection->raw('COALESCE(rt.Name, \'\') as inspection_type'), + // “查件原因”必须来自查件配置的二级选项;记账次要原因 + // 不是同一业务字段,不能在这里兜底混入。 + $this->connection->raw("COALESCE(rr.Name, '') as inspection_reason"), + $this->connection->raw('ret.returnExpressNo as return_waybill_no'), + $this->connection->raw("COALESCE(e.SetWeight, NULLIF(o.f_weight, ''), NULLIF(o.weight, ''), NULLIF(d.f_weight, ''), NULLIF(d0.f_weight, ''), NULLIF(x.weight, ''), b.Skuweight) as settlement_weight"), + $this->connection->raw("COALESCE(e.SetMoney, b.Payfee) as source_amount"), + $this->connection->raw("{$type} as surcharge_type"), + 'b.IsAgreeReason as approval_status', 'bm.Name as primary_reason', 'bs.Name as secondary_reason', + ]; + + return $query->select($select); + } + + private function latestCheck(): Builder + { + $table = $this->table(self::CHECK_DATASET); + + return $this->connection->table($table . ' as candidate') + ->whereNotExists(function ($query) use ($table): void { + $query->selectRaw('1')->from($table . ' as newer') + ->whereColumn('newer.ExpressNum', '=', 'candidate.ExpressNum') + ->whereColumn('newer.OrderNum', '=', 'candidate.OrderNum') + ->whereColumn('newer.Id', '>', 'candidate.Id'); + }) + ->select([ + 'candidate.ExpressNum', 'candidate.OrderNum', 'candidate.CheckType', 'candidate.CheckReason', + 'candidate.OmsSDName', 'candidate.DHLName', 'candidate.SkuCode', 'candidate.shopproid', + ]); + } + + private function latestExpressOriginal(): Builder + { + $table = $this->table(self::EXPRESS_ORIGINAL_DATASET); + + return $this->connection->table($table . ' as candidate') + ->whereNotExists(function ($query) use ($table): void { + $query->selectRaw('1')->from($table . ' as newer') + ->whereColumn('newer.OrderNum', '=', 'candidate.OrderNum') + ->whereColumn('newer.Id', '>', 'candidate.Id'); + }) + ->select([ + 'candidate.Id', 'candidate.ExpressName', 'candidate.OrderNum', 'candidate.Lenght', + 'candidate.Width', 'candidate.Height', 'candidate.Volume', 'candidate.SkuCode', + 'candidate.JiaoYiNum', 'candidate.ShopName', 'candidate.SetMoney', 'candidate.SetWeight', + 'candidate.shopproid', + ]); + } + + private function latestShippingDetailByOrder(): Builder + { + $table = $this->table(self::SHIPPING_DETAILS_DATASET); + + return $this->connection->table($table . ' as candidate') + ->whereNotExists(function ($query) use ($table): void { + $query->selectRaw('1')->from($table . ' as newer') + ->whereColumn('newer.logistic_id', '=', 'candidate.logistic_id') + ->whereColumn('newer.so_id', '=', 'candidate.so_id') + ->whereColumn('newer.id', '>', 'candidate.id'); + }) + ->select(['candidate.id', 'candidate.logistic_id', 'candidate.so_id', 'candidate.logistic_company_name', 'candidate.shop_name', 'candidate.f_weight']); + } + + private function latestShippingDetailByWaybill(): Builder + { + $table = $this->table(self::SHIPPING_DETAILS_DATASET); + + return $this->connection->table($table . ' as candidate') + ->whereNotExists(function ($query) use ($table): void { + $query->selectRaw('1')->from($table . ' as newer') + ->whereColumn('newer.logistic_id', '=', 'candidate.logistic_id') + ->whereColumn('newer.id', '>', 'candidate.id'); + }) + ->select(['candidate.id', 'candidate.logistic_id', 'candidate.logistic_company_name', 'candidate.shop_name', 'candidate.f_weight']); + } + + private function latestShippingExtension(): Builder + { + $table = $this->table(self::SHIPPING_EXTENSION_DATASET); + + return $this->connection->table($table . ' as candidate') + ->whereNotExists(function ($query) use ($table): void { + $query->selectRaw('1')->from($table . ' as newer') + ->whereColumn('newer.so_id', '=', 'candidate.so_id') + ->whereColumn('newer.Id', '>', 'candidate.Id'); + }) + ->select(['candidate.Id', 'candidate.io_id', 'candidate.so_id', 'candidate.shop_i_id', 'candidate.sku_ids', 'candidate.shop_name', 'candidate.l', 'candidate.w', 'candidate.h', 'candidate.volume', 'candidate.weight']); + } + + private function latestOutbound(): Builder + { + $table = $this->table(self::OUTBOUND_DATASET); + + return $this->connection->table($table . ' as candidate') + ->whereNotExists(function ($query) use ($table): void { + $query->selectRaw('1')->from($table . ' as newer') + ->whereColumn('newer.io_id', '=', 'candidate.io_id') + ->whereColumn('newer.Id', '>', 'candidate.Id'); + }) + ->select(['candidate.Id', 'candidate.io_id', 'candidate.f_weight', 'candidate.weight', 'candidate.f_volume', 'candidate.volume']); + } + + private function latestReturn(): Builder + { + $table = $this->table(self::RETURN_DATASET); + + return $this->connection->table($table . ' as candidate') + ->whereNotExists(function ($query) use ($table): void { + $query->selectRaw('1')->from($table . ' as newer') + ->where(function ($condition): void { + $condition->whereColumn('newer.transactionNo', '=', 'candidate.transactionNo') + ->orWhereColumn('newer.orderId', '=', 'candidate.orderId') + ->orWhereColumn('newer.returnExpressNo', '=', 'candidate.returnExpressNo'); + }) + ->whereColumn('newer.Id', '>', 'candidate.Id'); + }) + ->select(['candidate.orderId', 'candidate.transactionNo', 'candidate.returnExpressNo']); + } + + /** @param array $filters */ + private function applyFilters(Builder $query, array $filters): void + { + if (($filters['courier_name'] ?? '') !== '') { + $courier = trim((string) $filters['courier_name']); + $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $courier) . '%'; + $query->whereRaw("COALESCE(NULLIF(TRIM(r.OmsSDName), ''), NULLIF(TRIM(e.ExpressName), ''), NULLIF(TRIM(d.logistic_company_name), ''), NULLIF(TRIM(d0.logistic_company_name), ''), NULLIF(TRIM(b.ExpressName), ''), b.ShipExpressName) LIKE ? ESCAPE '!'", [$needle]); + } + $waybillColumn = "COALESCE(NULLIF(TRIM(e.OrderNum), ''), b.ExpressNum)"; + if (($filters['waybill'] ?? '') !== '') { + $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['waybill'])) . '%'; + $query->whereRaw($waybillColumn . " LIKE ? ESCAPE '!'", [$needle]); + } + $onlineOrderColumn = "COALESCE(NULLIF(TRIM(e.JiaoYiNum), ''), b.OrderNum)"; + if (($filters['online_order_no'] ?? '') !== '') { + $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['online_order_no'])) . '%'; + $query->whereRaw($onlineOrderColumn . " LIKE ? ESCAPE '!'", [$needle]); + } + $platformProductColumn = "COALESCE(NULLIF(TRIM(e.shopproid), ''), NULLIF(TRIM(b.shopproid), ''), NULLIF(TRIM(r.shopproid), ''), NULLIF(TRIM(x.shop_i_id), ''))"; + $productCodeColumn = "COALESCE(NULLIF(TRIM(e.SkuCode), ''), NULLIF(TRIM(b.SkuCode), ''), NULLIF(TRIM(r.SkuCode), ''), NULLIF(TRIM(x.sku_ids), ''))"; + if (($filters['platform_product_id'] ?? '') !== '') { + $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['platform_product_id'])) . '%'; + $query->whereRaw($platformProductColumn . " LIKE ? ESCAPE '!'", [$needle]); + } + if (($filters['product_code'] ?? '') !== '') { + $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['product_code'])) . '%'; + $query->whereRaw($productCodeColumn . " LIKE ? ESCAPE '!'", [$needle]); + } + if (($filters['shop_name'] ?? '') !== '') { + $shop = trim((string) $filters['shop_name']); + $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $shop) . '%'; + $shopColumn = "COALESCE(NULLIF(TRIM(e.ShopName), ''), NULLIF(TRIM(x.shop_name), ''), NULLIF(TRIM(d.shop_name), ''), NULLIF(TRIM(d0.shop_name), ''), b.ShopCode)"; + $query->whereRaw($shopColumn . " LIKE ? ESCAPE '!'", [$needle]); + } + if (($filters['created_from'] ?? '') !== '') { + $query->where('b.CreationTime', '>=', $filters['created_from'] . ' 00:00:00'); + } + $type = $this->surchargeTypeExpression(); + if (($filters['surcharge_type'] ?? '') !== '') $query->whereRaw("{$type} = ?", [$filters['surcharge_type']]); + if (($filters['inspection_type'] ?? '') !== '') $query->where('rt.Name', '=', $filters['inspection_type']); + if (($filters['inspection_reason'] ?? '') !== '') $query->where('rr.Name', '=', $filters['inspection_reason']); + if (($filters['created_to'] ?? '') !== '') $query->where('b.CreationTime', '<', date('Y-m-d 00:00:00', strtotime($filters['created_to'] . ' +1 day'))); + $query->whereRaw("{$type} IN (?, ?, ?, ?, ?, ?, ?, ?, ?)", self::SURCHARGE_TYPES); + } + + private function surchargeTypeExpression(): string + { + return "CASE + WHEN LOWER(LTRIM(RTRIM(COALESCE(b.IfReturn, '')))) IN ('已退回', '1', '是', 'true') THEN '退回费' + WHEN LOWER(LTRIM(RTRIM(COALESCE(b.IfBuckle, '')))) IN ('已扣单', '1', '是', 'true') THEN '拦截费' + WHEN COALESCE(bm.Name, '') LIKE '%退回%' OR COALESCE(bs.Name, '') LIKE '%退回%' + OR COALESCE(bm.Name, '') LIKE '%退货%' OR COALESCE(bs.Name, '') LIKE '%退货%' + OR COALESCE(rt.Name, '') LIKE '%退回%' OR COALESCE(rr.Name, '') LIKE '%退回%' THEN '退回费' + WHEN COALESCE(bm.Name, '') LIKE '%拦截%' OR COALESCE(bs.Name, '') LIKE '%拦截%' + OR COALESCE(b.expressCause, '') LIKE '%拦截%' + OR COALESCE(rt.Name, '') LIKE '%拦截%' OR COALESCE(rr.Name, '') LIKE '%拦截%' + OR COALESCE(rt.Name, '') LIKE '%扣单%' OR COALESCE(rr.Name, '') LIKE '%扣单%' THEN '拦截费' + WHEN COALESCE(bm.Name, '') LIKE '%改地址%' OR COALESCE(bs.Name, '') LIKE '%改地址%' + OR COALESCE(b.expressCause, '') LIKE '%改地址%' + OR COALESCE(rt.Name, '') LIKE '%改地址%' OR COALESCE(rr.Name, '') LIKE '%改地址%' THEN '改地址' + WHEN COALESCE(bm.Name, '') LIKE '%上楼%' OR COALESCE(bs.Name, '') LIKE '%上楼%' + OR COALESCE(b.expressCause, '') LIKE '%上楼%' + OR COALESCE(rt.Name, '') LIKE '%上楼%' OR COALESCE(rr.Name, '') LIKE '%上楼%' THEN '上楼费' + WHEN COALESCE(bm.Name, '') LIKE '%送货%' OR COALESCE(bs.Name, '') LIKE '%送货%' + OR COALESCE(b.expressCause, '') LIKE '%送货%' + OR COALESCE(rt.Name, '') LIKE '%送货%' OR COALESCE(rr.Name, '') LIKE '%送货%' THEN '送货费' + WHEN COALESCE(bm.Name, '') LIKE '%超区%' OR COALESCE(bs.Name, '') LIKE '%超区%' + OR COALESCE(b.expressCause, '') LIKE '%超区%' + OR COALESCE(rt.Name, '') LIKE '%超区%' OR COALESCE(rr.Name, '') LIKE '%超区%' THEN '超区费' + WHEN COALESCE(bm.Name, '') LIKE '%安装%' OR COALESCE(bs.Name, '') LIKE '%安装%' + OR COALESCE(b.expressCause, '') LIKE '%安装%' + OR COALESCE(rt.Name, '') LIKE '%安装%' OR COALESCE(rr.Name, '') LIKE '%安装%' THEN '安装费' + WHEN COALESCE(bm.Name, '') LIKE '%保费%' OR COALESCE(bs.Name, '') LIKE '%保费%' + OR COALESCE(bm.Name, '') LIKE '%保丢保损%' OR COALESCE(bs.Name, '') LIKE '%保丢保损%' + OR COALESCE(b.expressCause, '') LIKE '%保费%' + OR COALESCE(rt.Name, '') LIKE '%保费%' OR COALESCE(rr.Name, '') LIKE '%保费%' THEN '保费' + WHEN COALESCE(bm.Name, '') LIKE '%进仓%' OR COALESCE(bs.Name, '') LIKE '%进仓%' + OR COALESCE(b.expressCause, '') LIKE '%进仓%' + OR COALESCE(rt.Name, '') LIKE '%进仓%' OR COALESCE(rr.Name, '') LIKE '%进仓%' THEN '进仓费' + ELSE NULL END"; + } + + /** @return list */ + private function approvedValues(): array + { + $configured = (array) config('upstream_mirrors.courier_surcharge_settlement.approved_values', self::APPROVED_VALUES); + $values = array_values(array_filter(array_map('strval', $configured), static fn (string $value): bool => trim($value) !== '')); + + return $values === [] ? self::APPROVED_VALUES : $values; + } + + /** @param Collection|CourierSurchargeQuote> $quotes */ + private function mapRow(object $row, Collection $quotes): array + { + $type = trim((string) ($row->surcharge_type ?? '')); + $quote = $quotes->first(function (array|CourierSurchargeQuote $quote) use ($row, $type): bool { + $status = is_array($quote) ? ($quote['status_code'] ?? $quote['status'] ?? null) : $quote->status; + $courier = is_array($quote) ? ($quote['courier'] ?? '') : $quote->courier; + $types = is_array($quote) ? ($quote['types'] ?? []) : $quote->types; + + return $status === CourierSurchargeQuote::STATUS_ACTIVE + && (string) $courier === (string) ($row->courier_name ?? '') + && in_array($type, (array) $types, true); + }); + $weight = $this->number($row->settlement_weight ?? null); + $volume = $this->number($row->volume ?? null); + $length = $this->number($row->length ?? null); + $width = $this->number($row->width ?? null); + $height = $this->number($row->height ?? null); + $amount = $quote === null ? null : $this->calculate($quote, $weight, $volume, $row->source_amount ?? null, $length, $width, $height); + + return [ + 'source_id' => (string) ($row->source_id ?? ''), + 'created_at' => (string) ($row->created_at ?? ''), + 'courier_name' => (string) ($row->courier_name ?? ''), + 'waybill' => (string) ($row->waybill ?? ''), + 'platform_product_id' => (string) ($row->platform_product_id ?? ''), + 'product_code' => (string) ($row->product_code ?? ''), + 'shop_name' => (string) ($row->shop_name ?? ''), + 'online_order_no' => (string) ($row->online_order_no ?? ''), + 'length' => $length, + 'width' => $width, + 'height' => $height, + 'volume' => $volume, + 'inspection_type' => (string) ($row->inspection_type ?? ''), + 'inspection_reason' => (string) ($row->inspection_reason ?? ''), + 'return_waybill_no' => (string) ($row->return_waybill_no ?? ''), + 'settlement_weight' => $weight, + 'settlement_amount' => $amount, + 'surcharge_type' => $type, + 'approval_status' => (string) ($row->approval_status ?? ''), + 'quote_standard' => is_array($quote) ? ($quote['standard'] ?? null) : $quote?->standard, + 'settlement_status' => $quote === null ? '未匹配报价' : ($amount === null ? '待核价' : '已计算'), + ]; + } + + private function calculate(array|CourierSurchargeQuote $quote, ?float $weight, ?float $volume, mixed $sourceAmount, ?float $length = null, ?float $width = null, ?float $height = null): ?float + { + $mode = is_array($quote) ? (string) ($quote['mode'] ?? '') : (string) $quote->mode; + $standard = is_array($quote) ? ($quote['standard'] ?? null) : $quote->standard; + if ($mode === '固定单价') return $this->firstNumber($standard); + if ($mode === '按尺寸/最长边') { + // 尺寸报价以最长边和标准中的比较符匹配,例如: + // “最长边 > 160 cm:8 元/单”。没有明确阈值时不猜测金额。 + $dimensions = array_filter([$length, $width, $height], static fn (?float $dimension): bool => $dimension !== null); + + return $this->thresholdAmount($standard, $dimensions === [] ? null : max($dimensions)); + } + if ($mode === '按重量' && $weight !== null && $this->hasComparator($standard)) { + return $this->thresholdAmount($standard, $weight); + } + if ($mode === '按体积' && $volume !== null) { + $threshold = $this->thresholdAmount($standard, $volume); + if ($threshold !== null || $this->hasComparator($standard)) return $threshold; + } + $unit = $this->firstNumber($standard); + if ($unit !== null && $mode === '按重量' && $weight !== null) return round($unit * $weight, 2); + if ($unit !== null && $mode === '按体积' && $volume !== null) return round($unit * $volume, 2); + if ($mode === '发出运费倍数' && $sourceAmount !== null) { + $multiplier = $this->firstNumber($standard); + $amount = $this->number($sourceAmount); + + return $multiplier === null || $amount === null ? null : round($amount * $multiplier, 2); + } + if (in_array($mode, ['取发出运费', '按账单'], true)) { + return $this->number($sourceAmount); + } + + return null; + } + + private function thresholdAmount(?string $standard, ?float $value): ?float + { + if ($value === null) return null; + $text = trim((string) $standard); + if (preg_match('/(<=|>=|≤|≥|<|>|=)\s*(-?\d+(?:\.\d+)?)[^\d]{0,30}[::]\s*(-?\d+(?:\.\d+)?)/u', $text, $match) !== 1) { + return null; + } + + $operator = $match[1]; + $threshold = (float) $match[2]; + $amount = (float) $match[3]; + $matched = match ($operator) { + '<' => $value < $threshold, + '<=', '≤' => $value <= $threshold, + '>' => $value > $threshold, + '>=', '≥' => $value >= $threshold, + '=' => abs($value - $threshold) < 0.000001, + default => false, + }; + + return $matched ? round($amount, 2) : null; + } + + private function hasComparator(?string $standard): bool + { + return preg_match('/(?:<=|>=|≤|≥|<|>|=)/u', (string) $standard) === 1; + } + + private function firstNumber(?string $value): ?float + { + return preg_match('/-?\d+(?:\.\d+)?/u', (string) $value, $match) === 1 ? (float) $match[0] : null; + } + + private function number(mixed $value): ?float + { + return is_numeric($value) ? (float) $value : null; + } + + private function table(string $datasetKey): string + { + return $this->datasets->get($datasetKey)->targetTable(); + } + + private function hasTable(string $datasetKey): bool + { + return Schema::connection($this->connection->getName())->hasTable($this->table($datasetKey)); + } + + /** @param array $states */ + private function datasetKeyForState(array $states, ?array $needle): ?string + { + foreach ($states as $key => $state) { + if ($state === $needle) return $key; + } + + return null; + } +} diff --git a/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php index 20069ec9..ce07f435 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php @@ -1,5 +1,10 @@ @php $rows = $courierSurchargeSettlementRows ?? collect(); + $filters = $courierSurchargeSettlementFilters ?? []; + $summary = $courierSurchargeSettlementSummary ?? ['count' => 0, 'settlement_amount' => 0]; + $sync = $courierSurchargeSettlementSync ?? ['code' => 'unavailable', 'last_succeeded_at' => null]; + $state = $courierSurchargeSettlementState ?? []; + $error = $courierSurchargeSettlementError ?? null; $columns = [ ['key' => 'courier_name', 'label' => '快递名称', 'fixed' => 'true'], ['key' => 'waybill', 'label' => '单号'], @@ -12,12 +17,26 @@ ['key' => 'height', 'label' => '高'], ['key' => 'volume', 'label' => '体积'], ['key' => 'inspection_type', 'label' => '查件类型'], - ['key' => 'inspection_original', 'label' => '查件原件'], + ['key' => 'inspection_reason', 'label' => '查件原因'], ['key' => 'return_waybill_no', 'label' => '退回单号'], ['key' => 'settlement_weight', 'label' => '结算重量'], - ['key' => 'settlement_amount', 'label' => '结算金额', 'fixed' => 'end'], + ['key' => 'settlement_amount', 'label' => '结算金额'], + ['key' => 'surcharge_type', 'label' => '附加费类型', 'fixed' => 'end'], ]; - $filterValue = static fn (string $key): string => trim((string) request()->query($key, '')); + $filterValue = static fn (string $key): string => trim((string) ($filters[$key] ?? '')); + $syncLabel = match ($sync['code'] ?? null) { + 'fresh' => '最新', + 'stale' => '稍有延迟', + 'pending', 'initializing', 'syncing' => '同步中', + 'failed', 'initial_failed' => '同步失败', + default => '不可用', + }; + $emptyMessage = match (true) { + $error !== null => $error, + ($sync['code'] ?? null) === 'pending' || ($sync['code'] ?? null) === 'syncing' => '镜像正在同步,完成后将自动显示附加费明细。', + ($sync['code'] ?? null) === 'failed' || ($sync['code'] ?? null) === 'initial_failed' => '镜像同步失败,暂不展示不完整数据。', + default => '当前筛选条件没有匹配的附加费明细。', + }; $displayValue = static function (mixed $value, string $key): string { if ($value === null || $value === '') { return '—'; @@ -152,31 +171,45 @@ } -
- -
等待接入结算数据;确认数据来源后,将按附加费报价规则计算。
-
+ @if ($error !== null || ($sync['code'] ?? null) !== 'fresh') +
+ +
+ {{ $error !== null ? '附加费结算暂不可用' : '附加费结算数据状态' }} +
{{ $error ?? $emptyMessage }}
+ @if (! empty($state['failure_stage'])) + 失败阶段:{{ $state['failure_stage'] }} + @endif +
+
+ @endif
附加费结算
结算标准读取附加费报价维护
-
当前明细
{{ $rows->count() }}
-
结算金额
待接入
-
数据状态
待确认来源
+
筛选结果
{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }}
+
当前页结算金额
{{ number_format((float) ($summary['settlement_amount'] ?? 0), 2) }}
+
镜像状态
{{ $syncLabel }}
+
+
-
-
+
+
+
+
+
+
重置
-
附加费结算明细
快递名称和结算金额固定显示,其余字段可在“列设置”中调整。
列设置按当前用户和本页面保存
-
当前筛选结果:{{ $rows->count() }} 条报价匹配和金额计算将在确认数据来源后接入
+
附加费结算明细
快递名称和附加费类型固定显示,其余字段可在“列设置”中调整。
列设置按当前用户和本页面保存
+
当前筛选结果:{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }} 条数据状态:{{ $syncLabel }}@if (! empty($sync['last_succeeded_at'])),最近同步 {{ $sync['last_succeeded_at'] }}@endif
@@ -192,10 +225,14 @@ @endforeach @empty - + @endforelse
页面已就绪,等待接入附加费结算数据。
{{ $emptyMessage }}
+ + @if (method_exists($rows, 'links') && $rows->hasPages()) +
{{ $rows->withQueryString()->links() }}
+ @endif diff --git a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php index 8fe98792..4eb366fc 100644 --- a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php +++ b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php @@ -7,7 +7,7 @@ use Tests\Feature\FeatureTestCase; class CourierSurchargeSettlementPageTest extends FeatureTestCase { - public function test_page_renders_requested_settlement_columns_without_fake_rows(): void + public function test_page_renders_requested_settlement_columns_and_mirror_state(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); @@ -18,16 +18,17 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase ]))->assertOk() ->assertSee('data-surcharge-panel="quotes" hidden', false) ->assertSeeText('附加费结算') - ->assertSeeText('页面已就绪,等待接入附加费结算数据。') + ->assertSeeText('共享业务镜像尚未建立,附加费结算暂时无法读取。') ->assertSee('data-finance-table-name="courier-surcharge-settlement"', false) ->assertSee('data-finance-fixed="true"', false) ->assertSee('data-finance-fixed="end"', false) ->assertSeeText('快递名称') ->assertSeeText('平台商品ID') - ->assertSeeText('查件原件') + ->assertSeeText('查件原因') ->assertSeeText('退回单号') ->assertSeeText('结算重量') - ->assertSeeText('结算金额'); + ->assertSeeText('结算金额') + ->assertSeeText('附加费类型'); } public function test_settlement_filters_are_preserved_in_the_form(): void @@ -42,14 +43,14 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase 'waybill' => '001234', 'shop_name' => '测试店铺', 'online_order_no' => 'SO-001', - 'status' => '待接入', + 'status' => '已计算', ]))->assertOk() ->assertSee('value="测试快递"', false) ->assertSee('value="001234"', false) ->assertSee('value="测试店铺"', false) ->assertSee('value="SO-001"', false) - ->assertSee('value="待接入" selected', false) - ->assertViewHas('content', fn (array $content) => $content['courierSurchargeFilters']['status'] === ''); + ->assertSee('value="已计算" selected', false) + ->assertViewHas('content', fn (array $content) => $content['courierSurchargeSettlementFilters']['status'] === '已计算'); } public function test_quote_page_exposes_quote_and_settlement_tabs(): void diff --git a/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorConfigTest.php b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorConfigTest.php new file mode 100644 index 00000000..ce123d97 --- /dev/null +++ b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorConfigTest.php @@ -0,0 +1,59 @@ +get(CourierSurchargeSettlementMirrorRepository::PRIMARY_DATASET); + + $this->assertSame('jiansun', $dataset->sourceKey()); + $this->assertSame('dbo', $dataset->sourceSchema()); + $this->assertSame('ekf_Bookkeeping', $dataset->sourceObject()); + $this->assertSame('js_ekf_bookkeeping_surcharge', $dataset->targetTable()); + $this->assertSame(['Id'], $dataset->keyColumns()); + $this->assertSame('CreationTime', $dataset->watermarkColumn()); + $this->assertSame(['通过', '同意', '同意记账'], $dataset->fixedFilters()['IsAgreeReason']); + $this->assertSame(['通过', '同意', '同意记账'], config('upstream_mirrors.courier_surcharge_settlement.approved_values')); + + foreach ([ + CourierSurchargeSettlementMirrorRepository::EXPRESS_ORIGINAL_DATASET, + CourierSurchargeSettlementMirrorRepository::BOOKKEEPING_CONFIG_DATASET, + CourierSurchargeSettlementMirrorRepository::CHECK_DATASET, + CourierSurchargeSettlementMirrorRepository::CHECK_CONFIG_DATASET, + CourierSurchargeSettlementMirrorRepository::SHIPPING_DETAILS_DATASET, + CourierSurchargeSettlementMirrorRepository::SHIPPING_EXTENSION_DATASET, + CourierSurchargeSettlementMirrorRepository::OUTBOUND_DATASET, + CourierSurchargeSettlementMirrorRepository::RETURN_DATASET, + ] as $key) { + $this->assertSame('jiansun', $registry->get($key)->sourceKey()); + $this->assertNotSame('', $registry->get($key)->targetTable()); + } + + $expressOriginal = $registry->get(CourierSurchargeSettlementMirrorRepository::EXPRESS_ORIGINAL_DATASET); + $this->assertSame('express_Original', $expressOriginal->sourceObject()); + $this->assertSame('js_express_original', $expressOriginal->targetTable()); + $this->assertSame('DeliveryTime', $expressOriginal->watermarkColumn()); + + $this->assertSame('js_jst_order_express_details', $registry + ->get(CourierSurchargeSettlementMirrorRepository::SHIPPING_DETAILS_DATASET)->targetTable()); + $this->assertSame('js_jst_send_data_view_extension', $registry + ->get(CourierSurchargeSettlementMirrorRepository::SHIPPING_EXTENSION_DATASET)->targetTable()); + $this->assertSame('js_jst_sales_outbound_info', $registry + ->get(CourierSurchargeSettlementMirrorRepository::OUTBOUND_DATASET)->targetTable()); + } + + public function test_surcharge_type_contract_has_only_the_requested_nine_types(): void + { + $this->assertSame([ + '退回费', '拦截费', '改地址', '上楼费', '送货费', + '超区费', '安装费', '保费', '进仓费', + ], CourierSurchargeSettlementMirrorRepository::SURCHARGE_TYPES); + } +} diff --git a/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php new file mode 100644 index 00000000..cdcd9984 --- /dev/null +++ b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php @@ -0,0 +1,198 @@ + [ + 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + 'upstream_mirrors.courier_surcharge_settlement.approved_values' => ['通过', '同意', '同意记账'], + 'upstream_mirrors.datasets' => [ + 'jiansun.dbo.ekf_Bookkeeping' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_Bookkeeping', + 'target' => 'js_ekf_bookkeeping_surcharge', 'keys' => ['Id'], 'watermark' => 'CreationTime', + 'columns' => ['Id', 'CreationTime', 'OrderNum', 'ExpressNum', 'ExpressName', 'ShopCode', 'Province', 'City', 'District', 'Address', 'SupplierName', 'SkuName', 'SkuCode', 'Result', 'UserName', 'Payfee', 'SecodaryCause', 'ReasonId', 'State', 'Delete', 'IsAgreeReason', 'ProcessingState', 'BookkeepingName', 'PayTime', 'Svolume', 'Skuweight', 'IfReturn', 'IfBuckle', 'FuZhuMain', 'FuZhuMinor', 'ShipExpressName', 'expressCause', 'receivingDepartment', 'returnCost', 'shopproid'], + 'fixed_filters' => ['IsAgreeReason' => ['通过', '同意', '同意记账']], + ], + 'jiansun.dbo.express_Original' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'express_Original', + 'target' => 'js_express_original', 'keys' => ['Id'], 'watermark' => 'DeliveryTime', + 'columns' => ['Id', 'ExpressName', 'OrderNum', 'DeliveryTime', 'Lenght', 'Width', 'Height', 'Volume', 'SkuCode', 'JiaoYiNum', 'ShopName', 'SetMoney', 'SetWeight', 'shopproid'], + ], + 'jiansun.dbo.ekf_BookkeepingConfig' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_BookkeepingConfig', + 'target' => 'js_ekf_bookkeeping_config', 'keys' => ['Id'], 'watermark' => 'CreateTime', + 'columns' => ['Id', 'Name', 'ParentId', 'CreateTime'], + ], + 'jiansun.dbo.ekf_rCheck_Piece' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheck_Piece', + 'target' => 'js_ekf_rcheck_piece', 'keys' => ['Id'], 'watermark' => 'UpdateTime', + 'columns' => ['Id', 'CheckType', 'ExpressNum', 'OrderNum', 'CheckSDType', 'CheckReason', 'State', 'Accounting', 'ReturnState', 'CreationTime', 'UpdateTime', 'Delete', 'OmsSDName', 'DHLName', 'SkuCode', 'shopproid'], + ], + 'jiansun.dbo.ekf_rCheckPieceConfig' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheckPieceConfig', + 'target' => 'js_ekf_rcheck_piece_config', 'keys' => ['Id'], 'watermark' => 'CreateTime', + 'columns' => ['Id', 'ParentId', 'Name', 'Code', 'Type', 'Delete', 'CreateTime'], + ], + 'jiansun.dbo.jst_order_express_details' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_order_express_details', + 'target' => 'js_jst_order_express_details', 'keys' => ['id'], 'watermark' => 'updatedAt', + 'columns' => ['id', 'updatedAt', 'inner_order_id', 'so_id', 'logistic_id', 'logistic_company_name', 'logistic_status_name', 'platform_name', 'shop_name', 'warehouse_name', 'send_time', 'weight', 'f_weight'], + ], + 'jiansun.dbo.Jst_SendData_View_extension' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'Jst_SendData_View_extension', + 'target' => 'js_jst_send_data_view_extension', 'keys' => ['Id'], 'watermark' => null, + 'columns' => ['Id', 'io_id', 'qty', 'l', 'w', 'h', 'sku_ids', 'sku_name', 'so_id', 'shop_site', 'shop_name', 'shop_i_id', 'supplier_name', 'receiver_state', 'receiver_city', 'receiver_district', 'expressName', 'wareHoursName', 'volume', 'weight'], + ], + 'jiansun.dbo.jst_salesOutboundInfo' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_salesOutboundInfo', + 'target' => 'js_jst_sales_outbound_info', 'keys' => ['Id'], 'watermark' => 'updateTime', + 'columns' => ['Id', 'io_id', 'modified', 'updateTime', 'f_weight', 'weight', 'f_freight', 'freight', 'pay_date', 'volume', 'f_volume'], + ], + 'jiansun.dbo.ekf_ReturnPickupManagement' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_ReturnPickupManagement', + 'target' => 'js_ekf_return_pickup_management', 'keys' => ['Id'], 'watermark' => 'updateTime', + 'columns' => ['Id', 'orderId', 'returnExpressNo', 'transactionNo', 'updateTime'], + ], + ], + ]); + DB::purge('business_mirror'); + + $schema = Schema::connection('business_mirror'); + $schema->create('business_mirror_table_states', function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->dateTime('initial_completed_at')->nullable(); + $table->string('status')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + $table->string('last_error_code')->nullable(); + }); + $this->createMirrorTables($schema); + + $this->repository = new CourierSurchargeSettlementMirrorRepository( + app('db'), + new BusinessMirrorDatasetRegistry(), + new GenericBusinessMirrorRepository(app('db')), + ); + } + + public function test_it_maps_approved_bookkeeping_to_requested_fields_and_uses_outbound_weight(): void + { + $db = DB::connection('business_mirror'); + $this->markReady($db); + + $db->table('js_ekf_bookkeeping_config')->insert([ + ['Id' => 10, 'Name' => '退回', 'ParentId' => null], + ['Id' => 20, 'Name' => '二级原因', 'ParentId' => 1], + ]); + $db->table('js_ekf_rcheck_piece_config')->insert([ + ['Id' => 1, 'ParentId' => null, 'Name' => '一级查件', 'Code' => null, 'Type' => null, 'Delete' => null], + ['Id' => 2, 'ParentId' => 1, 'Name' => '二级查件原因', 'Code' => null, 'Type' => null, 'Delete' => null], + ]); + $db->table('js_ekf_bookkeeping_surcharge')->insert([ + 'Id' => 1, 'CreationTime' => '2026-09-27 10:00:00', 'OrderNum' => 'SO-1', 'ExpressNum' => 'WB-1', + 'ExpressName' => '备用快递', 'ShopCode' => '备用店铺', 'SkuCode' => '备用编码', 'Payfee' => 3.2, + 'SecodaryCause' => 20, 'ReasonId' => 10, 'IsAgreeReason' => '同意', 'Svolume' => 1.1, + 'Skuweight' => 0.9, 'IfReturn' => '已退回', 'IfBuckle' => null, 'ShipExpressName' => null, + 'expressCause' => null, 'shopproid' => '备用商品', + ]); + $db->table('js_ekf_rcheck_piece')->insert([ + 'Id' => 1, 'CheckType' => 1, 'ExpressNum' => 'WB-1', 'OrderNum' => 'SO-1', 'CheckReason' => 2, + 'OmsSDName' => '查件物流', 'DHLName' => '不作为快递名称', 'SkuCode' => 'CHECK-SKU', 'shopproid' => 'CHECK-ITEM', + ]); + $db->table('js_express_original')->insert([ + 'Id' => 1, 'ExpressName' => '原始物流', 'OrderNum' => 'WB-1', 'Lenght' => 11, 'Width' => 22, 'Height' => 33, + 'Volume' => 7260, 'SkuCode' => 'ORIGINAL-SKU', 'JiaoYiNum' => 'TRADE-1', 'ShopName' => '原始店铺', + 'SetMoney' => 18.5, 'SetWeight' => 1.75, 'shopproid' => 'ORIGINAL-ITEM', + ]); + $db->table('js_jst_order_express_details')->insert([ + 'id' => 1, 'updatedAt' => '2026-09-27 10:01:00', 'so_id' => 'SO-1', 'logistic_id' => 'WB-1', + 'logistic_company_name' => '正式快递', 'shop_name' => '正式店铺', 'f_weight' => 0.8, + ]); + $db->table('js_jst_send_data_view_extension')->insert([ + 'Id' => 1, 'io_id' => 100, 'so_id' => 'SO-1', 'shop_i_id' => 'ITEM-1', 'sku_ids' => 'SKU-1', + 'shop_name' => '正式店铺', 'l' => 10, 'w' => 20, 'h' => 30, 'volume' => 6000, 'weight' => 0.7, + ]); + $db->table('js_jst_sales_outbound_info')->insert([ + 'Id' => 1, 'io_id' => 100, 'f_weight' => 1.25, 'weight' => 1.2, 'f_volume' => 0.5, 'volume' => 0.4, + ]); + + $result = $this->repository->paginate([], 1, 25, collect()); + $row = $result['data']->first(); + + $this->assertSame(1, $result['total']); + $this->assertSame('查件物流', $row['courier_name']); + $this->assertSame('ORIGINAL-ITEM', $row['platform_product_id']); + $this->assertSame('ORIGINAL-SKU', $row['product_code']); + $this->assertSame('原始店铺', $row['shop_name']); + $this->assertSame('TRADE-1', $row['online_order_no']); + $this->assertSame(11.0, $row['length']); + $this->assertSame(22.0, $row['width']); + $this->assertSame(33.0, $row['height']); + $this->assertSame('一级查件', $row['inspection_type']); + $this->assertSame('二级查件原因', $row['inspection_reason']); + $this->assertSame('退回费', $row['surcharge_type']); + $this->assertSame(1.75, $row['settlement_weight']); + $this->assertSame(7260.0, $row['volume']); + + $thresholdResult = $this->repository->paginate([], 1, 25, collect([[ + 'status_code' => CourierSurchargeQuote::STATUS_ACTIVE, + 'courier' => '查件物流', 'types' => ['退回费'], + 'mode' => '按尺寸/最长边', 'standard' => '最长边 > 30 cm:8 元/单', + ]])); + $this->assertSame(8.0, $thresholdResult['data']->first()['settlement_amount']); + + $volumeResult = $this->repository->paginate([], 1, 25, collect([[ + 'status_code' => CourierSurchargeQuote::STATUS_ACTIVE, + 'courier' => '查件物流', 'types' => ['退回费'], + 'mode' => '按体积', 'standard' => '体积 ≤ 8000 cm³:50 元/单', + ]])); + $this->assertSame(50.0, $volumeResult['data']->first()['settlement_amount']); + } + + private function createMirrorTables($schema): void + { + $schema->create('js_ekf_bookkeeping_surcharge', function (Blueprint $table): void { + $table->integer('Id'); $table->dateTime('CreationTime')->nullable(); + foreach (['OrderNum', 'ExpressNum', 'ExpressName', 'ShopCode', 'Province', 'City', 'District', 'Address', 'SupplierName', 'SkuName', 'SkuCode', 'Result', 'UserName', 'State', 'Delete', 'IsAgreeReason', 'ProcessingState', 'BookkeepingName', 'PayTime', 'IfReturn', 'IfBuckle', 'FuZhuMain', 'FuZhuMinor', 'ShipExpressName', 'expressCause', 'receivingDepartment', 'returnCost', 'shopproid'] as $column) $table->string($column)->nullable(); + foreach (['Payfee', 'Svolume', 'Skuweight'] as $column) $table->decimal($column, 20, 4)->nullable(); + foreach (['SecodaryCause', 'ReasonId'] as $column) $table->integer($column)->nullable(); + }); + $schema->create('js_ekf_bookkeeping_config', function (Blueprint $table): void { $table->integer('Id'); $table->string('Name')->nullable(); $table->integer('ParentId')->nullable(); $table->dateTime('CreateTime')->nullable(); }); + $schema->create('js_ekf_rcheck_piece', function (Blueprint $table): void { $table->integer('Id'); foreach (['CheckType', 'CheckReason'] as $column) $table->integer($column)->nullable(); foreach (['ExpressNum', 'OrderNum', 'CheckSDType', 'State', 'Accounting', 'ReturnState', 'CreationTime', 'UpdateTime', 'Delete', 'OmsSDName', 'DHLName', 'SkuCode', 'shopproid'] as $column) $table->string($column)->nullable(); }); + $schema->create('js_ekf_rcheck_piece_config', function (Blueprint $table): void { $table->integer('Id'); $table->integer('ParentId')->nullable(); foreach (['Name', 'Code', 'Type', 'Delete', 'CreateTime'] as $column) $table->string($column)->nullable(); }); + $schema->create('js_jst_order_express_details', function (Blueprint $table): void { $table->integer('id'); foreach (['updatedAt', 'so_id', 'logistic_id', 'logistic_company_name', 'shop_name', 'f_weight'] as $column) $table->string($column)->nullable(); }); + $schema->create('js_jst_send_data_view_extension', function (Blueprint $table): void { $table->integer('Id'); $table->integer('io_id')->nullable(); foreach (['so_id', 'shop_i_id', 'sku_ids', 'shop_name', 'l', 'w', 'h', 'volume', 'weight'] as $column) $table->string($column)->nullable(); }); + $schema->create('js_jst_sales_outbound_info', function (Blueprint $table): void { $table->integer('Id'); $table->integer('io_id')->nullable(); foreach (['f_weight', 'weight', 'f_volume', 'volume'] as $column) $table->string($column)->nullable(); }); + $schema->create('js_ekf_return_pickup_management', function (Blueprint $table): void { $table->integer('Id'); foreach (['orderId', 'returnExpressNo', 'transactionNo', 'updateTime'] as $column) $table->string($column)->nullable(); }); + $schema->create('js_express_original', function (Blueprint $table): void { $table->integer('Id'); foreach (['ExpressName', 'OrderNum', 'DeliveryTime', 'Lenght', 'Width', 'Height', 'Volume', 'SkuCode', 'JiaoYiNum', 'ShopName', 'SetMoney', 'SetWeight', 'shopproid'] as $column) $table->string($column)->nullable(); }); + } + + private function markReady($db): void + { + $now = now()->toDateTimeString(); + foreach (array_keys(config('upstream_mirrors.datasets')) as $dataset) { + $db->table('business_mirror_table_states')->insert([ + 'dataset_key' => $dataset, 'initial_completed_at' => '2026-09-27 09:00:00', + 'status' => 'succeeded', 'last_succeeded_at' => $now, + ]); + } + } +}