CaiWuXiTong/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php
limeng 8fba83d768 merge: 合并最新 main 到发货数据分支(保留线上修复 + 对齐工作平台规则)
以 main 的同步实现为底(超时重连、按 io_date 预载出库映射、逐包裹替换并保留人工字段、
未扫到旧记录保留待核、全表扫描),移植发货数据对齐工作平台的规则:
- 出库映射预载改为富结构:单号/订单/单号+订单 三种定位,作废重出优先有效单,记录 io_date 作发货时间
- 订单元数据补查增加 状态(取消/拆分/合并) 与 线上发货 标记,替代原 LEFT JOIN;全表扫描后释放缓存
- 换单配对、出库单兜底、采购退货出库、组合件拆件、明细体积口径、仓库/名称清洗
- 按规则明确排除的包裹(取消/拆分/合并/线上发货)在本窗口内清除旧记录,其余未扫到记录仍按 main 逻辑保留
- @ 前缀:沿用 main 的同订单同日去重,剩余 @ 统一去掉
- 同步命令额外输出出库单兜底、采购退货条数(仅窗口模式返回)

验证:发货同步单元测试 29/29、发货页面测试 5/5 通过;本地清空 8.15-9.30 后全量同步 9.1-9.16,
与工作平台对比两边共有包裹 195,555,带单号漏拉仅 1 单(工作平台拉数后单号被改)。
BusinessDataPlacementTest(数据集数量写死 56,实际 66)在 main 上本就失败,与本次无关。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhry6eiuwf
2026-10-07 16:13:35 +08:00

