diff --git a/app/Console/Commands/PullCourierSurchargeOverlength.php b/app/Console/Commands/PullCourierSurchargeOverlength.php new file mode 100644 index 00000000..887e72ed --- /dev/null +++ b/app/Console/Commands/PullCourierSurchargeOverlength.php @@ -0,0 +1,683 @@ +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 $onlyRules + * @return list + */ + 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> + */ + 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> $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 $ioIds + * @return array>> + */ + 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> $orders + * @return array + */ + 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>> $items + * @param list> $orders + * @return array> + */ + 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>> $items + * @return array + */ + 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'), '.'); + } +} diff --git a/app/Console/Commands/PullCourierSurchargeReturn.php b/app/Console/Commands/PullCourierSurchargeReturn.php new file mode 100644 index 00000000..a7c1b031 --- /dev/null +++ b/app/Console/Commands/PullCourierSurchargeReturn.php @@ -0,0 +1,727 @@ +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 $onlyRules + * @param list $onlyTypes + * @return list + */ + 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> + */ + 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|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> $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> $orders + * @return array>> + */ + 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 $bindings + * @return list> + */ + 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 $skuIds + * @return array + */ + 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> $items + * @param list> $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 $order + * @param list> $matched + * @param list $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 $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; + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 907d6cc6..d03eaaa5 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -47,6 +47,7 @@ use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\View\BillOffsetPageData; use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository; use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository; +use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository; use Modules\EcommerceFinance\Repositories\ShippingOrderRepository; use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; @@ -241,6 +242,7 @@ class Pages extends Controller private readonly CourierSurchargeTypeRepository $courierSurchargeTypes, private readonly CourierSurchargeAuditLogRepository $courierSurchargeAudits, private readonly WithdrawalManagementReport $withdrawalManagementReport, + private readonly CourierSurchargeSettlementMirrorRepository $courierSurchargeSettlements, private readonly ShippingOrderRepository $shippingOrders, ) { $this->middleware('auth'); @@ -324,6 +326,15 @@ class Pages extends Controller abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。'); + // Keep the old direct URL working after settlement becomes a tab. + if ($section === 'courier-payables' && $page === 'courier-surcharge-settlement') { + return redirect()->route('business.pages.show', array_merge(request()->query(), [ + 'section' => $section, + 'page' => 'courier-surcharge', + 'tab' => 'settlement', + ])); + } + $pages = collect($sectionConfig['pages'] ?? []); if ($pages->isEmpty()) { @@ -390,6 +401,18 @@ class Pages extends Controller } if ($tab === null) { + if ($page === 'courier-surcharge') { + $activeTab = $tabs->first(); + + return view('business.pages.show', $this->viewData( + $sectionConfig, + $pageConfig, + $tabs, + $activeTab, + $this->resolveContent($page, $activeTab['slug']), + )); + } + return $this->redirectToPage($sectionConfig, $pageConfig); } @@ -2645,7 +2668,7 @@ class Pages extends Controller 'online-daifa-price' => $this->contentForOnlineDaifaPrice(), 'product-costs' => $this->contentForProductCosts(), 'financial-analysis' => $this->contentForFinancialAnalysis(), - 'courier-surcharge' => $this->contentForCourierSurcharge(), + 'courier-surcharge' => $this->contentForCourierSurcharge($tab), 'freight-difference', 'other-surcharge-summary', 'return-fee' => $this->contentForCourierPayableSummary($page), 'shipping-data' => $this->contentForShippingData(), 'return-pickup-management' => $this->contentForReturnPickupManagement(), @@ -3644,24 +3667,26 @@ class Pages extends Controller ]; } - protected function contentForCourierSurcharge(): array + protected function contentForCourierSurcharge(?string $tab = null): array { $clean = static fn (mixed $value, int $length = 60): string => mb_substr(trim((string) $value), 0, $length); + $quoteInput = $tab === 'settlement' ? [] : request()->query(); $filters = [ - 'keyword' => $clean(request('keyword', ''), 40), - 'courier' => $clean(request('courier', ''), 40), - 'type' => $clean(request('type', ''), 40), - 'mode' => $clean(request('mode', ''), 40), - 'scope' => $clean(request('scope', ''), 20), - 'reconciler' => $clean(request('reconciler', ''), 40), - 'supplier' => $clean(request('supplier', ''), 191), - 'status' => $clean(request('status', ''), 20), + 'keyword' => $clean($quoteInput['keyword'] ?? '', 40), + 'courier' => $clean($quoteInput['courier'] ?? '', 40), + 'type' => $clean($quoteInput['type'] ?? '', 40), + 'mode' => $clean($quoteInput['mode'] ?? '', 40), + 'scope' => $clean($quoteInput['scope'] ?? '', 20), + 'reconciler' => $clean($quoteInput['reconciler'] ?? '', 40), + 'supplier' => $clean($quoteInput['supplier'] ?? '', 191), + 'status' => $clean($quoteInput['status'] ?? '', 20), ]; $data = $this->courierSurchargeQuotes->pageData($filters); $auditReady = $this->courierSurchargeAudits->ready(); - return [ - 'view' => 'business.pages.courier-payables.courier-surcharge', + $content = [ + 'view' => 'business.pages.courier-payables.courier-surcharge-workspace', + 'courierSurchargeActiveTab' => $tab ?? 'quotes', 'courierSurchargeRows' => $data['rows'], 'courierSurchargeFilters' => $filters, 'courierSurchargeSummary' => $data['summary'], @@ -3672,6 +3697,380 @@ class Pages extends Controller 'courierSurchargeTypeUsage' => $this->courierSurchargeTypes->usageCounts($data['options']['type_records'] ?? collect()), 'courierSurchargeAuditLogs' => $this->courierSurchargeAudits->recent(), ]; + + if ($tab === 'settlement') { + $content += $this->contentForCourierSurchargeSettlement(); + } + + return $content; + } + + /** @return array */ + protected function contentForCourierSurchargeSettlement(): array + { + $request = request(); + $filters = $this->courierSurchargeSettlementFilters($request); + $page = max((int) $request->query('page', 1), 1); + $limit = min(max((int) setting('default.list_limit', 25), 1), 100); + $rows = new LengthAwarePaginator([], 0, $limit, $page, [ + 'path' => $request->url(), + 'query' => $request->except('page'), + ]); + $state = null; + $sync = ['code' => 'unavailable', 'last_succeeded_at' => null]; + $summary = ['count' => 0, 'settlement_amount' => 0.0]; + $error = null; + $pending = false; + $quoteData = $this->courierSurchargeQuotes->pageData([]); + $quotes = Collection::make($quoteData['rows'] ?? []); + + try { + if (! $this->courierSurchargeSettlements->available()) { + $error = '共享业务镜像尚未建立,附加费结算暂时无法读取。'; + } elseif (! $this->courierSurchargeSettlements->ready()) { + $state = $this->courierSurchargeSettlements->state(); + $sync = [ + 'code' => $state['code'] ?? 'pending', + 'last_succeeded_at' => $state['last_succeeded_at'] ?? null, + ]; + $pending = ($state['code'] ?? 'pending') !== 'failed'; + $error = ($state['code'] ?? null) === 'failed' + ? '附加费结算镜像同步失败,请联系管理员查看失败阶段。' + : '附加费结算镜像正在完成首次同步,完成前不会把未同步数据误判为无数据。'; + } else { + $result = $this->courierSurchargeSettlements->paginate($filters, $page, $limit, $quotes); + $rows = new LengthAwarePaginator( + Collection::make($result['data'] ?? []), + (int) ($result['total'] ?? 0), + $limit, + $page, + ['path' => $request->url(), 'query' => $request->except('page')], + ); + $state = $result['state'] ?? null; + $sync = $result['sync'] ?? $sync; + $summary = $result['summary'] ?? $summary; + } + } catch (Throwable $exception) { + report($exception); + $error = '附加费结算数据暂时无法读取,请稍后重试。'; + } + + return [ + 'courierSurchargeSettlementRows' => $rows, + 'courierSurchargeSettlementFilters' => $filters, + 'courierSurchargeSettlementSummary' => $summary, + 'courierSurchargeSettlementState' => $state, + 'courierSurchargeSettlementSync' => $sync, + 'courierSurchargeSettlementError' => $error, + 'courierSurchargeSettlementPending' => $pending, + 'courierSurchargeSettlementQuoteReady' => (bool) ($quoteData['ready'] ?? false), + ]; + } + + /** @return array */ + private function courierSurchargeSettlementFilters(Request $request): array + { + $text = static fn (mixed $value, int $length = 100): string => mb_substr(trim((string) $value), 0, $length); + $date = static function (mixed $value): string { + $value = trim((string) $value); + + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : ''; + }; + + return [ + 'courier_name' => $text($request->query('courier_name')), + 'waybill' => $text($request->query('waybill')), + 'platform_product_id' => $text($request->query('platform_product_id')), + 'product_code' => $text($request->query('product_code')), + 'shop_name' => $text($request->query('shop_name')), + 'online_order_no' => $text($request->query('online_order_no')), + 'inspection_type' => $text($request->query('inspection_type'), 60), + 'inspection_reason' => $text($request->query('inspection_reason'), 120), + 'surcharge_type' => $text($request->query('surcharge_type'), 40), + 'status' => $text($request->query('status'), 40), + 'created_from' => $date($request->query('created_from')), + 'created_to' => $date($request->query('created_to')), + ]; + } + + public function exportCourierSurchargeSettlement(Request $request): StreamedResponse + { + $filters = $this->courierSurchargeSettlementFilters($request); + $repository = app(\Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository::class); + $limit = 50000; + + return response()->streamDownload(function () use ($filters, $repository, $limit): void { + $writer = new \App\Support\StreamingXlsxWriter('附加费结算'); + + $writer->appendRow([ + '快递名称', '出库时间', '单号', '平台商品ID', '商品编码', '店铺名称', '线上订单号', + '长', '宽', '高', '体积', '查件类型', '查件原因', '退回单号', + '结算重量', '结算金额', '附加费类型', + ]); + + $exported = 0; + + foreach ($repository->exportRows($filters, $limit) as $row) { + $writer->appendRow([ + $this->xlsxText($row->courier_name ?? ''), + $this->xlsxText($row->shipped_at ?? ''), + $this->xlsxText($row->waybill ?? ''), + $this->xlsxText($row->platform_product_id ?? ''), + $this->xlsxText($row->product_code ?? ''), + $this->xlsxText($row->shop_name ?? ''), + $this->xlsxText($row->online_order_no ?? ''), + $row->length !== null ? (float) $row->length : null, + $row->width !== null ? (float) $row->width : null, + $row->height !== null ? (float) $row->height : null, + $row->volume !== null ? (float) $row->volume : null, + $this->xlsxText($row->inspection_type ?? ''), + $this->xlsxText($row->inspection_reason ?? ''), + $this->xlsxText($row->return_waybill_no ?? ''), + $row->settlement_weight !== null ? (float) $row->settlement_weight : null, + $row->settlement_amount !== null ? (float) $row->settlement_amount : null, + $this->xlsxText($row->surcharge_type ?? ''), + ]); + $exported++; + } + + if ($exported >= $limit) { + $writer->appendRow(['(已按上限截断,仅导出前 ' . $limit . ' 行,请缩小筛选范围后重试)']); + } + + $output = fopen('php://output', 'wb'); + + if ($output === false) { + throw new RuntimeException('无法创建附加费结算导出流。'); + } + + $writer->writeTo($output); + fclose($output); + }, '附加费结算_' . now()->format('Ymd_His') . '.xlsx', [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Cache-Control' => 'private, no-store', + ]); + } + + public function importCourierSurchargeSettlement(Request $request): StreamedResponse|\Illuminate\Http\RedirectResponse + { + $request->validate([ + 'file' => ['required', 'file', 'max:10240'], + ], [], ['file' => '导入文件']); + + try { + $path = (string) $request->file('file')?->getRealPath(); + $spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($path); + $sheet = $spreadsheet->getActiveSheet(); + $highestRow = min($sheet->getHighestDataRow(), 5001); + $data = $sheet->rangeToArray( + 'A1:' . $sheet->getHighestColumn() . $highestRow, + null, + false, + true, + false, + ); + } catch (\Throwable $exception) { + return back()->with('error', '导入失败:' . $exception->getMessage()); + } + + $headers = []; + + foreach ((array) ($data[0] ?? []) as $index => $name) { + $name = trim((string) $name); + + if ($name !== '') { + $headers[$name] = (int) $index; + } + } + + $waybillColumn = $this->findImportColumn($headers, ['单号', '运单号', '快递单号', 'waybill', 'express_no']); + $typeColumn = $this->findImportColumn($headers, ['附加费类型', '附加费类型名称', '类型', 'surcharge_type']); + $courierColumn = $this->findImportColumn($headers, ['快递名称', '快递公司', '物流公司', '快递', 'courier_name']); + $amountColumn = $this->findImportColumn($headers, ['金额', '导入金额', '费用金额', 'amount']); + + if ($waybillColumn === null || $typeColumn === null) { + return back()->with('error', '导入失败:文件首行需包含「单号」和「附加费类型」两列。'); + } + + $pairs = []; + + foreach (array_slice($data, 1) as $offset => $row) { + $waybill = $this->importCellText($row[$waybillColumn] ?? null); + $type = $this->importCellText($row[$typeColumn] ?? null); + + if ($waybill === '' && $type === '') { + continue; + } + + $pairs[] = [ + 'row_number' => $offset + 2, + 'waybill' => mb_substr($waybill, 0, 191), + 'surcharge_type' => mb_substr($type, 0, 40), + 'courier_name' => $courierColumn === null + ? '' + : mb_substr($this->importCellText($row[$courierColumn] ?? null), 0, 191), + 'amount' => $amountColumn === null + ? null + : $this->importCellAmount($row[$amountColumn] ?? null), + ]; + } + + if ($pairs === []) { + return back()->with('error', '导入文件没有可核验的数据行。'); + } + + if (count($pairs) > 5000) { + return back()->with('error', '导入失败:单次最多核验 5000 行。'); + } + + $amountByRow = []; + + foreach ($pairs as $pair) { + $amountByRow[(int) ($pair['row_number'] ?? 0)] = $pair['amount'] ?? null; + } + + $results = app(CourierSurchargeSettlementMirrorRepository::class)->decideImportRows($pairs); + + return response()->streamDownload(function () use ($results, $amountByRow): void { + $writer = new \App\Support\StreamingXlsxWriter('导入核验'); + + $writer->appendRow([ + '序号', '单号', '附加费类型', '结算结论', '不结原因', '快递名称', '出库时间', '平台商品ID', '商品编码', + '店铺名称', '线上订单号', '长', '宽', '高', '体积', '查件类型', '查件原因', '退回单号', + '结算重量', '结算金额', '导入金额', '差异金额', '结算状态', '判定依据', + ]); + + foreach ($results as $row) { + $importedAmount = $amountByRow[(int) ($row['row_number'] ?? 0)] ?? null; + $systemAmount = $row['settlement_amount'] !== null && $row['settlement_amount'] !== '' + ? (float) $row['settlement_amount'] + : null; + $difference = $importedAmount !== null && $systemAmount !== null + ? round($systemAmount - $importedAmount, 2) + : null; + + $writer->appendRow([ + (int) ($row['row_number'] ?? 0), + $this->xlsxText($row['waybill'] ?? ''), + $this->xlsxText($row['surcharge_type'] ?? ''), + $this->xlsxText($row['decision'] ?? ''), + $this->xlsxText($row['reason'] ?? ''), + $this->xlsxText($row['courier_name'] ?? ''), + $this->xlsxText($row['shipped_at'] ?? ''), + $this->xlsxText($row['platform_product_id'] ?? ''), + $this->xlsxText($row['product_code'] ?? ''), + $this->xlsxText($row['shop_name'] ?? ''), + $this->xlsxText($row['online_order_no'] ?? ''), + $row['length'] !== null ? (float) $row['length'] : null, + $row['width'] !== null ? (float) $row['width'] : null, + $row['height'] !== null ? (float) $row['height'] : null, + $row['volume'] !== null ? (float) $row['volume'] : null, + $this->xlsxText($row['inspection_type'] ?? ''), + $this->xlsxText($row['inspection_reason'] ?? ''), + $this->xlsxText($row['return_waybill_no'] ?? ''), + $row['settlement_weight'] !== null ? (float) $row['settlement_weight'] : null, + $systemAmount, + $importedAmount, + $difference, + $this->xlsxText($row['settlement_status'] ?? ''), + $this->xlsxText($row['evidence'] ?? ''), + ]); + } + + $output = fopen('php://output', 'wb'); + + if ($output === false) { + throw new RuntimeException('无法创建导入核验导出流。'); + } + + $writer->writeTo($output); + fclose($output); + }, '附加费结算_导入核验_' . now()->format('Ymd_His') . '.xlsx', [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Cache-Control' => 'private, no-store', + ]); + } + + public function downloadCourierSurchargeSettlementImportTemplate(): StreamedResponse + { + return response()->streamDownload(function (): void { + $writer = new \App\Support\StreamingXlsxWriter('导入模板'); + + $writer->appendRow(['单号', '附加费类型', '快递名称', '金额(可选)']); + $writer->appendRow(['DPK317138931411', '退回费', '曹县德邦小件', 20]); + $writer->appendRow(['DPK379220912936', '超长费', '常州德邦', null]); + + $output = fopen('php://output', 'wb'); + + if ($output === false) { + throw new RuntimeException('无法创建导入模板导出流。'); + } + + $writer->writeTo($output); + fclose($output); + }, '附加费结算_导入模板.xlsx', [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Cache-Control' => 'private, no-store', + ]); + } + + /** + * @param array $headers + * @param list $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 diff --git a/config/business_navigation.php b/config/business_navigation.php index bed51c82..37e03707 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -712,7 +712,14 @@ $navigation = [ ['slug' => 'compensation-details', 'title' => '赔偿明细'], ['slug' => 'advance-details', 'title' => '借支明细'], ['slug' => 'other-adjustments', 'title' => '其他扣款补款'], - ['slug' => 'courier-surcharge', 'title' => '附加费报价维护'], + [ + 'slug' => 'courier-surcharge', + 'title' => '附加费报价维护', + 'tabs' => [ + ['slug' => 'quotes', 'title' => '附加费报价'], + ['slug' => 'settlement', 'title' => '附加费结算'], + ], + ], ['slug' => 'payable-cloud-warehouse-vehicle-fee', 'title' => '应付云仓车费'], ['slug' => 'courier-payables-report', 'title' => '快递应付表'], ['slug' => 'freight-difference', 'title' => '运费差'], diff --git a/database/migrations/2026_10_09_000000_create_courier_surcharge_settlement_rows_table.php b/database/migrations/2026_10_09_000000_create_courier_surcharge_settlement_rows_table.php new file mode 100644 index 00000000..acf20dd2 --- /dev/null +++ b/database/migrations/2026_10_09_000000_create_courier_surcharge_settlement_rows_table.php @@ -0,0 +1,67 @@ +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'); + } +}; diff --git a/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php b/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php new file mode 100644 index 00000000..97c49952 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php @@ -0,0 +1,994 @@ +connection = $database->connection((string) config('upstream_mirrors.connection', 'business_mirror')); + } + + public function available(): bool + { + try { + return Schema::connection($this->connection->getName())->hasTable(self::TABLE); + } catch (Throwable) { + return false; + } + } + + public function ready(): bool + { + return $this->available() && $this->connection->table(self::TABLE)->exists(); + } + + /** @return array */ + public function state(): array + { + if (! $this->available()) { + return [ + 'code' => 'pending', + 'status' => 'pending', + 'last_succeeded_at' => null, + 'mirror_row_count' => 0, + ]; + } + + try { + $lastPulledAt = $this->connection->table(self::TABLE)->max('pulled_at'); + $rowCount = (int) $this->connection->table(self::TABLE)->count(); + } catch (Throwable) { + return [ + 'code' => 'pending', + 'status' => 'pending', + 'last_succeeded_at' => null, + 'mirror_row_count' => 0, + ]; + } + + return [ + 'code' => $lastPulledAt === null ? 'pending' : 'fresh', + 'status' => $lastPulledAt === null ? 'pending' : 'succeeded', + 'last_succeeded_at' => $lastPulledAt, + 'mirror_row_count' => $rowCount, + ]; + } + + /** @param array|null $state @return array{code:string,lag_minutes:?int} */ + public function syncStatus(?array $state = null, ?CarbonInterface $at = null): array + { + $state ??= $this->state(); + $lastSucceeded = $this->timestamp($state['last_succeeded_at'] ?? null); + + if ($lastSucceeded === null) { + return ['code' => 'pending', 'lag_minutes' => null]; + } + + $lagMinutes = (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded) / 60); + + return [ + 'code' => $lagMinutes > self::STALE_AFTER_MINUTES ? 'stale' : 'fresh', + 'lag_minutes' => $lagMinutes, + ]; + } + + /** + * @param array $filters + * @return array{data:list>,total:int,has_more:bool,summary:array,sync:array,state:array} + */ + public function paginate(array $filters, int $page, int $limit, mixed $quotes = null): array + { + if (! $this->ready()) { + throw new RuntimeException('surcharge_settlement_rows_not_ready'); + } + + $page = max($page, 1); + $limit = min(max($limit, 1), 100); + $query = $this->filteredQuery($filters); + $total = (int) (clone $query)->count(); + $rows = $query + ->orderByDesc('shipped_at') + ->orderByDesc('id') + ->forPage($page, $limit) + ->get(); + + $data = []; + $amount = 0.0; + + foreach ($rows as $row) { + $mapped = $this->mapRow($row); + $amount += (float) ($mapped['settlement_amount'] ?? 0); + $data[] = $mapped; + } + + $state = $this->state(); + + return [ + 'data' => $data, + 'total' => $total, + 'has_more' => ($page * $limit) < $total, + 'summary' => ['count' => $total, 'settlement_amount' => round($amount, 2)], + 'sync' => ['code' => $state['code'], 'last_succeeded_at' => $state['last_succeeded_at']], + 'state' => $state, + ]; + } + + /** @param array $filters */ + private function filteredQuery(array $filters): Builder + { + $query = $this->connection->table(self::TABLE); + + foreach ([ + 'courier_name' => 'courier_name', + 'waybill' => 'waybill', + 'platform_product_id' => 'platform_product_id', + 'product_code' => 'product_code', + 'shop_name' => 'shop_name', + 'online_order_no' => 'online_order_no', + 'inspection_type' => 'inspection_type', + 'inspection_reason' => 'inspection_reason', + ] as $filter => $column) { + $value = trim((string) ($filters[$filter] ?? '')); + + if ($value !== '') { + $query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($value)]); + } + } + + if (trim((string) ($filters['surcharge_type'] ?? '')) !== '') { + $query->where('surcharge_type', trim((string) $filters['surcharge_type'])); + } + + if (trim((string) ($filters['status'] ?? '')) !== '') { + $query->where('settlement_status', trim((string) $filters['status'])); + } + + if (trim((string) ($filters['created_from'] ?? '')) !== '') { + $query->where('shipped_at', '>=', $filters['created_from'] . ' 00:00:00'); + } + + if (trim((string) ($filters['created_to'] ?? '')) !== '') { + $query->where('shipped_at', '<', Carbon::parse($filters['created_to'])->addDay()->toDateTimeString()); + } + + return $query; + } + + /** + * 导出用流式游标:按筛选条件顺序读取结算行,默认最多 5 万行。 + * + * @param array $filters + * @return \Generator + */ + public function exportRows(array $filters, int $maxRows = 50000): \Generator + { + if (! $this->ready()) { + throw new RuntimeException('surcharge_settlement_rows_not_ready'); + } + + $query = $this->filteredQuery($filters) + ->orderByDesc('shipped_at') + ->orderByDesc('id') + ->limit(min(max($maxRows, 1), 50000)); + + foreach ($query->cursor() as $row) { + yield $row; + } + } + + /** + * 导入核验:按「单号 + 附加费类型」匹配结算行,给出正常结/不结结论与原因。 + * + * @param list $pairs + * @return list> + */ + public function decideImportRows(array $pairs): array + { + if (! $this->ready()) { + throw new RuntimeException('surcharge_settlement_rows_not_ready'); + } + + $waybills = []; + + foreach ($pairs as $pair) { + $waybill = trim((string) ($pair['waybill'] ?? '')); + + if ($waybill !== '') { + $waybills[$waybill] = true; + } + } + + $rowsByWaybill = []; + + foreach (array_chunk(array_keys($waybills), 300) as $chunk) { + $rows = $this->connection->table(self::TABLE) + ->whereIn('waybill', $chunk) + ->orderByDesc('settlement_amount') + ->orderByDesc('id') + ->get(); + + foreach ($rows as $row) { + $waybill = trim((string) $row->waybill); + + if ($waybill !== '') { + $rowsByWaybill[$waybill][] = $row; + } + } + } + + $normalized = []; + + foreach ($pairs as $pair) { + $normalized[] = [ + 'row_number' => (int) ($pair['row_number'] ?? 0), + 'waybill' => trim((string) ($pair['waybill'] ?? '')), + 'surcharge_type' => $this->normalizeImportType((string) ($pair['surcharge_type'] ?? '')), + 'courier_name' => trim((string) ($pair['courier_name'] ?? '')), + ]; + } + + $unmatchedDeductWaybills = []; + + foreach ($normalized as $pair) { + if ($pair['waybill'] === '' || ! in_array($pair['surcharge_type'], ['退回费', '拦截费'], true)) { + continue; + } + + if ($this->matchImportRow($rowsByWaybill[$pair['waybill']] ?? [], $pair['surcharge_type'], $pair['courier_name']) === null) { + $unmatchedDeductWaybills[$pair['waybill']] = true; + } + } + + $contextComplete = count($unmatchedDeductWaybills) <= self::IMPORT_RETURN_CONTEXT_LIMIT; + $returnContext = $this->deductOrderContext(array_keys($unmatchedDeductWaybills)); + $ruleModes = $this->activeRuleModes(); + + $fallbackRows = []; + + foreach ($rowsByWaybill as $waybill => $rows) { + if ($rows !== []) { + $fallbackRows[$waybill] = $rows[0]; + } + } + + $contextSkuIds = []; + + foreach ($returnContext as $context) { + foreach ($context['items'] ?? [] as $item) { + $skuId = trim((string) ($item['sku_id'] ?? '')); + + if ($skuId !== '') { + $contextSkuIds[$skuId] = true; + } + } + } + + $contextWeights = $this->importWeights(array_keys($contextSkuIds)); + $contextReturns = $this->importReturns($returnContext); + $results = []; + + foreach ($normalized as $pair) { + $matched = null; + $mismatchedCourier = ''; + + if ($pair['waybill'] !== '') { + $candidates = $rowsByWaybill[$pair['waybill']] ?? []; + $matched = $this->matchImportRow($candidates, $pair['surcharge_type'], $pair['courier_name']); + + if ($matched === null && $pair['courier_name'] !== '') { + $other = $this->matchImportRow($candidates, $pair['surcharge_type']); + + if ($other !== null) { + $mismatchedCourier = trim((string) $other->courier_name); + } + } + } + + $results[] = $matched !== null + ? $this->importMatchedRow($pair, $matched) + : $this->importMissingRow( + $pair, + $returnContext, + $ruleModes, + $contextComplete, + $mismatchedCourier, + $fallbackRows[$pair['waybill']] ?? null, + $contextWeights, + $contextReturns, + ); + } + + return $results; + } + + private function normalizeImportType(string $type): string + { + $type = trim($type); + + return [ + '退回件' => '退回费', + '退货费' => '退回费', + '退货件' => '退回费', + '超长' => '超长费', + '改地址费' => '改地址', + ][$type] ?? $type; + } + + /** + * @param iterable $rows + */ + private function matchImportRow(iterable $rows, string $type, string $courier = ''): ?object + { + $fallback = null; + + foreach ($rows as $row) { + if (trim((string) $row->surcharge_type) !== $type) { + continue; + } + + if ($courier !== '' && trim((string) $row->courier_name) !== $courier) { + continue; + } + + $fallback ??= $row; + + if ((float) ($row->settlement_amount ?? 0) > 0) { + return $row; + } + } + + return $fallback; + } + + /** + * @param array{row_number:int,waybill:string,surcharge_type:string} $pair + * @return array + */ + private function importMatchedRow(array $pair, object $row): array + { + $amount = (float) ($row->settlement_amount ?? 0); + $status = trim((string) ($row->settlement_status ?? '')); + $evidence = trim((string) ($row->evidence ?? '')); + $reason = ''; + + if ($amount > 0) { + $decision = '正常结'; + } else { + $decision = '不结'; + + if ($status === '未匹配报价') { + $reason = '未匹配到启用的报价规则'; + } elseif ($status === '待核价') { + $reason = '待核价:商品资料缺少重量或规则金额无法计算'; + } elseif (str_contains($evidence, '无进仓') || str_contains($evidence, '无退货')) { + $reason = '售后退货退款无进仓时间/实退数量'; + } else { + $reason = '结算金额为 0'; + } + } + + $mapped = $this->mapRow($row); + $mapped['settlement_status'] = $status; + $mapped['evidence'] = $evidence; + + return array_merge([ + 'row_number' => $pair['row_number'], + 'waybill' => $pair['waybill'], + 'surcharge_type' => $pair['surcharge_type'], + 'decision' => $decision, + 'reason' => $reason, + ], $mapped); + } + + /** + * @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair + * @param array> $returnContext + * @param array>> $ruleModes + * @param array $contextWeights + * @param array $contextReturns + * @return array + */ + private function importMissingRow( + array $pair, + array $returnContext, + array $ruleModes, + bool $contextComplete, + string $mismatchedCourier, + ?object $fallbackRow, + array $contextWeights, + array $contextReturns, + ): array { + $context = $returnContext[$pair['waybill']] ?? null; + $courier = $pair['courier_name']; + + if ($courier === '') { + $courier = trim((string) ($fallbackRow->courier_name ?? ($context['courier'] ?? ''))); + } + + $platformProductId = null; + $productCode = null; + $shopName = null; + $onlineOrderNo = null; + $inspectionType = null; + $inspectionReason = null; + $returnWaybillNo = null; + $settlementWeight = null; + $shippedAt = null; + $evidence = null; + + if ($fallbackRow !== null) { + // 同一运单已有其他类型的结算行时,直接复用其来源字段。 + $platformProductId = $fallbackRow->platform_product_id; + $productCode = $fallbackRow->product_code; + $shopName = $fallbackRow->shop_name; + $onlineOrderNo = $fallbackRow->online_order_no; + $inspectionType = $fallbackRow->inspection_type; + $inspectionReason = $fallbackRow->inspection_reason; + $returnWaybillNo = $fallbackRow->return_waybill_no; + $settlementWeight = $fallbackRow->settlement_weight; + $shippedAt = $fallbackRow->shipped_at; + $evidence = '未生成「' . $pair['surcharge_type'] . '」结算行;同运单已有「' . $fallbackRow->surcharge_type . '」结算记录'; + } elseif ($context !== null) { + // 没有任何结算行时,从查件扣单工单补齐快递、商品、店铺、线上下单等信息。 + $primary = $this->primaryImportItem($context['items'] ?? []); + $platformProductId = $primary['platform_product_id']; + $productCode = $primary['sku_id'] !== '' ? $primary['sku_id'] : $primary['i_id']; + $shopName = trim((string) ($context['shop_name'] ?? '')); + $onlineOrderNo = trim((string) ($context['online_order_no'] ?? '')); + $inspectionType = trim((string) ($context['inspection_type'] ?? '')) ?: null; + $inspectionReason = trim((string) ($context['inspection_reason'] ?? '')); + $returnWaybillNo = $contextReturns[(int) ($context['o_id'] ?? 0)] ?? null; + $settlementWeight = $this->importWeightSum($context['items'] ?? [], $contextWeights); + $sendTime = trim((string) ($context['send_time'] ?? '')); + $shippedAt = $sendTime !== '' ? $sendTime : null; + $evidence = ($inspectionType ?? '查件') . ';未生成「' . $pair['surcharge_type'] . '」结算行'; + + $platformProductId = $platformProductId !== '' ? $platformProductId : null; + $productCode = $productCode !== '' ? $productCode : null; + $shopName = $shopName !== '' ? $shopName : null; + $onlineOrderNo = $onlineOrderNo !== '' ? $onlineOrderNo : null; + $inspectionReason = $inspectionReason !== '' ? $inspectionReason : null; + } + + return [ + 'row_number' => $pair['row_number'], + 'waybill' => $pair['waybill'], + 'surcharge_type' => $pair['surcharge_type'], + 'decision' => '不结', + 'reason' => $this->missingReason($pair, $returnContext, $ruleModes, $contextComplete, $mismatchedCourier), + 'courier_name' => $courier !== '' ? $courier : null, + 'shipped_at' => $shippedAt, + 'platform_product_id' => $platformProductId, + 'product_code' => $productCode, + 'shop_name' => $shopName, + 'online_order_no' => $onlineOrderNo, + 'length' => null, + 'width' => null, + 'height' => null, + 'volume' => null, + 'inspection_type' => $inspectionType, + 'inspection_reason' => $inspectionReason, + 'return_waybill_no' => $returnWaybillNo !== null && $returnWaybillNo !== '' + ? mb_substr((string) $returnWaybillNo, 0, 191) + : null, + 'settlement_weight' => $settlementWeight !== null && (float) $settlementWeight > 0 + ? round((float) $settlementWeight, 2) + : null, + 'settlement_amount' => null, + 'settlement_status' => null, + 'evidence' => $evidence, + ]; + } + + /** + * @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair + * @param array $returnContext + * @param array>> $ruleModes + */ + private function missingReason( + array $pair, + array $returnContext, + array $ruleModes, + bool $contextComplete, + string $mismatchedCourier = '', + ): string { + $courier = $pair['courier_name']; + $type = $pair['surcharge_type']; + + if ($pair['waybill'] === '') { + return '单号为空'; + } + + if ($type === '') { + return '附加费类型为空'; + } + + if ($mismatchedCourier !== '') { + return '不结:该运单+类型匹配到快递「' . $mismatchedCourier . '」,与导入的快递「' . $courier . '」不一致'; + } + + if ($courier !== '') { + $modes = array_values(array_unique($ruleModes[$courier][$type] ?? [])); + + if ($modes === []) { + return '不结:快递「' . $courier . '」没有启用的「' . $type . '」报价规则'; + } + + if ($type === '退回费') { + if (array_intersect($modes, ['固定单价', '按重量']) === []) { + return '不结:快递「' . $courier . '」的退回费规则(' . implode('、', $modes) . ')暂不支持自动核算'; + } + + $context = $returnContext[$pair['waybill']] ?? null; + $inspectionType = trim((string) ($context['inspection_type'] ?? '')); + + if ($inspectionType !== '' && $inspectionType !== '扣单') { + return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($context['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件'; + } + + if ($context !== null) { + return '不结:快递「' . $courier . '」有启用的退回费规则,但该运单未生成结算行(不在已拉取范围或结算数据尚未同步)'; + } + + return '不结:查件工单中没有该运单的扣单记录'; + } + + if ($type === '拦截费') { + if (array_intersect($modes, ['固定单价']) === []) { + return '不结:快递「' . $courier . '」的拦截费规则(' . implode('、', $modes) . ')暂不支持自动核算'; + } + + $context = $returnContext[$pair['waybill']] ?? null; + $inspectionType = trim((string) ($context['inspection_type'] ?? '')); + + if ($inspectionType !== '' && $inspectionType !== '扣单') { + return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($context['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件'; + } + + if ($context !== null) { + return '不结:快递「' . $courier . '」有启用的拦截费规则,但该运单未生成结算行(不在已拉取范围或结算数据尚未同步)'; + } + + return '不结:查件工单中没有该运单的扣单记录'; + } + + if ($type === '超长费') { + return '不结:快递「' . $courier . '」有启用的超长费规则,但该运单未达条件或不在已拉取范围'; + } + + return '不结:快递「' . $courier . '」有启用规则(' . implode('、', $modes) . '),但「' . $type . '」暂未接入自动核算'; + } + + // 未提供快递名称时,沿用查件工单兜底原因。 + if ($type === '退回费') { + if (! $contextComplete) { + return '不结:该运单没有查件扣单记录,或快递/规则不在已拉取范围'; + } + + $contextCourier = trim((string) ($returnContext[$pair['waybill']]['courier'] ?? '')); + + if ($contextCourier === '') { + return '不结:查件工单中没有该运单的扣单记录'; + } + + $inspectionType = trim((string) ($returnContext[$pair['waybill']]['inspection_type'] ?? '')); + + if ($inspectionType !== '' && $inspectionType !== '扣单') { + return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($returnContext[$pair['waybill']]['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件'; + } + + $modes = array_values(array_unique($ruleModes[$contextCourier]['退回费'] ?? [])); + + if ($modes === []) { + return '不结:快递「' . $contextCourier . '」没有启用的退回费报价规则'; + } + + if (array_intersect($modes, ['固定单价', '按重量']) === []) { + return '不结:快递「' . $contextCourier . '」的退回费规则(' . implode('、', $modes) . ')暂不支持自动核算'; + } + + return '不结:该运单未生成退回费结算行(不在已拉取范围或结算数据尚未同步)'; + } + + if ($type === '拦截费') { + if (! $contextComplete) { + return '不结:该运单没有查件扣单记录,或快递/规则不在已拉取范围'; + } + + $contextCourier = trim((string) ($returnContext[$pair['waybill']]['courier'] ?? '')); + + if ($contextCourier === '') { + return '不结:查件工单中没有该运单的扣单记录'; + } + + $inspectionType = trim((string) ($returnContext[$pair['waybill']]['inspection_type'] ?? '')); + + if ($inspectionType !== '' && $inspectionType !== '扣单') { + return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($returnContext[$pair['waybill']]['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件'; + } + + $modes = array_values(array_unique($ruleModes[$contextCourier]['拦截费'] ?? [])); + + if ($modes === []) { + return '不结:快递「' . $contextCourier . '」没有启用的拦截费报价规则'; + } + + if (array_intersect($modes, ['固定单价']) === []) { + return '不结:快递「' . $contextCourier . '」的拦截费规则(' . implode('、', $modes) . ')暂不支持自动核算'; + } + + return '不结:该运单未生成拦截费结算行(不在已拉取范围或结算数据尚未同步)'; + } + + if ($type === '超长费') { + return '不结:未找到超长费结算行,单边未超报价阈值或不在已拉取范围'; + } + + return '不结:该附加费类型暂未接入自动核算'; + } + + /** + * 未匹配到结算行时,回查查件工单补齐快递、商品、店铺、线上下单号等信息。 + * + * @param list $waybills + * @return array> + */ + private function deductOrderContext(array $waybills): array + { + $waybills = array_values(array_unique(array_filter( + array_map(static fn ($waybill): string => trim((string) $waybill), $waybills), + static fn (string $waybill): bool => $waybill !== '', + ))); + + if ($waybills === [] || count($waybills) > self::IMPORT_RETURN_CONTEXT_LIMIT) { + return []; + } + + $context = []; + + foreach (array_chunk($waybills, 50) as $chunk) { + try { + $rows = $this->connection->table('js_jst_work_order_info') + ->where('title', '查件') + ->where(function ($query) use ($chunk): void { + foreach ($chunk as $waybill) { + $query->orWhere('businessFieldList', 'like', '%' . str_replace(['%', '_'], '', $waybill) . '%'); + } + }) + ->orderByDesc('Id') + ->get(['businessFieldList']); + } catch (Throwable) { + continue; + } + + foreach ($rows as $row) { + $fields = json_decode((string) $row->businessFieldList, true); + + if (! is_array($fields)) { + continue; + } + + $flat = []; + + foreach ($fields as $field) { + if (is_array($field) && isset($field['fieldId'])) { + $flat[(string) $field['fieldId']] = $field; + } + } + + $waybill = trim((string) ($flat['lIdField']['value'] ?? '')); + $cascade = trim((string) ($flat[self::CHECK_TYPE_FIELD]['value'] ?? '')); + + if ($waybill === '') { + continue; + } + + if (isset($context[$waybill]) && ($context[$waybill]['inspection_type'] ?? '') === '扣单') { + continue; + } + + [$inspectionType, $inspectionReason] = array_pad(explode('/', $cascade, 2), 2, ''); + $items = []; + + foreach (($flat['itemInfo']['childFieldList'] ?? []) as $group) { + if (! is_array($group)) { + continue; + } + + $item = []; + + foreach ($group as $field) { + if (is_array($field) && isset($field['fieldId'])) { + $item[(string) $field['fieldId']] = $field['value'] ?? null; + } + } + + $skuId = strtoupper(trim((string) ($item['skuId'] ?? ''))); + + if ($skuId === '') { + continue; + } + + $items[] = [ + 'sku_id' => $skuId, + 'qty' => (float) ($item['qty'] ?? 0), + 'platform_product_id' => trim((string) ($item['platformItemId'] ?? '')), + 'i_id' => trim((string) ($item['iid'] ?? '')), + ]; + } + + $context[$waybill] = [ + 'courier' => trim((string) ($flat['logisticsField']['value'] ?? '')), + 'o_id' => (int) ($flat['o_id']['value'] ?? 0), + 'online_order_no' => trim((string) ($flat['orderField']['value'] ?? '')), + 'shop_name' => trim((string) ($flat['shopField']['valueLabel'] ?? ($flat['shopField']['value'] ?? ''))), + 'inspection_type' => trim((string) $inspectionType), + 'inspection_reason' => trim((string) $inspectionReason), + 'send_time' => trim((string) ($flat['orderSendTime']['value'] ?? '')), + 'items' => $items, + ]; + } + } + + return $context; + } + + /** + * @param list> $items + * @return array{sku_id:string,i_id:string,platform_product_id:string} + */ + private function primaryImportItem(array $items): array + { + foreach ($items as $item) { + if (trim((string) ($item['platform_product_id'] ?? '')) !== '') { + return [ + 'sku_id' => strtoupper(trim((string) ($item['sku_id'] ?? ''))), + 'i_id' => trim((string) ($item['i_id'] ?? '')), + 'platform_product_id' => trim((string) $item['platform_product_id']), + ]; + } + } + + $first = $items[0] ?? []; + + return [ + 'sku_id' => strtoupper(trim((string) ($first['sku_id'] ?? ''))), + 'i_id' => trim((string) ($first['i_id'] ?? '')), + 'platform_product_id' => trim((string) ($first['platform_product_id'] ?? '')), + ]; + } + + /** + * @param list> $items + * @param array $weights + */ + private function importWeightSum(array $items, array $weights): ?float + { + $total = 0.0; + $found = false; + + foreach ($items as $item) { + $skuId = strtoupper(trim((string) ($item['sku_id'] ?? ''))); + $weight = $skuId !== '' ? ($weights[$skuId] ?? null) : null; + + if ($weight !== null) { + $found = true; + $total += (float) ($item['qty'] ?? 0) * $weight; + } + } + + return $found ? round($total, 2) : null; + } + + /** + * @param list $skuIds + * @return array + */ + private function importWeights(array $skuIds): array + { + $map = []; + + foreach (array_chunk($skuIds, 500) as $chunk) { + try { + $rows = $this->connection->table('ops_jst_sku') + ->whereIn('sku_id', $chunk) + ->get(['sku_id', 'weight']); + } catch (Throwable) { + return []; + } + + foreach ($rows as $row) { + $skuId = strtoupper(trim((string) $row->sku_id)); + $weight = $row->weight; + + if ($skuId === '' || $weight === null || $weight === '' || isset($map[$skuId])) { + continue; + } + + $map[$skuId] = (float) $weight; + } + } + + return $map; + } + + /** + * @param array> $returnContext + * @return array + */ + private function importReturns(array $returnContext): array + { + $oIds = []; + + foreach ($returnContext as $context) { + $oId = (int) ($context['o_id'] ?? 0); + + if ($oId > 0) { + $oIds[$oId] = true; + } + } + + if ($oIds === []) { + return []; + } + + try { + $upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('operations_replica'); + } catch (Throwable) { + return []; + } + + $byOrder = []; + + foreach (array_chunk(array_keys($oIds), 200) as $chunk) { + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + + try { + $rows = $upstream->select( + 'SELECT a.o_id, a.l_id, ri.r_qty, CONVERT(varchar(19), ri.receive_date, 120) AS receive_date ' + . 'FROM rpt.jst_returnAndRefundInfo a ' + . 'INNER JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = a.as_id ' + . 'WHERE a.o_id IN (' . $placeholders . ')', + $chunk, + ); + } catch (Throwable) { + continue; + } + + foreach ($rows as $row) { + $row = (array) $row; + $oId = (int) ($row['o_id'] ?? 0); + $waybill = trim((string) ($row['l_id'] ?? '')); + + if ($oId <= 0 + || $waybill === '' + || (float) ($row['r_qty'] ?? 0) <= 0 + || trim((string) ($row['receive_date'] ?? '')) === '') { + continue; + } + + $byOrder[$oId][$waybill] = true; + } + } + + $map = []; + + foreach ($byOrder as $oId => $waybills) { + $map[(int) $oId] = implode(',', array_keys($waybills)); + } + + return $map; + } + + /** @return array>> */ + private function activeRuleModes(): array + { + try { + $rules = DB::table('business_courier_surcharge_quotes') + ->where('status', 'active') + ->get(['courier', 'types', 'mode']); + } catch (Throwable) { + return []; + } + + $map = []; + + foreach ($rules as $rule) { + $types = json_decode((string) $rule->types, true) ?: []; + $courier = trim((string) $rule->courier); + + if ($courier === '') { + continue; + } + + foreach ($types as $type) { + $type = trim((string) $type); + + if ($type !== '') { + $map[$courier][$type][] = trim((string) $rule->mode); + } + } + } + + return $map; + } + + private function contains(string $value): string + { + return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%'; + } + + /** @return array */ + private function mapRow(object $row): array + { + return [ + 'courier_name' => $row->courier_name, + 'waybill' => $row->waybill, + 'shipped_at' => $row->shipped_at, + 'platform_product_id' => $row->platform_product_id, + 'product_code' => $row->product_code, + 'shop_name' => $row->shop_name, + 'online_order_no' => $row->online_order_no, + 'length' => $row->length, + 'width' => $row->width, + 'height' => $row->height, + 'volume' => $row->volume, + 'inspection_type' => $row->inspection_type, + 'inspection_reason' => $row->inspection_reason, + 'return_waybill_no' => $row->return_waybill_no, + 'settlement_weight' => $row->settlement_weight, + 'settlement_amount' => $row->settlement_amount, + 'surcharge_type' => $row->surcharge_type, + ]; + } + + private function timestamp(mixed $value): ?int + { + if (! is_string($value) || $value === '') { + return null; + } + + try { + return Carbon::parse($value)->getTimestamp(); + } catch (Throwable) { + return null; + } + } +} diff --git a/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php new file mode 100644 index 00000000..b2b6e67f --- /dev/null +++ b/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php @@ -0,0 +1,263 @@ +@php + $rows = $courierSurchargeSettlementRows ?? collect(); + $filters = $courierSurchargeSettlementFilters ?? []; + $summary = $courierSurchargeSettlementSummary ?? ['count' => 0, 'settlement_amount' => 0]; + $sync = $courierSurchargeSettlementSync ?? ['code' => 'unavailable', 'last_succeeded_at' => null]; + $state = $courierSurchargeSettlementState ?? []; + $error = $courierSurchargeSettlementError ?? null; + $columns = [ + ['key' => 'courier_name', 'label' => '快递名称', 'fixed' => 'true'], + ['key' => 'shipped_at', 'label' => '出库时间'], + ['key' => 'waybill', 'label' => '单号'], + ['key' => 'platform_product_id', 'label' => '平台商品ID'], + ['key' => 'product_code', 'label' => '商品编码'], + ['key' => 'shop_name', 'label' => '店铺名称'], + ['key' => 'online_order_no', 'label' => '线上订单号'], + ['key' => 'length', 'label' => '长'], + ['key' => 'width', 'label' => '宽'], + ['key' => 'height', 'label' => '高'], + ['key' => 'volume', 'label' => '体积'], + ['key' => 'inspection_type', 'label' => '查件类型'], + ['key' => 'inspection_reason', 'label' => '查件原因'], + ['key' => 'return_waybill_no', 'label' => '退回单号'], + ['key' => 'settlement_weight', 'label' => '结算重量'], + ['key' => 'settlement_amount', 'label' => '结算金额'], + ['key' => 'surcharge_type', 'label' => '附加费类型', 'fixed' => 'end'], + ]; + $filterValue = static fn (string $key): string => trim((string) ($filters[$key] ?? '')); + $syncLabel = match ($sync['code'] ?? null) { + 'fresh' => '最新', + 'stale' => '稍有延迟', + 'pending', 'initializing', 'syncing' => '同步中', + 'failed', 'initial_failed' => '同步失败', + default => '不可用', + }; + $emptyMessage = match (true) { + $error !== null => $error, + ($sync['code'] ?? null) === 'pending' || ($sync['code'] ?? null) === 'syncing' => '镜像正在同步,完成后将自动显示附加费明细。', + ($sync['code'] ?? null) === 'failed' || ($sync['code'] ?? null) === 'initial_failed' => '镜像同步失败,暂不展示不完整数据。', + default => '当前筛选条件没有匹配的附加费明细。', + }; + $displayValue = static function (mixed $value, string $key): string { + if ($value === null || $value === '') { + return '—'; + } + + if ($key === 'settlement_amount' && is_numeric($value)) { + return number_format((float) $value, 2, '.', ''); + } + + return (string) $value; + }; +@endphp + +
+ + + @if (session('error')) +
+ +
导入核验失败
{{ session('error') }}
+
+ @endif + + @if ($error !== null || ($sync['code'] ?? null) !== 'fresh') +
+ +
+ {{ $error !== null ? '附加费结算暂不可用' : '附加费结算数据状态' }} +
{{ $error ?? $emptyMessage }}
+ @if (! empty($state['failure_stage'])) + 失败阶段:{{ $state['failure_stage'] }} + @endif +
+
+ @endif + +
+
附加费结算
结算标准读取附加费报价维护
+
筛选结果
{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }}
+
当前页结算金额
{{ number_format((float) ($summary['settlement_amount'] ?? 0), 2) }}
+
镜像状态
{{ $syncLabel }}
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+ @csrf +
+ + +
+
+ + 下载导入模板 + 首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回导入金额与差异金额(系统金额 − 导入金额)。未生成结算行的运单用导入核验获取字段明细。 +
+
+ +
+
附加费结算明细
快递名称和附加费类型固定显示,其余字段可在“列设置”中调整。
列设置按当前用户和本页面保存
+
当前筛选结果:{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }} 条数据状态:{{ $syncLabel }}@if (! empty($sync['last_succeeded_at'])),最近同步 {{ $sync['last_succeeded_at'] }}@endif
+
+ + + @foreach ($columns as $column) + + @endforeach + + + @forelse ($rows as $row) + + @foreach ($columns as $column) + + @endforeach + + @empty + + @endforelse + +
{{ $column['label'] }}
{{ $displayValue(data_get($row, $column['key']), $column['key']) }}
{{ $emptyMessage }}
+
+
+ + @if (method_exists($rows, 'links') && $rows->hasPages()) +
{{ $rows->withQueryString()->links() }}
+ @endif +
diff --git a/resources/views/business/pages/courier-payables/courier-surcharge-workspace.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge-workspace.blade.php new file mode 100644 index 00000000..41ce8674 --- /dev/null +++ b/resources/views/business/pages/courier-payables/courier-surcharge-workspace.blade.php @@ -0,0 +1,63 @@ +@php + $surchargeTabUrls = collect(['quotes', 'settlement'])->mapWithKeys(fn ($tab) => [ + $tab => route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge', 'tab' => $tab]), + ])->all(); +@endphp +
+ + +
+ + diff --git a/routes/admin.php b/routes/admin.php index 7a9e9cd9..4de1c554 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -352,6 +352,19 @@ Route::get('workspace/courier-payables/return-pickup-management/export', 'Busine ->middleware('business.page:courier-payables,return-pickup-management') ->name('business.courier-payables.return-pickup-management.export'); +// 必须注册在通用 {section}/{page}/{tab}/export 之前,否则会被通用导出路由截获。 +Route::get('workspace/courier-payables/courier-surcharge/settlement/export', 'Business\Pages@exportCourierSurchargeSettlement') + ->middleware('business.page:courier-payables,courier-surcharge') + ->name('business.courier-surcharge.settlement.export'); + +Route::post('workspace/courier-payables/courier-surcharge/settlement/import', 'Business\Pages@importCourierSurchargeSettlement') + ->middleware('business.page:courier-payables,courier-surcharge') + ->name('business.courier-surcharge.settlement.import'); + +Route::get('workspace/courier-payables/courier-surcharge/settlement/import-template', 'Business\Pages@downloadCourierSurchargeSettlementImportTemplate') + ->middleware('business.page:courier-payables,courier-surcharge') + ->name('business.courier-surcharge.settlement.import-template'); + Route::get('workspace/courier-payables/third-party-warehouse-storage/export', 'Business\Pages@exportThirdPartyWarehouseStorage') ->middleware('business.page:courier-payables,third-party-warehouse-storage') ->name('business.courier-payables.third-party-warehouse-storage.export'); diff --git a/tests/Feature/Business/CourierSurchargePageTest.php b/tests/Feature/Business/CourierSurchargePageTest.php index 133c5c77..1614bf87 100644 --- a/tests/Feature/Business/CourierSurchargePageTest.php +++ b/tests/Feature/Business/CourierSurchargePageTest.php @@ -31,7 +31,8 @@ class CourierSurchargePageTest extends FeatureTestCase 'page' => 'courier-surcharge', ])) ->assertOk() - ->assertSeeText('附加费报价维护') + ->assertSeeText('附加费报价') + ->assertSee('aria-label="附加费报价维护"', false) ->assertSeeText('正式维护数据') ->assertSeeText('报价规则列表') ->assertSeeText('管理附加费类型') diff --git a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php new file mode 100644 index 00000000..7e9686f0 --- /dev/null +++ b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php @@ -0,0 +1,304 @@ +user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge', + 'tab' => 'settlement', + ]))->assertOk() + ->assertSee('data-surcharge-panel="quotes" hidden', false) + ->assertSeeText('附加费结算') + ->assertSeeText('共享业务镜像尚未建立,附加费结算暂时无法读取。') + ->assertSee('data-finance-table-name="courier-surcharge-settlement"', false) + ->assertSee('data-finance-fixed="true"', false) + ->assertSee('data-finance-fixed="end"', false) + ->assertSeeText('快递名称') + ->assertSeeText('出库时间') + ->assertSeeText('平台商品ID') + ->assertSeeText('查件原因') + ->assertSeeText('退回单号') + ->assertSeeText('结算重量') + ->assertSeeText('结算金额') + ->assertSeeText('附加费类型'); + } + + public function test_settlement_filters_are_preserved_in_the_form(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge', + 'tab' => 'settlement', + 'courier_name' => '测试快递', + 'waybill' => '001234', + 'shop_name' => '测试店铺', + 'online_order_no' => 'SO-001', + 'status' => '已计算', + ]))->assertOk() + ->assertSee('value="测试快递"', false) + ->assertSee('value="001234"', false) + ->assertSee('value="测试店铺"', false) + ->assertSee('value="SO-001"', false) + ->assertSee('value="已计算" selected', false) + ->assertViewHas('content', fn (array $content) => $content['courierSurchargeSettlementFilters']['status'] === '已计算'); + } + + public function test_quote_page_exposes_quote_and_settlement_tabs(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge', + ]))->assertOk() + ->assertSeeText('附加费报价') + ->assertSeeText('附加费结算') + ->assertSee('data-surcharge-panel="settlement" hidden', false) + ->assertSee(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge', + 'tab' => 'quotes', + ]), false) + ->assertSee(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge', + 'tab' => 'settlement', + ]), false); + } + + public function test_old_settlement_page_url_redirects_to_settlement_tab(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge-settlement', + 'waybill' => '0000123456789', + ]))->assertRedirect(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge', + 'tab' => 'settlement', + 'waybill' => '0000123456789', + ])); + } + + public function test_settlement_export_streams_filtered_rows_as_xlsx(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $repository = Mockery::mock(CourierSurchargeSettlementMirrorRepository::class); + $repository->shouldReceive('exportRows') + ->once() + ->withArgs(static fn (array $filters): bool => ($filters['courier_name'] ?? '') === '曹县德邦') + ->andReturn((function (): \Generator { + yield (object) [ + 'courier_name' => '曹县德邦', + 'waybill' => 'DPK317249507921', + 'shipped_at' => '2026-10-09 08:39:40', + 'platform_product_id' => null, + 'product_code' => 'DNZ032912N887V01', + 'shop_name' => '拼多多-满富油家具旗舰店', + 'online_order_no' => '261009-325069063762746', + 'length' => 194, + 'width' => 83, + 'height' => 12.5, + 'volume' => 201305, + 'inspection_type' => null, + 'inspection_reason' => null, + 'return_waybill_no' => null, + 'settlement_weight' => null, + 'settlement_amount' => 8, + 'surcharge_type' => '超长费', + ]; + })()); + $this->app->instance(CourierSurchargeSettlementMirrorRepository::class, $repository); + + $response = $this->loginAs()->get(route('business.courier-surcharge.settlement.export', [ + 'courier_name' => '曹县德邦', + ])); + + $response->assertOk(); + $this->assertStringContainsString( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + (string) $response->headers->get('content-type'), + ); + + $path = tempnam(sys_get_temp_dir(), 'surcharge_settlement_export_'); + file_put_contents($path, $response->streamedContent()); + $sheet = IOFactory::load($path)->getActiveSheet(); + + $plain = static fn (mixed $value): mixed => $value instanceof RichText ? $value->getPlainText() : $value; + $headers = []; + + foreach ($sheet->getRowIterator(1, 1) as $row) { + foreach ($row->getCellIterator() as $cell) { + $headers[$cell->getColumn()] = $plain($cell->getValue()); + } + } + + $column = static fn (string $label): string => (string) array_search($label, $headers, true); + $value = static fn (string $label): mixed => $plain($sheet->getCell($column($label) . '2')->getValue()); + + try { + $this->assertSame('快递名称', $headers['A']); + $this->assertSame('出库时间', $headers['B']); + $this->assertSame('单号', $headers['C']); + $this->assertSame('附加费类型', $headers['Q']); + $this->assertSame('曹县德邦', $value('快递名称')); + $this->assertSame('DPK317249507921', $value('单号')); + $this->assertSame('2026-10-09 08:39:40', $value('出库时间')); + $this->assertSame('DNZ032912N887V01', $value('商品编码')); + $this->assertSame('拼多多-满富油家具旗舰店', $value('店铺名称')); + $this->assertEquals(194, $value('长')); + $this->assertEquals(12.5, $value('高')); + $this->assertEquals(201305, $value('体积')); + $this->assertEquals(8, $value('结算金额')); + $this->assertSame('超长费', $value('附加费类型')); + $this->assertNull($value('查件类型')); + } finally { + unlink($path); + } + } + + public function test_settlement_import_streams_decision_workbook(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $repository = Mockery::mock(CourierSurchargeSettlementMirrorRepository::class); + $repository->shouldReceive('decideImportRows') + ->once() + ->withArgs(static function (array $pairs): bool { + return count($pairs) === 2 + && $pairs[0]['waybill'] === 'DPK317138931411' + && $pairs[0]['surcharge_type'] === '退回费' + && $pairs[0]['courier_name'] === '曹县德邦小件' + && $pairs[0]['amount'] === 10.0 + && $pairs[1]['waybill'] === 'DPK1' + && $pairs[1]['surcharge_type'] === '超长费' + && $pairs[1]['courier_name'] === '' + && $pairs[1]['amount'] === null; + }) + ->andReturn([ + [ + 'row_number' => 2, 'waybill' => 'DPK317138931411', 'surcharge_type' => '退回费', + 'decision' => '正常结', 'reason' => '', 'courier_name' => '曹县德邦小件', + 'shipped_at' => '2026-07-09 13:25:51', 'platform_product_id' => '968882879362', + 'product_code' => 'YMJ000904N722', 'shop_name' => '拼多多-卓禾住宅家具旗舰店', + 'online_order_no' => '260709-594595049040320', 'length' => null, 'width' => null, + 'height' => null, 'volume' => null, 'inspection_type' => '扣单', + 'inspection_reason' => '买家-不想要', 'return_waybill_no' => 'DPK317138931411', + 'settlement_weight' => 2.4, 'settlement_amount' => 20, 'settlement_status' => '已计算', + 'evidence' => '查件扣单/买家-不想要;退款确认 2026-07-15 15:00:09;售后退货退款已进仓 2026-07-18 09:53:32;实退 1', + ], + [ + 'row_number' => 3, 'waybill' => 'DPK1', 'surcharge_type' => '超长费', + 'decision' => '不结', 'reason' => '未找到超长费结算行:单边未超报价阈值或不在已拉取范围', + 'courier_name' => null, 'shipped_at' => null, 'platform_product_id' => null, + 'product_code' => null, 'shop_name' => null, 'online_order_no' => null, + 'length' => null, 'width' => null, 'height' => null, 'volume' => null, + 'inspection_type' => null, 'inspection_reason' => null, 'return_waybill_no' => null, + 'settlement_weight' => null, 'settlement_amount' => null, 'settlement_status' => null, + 'evidence' => null, + ], + ]); + $this->app->instance(CourierSurchargeSettlementMirrorRepository::class, $repository); + + $spreadsheet = new Spreadsheet(); + $spreadsheet->getActiveSheet()->fromArray([ + ['单号', '附加费类型', '快递名称', '金额'], + ['DPK317138931411', '退回费', '曹县德邦小件', 10], + ['DPK1', '超长费'], + ]); + $importPath = tempnam(sys_get_temp_dir(), 'surcharge_import_'); + (new Xlsx($spreadsheet))->save($importPath); + + $response = $this->loginAs()->post(route('business.courier-surcharge.settlement.import'), [ + 'file' => new UploadedFile( + $importPath, + 'surcharge-import.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + null, + true, + ), + ]); + + $response->assertOk(); + $this->assertStringContainsString( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + (string) $response->headers->get('content-type'), + ); + + $resultPath = tempnam(sys_get_temp_dir(), 'surcharge_import_result_'); + file_put_contents($resultPath, $response->streamedContent()); + $sheet = IOFactory::load($resultPath)->getActiveSheet(); + $plain = static fn (mixed $value): mixed => $value instanceof RichText ? $value->getPlainText() : $value; + + try { + $this->assertSame('序号', $plain($sheet->getCell('A1')->getValue())); + $this->assertSame('结算结论', $plain($sheet->getCell('D1')->getValue())); + $this->assertSame('不结原因', $plain($sheet->getCell('E1')->getValue())); + $this->assertSame('正常结', $plain($sheet->getCell('D2')->getValue())); + $this->assertSame('DPK317138931411', $plain($sheet->getCell('B2')->getValue())); + $this->assertSame('退回费', $plain($sheet->getCell('C2')->getValue())); + $this->assertSame('导入金额', $plain($sheet->getCell('U1')->getValue())); + $this->assertSame('差异金额', $plain($sheet->getCell('V1')->getValue())); + $this->assertEquals(10, $plain($sheet->getCell('U2')->getValue())); + $this->assertEquals(10, $plain($sheet->getCell('V2')->getValue())); + $this->assertSame('不结', $plain($sheet->getCell('D3')->getValue())); + $this->assertStringContainsString('未找到超长费', (string) $plain($sheet->getCell('E3')->getValue())); + $this->assertNull($plain($sheet->getCell('V3')->getValue())); + } finally { + @unlink($importPath); + @unlink($resultPath); + } + } + + public function test_settlement_import_template_streams_workbook(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $response = $this->loginAs()->get(route('business.courier-surcharge.settlement.import-template')); + + $response->assertOk(); + $this->assertStringContainsString( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + (string) $response->headers->get('content-type'), + ); + + $path = tempnam(sys_get_temp_dir(), 'surcharge_import_template_'); + file_put_contents($path, $response->streamedContent()); + $sheet = IOFactory::load($path)->getActiveSheet(); + $plain = static fn (mixed $value): mixed => $value instanceof RichText ? $value->getPlainText() : $value; + + try { + $this->assertSame('单号', $plain($sheet->getCell('A1')->getValue())); + $this->assertSame('附加费类型', $plain($sheet->getCell('B1')->getValue())); + $this->assertSame('快递名称', $plain($sheet->getCell('C1')->getValue())); + $this->assertSame('金额(可选)', $plain($sheet->getCell('D1')->getValue())); + $this->assertSame('DPK317138931411', $plain($sheet->getCell('A2')->getValue())); + $this->assertSame('退回费', $plain($sheet->getCell('B2')->getValue())); + $this->assertSame('曹县德邦小件', $plain($sheet->getCell('C2')->getValue())); + $this->assertEquals(20, $plain($sheet->getCell('D2')->getValue())); + } finally { + @unlink($path); + } + } +} diff --git a/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorConfigTest.php b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorConfigTest.php new file mode 100644 index 00000000..72136511 --- /dev/null +++ b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorConfigTest.php @@ -0,0 +1,22 @@ +assertSame('courier_surcharge_settlement_rows', CourierSurchargeSettlementMirrorRepository::TABLE); + } + + public function test_surcharge_type_contract_includes_overlength_alongside_the_nine_types(): void + { + $this->assertSame([ + '退回费', '拦截费', '改地址', '上楼费', '送货费', + '超区费', '安装费', '保费', '进仓费', '超长费', + ], CourierSurchargeSettlementMirrorRepository::SURCHARGE_TYPES); + } +} diff --git a/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php new file mode 100644 index 00000000..2ed8a50c --- /dev/null +++ b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php @@ -0,0 +1,200 @@ + [ + 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + ]); + + DB::purge('business_mirror'); + + $this->repository = new CourierSurchargeSettlementMirrorRepository(app('db')); + } + + public function test_it_reports_unavailable_until_the_settlement_table_exists(): void + { + $this->assertFalse($this->repository->available()); + $this->assertFalse($this->repository->ready()); + $this->assertSame('pending', $this->repository->state()['code']); + } + + public function test_it_filters_maps_and_summarises_settlement_rows(): void + { + $this->createTable(); + + DB::connection('business_mirror')->table(CourierSurchargeSettlementMirrorRepository::TABLE)->insert([ + [ + 'rule_id' => 6, 'surcharge_type' => '超长费', 'courier_name' => '曹县德邦', + 'waybill' => 'DPK1', 'outbound_io_id' => 1001, 'product_code' => 'DNZ032912N887V01', + 'shop_name' => '拼多多-满富油家具旗舰店', 'online_order_no' => '261009-325069063762746', + 'length' => 194, 'width' => 83, 'height' => 12.5, 'volume' => 201305, + 'settlement_amount' => 8, 'settlement_status' => '已计算', + 'shipped_at' => '2026-10-09 08:41:48', 'pulled_at' => '2026-10-09 12:00:00', + 'evidence' => '最长边 194cm > 160cm', + 'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00', + ], + [ + 'rule_id' => 42, 'surcharge_type' => '超长费', 'courier_name' => '常州德邦', + 'waybill' => 'DPK2', 'outbound_io_id' => 1002, 'product_code' => 'GBGJ-ZWJ024903A', + 'shop_name' => '京东-HX旗舰店104', 'online_order_no' => null, + 'length' => 244, 'width' => 7.5, 'height' => 8, 'volume' => 14640, + 'settlement_amount' => 7, 'settlement_status' => '已计算', + 'shipped_at' => '2026-10-08 10:00:00', 'pulled_at' => '2026-10-09 12:00:00', + 'evidence' => '最长边 244cm > 160cm', + 'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00', + ], + ]); + + $this->assertTrue($this->repository->available()); + $this->assertTrue($this->repository->ready()); + + $result = $this->repository->paginate([], 1, 25); + + $this->assertSame(2, $result['total']); + $this->assertSame(15.0, $result['summary']['settlement_amount']); + $this->assertSame('曹县德邦', $result['data'][0]['courier_name']); + $this->assertSame('DPK1', $result['data'][0]['waybill']); + $this->assertSame('2026-10-09 08:41:48', $result['data'][0]['shipped_at']); + $this->assertSame('超长费', $result['data'][0]['surcharge_type']); + $this->assertSame(194.0, (float) $result['data'][0]['length']); + $this->assertSame(8.0, (float) $result['data'][0]['settlement_amount']); + $this->assertSame('fresh', $result['sync']['code']); + } + + public function test_it_applies_page_filters(): void + { + $this->createTable(); + + DB::connection('business_mirror')->table(CourierSurchargeSettlementMirrorRepository::TABLE)->insert([ + [ + 'rule_id' => 6, 'surcharge_type' => '超长费', 'courier_name' => '曹县德邦', + 'waybill' => 'DPK1', 'outbound_io_id' => 1001, 'product_code' => 'DNZ032912N887V01', + 'settlement_amount' => 8, 'settlement_status' => '已计算', + 'shipped_at' => '2026-10-09 08:41:48', 'pulled_at' => '2026-10-09 12:00:00', + 'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00', + ], + [ + 'rule_id' => 42, 'surcharge_type' => '超长费', 'courier_name' => '常州德邦', + 'waybill' => 'DPK2', 'outbound_io_id' => 1002, 'product_code' => 'GBGJ-ZWJ024903A', + 'settlement_amount' => 7, 'settlement_status' => '待核价', + 'shipped_at' => '2026-09-01 10:00:00', 'pulled_at' => '2026-10-09 12:00:00', + 'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00', + ], + ]); + + $byCourier = $this->repository->paginate(['courier_name' => '常州', 'status' => '待核价'], 1, 25); + $this->assertSame(1, $byCourier['total']); + $this->assertSame('常州德邦', $byCourier['data'][0]['courier_name']); + $this->assertSame(7.0, $byCourier['summary']['settlement_amount']); + + $byDate = $this->repository->paginate(['created_from' => '2026-10-01', 'created_to' => '2026-10-09'], 1, 25); + $this->assertSame(1, $byDate['total']); + $this->assertSame('DPK1', $byDate['data'][0]['waybill']); + + $byWaybill = $this->repository->paginate(['waybill' => 'DPK2'], 1, 25); + $this->assertSame(1, $byWaybill['total']); + $this->assertSame('常州德邦', $byWaybill['data'][0]['courier_name']); + } + + public function test_import_decisions_report_settlement_and_reasons(): void + { + $this->createTable(); + + DB::connection('business_mirror')->table(CourierSurchargeSettlementMirrorRepository::TABLE)->insert([ + [ + 'rule_id' => 6, 'surcharge_type' => '超长费', 'courier_name' => '曹县德邦', + 'waybill' => 'DPK1', 'outbound_io_id' => 1001, 'product_code' => 'SKU-1', + 'settlement_amount' => 8, 'settlement_status' => '已计算', + 'shipped_at' => '2026-10-09 08:41:48', 'pulled_at' => '2026-10-09 12:00:00', + 'evidence' => '最长边 194cm > 160cm', + 'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00', + ], + [ + 'rule_id' => 7, 'surcharge_type' => '退回费', 'courier_name' => '曹县德邦小件', + 'waybill' => 'DPK2', 'outbound_io_id' => null, 'product_code' => 'SKU-2', + 'settlement_amount' => 0, 'settlement_status' => '已计算', + 'shipped_at' => '2026-09-01 10:00:00', 'pulled_at' => '2026-10-09 12:00:00', + 'evidence' => '查件扣单/买家-不想要;售后退货退款无进仓/实退记录;结算金额 0.00', + 'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00', + ], + ]); + + $result = $this->repository->decideImportRows([ + ['row_number' => 2, 'waybill' => 'DPK1', 'surcharge_type' => '超长费'], + ['row_number' => 3, 'waybill' => 'DPK2', 'surcharge_type' => '退回件'], + ['row_number' => 4, 'waybill' => 'DPK3', 'surcharge_type' => '退回费'], + ['row_number' => 5, 'waybill' => 'DPK4', 'surcharge_type' => '退回费', 'courier_name' => '恒思德邦'], + ['row_number' => 6, 'waybill' => 'DPK1', 'surcharge_type' => '超长费', 'courier_name' => '常州德邦'], + ]); + + $this->assertSame('正常结', $result[0]['decision']); + $this->assertSame('', $result[0]['reason']); + $this->assertSame('曹县德邦', $result[0]['courier_name']); + $this->assertSame(8.0, (float) $result[0]['settlement_amount']); + + $this->assertSame('不结', $result[1]['decision']); + $this->assertSame('退回费', $result[1]['surcharge_type']); + $this->assertStringContainsString('无进仓', $result[1]['reason']); + + $this->assertSame('不结', $result[2]['decision']); + $this->assertNotSame('', $result[2]['reason']); + $this->assertNull($result[2]['settlement_amount']); + + $this->assertSame('不结', $result[3]['decision']); + $this->assertStringContainsString('恒思德邦', $result[3]['reason']); + $this->assertSame('恒思德邦', $result[3]['courier_name']); + + $this->assertSame('不结', $result[4]['decision']); + $this->assertStringContainsString('不一致', $result[4]['reason']); + } + + private function createTable(): void + { + Schema::connection('business_mirror')->create( + CourierSurchargeSettlementMirrorRepository::TABLE, + function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->unsignedInteger('rule_id'); + $table->string('surcharge_type', 40); + $table->string('courier_name', 191); + $table->string('waybill', 191)->nullable(); + $table->unsignedBigInteger('outbound_io_id')->nullable(); + $table->string('platform_product_id', 191)->nullable(); + $table->string('product_code', 191)->nullable(); + $table->string('shop_name', 191)->nullable(); + $table->string('online_order_no', 191)->nullable(); + $table->decimal('length', 12, 2)->nullable(); + $table->decimal('width', 12, 2)->nullable(); + $table->decimal('height', 12, 2)->nullable(); + $table->decimal('volume', 16, 2)->nullable(); + $table->string('inspection_type', 60)->nullable(); + $table->string('inspection_reason', 120)->nullable(); + $table->string('return_waybill_no', 191)->nullable(); + $table->decimal('settlement_weight', 12, 2)->nullable(); + $table->decimal('settlement_amount', 12, 2)->default(0); + $table->string('settlement_status', 40)->default('已计算'); + $table->string('evidence', 500)->nullable(); + $table->dateTime('shipped_at')->nullable(); + $table->dateTime('pulled_at'); + $table->timestamps(); + }, + ); + } +}