附加费结算:超长费/退回费/拦截费拉取、导入核验与模板导出 #305

Merged
zhoushengchen merged 4 commits from feature/zhoushengchen-surcharge-settlement into main 2026-10-12 09:33:25 +08:00
13 changed files with 3757 additions and 14 deletions

View File

@ -0,0 +1,683 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Throwable;
/**
* 按「附加费报价维护」里启用的超长费规则,从销售出库单拉取结算行。
*
* 口径(第一期只做超长费,且只处理“按尺寸/最长边”的规则):
* - 规则状态必须是 active;
* - 生效条件解析出单边阈值(如“单边超160cm”);
* - 计价标准解析出每单金额(如“8元/单”);
* - 出库单按 logistics_company = 快递名称 匹配,运单号取 l_id,只取已分配运单号的单;
* - 长宽高按明细条形码 sku_id 到商品及库存管理(商品资料 jst_sku)换算,一票多件取最大边;
* - 查件类型/查件原因/退回单号/平台商品ID 在出库单与商品资料里取不到,留空。
*/
class PullCourierSurchargeOverlength extends Command
{
protected $signature = 'courier-surcharge:pull-overlength
{--days=90 : 默认拉取最近多少天(无 --from/--to 时生效)}
{--from= : 出库日期起(含),Y-m-d}
{--to= : 出库日期止(不含),Y-m-d}
{--rule=* : 只跑指定规则 ID,可多次传入}
{--chunk=300 : 每批处理的出库单数量}';
protected $description = '按启用的超长费报价规则,从销售出库单拉取附加费结算行';
public function handle(): int
{
$from = $this->option('from') !== null && $this->option('from') !== ''
? Carbon::parse((string) $this->option('from'))->startOfDay()
: now()->subDays(max((int) $this->option('days'), 1))->startOfDay();
$to = $this->option('to') !== null && $this->option('to') !== ''
? Carbon::parse((string) $this->option('to'))->startOfDay()
: now()->addDay()->startOfDay();
$onlyRules = array_filter(array_map('intval', (array) $this->option('rule')));
$chunkSize = min(max((int) $this->option('chunk'), 50), 500);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$schema = DB::connection($connection)->getSchemaBuilder();
if (! $schema->hasTable('courier_surcharge_settlement_rows')) {
$this->error('结算表 courier_surcharge_settlement_rows 不存在,请先执行迁移。');
return self::FAILURE;
}
$rules = $this->overlengthRules($onlyRules);
if ($rules === []) {
$this->warn('没有启用且可自动计算的超长费规则。');
return self::SUCCESS;
}
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
$totalWritten = 0;
foreach ($rules as $rule) {
$threshold = $rule['threshold'];
$amount = $rule['amount'];
$this->info(sprintf(
'规则 #%d %s:单边超 %scm 计 %s 元/单,取数 %s ~ %s',
$rule['id'],
$rule['courier'],
$threshold,
$amount,
$from->toDateString(),
$to->toDateString(),
));
$orders = $this->pullOrders($upstream, $rule['courier'], $from, $to, $chunkSize);
if ($orders === []) {
$this->line(' 没有运单号非空的出库单,跳过。');
continue;
}
$written = $this->writeSettlementRows($connection, $rule, $orders, $threshold, $amount, $from, $to);
$totalWritten += $written;
$this->line(' 出库单 ' . count($orders) . ' 单,命中超长并写入 ' . $written . ' 行。');
}
$this->info('完成,共写入 ' . $totalWritten . ' 行结算数据。');
return self::SUCCESS;
}
/**
* @param list<int> $onlyRules
* @return list<array{id:int,courier:string,threshold:float,amount:float}>
*/
private function overlengthRules(array $onlyRules): array
{
$rules = [];
foreach (DB::table('business_courier_surcharge_quotes')->where('status', 'active')->orderBy('id')->get() as $rule) {
$types = json_decode((string) $rule->types, true) ?: [];
if (! in_array('超长费', $types, true)) {
continue;
}
if (! str_contains((string) $rule->mode, '按尺寸') && ! str_contains((string) $rule->mode, '最长边')) {
continue;
}
if ($onlyRules !== [] && ! in_array((int) $rule->id, $onlyRules, true)) {
continue;
}
$condition = (string) $rule->condition;
$standard = (string) $rule->standard;
if (preg_match('/单边超\s*(\d+(?:\.\d+)?)\s*(?:cm|厘米|CM|㎝)/u', $condition, $matched) !== 1) {
$this->warn('规则 #' . $rule->id . ' 的生效条件解析不出单边阈值,已跳过:' . $condition);
continue;
}
$threshold = (float) $matched[1];
if (preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/?\s*单/u', $standard, $priceMatched) !== 1) {
$this->warn('规则 #' . $rule->id . ' 的计价标准解析不出每单金额,已跳过:' . $standard);
continue;
}
$rules[] = [
'id' => (int) $rule->id,
'courier' => trim((string) $rule->courier),
'threshold' => $threshold,
'amount' => (float) $priceMatched[1],
];
}
return $rules;
}
/**
* 拉取出库单(只取已分配运单号的单)。
*
* @return list<array<string,mixed>>
*/
private function pullOrders(
UpstreamReadOnlyQuery $upstream,
string $courier,
Carbon $from,
Carbon $to,
int $chunkSize,
): array {
$orders = [];
$lastId = 0;
while (true) {
$rows = $upstream->select(
'SELECT TOP ' . $chunkSize . ' Id, io_id, o_id, l_id, so_id, shop_name, io_date, weight, f_weight '
. 'FROM dbo.jst_salesOutboundInfo '
. 'WHERE logistics_company = ? AND io_date >= ? AND io_date < ? '
. "AND LEN(ISNULL(l_id, '')) > 0 AND Id > ? "
. 'ORDER BY Id',
[$courier, $from->toDateTimeString(), $to->toDateTimeString(), $lastId],
);
if ($rows === []) {
break;
}
foreach ($rows as $row) {
$row = (array) $row;
$orders[] = $row;
$lastId = max($lastId, (int) $row['Id']);
}
if (count($rows) < $chunkSize) {
break;
}
}
return $orders;
}
/**
* @param list<array<string,mixed>> $orders
*/
private function writeSettlementRows(
string $connection,
array $rule,
array $orders,
float $threshold,
float $amount,
Carbon $from,
Carbon $to,
): int {
$ioIds = array_values(array_filter(array_map(
static fn (array $order): int => (int) ($order['io_id'] ?? 0),
$orders,
), static fn (int $value): bool => $value > 0));
$items = $this->pullItems($ioIds);
$dimensions = $this->dimensionMap($items);
$platformProductIds = $this->platformProductIds($items, $orders);
$onlineOrderNumbers = $this->onlineOrderNumbers($orders);
$now = now();
$written = 0;
$rows = [];
foreach ($orders as $order) {
$ioId = (int) ($order['io_id'] ?? 0);
if ($ioId <= 0) {
continue;
}
$best = null;
foreach ($items[$ioId] ?? [] as $item) {
$skuId = trim((string) ($item['sku_id'] ?? ''));
$dim = $dimensions[$skuId] ?? null;
if ($dim === null) {
continue;
}
$maxSide = max($dim['l'], $dim['w'], $dim['h']);
if ($best === null || $maxSide > $best['max_side']) {
$best = [
'max_side' => $maxSide,
'sku_id' => $skuId,
'i_id' => trim((string) ($item['i_id'] ?? '')),
'length' => $dim['l'],
'width' => $dim['w'],
'height' => $dim['h'],
'platform_product_id' => $platformProductIds[$ioId][$skuId] ?? ($platformProductIds[$ioId][''] ?? null),
];
}
}
if ($best === null || $best['max_side'] <= $threshold) {
continue;
}
$rows[] = [
'rule_id' => $rule['id'],
'surcharge_type' => '超长费',
'courier_name' => $rule['courier'],
'waybill' => trim((string) ($order['l_id'] ?? '')),
'outbound_io_id' => $ioId,
'platform_product_id' => $best['platform_product_id'],
'product_code' => $best['sku_id'] !== '' ? $best['sku_id'] : ($best['i_id'] !== '' ? $best['i_id'] : null),
'shop_name' => mb_substr(trim((string) ($order['shop_name'] ?? '')), 0, 191) ?: null,
'online_order_no' => $onlineOrderNumbers[(int) ($order['o_id'] ?? 0)] ?? null,
'length' => $best['length'],
'width' => $best['width'],
'height' => $best['height'],
'volume' => round($best['length'] * $best['width'] * $best['height'], 2),
'inspection_type' => null,
'inspection_reason' => null,
'return_waybill_no' => null,
'settlement_weight' => null,
'settlement_amount' => $amount,
'settlement_status' => '已计算',
'evidence' => mb_substr(sprintf(
'条形码 %s 尺寸 %s×%s×%s,最长边 %scm > %scm',
$best['sku_id'] !== '' ? $best['sku_id'] : '(无)',
$this->trimNumber($best['length']),
$this->trimNumber($best['width']),
$this->trimNumber($best['height']),
$this->trimNumber($best['max_side']),
$this->trimNumber($threshold),
), 0, 500),
'shipped_at' => $order['io_date'] ?? null,
'pulled_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
$written++;
}
$target = DB::connection($connection)->table('courier_surcharge_settlement_rows');
// 同一规则、同一取数窗口内重跑时先清掉旧行,避免残留历史判断。
$target->where('rule_id', $rule['id'])
->where('shipped_at', '>=', $from->toDateTimeString())
->where('shipped_at', '<', $to->toDateTimeString())
->delete();
foreach (array_chunk($rows, 500) as $chunk) {
$target->insert($chunk);
}
return $written;
}
/**
* @param list<int> $ioIds
* @return array<int,list<array<string,mixed>>>
*/
private function pullItems(array $ioIds): array
{
if ($ioIds === []) {
return [];
}
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
$items = [];
foreach (array_chunk($ioIds, 500) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$rows = $upstream->select(
'SELECT io_id, sku_id, i_id, name, qty FROM dbo.jst_salesOutboundItem WHERE io_id IN (' . $placeholders . ')',
$chunk,
);
foreach ($rows as $row) {
$row = (array) $row;
$items[(int) $row['io_id']][] = $row;
}
}
return $items;
}
/**
* 线上订单号:优先用出库单自带 so_id,为空时按 o_id 到「未发货订单」表兜底。
*
* 部分渠道(如阿里巴巴-常州玖阔)的出库单 so_id 为空,但发货中订单表
* jst_nosend_orders 里仍能按 o_id 找到线上单号。
*
* @param list<array<string,mixed>> $orders
* @return array<int,string>
*/
private function onlineOrderNumbers(array $orders): array
{
$map = [];
$missing = [];
foreach ($orders as $order) {
$oId = (int) ($order['o_id'] ?? 0);
$soId = trim((string) ($order['so_id'] ?? ''));
if ($soId !== '') {
$map[$oId] = mb_substr($soId, 0, 191);
} elseif ($oId > 0) {
$missing[$oId] = true;
}
}
if ($missing === []) {
return $map;
}
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
foreach (array_chunk(array_keys($missing), 500) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
"SELECT o_id, so_id FROM dbo.jst_nosend_orders WHERE o_id IN (" . $placeholders . ')'
. " AND LEN(ISNULL(so_id, '')) > 0",
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
}
}
// 仍未补到的(多为已发货、已离开未发货订单表的单),到订单视图兜底;
// 该视图覆盖部分不在 jst_Orders 主表里的店铺(如阿里巴巴-常州玖阔)。
$remaining = array_values(array_diff(array_keys($missing), array_keys($map)));
foreach (array_chunk($remaining, 500) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
"SELECT o_id, so_id FROM dbo.jst_ordersView WHERE o_id IN (" . $placeholders . ')'
. " AND LEN(ISNULL(so_id, '')) > 0",
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
}
}
// 再兜底:发货数据视图在订单离开 jst_Orders/ordersView/nosend 后仍保留 o_id → so_id,
// 例如 2026-07-31 新佰广1688 已归档的出库单。
$remaining = array_values(array_diff(array_keys($missing), array_keys($map)));
foreach (array_chunk($remaining, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT DISTINCT o_id, so_id FROM dbo.Jst_SendData_View WHERE o_id IN (' . $placeholders . ')'
. ' AND LEN(so_id) > 0',
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
}
}
// 最后兜底:全渠道订单列表按月归档,部分天猫订单只在该表保留 o_id → so_id。
$remaining = array_values(array_diff(array_keys($missing), array_keys($map)));
if ($remaining !== []) {
$monthById = [];
foreach ($orders as $order) {
$oId = (int) ($order['o_id'] ?? 0);
$ioDate = trim((string) ($order['io_date'] ?? ''));
if ($oId <= 0 || $ioDate === '') {
continue;
}
try {
$monthById[$oId] = Carbon::parse($ioDate)->format('Ym');
} catch (Throwable) {
// io_date 无法解析时只查当前表。
}
}
$tables = ['jst_qmcust_orderlist' => array_values($remaining)];
foreach ($remaining as $oId) {
$month = $monthById[$oId] ?? null;
if ($month !== null) {
$tables['jst_qmcust_orderlist_' . $month][] = $oId;
}
}
foreach ($tables as $table => $ids) {
foreach (array_chunk($ids, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT o_id, so_id FROM dbo.' . $table . ' WHERE o_id IN (' . $placeholders . ')'
. ' AND LEN(so_id) > 0',
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
}
}
}
}
return $map;
}
/**
* 平台商品ID:查件工单里的 platformItemId 与订单/发货明细的 shop_i_id 同源。
* 优先按 io_id + sku_id 从发货明细视图取;缺行时按 o_id 到全渠道订单明细(当前表 + 月度归档)兜底。
*
* @param array<int,list<array<string,mixed>>> $items
* @param list<array<string,mixed>> $orders
* @return array<int,array<string,string>>
*/
private function platformProductIds(array $items, array $orders): array
{
$map = [];
$ioIds = array_keys($items);
if ($ioIds === []) {
return $map;
}
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
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);
$skuId = trim((string) ($row['sku_id'] ?? ''));
$shopIId = mb_substr(trim((string) ($row['shop_i_id'] ?? '')), 0, 191);
if ($ioId <= 0 || $shopIId === '') {
continue;
}
if ($skuId !== '') {
$map[$ioId][$skuId] = $shopIId;
}
$map[$ioId][''] ??= $shopIId;
}
}
// 剩余按 o_id 到全渠道订单明细(当前表 + 月度归档)兜底。
$oIdByIoId = [];
$monthByIoId = [];
foreach ($orders as $order) {
$ioId = (int) ($order['io_id'] ?? 0);
$oId = (int) ($order['o_id'] ?? 0);
$ioDate = trim((string) ($order['io_date'] ?? ''));
if ($ioId <= 0 || $oId <= 0) {
continue;
}
$oIdByIoId[$ioId] = $oId;
if ($ioDate === '') {
continue;
}
try {
$monthByIoId[$ioId] = Carbon::parse($ioDate)->format('Ym');
} catch (Throwable) {
// io_date 无法解析时只查当前表。
}
}
$missing = [];
foreach ($ioIds as $ioId) {
if (($map[$ioId] ?? []) !== []) {
continue;
}
$oId = $oIdByIoId[$ioId] ?? 0;
if ($oId > 0) {
$missing[$ioId] = $oId;
}
}
if ($missing === []) {
return $map;
}
$tables = ['jst_qmcust_orderlist_item' => $missing];
foreach ($missing as $ioId => $oId) {
$month = $monthByIoId[$ioId] ?? null;
if ($month !== null) {
$tables['jst_qmcust_orderlist_item_' . $month][$ioId] = $oId;
}
}
foreach ($tables as $table => $idsByIo) {
$oIds = array_values(array_unique(array_values($idsByIo)));
foreach (array_chunk($oIds, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT o_id, sku_id, shop_i_id FROM dbo.' . $table . ' WHERE o_id IN (' . $placeholders . ')'
. ' AND LEN(shop_i_id) > 0',
$chunk,
);
} catch (Throwable) {
continue;
}
$byOrder = [];
foreach ($rows as $row) {
$row = (array) $row;
$skuId = trim((string) ($row['sku_id'] ?? ''));
$shopIId = mb_substr(trim((string) ($row['shop_i_id'] ?? '')), 0, 191);
if ($shopIId === '') {
continue;
}
$byOrder[(int) ($row['o_id'] ?? 0)][] = ['sku_id' => $skuId, 'shop_i_id' => $shopIId];
}
foreach ($idsByIo as $ioId => $oId) {
foreach ($byOrder[$oId] ?? [] as $entry) {
if ($entry['sku_id'] !== '') {
$map[$ioId][$entry['sku_id']] = $entry['shop_i_id'];
}
$map[$ioId][''] ??= $entry['shop_i_id'];
}
}
}
}
return $map;
}
/**
* @param array<int,list<array<string,mixed>>> $items
* @return array<string,array{l:float,w:float,h:float}>
*/
private function dimensionMap(array $items): array
{
$skuIds = [];
foreach ($items as $rows) {
foreach ($rows as $row) {
$skuId = trim((string) ($row['sku_id'] ?? ''));
if ($skuId !== '') {
$skuIds[$skuId] = true;
}
}
}
$skuIds = array_keys($skuIds);
$dimensions = [];
// 商品及库存管理(商品资料)已在共享镜像里,直接读镜像,不再压上游。
foreach (array_chunk($skuIds, 500) as $chunk) {
try {
$rows = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('ops_jst_sku')
->whereIn('sku_id', $chunk)
->get(['sku_id', 'l', 'w', 'h']);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$l = (float) $row->l;
$w = (float) $row->w;
$h = (float) $row->h;
if ($l > 0 && $w > 0 && $h > 0) {
$dimensions[trim((string) $row->sku_id)] = ['l' => $l, 'w' => $w, 'h' => $h];
}
}
}
return $dimensions;
}
private function trimNumber(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
}

