附加费结算导入核验:只填单号+类型,自动匹配快递并按报价核算 #391
@ -4858,9 +4858,9 @@ class Pages extends Controller
|
||||
return response()->streamDownload(function (): void {
|
||||
$writer = new \App\Support\StreamingXlsxWriter('导入模板');
|
||||
|
||||
$writer->appendRow(['单号', '附加费类型', '快递名称', '金额(可选)']);
|
||||
$writer->appendRow(['DPK317138931411', '退回费', '曹县德邦小件', 20]);
|
||||
$writer->appendRow(['DPK379220912936', '超长费', '常州德邦', null]);
|
||||
$writer->appendRow(['单号', '附加费类型', '金额(可选)']);
|
||||
$writer->appendRow(['DPK317138931411', '退回费', 20]);
|
||||
$writer->appendRow(['DPK379220912936', '超长费', null]);
|
||||
|
||||
$output = fopen('php://output', 'wb');
|
||||
|
||||
|
||||
@ -38,6 +38,9 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
/** 导入核验时最多回查多少张扣单工单,避免大表扫描过重。 */
|
||||
private const IMPORT_RETURN_CONTEXT_LIMIT = 100;
|
||||
|
||||
/** 导入核验时最多按单号回查多少张销售出库单(超长费)。 */
|
||||
private const IMPORT_OVERLENGTH_CONTEXT_LIMIT = 200;
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(DatabaseManager $database)
|
||||
@ -279,6 +282,22 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
$contextComplete = count($unmatchedDeductWaybills) <= self::IMPORT_RETURN_CONTEXT_LIMIT;
|
||||
$returnContext = $this->deductOrderContext(array_keys($unmatchedDeductWaybills));
|
||||
$ruleModes = $this->activeRuleModes();
|
||||
$quoteRules = $this->importQuoteRules();
|
||||
|
||||
// 超长费没有结算行时,按单号回销售出库单查快递/尺寸并按启用的报价核算。
|
||||
$unmatchedOverlengthWaybills = [];
|
||||
|
||||
foreach ($normalized as $pair) {
|
||||
if ($pair['waybill'] === '' || $pair['surcharge_type'] !== '超长费') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->matchImportRow($rowsByWaybill[$pair['waybill']] ?? [], $pair['surcharge_type'], $pair['courier_name']) === null) {
|
||||
$unmatchedOverlengthWaybills[$pair['waybill']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$overlengthContext = $this->importOverlengthContext(array_keys($unmatchedOverlengthWaybills));
|
||||
|
||||
$fallbackRows = [];
|
||||
|
||||
@ -323,7 +342,16 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
|
||||
$results[] = $matched !== null
|
||||
? $this->importMatchedRow($pair, $matched)
|
||||
: $this->importMissingRow(
|
||||
: ($this->importQuoteResult(
|
||||
$pair,
|
||||
$fallbackRows[$pair['waybill']] ?? null,
|
||||
$returnContext,
|
||||
$ruleModes,
|
||||
$quoteRules,
|
||||
$overlengthContext,
|
||||
$contextWeights,
|
||||
$contextReturns,
|
||||
) ?? $this->importMissingRow(
|
||||
$pair,
|
||||
$returnContext,
|
||||
$ruleModes,
|
||||
@ -332,7 +360,7 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
$fallbackRows[$pair['waybill']] ?? null,
|
||||
$contextWeights,
|
||||
$contextReturns,
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
return $results;
|
||||
@ -422,7 +450,7 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
* @param array<string,array<string,mixed>> $returnContext
|
||||
* @param array<string,array<string,list<string>>> $ruleModes
|
||||
* @param array<string,float> $contextWeights
|
||||
* @param array<int,string> $contextReturns
|
||||
* @param array<int,list<array<string,mixed>>> $contextReturns
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function importMissingRow(
|
||||
@ -474,7 +502,9 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
$onlineOrderNo = trim((string) ($context['online_order_no'] ?? ''));
|
||||
$inspectionType = trim((string) ($context['inspection_type'] ?? '')) ?: null;
|
||||
$inspectionReason = trim((string) ($context['inspection_reason'] ?? ''));
|
||||
$returnWaybillNo = $contextReturns[(int) ($context['o_id'] ?? 0)] ?? null;
|
||||
$orderReturns = $contextReturns[(int) ($context['o_id'] ?? 0)] ?? [];
|
||||
$returnWaybills = array_values(array_unique(array_filter(array_column($orderReturns, 'l_id'))));
|
||||
$returnWaybillNo = $returnWaybills === [] ? null : implode(',', $returnWaybills);
|
||||
$settlementWeight = $this->importWeightSum($context['items'] ?? [], $contextWeights);
|
||||
$sendTime = trim((string) ($context['send_time'] ?? ''));
|
||||
$shippedAt = $sendTime !== '' ? $sendTime : null;
|
||||
@ -517,6 +547,591 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入核验的「按报价核算」:结算表里没有该运单+类型的结算行时,
|
||||
* 用单号回销售出库单(超长费)或查件扣单(退回费/拦截费)取上下文,
|
||||
* 再按启用的报价规则计算应结金额。返回 null 表示该单号取不到上下文,
|
||||
* 交给 importMissingRow 输出兜底的不结原因。
|
||||
*
|
||||
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
|
||||
* @param array<string,array<string,mixed>> $returnContext
|
||||
* @param array<string,array<string,list<string>>> $ruleModes
|
||||
* @param array{overlength:list<array<string,mixed>>,return:list<array<string,mixed>>,intercept:list<array<string,mixed>>} $quoteRules
|
||||
* @param array<string,array<string,mixed>> $overlengthContext
|
||||
* @param array<string,float> $contextWeights
|
||||
* @param array<int,list<array<string,mixed>>> $contextReturns
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function importQuoteResult(
|
||||
array $pair,
|
||||
?object $fallbackRow,
|
||||
array $returnContext,
|
||||
array $ruleModes,
|
||||
array $quoteRules,
|
||||
array $overlengthContext,
|
||||
array $contextWeights,
|
||||
array $contextReturns,
|
||||
): ?array {
|
||||
$type = $pair['surcharge_type'];
|
||||
|
||||
if ($type === '超长费') {
|
||||
$context = $overlengthContext[$pair['waybill']] ?? null;
|
||||
|
||||
return $context === null
|
||||
? null
|
||||
: $this->importOverlengthQuoteResult($pair, $context, $quoteRules['overlength'] ?? []);
|
||||
}
|
||||
|
||||
if (! in_array($type, ['退回费', '拦截费'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$context = $returnContext[$pair['waybill']] ?? null;
|
||||
|
||||
return $context === null
|
||||
? null
|
||||
: $this->importDeductQuoteResult($pair, $context, $fallbackRow, $ruleModes, $quoteRules, $contextWeights, $contextReturns);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按单号回销售出库单(JianSun)取快递、明细、尺寸与平台商品ID。
|
||||
*
|
||||
* @param list<string> $waybills
|
||||
* @return array<string,array<string,mixed>>
|
||||
*/
|
||||
private function importOverlengthContext(array $waybills): array
|
||||
{
|
||||
if ($waybills === [] || count($waybills) > self::IMPORT_OVERLENGTH_CONTEXT_LIMIT) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$orders = [];
|
||||
$orderByWaybill = [];
|
||||
|
||||
foreach (array_chunk($waybills, 100) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
|
||||
try {
|
||||
$rows = $upstream->select(
|
||||
'SELECT Id, io_id, o_id, l_id, so_id, shop_name, '
|
||||
. "CONVERT(varchar(19), io_date, 120) AS io_date, logistics_company "
|
||||
. 'FROM dbo.jst_salesOutboundInfo WHERE l_id IN (' . $placeholders . ') ORDER BY Id DESC',
|
||||
$chunk,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $row;
|
||||
$waybill = trim((string) ($row['l_id'] ?? ''));
|
||||
|
||||
if ($waybill === '' || isset($orderByWaybill[$waybill])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$orderByWaybill[$waybill] = $row;
|
||||
$orders[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
if ($orders === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ioIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $order): int => (int) ($order['io_id'] ?? 0),
|
||||
$orders,
|
||||
))));
|
||||
$items = [];
|
||||
$skuIds = [];
|
||||
|
||||
foreach (array_chunk($ioIds, 200) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
|
||||
try {
|
||||
$rows = $upstream->select(
|
||||
'SELECT io_id, sku_id, i_id, qty FROM dbo.jst_salesOutboundItem WHERE io_id IN (' . $placeholders . ')',
|
||||
$chunk,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $row;
|
||||
$ioId = (int) ($row['io_id'] ?? 0);
|
||||
$skuId = trim((string) ($row['sku_id'] ?? ''));
|
||||
|
||||
if ($ioId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[$ioId][] = $row;
|
||||
|
||||
if ($skuId !== '') {
|
||||
$skuIds[$skuId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$dimensions = $this->importDimensions(array_keys($skuIds));
|
||||
$platform = [];
|
||||
|
||||
foreach (array_chunk($ioIds, 200) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
|
||||
try {
|
||||
$rows = $upstream->select(
|
||||
'SELECT io_id, sku_id, shop_i_id FROM dbo.Jst_SendDataDetail_View WHERE io_id IN (' . $placeholders . ')'
|
||||
. ' AND LEN(shop_i_id) > 0',
|
||||
$chunk,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $row;
|
||||
$ioId = (int) ($row['io_id'] ?? 0);
|
||||
$shopIId = trim((string) ($row['shop_i_id'] ?? ''));
|
||||
$skuId = trim((string) ($row['sku_id'] ?? ''));
|
||||
|
||||
if ($ioId <= 0 || $shopIId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($skuId !== '') {
|
||||
$platform[$ioId . '|' . $skuId] = $shopIId;
|
||||
}
|
||||
|
||||
$platform[$ioId . '|'] ??= $shopIId;
|
||||
}
|
||||
}
|
||||
|
||||
$contexts = [];
|
||||
|
||||
foreach ($orderByWaybill as $waybill => $order) {
|
||||
$ioId = (int) ($order['io_id'] ?? 0);
|
||||
$contexts[$waybill] = [
|
||||
'order' => $order,
|
||||
'items' => $items[$ioId] ?? [],
|
||||
'dimensions' => $dimensions,
|
||||
'platform' => $platform,
|
||||
];
|
||||
}
|
||||
|
||||
return $contexts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
* @param list<array{id:int,courier:string,threshold:float,amount:float}> $rules
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function importOverlengthQuoteResult(array $pair, array $context, array $rules): array
|
||||
{
|
||||
$order = $context['order'];
|
||||
$actualCourier = trim((string) ($order['logistics_company'] ?? ''));
|
||||
$importedCourier = $pair['courier_name'];
|
||||
$courier = $importedCourier !== '' ? $importedCourier : $actualCourier;
|
||||
$ioId = (int) ($order['io_id'] ?? 0);
|
||||
$best = null;
|
||||
|
||||
foreach ($context['items'] as $item) {
|
||||
$skuId = trim((string) ($item['sku_id'] ?? ''));
|
||||
$dimension = $context['dimensions'][$skuId] ?? null;
|
||||
|
||||
if ($dimension === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$maxSide = max($dimension['l'], $dimension['w'], $dimension['h']);
|
||||
|
||||
if ($best === null || $maxSide > $best['max_side']) {
|
||||
$best = [
|
||||
'max_side' => $maxSide,
|
||||
'sku_id' => $skuId,
|
||||
'i_id' => trim((string) ($item['i_id'] ?? '')),
|
||||
'length' => $dimension['l'],
|
||||
'width' => $dimension['w'],
|
||||
'height' => $dimension['h'],
|
||||
'platform_product_id' => $context['platform'][$ioId . '|' . $skuId]
|
||||
?? ($context['platform'][$ioId . '|'] ?? null),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'courier_name' => $courier !== '' ? $courier : null,
|
||||
'shipped_at' => trim((string) ($order['io_date'] ?? '')) ?: null,
|
||||
'platform_product_id' => $best['platform_product_id'] ?? null,
|
||||
'product_code' => $best === null
|
||||
? null
|
||||
: ($best['sku_id'] !== '' ? $best['sku_id'] : ($best['i_id'] !== '' ? $best['i_id'] : null)),
|
||||
'shop_name' => trim((string) ($order['shop_name'] ?? '')) ?: null,
|
||||
'online_order_no' => trim((string) ($order['so_id'] ?? '')) ?: null,
|
||||
'length' => $best['length'] ?? null,
|
||||
'width' => $best['width'] ?? null,
|
||||
'height' => $best['height'] ?? null,
|
||||
'volume' => $best === null ? null : round($best['length'] * $best['width'] * $best['height'], 2),
|
||||
'inspection_type' => null,
|
||||
'inspection_reason' => null,
|
||||
'return_waybill_no' => null,
|
||||
'settlement_weight' => null,
|
||||
];
|
||||
|
||||
if ($importedCourier !== '' && $actualCourier !== '' && $importedCourier !== $actualCourier) {
|
||||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||||
'不结:该运单实际快递为「' . $actualCourier . '」,与导入的「' . $importedCourier . '」不一致', null);
|
||||
}
|
||||
|
||||
$rule = null;
|
||||
|
||||
foreach ($rules as $candidate) {
|
||||
if ($candidate['courier'] === $actualCourier) {
|
||||
$rule = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($rule === null) {
|
||||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||||
'不结:快递「' . ($actualCourier !== '' ? $actualCourier : '未知') . '」没有启用的「超长费」报价规则', null);
|
||||
}
|
||||
|
||||
if ($best === null) {
|
||||
return $this->importQuoteRow($pair, $fields, null, '待核价',
|
||||
'待核价:商品资料缺少长宽高,暂无法核算超长费', '销售出库单已匹配,但条形码在商品资料中没有长宽高');
|
||||
}
|
||||
|
||||
$evidence = mb_substr(sprintf(
|
||||
'规则 #%d;条形码 %s 尺寸 %s×%s×%s,最长边 %scm %s %scm',
|
||||
$rule['id'],
|
||||
$best['sku_id'] !== '' ? $best['sku_id'] : '(无)',
|
||||
$this->trimNumber($best['length']),
|
||||
$this->trimNumber($best['width']),
|
||||
$this->trimNumber($best['height']),
|
||||
$this->trimNumber($best['max_side']),
|
||||
$best['max_side'] > $rule['threshold'] ? '>' : '≤',
|
||||
$this->trimNumber($rule['threshold']),
|
||||
), 0, 500);
|
||||
|
||||
if ($best['max_side'] <= $rule['threshold']) {
|
||||
return $this->importQuoteRow($pair, $fields, 0.0, '已计算',
|
||||
'不结:最长边 ' . $this->trimNumber($best['max_side']) . 'cm 未超过报价阈值 ' . $this->trimNumber($rule['threshold']) . 'cm',
|
||||
$evidence);
|
||||
}
|
||||
|
||||
return $this->importQuoteRow($pair, $fields, (float) $rule['amount'], '已计算', '', $evidence);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
* @param array<string,array<string,list<string>>> $ruleModes
|
||||
* @param array{overlength:list<array<string,mixed>>,return:list<array<string,mixed>>,intercept:list<array<string,mixed>>} $quoteRules
|
||||
* @param array<string,float> $contextWeights
|
||||
* @param array<int,list<array<string,mixed>>> $contextReturns
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function importDeductQuoteResult(
|
||||
array $pair,
|
||||
array $context,
|
||||
?object $fallbackRow,
|
||||
array $ruleModes,
|
||||
array $quoteRules,
|
||||
array $contextWeights,
|
||||
array $contextReturns,
|
||||
): array {
|
||||
$type = $pair['surcharge_type'];
|
||||
$importedCourier = $pair['courier_name'];
|
||||
$contextCourier = trim((string) ($context['courier'] ?? ''));
|
||||
$fallbackCourier = trim((string) ($fallbackRow->courier_name ?? ''));
|
||||
$courier = $importedCourier !== '' ? $importedCourier : ($contextCourier !== '' ? $contextCourier : $fallbackCourier);
|
||||
$inspectionType = trim((string) ($context['inspection_type'] ?? ''));
|
||||
$items = $context['items'] ?? [];
|
||||
$primary = $this->primaryImportItem($items);
|
||||
$orderWeight = $this->importWeightSum($items, $contextWeights) ?? 0.0;
|
||||
$oId = (int) ($context['o_id'] ?? 0);
|
||||
$returnWaybills = [];
|
||||
$hasReturn = false;
|
||||
$returnedWeight = 0.0;
|
||||
$hasReturnWeight = false;
|
||||
|
||||
foreach ($contextReturns[$oId] ?? [] as $return) {
|
||||
$rQty = (float) ($return['r_qty'] ?? 0);
|
||||
$receiveDate = trim((string) ($return['receive_date'] ?? ''));
|
||||
|
||||
if ($rQty <= 0 || $receiveDate === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasReturn = true;
|
||||
$returnWaybill = trim((string) ($return['l_id'] ?? ''));
|
||||
|
||||
if ($returnWaybill !== '') {
|
||||
$returnWaybills[$returnWaybill] = true;
|
||||
}
|
||||
|
||||
$skuId = strtoupper(trim((string) ($return['sku_id'] ?? '')));
|
||||
$weight = $skuId !== '' ? ($contextWeights[$skuId] ?? null) : null;
|
||||
|
||||
if ($weight !== null) {
|
||||
$returnedWeight += $rQty * (float) $weight;
|
||||
$hasReturnWeight = true;
|
||||
}
|
||||
}
|
||||
|
||||
$settlementWeight = $hasReturn
|
||||
? ($hasReturnWeight && $returnedWeight > 0 ? $returnedWeight : $orderWeight)
|
||||
: $orderWeight;
|
||||
$fields = [
|
||||
'courier_name' => $courier !== '' ? $courier : null,
|
||||
'shipped_at' => trim((string) ($context['send_time'] ?? '')) ?: null,
|
||||
'platform_product_id' => $primary['platform_product_id'] !== '' ? $primary['platform_product_id'] : null,
|
||||
'product_code' => $primary['sku_id'] !== '' ? $primary['sku_id'] : ($primary['i_id'] !== '' ? $primary['i_id'] : null),
|
||||
'shop_name' => trim((string) ($context['shop_name'] ?? '')) ?: null,
|
||||
'online_order_no' => trim((string) ($context['online_order_no'] ?? '')) ?: null,
|
||||
'length' => null,
|
||||
'width' => null,
|
||||
'height' => null,
|
||||
'volume' => null,
|
||||
'inspection_type' => $inspectionType !== '' ? $inspectionType : null,
|
||||
'inspection_reason' => trim((string) ($context['inspection_reason'] ?? '')) ?: null,
|
||||
'return_waybill_no' => $returnWaybills === [] ? null : mb_substr((string) array_key_first($returnWaybills), 0, 191),
|
||||
'settlement_weight' => $settlementWeight > 0 ? round($settlementWeight, 2) : null,
|
||||
];
|
||||
|
||||
if ($importedCourier !== '' && $contextCourier !== '' && $importedCourier !== $contextCourier) {
|
||||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||||
'不结:该运单实际快递为「' . $contextCourier . '」,与导入的「' . $importedCourier . '」不一致', null);
|
||||
}
|
||||
|
||||
if ($inspectionType !== '扣单') {
|
||||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||||
'不结:该运单在查件工单里不是「扣单」类型', null);
|
||||
}
|
||||
|
||||
$rules = $type === '拦截费' ? ($quoteRules['intercept'] ?? []) : ($quoteRules['return'] ?? []);
|
||||
$rule = null;
|
||||
|
||||
foreach ($rules as $candidate) {
|
||||
if ($candidate['courier'] === $courier) {
|
||||
$rule = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($rule === null) {
|
||||
$modes = array_values(array_unique($ruleModes[$courier][$type] ?? []));
|
||||
|
||||
if ($modes !== []) {
|
||||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||||
'不结:快递「' . $courier . '」的' . $type . '报价规则(' . implode('、', $modes) . ')暂不支持自动核算', null);
|
||||
}
|
||||
|
||||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||||
'不结:快递「' . ($courier !== '' ? $courier : '未知') . '」没有启用的「' . $type . '」报价规则', null);
|
||||
}
|
||||
|
||||
if ($type === '拦截费') {
|
||||
return $this->importQuoteRow($pair, $fields, (float) $rule['value'], '已计算', '',
|
||||
'规则 #' . $rule['id'] . ';查件扣单按固定单价 ' . $this->trimNumber((float) $rule['value']) . ' 元/单');
|
||||
}
|
||||
|
||||
$evidenceParts = ['规则 #' . $rule['id']];
|
||||
|
||||
if ($hasReturn) {
|
||||
$evidenceParts[] = '售后退货退款已进仓,结算重量 ' . $this->trimNumber($settlementWeight) . 'kg';
|
||||
} else {
|
||||
$evidenceParts[] = '售后退货退款无进仓时间/实退数量';
|
||||
}
|
||||
|
||||
if ($rule['mode'] === '固定单价') {
|
||||
return $this->importQuoteRow(
|
||||
$pair,
|
||||
$fields,
|
||||
$hasReturn ? (float) $rule['value'] : 0.0,
|
||||
'已计算',
|
||||
$hasReturn ? '' : '不结:售后退货退款无进仓时间/实退数量',
|
||||
implode(';', $evidenceParts),
|
||||
);
|
||||
}
|
||||
|
||||
if (! $hasReturn) {
|
||||
return $this->importQuoteRow($pair, $fields, 0.0, '已计算',
|
||||
'不结:售后退货退款无进仓时间/实退数量', implode(';', $evidenceParts));
|
||||
}
|
||||
|
||||
if ($settlementWeight <= 0) {
|
||||
return $this->importQuoteRow($pair, $fields, null, '待核价',
|
||||
'待核价:商品资料缺少重量,暂无法按重量核算退回费', implode(';', $evidenceParts));
|
||||
}
|
||||
|
||||
return $this->importQuoteRow(
|
||||
$pair,
|
||||
$fields,
|
||||
round($settlementWeight * (float) $rule['value'], 2),
|
||||
'已计算',
|
||||
'',
|
||||
implode(';', $evidenceParts) . ';' . $this->trimNumber($settlementWeight) . 'kg × '
|
||||
. $this->trimNumber((float) $rule['value']) . ' 元/kg',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
|
||||
* @param array<string,mixed> $fields
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function importQuoteRow(
|
||||
array $pair,
|
||||
array $fields,
|
||||
?float $amount,
|
||||
string $status,
|
||||
string $reason,
|
||||
?string $evidence,
|
||||
): array {
|
||||
$decision = $amount !== null && $amount > 0 ? '正常结' : '不结';
|
||||
|
||||
return array_merge([
|
||||
'row_number' => $pair['row_number'],
|
||||
'waybill' => $pair['waybill'],
|
||||
'surcharge_type' => $pair['surcharge_type'],
|
||||
'decision' => $decision,
|
||||
'reason' => $decision === '正常结'
|
||||
? ''
|
||||
: ($reason !== '' ? $reason : '结算金额为 0'),
|
||||
], $fields, [
|
||||
'settlement_amount' => $amount,
|
||||
'settlement_status' => $status,
|
||||
'evidence' => $evidence,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前启用的报价规则里,可用于导入核算的部分。
|
||||
*
|
||||
* @return array{overlength:list<array{id:int,courier:string,threshold:float,amount:float}>,
|
||||
* return:list<array{id:int,courier:string,mode:string,value:float}>,
|
||||
* intercept:list<array{id:int,courier:string,value:float}>}
|
||||
*/
|
||||
private function importQuoteRules(): array
|
||||
{
|
||||
$rules = ['overlength' => [], 'return' => [], 'intercept' => []];
|
||||
|
||||
try {
|
||||
$rows = DB::table('business_courier_surcharge_quotes')
|
||||
->where('status', 'active')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
} catch (Throwable) {
|
||||
return $rules;
|
||||
}
|
||||
|
||||
foreach ($rows as $rule) {
|
||||
$types = json_decode((string) $rule->types, true) ?: [];
|
||||
$courier = trim((string) $rule->courier);
|
||||
$mode = trim((string) $rule->mode);
|
||||
$condition = (string) $rule->condition;
|
||||
$standard = (string) $rule->standard;
|
||||
|
||||
if ($courier === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array('超长费', $types, true)
|
||||
&& (str_contains($mode, '按尺寸') || str_contains($mode, '最长边'))
|
||||
&& preg_match('/单边超\s*(\d+(?:\.\d+)?)\s*(?:cm|厘米|CM|㎝)/u', $condition, $threshold) === 1
|
||||
&& preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/?\s*单/u', $standard, $price) === 1) {
|
||||
$rules['overlength'][] = [
|
||||
'id' => (int) $rule->id,
|
||||
'courier' => $courier,
|
||||
'threshold' => (float) $threshold[1],
|
||||
'amount' => (float) $price[1],
|
||||
];
|
||||
}
|
||||
|
||||
if (! str_contains($condition, '扣单')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array('拦截费', $types, true)
|
||||
&& $mode === '固定单价'
|
||||
&& preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $price) === 1) {
|
||||
$rules['intercept'][] = [
|
||||
'id' => (int) $rule->id,
|
||||
'courier' => $courier,
|
||||
'value' => (float) $price[1],
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array('退回费', $types, true)) {
|
||||
if ($mode === '固定单价'
|
||||
&& preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $price) === 1) {
|
||||
$rules['return'][] = [
|
||||
'id' => (int) $rule->id,
|
||||
'courier' => $courier,
|
||||
'mode' => '固定单价',
|
||||
'value' => (float) $price[1],
|
||||
];
|
||||
} elseif ($mode === '按重量'
|
||||
&& preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/\s*(?:kg|KG|公斤|千克)/u', $standard, $price) === 1) {
|
||||
$rules['return'][] = [
|
||||
'id' => (int) $rule->id,
|
||||
'courier' => $courier,
|
||||
'mode' => '按重量',
|
||||
'value' => (float) $price[1],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $skuIds
|
||||
* @return array<string,array{l:float,w:float,h:float}>
|
||||
*/
|
||||
private function importDimensions(array $skuIds): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
foreach (array_chunk(array_values(array_unique(array_filter($skuIds))), 500) as $chunk) {
|
||||
try {
|
||||
$rows = $this->connection->table('ops_jst_sku')
|
||||
->whereIn('sku_id', $chunk)
|
||||
->get(['sku_id', 'l', 'w', 'h']);
|
||||
} catch (Throwable) {
|
||||
return $map;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$length = (float) $row->l;
|
||||
$width = (float) $row->w;
|
||||
$height = (float) $row->h;
|
||||
|
||||
if ($length > 0 && $width > 0 && $height > 0) {
|
||||
$map[trim((string) $row->sku_id)] = ['l' => $length, 'w' => $width, 'h' => $height];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function trimNumber(float $value): string
|
||||
{
|
||||
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
|
||||
* @param array<string,array{courier:string}> $returnContext
|
||||
@ -851,7 +1466,7 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
|
||||
/**
|
||||
* @param array<string,array<string,mixed>> $returnContext
|
||||
* @return array<int,string>
|
||||
* @return array<int,list<array{o_id:int,l_id:string,sku_id:string,r_qty:float,receive_date:string}>>
|
||||
*/
|
||||
private function importReturns(array $returnContext): array
|
||||
{
|
||||
@ -882,7 +1497,8 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
|
||||
try {
|
||||
$rows = $upstream->select(
|
||||
'SELECT a.o_id, a.l_id, ri.r_qty, CONVERT(varchar(19), ri.receive_date, 120) AS receive_date '
|
||||
'SELECT a.o_id, a.l_id, ri.sku_id, ri.r_qty, '
|
||||
. 'CONVERT(varchar(19), ri.receive_date, 120) AS receive_date '
|
||||
. 'FROM rpt.jst_returnAndRefundInfo a '
|
||||
. 'INNER JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = a.as_id '
|
||||
. 'WHERE a.o_id IN (' . $placeholders . ')',
|
||||
@ -904,17 +1520,17 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
continue;
|
||||
}
|
||||
|
||||
$byOrder[$oId][$waybill] = true;
|
||||
$byOrder[$oId][] = [
|
||||
'o_id' => $oId,
|
||||
'l_id' => $waybill,
|
||||
'sku_id' => trim((string) ($row['sku_id'] ?? '')),
|
||||
'r_qty' => (float) ($row['r_qty'] ?? 0),
|
||||
'receive_date' => trim((string) ($row['receive_date'] ?? '')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$map = [];
|
||||
|
||||
foreach ($byOrder as $oId => $waybills) {
|
||||
$map[(int) $oId] = implode(',', array_keys($waybills));
|
||||
}
|
||||
|
||||
return $map;
|
||||
return $byOrder;
|
||||
}
|
||||
|
||||
/** @return array<string,array<string,list<string>>> */
|
||||
|
||||
@ -249,7 +249,7 @@
|
||||
<a class="settlement-button" href="{{ route('business.courier-surcharge.settlement.import-template') }}"><span class="material-icons-outlined" aria-hidden="true">download</span>下载导入模板</a>
|
||||
</div>
|
||||
<div class="settlement-import-meta" role="status"><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>
|
||||
<span class="settlement-table-hint">首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回与系统的差异金额。</span>
|
||||
<span class="settlement-table-hint">首行只需「单号」「附加费类型」:系统会按单号自动匹配快递,并按启用的报价核算应结金额;「金额」可选,填了会返回与系统的差异金额。</span>
|
||||
</form>
|
||||
|
||||
<div class="settlement-table-surface" data-finance-table-surface>
|
||||
|
||||
@ -254,7 +254,7 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
|
||||
return count($pairs) === 2
|
||||
&& $pairs[0]['waybill'] === 'DPK317138931411'
|
||||
&& $pairs[0]['surcharge_type'] === '退回费'
|
||||
&& $pairs[0]['courier_name'] === '曹县德邦小件'
|
||||
&& $pairs[0]['courier_name'] === ''
|
||||
&& $pairs[0]['amount'] === 10.0
|
||||
&& $pairs[1]['waybill'] === 'DPK1'
|
||||
&& $pairs[1]['surcharge_type'] === '超长费'
|
||||
@ -288,8 +288,8 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
|
||||
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$spreadsheet->getActiveSheet()->fromArray([
|
||||
['单号', '附加费类型', '快递名称', '金额'],
|
||||
['DPK317138931411', '退回费', '曹县德邦小件', 10],
|
||||
['单号', '附加费类型', '金额'],
|
||||
['DPK317138931411', '退回费', 10],
|
||||
['DPK1', '超长费'],
|
||||
]);
|
||||
$importPath = tempnam(sys_get_temp_dir(), 'surcharge_import_');
|
||||
@ -356,12 +356,11 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
|
||||
try {
|
||||
$this->assertSame('单号', $plain($sheet->getCell('A1')->getValue()));
|
||||
$this->assertSame('附加费类型', $plain($sheet->getCell('B1')->getValue()));
|
||||
$this->assertSame('快递名称', $plain($sheet->getCell('C1')->getValue()));
|
||||
$this->assertSame('金额(可选)', $plain($sheet->getCell('D1')->getValue()));
|
||||
$this->assertSame('金额(可选)', $plain($sheet->getCell('C1')->getValue()));
|
||||
$this->assertNull($plain($sheet->getCell('D1')->getValue()));
|
||||
$this->assertSame('DPK317138931411', $plain($sheet->getCell('A2')->getValue()));
|
||||
$this->assertSame('退回费', $plain($sheet->getCell('B2')->getValue()));
|
||||
$this->assertSame('曹县德邦小件', $plain($sheet->getCell('C2')->getValue()));
|
||||
$this->assertEquals(20, $plain($sheet->getCell('D2')->getValue()));
|
||||
$this->assertEquals(20, $plain($sheet->getCell('C2')->getValue()));
|
||||
} finally {
|
||||
@unlink($path);
|
||||
}
|
||||
|
||||
@ -5,7 +5,10 @@ namespace Tests\Unit\EcommerceFinance;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CourierSurchargeSettlementMirrorRepositoryTest extends TestCase
|
||||
@ -165,6 +168,183 @@ class CourierSurchargeSettlementMirrorRepositoryTest extends TestCase
|
||||
$this->assertStringContainsString('不一致', $result[4]['reason']);
|
||||
}
|
||||
|
||||
public function test_import_derives_courier_and_amount_from_overlength_quote(): void
|
||||
{
|
||||
$this->createTable();
|
||||
$this->seedSettlementPlaceholder();
|
||||
$this->createSupportTables();
|
||||
$this->seedQuotes([
|
||||
['id' => 6, 'courier' => '曹县德邦', 'types' => ['超长费'], 'mode' => '按尺寸', 'condition' => '单边超160cm', 'standard' => '8元/单'],
|
||||
]);
|
||||
DB::connection('business_mirror')->table('ops_jst_sku')->insert([
|
||||
['sku_id' => 'SKU-1', 'l' => 194, 'w' => 83, 'h' => 12.5, 'weight' => 2],
|
||||
]);
|
||||
$this->mockUpstream(static function (string $sql): array {
|
||||
if (str_contains($sql, 'jst_salesOutboundInfo')) {
|
||||
return [[
|
||||
'Id' => 1, 'io_id' => 9001, 'o_id' => 7001, 'l_id' => 'DPK9', 'so_id' => 'SO-9',
|
||||
'shop_name' => '拼多多-测试店', 'io_date' => '2026-10-10 10:00:00', 'logistics_company' => '曹县德邦',
|
||||
]];
|
||||
}
|
||||
|
||||
if (str_contains($sql, 'jst_salesOutboundItem')) {
|
||||
return [['io_id' => 9001, 'sku_id' => 'SKU-1', 'i_id' => 'I-1', 'qty' => 1]];
|
||||
}
|
||||
|
||||
if (str_contains($sql, 'Jst_SendDataDetail_View')) {
|
||||
return [['io_id' => 9001, 'sku_id' => 'SKU-1', 'shop_i_id' => '993815011271']];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
$result = $this->repository->decideImportRows([
|
||||
['row_number' => 2, 'waybill' => 'DPK9', 'surcharge_type' => '超长费', 'courier_name' => ''],
|
||||
]);
|
||||
|
||||
$this->assertSame('正常结', $result[0]['decision']);
|
||||
$this->assertSame('曹县德邦', $result[0]['courier_name']);
|
||||
$this->assertSame(8.0, (float) $result[0]['settlement_amount']);
|
||||
$this->assertSame('已计算', $result[0]['settlement_status']);
|
||||
$this->assertSame(194.0, (float) $result[0]['length']);
|
||||
$this->assertSame('993815011271', $result[0]['platform_product_id']);
|
||||
$this->assertSame('拼多多-测试店', $result[0]['shop_name']);
|
||||
$this->assertStringContainsString('160', (string) $result[0]['evidence']);
|
||||
}
|
||||
|
||||
public function test_import_derives_courier_and_amount_from_deduct_quotes(): void
|
||||
{
|
||||
$this->createTable();
|
||||
$this->seedSettlementPlaceholder();
|
||||
$this->createSupportTables();
|
||||
$this->seedQuotes([
|
||||
['id' => 20, 'courier' => '曹县德邦小件', 'types' => ['退回费'], 'mode' => '固定单价', 'condition' => '查件扣单并且买家原因已退回', 'standard' => '30元/单'],
|
||||
['id' => 21, 'courier' => '曹县德邦小件', 'types' => ['拦截费'], 'mode' => '固定单价', 'condition' => '查件扣单', 'standard' => '20元/单'],
|
||||
]);
|
||||
DB::connection('business_mirror')->table('ops_jst_sku')->insert([
|
||||
['sku_id' => 'SKU-2', 'l' => 0, 'w' => 0, 'h' => 0, 'weight' => 2.4],
|
||||
]);
|
||||
DB::connection('business_mirror')->table('js_jst_work_order_info')->insert([
|
||||
'Id' => 1,
|
||||
'title' => '查件',
|
||||
'businessFieldList' => json_encode([
|
||||
['fieldId' => 'lIdField', 'value' => 'DPK10'],
|
||||
['fieldId' => 'logisticsField', 'value' => '曹县德邦小件'],
|
||||
['fieldId' => 'o_id', 'value' => 7001],
|
||||
['fieldId' => 'orderField', 'value' => '260710-1'],
|
||||
['fieldId' => 'shopField', 'valueLabel' => '拼多多-测试店'],
|
||||
['fieldId' => 'orderSendTime', 'value' => '2026-10-10 09:00:00'],
|
||||
['fieldId' => 'SELECT_CASCADEField1413856620250813172526', 'value' => '扣单/买家-不想要'],
|
||||
['fieldId' => 'itemInfo', 'childFieldList' => [[
|
||||
['fieldId' => 'skuId', 'value' => 'SKU-2'],
|
||||
['fieldId' => 'qty', 'value' => 1],
|
||||
['fieldId' => 'platformItemId', 'value' => 'P-2'],
|
||||
['fieldId' => 'iid', 'value' => 'I-2'],
|
||||
]]],
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
$this->mockUpstream(static function (string $sql): array {
|
||||
if (str_contains($sql, 'jst_returnAndRefundInfo')) {
|
||||
return [[
|
||||
'o_id' => 7001, 'l_id' => 'DPK10', 'sku_id' => 'SKU-2', 'qty' => 1, 'r_qty' => 1,
|
||||
'receive_date' => '2026-10-12 09:00:00',
|
||||
]];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
$result = $this->repository->decideImportRows([
|
||||
['row_number' => 2, 'waybill' => 'DPK10', 'surcharge_type' => '退回费', 'courier_name' => ''],
|
||||
['row_number' => 3, 'waybill' => 'DPK10', 'surcharge_type' => '拦截费', 'courier_name' => ''],
|
||||
]);
|
||||
|
||||
$this->assertSame('正常结', $result[0]['decision']);
|
||||
$this->assertSame('曹县德邦小件', $result[0]['courier_name']);
|
||||
$this->assertSame(30.0, (float) $result[0]['settlement_amount']);
|
||||
$this->assertSame(2.4, (float) $result[0]['settlement_weight']);
|
||||
$this->assertSame('扣单', $result[0]['inspection_type']);
|
||||
$this->assertSame('DPK10', $result[0]['return_waybill_no']);
|
||||
|
||||
$this->assertSame('正常结', $result[1]['decision']);
|
||||
$this->assertSame(20.0, (float) $result[1]['settlement_amount']);
|
||||
}
|
||||
|
||||
private function seedSettlementPlaceholder(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table(CourierSurchargeSettlementMirrorRepository::TABLE)->insert([
|
||||
'rule_id' => 1, 'surcharge_type' => '超长费', 'courier_name' => '占位快递',
|
||||
'waybill' => 'PLACEHOLDER', 'settlement_amount' => 0, 'settlement_status' => '已计算',
|
||||
'shipped_at' => '2026-10-01 00:00:00', 'pulled_at' => '2026-10-01 00:00:00',
|
||||
'created_at' => '2026-10-01 00:00:00', 'updated_at' => '2026-10-01 00:00:00',
|
||||
]);
|
||||
}
|
||||
|
||||
private function createSupportTables(): void
|
||||
{
|
||||
$defaultSchema = Schema::connection((string) config('database.default'));
|
||||
|
||||
if (! $defaultSchema->hasTable('business_courier_surcharge_quotes')) {
|
||||
$defaultSchema->create('business_courier_surcharge_quotes', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('courier');
|
||||
$table->string('types');
|
||||
$table->string('mode');
|
||||
$table->string('condition');
|
||||
$table->string('standard');
|
||||
$table->string('status');
|
||||
});
|
||||
}
|
||||
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('ops_jst_sku', function (Blueprint $table): void {
|
||||
$table->string('sku_id');
|
||||
$table->decimal('l', 12, 2)->default(0);
|
||||
$table->decimal('w', 12, 2)->default(0);
|
||||
$table->decimal('h', 12, 2)->default(0);
|
||||
$table->decimal('weight', 12, 2)->default(0);
|
||||
});
|
||||
$schema->create('js_jst_work_order_info', function (Blueprint $table): void {
|
||||
$table->bigIncrements('Id');
|
||||
$table->string('title')->nullable();
|
||||
$table->text('businessFieldList')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rules */
|
||||
private function seedQuotes(array $rules): void
|
||||
{
|
||||
$table = DB::connection((string) config('database.default'))->table('business_courier_surcharge_quotes');
|
||||
$table->delete();
|
||||
$table->insert(
|
||||
array_map(static fn (array $rule): array => [
|
||||
'id' => $rule['id'],
|
||||
'company_id' => 1,
|
||||
'courier' => $rule['courier'],
|
||||
'scope' => '快递应付',
|
||||
'reconciler' => '测试',
|
||||
'types' => json_encode($rule['types'], JSON_UNESCAPED_UNICODE),
|
||||
'mode' => $rule['mode'],
|
||||
'condition' => $rule['condition'],
|
||||
'standard' => $rule['standard'],
|
||||
'effective_at' => '2026-01-01',
|
||||
'status' => 'active',
|
||||
], $rules),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param callable(string,array):list<array<string,mixed>> $handler */
|
||||
private function mockUpstream(callable $handler): void
|
||||
{
|
||||
$query = Mockery::mock(UpstreamReadOnlyQuery::class);
|
||||
$query->shouldReceive('select')->andReturnUsing(
|
||||
static fn (string $sql, array $bindings = []): array => $handler($sql, $bindings),
|
||||
);
|
||||
$factory = Mockery::mock(UpstreamReadOnlyQueryFactory::class);
|
||||
$factory->shouldReceive('forSource')->andReturn($query);
|
||||
$this->app->instance(UpstreamReadOnlyQueryFactory::class, $factory);
|
||||
}
|
||||
|
||||
private function createTable(): void
|
||||
{
|
||||
Schema::connection('business_mirror')->create(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user