fix(发货数据): 两种导出模式对齐工作平台取值逻辑
按工作平台 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
This commit is contained in:
parent
0e0ea619c6
commit
f06380bcd0
@ -27,7 +27,7 @@ class JstDump extends Command
|
||||
['actual_weight', '实称重量'], ['online_weight', '线上重量'], ['freight', '运费'],
|
||||
['ship_time', '发货时间'], ['pay_time', '付款时间'], ['product_code', '货号'],
|
||||
['receiver_state', '省'], ['receiver_city', '市'], ['receiver_district', '区'],
|
||||
['package_no', '配货单号'], ['amount', '金额'],
|
||||
['package_no', '配货单号'], ['amount', '金额'], ['express_name1', '快递名称'],
|
||||
];
|
||||
$fh = fopen('php://output', 'w');
|
||||
fwrite($fh, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||
|
||||
@ -38,6 +38,21 @@ return [
|
||||
'retry_sleep_ms' => (int) env('JST_RETRY_SLEEP_MS', 1000),
|
||||
],
|
||||
|
||||
// 发货数据「导出明细」体积口径(对齐工作平台 OmsDataDay:明细体积默认 = 数量 × 长×宽×高)。
|
||||
// 依据:工作平台 9.1-9.16 导出明细,非代发仓 8421/8421 行乘数量;代发仓走简尚线路快递的不乘数量。
|
||||
// 合并口径体积不受此影响(单独存 m_volume_cm3)。
|
||||
'detail_volume' => [
|
||||
// 这些发货仓的明细体积不乘数量(工厂代发虚拟仓)
|
||||
'no_qty_warehouses' => ['常州简尚家居有限公司'],
|
||||
// 上述仓库中,用这些标准承运商发货时仍乘数量
|
||||
'qty_carriers_in_no_qty_warehouses' => [
|
||||
'中通快运', '韵达快递', '申通E物流', '安能物流', '邮政电商标快', '邮政快递包裹POSTB',
|
||||
'壹米滴答', '百世物流', '德邦快递', '中通速递', '圆通速递',
|
||||
],
|
||||
// 这些快递的明细体积一律为 0
|
||||
'zero_volume_expresses' => ['白云韵达-天猫亿家达', '中通礼品单'],
|
||||
],
|
||||
|
||||
// 接口方法名(对应工作平台 ErpTradeData 中的调用)。
|
||||
'methods' => [
|
||||
'sctrade_list' => 'erp.sctrade.query', // 出库单列表(按时间窗分页)
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 组合件(视图一行多条码)拆件后:明细行存部件自己的名称/尺寸/体积,
|
||||
* 合并导出改用以下"合并用"列(整行值),对齐工作平台两种口径。普通行这些列为空。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private array $cols = ['m_name', 'm_volume_cm3', 'm_length_cm', 'm_width_cm', 'm_height_cm'];
|
||||
|
||||
private function conn(): \Illuminate\Database\Schema\Builder
|
||||
{
|
||||
return Schema::connection((string) config('jushuitan.connection', 'jushuitan_local'));
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = $this->conn();
|
||||
if (! $schema->hasTable('jst_oms_shipping')) {
|
||||
return;
|
||||
}
|
||||
$schema->table('jst_oms_shipping', function (Blueprint $table) use ($schema): void {
|
||||
if (! $schema->hasColumn('jst_oms_shipping', 'm_name')) {
|
||||
$table->string('m_name', 500)->nullable()->comment('合并用商品名称(组合件整行)');
|
||||
}
|
||||
if (! $schema->hasColumn('jst_oms_shipping', 'm_volume_cm3')) {
|
||||
$table->decimal('m_volume_cm3', 18, 4)->nullable()->comment('合并用体积(组合件整行均摊)');
|
||||
}
|
||||
foreach (['m_length_cm' => '长', 'm_width_cm' => '宽', 'm_height_cm' => '高'] as $c => $label) {
|
||||
if (! $schema->hasColumn('jst_oms_shipping', $c)) {
|
||||
$table->decimal($c, 12, 2)->nullable()->comment('合并用' . $label . '(组合件整行)');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = $this->conn();
|
||||
if (! $schema->hasTable('jst_oms_shipping')) {
|
||||
return;
|
||||
}
|
||||
$drop = array_values(array_filter($this->cols, fn ($c) => $schema->hasColumn('jst_oms_shipping', $c)));
|
||||
if ($drop) {
|
||||
$schema->table('jst_oms_shipping', function (Blueprint $table) use ($drop): void {
|
||||
$table->dropColumn($drop);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -17,6 +17,11 @@ class ShippingOrderRepository
|
||||
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) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -36,7 +41,7 @@ class ShippingOrderRepository
|
||||
->orderByDesc('source_id')
|
||||
->orderBy('sku_source_id')
|
||||
->forPage($page, $perPage)
|
||||
->get()->map(fn ($r) => (array) $r)->all();
|
||||
->get()->map(fn ($r) => $this->detailRow((array) $r))->all();
|
||||
|
||||
return ['data' => $rows, 'total' => $total];
|
||||
}
|
||||
@ -55,6 +60,28 @@ class ShippingOrderRepository
|
||||
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
|
||||
{
|
||||
@ -71,14 +98,22 @@ class ShippingOrderRepository
|
||||
'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 (in_array($c, $sum, true)) {
|
||||
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[] = "MIN(`{$c}`) as `{$c}`";
|
||||
$parts[] = $this->lastOf("`{$c}`") . " as `{$c}`";
|
||||
} else {
|
||||
$parts[] = "MAX(`{$c}`) as `{$c}`";
|
||||
}
|
||||
@ -107,7 +142,7 @@ class ShippingOrderRepository
|
||||
return;
|
||||
}
|
||||
foreach ($rows as $row) {
|
||||
yield (array) $row;
|
||||
yield $this->detailRow((array) $row);
|
||||
}
|
||||
$last = (int) $rows[array_key_last($rows)]->id;
|
||||
if (count($rows) < $batchSize) {
|
||||
|
||||
@ -25,10 +25,12 @@ class ShippingReplicaSynchronizer
|
||||
private const DATASET = 'jst.sctrade';
|
||||
private const AUTOSHIP_OFFSET = 1000000000;
|
||||
private const OUTBOUND_OFFSET = 2000000000;
|
||||
private const PURCHASE_OUT_OFFSET = 3000000000;
|
||||
private const FLUSH = 300;
|
||||
|
||||
private const VIEW = 'rpt.Jst_SendData_View_extension';
|
||||
private const OUTBOUND = 'rpt.jst_salesOutboundInfo';
|
||||
private const PURCHASE_OUT = 'rpt.jst_purchaseout';
|
||||
|
||||
private readonly \Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery $up;
|
||||
private readonly \Illuminate\Database\ConnectionInterface $db;
|
||||
@ -45,6 +47,24 @@ class ShippingReplicaSynchronizer
|
||||
/** @var array<string,bool> 本轮已处理的快递单号(去@) */
|
||||
private array $doneTrack = [];
|
||||
|
||||
/** 已实际输出的快递单号(去@),用于判断虚拟/补差单是否借用了别的包裹单号 */
|
||||
private array $emittedTrack = [];
|
||||
|
||||
/** 快递单号(去@) => [交易单号 => true]:同一单号被不同交易单号共用(合包/拼单)时,按交易单号区分是否已处理 */
|
||||
private array $doneTrackSo = [];
|
||||
|
||||
/** @var array<string,string> 最近一次 loadViewItems 的 io_id => 平台(视图 shop_site,工作平台口径) */
|
||||
private array $ioSite = [];
|
||||
|
||||
/** @var array<string,array{st?:string,ci?:string,di?:string}> 最近一次 loadViewItems 的 io_id => 视图收货省市区(工作平台口径) */
|
||||
private array $ioAddr = [];
|
||||
|
||||
/** @var array<string,string> io_id => 出库时间 io_date(工作平台发货时间口径) */
|
||||
private array $ioDate = [];
|
||||
|
||||
/** @var array<string,string> 最近一次 loadViewItems 的 io_id => 视图店铺名(发货明细缺店铺时兜底) */
|
||||
private array $ioShop = [];
|
||||
|
||||
public function __construct(UpstreamReadOnlyQueryFactory $factory)
|
||||
{
|
||||
$this->up = $factory->forSource(self::SOURCE);
|
||||
@ -76,8 +96,12 @@ class ShippingReplicaSynchronizer
|
||||
$total = 0;
|
||||
$auto = 0;
|
||||
$outb = 0;
|
||||
$pout = 0;
|
||||
$this->doneIo = [];
|
||||
$this->doneTrack = [];
|
||||
$this->emittedTrack = [];
|
||||
$this->doneTrackSo = [];
|
||||
$this->ioDate = [];
|
||||
if ($windowed) {
|
||||
$this->preloadDws($from, $to);
|
||||
if ($progress) {
|
||||
@ -86,9 +110,10 @@ class ShippingReplicaSynchronizer
|
||||
$total = $this->syncExpress(true, $fromS, $toS, $limit, $now, $progress);
|
||||
$auto = $this->syncAutoShip($fromS, $toS, $now, $progress);
|
||||
$outb = $this->syncOutbound($fromS, $toS, $now, $progress);
|
||||
$pout = $this->syncPurchaseOut($fromS, $toS, $now, $progress);
|
||||
$this->dwsCache = [];
|
||||
gc_collect_cycles();
|
||||
$removed = $this->dedupAtPrefix();
|
||||
$removed = $this->dedupAtPrefix($fromS, $toS);
|
||||
if ($progress && $removed > 0) {
|
||||
$progress('去除@重复单号:' . $removed . ' 条');
|
||||
}
|
||||
@ -97,7 +122,7 @@ class ShippingReplicaSynchronizer
|
||||
}
|
||||
|
||||
$this->markSucceeded();
|
||||
return ['processed' => $total, 'autoship' => $auto, 'outbound' => $outb];
|
||||
return ['processed' => $total, 'autoship' => $auto, 'outbound' => $outb, 'purchase_out' => $pout];
|
||||
} catch (Throwable $e) {
|
||||
$this->markFailed($e->getMessage());
|
||||
throw $e;
|
||||
@ -105,29 +130,46 @@ class ShippingReplicaSynchronizer
|
||||
}
|
||||
|
||||
/** 去除以 @ 开头、去@后已存在的重复单号(换单/重拉产生;工作平台只保留干净版)。 */
|
||||
public function dedupAtPrefix(): int
|
||||
public function dedupAtPrefix(?string $fromS = null, ?string $toS = null): int
|
||||
{
|
||||
$all = $this->db->table('jst_oms_shipping')
|
||||
->whereNotNull('express_no')->where('express_no', '!=', '')
|
||||
->distinct()->pluck('express_no')->all();
|
||||
$set = [];
|
||||
foreach ($all as $e) {
|
||||
$set[(string) $e] = true;
|
||||
// 只删"同一交易单号下 @X 与 X 并存"的 @X 行;不同交易单号/窗口外的旧行不作为依据,
|
||||
// 避免窗口外残留的旧 X 行把窗口内真实包裹 @X 误删(如作废重出的出库单)。
|
||||
// 只在本次同步窗口内互相比对:窗口外的历史残留行不参与(否则会误删窗口内真实包裹)
|
||||
$win = function ($q) use ($fromS, $toS) {
|
||||
if ($fromS !== null && $toS !== null) {
|
||||
$q->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS);
|
||||
}
|
||||
|
||||
return $q;
|
||||
};
|
||||
$at = $win($this->db->table('jst_oms_shipping')->where('express_no', 'like', '@%'))
|
||||
->select('express_no', 'trade_no')->distinct()->get();
|
||||
if ($at->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
$toDel = [];
|
||||
foreach (array_keys($set) as $e) {
|
||||
$e = (string) $e;
|
||||
if ($e !== '' && $e[0] === '@' && isset($set[substr($e, 1)])) {
|
||||
$toDel[] = $e;
|
||||
$plain = [];
|
||||
foreach ($at as $r) {
|
||||
$plain[substr((string) $r->express_no, 1)] = true;
|
||||
}
|
||||
$have = [];
|
||||
foreach (array_chunk(array_keys($plain), 500) as $chunk) {
|
||||
foreach ($win($this->db->table('jst_oms_shipping')->whereIn('express_no', $chunk))
|
||||
->select('express_no', 'trade_no')->distinct()->get() as $r) {
|
||||
$have[(string) $r->express_no . '|' . (string) $r->trade_no] = true;
|
||||
}
|
||||
}
|
||||
$removed = 0;
|
||||
foreach (array_chunk($toDel, 500) as $chunk) {
|
||||
if ($chunk === []) {
|
||||
continue;
|
||||
foreach ($at as $r) {
|
||||
$x = substr((string) $r->express_no, 1);
|
||||
if (isset($have[$x . '|' . (string) $r->trade_no])) {
|
||||
$removed += (int) $win($this->db->table('jst_oms_shipping')
|
||||
->where('express_no', (string) $r->express_no)
|
||||
->where('trade_no', $r->trade_no))->delete();
|
||||
}
|
||||
$removed += (int) $this->db->table('jst_oms_shipping')->whereIn('express_no', $chunk)->delete();
|
||||
}
|
||||
// 剩余 @X:工作平台导出单号不带 @,统一去掉前缀
|
||||
$win($this->db->table('jst_oms_shipping')->where('express_no', 'like', '@%'))
|
||||
->update(['express_no' => $this->db->raw('SUBSTRING(express_no, 2)')]);
|
||||
|
||||
return $removed;
|
||||
}
|
||||
@ -139,7 +181,8 @@ class ShippingReplicaSynchronizer
|
||||
$maxBatches = 5000;
|
||||
$cols = 'e.id, e.so_id, e.inner_order_id, e.logistic_id, e.logistic_company_name, e.send_time, e.pay_time, '
|
||||
. 'e.shop_name, e.platform_name, e.dis_co_name, e.warehouse_name, e.receiver_address, '
|
||||
. 'e.weight, e.f_weight, e.buyer_message, e.order_labels, jo.l_id AS jo_lid, jo.send_date AS jo_send';
|
||||
. 'e.weight, e.f_weight, e.buyer_message, e.order_labels, jo.l_id AS jo_lid, jo.send_date AS jo_send, '
|
||||
. 'jo.status AS jo_status, jo.labels AS jo_labels';
|
||||
|
||||
// 窗口模式:只按主键 e.id 翻页(有索引,毫秒级),send_time 窗口过滤放到 PHP,
|
||||
// 避免每页都对无索引的 send_time 做全表扫描(那是"卡死"的真凶)。
|
||||
@ -220,9 +263,12 @@ class ShippingReplicaSynchronizer
|
||||
}
|
||||
// 主口径:发货数据视图按出库单(io_id)取实发商品
|
||||
$ioMap = $this->loadIoMap(array_keys($logs), array_keys($oids));
|
||||
$swap = $this->pairChangedTracking($rows, $ioMap);
|
||||
$ioIds = [];
|
||||
foreach ($rows as $r) {
|
||||
$io = $this->ioFor($ioMap, (string) ($r['logistic_id'] ?? ''), (string) ($r['inner_order_id'] ?? ''));
|
||||
$io = isset($swap[(string) ($r['id'] ?? '')])
|
||||
? $swap[(string) $r['id']]['io']
|
||||
: $this->ioFor($ioMap, (string) ($r['logistic_id'] ?? ''), (string) ($r['inner_order_id'] ?? ''));
|
||||
if ($io !== '') {
|
||||
$ioIds[$io] = true;
|
||||
}
|
||||
@ -236,23 +282,48 @@ class ShippingReplicaSynchronizer
|
||||
if ($pkgId === 0) {
|
||||
continue;
|
||||
}
|
||||
if (in_array((string) ($r['jo_status'] ?? ''), ['Cancelled', 'Split', 'Merged'], true)) {
|
||||
// 订单已取消/已拆分/已合并:实际发货在子单或合并单上,工作平台不出此行。
|
||||
// 只登记本行自己的单号,不登记订单单号(可能是子单真实单号,需留给出库单兜底)
|
||||
$this->markDone('', (string) ($r['logistic_id'] ?? ''));
|
||||
$this->markTrackSo((string) ($r['so_id'] ?? ''), (string) ($r['logistic_id'] ?? ''));
|
||||
continue;
|
||||
}
|
||||
$sw = $swap[(string) $pkgId] ?? null;
|
||||
if ($sw !== null) {
|
||||
// 换单:快递明细仍是旧单号,出库单已换成新单号 → 用新单号与对应出库单(工作平台口径)
|
||||
$r['logistic_id'] = $sw['l_id'];
|
||||
}
|
||||
$this->markDone(
|
||||
$this->ioFor($ioMap, (string) ($r['logistic_id'] ?? ''), (string) ($r['inner_order_id'] ?? '')),
|
||||
$sw !== null ? $sw['io'] : $this->ioFor($ioMap, (string) ($r['logistic_id'] ?? ''), (string) ($r['inner_order_id'] ?? '')),
|
||||
(string) ($r['logistic_id'] ?? ''),
|
||||
(string) ($r['jo_lid'] ?? '')
|
||||
(string) ($r['jo_lid'] ?? ''),
|
||||
$sw !== null ? $sw['old'] : ''
|
||||
);
|
||||
if ($this->excludedByLabel($r['order_labels'] ?? null)) {
|
||||
continue; // 线上发货(平台代发/云仓):工作平台不拉,对齐排除
|
||||
$this->markTrackSo((string) ($r['so_id'] ?? ''), (string) ($r['logistic_id'] ?? ''), (string) ($r['jo_lid'] ?? ''));
|
||||
if ($this->excludedByLabel($r['order_labels'] ?? null) || $this->excludedByLabel($r['jo_labels'] ?? null)) {
|
||||
continue; // 线上发货(平台代发/云仓):工作平台不拉,对齐排除(发货明细标签可能滞后,订单标签一并判断)
|
||||
}
|
||||
$so = (string) ($r['so_id'] ?? '');
|
||||
$oid = (string) ($r['inner_order_id'] ?? '');
|
||||
$io = $this->ioFor($ioMap, (string) ($r['logistic_id'] ?? ''), $oid);
|
||||
$io = $sw !== null ? $sw['io'] : $this->ioFor($ioMap, (string) ($r['logistic_id'] ?? ''), $oid);
|
||||
$items = ($io !== '' && ! empty($viewItems[$io]))
|
||||
? $viewItems[$io]
|
||||
: $this->itemsFor($byOid, $bySo, $dws, $oid, $so);
|
||||
$tracking = $this->tracking((string) ($r['logistic_id'] ?? ''), (string) ($r['jo_lid'] ?? ''), $freight, $so);
|
||||
$shipTime = $this->ts($r['send_time'] ?? null) ?? $this->ts($r['jo_send'] ?? null);
|
||||
if (trim((string) ($r['logistic_id'] ?? '')) === '') {
|
||||
// 发货明细无单号:出库单单号 → 订单单号 → 空(不借运费表里同订单其它包裹的单号)
|
||||
$ioLid = ($io !== '') ? (string) ($ioMap['ioL'][$io] ?? '') : '';
|
||||
$joLid = trim((string) ($r['jo_lid'] ?? ''));
|
||||
$tracking = $ioLid !== '' ? $ioLid : ($joLid !== '' ? $joLid : null);
|
||||
} else {
|
||||
$tracking = $this->tracking((string) ($r['logistic_id'] ?? ''), (string) ($r['jo_lid'] ?? ''), $freight, $so);
|
||||
}
|
||||
// 发货时间 = 出库单 io_date(工作平台口径,样本 400/400 一致),缺失时回落发货明细时间
|
||||
$shipTime = ($io !== '' ? $this->ts($this->ioDate[$io] ?? null) : null)
|
||||
?? $this->ts($r['send_time'] ?? null) ?? $this->ts($r['jo_send'] ?? null);
|
||||
$pkg = $this->pkgFromExpress($r, $tracking, $shipTime);
|
||||
$pkg['platform'] = $this->platformOf($io, $pkg['platform']);
|
||||
$pkg = $this->withViewAddr($io, $pkg);
|
||||
$total += $this->emitPackage($pkgId, $pkg, $items, $skus, $freight, $so, $tracking, $now);
|
||||
}
|
||||
|
||||
@ -352,7 +423,13 @@ class ShippingReplicaSynchronizer
|
||||
if ($oid === '') {
|
||||
continue;
|
||||
}
|
||||
// 单号已属于其它包裹(发货明细已出):本单是虚拟/补差单,工作平台此类行快递单号为空
|
||||
$tracking = $this->tracking((string) ($r['l_id'] ?? ''), '', $freight, (string) ($r['so_id'] ?? ''));
|
||||
if ($tracking !== null && isset($this->emittedTrack[ltrim(trim($tracking), '@')])) {
|
||||
$tracking = null;
|
||||
}
|
||||
$this->markDone($this->ioFor($ioMap, (string) ($r['l_id'] ?? ''), $oid), (string) ($r['l_id'] ?? ''));
|
||||
$this->markTrackSo((string) ($r['so_id'] ?? ''), (string) ($r['l_id'] ?? ''));
|
||||
if ($this->excludedByLabel($r['labels'] ?? null)) {
|
||||
continue; // 线上发货:对齐排除
|
||||
}
|
||||
@ -361,9 +438,10 @@ class ShippingReplicaSynchronizer
|
||||
$items = ($io !== '' && ! empty($viewItems[$io]))
|
||||
? $viewItems[$io]
|
||||
: $this->itemsFor($byOid, $bySo, $dws, $oid, $so);
|
||||
$tracking = $this->tracking((string) ($r['l_id'] ?? ''), '', $freight, $so);
|
||||
$shipTime = $this->ts($r['send_date'] ?? null);
|
||||
$shipTime = ($io !== '' ? $this->ts($this->ioDate[$io] ?? null) : null) ?? $this->ts($r['send_date'] ?? null);
|
||||
$pkg = $this->pkgFromOrder($r, $tracking, $shipTime);
|
||||
$pkg['platform'] = $this->platformOf($io, $pkg['platform']);
|
||||
$pkg = $this->withViewAddr($io, $pkg);
|
||||
$pkgId = self::AUTOSHIP_OFFSET + (int) $oid;
|
||||
$total += $this->emitPackage($pkgId, $pkg, $items, $skus, $freight, $so, $tracking, $now, true);
|
||||
}
|
||||
@ -383,6 +461,40 @@ class ShippingReplicaSynchronizer
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台统一为工作平台口径:优先取发货数据视图 shop_site;视图没有时把原始平台名映射到工作平台归类名。
|
||||
*/
|
||||
private function platformOf(string $io, mixed $raw): ?string
|
||||
{
|
||||
if ($io !== '' && isset($this->ioSite[$io])) {
|
||||
return $this->ioSite[$io];
|
||||
}
|
||||
$raw = trim((string) ($raw ?? ''));
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
$base = strtoupper(explode(',', $raw)[0]);
|
||||
$map = [
|
||||
'天猫' => '淘宝天猫', '淘宝' => '淘宝天猫', '京东' => '京东商城', 'MOBILE' => '京东商城', 'PC' => '京东商城',
|
||||
'1688' => '阿里巴巴', '抖音' => '头条放心购', 'TOUTIAOFXG' => '头条放心购', 'PINDUODUO' => '拼多多',
|
||||
'DEWU' => '得物', '线下' => '线下店铺',
|
||||
];
|
||||
|
||||
return $map[$base] ?? $raw;
|
||||
}
|
||||
|
||||
/** 登记 单号×交易单号 已处理。 */
|
||||
private function markTrackSo(string $so, string ...$tracks): void
|
||||
{
|
||||
$so = trim($so);
|
||||
foreach ($tracks as $t) {
|
||||
$t = ltrim(trim($t), '@');
|
||||
if ($t !== '') {
|
||||
$this->doneTrackSo[$t][$so] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 登记已处理的出库单/快递单号,供兜底 pass 防重复。 */
|
||||
private function markDone(string $io, string ...$tracks): void
|
||||
{
|
||||
@ -443,10 +555,12 @@ class ShippingReplicaSynchronizer
|
||||
$skip['notrack']++;
|
||||
continue;
|
||||
}
|
||||
if (isset($this->doneIo[$io]) || isset($this->doneTrack[ltrim($lid, '@')])) {
|
||||
if (isset($this->doneIo[$io])) {
|
||||
$skip['done']++;
|
||||
continue;
|
||||
}
|
||||
// 单号已处理过:若是别的交易单号共用此单号(合包/拼单),仍要补吐;交易单号要等视图表头加载后判断
|
||||
$r['_trk_done'] = isset($this->doneTrack[ltrim($lid, '@')]);
|
||||
$st = (string) ($r['status'] ?? '');
|
||||
if (stripos($st, 'cancel') !== false || stripos($st, 'delete') !== false) {
|
||||
$skip['status']++;
|
||||
@ -480,9 +594,18 @@ class ShippingReplicaSynchronizer
|
||||
$h = $heads[$io] ?? [];
|
||||
$so = $this->outboundSo($r, $h);
|
||||
$tracking = trim((string) $r['l_id']);
|
||||
if (! empty($r['_trk_done'])) {
|
||||
$t0 = ltrim($tracking, '@');
|
||||
if ($so === '' || empty($this->doneTrackSo[$t0]) || isset($this->doneTrackSo[$t0][$so])) {
|
||||
$skip['done']++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$pkg = $this->pkgFromOutbound($r, $tracking, $this->ts($r['io_date'] ?? null), $h, $so);
|
||||
$pkg = $this->withViewAddr($io, $pkg);
|
||||
$total += $this->emitPackage(self::OUTBOUND_OFFSET + (int) $io, $pkg, $viewItems[$io], $skus, $freight, $so, $tracking, $now, true);
|
||||
$this->markDone($io, $tracking);
|
||||
$this->markTrackSo($so, $tracking);
|
||||
$emitted++;
|
||||
}
|
||||
unset($viewItems, $heads, $skus, $freight);
|
||||
@ -502,6 +625,106 @@ class ShippingReplicaSynchronizer
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 采购退货出库(退回供应商):工作平台的发货数据视图收录了其中走物流的那部分,按视图补吐。
|
||||
* 无交易单号/店铺/平台;仓库取视图(发货仓),收货信息取采购退货单。
|
||||
*/
|
||||
private function syncPurchaseOut(string $fromS, string $toS, $now, ?callable $progress = null): int
|
||||
{
|
||||
$rows = $this->up->select(
|
||||
'SELECT io_id, io_date, status, l_id, so_id, logistics_company, receiver_state, receiver_city, receiver_district, '
|
||||
. 'receiver_address, receiver_name, receiver_mobile FROM ' . self::PURCHASE_OUT . ' WHERE io_date >= ? AND io_date < ?',
|
||||
[$fromS, $toS]
|
||||
);
|
||||
$pick = [];
|
||||
$skip = ['done' => 0, 'cancel' => 0, 'noitem' => 0];
|
||||
foreach ($rows as $r) {
|
||||
$io = (string) ($r['io_id'] ?? '');
|
||||
if ($io === '' || isset($pick[$io])) {
|
||||
continue;
|
||||
}
|
||||
$lid = trim((string) ($r['l_id'] ?? ''));
|
||||
if (isset($this->doneIo[$io]) || ($lid !== '' && isset($this->doneTrack[ltrim($lid, '@')]))) {
|
||||
$skip['done']++;
|
||||
continue;
|
||||
}
|
||||
if (stripos((string) ($r['status'] ?? ''), 'cancel') !== false) {
|
||||
$skip['cancel']++;
|
||||
continue;
|
||||
}
|
||||
$pick[$io] = $r;
|
||||
}
|
||||
$total = 0;
|
||||
$emitted = 0;
|
||||
if ($pick !== []) {
|
||||
$ioIds = array_map('strval', array_keys($pick));
|
||||
$viewItems = $this->loadViewItems($ioIds);
|
||||
// 采购退货:每个条码数量取采购退货明细(jst_purchaseoutItem),体积=单件长宽高×数量(对齐工作平台)
|
||||
$poQty = [];
|
||||
foreach (array_chunk($ioIds, 800) as $ck) {
|
||||
$ph = implode(',', array_fill(0, count($ck), '?'));
|
||||
foreach ($this->up->select("SELECT io_id, sku_id, qty FROM rpt.jst_purchaseoutItem WHERE io_id IN ({$ph})", $ck) as $pr) {
|
||||
$pk = mb_strtolower(trim((string) ($pr['sku_id'] ?? '')));
|
||||
$poQty[(string) $pr['io_id']][$pk] = ($poQty[(string) $pr['io_id']][$pk] ?? 0) + (float) ($pr['qty'] ?? 0);
|
||||
}
|
||||
}
|
||||
foreach ($viewItems as $vio => $vitems) {
|
||||
foreach ($vitems as $vi => $it) {
|
||||
$pq = $poQty[(string) $vio][mb_strtolower((string) $it['sku'])] ?? null;
|
||||
if ($pq === null || $pq <= 0) {
|
||||
continue;
|
||||
}
|
||||
$pq = (int) round($pq);
|
||||
$viewItems[$vio][$vi]['qty'] = $pq;
|
||||
}
|
||||
}
|
||||
$skus = $this->loadSkus($this->collectSkuIds($viewItems));
|
||||
foreach ($pick as $io => $r) {
|
||||
$io = (string) $io;
|
||||
if (empty($viewItems[$io])) {
|
||||
$skip['noitem']++;
|
||||
continue;
|
||||
}
|
||||
$lid = trim((string) ($r['l_id'] ?? ''));
|
||||
$tracking = $lid !== '' ? $lid : null;
|
||||
$so = trim((string) ($r['so_id'] ?? ''));
|
||||
$pkg = [
|
||||
'tracking' => $tracking,
|
||||
'so_id' => $so !== '' ? $so : null,
|
||||
'express_name1' => $r['logistics_company'] ?? null,
|
||||
'ship_time' => $this->ts($r['io_date'] ?? null),
|
||||
'pay_time' => null,
|
||||
'shop_name' => null,
|
||||
'platform' => $this->ioSite[$io] ?? null,
|
||||
'dis_co_name' => null,
|
||||
'warehouse_name' => null,
|
||||
'r_state' => $r['receiver_state'] ?? null,
|
||||
'r_city' => $r['receiver_city'] ?? null,
|
||||
'r_district' => $r['receiver_district'] ?? null,
|
||||
'r_address' => (string) ($r['receiver_address'] ?? ''),
|
||||
'r_name' => $r['receiver_name'] ?? null,
|
||||
'r_mobile' => $r['receiver_mobile'] ?? null,
|
||||
'r_phone' => null,
|
||||
'buyer_memo' => null,
|
||||
'weight' => null,
|
||||
];
|
||||
$pkg = $this->withViewAddr($io, $pkg);
|
||||
$total += $this->emitPackage(self::PURCHASE_OUT_OFFSET + (int) $io, $pkg, $viewItems[$io], $skus, [], $so, $tracking, $now, true);
|
||||
$this->markDone($io, (string) $tracking);
|
||||
$emitted++;
|
||||
}
|
||||
}
|
||||
$this->flush();
|
||||
if ($progress) {
|
||||
$progress(sprintf(
|
||||
'采购退货出库完成:窗口 %d 单,补吐 %d 包裹 / %d 行;跳过 已处理%d 已取消%d 视图无商品%d',
|
||||
count($rows), $emitted, $total, $skip['done'], $skip['cancel'], $skip['noitem']
|
||||
));
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/** io_id => 视图表头字段(平台/交易单号/省市区/店铺/快递),出库单表头缺失时兜底用 */
|
||||
private function loadViewHeads(array $ioIds): array
|
||||
{
|
||||
@ -564,6 +787,10 @@ class ShippingReplicaSynchronizer
|
||||
{
|
||||
$items = array_values(array_filter($items, fn ($it) => ! $this->isPricePatch($it['name'] ?? null)));
|
||||
$n = count($items);
|
||||
$tk = ltrim(trim((string) ($tracking ?? '')), '@');
|
||||
if ($tk !== '') {
|
||||
$this->emittedTrack[$tk] = true;
|
||||
}
|
||||
$f = $freight[$so . '|' . ($tracking ?? '')] ?? ($freight[$so . '|'] ?? []);
|
||||
|
||||
if ($n === 0) {
|
||||
@ -584,7 +811,49 @@ class ShippingReplicaSynchronizer
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出明细体积(对齐工作平台 OmsDataDay):默认 数量×长×宽×高;
|
||||
* 代发仓(配置)且非标准承运商 → 长×宽×高;特定快递 → 0;无尺寸 → 空。
|
||||
*/
|
||||
private function detailVolume(mixed $L, mixed $W, mixed $H, float $qty, string $warehouse, string $express): ?float
|
||||
{
|
||||
$cfg = (array) config('jushuitan.detail_volume', []);
|
||||
$express = trim($express);
|
||||
if ($express !== '' && in_array($express, (array) ($cfg['zero_volume_expresses'] ?? []), true)) {
|
||||
return 0.0;
|
||||
}
|
||||
$l = $this->nz($L);
|
||||
$w = $this->nz($W);
|
||||
$h = $this->nz($H);
|
||||
if (! $l || ! $w || ! $h) {
|
||||
return null;
|
||||
}
|
||||
$unit = $l * $w * $h;
|
||||
$noQty = in_array(trim($warehouse), (array) ($cfg['no_qty_warehouses'] ?? []), true)
|
||||
&& ! in_array($express, (array) ($cfg['qty_carriers_in_no_qty_warehouses'] ?? []), true);
|
||||
|
||||
return round($noQty ? $unit : $unit * max($qty, 1), 4);
|
||||
}
|
||||
|
||||
/** 组装一件明细行。包裹级字段(实称重量/运费)仅首件携带。 */
|
||||
/** 文本对齐工作平台:工作平台入库时把 & 替换成 ^(如 ">" 导出为 "^gt;",样本 22/22)。 */
|
||||
private function wpText(mixed $v): ?string
|
||||
{
|
||||
if ($v === null || $v === '') {
|
||||
return $v === '' ? '' : null;
|
||||
}
|
||||
|
||||
return str_replace('&', '^', (string) $v);
|
||||
}
|
||||
|
||||
/** 仓库名:聚水潭占位名"{未指定发货仓}"在工作平台仓库表里没有对应,导出为空。 */
|
||||
private function whName(mixed $w): ?string
|
||||
{
|
||||
$w = trim((string) ($w ?? ''));
|
||||
|
||||
return ($w === '' || $w === '{未指定发货仓}') ? null : $w;
|
||||
}
|
||||
|
||||
private function buildRow(array $pkg, array $it, array $sk, array $f, bool $first, string $countStr, $now, bool $auto): array
|
||||
{
|
||||
$qty = (float) ($it['qty'] ?? 0);
|
||||
@ -604,7 +873,7 @@ class ShippingReplicaSynchronizer
|
||||
'trade_no' => $pkg['so_id'],
|
||||
'item_id' => $it['shopid'] ?? null,
|
||||
'barcode' => $it['sku'] ?? null,
|
||||
'product_name' => ($it['name'] ?? null) ?: ($sk['name'] ?? null),
|
||||
'product_name' => $this->wpText(($it['name'] ?? null) ?: ($sk['name'] ?? null)),
|
||||
'qty' => $it['qty'] ?? null,
|
||||
'pkg_count' => $countStr,
|
||||
'product_code' => ($it['iid'] ?? null) ?: ($sk['iid'] ?? null),
|
||||
@ -615,7 +884,7 @@ class ShippingReplicaSynchronizer
|
||||
'shop_name' => $pkg['shop_name'],
|
||||
'platform' => $pkg['platform'],
|
||||
'supplier_name' => ($it['supplier'] ?? null) ?: (($sk['supplier'] ?? null) ?: $pkg['dis_co_name']),
|
||||
'warehouse_name' => $pkg['warehouse_name'] ?: ($it['warehouse'] ?? null),
|
||||
'warehouse_name' => $this->whName($pkg['warehouse_name'] ?: ($it['warehouse'] ?? null)),
|
||||
'receiver_state' => $pkg['r_state'],
|
||||
'receiver_city' => $pkg['r_city'],
|
||||
'receiver_district' => $pkg['r_district'],
|
||||
@ -628,7 +897,12 @@ class ShippingReplicaSynchronizer
|
||||
'length_cm' => $L,
|
||||
'width_cm' => $W,
|
||||
'height_cm' => $H,
|
||||
'volume_cm3' => $vol,
|
||||
'volume_cm3' => $this->detailVolume($L, $W, $H, $qty, (string) ($pkg['warehouse_name'] ?: ($it['warehouse'] ?? '')), (string) ($pkg['express_name1'] ?? '')),
|
||||
'm_name' => $this->wpText($it['mname'] ?? null),
|
||||
'm_volume_cm3' => isset($it['mvol']) ? round((float) $it['mvol'], 4) : $vol,
|
||||
'm_length_cm' => $it['ml'] ?? null,
|
||||
'm_width_cm' => $it['mw'] ?? null,
|
||||
'm_height_cm' => $it['mh'] ?? null,
|
||||
'weight_kg' => $first ? $settleW : null,
|
||||
'settle_weight_kg' => $first ? $settleW : null,
|
||||
'amount' => $it['amount'] ?? null,
|
||||
@ -837,17 +1111,54 @@ class ShippingReplicaSynchronizer
|
||||
/** 快递单号/内部单号 -> 出库单 io_id(优先快递单号,精确到该次出库)。 */
|
||||
private function loadIoMap(array $logisticIds, array $oIds): array
|
||||
{
|
||||
// 同一单号/订单可能有多张出库单(作废重出):优先有效(非 Delete/Cancelled),再取最新 io_date
|
||||
$byL = [];
|
||||
$byO = [];
|
||||
$byOAll = [];
|
||||
$ioL = [];
|
||||
$rankL = [];
|
||||
$rankO = [];
|
||||
$byLO = [];
|
||||
$rankLO = [];
|
||||
$rank = static function (array $r): string {
|
||||
$st = strtolower(trim((string) ($r['status'] ?? '')));
|
||||
$ok = in_array($st, ['delete', 'deleted', 'cancelled', 'cancel', 'canceled'], true) ? '0' : '1';
|
||||
|
||||
return $ok . '|' . (string) ($r['io_date'] ?? '') . '|' . str_pad((string) ($r['io_id'] ?? ''), 12, '0', STR_PAD_LEFT);
|
||||
};
|
||||
foreach (array_chunk(array_values(array_unique(array_filter($logisticIds))), 800) as $chunk) {
|
||||
if ($chunk === []) {
|
||||
continue;
|
||||
}
|
||||
$ph = implode(',', array_fill(0, count($chunk), '?'));
|
||||
foreach ($this->up->select('SELECT l_id, io_id FROM ' . self::OUTBOUND . " WHERE l_id IN ({$ph})", $chunk) as $r) {
|
||||
$l = (string) ($r['l_id'] ?? '');
|
||||
if ($l !== '' && ! isset($byL[$l])) {
|
||||
$byL[$l] = (string) ($r['io_id'] ?? '');
|
||||
$q = [];
|
||||
foreach ($chunk as $c) {
|
||||
$c = ltrim(trim((string) $c), '@');
|
||||
if ($c !== '') {
|
||||
$q[$c] = true;
|
||||
$q['@' . $c] = true;
|
||||
}
|
||||
}
|
||||
$q = array_keys($q);
|
||||
$ph = implode(',', array_fill(0, count($q), '?'));
|
||||
foreach ($this->up->select('SELECT l_id, io_id, io_date, status, o_id FROM ' . self::OUTBOUND . " WHERE l_id IN ({$ph})", $q) as $r) {
|
||||
$rio = (string) ($r['io_id'] ?? '');
|
||||
if ($rio !== '' && ! empty($r['io_date'])) {
|
||||
$this->ioDate[$rio] = (string) $r['io_date'];
|
||||
}
|
||||
$l = ltrim(trim((string) ($r['l_id'] ?? '')), '@');
|
||||
if ($rio !== '') {
|
||||
$ioL[$rio] = $l;
|
||||
}
|
||||
$rk = $rank($r);
|
||||
if ($l !== '' && $rio !== '' && (! isset($rankL[$l]) || strcmp($rk, $rankL[$l]) > 0)) {
|
||||
$byL[$l] = $rio;
|
||||
$rankL[$l] = $rk;
|
||||
}
|
||||
// 同一单号多订单共用(拼单/合包):按 单号+订单 精确定位,避免拿到别的订单的商品
|
||||
$lo = $l . '|' . (string) ($r['o_id'] ?? '');
|
||||
if ($l !== '' && $rio !== '' && (! isset($rankLO[$lo]) || strcmp($rk, $rankLO[$lo]) > 0)) {
|
||||
$byLO[$lo] = $rio;
|
||||
$rankLO[$lo] = $rk;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -856,19 +1167,96 @@ class ShippingReplicaSynchronizer
|
||||
continue;
|
||||
}
|
||||
$ph = implode(',', array_fill(0, count($chunk), '?'));
|
||||
foreach ($this->up->select('SELECT o_id, io_id FROM ' . self::OUTBOUND . " WHERE o_id IN ({$ph})", $chunk) as $r) {
|
||||
foreach ($this->up->select('SELECT o_id, io_id, io_date, l_id, status FROM ' . self::OUTBOUND . " WHERE o_id IN ({$ph})", $chunk) as $r) {
|
||||
$rio = (string) ($r['io_id'] ?? '');
|
||||
if ($rio !== '' && ! empty($r['io_date'])) {
|
||||
$this->ioDate[$rio] = (string) $r['io_date'];
|
||||
}
|
||||
$o = (string) ($r['o_id'] ?? '');
|
||||
if ($o !== '' && ! isset($byO[$o])) {
|
||||
$byO[$o] = (string) ($r['io_id'] ?? '');
|
||||
$l = ltrim(trim((string) ($r['l_id'] ?? '')), '@');
|
||||
if ($rio !== '') {
|
||||
$ioL[$rio] = $l;
|
||||
}
|
||||
$rk = $rank($r);
|
||||
if ($o !== '' && $rio !== '' && $rk[0] === '1') {
|
||||
$byOAll[$o][$rio] = $l;
|
||||
}
|
||||
if ($o !== '' && $rio !== '' && (! isset($rankO[$o]) || strcmp($rk, $rankO[$o]) > 0)) {
|
||||
$byO[$o] = $rio;
|
||||
$rankO[$o] = $rk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['byL' => $byL, 'byO' => $byO];
|
||||
return ['byL' => $byL, 'byO' => $byO, 'byOAll' => $byOAll, 'ioL' => $ioL, 'byLO' => $byLO];
|
||||
}
|
||||
|
||||
/**
|
||||
* 换单配对:快递明细的单号在出库单里找不到时,到同一内部订单下找一张
|
||||
* "出库单有单号、但该单号不在任何快递明细里"的出库单,一对一配对(工作平台换单后只保留新单号)。
|
||||
*
|
||||
* @return array<string,array{io:string,l_id:string,old:string}> 以快递明细 id 为键
|
||||
*/
|
||||
private function pairChangedTracking(array $rows, array $ioMap): array
|
||||
{
|
||||
$cands = [];
|
||||
$need = [];
|
||||
foreach ($rows as $r) {
|
||||
$lg = ltrim(trim((string) ($r['logistic_id'] ?? '')), '@');
|
||||
$oid = (string) ($r['inner_order_id'] ?? '');
|
||||
if ($lg === '' || $oid === '' || isset($ioMap['byL'][$lg]) || empty($ioMap['byOAll'][$oid])
|
||||
|| in_array((string) ($r['jo_status'] ?? ''), ['Cancelled', 'Split', 'Merged'], true)) {
|
||||
continue;
|
||||
}
|
||||
$need[] = $r;
|
||||
foreach ($ioMap['byOAll'][$oid] as $io => $l) {
|
||||
if ($l !== '' && ! isset($this->doneIo[(string) $io]) && ! isset($this->doneTrack[$l])) {
|
||||
$cands[$l] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($need === [] || $cands === []) {
|
||||
return [];
|
||||
}
|
||||
// 候选新单号若本身出现在快递明细里(同订单拆包),不能拿来配对
|
||||
$inExpress = [];
|
||||
foreach (array_chunk(array_keys($cands), 400) as $chunk) {
|
||||
$q = [];
|
||||
foreach ($chunk as $c) {
|
||||
$q[] = (string) $c;
|
||||
$q[] = '@' . $c;
|
||||
}
|
||||
$ph = implode(',', array_fill(0, count($q), '?'));
|
||||
foreach ($this->up->select("SELECT DISTINCT logistic_id FROM rpt.jst_order_express_details WHERE logistic_id IN ({$ph})", $q) as $x) {
|
||||
$inExpress[ltrim(trim((string) ($x['logistic_id'] ?? '')), '@')] = true;
|
||||
}
|
||||
}
|
||||
$used = [];
|
||||
$swap = [];
|
||||
foreach ($need as $r) {
|
||||
$oid = (string) $r['inner_order_id'];
|
||||
$ios = $ioMap['byOAll'][$oid];
|
||||
ksort($ios, SORT_STRING);
|
||||
foreach ($ios as $io => $l) {
|
||||
$io = (string) $io;
|
||||
if ($l === '' || isset($inExpress[$l]) || isset($used[$io]) || isset($this->doneIo[$io]) || isset($this->doneTrack[$l])) {
|
||||
continue;
|
||||
}
|
||||
$used[$io] = true;
|
||||
$swap[(string) $r['id']] = ['io' => $io, 'l_id' => $l, 'old' => (string) $r['logistic_id']];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $swap;
|
||||
}
|
||||
|
||||
private function ioFor(array $ioMap, string $logisticId, string $oid): string
|
||||
{
|
||||
$logisticId = ltrim(trim($logisticId), '@');
|
||||
if ($logisticId !== '' && $oid !== '' && ! empty($ioMap['byLO'][$logisticId . '|' . $oid])) {
|
||||
return (string) $ioMap['byLO'][$logisticId . '|' . $oid];
|
||||
}
|
||||
if ($logisticId !== '' && ! empty($ioMap['byL'][$logisticId])) {
|
||||
return (string) $ioMap['byL'][$logisticId];
|
||||
}
|
||||
@ -879,19 +1267,41 @@ class ShippingReplicaSynchronizer
|
||||
return '';
|
||||
}
|
||||
|
||||
/** io_id -> 实发商品[itemArr](发货数据视图,按(io_id,条码)去重,type=0 之和优先)。 */
|
||||
/**
|
||||
* io_id -> 实发商品[itemArr](发货数据视图)。
|
||||
* 单条码行:按(io_id,条码)去重,type=0 之和优先。
|
||||
* 多条码行(组合件):见 appendComboParts,对齐工作平台导出明细的拆件口径。
|
||||
*/
|
||||
private function loadViewItems(array $ioIds): array
|
||||
{
|
||||
$agg = [];
|
||||
$multi = [];
|
||||
$this->ioSite = [];
|
||||
$this->ioAddr = [];
|
||||
$this->ioShop = [];
|
||||
foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 800) as $chunk) {
|
||||
if ($chunk === []) {
|
||||
continue;
|
||||
}
|
||||
$ph = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$sql = 'SELECT io_id, sku_ids, qty, type, sku_name, shop_i_id, wareHoursName, l, w, h, volume, weight, supplier_name FROM '
|
||||
$sql = 'SELECT io_id, sku_ids, qty, type, sku_name, shop_i_id, wareHoursName, l, w, h, volume, weight, supplier_name, shop_site, shop_name, receiver_state, receiver_city, receiver_district FROM '
|
||||
. self::VIEW . " WHERE io_id IN ({$ph})";
|
||||
foreach ($this->up->select($sql, $chunk) as $r) {
|
||||
$io = (string) ($r['io_id'] ?? '');
|
||||
if ($io !== '' && ! isset($this->ioSite[$io]) && trim((string) ($r['shop_site'] ?? '')) !== '') {
|
||||
$this->ioSite[$io] = trim((string) $r['shop_site']);
|
||||
}
|
||||
if ($io !== '' && ! isset($this->ioShop[$io]) && trim((string) ($r['shop_name'] ?? '')) !== '') {
|
||||
$this->ioShop[$io] = trim((string) $r['shop_name']);
|
||||
}
|
||||
if ($io !== '') {
|
||||
foreach (['st' => 'receiver_state', 'ci' => 'receiver_city', 'di' => 'receiver_district'] as $ak => $ac) {
|
||||
$av = trim((string) ($r[$ac] ?? ''));
|
||||
if ($av !== '' && ! isset($this->ioAddr[$io][$ak])) {
|
||||
$this->ioAddr[$io][$ak] = $av;
|
||||
}
|
||||
}
|
||||
}
|
||||
$skuIds = (string) ($r['sku_ids'] ?? '');
|
||||
if ($io === '' || $skuIds === '') {
|
||||
continue;
|
||||
@ -903,6 +1313,15 @@ class ShippingReplicaSynchronizer
|
||||
continue;
|
||||
}
|
||||
$n = count($bcs);
|
||||
if ($n > 1) {
|
||||
$multi[$io][] = [
|
||||
't' => (string) ($r['type'] ?? ''), 'qty' => (float) ($r['qty'] ?? 0), 'bcs' => $bcs,
|
||||
'name' => $r['sku_name'] ?? null, 'l' => $r['l'] ?? null, 'w' => $r['w'] ?? null, 'h' => $r['h'] ?? null,
|
||||
'vol' => $r['volume'] ?? null, 'shopid' => $r['shop_i_id'] ?? null, 'wh' => $r['wareHoursName'] ?? null,
|
||||
'supplier' => $r['supplier_name'] ?? null,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
$isT0 = ((string) ($r['type'] ?? '') === '0');
|
||||
$perQty = (float) ($r['qty'] ?? 0) / $n;
|
||||
$rowVol = ($r['volume'] !== null && $r['volume'] !== '') ? (float) $r['volume'] : null;
|
||||
@ -973,9 +1392,188 @@ class ShippingReplicaSynchronizer
|
||||
];
|
||||
}
|
||||
|
||||
if ($multi !== []) {
|
||||
$this->appendComboParts($multi, $out);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合件拆件(对齐工作平台导出明细):视图一行逗号拼接多个部件条码时,每个部件一行——
|
||||
* 名称/长宽高取 SKU 资料,体积=长×宽×高;
|
||||
* 数量:订单商品 → 运费逐件重量÷SKU单重 → 整数均分,前两者须部件合计=整行数量才采用;
|
||||
* 同时带整行 名称/体积(按部件数均摊)/长宽高(m_*),供合并口径使用(工作平台合并取整行值)。
|
||||
* 同一出库单有 type=0 行只用 type=0,否则用最小 type,避免 type=3 等重复行重复计数。
|
||||
*/
|
||||
private function appendComboParts(array $multi, array &$out): void
|
||||
{
|
||||
$sel = [];
|
||||
$bcAll = [];
|
||||
foreach ($multi as $io => $rows) {
|
||||
$types = array_values(array_unique(array_map(fn ($r) => $r['t'], $rows)));
|
||||
$useT = in_array('0', $types, true) ? '0' : min($types);
|
||||
foreach ($rows as $r) {
|
||||
if ($r['t'] === $useT) {
|
||||
$sel[(string) $io][] = $r;
|
||||
foreach ($r['bcs'] as $b) {
|
||||
$bcAll[$b] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$ioKeys = array_map('strval', array_keys($sel));
|
||||
|
||||
$o2io = [];
|
||||
foreach (array_chunk($ioKeys, 800) as $ck) {
|
||||
$ph = implode(',', array_fill(0, count($ck), '?'));
|
||||
foreach ($this->up->select('SELECT io_id, o_id FROM ' . self::OUTBOUND . " WHERE io_id IN ({$ph})", $ck) as $r) {
|
||||
$o = (string) ($r['o_id'] ?? '');
|
||||
if ($o !== '') {
|
||||
$o2io[$o][(string) $r['io_id']] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$ordQty = [];
|
||||
foreach (array_chunk(array_map('strval', array_keys($o2io)), 800) as $ck) {
|
||||
$ph = implode(',', array_fill(0, count($ck), '?'));
|
||||
foreach ($this->up->select("SELECT o_id, sku_id, qty FROM rpt.jst_OrderItem WHERE o_id IN ({$ph})", $ck) as $r) {
|
||||
$k = mb_strtolower(trim((string) ($r['sku_id'] ?? '')));
|
||||
foreach ($o2io[(string) $r['o_id']] ?? [] as $io => $_) {
|
||||
$ordQty[(string) $io][$k] = ($ordQty[(string) $io][$k] ?? 0) + (float) ($r['qty'] ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
$fysW = [];
|
||||
foreach (array_chunk($ioKeys, 800) as $ck) {
|
||||
$ph = implode(',', array_fill(0, count($ck), '?'));
|
||||
foreach ($this->up->select("SELECT io_id, sku_id, weight FROM rpt.fys_jstFreightCostDetail WHERE io_id IN ({$ph})", $ck) as $r) {
|
||||
$k = mb_strtolower(trim((string) ($r['sku_id'] ?? '')));
|
||||
$fysW[(string) $r['io_id']][$k] = ($fysW[(string) $r['io_id']][$k] ?? 0) + (float) ($r['weight'] ?? 0);
|
||||
}
|
||||
}
|
||||
$skuL = [];
|
||||
foreach ($this->loadSkus(array_map('strval', array_keys($bcAll))) as $k => $v) {
|
||||
$skuL[mb_strtolower((string) $k)] = $v;
|
||||
}
|
||||
$raw = fn ($v) => ($v === null || trim((string) $v) === '') ? null : (float) $v;
|
||||
|
||||
foreach ($sel as $io => $rows) {
|
||||
$io = (string) $io;
|
||||
$parts = [];
|
||||
foreach ($rows as $r) {
|
||||
$bcs = $r['bcs'];
|
||||
$n = count($bcs);
|
||||
$q = $this->partQty($bcs, (int) round($r['qty']), $ordQty[$io] ?? [], $fysW[$io] ?? [], $skuL);
|
||||
$rowVol = $raw($r['vol']);
|
||||
foreach ($bcs as $i => $bc) {
|
||||
$lk = mb_strtolower($bc);
|
||||
$s = $skuL[$lk] ?? [];
|
||||
$L = $this->nz($s['l'] ?? null);
|
||||
$W = $this->nz($s['w'] ?? null);
|
||||
$H = $this->nz($s['h'] ?? null);
|
||||
if (! isset($parts[$lk])) {
|
||||
$parts[$lk] = [
|
||||
'id' => null, 'sku' => $bc, 'iid' => $s['iid'] ?? null,
|
||||
'shopid' => ($r['shopid'] ?? null) ?: null,
|
||||
'name' => ($s['name'] ?? null) ?: $r['name'],
|
||||
'qty' => 0, 'amount' => null, 'warehouse' => ($r['wh'] ?? null) ?: null,
|
||||
'l' => $L, 'w' => $W, 'h' => $H,
|
||||
'vol' => ($L && $W && $H) ? round($L * $W * $H, 4) : null,
|
||||
'weight' => $s['weight'] ?? null,
|
||||
'supplier' => ($r['supplier'] ?? null) ?: null,
|
||||
'mname' => $r['name'], 'mvol' => null,
|
||||
'ml' => $raw($r['l']), 'mw' => $raw($r['w']), 'mh' => $raw($r['h']),
|
||||
];
|
||||
}
|
||||
$parts[$lk]['qty'] += $q[$i];
|
||||
if ($rowVol !== null) {
|
||||
$parts[$lk]['mvol'] = ($parts[$lk]['mvol'] ?? 0) + $rowVol / $n;
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($parts as $lk => $p) {
|
||||
$p['mvol'] = $p['mvol'] !== null ? round($p['mvol'], 4) : null;
|
||||
$hit = false;
|
||||
foreach ($out[$io] ?? [] as $idx => $it) {
|
||||
if (mb_strtolower((string) $it['sku']) === $lk) {
|
||||
$out[$io][$idx]['qty'] += $p['qty'];
|
||||
$hit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $hit) {
|
||||
$out[$io][] = $p;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 组合件部件数量:订单商品 → 运费逐件重量÷SKU单重 → 整数均分;前两者须合计=整行数量。 */
|
||||
private function partQty(array $bcs, int $rowQty, array $ord, array $fys, array $skuL): array
|
||||
{
|
||||
$n = count($bcs);
|
||||
$keys = array_map(fn ($b) => mb_strtolower($b), $bcs);
|
||||
$a = [];
|
||||
foreach ($keys as $k) {
|
||||
if (! isset($ord[$k])) {
|
||||
$a = null;
|
||||
break;
|
||||
}
|
||||
$a[] = (int) round($ord[$k]);
|
||||
}
|
||||
if ($a !== null && array_sum($a) === $rowQty && min($a) > 0) {
|
||||
return $a;
|
||||
}
|
||||
$b = [];
|
||||
foreach ($keys as $k) {
|
||||
$sw = (float) ($skuL[$k]['weight'] ?? 0);
|
||||
if (! isset($fys[$k]) || $sw <= 0) {
|
||||
$b = null;
|
||||
break;
|
||||
}
|
||||
$b[] = (int) round($fys[$k] / $sw);
|
||||
}
|
||||
if ($b !== null && array_sum($b) === $rowQty && min($b) > 0) {
|
||||
return $b;
|
||||
}
|
||||
$total = max($rowQty, 0);
|
||||
$base = intdiv($total, $n);
|
||||
$rem = $total - $base * $n;
|
||||
$c = [];
|
||||
foreach ($keys as $i => $_) {
|
||||
$c[] = $base + ($i < $rem ? 1 : 0);
|
||||
}
|
||||
|
||||
return $c;
|
||||
}
|
||||
|
||||
private function nz(mixed $v): ?float
|
||||
{
|
||||
if ($v === null || trim((string) $v) === '') {
|
||||
return null;
|
||||
}
|
||||
$f = (float) $v;
|
||||
|
||||
return $f == 0.0 ? null : $f;
|
||||
}
|
||||
|
||||
/** 收货省市区统一取发货数据视图(与工作平台一致),视图无值时保留原值。 */
|
||||
private function withViewAddr(string $io, array $pkg): array
|
||||
{
|
||||
if ($io !== '' && trim((string) ($pkg['shop_name'] ?? '')) === '' && isset($this->ioShop[$io])) {
|
||||
$pkg['shop_name'] = $this->ioShop[$io];
|
||||
}
|
||||
$a = ($io !== '') ? ($this->ioAddr[$io] ?? []) : [];
|
||||
foreach (['st' => 'r_state', 'ci' => 'r_city', 'di' => 'r_district'] as $ak => $pk) {
|
||||
if (isset($a[$ak]) && $a[$ak] !== '') {
|
||||
$pkg[$pk] = $a[$ak];
|
||||
}
|
||||
}
|
||||
|
||||
return $pkg;
|
||||
}
|
||||
|
||||
private function collectSkuIds(array ...$maps): array
|
||||
{
|
||||
$ids = [];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user