Compare commits
14 Commits
main
...
preview/zh
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fbb924924 | |||
| 2118abcf27 | |||
| ce2d3fee9e | |||
| 4b79370fae | |||
| a11e9fc2f8 | |||
| fffbe66b56 | |||
| d98aba6d77 | |||
| 477af16909 | |||
| 433d4d9665 | |||
| c52877d72c | |||
| 92d0305892 | |||
| 53f9c8855e | |||
| 1558a30e89 | |||
| a83347aef3 |
683
app/Console/Commands/PullCourierSurchargeOverlength.php
Normal file
683
app/Console/Commands/PullCourierSurchargeOverlength.php
Normal 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'), '.');
|
||||
}
|
||||
}
|
||||
727
app/Console/Commands/PullCourierSurchargeReturn.php
Normal file
727
app/Console/Commands/PullCourierSurchargeReturn.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,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;
|
||||
@ -94,6 +95,7 @@ class Pages extends Controller
|
||||
private readonly CourierSurchargeTypeRepository $courierSurchargeTypes,
|
||||
private readonly CourierSurchargeAuditLogRepository $courierSurchargeAudits,
|
||||
private readonly JushuitanShopBalanceRepository $jushuitanShopBalances,
|
||||
private readonly CourierSurchargeSettlementMirrorRepository $courierSurchargeSettlements,
|
||||
private readonly ShippingOrderRepository $shippingOrders,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
@ -174,6 +176,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()) {
|
||||
@ -240,6 +251,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);
|
||||
}
|
||||
|
||||
@ -811,9 +834,10 @@ class Pages extends Controller
|
||||
'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
|
||||
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
|
||||
'online-daifa-price' => $this->contentForOnlineDaifaPrice(),
|
||||
'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(),
|
||||
'withdrawal-management' => $this->contentForWithdrawalManagement(),
|
||||
'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(),
|
||||
default => null,
|
||||
@ -1138,24 +1162,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'],
|
||||
@ -1166,6 +1192,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
|
||||
@ -2958,6 +3358,31 @@ class Pages extends Controller
|
||||
return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* XLSX 文本单元格值:空值返回 null(跳过单元格)。
|
||||
* 文本单元格不会被当作公式执行,因此不需要 csvText 的公式前缀保护。
|
||||
*/
|
||||
private function xlsxText(mixed $value): ?string
|
||||
{
|
||||
$text = trim((string) ($value ?? ''));
|
||||
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* XLSX 日期时间单元格值:去掉 SQL Server 微秒尾缀(.000000),空值返回 null。
|
||||
*/
|
||||
private function xlsxDateTime(mixed $value): ?string
|
||||
{
|
||||
$text = trim((string) ($value ?? ''));
|
||||
|
||||
if ($text === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preg_replace('/\.\d{1,6}$/', '', $text) ?? $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep client-selected exports within the fields declared by the page.
|
||||
* An omitted or invalid selection falls back to the complete safe schema.
|
||||
@ -3174,6 +3599,317 @@ class Pages extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
protected function contentForReturnPickupManagement(): array
|
||||
{
|
||||
$filters = $this->returnPickupFilters(request());
|
||||
$page = max((int) request('pickup_page', 1), 1);
|
||||
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
|
||||
$repository = app(\Modules\EcommerceFinance\Repositories\ReturnPickupManagementMirrorRepository::class);
|
||||
$rows = null;
|
||||
$state = null;
|
||||
$syncStatus = ['code' => 'unavailable', 'lag_minutes' => null];
|
||||
$error = null;
|
||||
$pending = false;
|
||||
$carriers = [];
|
||||
$statuses = [];
|
||||
$detail = null;
|
||||
|
||||
try {
|
||||
if (! $repository->available()) {
|
||||
$error = '共享业务镜像尚未建立,退货取件管理暂时无法读取。';
|
||||
} else {
|
||||
$state = $repository->state();
|
||||
$syncStatus = $repository->syncStatus($state);
|
||||
|
||||
if (! $repository->ready()) {
|
||||
$pending = true;
|
||||
$error = '退货取件管理正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
|
||||
} else {
|
||||
$result = $repository->paginate($filters, $page, $limit);
|
||||
$rows = new LengthAwarePaginator(
|
||||
Collection::make($result['data'] ?? []),
|
||||
(int) ($result['total'] ?? 0),
|
||||
$limit,
|
||||
$page,
|
||||
[
|
||||
'path' => request()->url(),
|
||||
'query' => request()->except('pickup_page'),
|
||||
],
|
||||
);
|
||||
$carriers = $repository->carriers();
|
||||
$statuses = $repository->statuses();
|
||||
|
||||
$detailId = (int) request('pickup_id', 0);
|
||||
|
||||
if ($detailId > 0) {
|
||||
$detail = $this->enrichReturnPickupDetail(
|
||||
$repository->detail($detailId),
|
||||
$repository->warehouseDirectory(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$error = '退货取件管理共享镜像暂时无法读取,请稍后重试。';
|
||||
}
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.courier-payables.return-pickup-management',
|
||||
'returnPickupFilters' => $filters,
|
||||
'returnPickupRows' => $rows,
|
||||
'returnPickupState' => $state,
|
||||
'returnPickupSyncStatus' => $syncStatus,
|
||||
'returnPickupError' => $error,
|
||||
'returnPickupPending' => $pending,
|
||||
'returnPickupCarriers' => $carriers,
|
||||
'returnPickupStatuses' => $statuses,
|
||||
'returnPickupDetail' => $detail,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function returnPickupFilters(Request $request): array
|
||||
{
|
||||
$filters = [
|
||||
'created_from' => $this->dateFilter($request->query('created_from')),
|
||||
'created_to' => $this->dateFilter($request->query('created_to')),
|
||||
'carrier' => mb_substr(trim((string) $request->query('carrier', '')), 0, 64),
|
||||
'status' => mb_substr(trim((string) $request->query('status', '')), 0, 64),
|
||||
'shop' => mb_substr(trim((string) $request->query('shop', '')), 0, 191),
|
||||
'keyword' => mb_substr(trim((string) $request->query('keyword', '')), 0, 100),
|
||||
];
|
||||
|
||||
if ($filters['created_from'] !== ''
|
||||
&& $filters['created_to'] !== ''
|
||||
&& $filters['created_from'] > $filters['created_to']) {
|
||||
[$filters['created_from'], $filters['created_to']] = [
|
||||
$filters['created_to'],
|
||||
$filters['created_from'],
|
||||
];
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用逆向物流报价为退货取件明细补「实际运费」和「差异(预估−实际)」。
|
||||
*
|
||||
* @param array{main:object,items:list<object>,truth_numbers:list<string>}|null $detail
|
||||
* @param array<string,string> $warehouseDirectory 发货仓编码 → 名称
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function enrichReturnPickupDetail(?array $detail, array $warehouseDirectory = []): ?array
|
||||
{
|
||||
if ($detail === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$calculator = app(\App\Support\ReturnPickupFreightCalculator::class);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$calculator = null;
|
||||
}
|
||||
|
||||
$main = $detail['main'];
|
||||
$items = [];
|
||||
$estimatedTotal = 0.0;
|
||||
$actualTotal = 0.0;
|
||||
$surchargeTotal = 0.0;
|
||||
$hasActual = false;
|
||||
|
||||
foreach ($detail['items'] as $item) {
|
||||
$result = $calculator?->calculate(
|
||||
$main->express_company ?? null,
|
||||
$main->province ?? null,
|
||||
$item->receiverAddress ?? null,
|
||||
(float) ($item->grossWeight ?? 0),
|
||||
$item->length ?? null,
|
||||
$item->width ?? null,
|
||||
$item->height ?? null,
|
||||
) ?? ['covered' => false, 'reason' => '逆向物流报价快照不可用', 'total' => null, 'surcharge' => 0.0, 'billable' => null, 'formula' => null, 'note' => null, 'surcharge_note' => null];
|
||||
|
||||
$estimated = $item->estimatedFreight !== null ? (float) $item->estimatedFreight : null;
|
||||
$actual = $result['total'] !== null ? (float) $result['total'] : null;
|
||||
$surcharge = (float) ($result['surcharge'] ?? 0);
|
||||
$values = (array) $item;
|
||||
$values['actual_freight'] = $actual;
|
||||
$values['freight_surcharge'] = $surcharge;
|
||||
$values['freight_settlement'] = $result['settlement'];
|
||||
$values['freight_base_amount'] = $result['base_amount'];
|
||||
$values['freight_increment_amount'] = $result['increment_amount'];
|
||||
$values['freight_billable'] = $result['billable'];
|
||||
$values['freight_formula'] = $result['formula'];
|
||||
$values['freight_note'] = $result['covered']
|
||||
? trim(($result['note'] ?? '') . ($result['surcharge_note'] !== null ? ';' . $result['surcharge_note'] : ''))
|
||||
: $result['reason'];
|
||||
$values['freight_process'] = $result['process'] ?? $result['reason'];
|
||||
$values['freight_covered'] = $result['covered'];
|
||||
$values['freight_diff'] = ($estimated !== null && $actual !== null) ? round($estimated - $actual, 2) : null;
|
||||
$warehouseCode = trim((string) ($values['warehouse'] ?? ''));
|
||||
$values['warehouse_name'] = $warehouseCode === ''
|
||||
? null
|
||||
: ($warehouseDirectory[$warehouseCode] ?? null);
|
||||
$items[] = (object) $values;
|
||||
|
||||
if ($estimated !== null) {
|
||||
$estimatedTotal += $estimated;
|
||||
}
|
||||
|
||||
if ($actual !== null) {
|
||||
$actualTotal += $actual;
|
||||
$hasActual = true;
|
||||
}
|
||||
|
||||
$surchargeTotal += $surcharge;
|
||||
}
|
||||
|
||||
$detail['items'] = $items;
|
||||
$detail['totals'] = [
|
||||
'estimated' => round($estimatedTotal, 2),
|
||||
'actual' => $hasActual ? round($actualTotal, 2) : null,
|
||||
'surcharge' => round($surchargeTotal, 2),
|
||||
'diff' => $hasActual ? round($estimatedTotal - $actualTotal, 2) : null,
|
||||
];
|
||||
|
||||
return $detail;
|
||||
}
|
||||
|
||||
public function exportReturnPickupManagement(Request $request): StreamedResponse
|
||||
{
|
||||
$filters = $this->returnPickupFilters($request);
|
||||
$repository = app(\Modules\EcommerceFinance\Repositories\ReturnPickupManagementMirrorRepository::class);
|
||||
$calculator = app(\App\Support\ReturnPickupFreightCalculator::class);
|
||||
$limit = 50000;
|
||||
|
||||
return response()->streamDownload(function () use ($filters, $repository, $calculator, $limit): void {
|
||||
$writer = new \App\Support\StreamingXlsxWriter('退货取件明细');
|
||||
|
||||
$writer->appendRow([
|
||||
'订单号', '快递公司', '订单状态', '紧急程度', '店铺名称', '交易单号', '出发省', '到达省',
|
||||
'应退货号', '实退货号', '条形码', '品名', '平台商品id', '平台skuid',
|
||||
'应退数量', '实退数量', '待核销数量', '总件数', '长', '宽', '高',
|
||||
'毛重(kg)', '体积重(kg)', '重量取大(kg)', '计费重量(kg)', '面单(元)', '费率(元/kg)',
|
||||
'预估运费(元)', '实际运费(元)', '加收(元)', '差异(元)',
|
||||
'明细退货进度', '发货仓', '收货人', '收货电话', '收货地址', '实退单号', '实退快递单号',
|
||||
'预计取件时间', '退货进度', '付款方式', '取件类型', '发件人姓名', '备注',
|
||||
'问题类型', '问题描述', '问题跟进状态', '问题跟进结果', '问题跟进说明',
|
||||
'快递下单时间', '快递取件时间', '创建人', '取消人', '创建时间', '更新时间',
|
||||
]);
|
||||
|
||||
$warehouseDirectory = $repository->warehouseDirectory();
|
||||
$number = static fn (?float $value): ?float => $value === null ? null : round($value, 2);
|
||||
$quantity = static fn (mixed $value): ?int => $value === null ? null : (int) $value;
|
||||
$exported = 0;
|
||||
|
||||
foreach ($repository->exportRows($filters, $limit) as $row) {
|
||||
$length = (float) ($row->length ?? 0);
|
||||
$width = (float) ($row->width ?? 0);
|
||||
$height = (float) ($row->height ?? 0);
|
||||
$volumeWeight = ($length > 0 && $width > 0 && $height > 0)
|
||||
? round($length * $width * $height / 6000, 2)
|
||||
: null;
|
||||
$result = $calculator->calculate(
|
||||
$row->express_company ?? null,
|
||||
$row->province ?? null,
|
||||
$row->receiverAddress ?? null,
|
||||
(float) ($row->grossWeight ?? 0),
|
||||
$length,
|
||||
$width,
|
||||
$height,
|
||||
);
|
||||
$estimated = $row->estimatedFreight !== null ? round((float) $row->estimatedFreight, 2) : null;
|
||||
$actual = $result['total'];
|
||||
$warehouseCode = trim((string) ($row->warehouse ?? ''));
|
||||
$followStatus = $this->xlsxText($row->expressFollowStatus ?? '');
|
||||
$followResult = $this->xlsxText($row->expressFollowResult ?? '');
|
||||
$returnProgress = $followStatus === null
|
||||
? $followResult
|
||||
: ($followResult === null ? $followStatus : $followStatus . '(' . $followResult . ')');
|
||||
|
||||
$writer->appendRow([
|
||||
$this->xlsxText($row->orderId ?? ''),
|
||||
$this->xlsxText($row->express_company ?? ''),
|
||||
$this->xlsxText(\App\Support\ReturnPickupOrderStatus::label($row->orderStatus ?? null)),
|
||||
$this->xlsxText($row->urgencyLevel ?? ''),
|
||||
$this->xlsxText($row->shopName ?? ''),
|
||||
$this->xlsxText($row->transactionNo ?? ''),
|
||||
$this->xlsxText($row->province ?? ''),
|
||||
$this->xlsxText($result['covered']
|
||||
? \App\Support\ReverseLogisticsQuoteSnapshot::provinceKey(
|
||||
\App\Support\ReturnPickupFreightCalculator::destinationProvince((string) ($row->receiverAddress ?? ''))
|
||||
)
|
||||
: ''),
|
||||
$this->xlsxText($row->returnProcode ?? ''),
|
||||
$this->xlsxText($row->actualReturnProcode ?? ''),
|
||||
$this->xlsxText($row->barcode ?? ''),
|
||||
$this->xlsxText($row->productName ?? ''),
|
||||
$this->xlsxText($row->platformProductId ?? ''),
|
||||
$this->xlsxText($row->platformSkuId ?? ''),
|
||||
$quantity($row->returnQuantity ?? null),
|
||||
$quantity($row->actualReturnQuantity ?? null),
|
||||
$quantity($row->unverifiedQuantity ?? null),
|
||||
$quantity($row->totalReturnQuantity ?? null),
|
||||
$number($length > 0 ? $length : null),
|
||||
$number($width > 0 ? $width : null),
|
||||
$number($height > 0 ? $height : null),
|
||||
$number($row->grossWeight !== null ? (float) $row->grossWeight : null),
|
||||
$number($volumeWeight),
|
||||
$number($result['settlement']),
|
||||
$number($result['billable']),
|
||||
$number($result['base_amount']),
|
||||
$number($result['increment_amount']),
|
||||
$number($estimated),
|
||||
$number($actual),
|
||||
$number(($result['surcharge'] ?? 0) > 0 ? (float) $result['surcharge'] : null),
|
||||
$number($estimated !== null && $actual !== null ? round($estimated - $actual, 2) : null),
|
||||
$this->xlsxText($row->returnProgress ?? ''),
|
||||
$this->xlsxText($warehouseCode === '' ? '' : ($warehouseDirectory[$warehouseCode] ?? $warehouseCode)),
|
||||
$this->xlsxText($row->receiverName ?? ''),
|
||||
$this->xlsxText($row->receiverPhone ?? ''),
|
||||
$this->xlsxText($row->receiverAddress ?? ''),
|
||||
$this->xlsxText($row->actualReturnOrderNo ?? ''),
|
||||
$this->xlsxText($row->returnExpressNo ?? ''),
|
||||
$this->xlsxDateTime($row->expectedPickupTime ?? null),
|
||||
$this->xlsxText($returnProgress ?? ''),
|
||||
$this->xlsxText($row->paymentMethod ?? ''),
|
||||
$this->xlsxText($row->pickupType ?? ''),
|
||||
$this->xlsxText($row->senderName ?? ''),
|
||||
$this->xlsxText($row->note ?? ''),
|
||||
$this->xlsxText($row->issueType ?? ''),
|
||||
$this->xlsxText($row->issueDescription ?? ''),
|
||||
$this->xlsxText($row->expressFollowStatus ?? ''),
|
||||
$this->xlsxText($row->expressFollowResult ?? ''),
|
||||
$this->xlsxText($row->expressFollowDescription ?? ''),
|
||||
$this->xlsxDateTime($row->expressOrderTime ?? null),
|
||||
$this->xlsxDateTime($row->expressPickupTime ?? null),
|
||||
$this->xlsxText($row->creator ?? ''),
|
||||
$this->xlsxText($row->canceler ?? ''),
|
||||
$this->xlsxDateTime($row->createTime ?? null),
|
||||
$this->xlsxDateTime($row->updateTime ?? null),
|
||||
]);
|
||||
$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',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function contentForProductPrices(): array
|
||||
{
|
||||
$filters = [
|
||||
|
||||
245
app/Support/ReturnPickupFreightCalculator.php
Normal file
245
app/Support/ReturnPickupFreightCalculator.php
Normal file
@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 用逆向物流报价计算退货取件明细的实际运费。
|
||||
*
|
||||
* 报价来源与「快递应付 → 报价管理 → 逆向物流报价」完全同一份快照:
|
||||
* 速寄递 → 申通退货、德邦 → 常州德邦逆向、中通快运 → 中通快运逆向。
|
||||
*/
|
||||
class ReturnPickupFreightCalculator
|
||||
{
|
||||
private const CARRIER_MAP = [
|
||||
'速寄递' => '申通',
|
||||
'申通' => '申通',
|
||||
'德邦' => '德邦',
|
||||
'中通快运' => '中通快运',
|
||||
];
|
||||
|
||||
private const PROVINCES = [
|
||||
'北京', '天津', '上海', '重庆', '河北', '山西', '辽宁', '吉林', '黑龙江', '江苏', '浙江', '安徽',
|
||||
'福建', '江西', '山东', '河南', '湖北', '湖南', '广东', '海南', '四川', '贵州', '云南', '陕西',
|
||||
'甘肃', '青海', '内蒙古', '广西', '西藏', '宁夏', '新疆',
|
||||
];
|
||||
|
||||
/** @var array<string,array<string,array<string,mixed>>>|null */
|
||||
private ?array $rateIndex = null;
|
||||
|
||||
/** @var array<string,array<string,mixed>>|null */
|
||||
private ?array $rules = null;
|
||||
|
||||
public function __construct(private readonly ReverseLogisticsQuoteSnapshot $snapshot)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* carrier:?string,covered:bool,reason:?string,billable:?float,total:?float,surcharge:float,
|
||||
* total_with_surcharge:?float,formula:?string,note:?string,surcharge_note:?string,source:?string,
|
||||
* settlement:?float,gross_weight:?float,volume_weight:?float,base_amount:?float,increment_amount:?float,
|
||||
* process:?string
|
||||
* }
|
||||
*/
|
||||
public function calculate(
|
||||
?string $expressCompany,
|
||||
?string $originProvince,
|
||||
?string $receiverAddress,
|
||||
float $grossWeight,
|
||||
?float $length = null,
|
||||
?float $width = null,
|
||||
?float $height = null,
|
||||
): array {
|
||||
$carrier = self::CARRIER_MAP[trim((string) $expressCompany)] ?? null;
|
||||
|
||||
if ($carrier === null) {
|
||||
return $this->missing('该快递公司不在逆向物流报价范围内');
|
||||
}
|
||||
|
||||
$length = max((float) $length, 0);
|
||||
$width = max((float) $width, 0);
|
||||
$height = max((float) $height, 0);
|
||||
$volumeWeight = ($length > 0 && $width > 0 && $height > 0)
|
||||
? round($length * $width * $height / 6000, 4)
|
||||
: 0.0;
|
||||
$settlement = round(max($grossWeight, $volumeWeight), 4);
|
||||
|
||||
if ($settlement <= 0) {
|
||||
return $this->missing('缺少重量与尺寸,无法计算实际运费');
|
||||
}
|
||||
|
||||
$origin = ReverseLogisticsQuoteSnapshot::provinceKey((string) $originProvince);
|
||||
$destination = ReverseLogisticsQuoteSnapshot::provinceKey(self::destinationProvince((string) $receiverAddress));
|
||||
$this->load();
|
||||
$bucket = $this->rateIndex[$carrier][$origin . '|' . $destination] ?? [];
|
||||
|
||||
if ($bucket === []) {
|
||||
return $this->missing(sprintf('逆向物流报价未覆盖 %s→%s', $origin !== '' ? $origin : '(未知出发省)', $destination !== '' ? $destination : '(未知到达省)'));
|
||||
}
|
||||
|
||||
$rule = $this->rules[$carrier] ?? [];
|
||||
$mode = (string) ($rule['pricing_mode'] ?? '');
|
||||
$maxSide = max($length, $width, $height);
|
||||
$sideSum = $length + $width + $height;
|
||||
$billable = 0.0;
|
||||
$total = 0.0;
|
||||
$surcharge = 0.0;
|
||||
$surchargeNote = null;
|
||||
$formula = '';
|
||||
$note = '';
|
||||
$source = '';
|
||||
$baseAmount = 0.0;
|
||||
$incrementAmount = 0.0;
|
||||
$process = [];
|
||||
$number = static function (float $value): string {
|
||||
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||||
};
|
||||
|
||||
if ($volumeWeight > 0) {
|
||||
$process[] = '重量取大:max(毛重 ' . $number($grossWeight) . 'kg,体积重 ' . $number($volumeWeight) . 'kg) = ' . $number($settlement) . 'kg';
|
||||
} else {
|
||||
$process[] = '未填长宽高,按毛重 ' . $number($grossWeight) . 'kg 作为结算重量';
|
||||
}
|
||||
|
||||
if ($mode === 'first_weight_increment') {
|
||||
$rate = $bucket['base'] ?? null;
|
||||
if ($rate === null || ! ($rate['available'] ?? false)) {
|
||||
return $this->missing('该线路无可用报价');
|
||||
}
|
||||
|
||||
$baseAmount = (float) $rate['base_amount'];
|
||||
$incrementAmount = (float) $rate['increment_amount'];
|
||||
$billable = (float) max(1, (int) ceil($settlement));
|
||||
$total = $baseAmount + $incrementAmount * ($billable - 1);
|
||||
$formula = '面单 ' . $rate['base_amount'] . ' + 续重 ' . $rate['increment_amount'] . ' ×(' . $billable . ' − 1)';
|
||||
$note = '续重按结算重量取整(原表备注 3)';
|
||||
$source = (string) ($rate['source'] ?? '');
|
||||
$process[] = '续重向上取整:' . $number($settlement) . 'kg → ' . $number($billable) . 'kg';
|
||||
$process[] = '面单 ' . $number($baseAmount) . ' 元 + 续重 ' . $number($incrementAmount) . ' 元/kg ×(' . $number($billable) . ' − 1)= ' . $number($total) . ' 元';
|
||||
} elseif (($rule['weight_bands'] ?? []) !== []) {
|
||||
$billable = (float) max(15, (int) ceil($settlement));
|
||||
$band = $billable > 30 ? '31-60kg' : '0-30kg';
|
||||
$rate = $bucket[$band] ?? ($bucket['base'] ?? null);
|
||||
|
||||
// 重量段版式以“重量段费率”判断可用性:面单费存在但费率为“/”时视为无报价。
|
||||
if ($rate === null || ($rate['increment_amount'] ?? null) === null) {
|
||||
return $this->missing('该线路重量段无报价');
|
||||
}
|
||||
|
||||
$baseAmount = (float) $rate['base_amount'];
|
||||
$incrementAmount = (float) $rate['increment_amount'];
|
||||
$total = $baseAmount + $incrementAmount * $billable;
|
||||
$formula = '面单 ' . $rate['base_amount'] . ' + 费率 ' . $rate['increment_amount'] . ' × ' . $billable . '(' . $band . ')';
|
||||
$note = '不足 15kg 按 15kg 计费并向上取整(原表备注 2)';
|
||||
$process[] = '不足 15kg 按 15kg 计并向上取整:' . $number($settlement) . 'kg → ' . $number($billable) . 'kg';
|
||||
$process[] = '面单 ' . $number($baseAmount) . ' 元 + 费率 ' . $number($incrementAmount) . ' 元/kg × ' . $number($billable) . 'kg(' . $band . ')= ' . $number($total) . ' 元';
|
||||
|
||||
if ($billable > 60 || $maxSide > 200) {
|
||||
$surcharge = max(20, $billable);
|
||||
$surchargeNote = '超长超重附加费 ' . round($surcharge, 2) . ' 元(最长边>2m 或 计费重量>60kg,1 元/kg 最低 20 元)';
|
||||
}
|
||||
|
||||
$source = (string) ($rate['source'] ?? '');
|
||||
} else {
|
||||
$rate = $bucket['base'] ?? null;
|
||||
if ($rate === null || ! ($rate['available'] ?? false)) {
|
||||
return $this->missing('该线路无可用报价');
|
||||
}
|
||||
|
||||
$baseAmount = (float) $rate['base_amount'];
|
||||
$incrementAmount = (float) $rate['increment_amount'];
|
||||
$billable = round($settlement, 2);
|
||||
$total = $baseAmount + $incrementAmount * $billable;
|
||||
$formula = '面单 ' . $rate['base_amount'] . ' + 费率 ' . $rate['increment_amount'] . ' × ' . $billable;
|
||||
$note = '按原表「面单+重量×费率」';
|
||||
$process[] = '按结算重量计费(不取整):' . $number($billable) . 'kg';
|
||||
$process[] = '面单 ' . $number($baseAmount) . ' 元 + 费率 ' . $number($incrementAmount) . ' 元/kg × ' . $number($billable) . 'kg = ' . $number($total) . ' 元';
|
||||
|
||||
if ($maxSide > 160 || $sideSum > 250) {
|
||||
$surcharge = 7.0;
|
||||
$surchargeNote = '单边>1.6m 或 三边>2.5m,加收 7 元/单(原表备注 1)';
|
||||
}
|
||||
|
||||
$source = (string) ($rate['source'] ?? '');
|
||||
}
|
||||
|
||||
if ($surcharge > 0) {
|
||||
$process[] = '加收单列:' . $number($surcharge) . ' 元(' . $surchargeNote . ')';
|
||||
}
|
||||
|
||||
return [
|
||||
'carrier' => $carrier,
|
||||
'covered' => true,
|
||||
'reason' => null,
|
||||
'billable' => $billable,
|
||||
'total' => round($total, 2),
|
||||
'surcharge' => round($surcharge, 2),
|
||||
'total_with_surcharge' => round($total + $surcharge, 2),
|
||||
'formula' => $formula,
|
||||
'note' => $note,
|
||||
'surcharge_note' => $surchargeNote,
|
||||
'source' => $source,
|
||||
'settlement' => $settlement,
|
||||
'gross_weight' => round($grossWeight, 4),
|
||||
'volume_weight' => round($volumeWeight, 4),
|
||||
'base_amount' => $baseAmount,
|
||||
'increment_amount' => $incrementAmount,
|
||||
'process' => implode(';', $process),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{carrier:?string,covered:bool,reason:string,billable:?float,total:?float,surcharge:float,total_with_surcharge:?float,formula:?string,note:?string,surcharge_note:?string,source:?string,settlement:?float,gross_weight:?float,volume_weight:?float,base_amount:?float,increment_amount:?float,process:?string} */
|
||||
private function missing(string $reason): array
|
||||
{
|
||||
return [
|
||||
'carrier' => null,
|
||||
'covered' => false,
|
||||
'reason' => $reason,
|
||||
'billable' => null,
|
||||
'total' => null,
|
||||
'surcharge' => 0.0,
|
||||
'total_with_surcharge' => null,
|
||||
'formula' => null,
|
||||
'note' => null,
|
||||
'surcharge_note' => null,
|
||||
'source' => null,
|
||||
'settlement' => null,
|
||||
'gross_weight' => null,
|
||||
'volume_weight' => null,
|
||||
'base_amount' => null,
|
||||
'increment_amount' => null,
|
||||
'process' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function load(): void
|
||||
{
|
||||
if ($this->rateIndex !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->rateIndex = [];
|
||||
$this->rules = [];
|
||||
$payload = $this->snapshot->load();
|
||||
$this->rules = collect($payload['rate_groups'] ?? [])->keyBy('carrier')->all();
|
||||
|
||||
foreach ($payload['rates'] ?? [] as $rate) {
|
||||
$key = ($rate['origin_key'] ?? '') . '|' . ($rate['destination_key'] ?? '');
|
||||
$this->rateIndex[$rate['carrier']][$key][$rate['weight_band'] ?: 'base'] = $rate;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从收货地址里取出发往省份(用于匹配报价的到达省)。 */
|
||||
public static function destinationProvince(string $address): string
|
||||
{
|
||||
$address = trim($address);
|
||||
|
||||
foreach (self::PROVINCES as $province) {
|
||||
if (str_starts_with($address, $province)) {
|
||||
return $province;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
28
app/Support/ReturnPickupOrderStatus.php
Normal file
28
app/Support/ReturnPickupOrderStatus.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/** 工作平台退货取件的订单状态码与中文标签(与工作平台页面一致)。 */
|
||||
final class ReturnPickupOrderStatus
|
||||
{
|
||||
public const LABELS = [
|
||||
'WaitExpOrder' => '待快递下单',
|
||||
'WaitExpAccept' => '待快递接单',
|
||||
'ExpAccepted' => '快递已接单',
|
||||
'ExpCanceled' => '快递已取消订单',
|
||||
'CustomerServiceConfirmed' => '客服已确认',
|
||||
'WaitServerComfrim' => '待客服确认',
|
||||
'TicketTimeoutClose' => '工单超时关闭',
|
||||
];
|
||||
|
||||
public static function label(?string $code): string
|
||||
{
|
||||
$code = trim((string) $code);
|
||||
|
||||
if ($code === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return self::LABELS[$code] ?? $code;
|
||||
}
|
||||
}
|
||||
712
app/Support/ReverseLogisticsQuoteSnapshot.php
Normal file
712
app/Support/ReverseLogisticsQuoteSnapshot.php
Normal file
@ -0,0 +1,712 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use JsonException;
|
||||
|
||||
/**
|
||||
* 逆向物流报价快照:从本机私有 JSON(由逆向物流报价.xlsx 生成)读取报价版本,
|
||||
* 并把三种原表版式(矩阵、单一到达省、重量段)拆分为可用于运费计算的运价明细。
|
||||
*
|
||||
* 与 QuoteManagementSnapshot 同构:只读展示,不重新计算金额。
|
||||
*/
|
||||
final class ReverseLogisticsQuoteSnapshot
|
||||
{
|
||||
public const RELATIVE_PATH = 'app/private/quote-management/reverse-logistics-quotes.json';
|
||||
|
||||
/** 出发省份 × 到达省份矩阵,单元格为面单价格 + 续重价格(如申通退货)。 */
|
||||
private const LAYOUT_MATRIX = 'matrix';
|
||||
|
||||
/** 单一到达省份,每行一个出发省份(如常州德邦逆向)。 */
|
||||
private const LAYOUT_SINGLE_DESTINATION = 'single-destination';
|
||||
|
||||
/** 单一到达仓,每行一个出发省份,按重量段给出费率(如中通快运逆向)。 */
|
||||
private const LAYOUT_WEIGHT_BAND = 'weight-band';
|
||||
|
||||
public function __construct(private readonly ?string $path = null)
|
||||
{
|
||||
}
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
$path = $this->path ?? storage_path(self::RELATIVE_PATH);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return $this->emptySnapshot('missing', '尚未导入本机逆向物流报价文件。');
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return $this->emptySnapshot('invalid', '本机逆向物流报价快照无法读取,请重新导入源文件。');
|
||||
}
|
||||
|
||||
if (! is_array($payload) || ! is_array($payload['sheets'] ?? null)) {
|
||||
return $this->emptySnapshot('invalid', '本机逆向物流报价快照结构不完整,请重新导入源文件。');
|
||||
}
|
||||
|
||||
$quotes = [];
|
||||
|
||||
foreach (array_values($payload['sheets']) as $position => $sheet) {
|
||||
if (! is_array($sheet) || trim((string) ($sheet['name'] ?? '')) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$quotes[] = $this->normalizeSheet($sheet, $position);
|
||||
}
|
||||
|
||||
$rates = [];
|
||||
$rateGroups = [];
|
||||
$unparsedSheets = [];
|
||||
$originKeys = [];
|
||||
$destinationKeys = [];
|
||||
|
||||
foreach ($quotes as $quote) {
|
||||
$extracted = $this->extractRates($quote);
|
||||
|
||||
if ($extracted === null) {
|
||||
$unparsedSheets[] = $quote['sheet_name'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$rateGroups[] = $extracted['rule'];
|
||||
|
||||
foreach ($extracted['rates'] as $rate) {
|
||||
$rates[] = $rate;
|
||||
|
||||
if (! in_array($rate['origin_key'], $originKeys, true)) {
|
||||
$originKeys[] = $rate['origin_key'];
|
||||
}
|
||||
|
||||
if (! in_array($rate['destination_key'], $destinationKeys, true)) {
|
||||
$destinationKeys[] = $rate['destination_key'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'state' => 'ready',
|
||||
'message' => null,
|
||||
'source_file' => basename(str_replace('\\', '/', (string) ($payload['sourcePath'] ?? '逆向物流报价.xlsx'))),
|
||||
'generated_at' => (string) ($payload['generatedAt'] ?? ''),
|
||||
'quotes' => $quotes,
|
||||
'rates' => $rates,
|
||||
'rate_groups' => $rateGroups,
|
||||
'unparsed_sheets' => $unparsedSheets,
|
||||
'provinces' => [
|
||||
'origins' => $originKeys,
|
||||
'destinations' => $destinationKeys,
|
||||
],
|
||||
'summary' => [
|
||||
'quote_count' => count($quotes),
|
||||
'carrier_count' => count(array_unique(array_column($quotes, 'carrier'))),
|
||||
'dated_count' => count(array_filter($quotes, fn (array $quote) => $quote['effective_date'] !== null)),
|
||||
'formula_count' => array_sum(array_column($quotes, 'formula_count')),
|
||||
],
|
||||
'rate_summary' => [
|
||||
'rate_count' => count($rates),
|
||||
'carrier_count' => count($rateGroups),
|
||||
'origin_count' => count(array_unique(array_column($rates, 'origin'))),
|
||||
'destination_count' => count(array_unique(array_column($rates, 'destination'))),
|
||||
'weight_band_count' => count(array_unique(array_filter(
|
||||
array_column($rates, 'weight_band'),
|
||||
static fn ($band) => $band !== null && $band !== ''
|
||||
))),
|
||||
'available_count' => count(array_filter($rates, static fn (array $rate) => $rate['available'])),
|
||||
'missing_count' => count(array_filter($rates, static fn (array $rate) => ! $rate['available'])),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一个报价工作表拆分为运价明细;无法识别版式时返回 null。
|
||||
*
|
||||
* @return array{rule:array<string,mixed>,rates:list<array<string,mixed>>}|null
|
||||
*/
|
||||
private function extractRates(array $quote): ?array
|
||||
{
|
||||
$values = $quote['values'];
|
||||
$layout = $this->detectLayout($values);
|
||||
|
||||
if ($layout === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$notes = $this->noteLines($values, $layout);
|
||||
$context = [
|
||||
'carrier' => $quote['carrier'],
|
||||
'sheet_name' => $quote['sheet_name'],
|
||||
'quote_type' => $quote['quote_type'],
|
||||
'effective_date' => $quote['effective_date'],
|
||||
'layout' => $layout,
|
||||
'notes' => $notes,
|
||||
];
|
||||
|
||||
return match ($layout) {
|
||||
self::LAYOUT_MATRIX => $this->extractMatrixRates($values, $context),
|
||||
self::LAYOUT_SINGLE_DESTINATION => $this->extractSingleDestinationRates($values, $context),
|
||||
self::LAYOUT_WEIGHT_BAND => $this->extractWeightBandRates($values, $context),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/** @param list<array<int,mixed>> $values */
|
||||
private function detectLayout(array $values): ?string
|
||||
{
|
||||
$header = $values[1] ?? [];
|
||||
|
||||
foreach ($header as $index => $label) {
|
||||
if (! is_string($label)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($index >= 3 && preg_match('/^\d+\s*-\s*\d+\s*kg$/u', trim($label)) === 1) {
|
||||
return self::LAYOUT_WEIGHT_BAND;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($header[0], $header[1])
|
||||
&& trim((string) $header[0]) === '出发省份'
|
||||
&& trim((string) $header[1]) === '到达省份') {
|
||||
return self::LAYOUT_MATRIX;
|
||||
}
|
||||
|
||||
if (isset($header[1]) && is_string($header[1]) && str_starts_with(trim($header[1]), '到达省份')) {
|
||||
return self::LAYOUT_SINGLE_DESTINATION;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 矩阵版式:列方向按“到达省份”成组,每组两列(面单价格、续重价格),行方向为出发省份。
|
||||
*
|
||||
* @param list<array<int,mixed>> $values
|
||||
* @param array<string,mixed> $context
|
||||
* @return array{rule:array<string,mixed>,rates:list<array<string,mixed>>}
|
||||
*/
|
||||
private function extractMatrixRates(array $values, array $context): array
|
||||
{
|
||||
$destinations = [];
|
||||
$subHeader = $values[2] ?? [];
|
||||
|
||||
foreach ($subHeader as $index => $label) {
|
||||
if (! is_string($label) || trim($label) !== '面单价格') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim((string) ($values[1][$index] ?? ''));
|
||||
|
||||
if ($name === '' || $name === '到达省份') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$destinations[$index] = ['name' => $name, 'increment' => $index + 1];
|
||||
}
|
||||
|
||||
$rates = [];
|
||||
$position = 0;
|
||||
|
||||
foreach ($values as $rowIndex => $row) {
|
||||
if ($rowIndex < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$origin = trim((string) ($row[1] ?? ''));
|
||||
|
||||
if ($origin === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($destinations as $baseIndex => $destination) {
|
||||
$position++;
|
||||
$baseAmount = $this->amount($row[$baseIndex] ?? null);
|
||||
$incrementAmount = $this->amount($row[$destination['increment']] ?? null);
|
||||
|
||||
$rates[] = $this->rateRow($context, $position, [
|
||||
'origin' => $origin,
|
||||
'destination' => $destination['name'],
|
||||
'weight_band' => null,
|
||||
'base_amount' => $baseAmount,
|
||||
'increment_amount' => $incrementAmount,
|
||||
'source' => $this->cellRef($baseIndex, $rowIndex) . ':' . $this->cellRef($destination['increment'], $rowIndex),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'rule' => $this->rateRule($context, [
|
||||
'pricing_mode' => 'first_weight_increment',
|
||||
'pricing_label' => '首重 + 续重 × (结算重量 - 1)',
|
||||
'base_label' => '面单价格(首重 1kg)',
|
||||
'increment_label' => '续重价格',
|
||||
'route_label' => sprintf('行=出发省份,列=到达省份(%d 个到达省)', count($destinations)),
|
||||
'weight_bands' => [],
|
||||
'rates' => $rates,
|
||||
]),
|
||||
'rates' => $rates,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 单一到达省版式:A 列出发省份,随后两列为面单价格、续重价格。
|
||||
*
|
||||
* @param list<array<int,mixed>> $values
|
||||
* @param array<string,mixed> $context
|
||||
* @return array{rule:array<string,mixed>,rates:list<array<string,mixed>>}
|
||||
*/
|
||||
private function extractSingleDestinationRates(array $values, array $context): array
|
||||
{
|
||||
$destination = trim((string) ($values[1][1] ?? ''));
|
||||
$destination = trim((string) preg_replace('/^到达省份/u', '', $destination));
|
||||
|
||||
if ($destination === '') {
|
||||
$destination = '江苏';
|
||||
}
|
||||
|
||||
$rates = [];
|
||||
$position = 0;
|
||||
|
||||
foreach ($values as $rowIndex => $row) {
|
||||
if ($rowIndex < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$origin = trim((string) ($row[0] ?? ''));
|
||||
|
||||
if ($origin === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$baseAmount = $this->amount($row[1] ?? null);
|
||||
$incrementAmount = $this->amount($row[2] ?? null);
|
||||
|
||||
if ($baseAmount === null && $incrementAmount === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$position++;
|
||||
$rates[] = $this->rateRow($context, $position, [
|
||||
'origin' => $origin,
|
||||
'destination' => $destination,
|
||||
'weight_band' => null,
|
||||
'base_amount' => $baseAmount,
|
||||
'increment_amount' => $incrementAmount,
|
||||
'source' => $this->cellRef(1, $rowIndex) . ':' . $this->cellRef(2, $rowIndex),
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'rule' => $this->rateRule($context, [
|
||||
'pricing_mode' => 'base_plus_weight_rate',
|
||||
'pricing_label' => '面单 + 重量 × 费率',
|
||||
'base_label' => '面单价格',
|
||||
'increment_label' => '续重价格',
|
||||
'route_label' => '行=出发省份,到达固定为 ' . $destination,
|
||||
'weight_bands' => [],
|
||||
'rates' => $rates,
|
||||
]),
|
||||
'rates' => $rates,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 重量段版式:A 列出发省份、B 列面单费,其后每列一个重量段费率。
|
||||
*
|
||||
* @param list<array<int,mixed>> $values
|
||||
* @param array<string,mixed> $context
|
||||
* @return array{rule:array<string,mixed>,rates:list<array<string,mixed>>}
|
||||
*/
|
||||
private function extractWeightBandRates(array $values, array $context): array
|
||||
{
|
||||
$bands = [];
|
||||
$header = $values[1] ?? [];
|
||||
|
||||
foreach ($header as $index => $label) {
|
||||
if ($index < 3 || ! is_string($label)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = trim($label);
|
||||
|
||||
if ($label === '' || $label === '备注') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bands[$index] = $label;
|
||||
}
|
||||
|
||||
$destination = $this->weightBandDestination($context['notes']);
|
||||
|
||||
$rates = [];
|
||||
$position = 0;
|
||||
|
||||
foreach ($values as $rowIndex => $row) {
|
||||
if ($rowIndex < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$origin = trim((string) ($row[0] ?? ''));
|
||||
|
||||
if ($origin === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$baseAmount = $this->amount($row[1] ?? null);
|
||||
|
||||
foreach ($bands as $index => $band) {
|
||||
$position++;
|
||||
$rates[] = $this->rateRow($context, $position, [
|
||||
'origin' => $origin,
|
||||
'destination' => $destination,
|
||||
'weight_band' => $band,
|
||||
'base_amount' => $baseAmount,
|
||||
'increment_amount' => $this->amount($row[$index] ?? null),
|
||||
'source' => $this->cellRef($index, $rowIndex),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'rule' => $this->rateRule($context, [
|
||||
'pricing_mode' => 'base_plus_weight_rate',
|
||||
'pricing_label' => '面单 + 重量段费率 × 结算重量(不足 15kg 按 15kg 计费,向上取整)',
|
||||
'base_label' => '面单费',
|
||||
'increment_label' => '重量段费率',
|
||||
'route_label' => '行=出发省份,到达固定为 ' . $destination,
|
||||
'weight_bands' => array_values($bands),
|
||||
'rates' => $rates,
|
||||
]),
|
||||
'rates' => $rates,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
* @param array<string,mixed> $values
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function rateRow(array $context, int $position, array $values): array
|
||||
{
|
||||
return [
|
||||
'id' => sprintf('%s-%03d', $context['sheet_name'], $position),
|
||||
'carrier' => $context['carrier'],
|
||||
'sheet_name' => $context['sheet_name'],
|
||||
'quote_type' => $context['quote_type'],
|
||||
'origin' => $values['origin'],
|
||||
'destination' => $values['destination'],
|
||||
'origin_key' => self::provinceKey($values['origin']),
|
||||
'destination_key' => self::provinceKey($values['destination']),
|
||||
'weight_band' => $values['weight_band'],
|
||||
'base_amount' => $values['base_amount'],
|
||||
'increment_amount' => $values['increment_amount'],
|
||||
'available' => $values['base_amount'] !== null || $values['increment_amount'] !== null,
|
||||
'source' => $values['source'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
* @param array<string,mixed> $values
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function rateRule(array $context, array $values): array
|
||||
{
|
||||
$rates = $values['rates'];
|
||||
|
||||
return [
|
||||
'carrier' => $context['carrier'],
|
||||
'sheet_name' => $context['sheet_name'],
|
||||
'quote_type' => $context['quote_type'],
|
||||
'layout' => $context['layout'],
|
||||
'pricing_mode' => $values['pricing_mode'],
|
||||
'pricing_label' => $values['pricing_label'],
|
||||
'base_label' => $values['base_label'],
|
||||
'increment_label' => $values['increment_label'],
|
||||
'route_label' => $values['route_label'],
|
||||
'base_unit' => '元/票',
|
||||
'increment_unit' => '元/kg',
|
||||
'weight_bands' => $values['weight_bands'],
|
||||
'effective_date' => $context['effective_date'],
|
||||
'constraints' => $this->constraintLines($context['notes']),
|
||||
'notes' => $context['notes'],
|
||||
'rate_count' => count($rates),
|
||||
'available_count' => count(array_filter($rates, static fn (array $rate) => $rate['available'])),
|
||||
'origin_count' => count(array_unique(array_column($rates, 'origin'))),
|
||||
'destination_count' => count(array_unique(array_column($rates, 'destination'))),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 备注列:矩阵版式取 A 列尾部说明行,其余版式取最后一列说明。
|
||||
*
|
||||
* @param list<array<int,mixed>> $values
|
||||
* @return list<string>
|
||||
*/
|
||||
private function noteLines(array $values, string $layout): array
|
||||
{
|
||||
[$startRow, $column] = match ($layout) {
|
||||
self::LAYOUT_MATRIX => [3, 0],
|
||||
self::LAYOUT_SINGLE_DESTINATION => [3, 3],
|
||||
self::LAYOUT_WEIGHT_BAND => [2, 5],
|
||||
default => [null, null],
|
||||
};
|
||||
|
||||
if ($column === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
|
||||
foreach ($values as $rowIndex => $row) {
|
||||
if ($rowIndex < $startRow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cell = $row[$column] ?? null;
|
||||
|
||||
if (! is_string($cell) || trim($cell) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (preg_split('/\r\n|\r|\n/u', $cell) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
if ($line !== '') {
|
||||
$lines[] = $line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/** @param list<string> $notes @return list<string> */
|
||||
private function constraintLines(array $notes): array
|
||||
{
|
||||
$keywords = ['限重', '限长', '超标', '超长超重', '保丢', '计泡', '记泡', '抛比'];
|
||||
|
||||
return array_values(array_filter(
|
||||
$notes,
|
||||
static function (string $line) use ($keywords): bool {
|
||||
foreach ($keywords as $keyword) {
|
||||
if (str_contains($line, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/** @param list<string> $notes */
|
||||
private function weightBandDestination(array $notes): string
|
||||
{
|
||||
foreach ($notes as $line) {
|
||||
if (str_contains($line, '常州仓')) {
|
||||
return '常州仓(江苏)';
|
||||
}
|
||||
}
|
||||
|
||||
return '江苏';
|
||||
}
|
||||
|
||||
private function amount(mixed $value): ?float
|
||||
{
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return round((float) $value, 4);
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '' || in_array($value, ['/', '-', '—', '无'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_numeric($value) ? round((float) $value, 4) : null;
|
||||
}
|
||||
|
||||
private function cellRef(int $columnIndex, int $rowIndex): string
|
||||
{
|
||||
$label = '';
|
||||
$value = $columnIndex + 1;
|
||||
|
||||
while ($value > 0) {
|
||||
$value--;
|
||||
$label = chr(65 + ($value % 26)) . $label;
|
||||
$value = intdiv($value, 26);
|
||||
}
|
||||
|
||||
return $label . ($rowIndex + 1);
|
||||
}
|
||||
|
||||
/** 省份归一口径:去掉“省/市/自治区”后缀,仓名取括号内省份(如“常州仓(江苏)”)。 */
|
||||
public static function provinceKey(string $name): string
|
||||
{
|
||||
$text = trim($name);
|
||||
|
||||
if (preg_match('/^[^((]*[((]([^))]+)[))]$/u', $text, $matches) === 1) {
|
||||
$text = trim($matches[1]);
|
||||
}
|
||||
|
||||
$text = (string) preg_replace('/(维吾尔自治区|壮族自治区|回族自治区|自治区|特别行政区|省|市)$/u', '', $text);
|
||||
|
||||
return $text === '内蒙' ? '内蒙古' : $text;
|
||||
}
|
||||
private function normalizeSheet(array $sheet, int $position): array
|
||||
{
|
||||
$name = trim((string) $sheet['name']);
|
||||
$values = $this->normalizeMatrix($sheet['values'] ?? []);
|
||||
$formulas = $this->normalizeMatrix($sheet['formulas'] ?? []);
|
||||
$columnCount = max(array_map('count', $values ?: [[]]));
|
||||
|
||||
return [
|
||||
'id' => sprintf('RL-%03d', $position + 1),
|
||||
'sheet_index' => (int) ($sheet['index'] ?? $position),
|
||||
'sheet_name' => $name,
|
||||
'company_name' => $this->inferCompanyName($name),
|
||||
'carrier' => $this->inferCarrier($name),
|
||||
'quote_type' => $this->inferQuoteType($name),
|
||||
'effective_date' => $this->extractEffectiveDate($values),
|
||||
'source_range' => trim((string) ($sheet['address'] ?? '')),
|
||||
'row_count' => count($values),
|
||||
'column_count' => $columnCount,
|
||||
'formula_count' => $this->formulaCount($formulas),
|
||||
'status' => $this->extractEffectiveDate($values) === null ? '未标执行日期' : '已标执行日期',
|
||||
'signature_status' => 'Excel 未提供',
|
||||
'values' => $values,
|
||||
'formulas' => $formulas,
|
||||
];
|
||||
}
|
||||
|
||||
private function inferCompanyName(string $name): string
|
||||
{
|
||||
$companyName = trim((string) preg_replace('/(?:逆向|退货|报价表|报价)$/u', '', $name));
|
||||
|
||||
return $companyName !== '' ? $companyName : $name;
|
||||
}
|
||||
|
||||
private function inferCarrier(string $name): string
|
||||
{
|
||||
foreach ([
|
||||
'中通快运', '顺心捷达', '安能快运', '京广', 'EMS', '中通', '圆通', '德邦',
|
||||
'申通', '极兔', '安能', '邮政', '韵达', '顺丰',
|
||||
] as $carrier) {
|
||||
if (str_contains($name, $carrier)) {
|
||||
return $carrier;
|
||||
}
|
||||
}
|
||||
|
||||
return '其他快递';
|
||||
}
|
||||
|
||||
private function inferQuoteType(string $name): string
|
||||
{
|
||||
if (str_contains($name, '退货')) {
|
||||
return '退货报价';
|
||||
}
|
||||
|
||||
if (str_contains($name, '快运')) {
|
||||
return '逆向快运报价';
|
||||
}
|
||||
|
||||
return '逆向报价';
|
||||
}
|
||||
|
||||
private function extractEffectiveDate(array $values): ?string
|
||||
{
|
||||
foreach ($values as $row) {
|
||||
foreach ($row as $cell) {
|
||||
if (! is_string($cell)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/(20\d{2})[.\/\-年]\s*(\d{1,2})[.\/\-月]\s*(\d{1,2})日?/u', $cell, $match) === 1) {
|
||||
return sprintf('%04d-%02d-%02d', (int) $match[1], (int) $match[2], (int) $match[3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeMatrix(mixed $matrix): array
|
||||
{
|
||||
if (! is_array($matrix)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(function ($row) {
|
||||
if (! is_array($row)) {
|
||||
return [$this->normalizeCell($row)];
|
||||
}
|
||||
|
||||
return array_map(fn ($cell) => $this->normalizeCell($cell), array_values($row));
|
||||
}, array_values($matrix));
|
||||
}
|
||||
|
||||
private function normalizeCell(mixed $cell): string|int|float|bool|null
|
||||
{
|
||||
if ($cell === null || is_string($cell) || is_int($cell) || is_float($cell) || is_bool($cell)) {
|
||||
return $cell;
|
||||
}
|
||||
|
||||
return (string) $cell;
|
||||
}
|
||||
|
||||
private function formulaCount(array $formulas): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
foreach ($formulas as $row) {
|
||||
foreach ($row as $formula) {
|
||||
if (is_string($formula) && trim($formula) !== '') {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function emptySnapshot(string $state, string $message): array
|
||||
{
|
||||
return [
|
||||
'available' => false,
|
||||
'state' => $state,
|
||||
'message' => $message,
|
||||
'source_file' => '逆向物流报价.xlsx',
|
||||
'generated_at' => '',
|
||||
'quotes' => [],
|
||||
'rates' => [],
|
||||
'rate_groups' => [],
|
||||
'unparsed_sheets' => [],
|
||||
'provinces' => [
|
||||
'origins' => [],
|
||||
'destinations' => [],
|
||||
],
|
||||
'summary' => [
|
||||
'quote_count' => 0,
|
||||
'carrier_count' => 0,
|
||||
'dated_count' => 0,
|
||||
'formula_count' => 0,
|
||||
],
|
||||
'rate_summary' => [
|
||||
'rate_count' => 0,
|
||||
'carrier_count' => 0,
|
||||
'origin_count' => 0,
|
||||
'destination_count' => 0,
|
||||
'weight_band_count' => 0,
|
||||
'available_count' => 0,
|
||||
'missing_count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
224
app/Support/StreamingXlsxWriter.php
Normal file
224
app/Support/StreamingXlsxWriter.php
Normal file
@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use RuntimeException;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* 低内存流式写出 XLSX 工作簿。
|
||||
*
|
||||
* 退货取件管理等导出允许到数万行,PhpSpreadsheet 的常驻单元格写法在
|
||||
* 512M 内存限制下会失败。这里按最小 OOXML 结构把行顺序写入临时文件,
|
||||
* 最后打包成 zip 输出:内存占用与行数无关。
|
||||
*
|
||||
* 字符串一律写成文本单元格,长订单号/交易单号在 Excel 中不会变成
|
||||
* 科学计数法,也不需要 ' 前缀;int/float 写成数值单元格。
|
||||
*/
|
||||
class StreamingXlsxWriter
|
||||
{
|
||||
private const MAX_ROWS = 1048576;
|
||||
|
||||
/** @var resource */
|
||||
private $sheet;
|
||||
|
||||
private string $sheetPath = '';
|
||||
|
||||
private int $row = 0;
|
||||
|
||||
public function __construct(private readonly string $sheetName = 'Sheet1')
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'xlsx_sheet_');
|
||||
|
||||
if ($path === false) {
|
||||
throw new RuntimeException('无法创建 XLSX 临时文件。');
|
||||
}
|
||||
|
||||
$handle = fopen($path, 'wb');
|
||||
|
||||
if ($handle === false) {
|
||||
@unlink($path);
|
||||
|
||||
throw new RuntimeException('无法写入 XLSX 临时文件。');
|
||||
}
|
||||
|
||||
$this->sheetPath = $path;
|
||||
$this->sheet = $handle;
|
||||
|
||||
fwrite($handle, '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
. '<sheetData>');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string|int|float|null> $cells
|
||||
*/
|
||||
public function appendRow(array $cells): void
|
||||
{
|
||||
if (++$this->row > self::MAX_ROWS) {
|
||||
throw new RuntimeException('导出行数超过 XLSX 单表上限。');
|
||||
}
|
||||
|
||||
$xml = '<row r="' . $this->row . '">';
|
||||
|
||||
foreach (array_values($cells) as $index => $value) {
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reference = $this->columnName($index + 1) . $this->row;
|
||||
$xml .= is_int($value) || is_float($value)
|
||||
? '<c r="' . $reference . '"><v>' . $this->number($value) . '</v></c>'
|
||||
: '<c r="' . $reference . '" t="inlineStr"><is><t xml:space="preserve">'
|
||||
. $this->escape((string) $value) . '</t></is></c>';
|
||||
}
|
||||
|
||||
fwrite($this->sheet, $xml . '</row>');
|
||||
}
|
||||
|
||||
/**
|
||||
* 收尾并写入给定输出流(例如 php://output)。
|
||||
*
|
||||
* @param resource $output
|
||||
*/
|
||||
public function writeTo($output): void
|
||||
{
|
||||
fwrite($this->sheet, '</sheetData></worksheet>');
|
||||
fclose($this->sheet);
|
||||
|
||||
$zipPath = tempnam(sys_get_temp_dir(), 'xlsx_zip_');
|
||||
|
||||
if ($zipPath === false) {
|
||||
$this->cleanup();
|
||||
|
||||
throw new RuntimeException('无法创建 XLSX 打包文件。');
|
||||
}
|
||||
|
||||
try {
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
throw new RuntimeException('无法创建 XLSX 压缩包。');
|
||||
}
|
||||
|
||||
$zip->addFromString('[Content_Types].xml', $this->contentTypes());
|
||||
$zip->addFromString('_rels/.rels', $this->rootRelationships());
|
||||
$zip->addFromString('xl/workbook.xml', $this->workbook());
|
||||
$zip->addFromString('xl/_rels/workbook.xml.rels', $this->workbookRelationships());
|
||||
$zip->addFromString('xl/styles.xml', $this->styles());
|
||||
$zip->addFile($this->sheetPath, 'xl/worksheets/sheet1.xml');
|
||||
$zip->close();
|
||||
|
||||
$handle = fopen($zipPath, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('无法读取 XLSX 压缩包。');
|
||||
}
|
||||
|
||||
stream_copy_to_stream($handle, $output);
|
||||
fclose($handle);
|
||||
} finally {
|
||||
@unlink($zipPath);
|
||||
$this->cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanup(): void
|
||||
{
|
||||
if ($this->sheetPath !== '' && is_file($this->sheetPath)) {
|
||||
@unlink($this->sheetPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function number(int|float $value): string
|
||||
{
|
||||
if (is_int($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
if (! is_finite($value)) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
$text = rtrim(rtrim(sprintf('%.10F', $value), '0'), '.');
|
||||
|
||||
return ($text === '' || $text === '-') ? '0' : $text;
|
||||
}
|
||||
|
||||
private function escape(string $value): string
|
||||
{
|
||||
if (! mb_check_encoding($value, 'UTF-8')) {
|
||||
$value = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
|
||||
}
|
||||
|
||||
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/u', '', $value) ?? $value;
|
||||
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
}
|
||||
|
||||
private function columnName(int $index): string
|
||||
{
|
||||
$name = '';
|
||||
|
||||
while ($index > 0) {
|
||||
$index--;
|
||||
$name = chr(65 + ($index % 26)) . $name;
|
||||
$index = intdiv($index, 26);
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
private function contentTypes(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
. '<Default Extension="xml" ContentType="application/xml"/>'
|
||||
. '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||||
. '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||||
. '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
|
||||
. '</Types>';
|
||||
}
|
||||
|
||||
private function rootRelationships(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||||
. '</Relationships>';
|
||||
}
|
||||
|
||||
private function workbook(): string
|
||||
{
|
||||
$name = htmlspecialchars($this->sheetName, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
||||
. 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
. '<sheets><sheet name="' . $name . '" sheetId="1" r:id="rId1"/></sheets>'
|
||||
. '</workbook>';
|
||||
}
|
||||
|
||||
private function workbookRelationships(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
||||
. '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
|
||||
. '</Relationships>';
|
||||
}
|
||||
|
||||
private function styles(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
. '<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
|
||||
. '<fills count="2"><fill><patternFill patternType="none"/></fill>'
|
||||
. '<fill><patternFill patternType="gray125"/></fill></fills>'
|
||||
. '<borders count="1"><border/></borders>'
|
||||
. '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
||||
. '<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>'
|
||||
. '</styleSheet>';
|
||||
}
|
||||
}
|
||||
@ -115,6 +115,12 @@ return [
|
||||
'jiansun.dbo.pm_unOrderDetails' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '未定价明细'],
|
||||
'jiansun.dbo.finance_sampleDeduction' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '样品扣款'],
|
||||
'jiansun.dbo.fys_express_courier_settlement' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '代发快递'],
|
||||
'jiansun.dbo.ekf_Bookkeeping' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'],
|
||||
'jiansun.dbo.express_Original' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'],
|
||||
'jiansun.dbo.ekf_BookkeepingConfig' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'],
|
||||
'jiansun.dbo.ekf_rCheck_Piece' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'],
|
||||
'jiansun.dbo.ekf_rCheckPieceConfig' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'],
|
||||
'jiansun.dbo.ekf_ReturnPickupManagement' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'],
|
||||
'jiansun.dbo.jst_order_express_details' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账物流明细'],
|
||||
'jiansun.dbo.Jst_SendData_View_extension' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账商品与收货区域'],
|
||||
'jiansun.dbo.jst_salesOutboundInfo' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账出库运费'],
|
||||
|
||||
@ -533,12 +533,20 @@ $navigation = [
|
||||
['slug' => 'agreement-signing', 'title' => '协议签署'],
|
||||
['slug' => 'freight-reconciliation', 'title' => '运费对账'],
|
||||
['slug' => 'shipping-data', 'title' => '发货数据'],
|
||||
['slug' => 'return-pickup-management', 'title' => '退货取件管理'],
|
||||
['slug' => 'third-party-warehouse-storage', 'title' => '三方仓进销存'],
|
||||
['slug' => 'bookkeeping-details', 'title' => '记账明细'],
|
||||
['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' => '运费差'],
|
||||
|
||||
@ -147,6 +147,20 @@ return [
|
||||
'stale_after_minutes' => (int) env('COURIER_INFORMATION_MIRROR_STALE_MINUTES', 60),
|
||||
],
|
||||
|
||||
// 快递附加费结算要求所有必需镜像都完成首次同步;页面按最旧数据集
|
||||
// 的成功时间判断新鲜度,不把尚未核对的审核文本扩大为已通过数据。
|
||||
'courier_surcharge_settlement' => [
|
||||
'stale_after_minutes' => (int) env('COURIER_SURCHARGE_SETTLEMENT_MIRROR_STALE_MINUTES', 60),
|
||||
// 聚水潭现有审核文本使用“同意”和“同意记账”;“通过”保留给
|
||||
// 后续上游版本,不能把任意非空文本当作审核通过。
|
||||
'approved_values' => ['通过', '同意', '同意记账'],
|
||||
],
|
||||
|
||||
// 工作平台退货取件管理:客服取件单按天更新,允许比快递资料更长的同步间隔。
|
||||
'return_pickup' => [
|
||||
'stale_after_minutes' => (int) env('RETURN_PICKUP_MIRROR_STALE_MINUTES', 180),
|
||||
],
|
||||
|
||||
// 工作平台采购价格按 SKU 补充到普通商品资料;页面只读取共享镜像。
|
||||
'work_platform_prices' => [
|
||||
'dataset_key' => 'jiansun.dbo.pm_priceAdjustment',
|
||||
@ -184,6 +198,54 @@ return [
|
||||
|
||||
'datasets' => [
|
||||
// 经营分析副本库:组织资料、订单、退款和聚水潭基础数据。
|
||||
// 快递附加费结算:只同步上游审核字段已核对的“通过/同意/同意记账”明细;
|
||||
// 页面通过共享镜像读取,不在请求中直连聚水潭。
|
||||
'jiansun.dbo.ekf_Bookkeeping' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_Bookkeeping',
|
||||
'target' => 'js_ekf_bookkeeping_surcharge', 'keys' => ['Id'], 'watermark' => 'CreationTime',
|
||||
'columns' => [
|
||||
'Id', 'CreationTime', 'OrderNum', 'ExpressNum', 'ExpressName', 'ShopCode',
|
||||
'Province', 'City', 'District', 'Address', 'SupplierName', 'SkuName', 'SkuCode', 'Result', 'UserName',
|
||||
'Payfee', 'SecodaryCause', 'ReasonId', 'State', 'Delete', 'IsAgreeReason',
|
||||
'ProcessingState', 'BookkeepingName', 'PayTime', 'Svolume', 'Skuweight',
|
||||
'IfReturn', 'IfBuckle', 'FuZhuMain', 'FuZhuMinor', 'ShipExpressName',
|
||||
'expressCause', 'receivingDepartment', 'returnCost', 'shopproid',
|
||||
],
|
||||
'fixed_filters' => ['IsAgreeReason' => ['通过', '同意', '同意记账']],
|
||||
'indexes' => [['ExpressNum', 'Id'], ['OrderNum(100)', 'Id'], ['shopproid(100)', 'Id'], ['CreationTime', 'Id']],
|
||||
],
|
||||
'jiansun.dbo.express_Original' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'express_Original',
|
||||
'target' => 'js_express_original', 'keys' => ['Id'], 'watermark' => 'DeliveryTime',
|
||||
'columns' => [
|
||||
'Id', 'ExpressName', 'OrderNum', 'DeliveryTime', 'Weight', 'TotalFreight',
|
||||
'Lenght', 'Width', 'Height', 'Volume', 'SkuCode', 'JiaoYiNum', 'SkuName',
|
||||
'ShopName', 'SetMoney', 'SetWeight', 'ProCode', 'Reason', 'shopproid', 'paytime',
|
||||
],
|
||||
'indexes' => [['OrderNum', 'Id'], ['JiaoYiNum', 'Id'], ['shopproid(100)', 'Id']],
|
||||
],
|
||||
'jiansun.dbo.ekf_BookkeepingConfig' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_BookkeepingConfig',
|
||||
'target' => 'js_ekf_bookkeeping_config', 'keys' => ['Id'], 'watermark' => 'CreateTime',
|
||||
'columns' => ['Id', 'Name', 'ParentId', 'CreateTime'],
|
||||
'indexes' => [['ParentId', 'Id']],
|
||||
],
|
||||
'jiansun.dbo.ekf_rCheck_Piece' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheck_Piece',
|
||||
'target' => 'js_ekf_rcheck_piece', 'keys' => ['Id'], 'watermark' => 'UpdateTime',
|
||||
'columns' => [
|
||||
'Id', 'CheckType', 'ExpressNum', 'OrderNum', 'CheckSDType', 'CheckReason',
|
||||
'State', 'Accounting', 'ReturnState', 'CreationTime', 'UpdateTime', 'Delete',
|
||||
'OmsSDName', 'DHLName', 'SkuCode', 'shopproid',
|
||||
],
|
||||
'indexes' => [['ExpressNum', 'OrderNum(100)', 'Id'], ['shopproid(100)', 'Id']],
|
||||
],
|
||||
'jiansun.dbo.ekf_rCheckPieceConfig' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheckPieceConfig',
|
||||
'target' => 'js_ekf_rcheck_piece_config', 'keys' => ['Id'], 'watermark' => 'CreateTime',
|
||||
'columns' => ['Id', 'ParentId', 'Name', 'Code', 'Type', 'Delete', 'CreateTime'],
|
||||
'indexes' => [['ParentId', 'Id']],
|
||||
],
|
||||
'operations_replica.rpt.fys_companyInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_companyInfo', 'target' => 'ops_fys_company_info', 'keys' => ['Id'], 'watermark' => 'updateTIme'],
|
||||
'operations_replica.rpt.fys_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_shopInfo', 'target' => 'ops_fys_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
|
||||
'operations_replica.rpt.fys_TrademarkInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_TrademarkInfo', 'target' => 'ops_fys_trademark_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
|
||||
@ -515,7 +577,10 @@ return [
|
||||
'jiansun.dbo.jst_order_express_details' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_order_express_details',
|
||||
'target' => 'js_jst_order_express_details', 'keys' => ['id'], 'watermark' => 'updatedAt',
|
||||
'indexes' => [['send_time', 'id'], ['inner_order_id', 'id'], ['id', 'logistic_id']],
|
||||
'indexes' => [
|
||||
['send_time', 'id'], ['inner_order_id', 'id'], ['id', 'logistic_id'],
|
||||
['logistic_id', 'so_id(64)', 'id'], ['logistic_id', 'id'],
|
||||
],
|
||||
'columns' => [
|
||||
'id', 'updatedAt', 'inner_order_id', 'so_id', 'logistic_id', 'logistic_company_name',
|
||||
'logistic_status_name', 'platform_name', 'shop_name', 'warehouse_name', 'send_time',
|
||||
@ -628,5 +693,94 @@ return [
|
||||
'keys' => ['Id'],
|
||||
'watermark' => 'modifyTime',
|
||||
],
|
||||
|
||||
// 工作平台 2.0:客服系统 → 退款数据 → 退货取件管理(主表 + 明细 + 实退快递单号映射)。
|
||||
// 逆向物流运费核算与退件跟踪页面只读取共享镜像,不在请求中直连工作平台库。
|
||||
'jiansun.dbo.ekf_ReturnPickupManagement' => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'ekf_ReturnPickupManagement',
|
||||
'target' => 'js_ekf_return_pickup_management',
|
||||
'keys' => ['Id'],
|
||||
'watermark' => 'updateTime',
|
||||
'columns' => [
|
||||
'Id', 'orderId', 'urgencyLevel', 'expectedPickupTime', 'orderStatus', 'returnExpressNo',
|
||||
'paymentMethod', 'pickupType', 'shopName', 'transactionNo', 'senderName', 'senderPhone',
|
||||
'province', 'city', 'district', 'street', 'address', 'note', 'issueType', 'issueDescription',
|
||||
'expressFollowStatus', 'expressFollowResult', 'expressFollowDescription',
|
||||
'expressOrderTime', 'expressPickupTime', 'creator', 'createTime', 'updateTime', 'guid',
|
||||
'totalReturnQuantity', 'express_company', 'extend', 'canceler', 'sourceFrom',
|
||||
],
|
||||
'indexes' => [
|
||||
['orderId', 'Id'],
|
||||
['createTime', 'Id'],
|
||||
['updateTime', 'Id'],
|
||||
['express_company', 'createTime', 'Id'],
|
||||
['transactionNo', 'Id'],
|
||||
],
|
||||
],
|
||||
'jiansun.dbo.ekf_ReturnPickupManagement_Item' => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'ekf_ReturnPickupManagement_Item',
|
||||
'target' => 'js_ekf_return_pickup_management_item',
|
||||
'keys' => ['id'],
|
||||
'watermark' => 'updatedTime',
|
||||
'columns' => [
|
||||
'id', 'mainTableId', 'returnProcode', 'returnQuantity', 'barcode', 'platformProductId',
|
||||
'platformSkuId', 'warehouse', 'receiverName', 'receiverPhone', 'receiverAddress',
|
||||
'actualReturnOrderNo', 'actualReturnQuantity', 'unverifiedQuantity', 'returnProgress',
|
||||
'productName', 'length', 'width', 'height', 'volume', 'grossWeight', 'estimatedFreight',
|
||||
'updatedTime', 'actualReturnProcode',
|
||||
],
|
||||
'indexes' => [
|
||||
['mainTableId', 'id'],
|
||||
['barcode', 'id'],
|
||||
['returnProcode', 'id'],
|
||||
['updatedTime', 'id'],
|
||||
],
|
||||
],
|
||||
'jiansun.dbo.ekf_ReturnPickupManagement_TruthReExpressNumber' => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'ekf_ReturnPickupManagement_TruthReExpressNumber',
|
||||
'target' => 'js_ekf_return_pickup_truth_express',
|
||||
'keys' => ['Id'],
|
||||
'watermark' => 'createTime',
|
||||
'columns' => ['Id', 'mId', 'truthExpressNumber', 'createTime'],
|
||||
'indexes' => [
|
||||
['mId', 'Id'],
|
||||
['truthExpressNumber', 'Id'],
|
||||
['createTime', 'Id'],
|
||||
],
|
||||
],
|
||||
// 工作平台仓库资料:退货取件明细的「发货仓」同时存在工作平台短码
|
||||
// (wp_warehouse.code / wp_warehouseConfig.whCode,如 2852)和聚水潭
|
||||
// wms_co_id(如 15038426)。短码名称只从工作平台仓库表解析;这两张表
|
||||
// 都很小,页面与导出经共享镜像读取,不直连上游。
|
||||
'jiansun.dbo.wp_warehouse' => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'wp_warehouse',
|
||||
'target' => 'js_wp_warehouse',
|
||||
'keys' => ['id'],
|
||||
'watermark' => 'modified',
|
||||
'columns' => ['id', 'code', 'name', 'status', 'modified'],
|
||||
'indexes' => [
|
||||
['code', 'id'],
|
||||
],
|
||||
],
|
||||
// wp_warehouseConfig 没有更新时间列,按主键追加并依靠周期全量校准。
|
||||
'jiansun.dbo.wp_warehouseConfig' => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'wp_warehouseConfig',
|
||||
'target' => 'js_wp_warehouse_config',
|
||||
'keys' => ['Id'],
|
||||
'columns' => ['Id', 'moduleId', 'whCode', 'whName'],
|
||||
'indexes' => [
|
||||
['whCode', 'Id'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -16,6 +16,8 @@
|
||||
| 已接入校准 | 聚水潭“普通商品资料”完整 XLSX 导出通过 `product-catalog:import-authoritative-export` 流式导入;导出中不存在的经营分析副本历史行写入 `product_catalog_exclusions`,页面与导出统一排除,但不删除 `ops_jst_sku` 原始镜像行。 |
|
||||
| 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价。线上代发价格页面另读取 `Confirmed` / `Finished` 采购明细,以 `source_o_id` 关联 `ops_jst_orders.o_id`,展示线上单号、店铺、供应商、商品编码、数量、单价并标记“采购单管理”;页面请求不直连上游。 |
|
||||
| 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 |
|
||||
| 本次接入 | 工作平台“客服系统 → 退款数据 → 退货取件管理”读取 `jiansun/dbo.ekf_ReturnPickupManagement`(主表)、`dbo.ekf_ReturnPickupManagement_Item`(明细分录)与 `dbo.ekf_ReturnPickupManagement_TruthReExpressNumber`(实退快递单号映射),分别镜像为 `js_ekf_return_pickup_management`、`js_ekf_return_pickup_management_item`、`js_ekf_return_pickup_truth_express`;主表按 `Id` + `updateTime`、明细按 `id` + `updatedTime`、映射按 `Id` + `createTime` 增量,逆向物流运费核算与退件跟踪只读共享镜像,不在页面请求中直连工作平台。2026-10-02 已完成首次全量(296,707 / 378,705 / 173,346 行)。 明细 `warehouse` 是混合编码:4 位工作平台仓码(`dbo.wp_warehouse.code` / `dbo.wp_warehouseConfig.whCode`)镜像为 `js_wp_warehouse`(992 行,`id` + `modified`)与 `js_wp_warehouse_config`(132 行,无更新时间按主键全量校准),8 位聚水潭 `wms_co_id` 继续读 `ops_jst_warehouse_info`;页面与导出按名称展示,未命中时保留原始编码。 |
|
||||
| 本次接入 | 快递附加费结算读取 `jiansun.dbo.ekf_Bookkeeping` 中审核字段为固定白名单“通过/同意/同意记账”的明细,并关联 `jiansun.dbo.express_Original` 的物流公司、尺寸、商品、订单和结算字段,以及记账原因、查件配置、物流明细、发货扩展、出库重量和退件管理镜像;查件类型/查件原因只取 `ekf_rCheckPieceConfig` 的一级/二级选项,记账次要原因不作回退。页面固定限制九种附加费类型,按附加费报价维护匹配并计算。首次同步完成前只显示镜像状态,不把空结果当成无数据。当前上游严格值“通过”没有记录,“同意/同意记账”是否等价于审核通过仍需业务确认;实际样本只直接证明退回/拦截标记,其余类型依赖原因文本关键词,正式上线前需补充类型字段口径。 |
|
||||
| 本次接入 | 聚水潭聚工单协同工单登记 `work_order_main`、`work_order_sub`、`work_order_form_config`、`work_order_form_value` 四张表;工单 API 固定筛选类型“仓内未发”,首次同步完成前返回同步未完成状态。 |
|
||||
| 待首次同步 | 聚水潭聚工单“小额打款”读取 `jiansun.dbo.jst_workOrderInfo` 与已解析映射 `dbo.jst_small_payment_order_map`;财务经营报表的小额打款页面固定筛选工单类型 `82D94F5C54544EE3833CD8A3AB8B9F73`,页面只读共享镜像,首次同步完成前明确显示待同步状态。 |
|
||||
| 本次建立 | `config/upstream_mirrors.php` 中登记全部当前代码引用的原表;通用表级同步框架负责建表、稳定游标、断点续传、增量重叠和全量校准。 |
|
||||
@ -66,6 +68,7 @@
|
||||
| 营销账户 | `dbo.tmall_spread_bills`、`dbo.pdd_spread_bills`、`dbo.alibaba_bill`、`dbo.jd_spreadBills`、`dbo.jd_costBills` |
|
||||
| 供应商应付 | `dbo.finance_purchaseReturn`、`dbo.finance_invoiceDeduction`、`dbo.fys_supplierPaymentSummary`、`dbo.finance_advanceLoans`、`dbo.pm_unOrderDetails`、`dbo.finance_sampleDeduction`、`dbo.fys_express_courier_settlement` |
|
||||
| 快递运费对账明细 | `dbo.jst_order_express_details`、`dbo.Jst_SendData_View_extension`、`dbo.jst_salesOutboundInfo` |
|
||||
| 工作平台退货取件管理 | `dbo.ekf_ReturnPickupManagement`、`dbo.ekf_ReturnPickupManagement_Item`、`dbo.ekf_ReturnPickupManagement_TruthReExpressNumber`、发货仓名称 `dbo.wp_warehouse`、`dbo.wp_warehouseConfig` |
|
||||
| 聚水潭进销存 | `dbo.jst_inoutWater`、`dbo.jst_inoutWaterItem`、`dbo.jst_inventoryInfo`(全部仓库、`io_date >= 2026-08-01`;专用稳定游标与完整代次镜像。三方仓页面仍固定 `wms_co_id = 15045364`,商品价格管理的进销存报表读取全仓库) |
|
||||
| 安装明细 | `dbo.fys_Ticketing_install_view`、`dbo.fys_Ticketing_install_item` |
|
||||
| 板材 | `dbo.finance_boardDeduction`、`dbo.pm_plainPlateData`、`dbo.pm_boardPurchasePrice`、`dbo.pm_boardPriceChangeImport`、`dbo.pm_boardApplyDetail` |
|
||||
@ -74,6 +77,7 @@
|
||||
| 采购单价 | `dbo.jst_purchase`、`dbo.jst_purchaseItem`(受控字段联表镜像,不复制其他采购业务字段) |
|
||||
| 工作平台采购价格 | `dbo.pm_priceAdjustment`(`Id` 稳定键;按 `skuCode` 匹配 `jst_sku.sku_id`,页面取当前已生效的最新 `purchasePrice` / `effectTime`) |
|
||||
| 快递资料 | `dbo.wp_express`、`dbo.wp_express_expNet`、`dbo.wp_express_account`、`dbo.wp_express_extension` |
|
||||
| 快递附加费结算 | `dbo.ekf_Bookkeeping`、`dbo.express_Original`、`dbo.ekf_BookkeepingConfig`、`dbo.ekf_rCheck_Piece`、`dbo.ekf_rCheckPieceConfig`、既有物流镜像 `dbo.jst_order_express_details` / `dbo.Jst_SendData_View_extension` / `dbo.jst_salesOutboundInfo`、`dbo.ekf_ReturnPickupManagement`(审核字段固定为“通过/同意/同意记账”;查件原因只读二级选项;页面只读 `caiwu_business_mirror`) |
|
||||
| 聚水潭小额打款工单 | `dbo.jst_workOrderInfo`(稳定键 `Id`、增量字段 `updateTime`)与 `dbo.jst_small_payment_order_map`(稳定键 `woNo`、增量字段 `updated_at`) |
|
||||
| 拼多多货款账户账单 | `dbo.jst_originalBillInfo`(固定 `channel = PinduoduoImport`,镜像 `js_jst_pdd_receiving_bill_rows`) |
|
||||
| 得物完整账户账单 | 聚胜算平台原始账单(新)XLSX(受控导入 `dewu_settlement_bill_rows`;保留得物、退货退款订单、扣减其他费用明细) |
|
||||
|
||||
@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,406 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 工作平台「客服系统 → 退款数据 → 退货取件管理」的只读镜像仓库。
|
||||
*
|
||||
* 页面只读取 caiwu_business_mirror;首次全量与后续增量由
|
||||
* business-mirror:sync-tables 负责,请求过程不回退直连工作平台库。
|
||||
*/
|
||||
class ReturnPickupManagementMirrorRepository
|
||||
{
|
||||
public const DATASET_KEY = 'jiansun.dbo.ekf_ReturnPickupManagement';
|
||||
|
||||
public const ITEM_DATASET_KEY = 'jiansun.dbo.ekf_ReturnPickupManagement_Item';
|
||||
|
||||
public const TRUTH_DATASET_KEY = 'jiansun.dbo.ekf_ReturnPickupManagement_TruthReExpressNumber';
|
||||
|
||||
public const WAREHOUSE_DATASET_KEY = 'jiansun.dbo.wp_warehouse';
|
||||
|
||||
public const WAREHOUSE_CONFIG_DATASET_KEY = 'jiansun.dbo.wp_warehouseConfig';
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
private readonly GenericBusinessMirrorRepository $mirror,
|
||||
) {
|
||||
$this->connection = $mirror->connection();
|
||||
}
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
|
||||
return $schema->hasTable('business_mirror_table_states')
|
||||
&& $schema->hasTable($this->dataset()->targetTable());
|
||||
}
|
||||
|
||||
public function ready(): bool
|
||||
{
|
||||
return $this->mirror->ready($this->dataset());
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function state(): ?array
|
||||
{
|
||||
return $this->mirror->state($this->dataset());
|
||||
}
|
||||
|
||||
/**
|
||||
* @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();
|
||||
|
||||
if (! is_array($state)) {
|
||||
return ['code' => 'unavailable', 'lag_minutes' => null];
|
||||
}
|
||||
|
||||
$lastSucceeded = $this->timestamp($state['last_succeeded_at'] ?? null);
|
||||
$lagMinutes = $lastSucceeded === null
|
||||
? null
|
||||
: (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded) / 60);
|
||||
$status = (string) ($state['status'] ?? 'pending');
|
||||
|
||||
if (($state['initial_completed_at'] ?? null) === null) {
|
||||
$code = $status === 'failed'
|
||||
? 'initial_failed'
|
||||
: ($status === 'running' ? 'initializing' : 'pending');
|
||||
} elseif ($status === 'failed') {
|
||||
$code = 'failed';
|
||||
} elseif ($status === 'running') {
|
||||
$code = ($state['run_mode'] ?? null) === 'full' ? 'reconciling' : 'syncing';
|
||||
} elseif ($lagMinutes === null
|
||||
|| $lagMinutes > max((int) config('upstream_mirrors.return_pickup.stale_after_minutes', 180), 1)) {
|
||||
$code = 'delayed';
|
||||
} else {
|
||||
$code = 'healthy';
|
||||
}
|
||||
|
||||
return ['code' => $code, 'lag_minutes' => $lagMinutes];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $filters
|
||||
* @return array{data:list<object>,total:int}
|
||||
*/
|
||||
public function paginate(array $filters, int $page, int $perPage): array
|
||||
{
|
||||
if (! $this->ready()) {
|
||||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||||
}
|
||||
|
||||
$page = max($page, 1);
|
||||
$perPage = min(max($perPage, 1), 100);
|
||||
$query = $this->filteredQuery($filters);
|
||||
$total = (clone $query)->count();
|
||||
$rows = $query
|
||||
->orderByDesc('createTime')
|
||||
->orderByDesc('Id')
|
||||
->forPage($page, $perPage)
|
||||
->get()
|
||||
->all();
|
||||
|
||||
return ['data' => $rows, 'total' => $total];
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部去重快递公司(对应镜像中的 express_company)。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function carriers(): array
|
||||
{
|
||||
if (! $this->ready()) {
|
||||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||||
}
|
||||
|
||||
return $this->connection->table($this->dataset()->targetTable())
|
||||
->whereNotNull('express_company')
|
||||
->distinct()
|
||||
->orderBy('express_company')
|
||||
->pluck('express_company')
|
||||
->map(static fn ($value): string => trim((string) $value))
|
||||
->filter(static fn (string $value): bool => $value !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部去重订单状态(对应镜像中的 orderStatus)。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function statuses(): array
|
||||
{
|
||||
if (! $this->ready()) {
|
||||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||||
}
|
||||
|
||||
return $this->connection->table($this->dataset()->targetTable())
|
||||
->whereNotNull('orderStatus')
|
||||
->distinct()
|
||||
->orderBy('orderStatus')
|
||||
->pluck('orderStatus')
|
||||
->map(static fn ($value): string => trim((string) $value))
|
||||
->filter(static fn (string $value): bool => $value !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 单张退货取件单的完整明细:主表 + 明细分录 + 实退快递单号映射。
|
||||
*
|
||||
* @return array{main:object,items:list<object>,truth_numbers:list<string>}|null
|
||||
*/
|
||||
public function detail(int $id): ?array
|
||||
{
|
||||
if (! $this->ready()) {
|
||||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||||
}
|
||||
|
||||
$main = $this->connection->table($this->dataset()->targetTable())
|
||||
->where('Id', $id)
|
||||
->first();
|
||||
|
||||
if ($main === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$items = $this->connection->table($this->itemDataset()->targetTable())
|
||||
->where('mainTableId', $id)
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->all();
|
||||
|
||||
$truthNumbers = $this->connection->table($this->truthDataset()->targetTable())
|
||||
->where('mId', $id)
|
||||
->orderBy('Id')
|
||||
->pluck('truthExpressNumber')
|
||||
->map(static fn ($value): string => trim((string) $value))
|
||||
->filter(static fn (string $value): bool => $value !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return ['main' => $main, 'items' => $items, 'truth_numbers' => $truthNumbers];
|
||||
}
|
||||
|
||||
/**
|
||||
* 发货仓编码 → 名称目录。
|
||||
*
|
||||
* 明细里的 warehouse 可能是工作平台短码(wp_warehouse.code 或
|
||||
* wp_warehouseConfig.whCode,如 2852)或聚水潭 wms_co_id(如
|
||||
* 15038426)。短码优先取工作平台仓库资料,长码回退聚水潭仓库镜像;
|
||||
* 未命中的编码由调用方保留原始值展示,不猜测名称。
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public function warehouseDirectory(): array
|
||||
{
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
$directory = [];
|
||||
|
||||
foreach ([
|
||||
['js_wp_warehouse', 'code', 'name'],
|
||||
['js_wp_warehouse_config', 'whCode', 'whName'],
|
||||
] as [$table, $codeColumn, $nameColumn]) {
|
||||
if (! $schema->hasTable($table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = $this->connection->table($table)->get([$codeColumn, $nameColumn]);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$code = trim((string) ($row->{$codeColumn} ?? ''));
|
||||
$name = trim((string) ($row->{$nameColumn} ?? ''));
|
||||
|
||||
if ($code !== '' && $name !== '' && ! isset($directory[$code])) {
|
||||
$directory[$code] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($schema->hasTable('ops_jst_warehouse_info')) {
|
||||
try {
|
||||
$rows = $this->connection->table('ops_jst_warehouse_info')->get(['Id', 'wms_co_id', 'name']);
|
||||
} catch (Throwable) {
|
||||
$rows = [];
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$name = trim((string) ($row->name ?? ''));
|
||||
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ([$row->wms_co_id ?? null, $row->Id ?? null] as $value) {
|
||||
$code = trim((string) $value);
|
||||
|
||||
if ($code !== '' && $code !== '0' && ! isset($directory[$code])) {
|
||||
$directory[$code] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用明细流:按取件单 Id 倒序断点遍历,逐条输出「主表 + 明细」,
|
||||
* 由调用方按同一份快照计算实际运费与差异。
|
||||
*
|
||||
* @param array<string,string> $filters
|
||||
* @return \Generator<int,object>
|
||||
*/
|
||||
public function exportRows(array $filters, int $maxRows = 50000, int $batchSize = 300): \Generator
|
||||
{
|
||||
if (! $this->ready()) {
|
||||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||||
}
|
||||
|
||||
$mainTable = $this->dataset()->targetTable();
|
||||
$itemTable = $this->itemDataset()->targetTable();
|
||||
$batchSize = min(max($batchSize, 1), 1000);
|
||||
$yielded = 0;
|
||||
$lastId = null;
|
||||
|
||||
while ($yielded < $maxRows) {
|
||||
$mains = $this->filteredQuery($filters);
|
||||
|
||||
if ($lastId !== null) {
|
||||
$mains->where('Id', '<', $lastId);
|
||||
}
|
||||
|
||||
$ids = $mains->orderByDesc('Id')->limit($batchSize)->pluck('Id')->all();
|
||||
|
||||
if ($ids === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lastId = (int) min($ids);
|
||||
|
||||
$rows = $this->connection->table($mainTable . ' AS m')
|
||||
->join($itemTable . ' AS i', 'i.mainTableId', '=', 'm.Id')
|
||||
->whereIn('m.Id', $ids)
|
||||
->orderByDesc('m.Id')
|
||||
->orderBy('i.id')
|
||||
->select([
|
||||
'm.Id AS mainId', 'm.orderId', 'm.express_company', 'm.orderStatus', 'm.urgencyLevel',
|
||||
'm.shopName', 'm.transactionNo', 'm.province', 'm.city', 'm.senderName', 'm.senderPhone',
|
||||
'm.returnExpressNo', 'm.createTime', 'm.updateTime', 'm.expectedPickupTime', 'm.paymentMethod',
|
||||
'm.pickupType', 'm.totalReturnQuantity', 'm.note', 'm.issueType', 'm.issueDescription',
|
||||
'm.expressFollowStatus', 'm.expressFollowResult', 'm.expressFollowDescription',
|
||||
'm.expressOrderTime', 'm.expressPickupTime', 'm.creator', 'm.canceler',
|
||||
'i.id AS itemId', 'i.returnProcode', 'i.actualReturnProcode', 'i.barcode', 'i.productName',
|
||||
'i.platformProductId', 'i.platformSkuId', 'i.returnQuantity', 'i.actualReturnQuantity',
|
||||
'i.unverifiedQuantity', 'i.returnProgress', 'i.warehouse',
|
||||
'i.receiverName', 'i.receiverPhone', 'i.receiverAddress', 'i.actualReturnOrderNo',
|
||||
'i.grossWeight', 'i.length', 'i.width', 'i.height', 'i.estimatedFreight',
|
||||
])
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
yield $row;
|
||||
$yielded++;
|
||||
|
||||
if ($yielded >= $maxRows) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,string> $filters */
|
||||
private function filteredQuery(array $filters): Builder
|
||||
{
|
||||
$query = $this->connection->table($this->dataset()->targetTable());
|
||||
|
||||
if (($filters['created_from'] ?? '') !== '') {
|
||||
$query->where('createTime', '>=', $filters['created_from'] . ' 00:00:00');
|
||||
}
|
||||
|
||||
if (($filters['created_to'] ?? '') !== '') {
|
||||
$query->where('createTime', '<', Carbon::parse($filters['created_to'])->addDay()->toDateString());
|
||||
}
|
||||
|
||||
if (($filters['carrier'] ?? '') !== '') {
|
||||
$query->where('express_company', $filters['carrier']);
|
||||
}
|
||||
|
||||
if (($filters['status'] ?? '') !== '') {
|
||||
$query->where('orderStatus', $filters['status']);
|
||||
}
|
||||
|
||||
if (($filters['shop'] ?? '') !== '') {
|
||||
$query->whereRaw("shopName LIKE ? ESCAPE '!'", [$this->contains($filters['shop'])]);
|
||||
}
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
|
||||
if ($keyword !== '') {
|
||||
$pattern = $this->contains($keyword);
|
||||
$query->where(static function (Builder $inner) use ($pattern): void {
|
||||
$inner->whereRaw("orderId LIKE ? ESCAPE '!'", [$pattern])
|
||||
->orWhereRaw("transactionNo LIKE ? ESCAPE '!'", [$pattern])
|
||||
->orWhereRaw("returnExpressNo LIKE ? ESCAPE '!'", [$pattern])
|
||||
->orWhereRaw("senderName LIKE ? ESCAPE '!'", [$pattern])
|
||||
->orWhereRaw("senderPhone LIKE ? ESCAPE '!'", [$pattern]);
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function contains(string $value): string
|
||||
{
|
||||
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
|
||||
}
|
||||
|
||||
private function timestamp(mixed $value): ?int
|
||||
{
|
||||
if (! is_string($value) || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($value)->getTimestamp();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function dataset(): BusinessMirrorDataset
|
||||
{
|
||||
return $this->datasets->get(self::DATASET_KEY);
|
||||
}
|
||||
|
||||
private function itemDataset(): BusinessMirrorDataset
|
||||
{
|
||||
return $this->datasets->get(self::ITEM_DATASET_KEY);
|
||||
}
|
||||
|
||||
private function truthDataset(): BusinessMirrorDataset
|
||||
{
|
||||
return $this->datasets->get(self::TRUTH_DATASET_KEY);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -9,7 +9,9 @@
|
||||
->values()
|
||||
->all();
|
||||
$quoteTypes = collect($quotes)->pluck('quote_type')->unique()->sort()->values();
|
||||
$quoteView = request('quote_view') === 'warehouse' ? 'warehouse' : 'courier';
|
||||
$quoteView = in_array(request('quote_view'), ['warehouse', 'reverse-logistics'], true)
|
||||
? request('quote_view')
|
||||
: 'courier';
|
||||
@endphp
|
||||
|
||||
<section class="quote-management" aria-label="报价管理" data-quote-management>
|
||||
@ -654,6 +656,9 @@
|
||||
<button type="button" role="tab" @class(['quote-local-tab', 'is-active' => $quoteView === 'warehouse']) data-quote-view="warehouse" aria-controls="quote-warehouse-panel" aria-selected="{{ $quoteView === 'warehouse' ? 'true' : 'false' }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">warehouse</span><span>仓储报价</span>
|
||||
</button>
|
||||
<button type="button" role="tab" @class(['quote-local-tab', 'is-active' => $quoteView === 'reverse-logistics']) data-quote-view="reverse-logistics" aria-controls="quote-reverse-logistics-panel" aria-selected="{{ $quoteView === 'reverse-logistics' ? 'true' : 'false' }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">assignment_return</span><span>逆向物流报价</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="quote-local-panel" id="quote-courier-panel" role="tabpanel" data-quote-panel="courier" @if ($quoteView !== 'courier') hidden @endif>
|
||||
@ -1127,6 +1132,10 @@
|
||||
<livewire:business.third-party-warehouse-quotes />
|
||||
</div>
|
||||
|
||||
<div class="quote-local-panel" id="quote-reverse-logistics-panel" role="tabpanel" data-quote-panel="reverse-logistics" @if ($quoteView !== 'reverse-logistics') hidden @endif>
|
||||
@include('business.pages.courier-payables.reverse-logistics-quotes')
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.currentScript.closest('[data-quote-management]');
|
||||
@ -1144,8 +1153,8 @@
|
||||
panels.forEach((panel) => { panel.hidden = panel.dataset.quotePanel !== view; });
|
||||
if (updateUrl) {
|
||||
const url = new URL(window.location.href);
|
||||
if (view === 'warehouse') url.searchParams.set('quote_view', 'warehouse');
|
||||
else url.searchParams.delete('quote_view');
|
||||
if (view === 'courier') url.searchParams.delete('quote_view');
|
||||
else url.searchParams.set('quote_view', view);
|
||||
history.replaceState({}, '', url);
|
||||
}
|
||||
};
|
||||
|
||||
@ -0,0 +1,618 @@
|
||||
@php
|
||||
$filters = $returnPickupFilters ?? [];
|
||||
$rows = $returnPickupRows ?? null;
|
||||
$sync = $returnPickupSyncStatus ?? ['code' => 'unavailable', 'lag_minutes' => null];
|
||||
$state = $returnPickupState ?? null;
|
||||
$error = $returnPickupError ?? null;
|
||||
$pending = $returnPickupPending ?? false;
|
||||
$carriers = $returnPickupCarriers ?? [];
|
||||
$statuses = $returnPickupStatuses ?? [];
|
||||
$detail = $returnPickupDetail ?? null;
|
||||
|
||||
$syncLabels = [
|
||||
'healthy' => '同步正常',
|
||||
'delayed' => '同步延迟',
|
||||
'syncing' => '增量同步中',
|
||||
'reconciling' => '全量校准中',
|
||||
'initializing' => '首次同步中',
|
||||
'initial_failed' => '首次同步失败',
|
||||
'failed' => '同步失败',
|
||||
'pending' => '等待首次同步',
|
||||
'unavailable' => '镜像未建立',
|
||||
];
|
||||
|
||||
$orderStatusLabels = \App\Support\ReturnPickupOrderStatus::LABELS;
|
||||
|
||||
$syncClass = ($sync['code'] ?? '') === 'healthy'
|
||||
? 'is-success'
|
||||
: (in_array($sync['code'] ?? '', ['failed', 'initial_failed', 'unavailable'], true) ? 'is-danger' : 'is-warning');
|
||||
|
||||
$datetime = static function ($value): string {
|
||||
if ($value === null || $value === '') {
|
||||
return '-';
|
||||
}
|
||||
|
||||
try {
|
||||
return \Illuminate\Support\Carbon::parse((string) $value)->format('Y-m-d H:i');
|
||||
} catch (\Throwable) {
|
||||
return (string) $value;
|
||||
}
|
||||
};
|
||||
|
||||
$pageUrl = static function (int $page) use ($filters): string {
|
||||
return route('business.pages.show', array_merge(
|
||||
['section' => 'courier-payables', 'page' => 'return-pickup-management', 'pickup_page' => $page],
|
||||
array_filter($filters, static fn ($value): bool => $value !== '' && $value !== null),
|
||||
));
|
||||
};
|
||||
|
||||
$detailUrl = static function (int $id) use ($filters): string {
|
||||
return route('business.pages.show', array_merge(
|
||||
['section' => 'courier-payables', 'page' => 'return-pickup-management', 'pickup_id' => $id],
|
||||
array_filter($filters, static fn ($value): bool => $value !== '' && $value !== null),
|
||||
));
|
||||
};
|
||||
|
||||
$exportUrl = route(
|
||||
'business.courier-payables.return-pickup-management.export',
|
||||
array_filter($filters, static fn ($value): bool => $value !== '' && $value !== null),
|
||||
);
|
||||
@endphp
|
||||
|
||||
<style>
|
||||
.return-pickup-page {
|
||||
color: var(--vip-content-ink, #171717);
|
||||
font-family: JetBrains Mono, Microsoft YaHei, PingFang SC, monospace;
|
||||
font-size: 0.8125rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.return-pickup-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.return-pickup-status__main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.return-pickup-status__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--vip-content-muted, #595959);
|
||||
}
|
||||
|
||||
.return-pickup-status__dot.is-success { background: var(--vip-success, #53793e); }
|
||||
.return-pickup-status__dot.is-warning { background: var(--vip-warning, #935f07); }
|
||||
.return-pickup-status__dot.is-danger { background: var(--vip-danger, #b80000); }
|
||||
|
||||
.return-pickup-status__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
color: var(--vip-content-muted, #595959);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.return-pickup-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 10px 14px;
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.return-pickup-field {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.return-pickup-field label {
|
||||
color: var(--vip-content-muted, #595959);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.return-pickup-field input,
|
||||
.return-pickup-field select {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 0 10px;
|
||||
color: var(--vip-content-ink, #171717);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.return-pickup-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.return-pickup-button {
|
||||
display: inline-flex;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 0 12px;
|
||||
color: var(--vip-content-ink, #171717);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.return-pickup-button--primary {
|
||||
border-color: var(--vip-content-accent-dark, #856632);
|
||||
background: var(--vip-content-accent-dark, #856632);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.return-pickup-notice {
|
||||
margin-top: 12px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(147, 95, 7, 0.35);
|
||||
border-radius: 8px;
|
||||
background: rgba(147, 95, 7, 0.06);
|
||||
color: var(--vip-warning, #935f07);
|
||||
}
|
||||
|
||||
.return-pickup-notice.is-error {
|
||||
border-color: rgba(184, 0, 0, 0.3);
|
||||
background: rgba(184, 0, 0, 0.05);
|
||||
color: var(--vip-danger, #b80000);
|
||||
}
|
||||
|
||||
.return-pickup-panel {
|
||||
margin-top: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.return-pickup-panel__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--vip-border, #d8d2c7);
|
||||
background: var(--vip-content-accent-soft, #f7f0de);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.return-pickup-table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.return-pickup-table-wrap.finance-table-scroll-owner {
|
||||
max-height: max(12rem, calc(100vh - 30rem));
|
||||
}
|
||||
|
||||
.return-pickup-table {
|
||||
width: 100%;
|
||||
min-width: 1880px;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78125rem;
|
||||
}
|
||||
|
||||
.return-pickup-table th {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--vip-border, #d8d2c7);
|
||||
background: var(--vip-content-accent-soft, #f7f0de);
|
||||
color: #4f4a43;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.return-pickup-table td {
|
||||
max-width: 260px;
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid #eeeae3;
|
||||
color: var(--vip-content-ink, #171717);
|
||||
line-height: 1.45;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.return-pickup-table tbody tr:hover { background: #fcfaf5; }
|
||||
.return-pickup-table .is-code { font-variant-numeric: tabular-nums; }
|
||||
.return-pickup-table .is-clamped { max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.return-pickup-table .is-negative { color: var(--vip-danger, #b80000); font-weight: 600; }
|
||||
.return-pickup-table .is-positive { color: var(--vip-success, #53793e); font-weight: 600; }
|
||||
|
||||
.return-pickup-total-row td {
|
||||
background: #faf6ec;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.return-pickup-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--vip-content-muted, #595959);
|
||||
}
|
||||
|
||||
.return-pickup-badge.is-danger { border-color: rgba(184, 0, 0, .25); background: rgba(184, 0, 0, .05); color: var(--vip-danger, #b80000); }
|
||||
.return-pickup-badge.is-warning { border-color: rgba(147, 95, 7, .3); background: rgba(147, 95, 7, .06); color: var(--vip-warning, #935f07); }
|
||||
.return-pickup-badge.is-success { border-color: rgba(83, 121, 62, .3); background: rgba(83, 121, 62, .08); color: var(--vip-success, #53793e); }
|
||||
|
||||
.return-pickup-link {
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.return-pickup-empty {
|
||||
min-height: 160px;
|
||||
padding: 42px 16px !important;
|
||||
color: var(--vip-content-muted, #595959) !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.return-pickup-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--vip-border, #d8d2c7);
|
||||
color: var(--vip-content-muted, #595959);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.return-pickup-pagination { display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
.return-pickup-page-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 6px;
|
||||
color: var(--vip-content-ink, #171717);
|
||||
}
|
||||
|
||||
.return-pickup-page-link:hover { border-color: var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-soft, #f7f0de); }
|
||||
.return-pickup-page-link.is-disabled { opacity: .4; pointer-events: none; }
|
||||
|
||||
.return-pickup-detail-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px 16px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #eeeae3;
|
||||
}
|
||||
|
||||
.return-pickup-detail-meta div { display: grid; gap: 2px; }
|
||||
.return-pickup-detail-meta dt { color: var(--vip-content-muted, #595959); font-size: 0.72rem; }
|
||||
.return-pickup-detail-meta dd { margin: 0; word-break: break-word; white-space: normal; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.return-pickup-filters { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.return-pickup-detail-meta { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.return-pickup-filters { grid-template-columns: 1fr; }
|
||||
.return-pickup-status { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="return-pickup-page">
|
||||
<div class="return-pickup-status" role="status">
|
||||
<div class="return-pickup-status__main">
|
||||
<span class="return-pickup-status__dot {{ $syncClass }}" aria-hidden="true"></span>
|
||||
<span>{{ $syncLabels[$sync['code'] ?? 'unavailable'] ?? '镜像状态未知' }}</span>
|
||||
</div>
|
||||
<div class="return-pickup-status__meta">
|
||||
<span>数据来源:工作平台 客服系统 → 退款数据 → 退货取件管理</span>
|
||||
@if (!empty($state['last_succeeded_at']))
|
||||
<span>最近同步 {{ $datetime($state['last_succeeded_at']) }}</span>
|
||||
@endif
|
||||
@if (($sync['lag_minutes'] ?? null) !== null)
|
||||
<span>数据延迟 {{ number_format((int) $sync['lag_minutes']) }} 分钟</span>
|
||||
@endif
|
||||
@if (isset($state['mirror_row_count']))
|
||||
<span>镜像 {{ number_format((int) $state['mirror_row_count']) }} 条</span>
|
||||
@endif
|
||||
@if ($rows !== null && $error === null)
|
||||
<span>当前结果 {{ number_format($rows->total()) }} 条</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="GET" class="return-pickup-filters" aria-label="退货取件管理筛选">
|
||||
<div class="return-pickup-field">
|
||||
<label for="pickup-created-from">创建时间从</label>
|
||||
<input id="pickup-created-from" type="date" name="created_from" value="{{ $filters['created_from'] ?? '' }}">
|
||||
</div>
|
||||
<div class="return-pickup-field">
|
||||
<label for="pickup-created-to">创建时间至</label>
|
||||
<input id="pickup-created-to" type="date" name="created_to" value="{{ $filters['created_to'] ?? '' }}">
|
||||
</div>
|
||||
<div class="return-pickup-field">
|
||||
<label for="pickup-carrier">快递公司</label>
|
||||
<select id="pickup-carrier" name="carrier">
|
||||
<option value="">全部</option>
|
||||
@foreach ($carriers as $carrier)
|
||||
<option value="{{ $carrier }}" @selected(($filters['carrier'] ?? '') === $carrier)>{{ $carrier }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="return-pickup-field">
|
||||
<label for="pickup-status">订单状态</label>
|
||||
<select id="pickup-status" name="status">
|
||||
<option value="">全部</option>
|
||||
@foreach ($statuses as $status)
|
||||
<option value="{{ $status }}" @selected(($filters['status'] ?? '') === $status)>{{ $orderStatusLabels[$status] ?? $status }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="return-pickup-field">
|
||||
<label for="pickup-shop">店铺名称</label>
|
||||
<input id="pickup-shop" type="text" name="shop" value="{{ $filters['shop'] ?? '' }}" placeholder="支持模糊匹配">
|
||||
</div>
|
||||
<div class="return-pickup-field">
|
||||
<label for="pickup-keyword">订单号 / 交易单号 / 实退单号</label>
|
||||
<input id="pickup-keyword" type="text" name="keyword" value="{{ $filters['keyword'] ?? '' }}" placeholder="支持模糊匹配">
|
||||
</div>
|
||||
<div class="return-pickup-actions">
|
||||
<button class="return-pickup-button return-pickup-button--primary" type="submit">
|
||||
<span class="material-icons-outlined" aria-hidden="true">search</span>
|
||||
<span>查询</span>
|
||||
</button>
|
||||
<a class="return-pickup-button" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'return-pickup-management']) }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">restart_alt</span>
|
||||
<span>重置</span>
|
||||
</a>
|
||||
<a class="return-pickup-button" href="{{ $exportUrl }}" title="按当前筛选导出明细(含预估运费、实际运费、差异)">
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span>
|
||||
<span>导出</span>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if ($error)
|
||||
<div class="return-pickup-notice {{ $pending ? '' : 'is-error' }}">{{ $error }}</div>
|
||||
@endif
|
||||
|
||||
@if ($detail !== null)
|
||||
@php
|
||||
$main = $detail['main'];
|
||||
@endphp
|
||||
<section class="return-pickup-panel" aria-label="退货取件单详情">
|
||||
<div class="return-pickup-panel__head">
|
||||
<span>退货取件单详情 · {{ $main->orderId ?? '-' }}</span>
|
||||
<a class="return-pickup-link" href="{{ $pageUrl($rows?->currentPage() ?? 1) }}">关闭详情</a>
|
||||
</div>
|
||||
<dl class="return-pickup-detail-meta">
|
||||
<div><dt>订单号</dt><dd>{{ $main->orderId ?? '-' }}</dd></div>
|
||||
<div><dt>快递公司</dt><dd>{{ $main->express_company ?: '-' }}</dd></div>
|
||||
<div><dt>订单状态</dt><dd>{{ $orderStatusLabels[$main->orderStatus] ?? ($main->orderStatus ?: '-') }}</dd></div>
|
||||
<div><dt>紧急程度</dt><dd>{{ $main->urgencyLevel ?: '-' }}</dd></div>
|
||||
<div><dt>店铺</dt><dd>{{ $main->shopName ?: '-' }}</dd></div>
|
||||
<div><dt>交易单号</dt><dd>{{ $main->transactionNo ?: '-' }}</dd></div>
|
||||
<div><dt>总件数</dt><dd>{{ $main->totalReturnQuantity ?? '-' }}</dd></div>
|
||||
<div><dt>实退快递单号</dt><dd>{{ $main->returnExpressNo ?: ($detail['truth_numbers'][0] ?? '-') }}</dd></div>
|
||||
<div><dt>实退单号映射</dt><dd>{{ $detail['truth_numbers'] === [] ? '-' : implode('、', $detail['truth_numbers']) }}</dd></div>
|
||||
<div><dt>付款方式</dt><dd>{{ $main->paymentMethod ?: '-' }}</dd></div>
|
||||
<div><dt>取件类型</dt><dd>{{ $main->pickupType ?: '-' }}</dd></div>
|
||||
<div><dt>预计取件时间</dt><dd>{{ $datetime($main->expectedPickupTime ?? null) }}</dd></div>
|
||||
<div><dt>快递下单 / 取件时间</dt><dd>{{ $datetime($main->expressOrderTime ?? null) }} / {{ $datetime($main->expressPickupTime ?? null) }}</dd></div>
|
||||
<div><dt>发件人</dt><dd>{{ $main->senderName ?: '-' }} {{ $main->senderPhone ?: '' }}</dd></div>
|
||||
<div><dt>发件地址</dt><dd>{{ trim(($main->province ?? '') . ($main->city ?? '') . ($main->district ?? '') . ($main->street ?? '') . ($main->address ?? '')) ?: '-' }}</dd></div>
|
||||
<div><dt>问题类型 / 描述</dt><dd>{{ $main->issueType ?: '-' }} {{ $main->issueDescription ?: '' }}</dd></div>
|
||||
<div><dt>问题跟进</dt><dd>{{ $main->expressFollowStatus ?: '-' }} / {{ $main->expressFollowResult ?: '-' }}</dd></div>
|
||||
<div><dt>问题跟进说明</dt><dd>{{ $main->expressFollowDescription ?: '-' }}</dd></div>
|
||||
<div><dt>备注</dt><dd>{{ $main->note ?: '-' }}</dd></div>
|
||||
<div><dt>创建 / 更新</dt><dd>{{ $datetime($main->createTime ?? null) }} / {{ $datetime($main->updateTime ?? null) }}</dd></div>
|
||||
<div><dt>创建人 / 取消人</dt><dd>{{ $main->creator ?: '-' }} / {{ $main->canceler ?: '-' }}</dd></div>
|
||||
</dl>
|
||||
<div class="return-pickup-table-wrap">
|
||||
<table class="return-pickup-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">应退货号</th>
|
||||
<th scope="col">实退货号</th>
|
||||
<th scope="col">平台商品id</th>
|
||||
<th scope="col">平台skuid</th>
|
||||
<th scope="col">条形码</th>
|
||||
<th scope="col">品名</th>
|
||||
<th scope="col">应退数量</th>
|
||||
<th scope="col">实退数量</th>
|
||||
<th scope="col">待核销数量</th>
|
||||
<th scope="col">明细退货进度</th>
|
||||
<th scope="col">发货仓</th>
|
||||
<th scope="col">收件人</th>
|
||||
<th scope="col">收件地址</th>
|
||||
<th scope="col">长</th>
|
||||
<th scope="col">宽</th>
|
||||
<th scope="col">高</th>
|
||||
<th scope="col">毛重(kg)</th>
|
||||
<th scope="col">体积重(kg)</th>
|
||||
<th scope="col">重量取大(kg)</th>
|
||||
<th scope="col">计费重量(kg)</th>
|
||||
<th scope="col">面单(元)</th>
|
||||
<th scope="col">费率(元/kg)</th>
|
||||
<th scope="col">预估运费(元)</th>
|
||||
<th scope="col">实际运费(元)</th>
|
||||
<th scope="col">未算出原因</th>
|
||||
<th scope="col">加收(元)</th>
|
||||
<th scope="col">差异(元)</th>
|
||||
<th scope="col">实退单号</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($detail['items'] as $item)
|
||||
@php
|
||||
$volumeWeight = ($item->length && $item->width && $item->height)
|
||||
? round(((float) $item->length * (float) $item->width * (float) $item->height) / 6000, 2)
|
||||
: null;
|
||||
@endphp
|
||||
<tr>
|
||||
<td>{{ $item->returnProcode ?: '-' }}</td>
|
||||
<td class="is-code">{{ $item->actualReturnProcode ?: '-' }}</td>
|
||||
<td class="is-code">{{ $item->platformProductId ?: '-' }}</td>
|
||||
<td class="is-code">{{ $item->platformSkuId ?: '-' }}</td>
|
||||
<td class="is-code">{{ $item->barcode ?: '-' }}</td>
|
||||
<td class="is-clamped" title="{{ $item->productName }}">{{ $item->productName ?: '-' }}</td>
|
||||
<td>{{ $item->returnQuantity ?? '-' }}</td>
|
||||
<td>{{ $item->actualReturnQuantity ?? '-' }}</td>
|
||||
<td>{{ $item->unverifiedQuantity ?? '-' }}</td>
|
||||
<td>{{ $item->returnProgress ?: '-' }}</td>
|
||||
<td title="{{ $item->warehouse }}">{{ ($item->warehouse_name ?? null) ?: ($item->warehouse ?: '-') }}</td>
|
||||
<td>{{ $item->receiverName ?: '-' }} {{ $item->receiverPhone ?: '' }}</td>
|
||||
<td class="is-clamped" title="{{ $item->receiverAddress }}">{{ $item->receiverAddress ?: '-' }}</td>
|
||||
<td class="is-code">{{ $item->length !== null ? number_format((float) $item->length, 2) : '-' }}</td>
|
||||
<td class="is-code">{{ $item->width !== null ? number_format((float) $item->width, 2) : '-' }}</td>
|
||||
<td class="is-code">{{ $item->height !== null ? number_format((float) $item->height, 2) : '-' }}</td>
|
||||
<td class="is-code">{{ $item->grossWeight !== null ? number_format((float) $item->grossWeight, 2) : '-' }}</td>
|
||||
<td class="is-code">{{ $volumeWeight !== null ? number_format($volumeWeight, 2) : '-' }}</td>
|
||||
<td class="is-code">{{ $item->freight_settlement !== null ? number_format((float) $item->freight_settlement, 2) : '—' }}</td>
|
||||
<td class="is-code" title="{{ $item->freight_process }}">{{ $item->freight_billable !== null ? number_format((float) $item->freight_billable, 2) : '—' }}</td>
|
||||
<td class="is-code">{{ $item->freight_base_amount !== null ? number_format((float) $item->freight_base_amount, 2) : '—' }}</td>
|
||||
<td class="is-code">{{ $item->freight_increment_amount !== null ? number_format((float) $item->freight_increment_amount, 2) : '—' }}</td>
|
||||
<td class="is-code">{{ $item->estimatedFreight !== null ? number_format((float) $item->estimatedFreight, 2) : '-' }}</td>
|
||||
<td class="is-code" title="{{ $item->freight_note }}">
|
||||
{{ $item->actual_freight !== null ? number_format((float) $item->actual_freight, 2) : '—' }}
|
||||
</td>
|
||||
<td class="is-clamped" title="{{ $item->freight_note }}">
|
||||
{{ $item->actual_freight === null ? ($item->freight_note ?: '未匹配到报价') : '—' }}
|
||||
</td>
|
||||
<td class="is-code" title="{{ ($item->freight_surcharge ?? 0) > 0 ? $item->freight_note : '' }}">
|
||||
{{ ($item->freight_surcharge ?? 0) > 0 ? number_format((float) $item->freight_surcharge, 2) : '—' }}
|
||||
</td>
|
||||
<td class="is-code">
|
||||
@if ($item->freight_diff !== null)
|
||||
<span class="{{ (float) $item->freight_diff < 0 ? 'is-negative' : 'is-positive' }}">{{ number_format((float) $item->freight_diff, 2) }}</span>
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
<td class="is-code">{{ $item->actualReturnOrderNo ?: '-' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td class="return-pickup-empty" colspan="28">该取件单暂无明细分录。</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
@if (($detail['totals'] ?? null) !== null)
|
||||
<tbody>
|
||||
<tr class="return-pickup-total-row">
|
||||
<td colspan="22">合计({{ count($detail['items']) }} 条明细,差异 = 预估 − 实际运费,加收单列)</td>
|
||||
<td class="is-code">{{ number_format((float) $detail['totals']['estimated'], 2) }}</td>
|
||||
<td class="is-code">{{ $detail['totals']['actual'] !== null ? number_format((float) $detail['totals']['actual'], 2) : '—' }}</td>
|
||||
<td>—</td>
|
||||
<td class="is-code">{{ ($detail['totals']['surcharge'] ?? 0) > 0 ? number_format((float) $detail['totals']['surcharge'], 2) : '—' }}</td>
|
||||
<td class="is-code">
|
||||
{{ $detail['totals']['diff'] !== null ? number_format((float) $detail['totals']['diff'], 2) : '—' }}
|
||||
</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@endif
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
<section class="return-pickup-panel" aria-label="退货取件单列表">
|
||||
<div class="return-pickup-table-wrap">
|
||||
<table class="return-pickup-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">订单号</th>
|
||||
<th scope="col">快递公司</th>
|
||||
<th scope="col">紧急程度</th>
|
||||
<th scope="col">预计取件时间</th>
|
||||
<th scope="col">订单状态</th>
|
||||
<th scope="col">退货进度</th>
|
||||
<th scope="col">付款方式</th>
|
||||
<th scope="col">取件类型</th>
|
||||
<th scope="col">店铺名称</th>
|
||||
<th scope="col">交易单号</th>
|
||||
<th scope="col">总件数</th>
|
||||
<th scope="col">发件人</th>
|
||||
<th scope="col">发件省</th>
|
||||
<th scope="col">实退快递单号</th>
|
||||
<th scope="col">创建时间</th>
|
||||
<th scope="col">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse (($rows ?? collect()) as $row)
|
||||
<tr>
|
||||
<td class="is-code">{{ $row->orderId ?: '-' }}</td>
|
||||
<td>{{ $row->express_company ?: '-' }}</td>
|
||||
<td>
|
||||
<span class="return-pickup-badge {{ $row->urgencyLevel === '紧急' ? 'is-danger' : '' }}">{{ $row->urgencyLevel ?: '-' }}</span>
|
||||
</td>
|
||||
<td>{{ $datetime($row->expectedPickupTime ?? null) }}</td>
|
||||
<td>
|
||||
<span class="return-pickup-badge {{ $row->orderStatus === 'ExpCanceled' ? 'is-danger' : (in_array($row->orderStatus, ['WaitExpOrder', 'WaitExpAccept'], true) ? 'is-warning' : 'is-success') }}">
|
||||
{{ $orderStatusLabels[$row->orderStatus] ?? ($row->orderStatus ?: '-') }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ $row->expressFollowStatus ?: '-' }}{{ $row->expressFollowResult ? '(' . $row->expressFollowResult . ')' : '' }}</td>
|
||||
<td>{{ $row->paymentMethod ?: '-' }}</td>
|
||||
<td>{{ $row->pickupType ?: '-' }}</td>
|
||||
<td class="is-clamped" title="{{ $row->shopName }}">{{ $row->shopName ?: '-' }}</td>
|
||||
<td class="is-code">{{ $row->transactionNo ?: '-' }}</td>
|
||||
<td>{{ $row->totalReturnQuantity ?? '-' }}</td>
|
||||
<td class="is-clamped" title="{{ $row->senderName }}">{{ $row->senderName ?: '-' }}</td>
|
||||
<td>{{ $row->province ?: '-' }}</td>
|
||||
<td class="is-code">{{ $row->returnExpressNo ?: '-' }}</td>
|
||||
<td>{{ $datetime($row->createTime ?? null) }}</td>
|
||||
<td>
|
||||
<a class="return-pickup-link" href="{{ $detailUrl((int) $row->Id) }}">查看</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td class="return-pickup-empty" colspan="16">
|
||||
{{ $error !== null ? '退货取件管理数据暂时无法读取。' : '没有符合筛选条件的退货取件单。' }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if ($rows !== null)
|
||||
<div class="return-pickup-footer">
|
||||
<span>第 {{ $rows->currentPage() }} / {{ $rows->lastPage() }} 页,本页 {{ $rows->count() }} 条,共 {{ number_format($rows->total()) }} 条</span>
|
||||
<div class="return-pickup-pagination">
|
||||
<a class="return-pickup-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->onFirstPage() ? '#' : $pageUrl($rows->currentPage() - 1) }}" aria-label="上一页">
|
||||
<span class="material-icons-outlined" aria-hidden="true">chevron_left</span>
|
||||
</a>
|
||||
<a class="return-pickup-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->hasMorePages() ? $pageUrl($rows->currentPage() + 1) : '#' }}" aria-label="下一页">
|
||||
<span class="material-icons-outlined" aria-hidden="true">chevron_right</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
</div>
|
||||
@ -0,0 +1,943 @@
|
||||
@php
|
||||
$reverseQuoteSnapshot = app(\App\Support\ReverseLogisticsQuoteSnapshot::class)->load();
|
||||
$reverseQuotes = $reverseQuoteSnapshot['quotes'];
|
||||
$reverseRates = $reverseQuoteSnapshot['rates'] ?? [];
|
||||
$reverseRules = $reverseQuoteSnapshot['rate_groups'] ?? [];
|
||||
$reverseRateSummary = $reverseQuoteSnapshot['rate_summary'] ?? [
|
||||
'rate_count' => 0,
|
||||
'carrier_count' => 0,
|
||||
'origin_count' => 0,
|
||||
'destination_count' => 0,
|
||||
'weight_band_count' => 0,
|
||||
'available_count' => 0,
|
||||
'missing_count' => 0,
|
||||
];
|
||||
$rateCarriers = collect($reverseRules)->pluck('carrier')->unique()->values()->all();
|
||||
$rateOrigins = collect($reverseRates)->pluck('origin')->unique()->sort()->values()->all();
|
||||
$rateDestinations = collect($reverseRates)->pluck('destination')->unique()->sort()->values()->all();
|
||||
$calcOrigins = $reverseQuoteSnapshot['provinces']['origins'] ?? [];
|
||||
$calcDestinations = $reverseQuoteSnapshot['provinces']['destinations'] ?? [];
|
||||
@endphp
|
||||
|
||||
<div data-reverse-logistics-quotes>
|
||||
<style>
|
||||
.reverse-quote-rules {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.reverse-quote-rule {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.6rem;
|
||||
border: 1px solid var(--quote-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.85rem 0.9rem;
|
||||
background: #FFFDF8;
|
||||
}
|
||||
|
||||
.reverse-quote-rule__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.reverse-quote-rule__carrier {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.reverse-quote-rule__meta {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reverse-quote-rule__meta > div {
|
||||
display: grid;
|
||||
grid-template-columns: 5.5rem 1fr;
|
||||
gap: 0.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.reverse-quote-rule__meta dt {
|
||||
color: var(--quote-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.reverse-quote-rule__meta dd {
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.reverse-quote-rule__notes {
|
||||
border-top: 1px dashed var(--quote-border);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.reverse-quote-rule__notes summary {
|
||||
cursor: pointer;
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reverse-quote-rule__notes ul {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
margin: 0.5rem 0 0;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--quote-muted);
|
||||
}
|
||||
|
||||
.reverse-rate-band {
|
||||
color: var(--quote-muted);
|
||||
}
|
||||
|
||||
.reverse-rate-missing {
|
||||
color: var(--vip-content-warning, #935F07);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quote-empty[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reverse-calc-best td {
|
||||
background: var(--vip-content-accent-soft, #F7F0DE);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="quote-summary" aria-label="逆向物流报价数据概览">
|
||||
<div class="quote-source">
|
||||
<span class="quote-source__icon" aria-hidden="true">
|
||||
<span class="material-icons-outlined">assignment_return</span>
|
||||
</span>
|
||||
<div class="quote-source__body">
|
||||
<div class="quote-source__file" title="{{ $reverseQuoteSnapshot['source_file'] }}">{{ $reverseQuoteSnapshot['source_file'] }}</div>
|
||||
<div class="quote-source__state">本机私有报价源 · 原始工作簿未修改 · 金额按原表数值直读</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quote-metric">
|
||||
<div class="quote-metric__label">报价版本</div>
|
||||
<div class="quote-metric__value">{{ $reverseQuoteSnapshot['summary']['quote_count'] }}</div>
|
||||
</div>
|
||||
<div class="quote-metric">
|
||||
<div class="quote-metric__label">运价明细</div>
|
||||
<div class="quote-metric__value" data-reverse-rate-total>{{ $reverseRateSummary['rate_count'] }}</div>
|
||||
</div>
|
||||
<div class="quote-metric">
|
||||
<div class="quote-metric__label">可计算运价</div>
|
||||
<div class="quote-metric__value">{{ $reverseRateSummary['available_count'] }}</div>
|
||||
</div>
|
||||
<div class="quote-metric">
|
||||
<div class="quote-metric__label">无报价行</div>
|
||||
<div class="quote-metric__value">{{ $reverseRateSummary['missing_count'] }}</div>
|
||||
</div>
|
||||
<div class="quote-metric">
|
||||
<div class="quote-metric__label">已标执行日期</div>
|
||||
<div class="quote-metric__value">{{ $reverseQuoteSnapshot['summary']['dated_count'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($reverseQuoteSnapshot['available'])
|
||||
<div class="quote-workspace">
|
||||
<div class="quote-results">
|
||||
<span class="quote-results__count">计费规则</span>
|
||||
<span class="quote-results__notice">
|
||||
<span class="material-icons-outlined" aria-hidden="true">rule</span>
|
||||
<span>每种承运商的原表口径(面单、续重、重量段、限重、计泡)</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="reverse-quote-rules" data-reverse-rule-list>
|
||||
@foreach ($reverseRules as $rule)
|
||||
<article class="reverse-quote-rule" data-reverse-rule="{{ $rule['carrier'] }}">
|
||||
<header class="reverse-quote-rule__head">
|
||||
<span class="reverse-quote-rule__carrier">{{ $rule['carrier'] }}</span>
|
||||
<span class="quote-badge quote-badge--ready">{{ $rule['sheet_name'] }}</span>
|
||||
</header>
|
||||
<dl class="reverse-quote-rule__meta">
|
||||
<div><dt>计费方式</dt><dd>{{ $rule['pricing_label'] }}</dd></div>
|
||||
<div><dt>生效日期</dt><dd>{{ $rule['effective_date'] ?? '未标注' }}</dd></div>
|
||||
<div><dt>取价方向</dt><dd>{{ $rule['route_label'] ?? '出发省份 → 到达省份' }}</dd></div>
|
||||
<div><dt>面单口径</dt><dd>{{ $rule['base_label'] }}({{ $rule['base_unit'] }})</dd></div>
|
||||
<div><dt>续重口径</dt><dd>{{ $rule['increment_label'] }}({{ $rule['increment_unit'] }})</dd></div>
|
||||
<div><dt>重量段</dt><dd>{{ $rule['weight_bands'] === [] ? '首重 1kg + 续重' : implode(' / ', $rule['weight_bands']) }}</dd></div>
|
||||
<div><dt>运价覆盖</dt><dd>{{ $rule['rate_count'] }} 条(可用 {{ $rule['available_count'] }})· 出发 {{ $rule['origin_count'] }} 省 · 到达 {{ $rule['destination_count'] }} 处</dd></div>
|
||||
</dl>
|
||||
@if ($rule['notes'] !== [])
|
||||
<details class="reverse-quote-rule__notes">
|
||||
<summary>原表备注与限制({{ count($rule['notes']) }} 条)</summary>
|
||||
<ul>
|
||||
@foreach ($rule['notes'] as $note)
|
||||
<li>{{ $note }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</details>
|
||||
@endif
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quote-workspace" data-reverse-calculator>
|
||||
<div class="quote-results">
|
||||
<span class="quote-results__count">运费计算器</span>
|
||||
<span class="quote-results__notice">
|
||||
<span class="material-icons-outlined" aria-hidden="true">calculate</span>
|
||||
<span>计泡 6000;按各承运商原表口径计算,附加费按原表备注</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="quote-toolbar" aria-label="运费计算条件">
|
||||
<label class="quote-field">
|
||||
<span class="quote-field__label">承运商</span>
|
||||
<select class="quote-control" name="calc_carrier" data-reverse-calc="carrier">
|
||||
<option value="">自动试算(全部)</option>
|
||||
@foreach ($rateCarriers as $carrier)
|
||||
<option value="{{ $carrier }}">{{ $carrier }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label class="quote-field">
|
||||
<span class="quote-field__label">出发省份</span>
|
||||
<select class="quote-control" name="calc_origin" data-reverse-calc="origin">
|
||||
@foreach ($calcOrigins as $origin)
|
||||
<option value="{{ $origin }}" @selected($origin === '广东')>{{ $origin }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label class="quote-field">
|
||||
<span class="quote-field__label">到达省份</span>
|
||||
<select class="quote-control" name="calc_destination" data-reverse-calc="destination">
|
||||
@foreach ($calcDestinations as $destination)
|
||||
<option value="{{ $destination }}" @selected($destination === '江苏')>{{ $destination }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label class="quote-field">
|
||||
<span class="quote-field__label">结算重量(kg)</span>
|
||||
<input class="quote-control" type="number" min="0" step="0.01" value="10" name="calc_weight" data-reverse-calc="weight">
|
||||
</label>
|
||||
<label class="quote-field">
|
||||
<span class="quote-field__label">长(cm)</span>
|
||||
<input class="quote-control" type="number" min="0" step="1" placeholder="选填" name="calc_length" data-reverse-calc="length">
|
||||
</label>
|
||||
<label class="quote-field">
|
||||
<span class="quote-field__label">宽(cm)</span>
|
||||
<input class="quote-control" type="number" min="0" step="1" placeholder="选填" name="calc_width" data-reverse-calc="width">
|
||||
</label>
|
||||
<label class="quote-field">
|
||||
<span class="quote-field__label">高(cm)</span>
|
||||
<input class="quote-control" type="number" min="0" step="1" placeholder="选填" name="calc_height" data-reverse-calc="height">
|
||||
</label>
|
||||
<div class="quote-toolbar__actions">
|
||||
<button class="quote-button" type="button" data-reverse-calc-reset>
|
||||
<span class="material-icons-outlined" aria-hidden="true">restart_alt</span>
|
||||
<span>重置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quote-results">
|
||||
<span class="quote-results__count" data-reverse-calc-summary>按条件自动试算</span>
|
||||
<span class="quote-results__notice">
|
||||
<span class="material-icons-outlined" aria-hidden="true">straighten</span>
|
||||
<span data-reverse-calc-volume>未填写长宽高,按结算重量计算</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="quote-table-wrap">
|
||||
<table class="quote-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">承运商</th>
|
||||
<th scope="col">线路</th>
|
||||
<th scope="col">计费重量(kg)</th>
|
||||
<th scope="col">计算式</th>
|
||||
<th scope="col">运费(元)</th>
|
||||
<th scope="col">加收(元)</th>
|
||||
<th scope="col">附加费 / 说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-reverse-calc-body></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quote-workspace" data-reverse-rate-panel>
|
||||
<div class="quote-toolbar" aria-label="运价筛选">
|
||||
<label class="quote-field">
|
||||
<span class="quote-field__label">承运商</span>
|
||||
<select class="quote-control" name="rate_carrier" data-reverse-rate-filter="carrier">
|
||||
<option value="">全部承运商</option>
|
||||
@foreach ($rateCarriers as $carrier)
|
||||
<option value="{{ $carrier }}">{{ $carrier }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label class="quote-field">
|
||||
<span class="quote-field__label">出发省份</span>
|
||||
<select class="quote-control" name="rate_origin" data-reverse-rate-filter="origin">
|
||||
<option value="">全部出发省</option>
|
||||
@foreach ($rateOrigins as $origin)
|
||||
<option value="{{ $origin }}">{{ $origin }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label class="quote-field">
|
||||
<span class="quote-field__label">到达省份</span>
|
||||
<select class="quote-control" name="rate_destination" data-reverse-rate-filter="destination">
|
||||
<option value="">全部到达省</option>
|
||||
@foreach ($rateDestinations as $destination)
|
||||
<option value="{{ $destination }}">{{ $destination }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label class="quote-field">
|
||||
<span class="quote-field__label">搜索</span>
|
||||
<input class="quote-control" type="search" name="rate_keyword" placeholder="省份或来源单元格" autocomplete="off" data-reverse-rate-filter="keyword">
|
||||
</label>
|
||||
<div class="quote-toolbar__actions">
|
||||
<button class="quote-icon-button" type="button" title="重置运价筛选" aria-label="重置运价筛选" data-reverse-rate-reset>
|
||||
<span class="material-icons-outlined" aria-hidden="true">filter_alt_off</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quote-results">
|
||||
<span class="quote-results__count" data-reverse-rate-count>共 {{ count($reverseRates) }} 条运价</span>
|
||||
<span class="quote-results__notice">
|
||||
<span class="material-icons-outlined" aria-hidden="true">calculate</span>
|
||||
<span>面单价格与续重/费率直接取自原表单元格,页面不重新折算</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="quote-table-wrap">
|
||||
<table class="quote-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">承运商</th>
|
||||
<th scope="col">出发省份</th>
|
||||
<th scope="col">到达省份</th>
|
||||
<th scope="col">计费方式</th>
|
||||
<th scope="col">面单 / 首重(元/票)</th>
|
||||
<th scope="col">续重 / 重量段费率(元/kg)</th>
|
||||
<th scope="col">重量段</th>
|
||||
<th scope="col">生效日期</th>
|
||||
<th scope="col">来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-reverse-rate-body></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="quote-empty" role="status" data-reverse-rate-empty hidden>
|
||||
<div class="quote-empty__body">
|
||||
<span class="material-icons-outlined" aria-hidden="true">search_off</span>
|
||||
<div class="quote-empty__title">没有符合筛选条件的运价</div>
|
||||
<div class="quote-empty__text">调整承运商、出发省份或到达省份后重试。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quote-workspace">
|
||||
<div class="quote-results">
|
||||
<span class="quote-results__count" data-reverse-result-count>报价来源 · 共 {{ count($reverseQuotes) }} 个版本</span>
|
||||
<span class="quote-results__notice">
|
||||
<span class="material-icons-outlined" aria-hidden="true">verified_user</span>
|
||||
<span>原始报价规则保留,可查看每个工作表的完整单元格</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="quote-table-wrap">
|
||||
<table class="quote-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">报价版本</th>
|
||||
<th scope="col">快递公司</th>
|
||||
<th scope="col">报价类型</th>
|
||||
<th scope="col">原始范围</th>
|
||||
<th scope="col">数据规模</th>
|
||||
<th scope="col">执行日期</th>
|
||||
<th scope="col">签章</th>
|
||||
<th scope="col">资料状态</th>
|
||||
<th scope="col">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-reverse-table-body></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="quote-workspace">
|
||||
<div class="quote-empty" role="status">
|
||||
<div class="quote-empty__body">
|
||||
<span class="material-icons-outlined" aria-hidden="true">assignment_return</span>
|
||||
<div class="quote-empty__title">{{ $reverseQuoteSnapshot['message'] }}</div>
|
||||
<div class="quote-empty__text">导入本机逆向物流报价文件后,会在此显示各承运商的报价版本和运价明细。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($reverseQuoteSnapshot['available'])
|
||||
<div class="quote-modal" role="dialog" aria-modal="true" aria-labelledby="reverse-quote-detail-title" data-reverse-modal>
|
||||
<div class="quote-modal__panel">
|
||||
<header class="quote-modal__header">
|
||||
<div class="quote-modal__title-wrap">
|
||||
<div class="quote-modal__title" id="reverse-quote-detail-title" data-reverse-detail="title"></div>
|
||||
<div class="quote-modal__subtitle">原工作表内容 · 只读</div>
|
||||
</div>
|
||||
<button class="quote-modal__close" type="button" aria-label="关闭报价明细" title="关闭" data-reverse-close>
|
||||
<span class="material-icons-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</header>
|
||||
<dl class="quote-detail-meta">
|
||||
<div><dt>版本号</dt><dd data-reverse-detail="id"></dd></div>
|
||||
<div><dt>快递公司</dt><dd data-reverse-detail="carrier"></dd></div>
|
||||
<div><dt>报价类型</dt><dd data-reverse-detail="type"></dd></div>
|
||||
<div><dt>原始范围</dt><dd data-reverse-detail="range"></dd></div>
|
||||
<div><dt>执行日期</dt><dd data-reverse-detail="date"></dd></div>
|
||||
<div><dt>公式单元格</dt><dd data-reverse-detail="formulas"></dd></div>
|
||||
</dl>
|
||||
<div class="quote-detail-table-wrap">
|
||||
<table class="quote-detail-table" data-reverse-detail-table></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var root = document.querySelector('[data-reverse-logistics-quotes]');
|
||||
|
||||
if (!root || root.dataset.initialized === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
root.dataset.initialized = 'true';
|
||||
|
||||
var quotes = @json($reverseQuotes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
var rates = @json($reverseRates, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
var rules = @json($reverseRules, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
var body = root.querySelector('[data-reverse-table-body]');
|
||||
var rateBody = root.querySelector('[data-reverse-rate-body]');
|
||||
var rateCount = root.querySelector('[data-reverse-rate-count]');
|
||||
var rateEmpty = root.querySelector('[data-reverse-rate-empty]');
|
||||
var rateTableWrap = root.querySelector('[data-reverse-rate-panel] .quote-table-wrap');
|
||||
var rateFilters = {
|
||||
carrier: root.querySelector('[data-reverse-rate-filter="carrier"]'),
|
||||
origin: root.querySelector('[data-reverse-rate-filter="origin"]'),
|
||||
destination: root.querySelector('[data-reverse-rate-filter="destination"]'),
|
||||
keyword: root.querySelector('[data-reverse-rate-filter="keyword"]')
|
||||
};
|
||||
var ruleByCarrier = {};
|
||||
var modal = root.querySelector('[data-reverse-modal]');
|
||||
var lastFocusedElement = null;
|
||||
|
||||
rules.forEach(function (rule) {
|
||||
ruleByCarrier[rule.carrier] = rule;
|
||||
});
|
||||
|
||||
function text(value, fallback) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback || '';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function cellText(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'TRUE' : 'FALSE';
|
||||
}
|
||||
|
||||
return text(value, '');
|
||||
}
|
||||
|
||||
function amountText(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—';
|
||||
}
|
||||
|
||||
var number = Number(value);
|
||||
|
||||
if (!isFinite(number)) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return String(Math.round(number * 10000) / 10000);
|
||||
}
|
||||
|
||||
function create(tag, className, value) {
|
||||
var element = document.createElement(tag);
|
||||
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
|
||||
if (value !== undefined) {
|
||||
element.textContent = value;
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function appendCell(row, value, className) {
|
||||
var cell = create('td', className || '', value);
|
||||
row.appendChild(cell);
|
||||
return cell;
|
||||
}
|
||||
|
||||
function renderRateRow(rate) {
|
||||
var rule = ruleByCarrier[rate.carrier] || {};
|
||||
var row = create('tr');
|
||||
|
||||
appendCell(row, rate.carrier);
|
||||
appendCell(row, rate.origin);
|
||||
appendCell(row, rate.destination);
|
||||
appendCell(row, text(rule.pricing_label, '-'));
|
||||
appendCell(row, amountText(rate.base_amount));
|
||||
appendCell(row, amountText(rate.increment_amount), rate.available ? '' : 'reverse-rate-missing');
|
||||
appendCell(row, text(rate.weight_band, '首重 + 续重'), 'reverse-rate-band');
|
||||
appendCell(row, text(rule.effective_date, '未标注'));
|
||||
appendCell(row, text(rate.source, '-'), 'quote-table__secondary');
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function applyRateFilters() {
|
||||
if (!rateBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
var carrier = rateFilters.carrier.value;
|
||||
var origin = rateFilters.origin.value;
|
||||
var destination = rateFilters.destination.value;
|
||||
var keyword = rateFilters.keyword.value.trim().toLowerCase();
|
||||
var filtered = rates.filter(function (rate) {
|
||||
if (carrier && rate.carrier !== carrier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (origin && rate.origin !== origin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (destination && rate.destination !== destination) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
var haystack = (rate.origin + ' ' + rate.destination + ' ' + rate.carrier + ' ' + text(rate.source, '')).toLowerCase();
|
||||
|
||||
if (haystack.indexOf(keyword) === -1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
rateBody.replaceChildren();
|
||||
filtered.forEach(function (rate) {
|
||||
rateBody.appendChild(renderRateRow(rate));
|
||||
});
|
||||
|
||||
if (rateCount) {
|
||||
rateCount.textContent = '共 ' + filtered.length + ' 条运价(全部 ' + rates.length + ' 条)';
|
||||
}
|
||||
|
||||
if (rateEmpty) {
|
||||
rateEmpty.hidden = filtered.length > 0;
|
||||
}
|
||||
|
||||
if (rateTableWrap) {
|
||||
rateTableWrap.hidden = filtered.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRow(quote) {
|
||||
var row = create('tr');
|
||||
var nameCell = appendCell(row, '');
|
||||
nameCell.appendChild(create('span', 'quote-table__name', quote.id));
|
||||
appendCell(row, quote.company_name);
|
||||
appendCell(row, quote.quote_type);
|
||||
appendCell(row, text(quote.source_range, '-'));
|
||||
appendCell(row, quote.row_count + ' 行 × ' + quote.column_count + ' 列');
|
||||
appendCell(row, text(quote.effective_date, '-'));
|
||||
appendCell(row, quote.signature_status, 'quote-table__secondary');
|
||||
|
||||
var statusCell = appendCell(row, '');
|
||||
statusCell.appendChild(create(
|
||||
'span',
|
||||
'quote-badge ' + (quote.effective_date ? 'quote-badge--ready' : 'quote-badge--attention'),
|
||||
quote.status
|
||||
));
|
||||
|
||||
var actionCell = appendCell(row, '');
|
||||
var action = create('button', 'quote-action', '查看');
|
||||
action.type = 'button';
|
||||
action.dataset.reverseOpen = quote.id;
|
||||
action.setAttribute('aria-label', '查看 ' + quote.sheet_name);
|
||||
actionCell.appendChild(action);
|
||||
return row;
|
||||
}
|
||||
|
||||
function setDetail(name, value) {
|
||||
root.querySelector('[data-reverse-detail="' + name + '"]').textContent = value;
|
||||
}
|
||||
|
||||
function excelColumn(index) {
|
||||
var label = '';
|
||||
var value = index + 1;
|
||||
|
||||
while (value > 0) {
|
||||
value--;
|
||||
label = String.fromCharCode(65 + (value % 26)) + label;
|
||||
value = Math.floor(value / 26);
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
function openDetail(quote, trigger) {
|
||||
lastFocusedElement = trigger;
|
||||
setDetail('title', quote.sheet_name);
|
||||
setDetail('id', quote.id);
|
||||
setDetail('carrier', quote.company_name);
|
||||
setDetail('type', quote.quote_type);
|
||||
setDetail('range', text(quote.source_range, '-'));
|
||||
setDetail('date', text(quote.effective_date, 'Excel 未标注'));
|
||||
setDetail('formulas', String(quote.formula_count));
|
||||
|
||||
var table = root.querySelector('[data-reverse-detail-table]');
|
||||
var head = create('thead');
|
||||
var headerRow = create('tr');
|
||||
headerRow.appendChild(create('th', 'quote-detail-table__row-number', '#'));
|
||||
|
||||
for (var column = 0; column < quote.column_count; column++) {
|
||||
headerRow.appendChild(create('th', '', excelColumn(column)));
|
||||
}
|
||||
|
||||
head.appendChild(headerRow);
|
||||
var tableBody = create('tbody');
|
||||
|
||||
quote.values.forEach(function (values, rowIndex) {
|
||||
var row = create('tr');
|
||||
row.appendChild(create('th', 'quote-detail-table__row-number', String(rowIndex + 1)));
|
||||
|
||||
for (var columnIndex = 0; columnIndex < quote.column_count; columnIndex++) {
|
||||
var cell = create('td', '', cellText(values[columnIndex]));
|
||||
var formula = quote.formulas[rowIndex] && quote.formulas[rowIndex][columnIndex];
|
||||
|
||||
if (formula) {
|
||||
cell.appendChild(create('span', 'quote-detail-table__formula', '=' + String(formula).replace(/^=/, '')));
|
||||
}
|
||||
|
||||
row.appendChild(cell);
|
||||
}
|
||||
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
|
||||
table.replaceChildren(head, tableBody);
|
||||
modal.classList.add('is-open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
root.querySelector('[data-reverse-close]').focus();
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
modal.classList.remove('is-open');
|
||||
document.body.style.overflow = '';
|
||||
|
||||
if (lastFocusedElement) {
|
||||
lastFocusedElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
quotes.forEach(function (quote) {
|
||||
body.appendChild(renderRow(quote));
|
||||
});
|
||||
|
||||
Object.keys(rateFilters).forEach(function (key) {
|
||||
if (!rateFilters[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
rateFilters[key].addEventListener('input', applyRateFilters);
|
||||
rateFilters[key].addEventListener('change', applyRateFilters);
|
||||
});
|
||||
|
||||
var rateReset = root.querySelector('[data-reverse-rate-reset]');
|
||||
|
||||
if (rateReset) {
|
||||
rateReset.addEventListener('click', function () {
|
||||
Object.keys(rateFilters).forEach(function (key) {
|
||||
if (rateFilters[key]) {
|
||||
rateFilters[key].value = '';
|
||||
}
|
||||
});
|
||||
|
||||
applyRateFilters();
|
||||
});
|
||||
}
|
||||
|
||||
var calcFields = {
|
||||
carrier: root.querySelector('[data-reverse-calc="carrier"]'),
|
||||
origin: root.querySelector('[data-reverse-calc="origin"]'),
|
||||
destination: root.querySelector('[data-reverse-calc="destination"]'),
|
||||
weight: root.querySelector('[data-reverse-calc="weight"]'),
|
||||
length: root.querySelector('[data-reverse-calc="length"]'),
|
||||
width: root.querySelector('[data-reverse-calc="width"]'),
|
||||
height: root.querySelector('[data-reverse-calc="height"]')
|
||||
};
|
||||
var calcBody = root.querySelector('[data-reverse-calc-body]');
|
||||
var calcSummary = root.querySelector('[data-reverse-calc-summary]');
|
||||
var calcVolume = root.querySelector('[data-reverse-calc-volume]');
|
||||
var calcCarriers = rules.map(function (rule) { return rule.carrier; });
|
||||
var rateIndex = {};
|
||||
|
||||
rates.forEach(function (rate) {
|
||||
rateIndex[rate.carrier] = rateIndex[rate.carrier] || {};
|
||||
|
||||
var key = rate.origin_key + '|' + rate.destination_key;
|
||||
rateIndex[rate.carrier][key] = rateIndex[rate.carrier][key] || [];
|
||||
rateIndex[rate.carrier][key].push(rate);
|
||||
});
|
||||
|
||||
function calcNumber(field) {
|
||||
if (!field) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var value = parseFloat(field.value);
|
||||
|
||||
return isFinite(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function round2(value) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function pickBand(bucket, band) {
|
||||
for (var index = 0; index < bucket.length; index++) {
|
||||
if (bucket[index].weight_band === band) {
|
||||
return bucket[index];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeFreight(carrier, context) {
|
||||
var rule = ruleByCarrier[carrier] || {};
|
||||
var bucket = (rateIndex[carrier] || {})[context.key] || [];
|
||||
var row = {
|
||||
carrier: carrier,
|
||||
route: context.origin + ' → ' + context.destination,
|
||||
weight: '—',
|
||||
formula: '',
|
||||
total: null,
|
||||
surcharge: 0,
|
||||
notes: []
|
||||
};
|
||||
|
||||
if (!bucket.length) {
|
||||
row.notes.push('原表未覆盖该线路');
|
||||
return row;
|
||||
}
|
||||
|
||||
var rate = bucket[0];
|
||||
var billable = 0;
|
||||
var total = 0;
|
||||
|
||||
if (rule.pricing_mode === 'first_weight_increment') {
|
||||
billable = Math.max(1, Math.ceil(context.chargeable));
|
||||
total = Number(rate.base_amount) + Number(rate.increment_amount) * (billable - 1);
|
||||
row.formula = '面单 ' + rate.base_amount + ' + 续重 ' + rate.increment_amount + ' ×(' + billable + ' − 1)';
|
||||
row.notes.push('续重按结算重量取整(原表备注 3)');
|
||||
} else if (rule.weight_bands && rule.weight_bands.length > 0) {
|
||||
billable = Math.max(15, Math.ceil(context.chargeable));
|
||||
var band = billable > 30 ? '31-60kg' : '0-30kg';
|
||||
rate = pickBand(bucket, band) || rate;
|
||||
|
||||
if (!rate.available) {
|
||||
row.notes.push('该线路重量段无报价');
|
||||
return row;
|
||||
}
|
||||
|
||||
total = Number(rate.base_amount) + Number(rate.increment_amount) * billable;
|
||||
row.formula = '面单 ' + rate.base_amount + ' + 费率 ' + rate.increment_amount + ' × ' + billable + '(' + band + ')';
|
||||
row.notes.push('不足 15kg 按 15kg 计费并向上取整(原表备注 2)');
|
||||
|
||||
if (billable > 60 || context.maxSide > 200) {
|
||||
var surcharge = Math.max(20, billable * 1);
|
||||
row.surcharge = round2(surcharge);
|
||||
row.notes.push('超长超重附加费 ' + round2(surcharge) + ' 元(最长边>2m 或 计费重量>60kg,1 元/kg 最低 20 元)');
|
||||
}
|
||||
} else {
|
||||
billable = round2(context.chargeable);
|
||||
total = Number(rate.base_amount) + Number(rate.increment_amount) * billable;
|
||||
row.formula = '面单 ' + rate.base_amount + ' + 费率 ' + rate.increment_amount + ' × ' + billable;
|
||||
|
||||
if (context.maxSide > 160 || context.sideSum > 250) {
|
||||
row.surcharge = 7;
|
||||
row.notes.push('单边>1.6m 或 三边>2.5m,加收 7 元/单(原表备注 1)');
|
||||
}
|
||||
}
|
||||
|
||||
row.weight = billable;
|
||||
row.total = round2(total);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function calculate() {
|
||||
if (!calcBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
var settlement = calcNumber(calcFields.weight);
|
||||
var length = calcNumber(calcFields.length);
|
||||
var width = calcNumber(calcFields.width);
|
||||
var height = calcNumber(calcFields.height);
|
||||
var hasSize = length > 0 && width > 0 && height > 0;
|
||||
var volumeWeight = hasSize ? (length * width * height / 6000) : 0;
|
||||
var chargeable = Math.max(settlement, volumeWeight);
|
||||
var origin = calcFields.origin.value;
|
||||
var destination = calcFields.destination.value;
|
||||
var selected = calcFields.carrier.value;
|
||||
var context = {
|
||||
key: origin + '|' + destination,
|
||||
origin: origin,
|
||||
destination: destination,
|
||||
settlement: settlement,
|
||||
chargeable: chargeable,
|
||||
maxSide: Math.max(length, width, height),
|
||||
sideSum: length + width + height
|
||||
};
|
||||
var carriers = selected ? [selected] : calcCarriers;
|
||||
var results = carriers.map(function (carrier) { return computeFreight(carrier, context); });
|
||||
var bestTotal = null;
|
||||
var bestCarrier = null;
|
||||
|
||||
results.forEach(function (item) {
|
||||
if (item.total === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var itemTotal = round2(item.total + (item.surcharge || 0));
|
||||
|
||||
if (bestTotal === null || itemTotal < bestTotal) {
|
||||
bestTotal = itemTotal;
|
||||
bestCarrier = item.carrier;
|
||||
}
|
||||
});
|
||||
|
||||
calcBody.replaceChildren();
|
||||
results.forEach(function (item) {
|
||||
var row = create('tr');
|
||||
|
||||
if (item.total !== null && round2(item.total + (item.surcharge || 0)) === bestTotal && carriers.length > 1) {
|
||||
row.className = 'reverse-calc-best';
|
||||
}
|
||||
|
||||
appendCell(row, item.carrier);
|
||||
appendCell(row, item.route);
|
||||
appendCell(row, item.weight === '—' ? '—' : String(item.weight));
|
||||
appendCell(row, item.formula || '—');
|
||||
appendCell(row, item.total === null ? '—' : item.total.toFixed(2));
|
||||
appendCell(row, (item.surcharge || 0) > 0 ? Number(item.surcharge).toFixed(2) : '—');
|
||||
appendCell(row, item.notes.join(';') || '按原表价格计算', 'quote-table__secondary');
|
||||
calcBody.appendChild(row);
|
||||
});
|
||||
|
||||
if (calcVolume) {
|
||||
calcVolume.textContent = hasSize
|
||||
? '体积重 ' + round2(volumeWeight) + ' kg(' + length + '×' + width + '×' + height + '÷6000),结算重量取较大值 ' + round2(chargeable) + ' kg'
|
||||
: '未填写长宽高,按结算重量计算';
|
||||
}
|
||||
|
||||
if (calcSummary) {
|
||||
calcSummary.textContent = bestTotal === null
|
||||
? origin + ' → ' + destination + ':所选承运商无可用报价'
|
||||
: origin + ' → ' + destination + ' 最低总价 ' + bestCarrier + ' ' + bestTotal.toFixed(2) + ' 元(含加收,结算重量 ' + round2(chargeable) + ' kg)';
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(calcFields).forEach(function (key) {
|
||||
if (!calcFields[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
calcFields[key].addEventListener('input', calculate);
|
||||
calcFields[key].addEventListener('change', calculate);
|
||||
});
|
||||
|
||||
var calcReset = root.querySelector('[data-reverse-calc-reset]');
|
||||
|
||||
if (calcReset) {
|
||||
calcReset.addEventListener('click', function () {
|
||||
if (calcFields.carrier) {
|
||||
calcFields.carrier.value = '';
|
||||
}
|
||||
|
||||
if (calcFields.origin) {
|
||||
calcFields.origin.value = '广东';
|
||||
}
|
||||
|
||||
if (calcFields.destination) {
|
||||
calcFields.destination.value = '江苏';
|
||||
}
|
||||
|
||||
if (calcFields.weight) {
|
||||
calcFields.weight.value = '10';
|
||||
}
|
||||
|
||||
['length', 'width', 'height'].forEach(function (key) {
|
||||
if (calcFields[key]) {
|
||||
calcFields[key].value = '';
|
||||
}
|
||||
});
|
||||
|
||||
calculate();
|
||||
});
|
||||
}
|
||||
|
||||
body.addEventListener('click', function (event) {
|
||||
var trigger = event.target.closest('[data-reverse-open]');
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
var quote = quotes.find(function (item) { return item.id === trigger.dataset.reverseOpen; });
|
||||
|
||||
if (quote) {
|
||||
openDetail(quote, trigger);
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelector('[data-reverse-close]').addEventListener('click', closeDetail);
|
||||
modal.addEventListener('click', function (event) {
|
||||
if (event.target === modal) {
|
||||
closeDetail();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape' && modal.classList.contains('is-open')) {
|
||||
closeDetail();
|
||||
}
|
||||
});
|
||||
|
||||
applyRateFilters();
|
||||
calculate();
|
||||
}());
|
||||
</script>
|
||||
@endif
|
||||
</div>
|
||||
@ -201,6 +201,23 @@ Route::get('workspace/courier-payables/shipping-data/export', 'Business\Pages@ex
|
||||
->middleware('business.page:courier-payables,shipping-data')
|
||||
->name('business.courier-payables.shipping-data.export');
|
||||
|
||||
Route::get('workspace/courier-payables/return-pickup-management/export', 'Business\Pages@exportReturnPickupManagement')
|
||||
->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');
|
||||
|
||||
@ -31,7 +31,8 @@ class CourierSurchargePageTest extends FeatureTestCase
|
||||
'page' => 'courier-surcharge',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('附加费报价维护')
|
||||
->assertSeeText('附加费报价')
|
||||
->assertSee('aria-label="附加费报价维护"', false)
|
||||
->assertSeeText('正式维护数据')
|
||||
->assertSeeText('报价规则列表')
|
||||
->assertSeeText('管理附加费类型')
|
||||
|
||||
304
tests/Feature/Business/CourierSurchargeSettlementPageTest.php
Normal file
304
tests/Feature/Business/CourierSurchargeSettlementPageTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Support\QuoteManagementSnapshot;
|
||||
use App\Support\ReverseLogisticsQuoteSnapshot;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
@ -12,6 +13,8 @@ class QuoteManagementPageTest extends FeatureTestCase
|
||||
{
|
||||
private string $fixturePath;
|
||||
|
||||
private string $reverseFixturePath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
@ -66,6 +69,31 @@ class QuoteManagementPageTest extends FeatureTestCase
|
||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
|
||||
app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot($this->fixturePath));
|
||||
|
||||
$this->reverseFixturePath = storage_path('framework/testing/reverse-logistics-' . uniqid('', true) . '.json');
|
||||
file_put_contents($this->reverseFixturePath, json_encode([
|
||||
'sourcePath' => 'C:/private/逆向物流报价.xlsx',
|
||||
'sheets' => [
|
||||
[
|
||||
'index' => 0,
|
||||
'name' => '申通退货',
|
||||
'address' => 'A1:D5',
|
||||
'values' => [
|
||||
['报价单', null, null, null],
|
||||
['出发省份', '到达省份', '江苏', null],
|
||||
[null, null, '面单价格', '续重价格'],
|
||||
[null, '广东', 9, 1.75],
|
||||
['1、该价格不含税,生效日期:2026年5月1日', null, null, null],
|
||||
],
|
||||
'formulas' => [[], [], [], [], []],
|
||||
],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
|
||||
app()->instance(
|
||||
ReverseLogisticsQuoteSnapshot::class,
|
||||
new ReverseLogisticsQuoteSnapshot($this->reverseFixturePath)
|
||||
);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
@ -74,6 +102,10 @@ class QuoteManagementPageTest extends FeatureTestCase
|
||||
unlink($this->fixturePath);
|
||||
}
|
||||
|
||||
if (isset($this->reverseFixturePath) && is_file($this->reverseFixturePath)) {
|
||||
unlink($this->reverseFixturePath);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
@ -90,6 +122,10 @@ class QuoteManagementPageTest extends FeatureTestCase
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('报价管理')
|
||||
->assertSee('data-quote-view="reverse-logistics"', false)
|
||||
->assertSee('data-quote-panel="reverse-logistics"', false)
|
||||
->assertSeeText('计费规则')
|
||||
->assertSee('data-reverse-rate-body', false)
|
||||
->assertSeeText('报价单合并.xlsx')
|
||||
->assertSeeText('报价名称')
|
||||
->assertSeeText('快递公司')
|
||||
@ -122,6 +158,42 @@ class QuoteManagementPageTest extends FeatureTestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function testReverseLogisticsQuoteTabCanBeOpenedDirectly(): void
|
||||
{
|
||||
$courierPayables = collect(config('business_navigation'))
|
||||
->firstWhere('slug', 'courier-payables');
|
||||
|
||||
$this->assertIsArray($courierPayables);
|
||||
$this->assertContains('quote-management', array_column($courierPayables['pages'], 'slug'));
|
||||
$this->assertNotContains('reverse-logistics-quotes', array_column($courierPayables['pages'], 'slug'));
|
||||
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'quote-management',
|
||||
'quote_view' => 'reverse-logistics',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSee('data-quote-view="reverse-logistics" aria-controls="quote-reverse-logistics-panel" aria-selected="true"', false)
|
||||
->assertSee('id="quote-reverse-logistics-panel" role="tabpanel" data-quote-panel="reverse-logistics"', false)
|
||||
->assertSeeText('计费规则')
|
||||
->assertSeeText('申通退货')
|
||||
->assertSee('共 1 条运价', false)
|
||||
->assertSee('首重 + 续重 × (结算重量 - 1)', false)
|
||||
->assertSee('2026-05-01', false)
|
||||
->assertSee('"origin":"广东"', false)
|
||||
->assertSee('"destination":"江苏"', false)
|
||||
->assertSee('"increment_amount":1.75', false)
|
||||
->assertSee('data-reverse-calc-body', false)
|
||||
->assertSeeText('运费计算器')
|
||||
->assertSee('data-reverse-calc="weight"', false)
|
||||
->assertSee('data-reverse-rate-body', false);
|
||||
}
|
||||
|
||||
public function testQuoteManagementPageKeepsTheShellWhenThePrivateSnapshotIsMissing(): void
|
||||
{
|
||||
app()->instance(
|
||||
@ -129,13 +201,20 @@ class QuoteManagementPageTest extends FeatureTestCase
|
||||
new QuoteManagementSnapshot(storage_path('framework/testing/missing-quote-snapshot.json'))
|
||||
);
|
||||
|
||||
app()->instance(
|
||||
ReverseLogisticsQuoteSnapshot::class,
|
||||
new ReverseLogisticsQuoteSnapshot(storage_path('framework/testing/missing-reverse-logistics-snapshot.json'))
|
||||
);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'quote-management',
|
||||
'quote_view' => 'reverse-logistics',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('尚未导入本机报价文件。')
|
||||
->assertSeeText('尚未导入本机逆向物流报价文件。')
|
||||
->assertSeeText('报价页面仍可访问')
|
||||
->assertDontSee('class="business-blank-page"', false);
|
||||
}
|
||||
|
||||
491
tests/Feature/Business/ReturnPickupManagementPageTest.php
Normal file
491
tests/Feature/Business/ReturnPickupManagementPageTest.php
Normal file
@ -0,0 +1,491 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Support\ReverseLogisticsQuoteSnapshot;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\ReturnPickupManagementMirrorRepository;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class ReturnPickupManagementPageTest extends FeatureTestCase
|
||||
{
|
||||
private string $quoteFixturePath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->quoteFixturePath = storage_path('framework/testing/return-pickup-page-quote-' . uniqid('', true) . '.json');
|
||||
file_put_contents($this->quoteFixturePath, json_encode([
|
||||
'sourcePath' => 'C:/private/逆向物流报价.xlsx',
|
||||
'sheets' => [
|
||||
[
|
||||
'index' => 0,
|
||||
'name' => '中通快运逆向',
|
||||
'address' => 'A1:F3',
|
||||
'values' => [
|
||||
['中通快运逆向2026.4.21起', null, null, null, null, null],
|
||||
['省份', '面单费', '省份', '0-30kg', '31-60kg', '备注'],
|
||||
['河南省', 9, '河南省', 1.7, 1.7, '1、目前该报价仅支持常州仓收件地址。'],
|
||||
],
|
||||
'formulas' => [[], [], []],
|
||||
],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
|
||||
$this->app->instance(
|
||||
ReverseLogisticsQuoteSnapshot::class,
|
||||
new ReverseLogisticsQuoteSnapshot($this->quoteFixturePath)
|
||||
);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (isset($this->quoteFixturePath) && is_file($this->quoteFixturePath)) {
|
||||
unlink($this->quoteFixturePath);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_page_is_registered_in_courier_payables_navigation(): void
|
||||
{
|
||||
$courierPayables = collect(config('business_navigation'))
|
||||
->firstWhere('slug', 'courier-payables');
|
||||
|
||||
$this->assertIsArray($courierPayables);
|
||||
$this->assertContains('return-pickup-management', array_column($courierPayables['pages'], 'slug'));
|
||||
$this->assertSame(
|
||||
'退货取件管理',
|
||||
collect($courierPayables['pages'])->firstWhere('slug', 'return-pickup-management')['title']
|
||||
);
|
||||
}
|
||||
|
||||
public function test_page_explains_when_return_pickup_mirror_is_not_available(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$repository = Mockery::mock(ReturnPickupManagementMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnFalse();
|
||||
$repository->shouldNotReceive('state');
|
||||
$repository->shouldNotReceive('syncStatus');
|
||||
$repository->shouldNotReceive('ready');
|
||||
$repository->shouldNotReceive('paginate');
|
||||
$this->app->instance(ReturnPickupManagementMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'return-pickup-management',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('退货取件管理')
|
||||
->assertSeeText('共享业务镜像尚未建立,退货取件管理暂时无法读取。')
|
||||
->assertSeeText('创建时间从')
|
||||
->assertSeeText('快递公司')
|
||||
->assertSeeText('订单状态')
|
||||
->assertSeeText('实退快递单号');
|
||||
}
|
||||
|
||||
public function test_page_renders_mirrored_rows_filters_and_detail(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$repository = Mockery::mock(ReturnPickupManagementMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('state')->once()->andReturn([
|
||||
'status' => 'succeeded',
|
||||
'initial_completed_at' => '2026-10-02 16:49:22',
|
||||
'last_succeeded_at' => '2026-10-02 16:49:22',
|
||||
'mirror_row_count' => 296707,
|
||||
]);
|
||||
$repository->shouldReceive('syncStatus')->once()->andReturn(['code' => 'healthy', 'lag_minutes' => 5]);
|
||||
$repository->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('paginate')
|
||||
->once()
|
||||
->withArgs(function (array $filters, int $page, int $limit): bool {
|
||||
return $filters['carrier'] === '中通快运'
|
||||
&& $filters['keyword'] === 'E202610020090'
|
||||
&& $filters['created_from'] === '2026-10-01'
|
||||
&& $page === 1
|
||||
&& $limit > 0;
|
||||
})
|
||||
->andReturn([
|
||||
'data' => [(object) [
|
||||
'Id' => 307390,
|
||||
'orderId' => 'E202610020090',
|
||||
'express_company' => '中通快运',
|
||||
'urgencyLevel' => '正常',
|
||||
'expectedPickupTime' => '2026-10-02 18:00:00',
|
||||
'orderStatus' => 'WaitExpOrder',
|
||||
'returnExpressNo' => '',
|
||||
'paymentMethod' => '月结',
|
||||
'pickupType' => '有主取件',
|
||||
'shopName' => '天猫-欧意朗旗舰店',
|
||||
'transactionNo' => '3316448031005001855',
|
||||
'totalReturnQuantity' => 1,
|
||||
'senderName' => '张三',
|
||||
'province' => '河南省',
|
||||
'expressFollowStatus' => '待跟进',
|
||||
'expressFollowResult' => '',
|
||||
'createTime' => '2026-10-02 16:48:07',
|
||||
]],
|
||||
'total' => 1,
|
||||
]);
|
||||
$repository->shouldReceive('carriers')->once()->andReturn(['中通快运', '速寄递']);
|
||||
$repository->shouldReceive('statuses')->once()->andReturn(['WaitExpOrder', 'ExpCanceled']);
|
||||
$repository->shouldReceive('warehouseDirectory')->once()->andReturn(['15444120' => '简尚-POP仓']);
|
||||
$repository->shouldReceive('detail')->once()->withArgs(
|
||||
static fn (int $id): bool => $id === 307390
|
||||
)->andReturn([
|
||||
'main' => (object) [
|
||||
'Id' => 307390,
|
||||
'orderId' => 'E202610020090',
|
||||
'express_company' => '中通快运',
|
||||
'orderStatus' => 'WaitExpOrder',
|
||||
'urgencyLevel' => '正常',
|
||||
'shopName' => '天猫-欧意朗旗舰店',
|
||||
'transactionNo' => '3316448031005001855',
|
||||
'totalReturnQuantity' => 1,
|
||||
'returnExpressNo' => '',
|
||||
'paymentMethod' => '月结',
|
||||
'pickupType' => '有主取件',
|
||||
'expectedPickupTime' => '2026-10-02 18:00:00',
|
||||
'expressOrderTime' => '2026-10-02 17:00:00',
|
||||
'expressPickupTime' => null,
|
||||
'senderName' => '张三',
|
||||
'senderPhone' => '13800000000',
|
||||
'province' => '河南省',
|
||||
'city' => '洛阳市',
|
||||
'district' => '栾川县',
|
||||
'street' => '',
|
||||
'address' => '庙子镇某处',
|
||||
'issueType' => '',
|
||||
'issueDescription' => '',
|
||||
'expressFollowStatus' => '待跟进',
|
||||
'expressFollowResult' => '',
|
||||
'expressFollowDescription' => '已与客户确认上门时间',
|
||||
'note' => '客户要求下午上门',
|
||||
'createTime' => '2026-10-02 16:48:07',
|
||||
'updateTime' => '2026-10-02 16:48:07',
|
||||
'creator' => '白沙',
|
||||
'canceler' => '',
|
||||
],
|
||||
'items' => [(object) [
|
||||
'id' => 388432,
|
||||
'returnProcode' => 'SG007027',
|
||||
'actualReturnProcode' => 'SF-RMA-0001',
|
||||
'barcode' => 'SG007027N223V01',
|
||||
'productName' => '住宅家具 - 书柜',
|
||||
'platformProductId' => '3316448031005001855',
|
||||
'platformSkuId' => '4200000000999',
|
||||
'returnQuantity' => 1,
|
||||
'actualReturnQuantity' => 0,
|
||||
'unverifiedQuantity' => 1,
|
||||
'returnProgress' => '在途',
|
||||
'warehouse' => '15444120',
|
||||
'receiverName' => '逆向物流萧十一郎',
|
||||
'receiverPhone' => '15370596225',
|
||||
'receiverAddress' => '江苏省宿迁市沭阳县某处',
|
||||
'length' => 93,
|
||||
'width' => 13,
|
||||
'height' => 13,
|
||||
'grossWeight' => 18.2,
|
||||
'estimatedFreight' => 39.94,
|
||||
'actualReturnOrderNo' => '',
|
||||
]],
|
||||
'truth_numbers' => ['SF1234567890'],
|
||||
]);
|
||||
$this->app->instance(ReturnPickupManagementMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'return-pickup-management',
|
||||
'carrier' => '中通快运',
|
||||
'keyword' => 'E202610020090',
|
||||
'created_from' => '2026-10-01',
|
||||
'pickup_id' => 307390,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('同步正常')
|
||||
->assertSeeText('待快递下单')
|
||||
->assertSeeText('E202610020090')
|
||||
->assertSeeText('天猫-欧意朗旗舰店')
|
||||
->assertSeeText('第 1 / 1 页,本页 1 条,共 1 条')
|
||||
->assertSeeText('退货取件单详情')
|
||||
->assertSeeText('SG007027')
|
||||
->assertSeeText('SF-RMA-0001')
|
||||
->assertSeeText('3316448031005001855')
|
||||
->assertSeeText('4200000000999')
|
||||
->assertSeeText('平台商品id')
|
||||
->assertSeeText('平台skuid')
|
||||
->assertSeeText('实退货号')
|
||||
->assertSeeText('简尚-POP仓')
|
||||
->assertSeeText('总件数')
|
||||
->assertSeeText('已与客户确认上门时间')
|
||||
->assertSeeText('客户要求下午上门')
|
||||
->assertSeeText('长')
|
||||
->assertSeeText('93.00')
|
||||
->assertSeeText('13.00')
|
||||
->assertSeeText('明细退货进度')
|
||||
->assertSeeText('住宅家具 - 书柜')
|
||||
->assertSeeText('39.94')
|
||||
->assertSeeText('实际运费(元)')
|
||||
->assertSeeText('未算出原因')
|
||||
->assertSeeText('加收(元)')
|
||||
->assertSeeText('差异(元)')
|
||||
->assertSeeText('41.30')
|
||||
->assertSeeText('-1.36')
|
||||
->assertSeeText('重量取大(kg)')
|
||||
->assertSeeText('计费重量(kg)')
|
||||
->assertSeeText('面单(元)')
|
||||
->assertSeeText('费率(元/kg)')
|
||||
->assertSeeText('18.20')
|
||||
->assertSeeText('19.00')
|
||||
->assertSeeText('9.00')
|
||||
->assertSeeText('1.70')
|
||||
->assertSeeText('SF1234567890')
|
||||
->assertSee('value="中通快运" selected', false)
|
||||
->assertSee('value="2026-10-01"', false);
|
||||
}
|
||||
|
||||
public function test_detail_shows_why_actual_freight_cannot_be_calculated(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$repository = Mockery::mock(ReturnPickupManagementMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('state')->once()->andReturn([
|
||||
'status' => 'succeeded',
|
||||
'initial_completed_at' => '2026-10-02 16:49:22',
|
||||
'last_succeeded_at' => '2026-10-02 16:49:22',
|
||||
]);
|
||||
$repository->shouldReceive('syncStatus')->once()->andReturn(['code' => 'healthy', 'lag_minutes' => 5]);
|
||||
$repository->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('paginate')->once()->andReturn(['data' => [], 'total' => 0]);
|
||||
$repository->shouldReceive('carriers')->once()->andReturn([]);
|
||||
$repository->shouldReceive('statuses')->once()->andReturn([]);
|
||||
$repository->shouldReceive('warehouseDirectory')->once()->andReturn([]);
|
||||
$repository->shouldReceive('detail')->once()->andReturn([
|
||||
'main' => (object) [
|
||||
'Id' => 900001,
|
||||
'orderId' => 'E202609130088',
|
||||
'express_company' => '百世',
|
||||
'orderStatus' => 'WaitExpOrder',
|
||||
'urgencyLevel' => '正常',
|
||||
'shopName' => '天猫-欧意朗旗舰店',
|
||||
'transactionNo' => '3316448031005001999',
|
||||
'returnExpressNo' => '',
|
||||
'paymentMethod' => '月结',
|
||||
'pickupType' => '有主取件',
|
||||
'expectedPickupTime' => null,
|
||||
'expressOrderTime' => null,
|
||||
'expressPickupTime' => null,
|
||||
'senderName' => '张三',
|
||||
'senderPhone' => '13800000000',
|
||||
'province' => '福建省',
|
||||
'city' => '泉州市',
|
||||
'district' => '',
|
||||
'street' => '',
|
||||
'address' => '某处',
|
||||
'issueType' => '',
|
||||
'issueDescription' => '',
|
||||
'expressFollowStatus' => '',
|
||||
'expressFollowResult' => '',
|
||||
'expressFollowDescription' => '',
|
||||
'note' => '',
|
||||
'totalReturnQuantity' => 1,
|
||||
'createTime' => '2026-09-13 10:00:00',
|
||||
'updateTime' => '2026-09-13 10:00:00',
|
||||
'creator' => '白沙',
|
||||
'canceler' => '',
|
||||
],
|
||||
'items' => [(object) [
|
||||
'id' => 1,
|
||||
'returnProcode' => 'CJ002318',
|
||||
'actualReturnProcode' => '',
|
||||
'barcode' => 'CJ002318N1',
|
||||
'productName' => '住宅家具 - 床',
|
||||
'platformProductId' => '3316448031005001999',
|
||||
'platformSkuId' => '4200000001001',
|
||||
'returnQuantity' => 1,
|
||||
'actualReturnQuantity' => null,
|
||||
'unverifiedQuantity' => null,
|
||||
'returnProgress' => '在途',
|
||||
'warehouse' => '',
|
||||
'receiverName' => '逆向物流萧十一郎',
|
||||
'receiverPhone' => '15370596225',
|
||||
'receiverAddress' => '江苏省常州市某处',
|
||||
'grossWeight' => 3.0,
|
||||
'length' => 87,
|
||||
'width' => 46,
|
||||
'height' => 6,
|
||||
'estimatedFreight' => null,
|
||||
'actualReturnOrderNo' => '',
|
||||
]],
|
||||
'truth_numbers' => [],
|
||||
]);
|
||||
$this->app->instance(ReturnPickupManagementMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'return-pickup-management',
|
||||
'pickup_id' => 900001,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('未算出原因')
|
||||
->assertSeeText('该快递公司不在逆向物流报价范围内');
|
||||
}
|
||||
|
||||
public function test_export_streams_filtered_rows_with_actual_freight_and_difference(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$repository = Mockery::mock(ReturnPickupManagementMirrorRepository::class);
|
||||
$repository->shouldReceive('exportRows')
|
||||
->once()
|
||||
->withArgs(static function (array $filters): bool {
|
||||
return ($filters['carrier'] ?? '') === '中通快运';
|
||||
})
|
||||
->andReturn((function (): \Generator {
|
||||
yield (object) [
|
||||
'orderId' => 'E202610020090',
|
||||
'express_company' => '中通快运',
|
||||
'orderStatus' => 'WaitExpOrder',
|
||||
'urgencyLevel' => '正常',
|
||||
'shopName' => '天猫-欧意朗旗舰店',
|
||||
'transactionNo' => '3316448031005001855',
|
||||
'province' => '河南省',
|
||||
'createTime' => '2026-10-02 16:48:07',
|
||||
'updateTime' => '2026-10-02 17:30:00.000000',
|
||||
'expectedPickupTime' => '2026-10-02 18:00:00.000000',
|
||||
'paymentMethod' => '月结',
|
||||
'pickupType' => '有主取件',
|
||||
'totalReturnQuantity' => 1,
|
||||
'note' => '客户要求下午上门',
|
||||
'issueType' => '退货',
|
||||
'issueDescription' => '少发配件',
|
||||
'expressFollowStatus' => '待跟进',
|
||||
'expressFollowResult' => '已联系',
|
||||
'expressFollowDescription' => '已与客户确认上门时间',
|
||||
'expressOrderTime' => '2026-10-02 17:00:00.000000',
|
||||
'expressPickupTime' => '2026-10-02 18:10:00.000000',
|
||||
'creator' => '白沙',
|
||||
'canceler' => '张三',
|
||||
'returnProcode' => 'SG007027',
|
||||
'actualReturnProcode' => 'SF-RMA-0001',
|
||||
'barcode' => 'SG007027N223V01',
|
||||
'productName' => '住宅家具 - 书柜',
|
||||
'platformProductId' => '3316448031005001855',
|
||||
'platformSkuId' => '4200000000999',
|
||||
'returnQuantity' => 1,
|
||||
'actualReturnQuantity' => 0,
|
||||
'unverifiedQuantity' => 1,
|
||||
'returnProgress' => '在途',
|
||||
'warehouse' => '15444120',
|
||||
'receiverName' => '逆向物流萧十一郎',
|
||||
'receiverPhone' => '15370596225',
|
||||
'receiverAddress' => '江苏省常州市某处',
|
||||
'actualReturnOrderNo' => '',
|
||||
'grossWeight' => 18.2,
|
||||
'length' => 210,
|
||||
'width' => 13,
|
||||
'height' => 13,
|
||||
'estimatedFreight' => 39.94,
|
||||
'returnExpressNo' => '',
|
||||
];
|
||||
})());
|
||||
$repository->shouldReceive('warehouseDirectory')->once()->andReturn(['15444120' => '简尚-POP仓']);
|
||||
$this->app->instance(ReturnPickupManagementMirrorRepository::class, $repository);
|
||||
|
||||
$response = $this->loginAs()->get(route('business.courier-payables.return-pickup-management.export', [
|
||||
'carrier' => '中通快运',
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertStringContainsString(
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
(string) $response->headers->get('content-type')
|
||||
);
|
||||
$this->assertStringContainsString('.xlsx', (string) $response->headers->get('content-disposition'));
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'return_pickup_export_');
|
||||
file_put_contents($path, $response->streamedContent());
|
||||
$sheet = IOFactory::load($path)->getActiveSheet();
|
||||
|
||||
$headers = [];
|
||||
$plain = static fn (mixed $cellValue): mixed => $cellValue instanceof \PhpOffice\PhpSpreadsheet\RichText\RichText
|
||||
? $cellValue->getPlainText()
|
||||
: $cellValue;
|
||||
|
||||
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());
|
||||
$type = static fn (string $label): string => $sheet->getCell($column($label) . '2')->getDataType();
|
||||
|
||||
try {
|
||||
$this->assertSame('订单号', $headers['A']);
|
||||
$this->assertSame('E202610020090', $value('订单号'));
|
||||
$this->assertSame('待快递下单', $value('订单状态'));
|
||||
$this->assertSame('3316448031005001855', $value('交易单号'));
|
||||
$this->assertSame('SG007027', $value('应退货号'));
|
||||
$this->assertSame('SF-RMA-0001', $value('实退货号'));
|
||||
$this->assertSame('3316448031005001855', $value('平台商品id'));
|
||||
$this->assertSame('4200000000999', $value('平台skuid'));
|
||||
$this->assertSame(1, $value('应退数量'));
|
||||
$this->assertSame(0, $value('实退数量'));
|
||||
$this->assertSame(1, $value('待核销数量'));
|
||||
$this->assertSame(1, $value('总件数'));
|
||||
$this->assertSame('简尚-POP仓', $value('发货仓'));
|
||||
$this->assertEquals(210.0, $value('长'));
|
||||
$this->assertEquals(13.0, $value('宽'));
|
||||
$this->assertEquals(13.0, $value('高'));
|
||||
$this->assertSame('2026-10-02 18:00:00', $value('预计取件时间'));
|
||||
$this->assertSame('待跟进(已联系)', $value('退货进度'));
|
||||
$this->assertSame('月结', $value('付款方式'));
|
||||
$this->assertSame('有主取件', $value('取件类型'));
|
||||
$this->assertSame('客户要求下午上门', $value('备注'));
|
||||
$this->assertSame('退货', $value('问题类型'));
|
||||
$this->assertSame('少发配件', $value('问题描述'));
|
||||
$this->assertSame('待跟进', $value('问题跟进状态'));
|
||||
$this->assertSame('已联系', $value('问题跟进结果'));
|
||||
$this->assertSame('已与客户确认上门时间', $value('问题跟进说明'));
|
||||
$this->assertSame('2026-10-02 17:00:00', $value('快递下单时间'));
|
||||
$this->assertSame('2026-10-02 18:10:00', $value('快递取件时间'));
|
||||
$this->assertSame('白沙', $value('创建人'));
|
||||
$this->assertSame('张三', $value('取消人'));
|
||||
$this->assertSame('2026-10-02 16:48:07', $value('创建时间'));
|
||||
$this->assertSame('2026-10-02 17:30:00', $value('更新时间'));
|
||||
$this->assertContains('明细退货进度', $headers);
|
||||
$this->assertContains('平台skuid', $headers);
|
||||
$this->assertEquals(39.94, $value('预估运费(元)'));
|
||||
$this->assertEquals(41.3, $value('实际运费(元)'));
|
||||
$this->assertEquals(20.0, $value('加收(元)'));
|
||||
$this->assertEquals(-1.36, $value('差异(元)'));
|
||||
$this->assertEquals(18.2, $value('毛重(kg)'));
|
||||
$this->assertEquals(9.0, $value('面单(元)'));
|
||||
$this->assertEquals(1.7, $value('费率(元/kg)'));
|
||||
$this->assertSame('inlineStr', $type('交易单号'));
|
||||
$this->assertSame('inlineStr', $type('平台商品id'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
155
tests/Unit/Business/ReturnPickupFreightCalculatorTest.php
Normal file
155
tests/Unit/Business/ReturnPickupFreightCalculatorTest.php
Normal file
@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Business;
|
||||
|
||||
use App\Support\ReturnPickupFreightCalculator;
|
||||
use App\Support\ReverseLogisticsQuoteSnapshot;
|
||||
use Tests\TestCase;
|
||||
|
||||
/** 退货取件实际运费:按逆向物流报价的三家口径计算,并给出差异依据。 */
|
||||
final class ReturnPickupFreightCalculatorTest extends TestCase
|
||||
{
|
||||
private string $fixturePath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->fixturePath = storage_path('framework/testing/return-pickup-freight-' . uniqid('', true) . '.json');
|
||||
file_put_contents($this->fixturePath, json_encode([
|
||||
'sourcePath' => 'C:/private/逆向物流报价.xlsx',
|
||||
'sheets' => [
|
||||
[
|
||||
'index' => 0,
|
||||
'name' => '申通退货',
|
||||
'address' => 'A1:F5',
|
||||
'values' => [
|
||||
['报价单', null, null, null, null, null],
|
||||
['出发省份', '到达省份', '江苏', null, '山东', null],
|
||||
[null, null, '面单价格', '续重价格', '面单价格', '续重价格'],
|
||||
[null, '江苏', 9, 1, 9, 1.75],
|
||||
[null, '湖南', 9, 1.75, 9, 1.75],
|
||||
['1、该价格不含税,生效日期:2026年5月1日', null, null, null, null, null],
|
||||
],
|
||||
'formulas' => [[], [], [], [], [], []],
|
||||
],
|
||||
[
|
||||
'index' => 1,
|
||||
'name' => '常州德邦逆向',
|
||||
'address' => 'A1:D5',
|
||||
'values' => [
|
||||
['常州德邦逆向报价2026.4.7', null, null, null],
|
||||
['出发省份', '到达省份江苏', null, '备注:'],
|
||||
[null, '面单价格', '续重价格', null],
|
||||
['江苏', 9, 1, '1、此价格单件限重60KG以内 单边超1.6米或三边超2.5米加收7元每单'],
|
||||
['浙江', 9, 1, '6.计算方式:面单+重量*费率'],
|
||||
],
|
||||
'formulas' => [[], [], [], [], []],
|
||||
],
|
||||
[
|
||||
'index' => 2,
|
||||
'name' => '中通快运逆向',
|
||||
'address' => 'A1:F4',
|
||||
'values' => [
|
||||
['中通快运逆向2026.4.21起', null, null, null, null, null],
|
||||
['省份', '面单费', '省份', '0-30kg', '31-60kg', '备注'],
|
||||
['河南省', 9, '河南省', 1.7, 1.7, '1、目前该报价仅支持常州仓收件地址。'],
|
||||
['浙江省', 9, '浙江省', 0.95, 0.95, null],
|
||||
['新疆', '/', '新疆', '/', '/', null],
|
||||
],
|
||||
'formulas' => [[], [], [], [], [], []],
|
||||
],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (isset($this->fixturePath) && is_file($this->fixturePath)) {
|
||||
unlink($this->fixturePath);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_it_prices_each_carrier_with_its_own_rule(): void
|
||||
{
|
||||
$calculator = new ReturnPickupFreightCalculator(new ReverseLogisticsQuoteSnapshot($this->fixturePath));
|
||||
|
||||
// 速寄递 = 申通退货:首重 + 续重 × (计费重量 - 1),续重取整。
|
||||
$shentong = $calculator->calculate('速寄递', '湖南省', '江苏省宿迁市沭阳县某处', 13.4, 93, 13, 13);
|
||||
$this->assertTrue($shentong['covered']);
|
||||
$this->assertSame(14.0, $shentong['billable']);
|
||||
$this->assertSame(31.75, $shentong['total']);
|
||||
$this->assertSame('面单 9 + 续重 1.75 ×(14 − 1)', $shentong['formula']);
|
||||
|
||||
// 德邦:面单 + 费率 × 重量,不取整,不加 15kg 下限。
|
||||
$deppon = $calculator->calculate('德邦', '浙江', '江苏省常州市某处', 8.7, 116, 35, 11);
|
||||
$this->assertTrue($deppon['covered']);
|
||||
$this->assertSame(8.7, $deppon['billable']);
|
||||
$this->assertSame(17.7, $deppon['total']);
|
||||
|
||||
// 德邦超长/超三边:加收单列 7 元,不并入运费;体积重 170×35×11÷6000 = 10.91kg 大于毛重。
|
||||
$depponOversize = $calculator->calculate('德邦', '浙江', '江苏省常州市某处', 8.7, 170, 35, 11);
|
||||
$this->assertSame(10.91, $depponOversize['billable']);
|
||||
$this->assertSame(19.91, $depponOversize['total']);
|
||||
$this->assertSame(7.0, $depponOversize['surcharge']);
|
||||
$this->assertSame(26.91, $depponOversize['total_with_surcharge']);
|
||||
$this->assertStringContainsString('加收 7 元/单', (string) $depponOversize['surcharge_note']);
|
||||
|
||||
// 中通快运:面单 + 重量段费率 × 计费重量(不足 15kg 按 15kg,向上取整)。
|
||||
$zto = $calculator->calculate('中通快运', '浙江省', '江苏省常州市某处', 39.1, 112, 45, 24);
|
||||
$this->assertTrue($zto['covered']);
|
||||
$this->assertSame(40.0, $zto['billable']);
|
||||
$this->assertSame(47.0, $zto['total']);
|
||||
$this->assertSame(0.0, $zto['surcharge']);
|
||||
$this->assertSame('面单 9 + 费率 0.95 × 40(31-60kg)', $zto['formula']);
|
||||
$this->assertStringContainsString('重量取大:max(毛重 39.1kg,体积重 20.16kg) = 39.1kg', (string) $zto['process']);
|
||||
$this->assertStringContainsString('向上取整:39.1kg → 40kg', (string) $zto['process']);
|
||||
$this->assertStringContainsString('面单 9 元 + 费率 0.95 元/kg × 40kg(31-60kg)= 47 元', (string) $zto['process']);
|
||||
|
||||
// 中通快运超长/超重:基础运费与加收分开。
|
||||
$ztoOversize = $calculator->calculate('中通快运', '浙江省', '江苏省常州市某处', 70, 210, 45, 24);
|
||||
$this->assertSame(70.0, $ztoOversize['billable']);
|
||||
$this->assertSame(75.5, $ztoOversize['total']);
|
||||
$this->assertSame(70.0, $ztoOversize['surcharge']);
|
||||
$this->assertSame(145.5, $ztoOversize['total_with_surcharge']);
|
||||
$this->assertStringContainsString('加收单列:70 元', (string) $ztoOversize['process']);
|
||||
}
|
||||
|
||||
public function test_it_uses_volume_weight_and_reports_uncovered_routes(): void
|
||||
{
|
||||
$calculator = new ReturnPickupFreightCalculator(new ReverseLogisticsQuoteSnapshot($this->fixturePath));
|
||||
|
||||
// 体积重 60×50×40÷6000 = 20kg > 毛重 10kg。
|
||||
$volume = $calculator->calculate('速寄递', '湖南', '江苏省南京市某处', 10, 60, 50, 40);
|
||||
$this->assertSame(20.0, $volume['settlement']);
|
||||
$this->assertSame(20.0, $volume['billable']);
|
||||
$this->assertSame(42.25, $volume['total']);
|
||||
|
||||
// 中通快运新疆无报价。
|
||||
$xinjiang = $calculator->calculate('中通快运', '新疆', '江苏省常州市某处', 20, 50, 40, 30);
|
||||
$this->assertFalse($xinjiang['covered']);
|
||||
$this->assertNull($xinjiang['total']);
|
||||
$this->assertStringContainsString('无报价', (string) $xinjiang['reason']);
|
||||
|
||||
// 德邦只覆盖江苏到达,山东线路不覆盖。
|
||||
$depponShandong = $calculator->calculate('德邦', '浙江', '山东省济南市某处', 10, 50, 40, 30);
|
||||
$this->assertFalse($depponShandong['covered']);
|
||||
$this->assertStringContainsString('未覆盖', (string) $depponShandong['reason']);
|
||||
|
||||
// 不在报价范围内的快递公司。
|
||||
$other = $calculator->calculate('顺心捷达', '浙江', '江苏省南京市某处', 10, 50, 40, 30);
|
||||
$this->assertFalse($other['covered']);
|
||||
$this->assertStringContainsString('不在逆向物流报价范围内', (string) $other['reason']);
|
||||
}
|
||||
|
||||
public function test_it_parses_destination_province_from_address(): void
|
||||
{
|
||||
$this->assertSame('江苏', ReturnPickupFreightCalculator::destinationProvince('江苏省宿迁市沭阳县某处'));
|
||||
$this->assertSame('内蒙古', ReverseLogisticsQuoteSnapshot::provinceKey(
|
||||
ReturnPickupFreightCalculator::destinationProvince('内蒙古自治区呼和浩特市某处')
|
||||
));
|
||||
$this->assertSame('', ReturnPickupFreightCalculator::destinationProvince('某处'));
|
||||
}
|
||||
}
|
||||
155
tests/Unit/Business/ReverseLogisticsQuoteSnapshotTest.php
Normal file
155
tests/Unit/Business/ReverseLogisticsQuoteSnapshotTest.php
Normal file
@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Business;
|
||||
|
||||
use App\Support\ReverseLogisticsQuoteSnapshot;
|
||||
use Tests\TestCase;
|
||||
|
||||
/** 逆向物流报价拆分:矩阵、单一到达省、重量段三种版式都要还原成可计算运价。 */
|
||||
final class ReverseLogisticsQuoteSnapshotTest extends TestCase
|
||||
{
|
||||
private string $fixturePath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->fixturePath = storage_path('framework/testing/reverse-logistics-' . uniqid('', true) . '.json');
|
||||
file_put_contents($this->fixturePath, json_encode([
|
||||
'sourcePath' => 'C:/private/逆向物流报价.xlsx',
|
||||
'generatedAt' => '2026-09-29 12:00:00',
|
||||
'sheets' => [
|
||||
[
|
||||
'index' => 0,
|
||||
'name' => '申通退货',
|
||||
'address' => 'A1:F8',
|
||||
'values' => [
|
||||
['报价单', null, null, null, null, null],
|
||||
['出发省份', '到达省份', '江苏', null, '安徽', null],
|
||||
[null, null, '面单价格', '续重价格', '面单价格', '续重价格'],
|
||||
[null, '江苏', 9, 1, 9, 1],
|
||||
[null, '广东', 9, 1.75, 9, 1.75],
|
||||
['1、该价格不含税,生效日期:2026年5月1日', null, null, null, null, null],
|
||||
['4、计泡标准:长*宽*高/6000', null, null, null, null, null],
|
||||
['7、结算方式:总价=首重+续重*(货物结算重量-1)', null, null, null, null, null],
|
||||
],
|
||||
'formulas' => [[], [], [], [], []],
|
||||
],
|
||||
[
|
||||
'index' => 1,
|
||||
'name' => '常州德邦逆向',
|
||||
'address' => 'A1:D5',
|
||||
'values' => [
|
||||
['常州德邦逆向报价2026.4.7', null, null, null],
|
||||
['出发省份', '到达省份江苏', null, '备注:'],
|
||||
[null, '面单价格', '续重价格', null],
|
||||
['江苏', 9, 1, '1、此价格单件限重60KG以内 单边超1.6米或三边超2.5米加收7元每单'],
|
||||
['新疆', 18, 5.5, '6.计算方式:面单+重量*费率'],
|
||||
],
|
||||
'formulas' => [[], [], [], [], []],
|
||||
],
|
||||
[
|
||||
'index' => 2,
|
||||
'name' => '中通快运逆向',
|
||||
'address' => 'A1:F4',
|
||||
'values' => [
|
||||
['中通快运逆向2026.4.21起', null, null, null, null, null],
|
||||
['省份', '面单费', '省份', '0-30kg', '31-60kg', '备注'],
|
||||
['安徽省', 9, '安徽省', 0.95, 0.95, '1、目前该报价仅支持常州仓收件地址。'],
|
||||
['新疆', '/', '新疆', '/', '/', null],
|
||||
],
|
||||
'formulas' => [[], [], [], []],
|
||||
],
|
||||
[
|
||||
'index' => 3,
|
||||
'name' => '未识别报价表',
|
||||
'address' => 'A1:B2',
|
||||
'values' => [['随便', '什么'], ['没有报价结构', null]],
|
||||
'formulas' => [[], []],
|
||||
],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (isset($this->fixturePath) && is_file($this->fixturePath)) {
|
||||
unlink($this->fixturePath);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testItSplitsEverySupportedSheetLayoutIntoRates(): void
|
||||
{
|
||||
$snapshot = (new ReverseLogisticsQuoteSnapshot($this->fixturePath))->load();
|
||||
|
||||
$this->assertTrue($snapshot['available']);
|
||||
$this->assertSame(['未识别报价表'], $snapshot['unparsed_sheets']);
|
||||
$this->assertSame(['申通', '德邦', '中通快运'], array_column($snapshot['rate_groups'], 'carrier'));
|
||||
$this->assertSame(10, $snapshot['rate_summary']['rate_count']);
|
||||
$this->assertSame(8, $snapshot['rate_summary']['available_count']);
|
||||
$this->assertSame(2, $snapshot['rate_summary']['missing_count']);
|
||||
$this->assertSame(2, $snapshot['rate_summary']['weight_band_count']);
|
||||
$this->assertSame(['江苏', '广东', '新疆', '安徽'], $snapshot['provinces']['origins']);
|
||||
$this->assertSame(['江苏', '安徽'], $snapshot['provinces']['destinations']);
|
||||
|
||||
$rules = collect($snapshot['rate_groups'])->keyBy('carrier');
|
||||
|
||||
$this->assertSame(4, $rules['申通']['rate_count']);
|
||||
$this->assertSame('2026-05-01', $rules['申通']['effective_date']);
|
||||
$this->assertSame('首重 + 续重 × (结算重量 - 1)', $rules['申通']['pricing_label']);
|
||||
$this->assertSame('行=出发省份,列=到达省份(2 个到达省)', $rules['申通']['route_label']);
|
||||
$this->assertContains('4、计泡标准:长*宽*高/6000', $rules['申通']['constraints']);
|
||||
|
||||
$this->assertSame(2, $rules['德邦']['rate_count']);
|
||||
$this->assertSame('2026-04-07', $rules['德邦']['effective_date']);
|
||||
$this->assertSame('面单 + 重量 × 费率', $rules['德邦']['pricing_label']);
|
||||
|
||||
$this->assertSame(4, $rules['中通快运']['rate_count']);
|
||||
$this->assertSame(2, $rules['中通快运']['available_count']);
|
||||
$this->assertSame('2026-04-21', $rules['中通快运']['effective_date']);
|
||||
$this->assertSame(['0-30kg', '31-60kg'], $rules['中通快运']['weight_bands']);
|
||||
$this->assertSame(1, $rules['中通快运']['destination_count']);
|
||||
|
||||
$rates = collect($snapshot['rates']);
|
||||
|
||||
$shentong = $rates->firstWhere(fn (array $rate) => $rate['carrier'] === '申通'
|
||||
&& $rate['origin'] === '广东'
|
||||
&& $rate['destination'] === '江苏');
|
||||
$this->assertNotNull($shentong);
|
||||
$this->assertSame(9.0, $shentong['base_amount']);
|
||||
$this->assertSame(1.75, $shentong['increment_amount']);
|
||||
$this->assertSame('C5:D5', $shentong['source']);
|
||||
$this->assertSame('广东', $shentong['origin_key']);
|
||||
$this->assertSame('江苏', $shentong['destination_key']);
|
||||
$this->assertTrue($shentong['available']);
|
||||
|
||||
$deppon = $rates->firstWhere(fn (array $rate) => $rate['carrier'] === '德邦' && $rate['origin'] === '新疆');
|
||||
$this->assertNotNull($deppon);
|
||||
$this->assertSame('江苏', $deppon['destination']);
|
||||
$this->assertSame(18.0, $deppon['base_amount']);
|
||||
$this->assertSame(5.5, $deppon['increment_amount']);
|
||||
|
||||
$bandRates = $rates->filter(fn (array $rate) => $rate['carrier'] === '中通快运' && $rate['origin'] === '安徽省');
|
||||
$this->assertCount(2, $bandRates);
|
||||
$this->assertSame(['0-30kg', '31-60kg'], array_column($bandRates->values()->all(), 'weight_band'));
|
||||
$this->assertSame('安徽', $bandRates->first()['origin_key']);
|
||||
$this->assertSame('江苏', $bandRates->first()['destination_key']);
|
||||
|
||||
$missing = $rates->filter(fn (array $rate) => ! $rate['available']);
|
||||
$this->assertCount(2, $missing);
|
||||
$this->assertSame(['新疆', '新疆'], $missing->pluck('origin')->values()->all());
|
||||
}
|
||||
|
||||
public function testItKeepsTheShellWhenTheSnapshotIsMissing(): void
|
||||
{
|
||||
$snapshot = (new ReverseLogisticsQuoteSnapshot(storage_path('framework/testing/missing-reverse-logistics.json')))->load();
|
||||
|
||||
$this->assertFalse($snapshot['available']);
|
||||
$this->assertSame('missing', $snapshot['state']);
|
||||
$this->assertSame('尚未导入本机逆向物流报价文件。', $snapshot['message']);
|
||||
$this->assertSame([], $snapshot['rates']);
|
||||
$this->assertSame([], $snapshot['rate_groups']);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user