View File

@ -0,0 +1,727 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Throwable;
/**
* 按「附加费报价维护」里启用的退回费 / 拦截费规则,从查件扣单拉取结算行。
*
* 口径:
* - 规则状态 active、生效条件含「查件扣单」;
* - 退回费:types 含「退回费」,支持 固定单价 / 按重量;已退回(售后退货退款
* receive_date 进仓时间 + r_qty 实退数量)按标准计价,未退回计 0;
* 结算重量取商品资料 ops_jst_sku.weight,按实退数量汇总;未退回时按发货数量展示;
* - 拦截费:types 含「拦截费」且模式为固定单价,查件扣单即按标准金额计价;
* - 扣单来源:共享镜像 js_jst_work_order_info 中 title=查件、查件类型=扣单 的工单。
*/
class PullCourierSurchargeReturn extends Command
{
private const CHECK_TYPE_FIELD = 'SELECT_CASCADEField1413856620250813172526';
protected $signature = 'courier-surcharge:pull-return
{--days=90 : 默认拉取最近多少天(无 --from/--to 时生效,按查件工单创建时间)}
{--from= : 查件工单创建起(含),Y-m-d}
{--to= : 查件工单创建止(不含),Y-m-d}
{--rule=* : 只跑指定规则 ID,可多次传入}
{--type=* : 只跑指定附加费类型(退回费/拦截费),可多次传入}
{--limit=0 : 调试用,最多处理多少张扣单工单,0 表示不限}';
protected $description = '按启用的退回费/拦截费报价规则,从查件扣单拉取结算行';
public function handle(): int
{
$from = $this->option('from') !== null && $this->option('from') !== ''
? Carbon::parse((string) $this->option('from'))->startOfDay()
: now()->subDays(max((int) $this->option('days'), 1))->startOfDay();
$to = $this->option('to') !== null && $this->option('to') !== ''
? Carbon::parse((string) $this->option('to'))->startOfDay()
: now()->addDay()->startOfDay();
$onlyRules = array_filter(array_map('intval', (array) $this->option('rule')));
$onlyTypes = array_values(array_filter(
array_map(static fn ($type): string => trim((string) $type), (array) $this->option('type')),
static fn (string $type): bool => in_array($type, ['退回费', '拦截费'], true),
));
$limit = max((int) $this->option('limit'), 0);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$schema = DB::connection($connection)->getSchemaBuilder();
if (! $schema->hasTable('courier_surcharge_settlement_rows')) {
$this->error('结算表 courier_surcharge_settlement_rows 不存在,请先执行迁移。');
return self::FAILURE;
}
if (! $schema->hasTable('js_jst_work_order_info')) {
$this->error('查件工单镜像 js_jst_work_order_info 不存在,请先完成镜像同步。');
return self::FAILURE;
}
$rules = $this->rules($onlyRules, $onlyTypes);
if ($rules === []) {
$this->warn('没有启用且可自动计算的退回费/拦截费规则。');
return self::SUCCESS;
}
$orders = $this->pullDeductOrders($connection, $from, $to, $limit);
$this->info(sprintf('取数 %s ~ %s,扣单工单 %d 单。', $from->toDateString(), $to->toDateString(), count($orders)));
if ($orders === []) {
return self::SUCCESS;
}
$ordersByCourier = [];
foreach ($orders as $order) {
$ordersByCourier[$order['courier']][] = $order;
}
$totalWritten = 0;
foreach ($rules as $rule) {
$matched = $ordersByCourier[$rule['courier']] ?? [];
if ($matched === []) {
$this->line('规则 #' . $rule['id'] . ' ' . $rule['courier'] . ':没有匹配的扣单工单,跳过。');
continue;
}
$written = $this->writeSettlementRows($connection, $rule, $matched);
$totalWritten += $written;
$this->line('规则 #' . $rule['id'] . ' ' . $rule['courier'] . '(' . $rule['type'] . '/' . $rule['mode'] . '):扣单 ' . count($matched) . ' 单,写入 ' . $written . ' 行。');
}
$this->info('完成,共写入 ' . $totalWritten . ' 行附加费结算数据。');
return self::SUCCESS;
}
/**
* @param list<int> $onlyRules
* @param list<string> $onlyTypes
* @return list<array{id:int,type:string,courier:string,mode:string,value:float,effective_at:?string}>
*/
private function rules(array $onlyRules, array $onlyTypes): array
{
$rules = [];
foreach (DB::table('business_courier_surcharge_quotes')->where('status', 'active')->orderBy('id')->get() as $rule) {
$types = json_decode((string) $rule->types, true) ?: [];
$candidateTypes = array_values(array_intersect(['退回费', '拦截费'], $types));
if ($candidateTypes === []) {
continue;
}
if ($onlyRules !== [] && ! in_array((int) $rule->id, $onlyRules, true)) {
continue;
}
$condition = trim((string) $rule->condition);
if (! str_contains($condition, '扣单')) {
$this->warn('规则 #' . $rule->id . ' 的生效条件不含「扣单」,已跳过:' . $condition);
continue;
}
$mode = trim((string) $rule->mode);
$standard = trim((string) $rule->standard);
foreach ($candidateTypes as $type) {
if ($onlyTypes !== [] && ! in_array($type, $onlyTypes, true)) {
continue;
}
if ($type === '退回费') {
if ($mode === '固定单价') {
$value = $this->parseFixedAmount($standard);
} elseif ($mode === '按重量') {
$value = $this->parseWeightRate($standard);
} else {
$this->line('规则 #' . $rule->id . ' ' . $rule->courier . ' 的退回费模式「' . $mode . '」暂不支持自动计算,已跳过。');
continue;
}
} else {
if ($mode === '固定单价') {
$value = $this->parseFixedAmount($standard);
} else {
$this->line('规则 #' . $rule->id . ' ' . $rule->courier . ' 的拦截费模式「' . $mode . '」暂不支持自动计算,已跳过。');
continue;
}
}
if ($value === null) {
$this->warn('规则 #' . $rule->id . ' 的' . $type . '计价标准解析失败,已跳过:' . $standard);
continue;
}
$rules[] = [
'id' => (int) $rule->id,
'type' => $type,
'courier' => trim((string) $rule->courier),
'mode' => $mode,
'value' => $value,
'effective_at' => $rule->effective_at !== null && $rule->effective_at !== ''
? (string) $rule->effective_at
: null,
];
}
}
return $rules;
}
private function parseFixedAmount(string $standard): ?float
{
return preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $matched) === 1
? (float) $matched[1]
: null;
}
private function parseWeightRate(string $standard): ?float
{
return preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/\s*(?:kg|KG|公斤|千克)/u', $standard, $matched) === 1
? (float) $matched[1]
: null;
}
/**
* 从查件工单镜像按创建时间拉取扣单工单。
*
* @return list<array<string,mixed>>
*/
private function pullDeductOrders(string $connection, Carbon $from, Carbon $to, int $limit): array
{
$orders = [];
$lastId = 0;
$batchSize = 500;
while (true) {
$rows = DB::connection($connection)->table('js_jst_work_order_info')
->where('title', '查件')
->where('businessFieldList', 'like', '%"value":"扣单/%')
->where('created', '>=', $from->toDateTimeString())
->where('created', '<', $to->toDateTimeString())
->where('Id', '>', $lastId)
->orderBy('Id')
->limit($batchSize)
->get(['Id', 'woNo', 'created', 'businessFieldList']);
if ($rows->isEmpty()) {
break;
}
foreach ($rows as $row) {
$lastId = max($lastId, (int) $row->Id);
$order = $this->parseWorkOrder((string) $row->businessFieldList, (string) $row->woNo, (string) $row->created);
if ($order !== null) {
$orders[] = $order;
}
if ($limit > 0 && count($orders) >= $limit) {
break 2;
}
}
if ($rows->count() < $batchSize) {
break;
}
}
return $orders;
}
/**
* @return array<string,mixed>|null
*/
private function parseWorkOrder(string $json, string $woNo, string $created): ?array
{
$fields = json_decode($json, true);
if (! is_array($fields)) {
return null;
}
$flat = [];
foreach ($fields as $field) {
if (is_array($field) && isset($field['fieldId'])) {
$flat[(string) $field['fieldId']] = $field;
}
}
$waybill = trim((string) ($flat['lIdField']['value'] ?? ''));
$oId = (int) ($flat['o_id']['value'] ?? 0);
$courier = trim((string) ($flat['logisticsField']['value'] ?? ''));
$cascade = trim((string) ($flat[self::CHECK_TYPE_FIELD]['value'] ?? ''));
if ($waybill === '' || $courier === '' || ! str_starts_with($cascade, '扣单')) {
return null;
}
[$inspectionType, $inspectionReason] = array_pad(explode('/', $cascade, 2), 2, '');
$items = [];
foreach (($flat['itemInfo']['childFieldList'] ?? []) as $group) {
if (! is_array($group)) {
continue;
}
$item = [];
foreach ($group as $field) {
if (is_array($field) && isset($field['fieldId'])) {
$item[(string) $field['fieldId']] = $field['value'] ?? null;
}
}
$skuId = strtoupper(trim((string) ($item['skuId'] ?? '')));
if ($skuId === '') {
continue;
}
$items[] = [
'sku_id' => $skuId,
'qty' => (float) ($item['qty'] ?? 0),
'platform_product_id' => mb_substr(trim((string) ($item['platformItemId'] ?? '')), 0, 191),
'i_id' => trim((string) ($item['iid'] ?? '')),
'name' => mb_substr(trim((string) ($item['name'] ?? '')), 0, 191),
];
}
$createdAt = null;
try {
$createdAt = Carbon::parse($created);
} catch (Throwable) {
// created 无法解析时仍保留工单,仅规则生效时间过滤会放宽。
}
return [
'wo_no' => $woNo,
'waybill' => $waybill,
'o_id' => $oId,
'courier' => $courier,
'online_order_no' => mb_substr(trim((string) ($flat['orderField']['value'] ?? '')), 0, 191),
'shop_name' => mb_substr(trim((string) ($flat['shopField']['valueLabel'] ?? ($flat['shopField']['value'] ?? ''))), 0, 191),
'inspection_type' => mb_substr(trim((string) $inspectionType), 0, 60),
'inspection_reason' => mb_substr(trim((string) $inspectionReason), 0, 120),
'send_time' => trim((string) ($flat['orderSendTime']['value'] ?? '')),
'created_at' => $createdAt,
'items' => $items,
];
}
/**
* @param array{id:int,courier:string,mode:string,value:float,effective_at:?string} $rule
* @param list<array<string,mixed>> $orders
*/
private function writeSettlementRows(string $connection, array $rule, array $orders): int
{
$effectiveAt = null;
if (($rule['effective_at'] ?? null) !== null) {
try {
$effectiveAt = Carbon::parse((string) $rule['effective_at'])->startOfDay();
} catch (Throwable) {
// 规则生效时间无法解析时不做下界过滤。
}
}
if ($effectiveAt !== null) {
$orders = array_values(array_filter(
$orders,
static fn (array $order): bool => ! $order['created_at'] instanceof Carbon
|| $order['created_at']->greaterThanOrEqualTo($effectiveAt),
));
}
if ($orders === []) {
return 0;
}
$oIds = [];
$waybills = [];
$skuIds = [];
foreach ($orders as $order) {
if ((int) ($order['o_id'] ?? 0) > 0) {
$oIds[(int) $order['o_id']] = true;
}
$waybills[(string) $order['waybill']] = true;
foreach ($order['items'] as $item) {
$skuIds[(string) $item['sku_id']] = true;
}
}
$type = (string) $rule['type'];
$returns = $type === '退回费' ? $this->pullReturns($orders) : [];
$weights = $this->pullWeights($connection, array_keys($skuIds));
$now = now();
$rows = [];
$hasReturnByWaybill = [];
foreach ($orders as $order) {
$matched = [];
$receiveDates = [];
$returnWaybills = [];
$hasReturn = false;
$settlementWeight = 0.0;
$orderWeight = 0.0;
foreach ($order['items'] as $item) {
$weight = $weights[(string) $item['sku_id']] ?? null;
if ($weight !== null) {
$orderWeight += (float) $item['qty'] * $weight;
}
}
if ($type === '拦截费') {
// 拦截费口径:查件扣单即按固定单价结算,不要求退货进仓。
$amount = (float) $rule['value'];
$status = '已计算';
} else {
foreach ($returns[(int) ($order['o_id'] ?? 0)] ?? [] as $return) {
$rQty = (float) ($return['r_qty'] ?? 0);
$receiveDate = trim((string) ($return['receive_date'] ?? ''));
if ($rQty <= 0 || $receiveDate === '') {
continue;
}
$hasReturn = true;
$matched[] = $return;
$receiveDates[] = $receiveDate;
$returnWaybill = trim((string) ($return['l_id'] ?? ''));
if ($returnWaybill !== '') {
$returnWaybills[$returnWaybill] = true;
}
}
$returnedWeight = 0.0;
foreach ($matched as $return) {
$weight = $weights[strtoupper(trim((string) ($return['sku_id'] ?? '')))] ?? null;
if ($weight !== null) {
$returnedWeight += (float) $return['r_qty'] * $weight;
}
}
$settlementWeight = $hasReturn
? ($returnedWeight > 0 ? $returnedWeight : $orderWeight)
: $orderWeight;
if ($rule['mode'] === '固定单价') {
$amount = $hasReturn ? (float) $rule['value'] : 0.0;
$status = '已计算';
} elseif (! $hasReturn) {
$amount = 0.0;
$status = '已计算';
} elseif ($settlementWeight > 0) {
$amount = round($settlementWeight * (float) $rule['value'], 2);
$status = '已计算';
} else {
$amount = 0.0;
$status = '待核价';
}
}
$primary = $this->primaryItem($order['items'], $matched);
$waybill = (string) $order['waybill'];
// 同一运单可能有多张扣单工单;只保留一行,优先保留有退回记录的那行。
if (($hasReturnByWaybill[$waybill] ?? null) === true && ! $hasReturn) {
continue;
}
$rows[$waybill] = [
'rule_id' => $rule['id'],
'surcharge_type' => $type,
'courier_name' => $rule['courier'],
'waybill' => $waybill,
'outbound_io_id' => 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' => $order['shop_name'] !== '' ? $order['shop_name'] : null,
'online_order_no' => $order['online_order_no'] !== '' ? $order['online_order_no'] : null,
'length' => null,
'width' => null,
'height' => null,
'volume' => null,
'inspection_type' => $order['inspection_type'],
'inspection_reason' => $order['inspection_reason'],
'return_waybill_no' => $returnWaybills === []
? null
: mb_substr(implode(',', array_keys($returnWaybills)), 0, 191),
'settlement_weight' => $settlementWeight > 0 ? round($settlementWeight, 2) : null,
'settlement_amount' => $amount,
'settlement_status' => $status,
'evidence' => mb_substr($this->evidence($rule, $order, $hasReturn, $matched, $receiveDates, $settlementWeight, $amount), 0, 500),
'shipped_at' => $this->shippedAt($order),
'pulled_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
$hasReturnByWaybill[$waybill] = $hasReturn;
}
foreach (array_chunk(array_keys($waybills), 500) as $chunk) {
DB::connection($connection)->table('courier_surcharge_settlement_rows')
->where('rule_id', $rule['id'])
->where('surcharge_type', $type)
->whereIn('waybill', $chunk)
->delete();
}
foreach (array_chunk(array_values($rows), 500) as $chunk) {
DB::connection($connection)->table('courier_surcharge_settlement_rows')->insert($chunk);
}
return count($rows);
}
/**
* 售后退货退款:进仓时间取 receive_date,实退数量取 r_qty。
*
* @param list<array<string,mixed>> $orders
* @return array<int,list<array<string,mixed>>>
*/
private function pullReturns(array $orders): array
{
$map = [];
$oIds = [];
$oIdByWaybill = [];
foreach ($orders as $order) {
$oId = (int) ($order['o_id'] ?? 0);
$waybill = trim((string) ($order['waybill'] ?? ''));
if ($oId > 0) {
$oIds[$oId] = true;
}
if ($oId > 0 && $waybill !== '') {
$oIdByWaybill[$waybill] = $oId;
}
}
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('operations_replica');
foreach (array_chunk(array_keys($oIds), 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->selectReturns($upstream, 'a.o_id IN (' . $placeholders . ')', $chunk) as $row) {
$oId = (int) ($row['o_id'] ?? 0);
if ($oId > 0) {
$map[$oId][] = $row;
}
}
}
// 部分售后单的 o_id 与查件工单不一致,按原始运单号再兜底一次。
$remainingWaybills = [];
foreach ($oIdByWaybill as $waybill => $oId) {
if (($map[$oId] ?? []) === []) {
$remainingWaybills[] = $waybill;
}
}
foreach (array_chunk($remainingWaybills, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->selectReturns($upstream, 'a.l_id IN (' . $placeholders . ')', $chunk) as $row) {
$oId = $oIdByWaybill[trim((string) ($row['l_id'] ?? ''))] ?? 0;
if ($oId > 0) {
$map[$oId][] = $row;
}
}
}
return $map;
}
/**
* @param list<mixed> $bindings
* @return list<array<string,mixed>>
*/
private function selectReturns(UpstreamReadOnlyQuery $upstream, string $where, array $bindings): array
{
try {
$rows = $upstream->select(
'SELECT a.o_id, a.as_id, a.l_id, a.status, CONVERT(varchar(19), a.confirm_date, 120) AS confirm_date, '
. 'ri.sku_id, ri.qty, 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 ' . $where,
$bindings,
);
} catch (Throwable) {
return [];
}
return array_map(static fn ($row): array => (array) $row, $rows);
}
/**
* @param list<string> $skuIds
* @return array<string,float>
*/
private function pullWeights(string $connection, array $skuIds): array
{
$map = [];
foreach (array_chunk($skuIds, 500) as $chunk) {
$rows = DB::connection($connection)->table('ops_jst_sku')
->whereIn('sku_id', $chunk)
->get(['sku_id', 'weight']);
foreach ($rows as $row) {
$skuId = strtoupper(trim((string) $row->sku_id));
$weight = $row->weight;
if ($skuId === '' || $weight === null || $weight === '' || isset($map[$skuId])) {
continue;
}
$map[$skuId] = (float) $weight;
}
}
return $map;
}
/**
* @param list<array<string,mixed>> $items
* @param list<array<string,mixed>> $returns
* @return array{sku_id:string,i_id:string,platform_product_id:string,name:string}
*/
private function primaryItem(array $items, array $returns): array
{
$returnSkus = [];
foreach ($returns as $return) {
$skuId = strtoupper(trim((string) ($return['sku_id'] ?? '')));
if ($skuId !== '') {
$returnSkus[$skuId] = true;
}
}
foreach ($items as $item) {
if (isset($returnSkus[$item['sku_id']])) {
return $item;
}
}
foreach ($items as $item) {
if ($item['platform_product_id'] !== '') {
return $item;
}
}
return $items[0] ?? ['sku_id' => '', 'i_id' => '', 'platform_product_id' => '', 'name' => ''];
}
/**
* @param array{id:int,type:string,courier:string,mode:string,value:float,effective_at:?string} $rule
* @param array<string,mixed> $order
* @param list<array<string,mixed>> $matched
* @param list<string> $receiveDates
*/
private function evidence(
array $rule,
array $order,
bool $hasReturn,
array $matched,
array $receiveDates,
float $settlementWeight,
float $amount,
): string {
$parts = ['查件扣单/' . $order['inspection_reason']];
if (($rule['type'] ?? '退回费') === '拦截费') {
$parts[] = '拦截费规则 ' . $this->trimNumber((float) $rule['value']) . '元/单';
$parts[] = '结算金额 ' . number_format($amount, 2, '.', '');
return implode(';', $parts);
}
if ($hasReturn) {
$returnQty = 0.0;
$confirmDates = [];
foreach ($matched as $return) {
$returnQty += (float) ($return['r_qty'] ?? 0);
$confirmDate = trim((string) ($return['confirm_date'] ?? ''));
if ($confirmDate !== '') {
$confirmDates[] = $confirmDate;
}
}
if ($confirmDates !== []) {
$parts[] = '退款确认 ' . max($confirmDates);
}
$parts[] = '售后退货退款已进仓 ' . ($receiveDates === [] ? '' : max($receiveDates));
$parts[] = '实退 ' . $this->trimNumber($returnQty);
} else {
$parts[] = '售后退货退款无进仓/实退记录';
}
$parts[] = '商品资料重量合计 ' . $this->trimNumber($settlementWeight) . 'kg';
$parts[] = $rule['mode'] === '固定单价'
? '规则 ' . $this->trimNumber((float) $rule['value']) . '元/单'
: '规则 ' . $this->trimNumber((float) $rule['value']) . '元/kg';
$parts[] = '结算金额 ' . number_format($amount, 2, '.', '');
return implode(';', $parts);
}
/**
* @param array<string,mixed> $order
*/
private function shippedAt(array $order): ?string
{
$sendTime = trim((string) ($order['send_time'] ?? ''));
if ($sendTime !== '') {
try {
return Carbon::parse($sendTime)->toDateTimeString();
} catch (Throwable) {
// 发货时间无法解析时回退到查件工单创建时间。
}
}
return $order['created_at'] instanceof Carbon ? $order['created_at']->toDateTimeString() : null;
}
private function trimNumber(float $value): string
{
$text = rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
return $text === '' ? '0' : $text;
}
}