319 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
/**
* 发货明细只读仓库。读取扁平表 jst_oms_shipping(对齐工作平台发货数据 47 列),
* 供“发货数据”页面展示与后续快递运费核算使用。
*/
class ShippingOrderRepository
{
private readonly ConnectionInterface $db;
public function __construct()
{
$this->db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
try {
// 合并取"最后一行"值用 GROUP_CONCAT,放宽默认 1024 长度避免截断
$this->db->statement('SET SESSION group_concat_max_len = 4194304');
} catch (\Throwable) {
}
}
/**
* @param array<string,string> $filters
* @return array{data:array,total:int}
*/
public function paginate(array $filters, int $page, int $perPage, string $mode = 'merged'): array
{
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
if ($mode === 'detail') {
$q = $this->filteredQuery($filters);
$total = (clone $q)->count();
$rows = (clone $q)
->orderByDesc('ship_time')
->orderByDesc('source_id')
->orderBy('sku_source_id')
->forPage($page, $perPage)
->get()->map(fn ($r) => $this->detailRow((array) $r))->all();
return ['data' => $rows, 'total' => $total];
}
// 合并:按包裹(source_id)归并
$base = $this->filteredQuery($filters);
$total = (clone $base)->distinct()->count('source_id');
$rows = (clone $base)
->groupBy('source_id')
->selectRaw($this->mergedSelectRaw())
->orderByDesc('ship_time')
->orderByDesc('source_id')
->forPage($page, $perPage)
->get()->map(fn ($r) => (array) $r)->all();
return ['data' => $rows, 'total' => $total];
}
/** 明细口径:无尺寸(0)的长宽高/体积显示为空白,对齐工作平台导出明细(合并保持 0)。 */
private function detailRow(array $r): array
{
foreach (['length_cm', 'width_cm', 'height_cm', 'volume_cm3'] as $c) {
if (array_key_exists($c, $r) && $r[$c] !== null && $r[$c] !== '' && (float) $r[$c] == 0.0) {
$r[$c] = null;
}
}
return $r;
}
/**
* 包裹内"最后一行"(按明细 id 顺序)的值。工作平台合并的商品名称/供应商取的是
* 条码串最后一个商品那行(样本 6213/6216、575/575),不是 MIN。
*/
private function lastOf(string $expr): string
{
// SEPARATOR 只能是字符串常量;用名称里不会出现的 '#~#' 作分隔
return "NULLIF(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE({$expr}, '') ORDER BY `id` SEPARATOR '#~#'), '#~#', -1), '')";
}
/** 合并聚合 SELECT:条码/商品ID 逗号拼接、数量/重量/体积/金额/运费求和、长宽高取最大、其余取值。 */
private function mergedSelectRaw(): string
{
$sum = ['qty', 'freight', 'online_weight', 'actual_weight', 'volume_cm3', 'weight_kg',
'settle_weight_kg', 'settle_volume_cm3', 'recon_weight_kg', 'recon_volume_cm3', 'amount', 'settle_freight'];
$concat = ['item_id', 'barcode'];
$min = ['product_name', 'supplier_name', 'product_code'];
$all = ['express_no', 'trade_no', 'item_id', 'barcode', 'product_name', 'qty', 'pkg_count',
'receiver_state', 'buyer_nick', 'receiver_name', 'shop_name', 'express_name1', 'warehouse_name',
'ship_time', 'freight', 'online_weight', 'actual_weight', 'length_cm', 'width_cm', 'height_cm',
'volume_cm3', 'receiver_city', 'receiver_district', 'supplier_name', 'receiver_address',
'receiver_mobile', 'receiver_phone', 'express_name2', 'platform', 'buyer_memo', 'seller_memo',
'package_no', 'amount', 'product_code', 'download_time', 'pay_time', 'freight_rate', 'freight_formula',
'weight_kg', 'settle_weight_kg', 'settle_volume_cm3', 'recon_weight_kg', 'recon_volume_cm3',
'finished_barcode', 'finished_name', 'finished_supplier', 'settle_freight', 'front_no'];
// 组合件拆件后明细存部件值,合并取整行值(m_*),普通行 m_* 为空则回落原列
$mcol = ['product_name' => 'm_name', 'volume_cm3' => 'm_volume_cm3',
'length_cm' => 'm_length_cm', 'width_cm' => 'm_width_cm', 'height_cm' => 'm_height_cm'];
$parts = ['MAX(id) as id', 'source_id'];
foreach ($all as $c) {
if ($c === 'product_name') {
$parts[] = $this->lastOf("COALESCE(`m_name`, `product_name`)") . " as `{$c}`";
} elseif (isset($mcol[$c])) {
$fn = $c === 'volume_cm3' ? 'SUM' : 'MAX';
$parts[] = "{$fn}(COALESCE(`{$mcol[$c]}`, `{$c}`)) as `{$c}`";
} elseif (in_array($c, $sum, true)) {
$parts[] = "SUM(`{$c}`) as `{$c}`";
} elseif (in_array($c, $concat, true)) {
$parts[] = "GROUP_CONCAT(DISTINCT `{$c}` SEPARATOR ',') as `{$c}`";
} elseif (in_array($c, $min, true)) {
$parts[] = $this->lastOf("`{$c}`") . " as `{$c}`";
} else {
$parts[] = "MAX(`{$c}`) as `{$c}`";
}
}
return implode(', ', $parts);
}
/**
* @param array<string,string> $filters
* @return \Generator<int,array<string,mixed>>
*/
public function exportRows(array $filters, int $batchSize = 1000, string $mode = 'merged'): \Generator
{
$batchSize = min(max($batchSize, 1), 5000);
$last = null;
if ($mode === 'detail') {
while (true) {
$query = $this->filteredQuery($filters);
if ($last !== null) {
$query->where('id', '>', $last);
}
$rows = $query->orderBy('id')->limit($batchSize)->get()->all();
if ($rows === []) {
return;
}
foreach ($rows as $row) {
yield $this->detailRow((array) $row);
}
$last = (int) $rows[array_key_last($rows)]->id;
if (count($rows) < $batchSize) {
return;
}
}
}
// 合并导出:按 source_id 键集分页 + 归并
while (true) {
$query = $this->filteredQuery($filters);
if ($last !== null) {
$query->where('source_id', '>', $last);
}
$rows = $query->groupBy('source_id')->selectRaw($this->mergedSelectRaw())
->orderBy('source_id')->limit($batchSize)->get()->all();
if ($rows === []) {
return;
}
foreach ($rows as $row) {
yield (array) $row;
}
$last = (int) $rows[array_key_last($rows)]->source_id;
if (count($rows) < $batchSize) {
return;
}
}
}
/** @param array<string,string> $filters */
private function filteredQuery(array $filters): Builder
{
$query = $this->db->table('jst_oms_shipping');
if (($filters['express_no'] ?? '') !== '') {
$query->where('express_no', '=', trim((string) $filters['express_no']));
}
if (($filters['sourceno'] ?? '') !== '') {
$query->where('trade_no', '=', trim((string) $filters['sourceno']));
}
// 条形码:单值关键词
if (($filters['barcode'] ?? '') !== '') {
$query->whereRaw("barcode LIKE ? ESCAPE '!'", [$this->contains((string) $filters['barcode'])]);
}
// 供应商/店铺/仓库:多选,精确匹配所选的任意一个
foreach (['supplier_name', 'shop_name', 'warehouse_name'] as $column) {
$values = $this->multi($filters[$column] ?? null);
if ($values !== []) {
$query->whereIn($column, $values);
}
}
// 快递公司:多选,匹配快递名称1/2 任意一个
$couriers = $this->multi($filters['express_company'] ?? null);
if ($couriers !== []) {
$query->where(function (Builder $group) use ($couriers): void {
$group->whereIn('express_name1', $couriers)
->orWhereIn('express_name2', $couriers);
});
}
if (($filters['ship_from'] ?? '') !== '') {
$query->where('ship_time', '>=', $filters['ship_from'] . ' 00:00:00');
}
if (($filters['ship_to'] ?? '') !== '') {
$query->where('ship_time', '<', date('Y-m-d 00:00:00', strtotime($filters['ship_to'] . ' +1 day')));
}
return $query;
}
private function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
}
/**
* 归一多选筛选值为字符串数组(兼容单个字符串)。
* @return array<int,string>
*/
private function multi(mixed $value): array
{
if (is_string($value)) {
$value = $value === '' ? [] : [$value];
}
if (! is_array($value)) {
return [];
}
$out = [];
foreach ($value as $item) {
$item = trim((string) $item);
if ($item !== '' && ! in_array($item, $out, true)) {
$out[] = $item;
}
}
return array_slice($out, 0, 50);
}
/**
* 取某字段去重候选值(供筛选框自动补全)。
* @return array<int,string>
*/
public function distinctValues(string $field, string $keyword, int $limit = 20): array
{
$map = [
'supplier_name' => ['supplier_name'],
'shop_name' => ['shop_name'],
'warehouse_name' => ['warehouse_name'],
'express_company' => ['express_name1', 'express_name2'],
];
if (! isset($map[$field])) {
return [];
}
$limit = min(max($limit, 1), 500);
$like = $this->contains($keyword);
$values = [];
foreach ($map[$field] as $col) {
$rows = $this->db->table('jst_oms_shipping')
->selectRaw($col . ' as v')
->whereRaw($col . " LIKE ? ESCAPE '!'", [$like])
->whereNotNull($col)
->where($col, '<>', '')
->groupBy($col)
->orderBy($col)
->limit($limit)
->pluck('v');
foreach ($rows as $v) {
$v = trim((string) $v);
// 过滤空值与占位脏值(如自动发货仓 send_warehouse 为 "0")
if ($v !== '' && $v !== '0') {
$values[$v] = true;
}
}
}
$values = array_keys($values);
sort($values, SORT_FLAG_CASE | SORT_STRING);
return array_slice($values, 0, $limit);
}
/** 同步状态(供页面显示同步中/最近完成)。 */
public function syncStatus(): array
{
try {
$row = $this->db->table('jst_sync_state')->where('dataset_key', 'jst.sctrade')->first();
if (! $row) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
$freightPending = $this->db->table('jst_sync_state')
->where('dataset_key', 'like', 'jst.sctrade.freight.%')->where('status', '!=', 'succeeded')->count();
$itemsPending = $this->db->table('jst_sync_state')
->where('dataset_key', 'like', 'jst.sctrade.items.%')->where('status', '!=', 'succeeded')->count();
$code = (string) ($row->status ?? 'unavailable');
return [
'code' => ($freightPending || $itemsPending) && $code === 'succeeded' ? 'partial' : $code,
'last_succeeded_at' => $row->last_succeeded_at ?? null,
'freight_pending' => (bool) $freightPending,
'warning' => implode(' ', array_filter([
$code === 'partial' ? '部分包裹尚需与上游源键核对,原有镜像记录已保留。' : null,
$freightPending ? "{$freightPending} 个日期窗口的运费和结算重量待补,空白不代表 0。" : null,
$itemsPending ? "{$itemsPending} 个日期窗口的商品或金额待核对。" : null,
])),
];
} catch (\Throwable) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
}
}