feat: 接入聚水潭附加费结算镜像

(cherry picked from commit 4b79370faea61aefe05dfe058480600b6fe9981e)
This commit is contained in:
zhoushengchen 2026-09-27 15:00:36 +08:00
parent 28f99acebc
commit d3fee64f80
9 changed files with 1101 additions and 24 deletions

View File

@ -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<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')),
];
}
private function freightReconciliationFilters(): array

View File

@ -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' => '运费对账出库运费'],

View File

@ -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',

View File

@ -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`;保留得物、退货退款订单、扣减其他费用明细) |

View File

@ -0,0 +1,611 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use App\Models\Business\CourierSurchargeQuote;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
/**
* 附加费结算的只读业务镜像入口。
*
* 聚水潭原表只在共享镜像刷新任务中读取;页面请求永远不连接上游。
* 类型和审核条件在这里固定,避免用户输入进入 SQL 或绕过业务范围。
*/
class CourierSurchargeSettlementMirrorRepository
{
public const PRIMARY_DATASET = 'jiansun.dbo.ekf_Bookkeeping';
public const EXPRESS_ORIGINAL_DATASET = 'jiansun.dbo.express_Original';
public const BOOKKEEPING_CONFIG_DATASET = 'jiansun.dbo.ekf_BookkeepingConfig';
public const CHECK_DATASET = 'jiansun.dbo.ekf_rCheck_Piece';
public const CHECK_CONFIG_DATASET = 'jiansun.dbo.ekf_rCheckPieceConfig';
public const SHIPPING_DETAILS_DATASET = 'jiansun.dbo.jst_order_express_details';
public const SHIPPING_EXTENSION_DATASET = 'jiansun.dbo.Jst_SendData_View_extension';
public const OUTBOUND_DATASET = 'jiansun.dbo.jst_salesOutboundInfo';
public const RETURN_DATASET = 'jiansun.dbo.ekf_ReturnPickupManagement';
/** @var list<string> */
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<string> */
public const SURCHARGE_TYPES = [
'退回费', '拦截费', '改地址', '上楼费', '送货费', '超区费', '安装费', '保费', '进仓费',
];
/**
* 上游审核字段是自由文本;只有这些明确的已批准值允许进入页面。
* 其它说明、退回原因、空值都不能被当作审核通过。
*
* @var list<string>
*/
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<string,mixed> */
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<string,string> $filters
* @param Collection<int,CourierSurchargeQuote> $quotes
* @return array{data:Collection<int,array<string,mixed>>,has_more:bool,total:int,summary:array<string,mixed>,sync:array<string,mixed>,state:array<string,mixed>}
*/
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<string,string> $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<string> */
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<int,array<string,mixed>|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<string,?array> $states */
private function datasetKeyForState(array $states, ?array $needle): ?string
{
foreach ($states as $key => $state) {
if ($state === $needle) return $key;
}
return null;
}
}

View File

@ -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 @@
}
</style>
<div class="settlement-notice" role="status">
<span class="material-icons-outlined" aria-hidden="true">info</span>
<div>等待接入结算数据;确认数据来源后,将按附加费报价规则计算。</div>
</div>
@if ($error !== null || ($sync['code'] ?? null) !== 'fresh')
<div class="settlement-notice" role="status">
<span class="material-icons-outlined" aria-hidden="true">{{ $error !== null ? 'error_outline' : 'info' }}</span>
<div>
<strong>{{ $error !== null ? '附加费结算暂不可用' : '附加费结算数据状态' }}</strong>
<div>{{ $error ?? $emptyMessage }}</div>
@if (! empty($state['failure_stage']))
<small>失败阶段:{{ $state['failure_stage'] }}</small>
@endif
</div>
</div>
@endif
<div class="settlement-summary" aria-label="附加费结算概览">
<div class="settlement-summary__intro"><span class="settlement-summary__icon material-icons-outlined" aria-hidden="true">calculate</span><div><div class="settlement-summary__title">附加费结算</div><div class="settlement-summary__subtitle">结算标准读取附加费报价维护</div></div></div>
<div class="settlement-summary__metric"><div class="settlement-summary__label">当前明细</div><div class="settlement-summary__value">{{ $rows->count() }}</div></div>
<div class="settlement-summary__metric"><div class="settlement-summary__label">结算金额</div><div class="settlement-summary__value">待接入</div></div>
<div class="settlement-summary__metric"><div class="settlement-summary__label">数据状态</div><div class="settlement-summary__value">待确认来源</div></div>
<div class="settlement-summary__metric"><div class="settlement-summary__label">筛选结果</div><div class="settlement-summary__value">{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }}</div></div>
<div class="settlement-summary__metric"><div class="settlement-summary__label">当前页结算金额</div><div class="settlement-summary__value">{{ number_format((float) ($summary['settlement_amount'] ?? 0), 2) }}</div></div>
<div class="settlement-summary__metric"><div class="settlement-summary__label">镜像状态</div><div class="settlement-summary__value">{{ $syncLabel }}</div></div>
</div>
<form class="settlement-filter-bar" method="GET" action="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge', 'tab' => 'settlement']) }}" aria-label="附加费结算筛选">
<div class="settlement-field"><label for="settlement-courier">快递名称</label><input id="settlement-courier" type="search" name="courier_name" value="{{ $filterValue('courier_name') }}" placeholder="输入快递名称"></div>
<div class="settlement-field"><label for="settlement-waybill">单号</label><input id="settlement-waybill" type="search" name="waybill" value="{{ $filterValue('waybill') }}" placeholder="输入单号"></div>
<div class="settlement-field"><label for="settlement-platform-product">平台商品ID</label><input id="settlement-platform-product" type="search" name="platform_product_id" value="{{ $filterValue('platform_product_id') }}" placeholder="输入平台商品ID"></div>
<div class="settlement-field"><label for="settlement-product-code">商品编码</label><input id="settlement-product-code" type="search" name="product_code" value="{{ $filterValue('product_code') }}" placeholder="输入商品编码"></div>
<div class="settlement-field"><label for="settlement-shop">店铺名称</label><input id="settlement-shop" type="search" name="shop_name" value="{{ $filterValue('shop_name') }}" placeholder="输入店铺名称"></div>
<div class="settlement-field"><label for="settlement-online-order">线上订单号</label><input id="settlement-online-order" type="search" name="online_order_no" value="{{ $filterValue('online_order_no') }}" placeholder="输入线上订单号"></div>
<div class="settlement-field"><label for="settlement-inspection">查件类型</label><select id="settlement-inspection" name="inspection_type"><option value="">全部查件类型</option></select></div>
<div class="settlement-field"><label for="settlement-status">结算状态</label><select id="settlement-status" name="status"><option value="">全部状态</option><option value="待接入" @selected($filterValue('status') === '待接入')>待接入</option><option value="已计算" @selected($filterValue('status') === '已计算')>已计算</option></select></div>
<div class="settlement-field"><label for="settlement-inspection">查件类型</label><input id="settlement-inspection" type="search" name="inspection_type" value="{{ $filterValue('inspection_type') }}" placeholder="一级选项"></div>
<div class="settlement-field"><label for="settlement-reason">查件原因</label><input id="settlement-reason" type="search" name="inspection_reason" value="{{ $filterValue('inspection_reason') }}" placeholder="二级选项"></div>
<div class="settlement-field"><label for="settlement-surcharge-type">附加费类型</label><select id="settlement-surcharge-type" name="surcharge_type"><option value="">全部附加费类型</option>@foreach (['退回费','拦截费','改地址','上楼费','送货费','超区费','安装费','保费','进仓费'] as $type)<option value="{{ $type }}" @selected($filterValue('surcharge_type') === $type)>{{ $type }}</option>@endforeach</select></div>
<div class="settlement-field"><label for="settlement-status">结算状态</label><select id="settlement-status" name="status"><option value="">全部状态</option>@foreach (['未匹配报价','待核价','已计算'] as $status)<option value="{{ $status }}" @selected($filterValue('status') === $status)>{{ $status }}</option>@endforeach</select></div>
<div class="settlement-field"><label for="settlement-created-from">创建日期起</label><input id="settlement-created-from" type="date" name="created_from" value="{{ $filterValue('created_from') }}"></div>
<div class="settlement-field"><label for="settlement-created-to">创建日期止</label><input id="settlement-created-to" type="date" name="created_to" value="{{ $filterValue('created_to') }}"></div>
<div class="settlement-actions"><button class="settlement-button settlement-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button><a class="settlement-button" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge', 'tab' => 'settlement']) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a></div>
</form>
<div class="settlement-table-surface" data-finance-table-surface>
<div class="settlement-table-head"><div><div class="settlement-table-title"><span class="material-icons-outlined" aria-hidden="true">table_view</span>附加费结算明细</div><div class="settlement-table-hint">快递名称和结算金额固定显示,其余字段可在“列设置”中调整。</div></div><div class="finance-table-controls" data-finance-controls><span class="settlement-table-hint">列设置按当前用户和本页面保存</span></div></div>
<div class="settlement-table-meta"><span>当前筛选结果:<strong>{{ $rows->count() }}</strong> 条</span><span>报价匹配和金额计算将在确认数据来源后接入</span></div>
<div class="settlement-table-head"><div><div class="settlement-table-title"><span class="material-icons-outlined" aria-hidden="true">table_view</span>附加费结算明细</div><div class="settlement-table-hint">快递名称和附加费类型固定显示,其余字段可在“列设置”中调整。</div></div><div class="finance-table-controls" data-finance-controls><span class="settlement-table-hint">列设置按当前用户和本页面保存</span></div></div>
<div class="settlement-table-meta"><span>当前筛选结果:<strong>{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }}</strong> 条</span><span>数据状态:{{ $syncLabel }}@if (! empty($sync['last_succeeded_at'])),最近同步 {{ $sync['last_succeeded_at'] }}@endif</span></div>
<div class="settlement-table-scroll finance-table-scroll-owner">
<table class="settlement-table" data-finance-table data-finance-table-name="courier-surcharge-settlement" data-finance-table-key="{{ auth()->id() }}:{{ company_id() }}:courier-payables:courier-surcharge:settlement:details" aria-label="附加费结算明细">
<thead><tr>
@ -192,10 +225,14 @@
@endforeach
</tr>
@empty
<tr class="settlement-empty is-empty"><td colspan="{{ count($columns) }}"><span class="material-icons-outlined" aria-hidden="true">hourglass_empty</span><div>页面已就绪,等待接入附加费结算数据。</div></td></tr>
<tr class="settlement-empty is-empty"><td colspan="{{ count($columns) }}"><span class="material-icons-outlined" aria-hidden="true">{{ $error !== null ? 'error_outline' : 'inbox' }}</span><div>{{ $emptyMessage }}</div></td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
@if (method_exists($rows, 'links') && $rows->hasPages())
<div class="settlement-pagination">{{ $rows->withQueryString()->links() }}</div>
@endif
</section>

View File

@ -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

View File

@ -0,0 +1,59 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class CourierSurchargeSettlementMirrorConfigTest extends TestCase
{
public function test_settlement_sources_are_registered_with_stable_keys_and_fixed_approval_filter(): void
{
$registry = new BusinessMirrorDatasetRegistry();
$dataset = $registry->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);
}
}

View File

@ -0,0 +1,198 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use App\Models\Business\CourierSurchargeQuote;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class CourierSurchargeSettlementMirrorRepositoryTest extends TestCase
{
private CourierSurchargeSettlementMirrorRepository $repository;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'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,
]);
}
}
}