View File

@ -47,6 +47,7 @@ use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\View\BillOffsetPageData;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository;
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
@ -241,6 +242,7 @@ class Pages extends Controller
private readonly CourierSurchargeTypeRepository $courierSurchargeTypes,
private readonly CourierSurchargeAuditLogRepository $courierSurchargeAudits,
private readonly WithdrawalManagementReport $withdrawalManagementReport,
private readonly CourierSurchargeSettlementMirrorRepository $courierSurchargeSettlements,
private readonly ShippingOrderRepository $shippingOrders,
) {
$this->middleware('auth');
@ -324,6 +326,15 @@ class Pages extends Controller
abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。');
// Keep the old direct URL working after settlement becomes a tab.
if ($section === 'courier-payables' && $page === 'courier-surcharge-settlement') {
return redirect()->route('business.pages.show', array_merge(request()->query(), [
'section' => $section,
'page' => 'courier-surcharge',
'tab' => 'settlement',
]));
}
$pages = collect($sectionConfig['pages'] ?? []);
if ($pages->isEmpty()) {
@ -390,6 +401,18 @@ class Pages extends Controller
}
if ($tab === null) {
if ($page === 'courier-surcharge') {
$activeTab = $tabs->first();
return view('business.pages.show', $this->viewData(
$sectionConfig,
$pageConfig,
$tabs,
$activeTab,
$this->resolveContent($page, $activeTab['slug']),
));
}
return $this->redirectToPage($sectionConfig, $pageConfig);
}
@ -2645,7 +2668,7 @@ class Pages extends Controller
'online-daifa-price' => $this->contentForOnlineDaifaPrice(),
'product-costs' => $this->contentForProductCosts(),
'financial-analysis' => $this->contentForFinancialAnalysis(),
'courier-surcharge' => $this->contentForCourierSurcharge(),
'courier-surcharge' => $this->contentForCourierSurcharge($tab),
'freight-difference', 'other-surcharge-summary', 'return-fee' => $this->contentForCourierPayableSummary($page),
'shipping-data' => $this->contentForShippingData(),
'return-pickup-management' => $this->contentForReturnPickupManagement(),
@ -3644,24 +3667,26 @@ class Pages extends Controller
];
}
protected function contentForCourierSurcharge(): array
protected function contentForCourierSurcharge(?string $tab = null): array
{
$clean = static fn (mixed $value, int $length = 60): string => mb_substr(trim((string) $value), 0, $length);
$quoteInput = $tab === 'settlement' ? [] : request()->query();
$filters = [
'keyword' => $clean(request('keyword', ''), 40),
'courier' => $clean(request('courier', ''), 40),
'type' => $clean(request('type', ''), 40),
'mode' => $clean(request('mode', ''), 40),
'scope' => $clean(request('scope', ''), 20),
'reconciler' => $clean(request('reconciler', ''), 40),
'supplier' => $clean(request('supplier', ''), 191),
'status' => $clean(request('status', ''), 20),
'keyword' => $clean($quoteInput['keyword'] ?? '', 40),
'courier' => $clean($quoteInput['courier'] ?? '', 40),
'type' => $clean($quoteInput['type'] ?? '', 40),
'mode' => $clean($quoteInput['mode'] ?? '', 40),
'scope' => $clean($quoteInput['scope'] ?? '', 20),
'reconciler' => $clean($quoteInput['reconciler'] ?? '', 40),
'supplier' => $clean($quoteInput['supplier'] ?? '', 191),
'status' => $clean($quoteInput['status'] ?? '', 20),
];
$data = $this->courierSurchargeQuotes->pageData($filters);
$auditReady = $this->courierSurchargeAudits->ready();
return [
'view' => 'business.pages.courier-payables.courier-surcharge',
$content = [
'view' => 'business.pages.courier-payables.courier-surcharge-workspace',
'courierSurchargeActiveTab' => $tab ?? 'quotes',
'courierSurchargeRows' => $data['rows'],
'courierSurchargeFilters' => $filters,
'courierSurchargeSummary' => $data['summary'],
@ -3672,6 +3697,380 @@ 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')),
];
}
public function exportCourierSurchargeSettlement(Request $request): StreamedResponse
{
$filters = $this->courierSurchargeSettlementFilters($request);
$repository = app(\Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository::class);
$limit = 50000;
return response()->streamDownload(function () use ($filters, $repository, $limit): void {
$writer = new \App\Support\StreamingXlsxWriter('附加费结算');
$writer->appendRow([
'快递名称', '出库时间', '单号', '平台商品ID', '商品编码', '店铺名称', '线上订单号',
'长', '宽', '高', '体积', '查件类型', '查件原因', '退回单号',
'结算重量', '结算金额', '附加费类型',
]);
$exported = 0;
foreach ($repository->exportRows($filters, $limit) as $row) {
$writer->appendRow([
$this->xlsxText($row->courier_name ?? ''),
$this->xlsxText($row->shipped_at ?? ''),
$this->xlsxText($row->waybill ?? ''),
$this->xlsxText($row->platform_product_id ?? ''),
$this->xlsxText($row->product_code ?? ''),
$this->xlsxText($row->shop_name ?? ''),
$this->xlsxText($row->online_order_no ?? ''),
$row->length !== null ? (float) $row->length : null,
$row->width !== null ? (float) $row->width : null,
$row->height !== null ? (float) $row->height : null,
$row->volume !== null ? (float) $row->volume : null,
$this->xlsxText($row->inspection_type ?? ''),
$this->xlsxText($row->inspection_reason ?? ''),
$this->xlsxText($row->return_waybill_no ?? ''),
$row->settlement_weight !== null ? (float) $row->settlement_weight : null,
$row->settlement_amount !== null ? (float) $row->settlement_amount : null,
$this->xlsxText($row->surcharge_type ?? ''),
]);
$exported++;
}
if ($exported >= $limit) {
$writer->appendRow(['(已按上限截断,仅导出前 ' . $limit . ' 行,请缩小筛选范围后重试)']);
}
$output = fopen('php://output', 'wb');
if ($output === false) {
throw new RuntimeException('无法创建附加费结算导出流。');
}
$writer->writeTo($output);
fclose($output);
}, '附加费结算_' . now()->format('Ymd_His') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
public function importCourierSurchargeSettlement(Request $request): StreamedResponse|\Illuminate\Http\RedirectResponse
{
$request->validate([
'file' => ['required', 'file', 'max:10240'],
], [], ['file' => '导入文件']);
try {
$path = (string) $request->file('file')?->getRealPath();
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($path);
$sheet = $spreadsheet->getActiveSheet();
$highestRow = min($sheet->getHighestDataRow(), 5001);
$data = $sheet->rangeToArray(
'A1:' . $sheet->getHighestColumn() . $highestRow,
null,
false,
true,
false,
);
} catch (\Throwable $exception) {
return back()->with('error', '导入失败:' . $exception->getMessage());
}
$headers = [];
foreach ((array) ($data[0] ?? []) as $index => $name) {
$name = trim((string) $name);
if ($name !== '') {
$headers[$name] = (int) $index;
}
}
$waybillColumn = $this->findImportColumn($headers, ['单号', '运单号', '快递单号', 'waybill', 'express_no']);
$typeColumn = $this->findImportColumn($headers, ['附加费类型', '附加费类型名称', '类型', 'surcharge_type']);
$courierColumn = $this->findImportColumn($headers, ['快递名称', '快递公司', '物流公司', '快递', 'courier_name']);
$amountColumn = $this->findImportColumn($headers, ['金额', '导入金额', '费用金额', 'amount']);
if ($waybillColumn === null || $typeColumn === null) {
return back()->with('error', '导入失败:文件首行需包含「单号」和「附加费类型」两列。');
}
$pairs = [];
foreach (array_slice($data, 1) as $offset => $row) {
$waybill = $this->importCellText($row[$waybillColumn] ?? null);
$type = $this->importCellText($row[$typeColumn] ?? null);
if ($waybill === '' && $type === '') {
continue;
}
$pairs[] = [
'row_number' => $offset + 2,
'waybill' => mb_substr($waybill, 0, 191),
'surcharge_type' => mb_substr($type, 0, 40),
'courier_name' => $courierColumn === null
? ''
: mb_substr($this->importCellText($row[$courierColumn] ?? null), 0, 191),
'amount' => $amountColumn === null
? null
: $this->importCellAmount($row[$amountColumn] ?? null),
];
}
if ($pairs === []) {
return back()->with('error', '导入文件没有可核验的数据行。');
}
if (count($pairs) > 5000) {
return back()->with('error', '导入失败:单次最多核验 5000 行。');
}
$amountByRow = [];
foreach ($pairs as $pair) {
$amountByRow[(int) ($pair['row_number'] ?? 0)] = $pair['amount'] ?? null;
}
$results = app(CourierSurchargeSettlementMirrorRepository::class)->decideImportRows($pairs);
return response()->streamDownload(function () use ($results, $amountByRow): void {
$writer = new \App\Support\StreamingXlsxWriter('导入核验');
$writer->appendRow([
'序号', '单号', '附加费类型', '结算结论', '不结原因', '快递名称', '出库时间', '平台商品ID', '商品编码',
'店铺名称', '线上订单号', '长', '宽', '高', '体积', '查件类型', '查件原因', '退回单号',
'结算重量', '结算金额', '导入金额', '差异金额', '结算状态', '判定依据',
]);
foreach ($results as $row) {
$importedAmount = $amountByRow[(int) ($row['row_number'] ?? 0)] ?? null;
$systemAmount = $row['settlement_amount'] !== null && $row['settlement_amount'] !== ''
? (float) $row['settlement_amount']
: null;
$difference = $importedAmount !== null && $systemAmount !== null
? round($systemAmount - $importedAmount, 2)
: null;
$writer->appendRow([
(int) ($row['row_number'] ?? 0),
$this->xlsxText($row['waybill'] ?? ''),
$this->xlsxText($row['surcharge_type'] ?? ''),
$this->xlsxText($row['decision'] ?? ''),
$this->xlsxText($row['reason'] ?? ''),
$this->xlsxText($row['courier_name'] ?? ''),
$this->xlsxText($row['shipped_at'] ?? ''),
$this->xlsxText($row['platform_product_id'] ?? ''),
$this->xlsxText($row['product_code'] ?? ''),
$this->xlsxText($row['shop_name'] ?? ''),
$this->xlsxText($row['online_order_no'] ?? ''),
$row['length'] !== null ? (float) $row['length'] : null,
$row['width'] !== null ? (float) $row['width'] : null,
$row['height'] !== null ? (float) $row['height'] : null,
$row['volume'] !== null ? (float) $row['volume'] : null,
$this->xlsxText($row['inspection_type'] ?? ''),
$this->xlsxText($row['inspection_reason'] ?? ''),
$this->xlsxText($row['return_waybill_no'] ?? ''),
$row['settlement_weight'] !== null ? (float) $row['settlement_weight'] : null,
$systemAmount,
$importedAmount,
$difference,
$this->xlsxText($row['settlement_status'] ?? ''),
$this->xlsxText($row['evidence'] ?? ''),
]);
}
$output = fopen('php://output', 'wb');
if ($output === false) {
throw new RuntimeException('无法创建导入核验导出流。');
}
$writer->writeTo($output);
fclose($output);
}, '附加费结算_导入核验_' . now()->format('Ymd_His') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
public function downloadCourierSurchargeSettlementImportTemplate(): StreamedResponse
{
return response()->streamDownload(function (): void {
$writer = new \App\Support\StreamingXlsxWriter('导入模板');
$writer->appendRow(['单号', '附加费类型', '快递名称', '金额(可选)']);
$writer->appendRow(['DPK317138931411', '退回费', '曹县德邦小件', 20]);
$writer->appendRow(['DPK379220912936', '超长费', '常州德邦', null]);
$output = fopen('php://output', 'wb');
if ($output === false) {
throw new RuntimeException('无法创建导入模板导出流。');
}
$writer->writeTo($output);
fclose($output);
}, '附加费结算_导入模板.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
/**
* @param array<string,int> $headers
* @param list<string> $candidates
*/
private function findImportColumn(array $headers, array $candidates): ?int
{
foreach ($headers as $name => $index) {
foreach ($candidates as $candidate) {
if (mb_strtolower($name) === mb_strtolower($candidate)) {
return $index;
}
}
}
foreach ($headers as $name => $index) {
foreach ($candidates as $candidate) {
if (str_contains(mb_strtolower($name), mb_strtolower($candidate))) {
return $index;
}
}
}
return null;
}
private function importCellText(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_int($value)) {
return (string) $value;
}
if (is_float($value)) {
return abs($value - round($value)) < 0.000001 && abs($value) >= 1e11
? number_format($value, 0, '', '')
: rtrim(rtrim(number_format($value, 6, '.', ''), '0'), '.');
}
return trim((string) $value);
}
private function importCellAmount(mixed $value): ?float
{
if ($value === null) {
return null;
}
if (is_int($value) || is_float($value)) {
return (float) $value;
}
$text = str_replace([',', ',', '¥', '¥', '元', ' '], '', trim((string) $value));
return $text !== '' && is_numeric($text) ? (float) $text : null;
}
private function freightReconciliationFilters(): array

View File

@ -712,7 +712,14 @@ $navigation = [
['slug' => 'compensation-details', 'title' => '赔偿明细'],
['slug' => 'advance-details', 'title' => '借支明细'],
['slug' => 'other-adjustments', 'title' => '其他扣款补款'],
['slug' => 'courier-surcharge', 'title' => '附加费报价维护'],
[
'slug' => 'courier-surcharge',
'title' => '附加费报价维护',
'tabs' => [
['slug' => 'quotes', 'title' => '附加费报价'],
['slug' => 'settlement', 'title' => '附加费结算'],
],
],
['slug' => 'payable-cloud-warehouse-vehicle-fee', 'title' => '应付云仓车费'],
['slug' => 'courier-payables-report', 'title' => '快递应付表'],
['slug' => 'freight-difference', 'title' => '运费差'],

View File

@ -0,0 +1,67 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
// 本机临时引导库只负责让新人进入设置页;正式镜像表由共享环境迁移。
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->connection());
if ($schema->hasTable('courier_surcharge_settlement_rows')) {
return;
}
$schema->create('courier_surcharge_settlement_rows', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->unsignedInteger('rule_id');
$table->string('surcharge_type', 40);
$table->string('courier_name', 191);
$table->string('waybill', 191)->nullable();
$table->unsignedBigInteger('outbound_io_id')->nullable();
$table->string('platform_product_id', 191)->nullable();
$table->string('product_code', 191)->nullable();
$table->string('shop_name', 191)->nullable();
$table->string('online_order_no', 191)->nullable();
$table->decimal('length', 12, 2)->nullable();
$table->decimal('width', 12, 2)->nullable();
$table->decimal('height', 12, 2)->nullable();
$table->decimal('volume', 16, 2)->nullable();
$table->string('inspection_type', 60)->nullable();
$table->string('inspection_reason', 120)->nullable();
$table->string('return_waybill_no', 191)->nullable();
$table->decimal('settlement_weight', 12, 2)->nullable();
$table->decimal('settlement_amount', 12, 2)->default(0);
$table->string('settlement_status', 40)->default('已计算');
// 判定依据(如:最长边 168cm > 160cm;条形码 XXX 尺寸 168×20×10)。
$table->string('evidence', 500)->nullable();
$table->dateTime('shipped_at')->nullable();
$table->dateTime('pulled_at');
$table->timestamps();
// 一个报价规则下,同一张出库单只保留一行,重复拉取直接覆盖。
$table->unique(['rule_id', 'outbound_io_id'], 'surcharge_settlement_rule_order_unique');
$table->index(['courier_name', 'id'], 'surcharge_settlement_courier_index');
$table->index(['waybill', 'id'], 'surcharge_settlement_waybill_index');
$table->index(['shipped_at', 'id'], 'surcharge_settlement_shipped_index');
});
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('courier_surcharge_settlement_rows');
}
};

View File

@ -0,0 +1,994 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonInterface;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use RuntimeException;
use Throwable;
/**
* 「快递应付 → 附加费报价维护 → 附加费结算」的只读仓库。
*
* 结算行由 `courier-surcharge:pull-overlength` 按启用的附加费报价规则
* (第一期只处理“超长费 + 按尺寸/最长边”)从销售出库单拉取后写入
* courier_surcharge_settlement_rows;页面只读该表,不在请求中回源上游。
*/
class CourierSurchargeSettlementMirrorRepository
{
public const TABLE = 'courier_surcharge_settlement_rows';
/** 页面附加费类型下拉使用的固定类型。 */
public const SURCHARGE_TYPES = [
'退回费', '拦截费', '改地址', '上楼费', '送货费', '超区费', '安装费', '保费', '进仓费', '超长费',
];
/** 超出多久没有重新拉取就标记为“稍有延迟”。 */
private const STALE_AFTER_MINUTES = 1440;
/** 查件工单里「查件类型/原因」级联字段。 */
private const CHECK_TYPE_FIELD = 'SELECT_CASCADEField1413856620250813172526';
/** 导入核验时最多回查多少张扣单工单,避免大表扫描过重。 */
private const IMPORT_RETURN_CONTEXT_LIMIT = 100;
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
public function available(): bool
{
try {
return Schema::connection($this->connection->getName())->hasTable(self::TABLE);
} catch (Throwable) {
return false;
}
}
public function ready(): bool
{
return $this->available() && $this->connection->table(self::TABLE)->exists();
}
/** @return array<string,mixed> */
public function state(): array
{
if (! $this->available()) {
return [
'code' => 'pending',
'status' => 'pending',
'last_succeeded_at' => null,
'mirror_row_count' => 0,
];
}
try {
$lastPulledAt = $this->connection->table(self::TABLE)->max('pulled_at');
$rowCount = (int) $this->connection->table(self::TABLE)->count();
} catch (Throwable) {
return [
'code' => 'pending',
'status' => 'pending',
'last_succeeded_at' => null,
'mirror_row_count' => 0,
];
}
return [
'code' => $lastPulledAt === null ? 'pending' : 'fresh',
'status' => $lastPulledAt === null ? 'pending' : 'succeeded',
'last_succeeded_at' => $lastPulledAt,
'mirror_row_count' => $rowCount,
];
}
/** @param array<string,mixed>|null $state @return array{code:string,lag_minutes:?int} */
public function syncStatus(?array $state = null, ?CarbonInterface $at = null): array
{
$state ??= $this->state();
$lastSucceeded = $this->timestamp($state['last_succeeded_at'] ?? null);
if ($lastSucceeded === null) {
return ['code' => 'pending', 'lag_minutes' => null];
}
$lagMinutes = (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded) / 60);
return [
'code' => $lagMinutes > self::STALE_AFTER_MINUTES ? 'stale' : 'fresh',
'lag_minutes' => $lagMinutes,
];
}
/**
* @param array<string,string> $filters
* @return array{data:list<array<string,mixed>>,total:int,has_more:bool,summary:array<string,mixed>,sync:array<string,mixed>,state:array<string,mixed>}
*/
public function paginate(array $filters, int $page, int $limit, mixed $quotes = null): array
{
if (! $this->ready()) {
throw new RuntimeException('surcharge_settlement_rows_not_ready');
}
$page = max($page, 1);
$limit = min(max($limit, 1), 100);
$query = $this->filteredQuery($filters);
$total = (int) (clone $query)->count();
$rows = $query
->orderByDesc('shipped_at')
->orderByDesc('id')
->forPage($page, $limit)
->get();
$data = [];
$amount = 0.0;
foreach ($rows as $row) {
$mapped = $this->mapRow($row);
$amount += (float) ($mapped['settlement_amount'] ?? 0);
$data[] = $mapped;
}
$state = $this->state();
return [
'data' => $data,
'total' => $total,
'has_more' => ($page * $limit) < $total,
'summary' => ['count' => $total, 'settlement_amount' => round($amount, 2)],
'sync' => ['code' => $state['code'], 'last_succeeded_at' => $state['last_succeeded_at']],
'state' => $state,
];
}
/** @param array<string,string> $filters */
private function filteredQuery(array $filters): Builder
{
$query = $this->connection->table(self::TABLE);
foreach ([
'courier_name' => 'courier_name',
'waybill' => 'waybill',
'platform_product_id' => 'platform_product_id',
'product_code' => 'product_code',
'shop_name' => 'shop_name',
'online_order_no' => 'online_order_no',
'inspection_type' => 'inspection_type',
'inspection_reason' => 'inspection_reason',
] as $filter => $column) {
$value = trim((string) ($filters[$filter] ?? ''));
if ($value !== '') {
$query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($value)]);
}
}
if (trim((string) ($filters['surcharge_type'] ?? '')) !== '') {
$query->where('surcharge_type', trim((string) $filters['surcharge_type']));
}
if (trim((string) ($filters['status'] ?? '')) !== '') {
$query->where('settlement_status', trim((string) $filters['status']));
}
if (trim((string) ($filters['created_from'] ?? '')) !== '') {
$query->where('shipped_at', '>=', $filters['created_from'] . ' 00:00:00');
}
if (trim((string) ($filters['created_to'] ?? '')) !== '') {
$query->where('shipped_at', '<', Carbon::parse($filters['created_to'])->addDay()->toDateTimeString());
}
return $query;
}
/**
* 导出用流式游标:按筛选条件顺序读取结算行,默认最多 5 万行。
*
* @param array<string,string> $filters
* @return \Generator<int,object>
*/
public function exportRows(array $filters, int $maxRows = 50000): \Generator
{
if (! $this->ready()) {
throw new RuntimeException('surcharge_settlement_rows_not_ready');
}
$query = $this->filteredQuery($filters)
->orderByDesc('shipped_at')
->orderByDesc('id')
->limit(min(max($maxRows, 1), 50000));
foreach ($query->cursor() as $row) {
yield $row;
}
}
/**
* 导入核验:按「单号 + 附加费类型」匹配结算行,给出正常结/不结结论与原因。
*
* @param list<array{row_number:int,waybill:string,surcharge_type:string}> $pairs
* @return list<array<string,mixed>>
*/
public function decideImportRows(array $pairs): array
{
if (! $this->ready()) {
throw new RuntimeException('surcharge_settlement_rows_not_ready');
}
$waybills = [];
foreach ($pairs as $pair) {
$waybill = trim((string) ($pair['waybill'] ?? ''));
if ($waybill !== '') {
$waybills[$waybill] = true;
}
}
$rowsByWaybill = [];
foreach (array_chunk(array_keys($waybills), 300) as $chunk) {
$rows = $this->connection->table(self::TABLE)
->whereIn('waybill', $chunk)
->orderByDesc('settlement_amount')
->orderByDesc('id')
->get();
foreach ($rows as $row) {
$waybill = trim((string) $row->waybill);
if ($waybill !== '') {
$rowsByWaybill[$waybill][] = $row;
}
}
}
$normalized = [];
foreach ($pairs as $pair) {
$normalized[] = [
'row_number' => (int) ($pair['row_number'] ?? 0),
'waybill' => trim((string) ($pair['waybill'] ?? '')),
'surcharge_type' => $this->normalizeImportType((string) ($pair['surcharge_type'] ?? '')),
'courier_name' => trim((string) ($pair['courier_name'] ?? '')),
];
}
$unmatchedDeductWaybills = [];
foreach ($normalized as $pair) {
if ($pair['waybill'] === '' || ! in_array($pair['surcharge_type'], ['退回费', '拦截费'], true)) {
continue;
}
if ($this->matchImportRow($rowsByWaybill[$pair['waybill']] ?? [], $pair['surcharge_type'], $pair['courier_name']) === null) {
$unmatchedDeductWaybills[$pair['waybill']] = true;
}
}
$contextComplete = count($unmatchedDeductWaybills) <= self::IMPORT_RETURN_CONTEXT_LIMIT;
$returnContext = $this->deductOrderContext(array_keys($unmatchedDeductWaybills));
$ruleModes = $this->activeRuleModes();
$fallbackRows = [];
foreach ($rowsByWaybill as $waybill => $rows) {
if ($rows !== []) {
$fallbackRows[$waybill] = $rows[0];
}
}
$contextSkuIds = [];
foreach ($returnContext as $context) {
foreach ($context['items'] ?? [] as $item) {
$skuId = trim((string) ($item['sku_id'] ?? ''));
if ($skuId !== '') {
$contextSkuIds[$skuId] = true;
}
}
}
$contextWeights = $this->importWeights(array_keys($contextSkuIds));
$contextReturns = $this->importReturns($returnContext);
$results = [];
foreach ($normalized as $pair) {
$matched = null;
$mismatchedCourier = '';
if ($pair['waybill'] !== '') {
$candidates = $rowsByWaybill[$pair['waybill']] ?? [];
$matched = $this->matchImportRow($candidates, $pair['surcharge_type'], $pair['courier_name']);
if ($matched === null && $pair['courier_name'] !== '') {
$other = $this->matchImportRow($candidates, $pair['surcharge_type']);
if ($other !== null) {
$mismatchedCourier = trim((string) $other->courier_name);
}
}
}
$results[] = $matched !== null
? $this->importMatchedRow($pair, $matched)
: $this->importMissingRow(
$pair,
$returnContext,
$ruleModes,
$contextComplete,
$mismatchedCourier,
$fallbackRows[$pair['waybill']] ?? null,
$contextWeights,
$contextReturns,
);
}
return $results;
}
private function normalizeImportType(string $type): string
{
$type = trim($type);
return [
'退回件' => '退回费',
'退货费' => '退回费',
'退货件' => '退回费',
'超长' => '超长费',
'改地址费' => '改地址',
][$type] ?? $type;
}
/**
* @param iterable<object> $rows
*/
private function matchImportRow(iterable $rows, string $type, string $courier = ''): ?object
{
$fallback = null;
foreach ($rows as $row) {
if (trim((string) $row->surcharge_type) !== $type) {
continue;
}
if ($courier !== '' && trim((string) $row->courier_name) !== $courier) {
continue;
}
$fallback ??= $row;
if ((float) ($row->settlement_amount ?? 0) > 0) {
return $row;
}
}
return $fallback;
}
/**
* @param array{row_number:int,waybill:string,surcharge_type:string} $pair
* @return array<string,mixed>
*/
private function importMatchedRow(array $pair, object $row): array
{
$amount = (float) ($row->settlement_amount ?? 0);
$status = trim((string) ($row->settlement_status ?? ''));
$evidence = trim((string) ($row->evidence ?? ''));
$reason = '';
if ($amount > 0) {
$decision = '正常结';
} else {
$decision = '不结';
if ($status === '未匹配报价') {
$reason = '未匹配到启用的报价规则';
} elseif ($status === '待核价') {
$reason = '待核价:商品资料缺少重量或规则金额无法计算';
} elseif (str_contains($evidence, '无进仓') || str_contains($evidence, '无退货')) {
$reason = '售后退货退款无进仓时间/实退数量';
} else {
$reason = '结算金额为 0';
}
}
$mapped = $this->mapRow($row);
$mapped['settlement_status'] = $status;
$mapped['evidence'] = $evidence;
return array_merge([
'row_number' => $pair['row_number'],
'waybill' => $pair['waybill'],
'surcharge_type' => $pair['surcharge_type'],
'decision' => $decision,
'reason' => $reason,
], $mapped);
}
/**
* @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<string,float> $contextWeights
* @param array<int,string> $contextReturns
* @return array<string,mixed>
*/
private function importMissingRow(
array $pair,
array $returnContext,
array $ruleModes,
bool $contextComplete,
string $mismatchedCourier,
?object $fallbackRow,
array $contextWeights,
array $contextReturns,
): array {
$context = $returnContext[$pair['waybill']] ?? null;
$courier = $pair['courier_name'];
if ($courier === '') {
$courier = trim((string) ($fallbackRow->courier_name ?? ($context['courier'] ?? '')));
}
$platformProductId = null;
$productCode = null;
$shopName = null;
$onlineOrderNo = null;
$inspectionType = null;
$inspectionReason = null;
$returnWaybillNo = null;
$settlementWeight = null;
$shippedAt = null;
$evidence = null;
if ($fallbackRow !== null) {
// 同一运单已有其他类型的结算行时,直接复用其来源字段。
$platformProductId = $fallbackRow->platform_product_id;
$productCode = $fallbackRow->product_code;
$shopName = $fallbackRow->shop_name;
$onlineOrderNo = $fallbackRow->online_order_no;
$inspectionType = $fallbackRow->inspection_type;
$inspectionReason = $fallbackRow->inspection_reason;
$returnWaybillNo = $fallbackRow->return_waybill_no;
$settlementWeight = $fallbackRow->settlement_weight;
$shippedAt = $fallbackRow->shipped_at;
$evidence = '未生成「' . $pair['surcharge_type'] . '」结算行;同运单已有「' . $fallbackRow->surcharge_type . '」结算记录';
} elseif ($context !== null) {
// 没有任何结算行时,从查件扣单工单补齐快递、商品、店铺、线上下单等信息。
$primary = $this->primaryImportItem($context['items'] ?? []);
$platformProductId = $primary['platform_product_id'];
$productCode = $primary['sku_id'] !== '' ? $primary['sku_id'] : $primary['i_id'];
$shopName = trim((string) ($context['shop_name'] ?? ''));
$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;
$settlementWeight = $this->importWeightSum($context['items'] ?? [], $contextWeights);
$sendTime = trim((string) ($context['send_time'] ?? ''));
$shippedAt = $sendTime !== '' ? $sendTime : null;
$evidence = ($inspectionType ?? '查件') . ';未生成「' . $pair['surcharge_type'] . '」结算行';
$platformProductId = $platformProductId !== '' ? $platformProductId : null;
$productCode = $productCode !== '' ? $productCode : null;
$shopName = $shopName !== '' ? $shopName : null;
$onlineOrderNo = $onlineOrderNo !== '' ? $onlineOrderNo : null;
$inspectionReason = $inspectionReason !== '' ? $inspectionReason : null;
}
return [
'row_number' => $pair['row_number'],
'waybill' => $pair['waybill'],
'surcharge_type' => $pair['surcharge_type'],
'decision' => '不结',
'reason' => $this->missingReason($pair, $returnContext, $ruleModes, $contextComplete, $mismatchedCourier),
'courier_name' => $courier !== '' ? $courier : null,
'shipped_at' => $shippedAt,
'platform_product_id' => $platformProductId,
'product_code' => $productCode,
'shop_name' => $shopName,
'online_order_no' => $onlineOrderNo,
'length' => null,
'width' => null,
'height' => null,
'volume' => null,
'inspection_type' => $inspectionType,
'inspection_reason' => $inspectionReason,
'return_waybill_no' => $returnWaybillNo !== null && $returnWaybillNo !== ''
? mb_substr((string) $returnWaybillNo, 0, 191)
: null,
'settlement_weight' => $settlementWeight !== null && (float) $settlementWeight > 0
? round((float) $settlementWeight, 2)
: null,
'settlement_amount' => null,
'settlement_status' => null,
'evidence' => $evidence,
];
}
/**
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
* @param array<string,array{courier:string}> $returnContext
* @param array<string,array<string,list<string>>> $ruleModes
*/
private function missingReason(
array $pair,
array $returnContext,
array $ruleModes,
bool $contextComplete,
string $mismatchedCourier = '',
): string {
$courier = $pair['courier_name'];
$type = $pair['surcharge_type'];
if ($pair['waybill'] === '') {
return '单号为空';
}
if ($type === '') {
return '附加费类型为空';
}
if ($mismatchedCourier !== '') {
return '不结:该运单+类型匹配到快递「' . $mismatchedCourier . '」,与导入的快递「' . $courier . '」不一致';
}
if ($courier !== '') {
$modes = array_values(array_unique($ruleModes[$courier][$type] ?? []));
if ($modes === []) {
return '不结:快递「' . $courier . '」没有启用的「' . $type . '」报价规则';
}
if ($type === '退回费') {
if (array_intersect($modes, ['固定单价', '按重量']) === []) {
return '不结:快递「' . $courier . '」的退回费规则(' . implode('、', $modes) . ')暂不支持自动核算';
}
$context = $returnContext[$pair['waybill']] ?? null;
$inspectionType = trim((string) ($context['inspection_type'] ?? ''));
if ($inspectionType !== '' && $inspectionType !== '扣单') {
return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($context['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件';
}
if ($context !== null) {
return '不结:快递「' . $courier . '」有启用的退回费规则,但该运单未生成结算行(不在已拉取范围或结算数据尚未同步)';
}
return '不结:查件工单中没有该运单的扣单记录';
}
if ($type === '拦截费') {
if (array_intersect($modes, ['固定单价']) === []) {
return '不结:快递「' . $courier . '」的拦截费规则(' . implode('、', $modes) . ')暂不支持自动核算';
}
$context = $returnContext[$pair['waybill']] ?? null;
$inspectionType = trim((string) ($context['inspection_type'] ?? ''));
if ($inspectionType !== '' && $inspectionType !== '扣单') {
return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($context['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件';
}
if ($context !== null) {
return '不结:快递「' . $courier . '」有启用的拦截费规则,但该运单未生成结算行(不在已拉取范围或结算数据尚未同步)';
}
return '不结:查件工单中没有该运单的扣单记录';
}
if ($type === '超长费') {
return '不结:快递「' . $courier . '」有启用的超长费规则,但该运单未达条件或不在已拉取范围';
}
return '不结:快递「' . $courier . '」有启用规则(' . implode('、', $modes) . '),但「' . $type . '」暂未接入自动核算';
}
// 未提供快递名称时,沿用查件工单兜底原因。
if ($type === '退回费') {
if (! $contextComplete) {
return '不结:该运单没有查件扣单记录,或快递/规则不在已拉取范围';
}
$contextCourier = trim((string) ($returnContext[$pair['waybill']]['courier'] ?? ''));
if ($contextCourier === '') {
return '不结:查件工单中没有该运单的扣单记录';
}
$inspectionType = trim((string) ($returnContext[$pair['waybill']]['inspection_type'] ?? ''));
if ($inspectionType !== '' && $inspectionType !== '扣单') {
return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($returnContext[$pair['waybill']]['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件';
}
$modes = array_values(array_unique($ruleModes[$contextCourier]['退回费'] ?? []));
if ($modes === []) {
return '不结:快递「' . $contextCourier . '」没有启用的退回费报价规则';
}
if (array_intersect($modes, ['固定单价', '按重量']) === []) {
return '不结:快递「' . $contextCourier . '」的退回费规则(' . implode('、', $modes) . ')暂不支持自动核算';
}
return '不结:该运单未生成退回费结算行(不在已拉取范围或结算数据尚未同步)';
}
if ($type === '拦截费') {
if (! $contextComplete) {
return '不结:该运单没有查件扣单记录,或快递/规则不在已拉取范围';
}
$contextCourier = trim((string) ($returnContext[$pair['waybill']]['courier'] ?? ''));
if ($contextCourier === '') {
return '不结:查件工单中没有该运单的扣单记录';
}
$inspectionType = trim((string) ($returnContext[$pair['waybill']]['inspection_type'] ?? ''));
if ($inspectionType !== '' && $inspectionType !== '扣单') {
return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($returnContext[$pair['waybill']]['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件';
}
$modes = array_values(array_unique($ruleModes[$contextCourier]['拦截费'] ?? []));
if ($modes === []) {
return '不结:快递「' . $contextCourier . '」没有启用的拦截费报价规则';
}
if (array_intersect($modes, ['固定单价']) === []) {
return '不结:快递「' . $contextCourier . '」的拦截费规则(' . implode('、', $modes) . ')暂不支持自动核算';
}
return '不结:该运单未生成拦截费结算行(不在已拉取范围或结算数据尚未同步)';
}
if ($type === '超长费') {
return '不结:未找到超长费结算行,单边未超报价阈值或不在已拉取范围';
}
return '不结:该附加费类型暂未接入自动核算';
}
/**
* 未匹配到结算行时,回查查件工单补齐快递、商品、店铺、线上下单号等信息。
*
* @param list<string> $waybills
* @return array<string,array<string,mixed>>
*/
private function deductOrderContext(array $waybills): array
{
$waybills = array_values(array_unique(array_filter(
array_map(static fn ($waybill): string => trim((string) $waybill), $waybills),
static fn (string $waybill): bool => $waybill !== '',
)));
if ($waybills === [] || count($waybills) > self::IMPORT_RETURN_CONTEXT_LIMIT) {
return [];
}
$context = [];
foreach (array_chunk($waybills, 50) as $chunk) {
try {
$rows = $this->connection->table('js_jst_work_order_info')
->where('title', '查件')
->where(function ($query) use ($chunk): void {
foreach ($chunk as $waybill) {
$query->orWhere('businessFieldList', 'like', '%' . str_replace(['%', '_'], '', $waybill) . '%');
}
})
->orderByDesc('Id')
->get(['businessFieldList']);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$fields = json_decode((string) $row->businessFieldList, true);
if (! is_array($fields)) {
continue;
}
$flat = [];
foreach ($fields as $field) {
if (is_array($field) && isset($field['fieldId'])) {
$flat[(string) $field['fieldId']] = $field;
}
}
$waybill = trim((string) ($flat['lIdField']['value'] ?? ''));
$cascade = trim((string) ($flat[self::CHECK_TYPE_FIELD]['value'] ?? ''));
if ($waybill === '') {
continue;
}
if (isset($context[$waybill]) && ($context[$waybill]['inspection_type'] ?? '') === '扣单') {
continue;
}
[$inspectionType, $inspectionReason] = array_pad(explode('/', $cascade, 2), 2, '');
$items = [];
foreach (($flat['itemInfo']['childFieldList'] ?? []) as $group) {
if (! is_array($group)) {
continue;
}
$item = [];
foreach ($group as $field) {
if (is_array($field) && isset($field['fieldId'])) {
$item[(string) $field['fieldId']] = $field['value'] ?? null;
}
}
$skuId = strtoupper(trim((string) ($item['skuId'] ?? '')));
if ($skuId === '') {
continue;
}
$items[] = [
'sku_id' => $skuId,
'qty' => (float) ($item['qty'] ?? 0),
'platform_product_id' => trim((string) ($item['platformItemId'] ?? '')),
'i_id' => trim((string) ($item['iid'] ?? '')),
];
}
$context[$waybill] = [
'courier' => trim((string) ($flat['logisticsField']['value'] ?? '')),
'o_id' => (int) ($flat['o_id']['value'] ?? 0),
'online_order_no' => trim((string) ($flat['orderField']['value'] ?? '')),
'shop_name' => trim((string) ($flat['shopField']['valueLabel'] ?? ($flat['shopField']['value'] ?? ''))),
'inspection_type' => trim((string) $inspectionType),
'inspection_reason' => trim((string) $inspectionReason),
'send_time' => trim((string) ($flat['orderSendTime']['value'] ?? '')),
'items' => $items,
];
}
}
return $context;
}
/**
* @param list<array<string,mixed>> $items
* @return array{sku_id:string,i_id:string,platform_product_id:string}
*/
private function primaryImportItem(array $items): array
{
foreach ($items as $item) {
if (trim((string) ($item['platform_product_id'] ?? '')) !== '') {
return [
'sku_id' => strtoupper(trim((string) ($item['sku_id'] ?? ''))),
'i_id' => trim((string) ($item['i_id'] ?? '')),
'platform_product_id' => trim((string) $item['platform_product_id']),
];
}
}
$first = $items[0] ?? [];
return [
'sku_id' => strtoupper(trim((string) ($first['sku_id'] ?? ''))),
'i_id' => trim((string) ($first['i_id'] ?? '')),
'platform_product_id' => trim((string) ($first['platform_product_id'] ?? '')),
];
}
/**
* @param list<array<string,mixed>> $items
* @param array<string,float> $weights
*/
private function importWeightSum(array $items, array $weights): ?float
{
$total = 0.0;
$found = false;
foreach ($items as $item) {
$skuId = strtoupper(trim((string) ($item['sku_id'] ?? '')));
$weight = $skuId !== '' ? ($weights[$skuId] ?? null) : null;
if ($weight !== null) {
$found = true;
$total += (float) ($item['qty'] ?? 0) * $weight;
}
}
return $found ? round($total, 2) : null;
}
/**
* @param list<string> $skuIds
* @return array<string,float>
*/
private function importWeights(array $skuIds): array
{
$map = [];
foreach (array_chunk($skuIds, 500) as $chunk) {
try {
$rows = $this->connection->table('ops_jst_sku')
->whereIn('sku_id', $chunk)
->get(['sku_id', 'weight']);
} catch (Throwable) {
return [];
}
foreach ($rows as $row) {
$skuId = strtoupper(trim((string) $row->sku_id));
$weight = $row->weight;
if ($skuId === '' || $weight === null || $weight === '' || isset($map[$skuId])) {
continue;
}
$map[$skuId] = (float) $weight;
}
}
return $map;
}
/**
* @param array<string,array<string,mixed>> $returnContext
* @return array<int,string>
*/
private function importReturns(array $returnContext): array
{
$oIds = [];
foreach ($returnContext as $context) {
$oId = (int) ($context['o_id'] ?? 0);
if ($oId > 0) {
$oIds[$oId] = true;
}
}
if ($oIds === []) {
return [];
}
try {
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('operations_replica');
} catch (Throwable) {
return [];
}
$byOrder = [];
foreach (array_chunk(array_keys($oIds), 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT a.o_id, a.l_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 . ')',
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$oId = (int) ($row['o_id'] ?? 0);
$waybill = trim((string) ($row['l_id'] ?? ''));
if ($oId <= 0
|| $waybill === ''
|| (float) ($row['r_qty'] ?? 0) <= 0
|| trim((string) ($row['receive_date'] ?? '')) === '') {
continue;
}
$byOrder[$oId][$waybill] = true;
}
}
$map = [];
foreach ($byOrder as $oId => $waybills) {
$map[(int) $oId] = implode(',', array_keys($waybills));
}
return $map;
}
/** @return array<string,array<string,list<string>>> */
private function activeRuleModes(): array
{
try {
$rules = DB::table('business_courier_surcharge_quotes')
->where('status', 'active')
->get(['courier', 'types', 'mode']);
} catch (Throwable) {
return [];
}
$map = [];
foreach ($rules as $rule) {
$types = json_decode((string) $rule->types, true) ?: [];
$courier = trim((string) $rule->courier);
if ($courier === '') {
continue;
}
foreach ($types as $type) {
$type = trim((string) $type);
if ($type !== '') {
$map[$courier][$type][] = trim((string) $rule->mode);
}
}
}
return $map;
}
private function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
}
/** @return array<string,mixed> */
private function mapRow(object $row): array
{
return [
'courier_name' => $row->courier_name,
'waybill' => $row->waybill,
'shipped_at' => $row->shipped_at,
'platform_product_id' => $row->platform_product_id,
'product_code' => $row->product_code,
'shop_name' => $row->shop_name,
'online_order_no' => $row->online_order_no,
'length' => $row->length,
'width' => $row->width,
'height' => $row->height,
'volume' => $row->volume,
'inspection_type' => $row->inspection_type,
'inspection_reason' => $row->inspection_reason,
'return_waybill_no' => $row->return_waybill_no,
'settlement_weight' => $row->settlement_weight,
'settlement_amount' => $row->settlement_amount,
'surcharge_type' => $row->surcharge_type,
];
}
private function timestamp(mixed $value): ?int
{
if (! is_string($value) || $value === '') {
return null;
}
try {
return Carbon::parse($value)->getTimestamp();
} catch (Throwable) {
return null;
}
}
}

View File

@ -0,0 +1,263 @@
@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' => 'shipped_at', 'label' => '出库时间'],
['key' => 'waybill', 'label' => '单号'],
['key' => 'platform_product_id', 'label' => '平台商品ID'],
['key' => 'product_code', 'label' => '商品编码'],
['key' => 'shop_name', 'label' => '店铺名称'],
['key' => 'online_order_no', 'label' => '线上订单号'],
['key' => 'length', 'label' => '长'],
['key' => 'width', 'label' => '宽'],
['key' => 'height', 'label' => '高'],
['key' => 'volume', 'label' => '体积'],
['key' => 'inspection_type', 'label' => '查件类型'],
['key' => 'inspection_reason', 'label' => '查件原因'],
['key' => 'return_waybill_no', 'label' => '退回单号'],
['key' => 'settlement_weight', 'label' => '结算重量'],
['key' => 'settlement_amount', 'label' => '结算金额'],
['key' => 'surcharge_type', 'label' => '附加费类型', 'fixed' => 'end'],
];
$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 '—';
}
if ($key === 'settlement_amount' && is_numeric($value)) {
return number_format((float) $value, 2, '.', '');
}
return (string) $value;
};
@endphp
<section class="surcharge-settlement-page" aria-label="附加费结算">
<style>
.surcharge-settlement-page {
--settlement-ink: var(--vip-content-ink, #171717);
--settlement-muted: var(--vip-content-muted, #595959);
--settlement-accent: var(--vip-content-accent-dark, #856632);
--settlement-soft: var(--vip-content-accent-soft, #f7f0de);
--settlement-border: var(--vip-border, #d8d2c7);
display: grid;
gap: 1rem;
min-width: 0;
color: var(--settlement-ink);
font-family: "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace;
font-size: .8125rem;
}
.surcharge-settlement-page *,
.surcharge-settlement-page *::before,
.surcharge-settlement-page *::after { box-sizing: border-box; }
.surcharge-settlement-page > * { min-width: 0; }
.settlement-notice {
display: flex;
align-items: flex-start;
gap: .65rem;
padding: .75rem 1rem;
border: 1px solid #d7c39d;
border-radius: .65rem;
background: #fffaf0;
color: #6a5129;
line-height: 1.55;
}
.settlement-notice .material-icons-outlined { color: var(--settlement-accent); font-size: 1.15rem; }
.settlement-notice strong { color: var(--settlement-ink); }
.settlement-summary {
display: grid;
grid-template-columns: 1.35fr repeat(3, minmax(8rem, 1fr));
overflow: hidden;
border: 1px solid var(--settlement-border);
border-radius: .75rem;
background: #fff;
}
.settlement-summary__intro,
.settlement-summary__metric { min-width: 0; padding: 1rem 1.15rem; }
.settlement-summary__intro { display: flex; align-items: center; gap: .75rem; }
.settlement-summary__icon { display: grid; flex: none; width: 2.4rem; height: 2.4rem; place-items: center; border-radius: .6rem; background: var(--settlement-soft); color: var(--settlement-accent); }
.settlement-summary__title { font-size: .95rem; font-weight: 700; }
.settlement-summary__subtitle { margin-top: .2rem; color: var(--settlement-muted); font-size: .72rem; }
.settlement-summary__metric { border-left: 1px solid var(--settlement-border); }
.settlement-summary__label { color: var(--settlement-muted); font-size: .72rem; }
.settlement-summary__value { margin-top: .25rem; font-size: 1.1rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.settlement-filter-bar {
display: grid;
grid-template-columns: repeat(4, minmax(10rem, 1fr));
gap: .7rem;
align-items: end;
padding: 1rem 1.15rem;
border: 1px solid var(--settlement-border);
border-radius: .75rem;
background: #fffdf8;
}
.settlement-field { display: grid; gap: .3rem; min-width: 0; }
.settlement-field label { color: var(--settlement-muted); font-size: .72rem; font-weight: 600; }
.settlement-field input,
.settlement-field select {
width: 100%;
height: 2.2rem;
min-width: 0;
padding: 0 .6rem;
border: 1px solid var(--settlement-border);
border-radius: .45rem;
background: #fff;
color: var(--settlement-ink);
font: inherit;
}
.settlement-field input:focus,
.settlement-field select:focus { border-color: var(--settlement-accent); outline: none; box-shadow: 0 0 0 3px rgba(133, 102, 50, .18); }
.settlement-actions { display: flex; flex-wrap: wrap; gap: .45rem; align-items: end; }
.settlement-button { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; min-height: 2.2rem; padding: 0 .75rem; border: 1px solid var(--settlement-border); border-radius: .45rem; background: #fff; color: var(--settlement-ink); font: inherit; font-weight: 600; text-decoration: none; white-space: nowrap; cursor: pointer; }
.settlement-button--primary { border-color: var(--settlement-accent); background: var(--settlement-accent); color: #fff; }
.settlement-button .material-icons-outlined { font-size: 1rem; }
.settlement-import-bar { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem 1rem; margin-top: .75rem; padding: .85rem 1.15rem; border: 1px solid var(--settlement-border); border-radius: .75rem; background: #fff; }
.settlement-import-bar .settlement-field { flex: 0 1 22rem; }
.settlement-import-bar input[type="file"] { width: 100%; height: 2.2rem; padding: .35rem .5rem; border: 1px dashed var(--settlement-border); border-radius: .45rem; background: #fffdf8; color: var(--settlement-ink); font: inherit; }
.settlement-import-bar .settlement-table-hint { align-self: center; }
.settlement-table-surface { overflow: visible; border: 1px solid var(--settlement-border); border-radius: .75rem; background: #fff; }
.settlement-table-head { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: .75rem 1rem; padding: .85rem 1.15rem; border-bottom: 1px solid var(--settlement-border); }
.settlement-table-title { display: flex; align-items: center; gap: .45rem; font-size: .9rem; font-weight: 700; }
.settlement-table-title .material-icons-outlined { color: var(--settlement-accent); font-size: 1.1rem; }
.settlement-table-hint { color: var(--settlement-muted); font-size: .72rem; }
.settlement-table-meta { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .6rem 1rem; padding: .65rem 1.15rem; border-bottom: 1px solid var(--settlement-border); background: #fffdf8; color: var(--settlement-muted); font-size: .72rem; }
.settlement-table-meta strong { color: var(--settlement-ink); }
.settlement-table-scroll { max-height: min(62vh, 42rem); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable both-edges; }
.settlement-table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; font-size: .75rem; }
.settlement-table th,
.settlement-table td { min-width: 7.5rem; padding: .62rem .7rem; border-right: 1px solid var(--settlement-border); border-bottom: 1px solid var(--settlement-border); text-align: left; vertical-align: top; white-space: nowrap; }
.settlement-table th { background: var(--settlement-soft); font-weight: 700; }
.settlement-table th[data-finance-fixed="true"],
.settlement-table td[data-finance-fixed="true"] { background: #fffdf8; }
.settlement-table th[data-finance-fixed="end"],
.settlement-table td[data-finance-fixed="end"] { background: #fffdf8; }
.settlement-table td { overflow: hidden; text-overflow: ellipsis; }
.settlement-table .settlement-empty td { min-width: 22rem; padding: 3rem 1rem; text-align: center; color: var(--settlement-muted); white-space: normal; }
@media (max-width: 900px) {
.settlement-summary { grid-template-columns: 1fr 1fr; }
.settlement-summary__intro { grid-column: 1 / -1; }
.settlement-summary__metric:nth-child(2) { border-left: 0; }
.settlement-filter-bar { grid-template-columns: repeat(2, minmax(10rem, 1fr)); }
}
@media (max-width: 560px) {
.settlement-summary { grid-template-columns: 1fr; }
.settlement-summary__metric { border-left: 0; border-top: 1px solid var(--settlement-border); }
.settlement-filter-bar { grid-template-columns: 1fr; }
}
</style>
@if (session('error'))
<div class="settlement-notice" role="status">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<div><strong>导入核验失败</strong><div>{{ session('error') }}</div></div>
</div>
@endif
@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">{{ 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><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><a class="settlement-button" href="{{ route('business.courier-surcharge.settlement.export', array_filter($filters, static fn ($value): bool => $value !== '' && $value !== null)) }}" title="按当前筛选导出附加费结算明细(XLSX,仅含已生成结算行的数据;未生成结算行的运单请用导入核验)"><span class="material-icons-outlined" aria-hidden="true">download</span>导出</a></div>
</form>
<form class="settlement-import-bar" method="POST" action="{{ route('business.courier-surcharge.settlement.import') }}" enctype="multipart/form-data" aria-label="附加费结算导入核验">
@csrf
<div class="settlement-field">
<label for="settlement-import-file">导入核验(单号 + 附加费类型)</label>
<input id="settlement-import-file" type="file" name="file" accept=".xlsx,.xls,.csv" required>
</div>
<div class="settlement-actions">
<button class="settlement-button settlement-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">upload_file</span>导入并下载结果</button>
<a class="settlement-button" href="{{ route('business.courier-surcharge.settlement.import-template') }}"><span class="material-icons-outlined" aria-hidden="true">download</span>下载导入模板</a>
<span class="settlement-table-hint">首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回导入金额与差异金额(系统金额 − 导入金额)。未生成结算行的运单用导入核验获取字段明细。</span>
</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>{{ 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>
@foreach ($columns as $column)
<th scope="col" data-finance-column-key="{{ $column['key'] }}" data-finance-fixed="{{ $column['fixed'] ?? '' }}" data-export-field="{{ $column['key'] }}">{{ $column['label'] }}</th>
@endforeach
</tr></thead>
<tbody>
@forelse ($rows as $row)
<tr>
@foreach ($columns as $column)
<td data-finance-column-key="{{ $column['key'] }}" data-export-value="{{ $displayValue(data_get($row, $column['key']), $column['key']) }}">{{ $displayValue(data_get($row, $column['key']), $column['key']) }}</td>
@endforeach
</tr>
@empty
<tr class="settlement-empty is-empty"><td colspan="{{ count($columns) }}"><span class="material-icons-outlined" aria-hidden="true">{{ $error !== null ? 'error_outline' : 'inbox' }}</span><div>{{ $emptyMessage }}</div></td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
@if (method_exists($rows, 'links') && $rows->hasPages())
<div class="settlement-pagination">{{ $rows->withQueryString()->links() }}</div>
@endif
</section>

View File

@ -0,0 +1,63 @@
@php
$surchargeTabUrls = collect(['quotes', 'settlement'])->mapWithKeys(fn ($tab) => [
$tab => route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge', 'tab' => $tab]),
])->all();
@endphp
<div data-surcharge-workspace>
<div data-surcharge-panel="quotes" @if ($courierSurchargeActiveTab !== 'quotes') hidden @endif>
@include('business.pages.courier-payables.courier-surcharge')
</div>
<div data-surcharge-panel="settlement" @if ($courierSurchargeActiveTab !== 'settlement') hidden @endif>
@include('business.pages.courier-payables.courier-surcharge-settlement')
</div>
</div>
<script>
(() => {
const root = document.querySelector('[data-surcharge-workspace]');
const content = root.closest('.business-content-section');
const navigation = content.parentElement.querySelector('.business-tab-bar');
const links = [...navigation.querySelectorAll('a')];
const panels = [...root.querySelectorAll('[data-surcharge-panel]')];
const title = document.querySelector('[data-page-title-first] h1');
const documentTitle = document.title;
const initialTitle = @json($courierSurchargeActiveTab === 'settlement' ? '附加费结算' : '附加费报价');
const urls = @json($surchargeTabUrls);
const keyFor = (link) => Object.keys(urls).find(key => new URL(urls[key]).pathname === new URL(link.href).pathname);
const savedUrls = { ...urls };
let active = @json($courierSurchargeActiveTab);
savedUrls[active] = location.href;
function select(key, push = true) {
if (!Object.hasOwn(urls, key)) return;
if (push) savedUrls[active] = location.href;
active = key;
panels.forEach(panel => { panel.hidden = panel.dataset.surchargePanel !== key; });
links.forEach(link => {
const selected = keyFor(link) === key;
link.classList.toggle('is-active', selected);
if (selected) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
});
const label = key === 'settlement' ? '附加费结算' : '附加费报价';
if (title) title.textContent = label;
content.setAttribute('aria-label', label);
document.title = documentTitle.replace(initialTitle, label);
if (push) history.pushState({}, '', savedUrls[key]);
window.dispatchEvent(new Event('resize'));
}
links.forEach(link => link.addEventListener('click', event => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
const key = keyFor(link);
if (!key) return;
event.preventDefault();
if (key !== active) select(key);
}));
window.addEventListener('popstate', () => {
const key = Object.keys(urls).find(key => new URL(urls[key]).pathname === location.pathname) || 'quotes';
select(key, false);
savedUrls[key] = location.href;
});
})();
</script>

View File

@ -352,6 +352,19 @@ Route::get('workspace/courier-payables/return-pickup-management/export', 'Busine
->middleware('business.page:courier-payables,return-pickup-management')
->name('business.courier-payables.return-pickup-management.export');
// 必须注册在通用 {section}/{page}/{tab}/export 之前,否则会被通用导出路由截获。
Route::get('workspace/courier-payables/courier-surcharge/settlement/export', 'Business\Pages@exportCourierSurchargeSettlement')
->middleware('business.page:courier-payables,courier-surcharge')
->name('business.courier-surcharge.settlement.export');
Route::post('workspace/courier-payables/courier-surcharge/settlement/import', 'Business\Pages@importCourierSurchargeSettlement')
->middleware('business.page:courier-payables,courier-surcharge')
->name('business.courier-surcharge.settlement.import');
Route::get('workspace/courier-payables/courier-surcharge/settlement/import-template', 'Business\Pages@downloadCourierSurchargeSettlementImportTemplate')
->middleware('business.page:courier-payables,courier-surcharge')
->name('business.courier-surcharge.settlement.import-template');
Route::get('workspace/courier-payables/third-party-warehouse-storage/export', 'Business\Pages@exportThirdPartyWarehouseStorage')
->middleware('business.page:courier-payables,third-party-warehouse-storage')
->name('business.courier-payables.third-party-warehouse-storage.export');

View File

@ -31,7 +31,8 @@ class CourierSurchargePageTest extends FeatureTestCase
'page' => 'courier-surcharge',
]))
->assertOk()
->assertSeeText('附加费报价维护')
->assertSeeText('附加费报价')
->assertSee('aria-label="附加费报价维护"', false)
->assertSeeText('正式维护数据')
->assertSeeText('报价规则列表')
->assertSeeText('管理附加费类型')

View File

@ -0,0 +1,304 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Http\UploadedFile;
use Mockery;
use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\RichText\RichText;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\Feature\FeatureTestCase;
class CourierSurchargeSettlementPageTest extends FeatureTestCase
{
public function test_page_renders_requested_settlement_columns_and_mirror_state(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
'tab' => 'settlement',
]))->assertOk()
->assertSee('data-surcharge-panel="quotes" hidden', false)
->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('出库时间')
->assertSeeText('平台商品ID')
->assertSeeText('查件原因')
->assertSeeText('退回单号')
->assertSeeText('结算重量')
->assertSeeText('结算金额')
->assertSeeText('附加费类型');
}
public function test_settlement_filters_are_preserved_in_the_form(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
'tab' => 'settlement',
'courier_name' => '测试快递',
'waybill' => '001234',
'shop_name' => '测试店铺',
'online_order_no' => 'SO-001',
'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['courierSurchargeSettlementFilters']['status'] === '已计算');
}
public function test_quote_page_exposes_quote_and_settlement_tabs(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
]))->assertOk()
->assertSeeText('附加费报价')
->assertSeeText('附加费结算')
->assertSee('data-surcharge-panel="settlement" hidden', false)
->assertSee(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
'tab' => 'quotes',
]), false)
->assertSee(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
'tab' => 'settlement',
]), false);
}
public function test_old_settlement_page_url_redirects_to_settlement_tab(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge-settlement',
'waybill' => '0000123456789',
]))->assertRedirect(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
'tab' => 'settlement',
'waybill' => '0000123456789',
]));
}
public function test_settlement_export_streams_filtered_rows_as_xlsx(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(CourierSurchargeSettlementMirrorRepository::class);
$repository->shouldReceive('exportRows')
->once()
->withArgs(static fn (array $filters): bool => ($filters['courier_name'] ?? '') === '曹县德邦')
->andReturn((function (): \Generator {
yield (object) [
'courier_name' => '曹县德邦',
'waybill' => 'DPK317249507921',
'shipped_at' => '2026-10-09 08:39:40',
'platform_product_id' => null,
'product_code' => 'DNZ032912N887V01',
'shop_name' => '拼多多-满富油家具旗舰店',
'online_order_no' => '261009-325069063762746',
'length' => 194,
'width' => 83,
'height' => 12.5,
'volume' => 201305,
'inspection_type' => null,
'inspection_reason' => null,
'return_waybill_no' => null,
'settlement_weight' => null,
'settlement_amount' => 8,
'surcharge_type' => '超长费',
];
})());
$this->app->instance(CourierSurchargeSettlementMirrorRepository::class, $repository);
$response = $this->loginAs()->get(route('business.courier-surcharge.settlement.export', [
'courier_name' => '曹县德邦',
]));
$response->assertOk();
$this->assertStringContainsString(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
(string) $response->headers->get('content-type'),
);
$path = tempnam(sys_get_temp_dir(), 'surcharge_settlement_export_');
file_put_contents($path, $response->streamedContent());
$sheet = IOFactory::load($path)->getActiveSheet();
$plain = static fn (mixed $value): mixed => $value instanceof RichText ? $value->getPlainText() : $value;
$headers = [];
foreach ($sheet->getRowIterator(1, 1) as $row) {
foreach ($row->getCellIterator() as $cell) {
$headers[$cell->getColumn()] = $plain($cell->getValue());
}
}
$column = static fn (string $label): string => (string) array_search($label, $headers, true);
$value = static fn (string $label): mixed => $plain($sheet->getCell($column($label) . '2')->getValue());
try {
$this->assertSame('快递名称', $headers['A']);
$this->assertSame('出库时间', $headers['B']);
$this->assertSame('单号', $headers['C']);
$this->assertSame('附加费类型', $headers['Q']);
$this->assertSame('曹县德邦', $value('快递名称'));
$this->assertSame('DPK317249507921', $value('单号'));
$this->assertSame('2026-10-09 08:39:40', $value('出库时间'));
$this->assertSame('DNZ032912N887V01', $value('商品编码'));
$this->assertSame('拼多多-满富油家具旗舰店', $value('店铺名称'));
$this->assertEquals(194, $value('长'));
$this->assertEquals(12.5, $value('高'));
$this->assertEquals(201305, $value('体积'));
$this->assertEquals(8, $value('结算金额'));
$this->assertSame('超长费', $value('附加费类型'));
$this->assertNull($value('查件类型'));
} finally {
unlink($path);
}
}
public function test_settlement_import_streams_decision_workbook(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(CourierSurchargeSettlementMirrorRepository::class);
$repository->shouldReceive('decideImportRows')
->once()
->withArgs(static function (array $pairs): bool {
return count($pairs) === 2
&& $pairs[0]['waybill'] === 'DPK317138931411'
&& $pairs[0]['surcharge_type'] === '退回费'
&& $pairs[0]['courier_name'] === '曹县德邦小件'
&& $pairs[0]['amount'] === 10.0
&& $pairs[1]['waybill'] === 'DPK1'
&& $pairs[1]['surcharge_type'] === '超长费'
&& $pairs[1]['courier_name'] === ''
&& $pairs[1]['amount'] === null;
})
->andReturn([
[
'row_number' => 2, 'waybill' => 'DPK317138931411', 'surcharge_type' => '退回费',
'decision' => '正常结', 'reason' => '', 'courier_name' => '曹县德邦小件',
'shipped_at' => '2026-07-09 13:25:51', 'platform_product_id' => '968882879362',
'product_code' => 'YMJ000904N722', 'shop_name' => '拼多多-卓禾住宅家具旗舰店',
'online_order_no' => '260709-594595049040320', 'length' => null, 'width' => null,
'height' => null, 'volume' => null, 'inspection_type' => '扣单',
'inspection_reason' => '买家-不想要', 'return_waybill_no' => 'DPK317138931411',
'settlement_weight' => 2.4, 'settlement_amount' => 20, 'settlement_status' => '已计算',
'evidence' => '查件扣单/买家-不想要;退款确认 2026-07-15 15:00:09;售后退货退款已进仓 2026-07-18 09:53:32;实退 1',
],
[
'row_number' => 3, 'waybill' => 'DPK1', 'surcharge_type' => '超长费',
'decision' => '不结', 'reason' => '未找到超长费结算行:单边未超报价阈值或不在已拉取范围',
'courier_name' => null, 'shipped_at' => null, 'platform_product_id' => null,
'product_code' => null, 'shop_name' => null, 'online_order_no' => null,
'length' => null, 'width' => null, 'height' => null, 'volume' => null,
'inspection_type' => null, 'inspection_reason' => null, 'return_waybill_no' => null,
'settlement_weight' => null, 'settlement_amount' => null, 'settlement_status' => null,
'evidence' => null,
],
]);
$this->app->instance(CourierSurchargeSettlementMirrorRepository::class, $repository);
$spreadsheet = new Spreadsheet();
$spreadsheet->getActiveSheet()->fromArray([
['单号', '附加费类型', '快递名称', '金额'],
['DPK317138931411', '退回费', '曹县德邦小件', 10],
['DPK1', '超长费'],
]);
$importPath = tempnam(sys_get_temp_dir(), 'surcharge_import_');
(new Xlsx($spreadsheet))->save($importPath);
$response = $this->loginAs()->post(route('business.courier-surcharge.settlement.import'), [
'file' => new UploadedFile(
$importPath,
'surcharge-import.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
null,
true,
),
]);
$response->assertOk();
$this->assertStringContainsString(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
(string) $response->headers->get('content-type'),
);
$resultPath = tempnam(sys_get_temp_dir(), 'surcharge_import_result_');
file_put_contents($resultPath, $response->streamedContent());
$sheet = IOFactory::load($resultPath)->getActiveSheet();
$plain = static fn (mixed $value): mixed => $value instanceof RichText ? $value->getPlainText() : $value;
try {
$this->assertSame('序号', $plain($sheet->getCell('A1')->getValue()));
$this->assertSame('结算结论', $plain($sheet->getCell('D1')->getValue()));
$this->assertSame('不结原因', $plain($sheet->getCell('E1')->getValue()));
$this->assertSame('正常结', $plain($sheet->getCell('D2')->getValue()));
$this->assertSame('DPK317138931411', $plain($sheet->getCell('B2')->getValue()));
$this->assertSame('退回费', $plain($sheet->getCell('C2')->getValue()));
$this->assertSame('导入金额', $plain($sheet->getCell('U1')->getValue()));
$this->assertSame('差异金额', $plain($sheet->getCell('V1')->getValue()));
$this->assertEquals(10, $plain($sheet->getCell('U2')->getValue()));
$this->assertEquals(10, $plain($sheet->getCell('V2')->getValue()));
$this->assertSame('不结', $plain($sheet->getCell('D3')->getValue()));
$this->assertStringContainsString('未找到超长费', (string) $plain($sheet->getCell('E3')->getValue()));
$this->assertNull($plain($sheet->getCell('V3')->getValue()));
} finally {
@unlink($importPath);
@unlink($resultPath);
}
}
public function test_settlement_import_template_streams_workbook(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$response = $this->loginAs()->get(route('business.courier-surcharge.settlement.import-template'));
$response->assertOk();
$this->assertStringContainsString(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
(string) $response->headers->get('content-type'),
);
$path = tempnam(sys_get_temp_dir(), 'surcharge_import_template_');
file_put_contents($path, $response->streamedContent());
$sheet = IOFactory::load($path)->getActiveSheet();
$plain = static fn (mixed $value): mixed => $value instanceof RichText ? $value->getPlainText() : $value;
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('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()));
} finally {
@unlink($path);
}
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository;
use Tests\TestCase;
class CourierSurchargeSettlementMirrorConfigTest extends TestCase
{
public function test_settlement_rows_table_contract(): void
{
$this->assertSame('courier_surcharge_settlement_rows', CourierSurchargeSettlementMirrorRepository::TABLE);
}
public function test_surcharge_type_contract_includes_overlength_alongside_the_nine_types(): void
{
$this->assertSame([
'退回费', '拦截费', '改地址', '上楼费', '送货费',
'超区费', '安装费', '保费', '进仓费', '超长费',
], CourierSurchargeSettlementMirrorRepository::SURCHARGE_TYPES);
}
}

View File

@ -0,0 +1,200 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository;
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',
]);
DB::purge('business_mirror');
$this->repository = new CourierSurchargeSettlementMirrorRepository(app('db'));
}
public function test_it_reports_unavailable_until_the_settlement_table_exists(): void
{
$this->assertFalse($this->repository->available());
$this->assertFalse($this->repository->ready());
$this->assertSame('pending', $this->repository->state()['code']);
}
public function test_it_filters_maps_and_summarises_settlement_rows(): void
{
$this->createTable();
DB::connection('business_mirror')->table(CourierSurchargeSettlementMirrorRepository::TABLE)->insert([
[
'rule_id' => 6, 'surcharge_type' => '超长费', 'courier_name' => '曹县德邦',
'waybill' => 'DPK1', 'outbound_io_id' => 1001, 'product_code' => 'DNZ032912N887V01',
'shop_name' => '拼多多-满富油家具旗舰店', 'online_order_no' => '261009-325069063762746',
'length' => 194, 'width' => 83, 'height' => 12.5, 'volume' => 201305,
'settlement_amount' => 8, 'settlement_status' => '已计算',
'shipped_at' => '2026-10-09 08:41:48', 'pulled_at' => '2026-10-09 12:00:00',
'evidence' => '最长边 194cm > 160cm',
'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00',
],
[
'rule_id' => 42, 'surcharge_type' => '超长费', 'courier_name' => '常州德邦',
'waybill' => 'DPK2', 'outbound_io_id' => 1002, 'product_code' => 'GBGJ-ZWJ024903A',
'shop_name' => '京东-HX旗舰店104', 'online_order_no' => null,
'length' => 244, 'width' => 7.5, 'height' => 8, 'volume' => 14640,
'settlement_amount' => 7, 'settlement_status' => '已计算',
'shipped_at' => '2026-10-08 10:00:00', 'pulled_at' => '2026-10-09 12:00:00',
'evidence' => '最长边 244cm > 160cm',
'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00',
],
]);
$this->assertTrue($this->repository->available());
$this->assertTrue($this->repository->ready());
$result = $this->repository->paginate([], 1, 25);
$this->assertSame(2, $result['total']);
$this->assertSame(15.0, $result['summary']['settlement_amount']);
$this->assertSame('曹县德邦', $result['data'][0]['courier_name']);
$this->assertSame('DPK1', $result['data'][0]['waybill']);
$this->assertSame('2026-10-09 08:41:48', $result['data'][0]['shipped_at']);
$this->assertSame('超长费', $result['data'][0]['surcharge_type']);
$this->assertSame(194.0, (float) $result['data'][0]['length']);
$this->assertSame(8.0, (float) $result['data'][0]['settlement_amount']);
$this->assertSame('fresh', $result['sync']['code']);
}
public function test_it_applies_page_filters(): void
{
$this->createTable();
DB::connection('business_mirror')->table(CourierSurchargeSettlementMirrorRepository::TABLE)->insert([
[
'rule_id' => 6, 'surcharge_type' => '超长费', 'courier_name' => '曹县德邦',
'waybill' => 'DPK1', 'outbound_io_id' => 1001, 'product_code' => 'DNZ032912N887V01',
'settlement_amount' => 8, 'settlement_status' => '已计算',
'shipped_at' => '2026-10-09 08:41:48', 'pulled_at' => '2026-10-09 12:00:00',
'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00',
],
[
'rule_id' => 42, 'surcharge_type' => '超长费', 'courier_name' => '常州德邦',
'waybill' => 'DPK2', 'outbound_io_id' => 1002, 'product_code' => 'GBGJ-ZWJ024903A',
'settlement_amount' => 7, 'settlement_status' => '待核价',
'shipped_at' => '2026-09-01 10:00:00', 'pulled_at' => '2026-10-09 12:00:00',
'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00',
],
]);
$byCourier = $this->repository->paginate(['courier_name' => '常州', 'status' => '待核价'], 1, 25);
$this->assertSame(1, $byCourier['total']);
$this->assertSame('常州德邦', $byCourier['data'][0]['courier_name']);
$this->assertSame(7.0, $byCourier['summary']['settlement_amount']);
$byDate = $this->repository->paginate(['created_from' => '2026-10-01', 'created_to' => '2026-10-09'], 1, 25);
$this->assertSame(1, $byDate['total']);
$this->assertSame('DPK1', $byDate['data'][0]['waybill']);
$byWaybill = $this->repository->paginate(['waybill' => 'DPK2'], 1, 25);
$this->assertSame(1, $byWaybill['total']);
$this->assertSame('常州德邦', $byWaybill['data'][0]['courier_name']);
}
public function test_import_decisions_report_settlement_and_reasons(): void
{
$this->createTable();
DB::connection('business_mirror')->table(CourierSurchargeSettlementMirrorRepository::TABLE)->insert([
[
'rule_id' => 6, 'surcharge_type' => '超长费', 'courier_name' => '曹县德邦',
'waybill' => 'DPK1', 'outbound_io_id' => 1001, 'product_code' => 'SKU-1',
'settlement_amount' => 8, 'settlement_status' => '已计算',
'shipped_at' => '2026-10-09 08:41:48', 'pulled_at' => '2026-10-09 12:00:00',
'evidence' => '最长边 194cm > 160cm',
'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00',
],
[
'rule_id' => 7, 'surcharge_type' => '退回费', 'courier_name' => '曹县德邦小件',
'waybill' => 'DPK2', 'outbound_io_id' => null, 'product_code' => 'SKU-2',
'settlement_amount' => 0, 'settlement_status' => '已计算',
'shipped_at' => '2026-09-01 10:00:00', 'pulled_at' => '2026-10-09 12:00:00',
'evidence' => '查件扣单/买家-不想要;售后退货退款无进仓/实退记录;结算金额 0.00',
'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00',
],
]);
$result = $this->repository->decideImportRows([
['row_number' => 2, 'waybill' => 'DPK1', 'surcharge_type' => '超长费'],
['row_number' => 3, 'waybill' => 'DPK2', 'surcharge_type' => '退回件'],
['row_number' => 4, 'waybill' => 'DPK3', 'surcharge_type' => '退回费'],
['row_number' => 5, 'waybill' => 'DPK4', 'surcharge_type' => '退回费', 'courier_name' => '恒思德邦'],
['row_number' => 6, 'waybill' => 'DPK1', 'surcharge_type' => '超长费', 'courier_name' => '常州德邦'],
]);
$this->assertSame('正常结', $result[0]['decision']);
$this->assertSame('', $result[0]['reason']);
$this->assertSame('曹县德邦', $result[0]['courier_name']);
$this->assertSame(8.0, (float) $result[0]['settlement_amount']);
$this->assertSame('不结', $result[1]['decision']);
$this->assertSame('退回费', $result[1]['surcharge_type']);
$this->assertStringContainsString('无进仓', $result[1]['reason']);
$this->assertSame('不结', $result[2]['decision']);
$this->assertNotSame('', $result[2]['reason']);
$this->assertNull($result[2]['settlement_amount']);
$this->assertSame('不结', $result[3]['decision']);
$this->assertStringContainsString('恒思德邦', $result[3]['reason']);
$this->assertSame('恒思德邦', $result[3]['courier_name']);
$this->assertSame('不结', $result[4]['decision']);
$this->assertStringContainsString('不一致', $result[4]['reason']);
}
private function createTable(): void
{
Schema::connection('business_mirror')->create(
CourierSurchargeSettlementMirrorRepository::TABLE,
function (Blueprint $table): void {
$table->bigIncrements('id');
$table->unsignedInteger('rule_id');
$table->string('surcharge_type', 40);
$table->string('courier_name', 191);
$table->string('waybill', 191)->nullable();
$table->unsignedBigInteger('outbound_io_id')->nullable();
$table->string('platform_product_id', 191)->nullable();
$table->string('product_code', 191)->nullable();
$table->string('shop_name', 191)->nullable();
$table->string('online_order_no', 191)->nullable();
$table->decimal('length', 12, 2)->nullable();
$table->decimal('width', 12, 2)->nullable();
$table->decimal('height', 12, 2)->nullable();
$table->decimal('volume', 16, 2)->nullable();
$table->string('inspection_type', 60)->nullable();
$table->string('inspection_reason', 120)->nullable();
$table->string('return_waybill_no', 191)->nullable();
$table->decimal('settlement_weight', 12, 2)->nullable();
$table->decimal('settlement_amount', 12, 2)->default(0);
$table->string('settlement_status', 40)->default('已计算');
$table->string('evidence', 500)->nullable();
$table->dateTime('shipped_at')->nullable();
$table->dateTime('pulled_at');
$table->timestamps();
},
);
}
}