feat: 接入聚水潭附加费结算镜像
(cherry picked from commit 4b79370faea61aefe05dfe058480600b6fe9981e)
This commit is contained in:
parent
28f99acebc
commit
d3fee64f80
@ -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
|
||||
|
||||
@ -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' => '运费对账出库运费'],
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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`;保留得物、退货退款订单、扣减其他费用明细) |
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user