feat: 附加费结算接入超长费/退回费/拦截费拉取、导入核验与模板导出
- 超长费按报价规则从销售出库单拉取;退回费/拦截费从查件扣单拉取,并补出库时间、平台商品ID、线上订单号等字段 - 页面新增导入核验(单号+附加费类型+快递名称+可选金额),返回正常结/不结原因与差异金额,并提供导入模板下载
This commit is contained in:
parent
2118abcf27
commit
1fbb924924
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;
|
||||
}
|
||||
}
|
||||
@ -1288,6 +1288,286 @@ class Pages extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$request = request();
|
||||
|
||||
@ -212,7 +212,7 @@ return [
|
||||
'expressCause', 'receivingDepartment', 'returnCost', 'shopproid',
|
||||
],
|
||||
'fixed_filters' => ['IsAgreeReason' => ['通过', '同意', '同意记账']],
|
||||
'indexes' => [['ExpressNum', 'Id'], ['OrderNum', 'Id'], ['shopproid', 'Id'], ['CreationTime', 'Id']],
|
||||
'indexes' => [['ExpressNum', 'Id'], ['OrderNum(100)', 'Id'], ['shopproid(100)', 'Id'], ['CreationTime', 'Id']],
|
||||
],
|
||||
'jiansun.dbo.express_Original' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'express_Original',
|
||||
@ -222,7 +222,7 @@ return [
|
||||
'Lenght', 'Width', 'Height', 'Volume', 'SkuCode', 'JiaoYiNum', 'SkuName',
|
||||
'ShopName', 'SetMoney', 'SetWeight', 'ProCode', 'Reason', 'shopproid', 'paytime',
|
||||
],
|
||||
'indexes' => [['OrderNum', 'Id'], ['JiaoYiNum', 'Id'], ['shopproid', 'Id']],
|
||||
'indexes' => [['OrderNum', 'Id'], ['JiaoYiNum', 'Id'], ['shopproid(100)', 'Id']],
|
||||
],
|
||||
'jiansun.dbo.ekf_BookkeepingConfig' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_BookkeepingConfig',
|
||||
@ -238,7 +238,7 @@ return [
|
||||
'State', 'Accounting', 'ReturnState', 'CreationTime', 'UpdateTime', 'Delete',
|
||||
'OmsSDName', 'DHLName', 'SkuCode', 'shopproid',
|
||||
],
|
||||
'indexes' => [['ExpressNum', 'OrderNum', 'Id'], ['shopproid', 'Id']],
|
||||
'indexes' => [['ExpressNum', 'OrderNum(100)', 'Id'], ['shopproid(100)', 'Id']],
|
||||
],
|
||||
'jiansun.dbo.ekf_rCheckPieceConfig' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheckPieceConfig',
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,7 @@
|
||||
$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' => '商品编码'],
|
||||
@ -137,6 +138,10 @@
|
||||
.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); }
|
||||
@ -171,6 +176,13 @@
|
||||
}
|
||||
</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>
|
||||
@ -200,11 +212,24 @@
|
||||
<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-surcharge-type">附加费类型</label><select id="settlement-surcharge-type" name="surcharge_type"><option value="">全部附加费类型</option>@foreach (['退回费','拦截费','改地址','上楼费','送货费','超区费','安装费','保费','进仓费','超长费'] as $type)<option value="{{ $type }}" @selected($filterValue('surcharge_type') === $type)>{{ $type }}</option>@endforeach</select></div>
|
||||
<div class="settlement-field"><label for="settlement-status">结算状态</label><select id="settlement-status" name="status"><option value="">全部状态</option>@foreach (['未匹配报价','待核价','已计算'] as $status)<option value="{{ $status }}" @selected($filterValue('status') === $status)>{{ $status }}</option>@endforeach</select></div>
|
||||
<div class="settlement-field"><label for="settlement-created-from">创建日期起</label><input id="settlement-created-from" type="date" name="created_from" value="{{ $filterValue('created_from') }}"></div>
|
||||
<div class="settlement-field"><label for="settlement-created-to">创建日期止</label><input id="settlement-created-to" type="date" name="created_to" value="{{ $filterValue('created_to') }}"></div>
|
||||
<div class="settlement-actions"><button class="settlement-button settlement-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button><a class="settlement-button" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge', 'tab' => 'settlement']) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a></div>
|
||||
<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>
|
||||
|
||||
@ -205,6 +205,19 @@ Route::get('workspace/courier-payables/return-pickup-management/export', 'Busine
|
||||
->middleware('business.page:courier-payables,return-pickup-management')
|
||||
->name('business.courier-payables.return-pickup-management.export');
|
||||
|
||||
// 必须注册在通用 {section}/{page}/{tab}/export 之前,否则会被通用导出路由截获。
|
||||
Route::get('workspace/courier-payables/courier-surcharge/settlement/export', 'Business\Pages@exportCourierSurchargeSettlement')
|
||||
->middleware('business.page:courier-payables,courier-surcharge')
|
||||
->name('business.courier-surcharge.settlement.export');
|
||||
|
||||
Route::post('workspace/courier-payables/courier-surcharge/settlement/import', 'Business\Pages@importCourierSurchargeSettlement')
|
||||
->middleware('business.page:courier-payables,courier-surcharge')
|
||||
->name('business.courier-surcharge.settlement.import');
|
||||
|
||||
Route::get('workspace/courier-payables/courier-surcharge/settlement/import-template', 'Business\Pages@downloadCourierSurchargeSettlementImportTemplate')
|
||||
->middleware('business.page:courier-payables,courier-surcharge')
|
||||
->name('business.courier-surcharge.settlement.import-template');
|
||||
|
||||
Route::get('workspace/courier-payables/third-party-warehouse-storage/export', 'Business\Pages@exportThirdPartyWarehouseStorage')
|
||||
->middleware('business.page:courier-payables,third-party-warehouse-storage')
|
||||
->name('business.courier-payables.third-party-warehouse-storage.export');
|
||||
|
||||
@ -3,6 +3,13 @@
|
||||
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
|
||||
@ -23,6 +30,7 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
|
||||
->assertSee('data-finance-fixed="true"', false)
|
||||
->assertSee('data-finance-fixed="end"', false)
|
||||
->assertSeeText('快递名称')
|
||||
->assertSeeText('出库时间')
|
||||
->assertSeeText('平台商品ID')
|
||||
->assertSeeText('查件原因')
|
||||
->assertSeeText('退回单号')
|
||||
@ -91,4 +99,206 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
|
||||
'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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,57 +3,20 @@
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CourierSurchargeSettlementMirrorConfigTest extends TestCase
|
||||
{
|
||||
public function test_settlement_sources_are_registered_with_stable_keys_and_fixed_approval_filter(): void
|
||||
public function test_settlement_rows_table_contract(): void
|
||||
{
|
||||
$registry = new BusinessMirrorDatasetRegistry();
|
||||
$dataset = $registry->get(CourierSurchargeSettlementMirrorRepository::PRIMARY_DATASET);
|
||||
|
||||
$this->assertSame('jiansun', $dataset->sourceKey());
|
||||
$this->assertSame('dbo', $dataset->sourceSchema());
|
||||
$this->assertSame('ekf_Bookkeeping', $dataset->sourceObject());
|
||||
$this->assertSame('js_ekf_bookkeeping_surcharge', $dataset->targetTable());
|
||||
$this->assertSame(['Id'], $dataset->keyColumns());
|
||||
$this->assertSame('CreationTime', $dataset->watermarkColumn());
|
||||
$this->assertSame(['通过', '同意', '同意记账'], $dataset->fixedFilters()['IsAgreeReason']);
|
||||
$this->assertSame(['通过', '同意', '同意记账'], config('upstream_mirrors.courier_surcharge_settlement.approved_values'));
|
||||
|
||||
foreach ([
|
||||
CourierSurchargeSettlementMirrorRepository::EXPRESS_ORIGINAL_DATASET,
|
||||
CourierSurchargeSettlementMirrorRepository::BOOKKEEPING_CONFIG_DATASET,
|
||||
CourierSurchargeSettlementMirrorRepository::CHECK_DATASET,
|
||||
CourierSurchargeSettlementMirrorRepository::CHECK_CONFIG_DATASET,
|
||||
CourierSurchargeSettlementMirrorRepository::SHIPPING_DETAILS_DATASET,
|
||||
CourierSurchargeSettlementMirrorRepository::SHIPPING_EXTENSION_DATASET,
|
||||
CourierSurchargeSettlementMirrorRepository::OUTBOUND_DATASET,
|
||||
CourierSurchargeSettlementMirrorRepository::RETURN_DATASET,
|
||||
] as $key) {
|
||||
$this->assertSame('jiansun', $registry->get($key)->sourceKey());
|
||||
$this->assertNotSame('', $registry->get($key)->targetTable());
|
||||
}
|
||||
|
||||
$expressOriginal = $registry->get(CourierSurchargeSettlementMirrorRepository::EXPRESS_ORIGINAL_DATASET);
|
||||
$this->assertSame('express_Original', $expressOriginal->sourceObject());
|
||||
$this->assertSame('js_express_original', $expressOriginal->targetTable());
|
||||
$this->assertSame('DeliveryTime', $expressOriginal->watermarkColumn());
|
||||
|
||||
$this->assertSame('js_jst_order_express_details', $registry
|
||||
->get(CourierSurchargeSettlementMirrorRepository::SHIPPING_DETAILS_DATASET)->targetTable());
|
||||
$this->assertSame('js_jst_send_data_view_extension', $registry
|
||||
->get(CourierSurchargeSettlementMirrorRepository::SHIPPING_EXTENSION_DATASET)->targetTable());
|
||||
$this->assertSame('js_jst_sales_outbound_info', $registry
|
||||
->get(CourierSurchargeSettlementMirrorRepository::OUTBOUND_DATASET)->targetTable());
|
||||
$this->assertSame('courier_surcharge_settlement_rows', CourierSurchargeSettlementMirrorRepository::TABLE);
|
||||
}
|
||||
|
||||
public function test_surcharge_type_contract_has_only_the_requested_nine_types(): void
|
||||
public function test_surcharge_type_contract_includes_overlength_alongside_the_nine_types(): void
|
||||
{
|
||||
$this->assertSame([
|
||||
'退回费', '拦截费', '改地址', '上楼费', '送货费',
|
||||
'超区费', '安装费', '保费', '进仓费',
|
||||
'超区费', '安装费', '保费', '进仓费', '超长费',
|
||||
], CourierSurchargeSettlementMirrorRepository::SURCHARGE_TYPES);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,13 +2,10 @@
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use App\Models\Business\CourierSurchargeQuote;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CourierSurchargeSettlementMirrorRepositoryTest extends TestCase
|
||||
@ -24,175 +21,180 @@ class CourierSurchargeSettlementMirrorRepositoryTest extends TestCase
|
||||
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
|
||||
],
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
'upstream_mirrors.courier_surcharge_settlement.approved_values' => ['通过', '同意', '同意记账'],
|
||||
'upstream_mirrors.datasets' => [
|
||||
'jiansun.dbo.ekf_Bookkeeping' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_Bookkeeping',
|
||||
'target' => 'js_ekf_bookkeeping_surcharge', 'keys' => ['Id'], 'watermark' => 'CreationTime',
|
||||
'columns' => ['Id', 'CreationTime', 'OrderNum', 'ExpressNum', 'ExpressName', 'ShopCode', 'Province', 'City', 'District', 'Address', 'SupplierName', 'SkuName', 'SkuCode', 'Result', 'UserName', 'Payfee', 'SecodaryCause', 'ReasonId', 'State', 'Delete', 'IsAgreeReason', 'ProcessingState', 'BookkeepingName', 'PayTime', 'Svolume', 'Skuweight', 'IfReturn', 'IfBuckle', 'FuZhuMain', 'FuZhuMinor', 'ShipExpressName', 'expressCause', 'receivingDepartment', 'returnCost', 'shopproid'],
|
||||
'fixed_filters' => ['IsAgreeReason' => ['通过', '同意', '同意记账']],
|
||||
],
|
||||
'jiansun.dbo.express_Original' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'express_Original',
|
||||
'target' => 'js_express_original', 'keys' => ['Id'], 'watermark' => 'DeliveryTime',
|
||||
'columns' => ['Id', 'ExpressName', 'OrderNum', 'DeliveryTime', 'Lenght', 'Width', 'Height', 'Volume', 'SkuCode', 'JiaoYiNum', 'ShopName', 'SetMoney', 'SetWeight', 'shopproid'],
|
||||
],
|
||||
'jiansun.dbo.ekf_BookkeepingConfig' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_BookkeepingConfig',
|
||||
'target' => 'js_ekf_bookkeeping_config', 'keys' => ['Id'], 'watermark' => 'CreateTime',
|
||||
'columns' => ['Id', 'Name', 'ParentId', 'CreateTime'],
|
||||
],
|
||||
'jiansun.dbo.ekf_rCheck_Piece' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheck_Piece',
|
||||
'target' => 'js_ekf_rcheck_piece', 'keys' => ['Id'], 'watermark' => 'UpdateTime',
|
||||
'columns' => ['Id', 'CheckType', 'ExpressNum', 'OrderNum', 'CheckSDType', 'CheckReason', 'State', 'Accounting', 'ReturnState', 'CreationTime', 'UpdateTime', 'Delete', 'OmsSDName', 'DHLName', 'SkuCode', 'shopproid'],
|
||||
],
|
||||
'jiansun.dbo.ekf_rCheckPieceConfig' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheckPieceConfig',
|
||||
'target' => 'js_ekf_rcheck_piece_config', 'keys' => ['Id'], 'watermark' => 'CreateTime',
|
||||
'columns' => ['Id', 'ParentId', 'Name', 'Code', 'Type', 'Delete', 'CreateTime'],
|
||||
],
|
||||
'jiansun.dbo.jst_order_express_details' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_order_express_details',
|
||||
'target' => 'js_jst_order_express_details', 'keys' => ['id'], 'watermark' => 'updatedAt',
|
||||
'columns' => ['id', 'updatedAt', 'inner_order_id', 'so_id', 'logistic_id', 'logistic_company_name', 'logistic_status_name', 'platform_name', 'shop_name', 'warehouse_name', 'send_time', 'weight', 'f_weight'],
|
||||
],
|
||||
'jiansun.dbo.Jst_SendData_View_extension' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'Jst_SendData_View_extension',
|
||||
'target' => 'js_jst_send_data_view_extension', 'keys' => ['Id'], 'watermark' => null,
|
||||
'columns' => ['Id', 'io_id', 'qty', 'l', 'w', 'h', 'sku_ids', 'sku_name', 'so_id', 'shop_site', 'shop_name', 'shop_i_id', 'supplier_name', 'receiver_state', 'receiver_city', 'receiver_district', 'expressName', 'wareHoursName', 'volume', 'weight'],
|
||||
],
|
||||
'jiansun.dbo.jst_salesOutboundInfo' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_salesOutboundInfo',
|
||||
'target' => 'js_jst_sales_outbound_info', 'keys' => ['Id'], 'watermark' => 'updateTime',
|
||||
'columns' => ['Id', 'io_id', 'modified', 'updateTime', 'f_weight', 'weight', 'f_freight', 'freight', 'pay_date', 'volume', 'f_volume'],
|
||||
],
|
||||
'jiansun.dbo.ekf_ReturnPickupManagement' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_ReturnPickupManagement',
|
||||
'target' => 'js_ekf_return_pickup_management', 'keys' => ['Id'], 'watermark' => 'updateTime',
|
||||
'columns' => ['Id', 'orderId', 'returnExpressNo', 'transactionNo', 'updateTime'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
DB::purge('business_mirror');
|
||||
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->dateTime('initial_completed_at')->nullable();
|
||||
$table->string('status')->nullable();
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
$table->string('last_error_code')->nullable();
|
||||
});
|
||||
$this->createMirrorTables($schema);
|
||||
$this->repository = new CourierSurchargeSettlementMirrorRepository(app('db'));
|
||||
}
|
||||
|
||||
$this->repository = new CourierSurchargeSettlementMirrorRepository(
|
||||
app('db'),
|
||||
new BusinessMirrorDatasetRegistry(),
|
||||
new GenericBusinessMirrorRepository(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();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_maps_approved_bookkeeping_to_requested_fields_and_uses_outbound_weight(): void
|
||||
{
|
||||
$db = DB::connection('business_mirror');
|
||||
$this->markReady($db);
|
||||
|
||||
$db->table('js_ekf_bookkeeping_config')->insert([
|
||||
['Id' => 10, 'Name' => '退回', 'ParentId' => null],
|
||||
['Id' => 20, 'Name' => '二级原因', 'ParentId' => 1],
|
||||
]);
|
||||
$db->table('js_ekf_rcheck_piece_config')->insert([
|
||||
['Id' => 1, 'ParentId' => null, 'Name' => '一级查件', 'Code' => null, 'Type' => null, 'Delete' => null],
|
||||
['Id' => 2, 'ParentId' => 1, 'Name' => '二级查件原因', 'Code' => null, 'Type' => null, 'Delete' => null],
|
||||
]);
|
||||
$db->table('js_ekf_bookkeeping_surcharge')->insert([
|
||||
'Id' => 1, 'CreationTime' => '2026-09-27 10:00:00', 'OrderNum' => 'SO-1', 'ExpressNum' => 'WB-1',
|
||||
'ExpressName' => '备用快递', 'ShopCode' => '备用店铺', 'SkuCode' => '备用编码', 'Payfee' => 3.2,
|
||||
'SecodaryCause' => 20, 'ReasonId' => 10, 'IsAgreeReason' => '同意', 'Svolume' => 1.1,
|
||||
'Skuweight' => 0.9, 'IfReturn' => '已退回', 'IfBuckle' => null, 'ShipExpressName' => null,
|
||||
'expressCause' => null, 'shopproid' => '备用商品',
|
||||
]);
|
||||
$db->table('js_ekf_rcheck_piece')->insert([
|
||||
'Id' => 1, 'CheckType' => 1, 'ExpressNum' => 'WB-1', 'OrderNum' => 'SO-1', 'CheckReason' => 2,
|
||||
'OmsSDName' => '查件物流', 'DHLName' => '不作为快递名称', 'SkuCode' => 'CHECK-SKU', 'shopproid' => 'CHECK-ITEM',
|
||||
]);
|
||||
$db->table('js_express_original')->insert([
|
||||
'Id' => 1, 'ExpressName' => '原始物流', 'OrderNum' => 'WB-1', 'Lenght' => 11, 'Width' => 22, 'Height' => 33,
|
||||
'Volume' => 7260, 'SkuCode' => 'ORIGINAL-SKU', 'JiaoYiNum' => 'TRADE-1', 'ShopName' => '原始店铺',
|
||||
'SetMoney' => 18.5, 'SetWeight' => 1.75, 'shopproid' => 'ORIGINAL-ITEM',
|
||||
]);
|
||||
$db->table('js_jst_order_express_details')->insert([
|
||||
'id' => 1, 'updatedAt' => '2026-09-27 10:01:00', 'so_id' => 'SO-1', 'logistic_id' => 'WB-1',
|
||||
'logistic_company_name' => '正式快递', 'shop_name' => '正式店铺', 'f_weight' => 0.8,
|
||||
]);
|
||||
$db->table('js_jst_send_data_view_extension')->insert([
|
||||
'Id' => 1, 'io_id' => 100, 'so_id' => 'SO-1', 'shop_i_id' => 'ITEM-1', 'sku_ids' => 'SKU-1',
|
||||
'shop_name' => '正式店铺', 'l' => 10, 'w' => 20, 'h' => 30, 'volume' => 6000, 'weight' => 0.7,
|
||||
]);
|
||||
$db->table('js_jst_sales_outbound_info')->insert([
|
||||
'Id' => 1, 'io_id' => 100, 'f_weight' => 1.25, 'weight' => 1.2, 'f_volume' => 0.5, 'volume' => 0.4,
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginate([], 1, 25, collect());
|
||||
$row = $result['data']->first();
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame('查件物流', $row['courier_name']);
|
||||
$this->assertSame('ORIGINAL-ITEM', $row['platform_product_id']);
|
||||
$this->assertSame('ORIGINAL-SKU', $row['product_code']);
|
||||
$this->assertSame('原始店铺', $row['shop_name']);
|
||||
$this->assertSame('TRADE-1', $row['online_order_no']);
|
||||
$this->assertSame(11.0, $row['length']);
|
||||
$this->assertSame(22.0, $row['width']);
|
||||
$this->assertSame(33.0, $row['height']);
|
||||
$this->assertSame('一级查件', $row['inspection_type']);
|
||||
$this->assertSame('二级查件原因', $row['inspection_reason']);
|
||||
$this->assertSame('退回费', $row['surcharge_type']);
|
||||
$this->assertSame(1.75, $row['settlement_weight']);
|
||||
$this->assertSame(7260.0, $row['volume']);
|
||||
|
||||
$thresholdResult = $this->repository->paginate([], 1, 25, collect([[
|
||||
'status_code' => CourierSurchargeQuote::STATUS_ACTIVE,
|
||||
'courier' => '查件物流', 'types' => ['退回费'],
|
||||
'mode' => '按尺寸/最长边', 'standard' => '最长边 > 30 cm:8 元/单',
|
||||
]]));
|
||||
$this->assertSame(8.0, $thresholdResult['data']->first()['settlement_amount']);
|
||||
|
||||
$volumeResult = $this->repository->paginate([], 1, 25, collect([[
|
||||
'status_code' => CourierSurchargeQuote::STATUS_ACTIVE,
|
||||
'courier' => '查件物流', 'types' => ['退回费'],
|
||||
'mode' => '按体积', 'standard' => '体积 ≤ 8000 cm³:50 元/单',
|
||||
]]));
|
||||
$this->assertSame(50.0, $volumeResult['data']->first()['settlement_amount']);
|
||||
}
|
||||
|
||||
private function createMirrorTables($schema): void
|
||||
{
|
||||
$schema->create('js_ekf_bookkeeping_surcharge', function (Blueprint $table): void {
|
||||
$table->integer('Id'); $table->dateTime('CreationTime')->nullable();
|
||||
foreach (['OrderNum', 'ExpressNum', 'ExpressName', 'ShopCode', 'Province', 'City', 'District', 'Address', 'SupplierName', 'SkuName', 'SkuCode', 'Result', 'UserName', 'State', 'Delete', 'IsAgreeReason', 'ProcessingState', 'BookkeepingName', 'PayTime', 'IfReturn', 'IfBuckle', 'FuZhuMain', 'FuZhuMinor', 'ShipExpressName', 'expressCause', 'receivingDepartment', 'returnCost', 'shopproid'] as $column) $table->string($column)->nullable();
|
||||
foreach (['Payfee', 'Svolume', 'Skuweight'] as $column) $table->decimal($column, 20, 4)->nullable();
|
||||
foreach (['SecodaryCause', 'ReasonId'] as $column) $table->integer($column)->nullable();
|
||||
});
|
||||
$schema->create('js_ekf_bookkeeping_config', function (Blueprint $table): void { $table->integer('Id'); $table->string('Name')->nullable(); $table->integer('ParentId')->nullable(); $table->dateTime('CreateTime')->nullable(); });
|
||||
$schema->create('js_ekf_rcheck_piece', function (Blueprint $table): void { $table->integer('Id'); foreach (['CheckType', 'CheckReason'] as $column) $table->integer($column)->nullable(); foreach (['ExpressNum', 'OrderNum', 'CheckSDType', 'State', 'Accounting', 'ReturnState', 'CreationTime', 'UpdateTime', 'Delete', 'OmsSDName', 'DHLName', 'SkuCode', 'shopproid'] as $column) $table->string($column)->nullable(); });
|
||||
$schema->create('js_ekf_rcheck_piece_config', function (Blueprint $table): void { $table->integer('Id'); $table->integer('ParentId')->nullable(); foreach (['Name', 'Code', 'Type', 'Delete', 'CreateTime'] as $column) $table->string($column)->nullable(); });
|
||||
$schema->create('js_jst_order_express_details', function (Blueprint $table): void { $table->integer('id'); foreach (['updatedAt', 'so_id', 'logistic_id', 'logistic_company_name', 'shop_name', 'f_weight'] as $column) $table->string($column)->nullable(); });
|
||||
$schema->create('js_jst_send_data_view_extension', function (Blueprint $table): void { $table->integer('Id'); $table->integer('io_id')->nullable(); foreach (['so_id', 'shop_i_id', 'sku_ids', 'shop_name', 'l', 'w', 'h', 'volume', 'weight'] as $column) $table->string($column)->nullable(); });
|
||||
$schema->create('js_jst_sales_outbound_info', function (Blueprint $table): void { $table->integer('Id'); $table->integer('io_id')->nullable(); foreach (['f_weight', 'weight', 'f_volume', 'volume'] as $column) $table->string($column)->nullable(); });
|
||||
$schema->create('js_ekf_return_pickup_management', function (Blueprint $table): void { $table->integer('Id'); foreach (['orderId', 'returnExpressNo', 'transactionNo', 'updateTime'] as $column) $table->string($column)->nullable(); });
|
||||
$schema->create('js_express_original', function (Blueprint $table): void { $table->integer('Id'); foreach (['ExpressName', 'OrderNum', 'DeliveryTime', 'Lenght', 'Width', 'Height', 'Volume', 'SkuCode', 'JiaoYiNum', 'ShopName', 'SetMoney', 'SetWeight', 'shopproid'] as $column) $table->string($column)->nullable(); });
|
||||
}
|
||||
|
||||
private function markReady($db): void
|
||||
{
|
||||
$now = now()->toDateTimeString();
|
||||
foreach (array_keys(config('upstream_mirrors.datasets')) as $dataset) {
|
||||
$db->table('business_mirror_table_states')->insert([
|
||||
'dataset_key' => $dataset, 'initial_completed_at' => '2026-09-27 09:00:00',
|
||||
'status' => 'succeeded', 'last_succeeded_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user