按工作平台 OmsDataDay 源码 + 9.1-9.16 逐单对比反推,合并/明细基础信息一致率均达 99.9% 以上。
取数范围
- 换单配对:快递明细旧单号在出库单查不到时,配同订单下未被占用的出库单新单号
- 作废重出:同单号/同订单多张出库单时优先有效单、再取最新 io_date
- 订单已取消/已拆分/已合并的快递明细不出行;订单标签含"线上发货"同样排除
- 合包/拼单:按 单号+订单号 定位出库单;同单号不同交易单号的出库单照常补出
- 发货明细无单号:出库单单号 -> 订单单号 -> 空,不再借运费表里其它包裹单号
- 自动发货仓虚拟/补差单:单号已被别的包裹输出则留空
- @ 去重只在同一交易单号、本次同步时间段内比对,剩余 @ 前缀统一去掉
字段取值
- 发货时间取出库单 io_date;平台取视图 shop_site(无则映射);省市区取视图
- 合并商品名称/供应商取包裹内最后一个商品(原为 MIN);名称 & 转 ^
- 明细体积:数量×长×宽×高,代发仓非标准承运商不乘数量,指定快递为 0(config detail_volume)
- 组合件合并值单独存 m_* 列(新迁移),合并导出优先使用
- 仓库 {未指定发货仓} 导出为空;明细 0 尺寸/体积导出为空
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhry6eiuwf
307 lines
12 KiB
PHP
307 lines
12 KiB
PHP
<?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];
|
||
}
|
||
|
||
return [
|
||
'code' => (string) ($row->status ?? 'unavailable'),
|
||
'last_succeeded_at' => $row->last_succeeded_at ?? null,
|
||
];
|
||
} catch (\Throwable) {
|
||
return ['code' => 'unavailable', 'last_succeeded_at' => null];
|
||
}
|
||
}
|
||
}
|