feat: add Jushuitan shipping data mirror

This commit is contained in:
limeng 2026-09-23 18:29:15 +08:00
parent fe2c56a9c2
commit 2017d6520b
17 changed files with 1687 additions and 75 deletions

View File

@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\Jushuitan\ShippingReplicaSynchronizer;
/**
* 一次性:删除现有发货数据里以 @ 开头、去@后已存在的重复单号。
* php artisan jst:dedup-at
*/
class DedupAt extends Command
{
protected $signature = 'jst:dedup-at';
protected $description = '去除 @ 开头的重复单号(去@后已存在则删)';
public function handle(): int
{
@ini_set('memory_limit', '1024M');
@set_time_limit(0);
$removed = ShippingReplicaSynchronizer::make()->dedupAtPrefix();
$this->info('去除@重复单号:' . $removed . ' 条');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class JstCount extends Command
{
protected $signature = 'jst:count';
protected $description = '发货数据本地表统计';
public function handle(): int
{
$c = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
$t = fn () => $c->table('jst_oms_shipping');
$this->line('明细行数 total='.$t()->count());
$this->line('包裹数(distinct source_id)='.$t()->distinct()->count('source_id'));
$this->line('自动发货仓行(source>=1e9)='.$t()->where('source_id', '>=', 1000000000)->count());
$this->line('补差残留='.$t()->where('product_name', 'like', '%补收差价%')->count());
$this->line('快递单号非空='.$t()->whereNotNull('express_no')->where('express_no', '<>', '')->count());
$this->line('件数pkg_count非空='.$t()->whereNotNull('pkg_count')->where('pkg_count', '<>', '')->count());
// 多件包裹数(pkg_count 不是 1/1)
$multi = $t()->where('pkg_count', 'not like', '1/1')->where('pkg_count', 'like', '1/%')->distinct()->count('source_id');
$this->line('多件包裹数(件数≠1/1)='.$multi);
// 一个多件包裹样例
$sample = $t()->where('pkg_count', 'like', '1/%')->where('pkg_count', '<>', '1/1')->orderByDesc('source_id')->first();
if ($sample) {
$rows = $t()->where('source_id', $sample->source_id)->get(['barcode', 'product_name', 'qty', 'pkg_count', 'actual_weight', 'freight', 'volume_cm3']);
$this->line('--- 样例多件包裹 source_id='.$sample->source_id.' 快递单号='.$sample->express_no.' ---');
foreach ($rows as $r) {
$this->line(sprintf(' 条码=%s 名称=%s 数量=%s 件数=%s 实称=%s 运费=%s 体积=%s',
$r->barcode, mb_substr((string) $r->product_name, 0, 12), $r->qty, $r->pkg_count, $r->actual_weight, $r->freight, $r->volume_cm3));
}
}
return self::SUCCESS;
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
class JstDump extends Command
{
protected $signature = 'jst:dump {--mode=merged} {--from=} {--to=}';
protected $description = '把 caiwu 发货数据(合并/明细)以 CSV 输出到 STDOUT,供比对';
public function handle(ShippingOrderRepository $repo): int
{
@ini_set('memory_limit', '1024M');
@set_time_limit(0);
$mode = $this->option('mode') === 'detail' ? 'detail' : 'merged';
$filters = [
'ship_from' => (string) $this->option('from'),
'ship_to' => (string) $this->option('to'),
];
$cols = [
['express_no', '快递单号'], ['trade_no', '交易单号'], ['item_id', '商品ID'], ['barcode', '条形码'],
['product_name', '商品名称'], ['qty', '数量'], ['pkg_count', '件数'], ['warehouse_name', '仓库'],
['supplier_name', '供应商'], ['platform', '平台'], ['shop_name', '店铺名称'],
['volume_cm3', '体积'], ['length_cm', '长'], ['width_cm', '宽'], ['height_cm', '高'],
['actual_weight', '实称重量'], ['online_weight', '线上重量'], ['freight', '运费'],
['ship_time', '发货时间'], ['pay_time', '付款时间'], ['product_code', '货号'],
['receiver_state', '省'], ['receiver_city', '市'], ['receiver_district', '区'],
['package_no', '配货单号'], ['amount', '金额'],
];
$fh = fopen('php://output', 'w');
fwrite($fh, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($fh, array_map(fn ($c) => $c[1], $cols));
$n = 0;
foreach ($repo->exportRows($filters, 2000, $mode) as $row) {
fputcsv($fh, array_map(fn ($c) => (string) ($row[$c[0]] ?? ''), $cols));
$n++;
}
fflush($fh);
fwrite(STDERR, 'wrote '.$n.' rows ('.$mode.')'.PHP_EOL);
return self::SUCCESS;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class JstRow extends Command
{
protected $signature = 'jst:row {--trade=}';
protected $description = '看本地表某交易单号的行';
public function handle(): int
{
$c = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
$rows = $c->table('jst_oms_shipping')->where('trade_no', (string) $this->option('trade'))
->get(['express_no', 'barcode', 'product_name', 'qty', 'pkg_count', 'supplier_name', 'length_cm', 'volume_cm3']);
$this->line('行数='.count($rows));
foreach ($rows as $r) {
$this->line(' '.json_encode($r, JSON_UNESCAPED_UNICODE));
}
return self::SUCCESS;
}
}

View File

@ -65,12 +65,12 @@ class Kernel extends ConsoleKernel
->runInBackground();
}
// 聚水潭发货数据增量同步:每 10 分钟按 modified 水位拉销售出库单。
// 聚水潭发货数据每日重拉最近三天,覆盖副本库迟到更新。
// 默认关闭(JST_SYNC_ENABLED=false);填好凭证并完成迁移后再打开。
if ((bool) env('JST_SYNC_ENABLED', false)) {
$schedule->command('jushuitan:sync-shipping')
->everyTenMinutes()
->withoutOverlapping(30)
$schedule->command('jushuitan:sync-shipping --days=3')
->dailyAt('01:28')
->withoutOverlapping(120)
->runInBackground();
}

View File

@ -990,6 +990,7 @@ class Pages extends Controller
{
$request = request();
$filters = $this->shippingDataFilters($request);
$mode = $request->query('mode') === 'detail' ? 'detail' : 'merged';
$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, [
@ -1000,7 +1001,7 @@ class Pages extends Controller
$error = null;
try {
$result = $this->shippingOrders->paginate($filters, $page, $limit);
$result = $this->shippingOrders->paginate($filters, $page, $limit, $mode);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
@ -1020,6 +1021,7 @@ class Pages extends Controller
'shippingFilters' => $filters,
'shippingSync' => $sync,
'shippingError' => $error,
'shippingMode' => $mode,
'shippingExportUrl' => route('business.courier-payables.shipping-data.export'),
];
}
@ -1027,9 +1029,10 @@ class Pages extends Controller
public function exportShippingData(Request $request): StreamedResponse
{
$filters = $this->shippingDataFilters($request);
$mode = $request->query('mode') === 'detail' ? 'detail' : 'merged';
$columns = $this->shippingDataColumns();
return response()->streamDownload(function () use ($filters, $columns): void {
return response()->streamDownload(function () use ($filters, $columns, $mode): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建发货数据导出流。');
@ -1038,12 +1041,12 @@ class Pages extends Controller
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
foreach ($this->shippingOrders->exportRows($filters) as $row) {
foreach ($this->shippingOrders->exportRows($filters, 1000, $mode) as $row) {
fputcsv($csv, array_map(function (array $column) use ($row): mixed {
$value = data_get($row, $column['field']);
// 发货数据导出:标识列(快递单号/交易单号/商品ID/条形码)按原样文本导出,不加 ' 前缀
return match ($column['type']) {
'identifier' => $this->csvIdentifier($value),
'number' => $value === null || $value === '' ? '' : $value,
default => $this->csvText($value),
};
@ -1051,13 +1054,33 @@ class Pages extends Controller
}
fclose($csv);
}, '发货数据_' . now()->format('Ymd_His') . '.csv', [
}, '发货数据' . ($mode === 'detail' ? '明细' : '合并') . '_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<string,string> */
public function suggestShippingData(Request $request): \Illuminate\Http\JsonResponse
{
$allowed = ['supplier_name', 'shop_name', 'warehouse_name', 'express_company'];
$field = (string) $request->query('field', '');
if (! in_array($field, $allowed, true)) {
return response()->json(['items' => []]);
}
$keyword = mb_substr(trim((string) $request->query('q', '')), 0, 64);
$limit = min(max((int) $request->query('limit', 20), 1), 500);
try {
$items = $this->shippingOrders->distinctValues($field, $keyword, $limit);
} catch (Throwable $exception) {
report($exception);
$items = [];
}
return response()->json(['items' => array_values($items)]);
}
/** @return array<string,mixed> */
private function shippingDataFilters(Request $request): array
{
$text = static fn (string $key, int $length = 128): string => mb_substr(
@ -1065,6 +1088,28 @@ class Pages extends Controller
0,
$length,
);
// 多选字段:接收 name[] 数组(也兼容单个字符串),去重、限长、限个数。
$multi = static function (string $key, int $length = 191, int $max = 50) use ($request): array {
$raw = $request->query($key, []);
if (is_string($raw)) {
$raw = $raw === '' ? [] : [$raw];
}
if (! is_array($raw)) {
$raw = [];
}
$out = [];
foreach ($raw as $value) {
$value = mb_substr(trim((string) $value), 0, $length);
if ($value !== '' && ! in_array($value, $out, true)) {
$out[] = $value;
}
if (count($out) >= $max) {
break;
}
}
return $out;
};
$date = static function (mixed $value): string {
$value = trim((string) $value);
@ -1075,10 +1120,10 @@ class Pages extends Controller
'express_no' => $text('express_no', 80),
'sourceno' => $text('sourceno', 80),
'barcode' => $text('barcode'),
'supplier_name' => $text('supplier_name'),
'shop_name' => $text('shop_name', 255),
'warehouse_name' => $text('warehouse_name'),
'express_company' => $text('express_company'),
'supplier_name' => $multi('supplier_name'),
'shop_name' => $multi('shop_name', 255),
'warehouse_name' => $multi('warehouse_name'),
'express_company' => $multi('express_company'),
'ship_from' => $date($request->query('ship_from', '')),
'ship_to' => $date($request->query('ship_to', '')),
];
@ -1094,6 +1139,7 @@ class Pages extends Controller
['field' => 'barcode', 'label' => '条形码', 'type' => 'identifier'],
['field' => 'product_name', 'label' => '商品名称', 'type' => 'text'],
['field' => 'qty', 'label' => '数量', 'type' => 'number'],
['field' => 'pkg_count', 'label' => '件数', 'type' => 'text'],
['field' => 'receiver_state', 'label' => '省', 'type' => 'text'],
['field' => 'buyer_nick', 'label' => '买家昵称', 'type' => 'text'],
['field' => 'receiver_name', 'label' => '收货姓名', 'type' => 'text'],

View File

@ -183,6 +183,7 @@ CRON_TZ=Asia/Shanghai
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
28 1 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan jushuitan:sync-shipping --days=3 >/dev/null 2>&1
EOF
chmod 644 /etc/cron.d/caiwu-jiansun-snapshots

View File

@ -187,6 +187,7 @@ CRON_TZ=Asia/Shanghai
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
28 1 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan jushuitan:sync-shipping --days=3 >/dev/null 2>&1
EOF
chmod 644 /etc/cron.d/caiwu-jiansun-snapshots

View File

@ -0,0 +1,102 @@
# 发货数据:工作平台 → 财务系统(caiwu) 100% 还原逻辑对照
> 目的:让 caiwu「发货数据」界面与工作平台一致(含"导出/导出明细"两种口径),并把逻辑梳理清楚,脱离将停用的工作平台/ jiansun 库,改由经营分析副本库(operations_replica)直取。
## 一、总体数据流
**工作平台(现状,即将停用)**
1. 定时调聚水潭 `erp.dlv.query`(参数 `tradetype=1`=正常出库、`sendsgn=1`=已发货),按发货时间取发货单;`erp.dlv.getDlv` 取每单的商品明细。
2. **逐件**写入 `fys_omsDataDayDetail`(明细表,一件商品一行)。
3. 按 `Expressno, Packageno` **归并**成 `fys_omsDataDay`(合并表,一个快递单号/包裹一行)。
4. 页面与导出:`导出明细`=查明细表;`导出`=查合并表;两者都再左连 店铺/仓库/快递/成品/运费/换单 维表。
**caiwu(目标)**
1. 副本库 `rpt.jst_order_express_details`(发货单 dlv 镜像)× `rpt.jst_OrderItem`(逐件)+ `rpt.jst_sku`(尺寸/重量/供应商)+ `rpt.fys_jstFreightCostDetail`(运费)。
2. **逐件**写入 `jst_oms_shipping`(明细,一件一行;唯一键=包裹id+商品id)。
3. 查询时按包裹**归并**出"合并"视图(不再单独建表,用 GROUP BY 实时归并)。
4. 页面加「合并/明细」切换+两个导出,默认合并。
## 二、明细口径(对应"导出明细")——每件商品一行
一个发货单(包裹) × 其每件商品 = 一行。字段来源:
| 列 | 工作平台字段 | caiwu 来源 |
|---|---|---|
| 快递单号 Expressno | dlv.postcode | express_details.logistic_id(空时回落 l_id/运费expressNo)|
| 交易单号 Sourceno | dlv.sourceno | express_details.so_id |
| 配货单号 Packageno | dlv.wmspackno | 待补(副本库对应字段确认中)|
| 商品ID shopproid | item.shopproid | OrderItem.shop_i_id |
| 条形码 Barcode | item.skuoid | OrderItem.sku_id |
| 货号 Productcode | item.procode | OrderItem.i_id |
| 商品名称 SkuName | item.skuname | OrderItem.name |
| 数量 Num | item.skucount | OrderItem.qty |
| 供应商 SupplierName | item.scmname / scsku | sku.supplier_name / dis_co_name |
| 长/宽/高 | 商品资料 | sku.l / w / h |
| 体积 Svolume | 见"体积规则" | 见"体积规则" |
| 实称重量 Weight | dlv 回传重量 | express_details.weight |
| 发货时间 Shiptime | dlv.sended | express_details.send_time(空回落 jst_Orders.send_date)|
| 付款时间 Paydate | dlv.paytime | express_details.pay_time |
| 省/市/区/地址/收货人/电话 | dlv.linker.* | express_details.receiver_* / 地址拆分 |
| 店铺/平台/仓库 | dlv.shopcode/pltmcode/whcode | express_details.shop_name/platform_name/warehouse_name |
| 件数 Count | '1/N' | 同 Packageno 商品件数 → '1/N' |
**明细专有规则**
- **Count = '1/N'**:同一 Packageno 有 N 件商品,则每行显示 `1/N`。
- **体积 Svolume**:
- 普通商品(货号不在配件表 ProCodeOfShipData、快递在 ExpressOfShipData、条码不含 `P-`):`Svolume = 数量 × 长 × 宽 × 高`。
- 配件"一单多货"(同快递单号多件配件):只保留体积最大的一件,其余置 0(避免重复计泡)。
- 去重:同 OmsId 重复的明细只留最小 Id 的一条。
## 三、合并口径(对应"导出")——按 `Expressno + Packageno` 归并
工作平台 `group by Expressno, Packageno`,各列归并规则(**照抄其 groupStr**):
- **去重逗号拼接**:`条形码 Barcode`、`商品ID shopproid`(同包裹多件时拼成 `A,B,C`)。
- **求和 sum**:`数量 Num`、`实称重量 Weight`、`体积 Svolume`、`对账重量 ReturnMinWeight`。
- **取最大 max**:`长 Slength`、`宽 Swidth`、`高 Sheight`、`RuleWeight`、`RuleVolume`。
- **取第一 min**:其余全部(交易单号、发货时间、省市区、地址、收货人、电话、商品名称 SkuName、店铺、买家昵称、买家/卖家留言、仓库、供应商、货号、平台、付款时间、成品编码/名称、备注 等)。
- 分组键:`Expressno, Packageno`。
> 直白说:一个快递单号发了多件不同货 → 合并视图里"条形码/商品ID"逗号拼接、数量/重量/体积加总、长宽高取最大、其它取任一件的值;明细视图里则拆成多行、每行 `1/N`。
## 四、派生显示字段(两口径查询时左连/计算)
- **快递名称 ExNameDto**:`fys_express.ExpressName` 去掉"一区""其他"后,取"-"前一段(无"-"则原值)。
- **店铺名 SpName**:快递为"发票邮政"时=买家留言(去逗号),否则=店铺名。
- **发货批次 ExpreBatch**:仅拼多多/京东——发货时间 0–12 点 → `日期_第一批`,12–24 点 → `日期_第二批`。
- **换单前单号 FrontNum**:`fys_omsDataDay_ChangeOrder`(按 Sourceno+Packageno, chgsgn=2)。
- 地区 Area、ABpackage、CommodityExpress 等由维表补充。
## 五、筛选条件(合并/明细一致)——已核对工作平台 searchStr
| 条件 | 匹配方式 | caiwu 现状 |
|---|---|---|
| 发货时间起/止 | Shiptime 区间 | ✅ 已实现 |
| 交易单号 Sourceno | 精确 = | ✅ |
| 快递单号 Expressno | 精确 = | ✅ |
| 条形码 BarCode | 精确 = | ✅(现为模糊,可改精确)|
| 货号 ProCode | 精确 = | 待补 |
| 配货单号 Packageno | 精确 = | 待补 |
| 买家昵称 Buyernick | 精确 = | 待补 |
| 收货人 Receivername | 精确 = | 待补 |
| 电话 Receiverphone | 精确 = | 待补 |
| 快递公司 Express | **IN 多选** | ✅ 已做多选 |
| 店铺 Shop / 仓库 Warehouse / 供应商 Supplier | **IN 多选** | ✅ 已做多选 |
| 异常/加急/换单/快递标记 errsgn/jjsgn/chgsgn/kdsgn | = | 待补(可选)|
> 工作平台快递/店铺/仓库/供应商本就是多选 IN —— 与刚做的勾选式多选一致。
## 六、暂缓项(依赖"计泡 + 报价规则",报价系统建好后再做)
`NewWeight`(计泡后重量)、`NewVolue`(计泡体积)、`ReturnMinVolue`、`FreightNew`(运费)、`FreightRate`(费率)、`FreightFormula`(公式)、`JSFreight`(结算运费)。
这些依赖各快递计泡规则(fys_DisExpressWeight 等)与报价,先留空,后续接。
## 七、落地计划(分阶段,均可脱离工作平台)
1. **明细存储**:同步改成"发货单 × 每件商品"一行;补 Count='1/N'、体积规则(数量×长×宽×高 / 配件只留最大件)。
2. **双口径**:仓储/页面支持 合并(默认) / 明细 切换;合并按 §三 规则 GROUP BY 实时归并;两个导出分别对应。
3. **补齐筛选与派生**:货号/配货单号/买家昵称/收货人/电话 单值筛选;ExNameDto/SpName/ExpreBatch/换单前单号 派生列。
4. **(后续)计泡 + 运费**:报价系统就绪后接 §六。
---
_本文据 JSCrm 源码 `OmsDataDayService.cs`(getPageList / searchStr / searchDetailStr / DownLoadShipmentData 归并段)整理,作为 caiwu 发货数据还原的对照基准。_

View File

@ -4,34 +4,55 @@ namespace Modules\EcommerceFinance\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Modules\EcommerceFinance\Services\Jushuitan\ShippingDataSynchronizer;
use Modules\EcommerceFinance\Services\Jushuitan\ShippingReplicaSynchronizer;
/**
* 聚水潭发货数据同步命令。
* 发货数据同步:从经营分析副本库(operations_replica)直读聚水潭发货明细,清洗进本地展示表。
*
* php artisan jushuitan:sync-shipping # 增量(按水位 - 重叠窗口)
* php artisan jushuitan:sync-shipping --from=... --to=... # 指定时间窗(首次全量分批用)
* php artisan jushuitan:sync-shipping --from=2026-09-01 --to=2026-09-17 # 按发货时间窗口拉全量
* php artisan jushuitan:sync-shipping --days=3 # 滚动最近 3 天(每日调度用)
* php artisan jushuitan:sync-shipping # 最近 2000 条(快速看效果)
*
* 按 id 去重 upsert,任何时候补拉早期区间都不会重复。
*/
class SyncJushuitanShipping extends Command
{
protected $signature = 'jushuitan:sync-shipping {--from=} {--to=}';
protected $signature = 'jushuitan:sync-shipping {--from=} {--to=} {--days=} {--limit=2000}';
protected $description = '从聚水潭(奇门)拉取销售出库单发货数据到 caiwu 共享镜像库';
protected $description = '从经营分析副本库直读聚水潭发货明细,清洗写入 caiwu 发货数据表';
public function handle(): int
{
@ini_set('memory_limit', '1536M');
@set_time_limit(0);
$from = $this->option('from') ? Carbon::parse($this->option('from')) : null;
$to = $this->option('to') ? Carbon::parse($this->option('to')) : null;
$this->info('开始同步聚水潭发货数据...');
// --days=N:滚动最近 N 天(含今天)。显式 --from/--to 优先。
if (! $from && ! $to && $this->option('days')) {
$days = max((int) $this->option('days'), 1);
$from = now()->startOfDay()->subDays($days - 1);
$to = now()->startOfDay()->addDay();
}
$limit = (int) ($this->option('limit') ?: 2000);
$range = ($from && $to) ? $from->toDateString() . ' ~ ' . $to->toDateString() : '最近 ' . $limit . ' 条';
$this->info('开始从副本库同步发货明细(' . $range . ')...');
$progress = function (string $msg): void {
$this->info(' ' . $msg);
if (function_exists('fflush')) {
@fflush(STDOUT);
}
};
try {
$result = ShippingDataSynchronizer::make()->syncIncremental($from, $to);
$result = ShippingReplicaSynchronizer::make()->sync($from, $to, $limit, $progress);
} catch (\Throwable $e) {
$this->error('同步失败:' . $e->getMessage());
return self::FAILURE;
}
$this->info(sprintf('完成:处理 %d 单,水位推进到 %s', $result['processed'], $result['watermark'] ?? '(未变)'));
$this->info('完成:写入 ' . ($result['processed'] ?? 0) . ' 条发货明细。');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
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;
}
if (! $schema->hasColumn('jst_oms_shipping', 'pkg_count')) {
$schema->table('jst_oms_shipping', function (Blueprint $table): void {
$table->string('pkg_count', 16)->nullable()->comment('件数 1/N')->after('package_no');
});
}
}
public function down(): void
{
$schema = $this->conn();
if ($schema->hasTable('jst_oms_shipping') && $schema->hasColumn('jst_oms_shipping', 'pkg_count')) {
$schema->table('jst_oms_shipping', function (Blueprint $table): void {
$table->dropColumn('pkg_count');
});
}
}
};

View File

@ -23,50 +23,114 @@ class ShippingOrderRepository
* @param array<string,string> $filters
* @return array{data:array,total:int}
*/
public function paginate(array $filters, int $page, int $perPage): array
public function paginate(array $filters, int $page, int $perPage, string $mode = 'merged'): array
{
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$q = $this->filteredQuery($filters);
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) => (array) $r)->all();
$total = (clone $q)->count();
$rows = (clone $q)
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('id')
->orderByDesc('source_id')
->forPage($page, $perPage)
->get()
->map(fn ($r) => (array) $r)
->all();
->get()->map(fn ($r) => (array) $r)->all();
return ['data' => $rows, 'total' => $total];
}
/** 合并聚合 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'];
$parts = ['MAX(id) as id', 'source_id'];
foreach ($all as $c) {
if (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}`";
} 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): \Generator
public function exportRows(array $filters, int $batchSize = 1000, string $mode = 'merged'): \Generator
{
$batchSize = min(max($batchSize, 1), 5000);
$lastId = null;
$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 (array) $row;
}
$last = (int) $rows[array_key_last($rows)]->id;
if (count($rows) < $batchSize) {
return;
}
}
}
// 合并导出:按 source_id 键集分页 + 归并
while (true) {
$query = $this->filteredQuery($filters);
if ($lastId !== null) {
$query->where('id', '>', $lastId);
if ($last !== null) {
$query->where('source_id', '>', $last);
}
$rows = $query->orderBy('id')->limit($batchSize)->get()->all();
$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;
}
$lastId = (int) $rows[array_key_last($rows)]->id;
$last = (int) $rows[array_key_last($rows)]->source_id;
if (count($rows) < $batchSize) {
return;
}
@ -85,17 +149,25 @@ class ShippingOrderRepository
$query->where('trade_no', '=', trim((string) $filters['sourceno']));
}
foreach (['barcode', 'supplier_name', 'shop_name', 'warehouse_name'] as $column) {
if (($filters[$column] ?? '') !== '') {
$query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains((string) $filters[$column])]);
// 条形码:单值关键词
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);
}
}
if (($filters['express_company'] ?? '') !== '') {
$value = $this->contains((string) $filters['express_company']);
$query->where(static function (Builder $courier) use ($value): void {
$courier->whereRaw("express_name1 LIKE ? ESCAPE '!'", [$value])
->orWhereRaw("express_name2 LIKE ? ESCAPE '!'", [$value]);
// 快递公司:多选,匹配快递名称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);
});
}
@ -114,6 +186,71 @@ class ShippingOrderRepository
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
{

View File

@ -0,0 +1,943 @@
<?php
namespace Modules\EcommerceFinance\Services\Jushuitan;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Throwable;
/**
* 直读经营分析副本库(operations_replica)聚水潭发货明细,清洗写入 jst_oms_shipping。
* 对齐工作平台口径:明细=一个发货单×每件商品一行(导出明细);合并由查询层按包裹归并(导出)。
*
* 每行 = 一个发货单(包裹, express_details) × 其一件商品(OrderItem)。
* - source_id = 发货单 id(包裹)
* - sku_source_id = 商品明细 id(件)
* - pkg_count = '1/N'(同包裹件数)
* - 体积 volume_cm3 = 数量 × 长 × 宽 × 高(工作平台 Svolume 规则)
* - 包裹级字段(实称重量/运费)只落在首件,其余置空 —— 便于合并 SUM 不重复
* - 剔除补差虚拟单;并入自动发货仓(jst_Orders 已发货但不在发货明细表)
*/
class ShippingReplicaSynchronizer
{
private const SOURCE = 'operations_replica';
private const DATASET = 'jst.sctrade';
private const AUTOSHIP_OFFSET = 1000000000;
private const DEMO_SOURCE_IDS = [9000001, 9000002, 9000003, 9000004, 9000005, 9000006];
private const FLUSH = 300;
private const VIEW = 'rpt.Jst_SendData_View_extension';
private const OUTBOUND = 'rpt.jst_salesOutboundInfo';
private readonly \Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery $up;
private readonly \Illuminate\Database\ConnectionInterface $db;
/** @var array<int,array> */
private array $buffer = [];
/** @var array<string,array> 窗口内 dws 销售全量商品缓存 sourceno=>items */
private array $dwsCache = [];
public function __construct(UpstreamReadOnlyQueryFactory $factory)
{
$this->up = $factory->forSource(self::SOURCE);
$this->db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
$this->db->disableQueryLog();
}
public static function make(): self
{
return new self(app(UpstreamReadOnlyQueryFactory::class));
}
public function sync(?Carbon $from = null, ?Carbon $to = null, int $limit = 2000, ?callable $progress = null): array
{
$this->markRunning();
try {
$windowed = $from && $to;
$now = now();
$fromS = $windowed ? $from->format('Y-m-d H:i:s') : null;
$toS = $windowed ? $to->format('Y-m-d H:i:s') : null;
$this->db->table('jst_oms_shipping')->whereIn('source_id', self::DEMO_SOURCE_IDS)->delete();
if ($windowed) {
$this->db->table('jst_oms_shipping')
->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS)->delete();
}
$total = 0;
$auto = 0;
if ($windowed) {
$this->preloadDws($from, $to);
if ($progress) {
$progress('dws 预载完成:' . count($this->dwsCache) . ' 个 sourceno');
}
$total = $this->syncExpress(true, $fromS, $toS, $limit, $now, $progress);
$auto = $this->syncAutoShip($fromS, $toS, $now, $progress);
$this->dwsCache = [];
gc_collect_cycles();
$removed = $this->dedupAtPrefix();
if ($progress && $removed > 0) {
$progress('去除@重复单号:' . $removed . ' 条');
}
} else {
$total = $this->syncExpress(false, $fromS, $toS, $limit, $now, $progress);
}
$this->markSucceeded();
return ['processed' => $total, 'autoship' => $auto];
} catch (Throwable $e) {
$this->markFailed($e->getMessage());
throw $e;
}
}
/** 去除以 @ 开头、去@后已存在的重复单号(换单/重拉产生;工作平台只保留干净版)。 */
public function dedupAtPrefix(): 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;
}
$toDel = [];
foreach (array_keys($set) as $e) {
$e = (string) $e;
if ($e !== '' && $e[0] === '@' && isset($set[substr($e, 1)])) {
$toDel[] = $e;
}
}
$removed = 0;
foreach (array_chunk($toDel, 500) as $chunk) {
if ($chunk === []) {
continue;
}
$removed += (int) $this->db->table('jst_oms_shipping')->whereIn('express_no', $chunk)->delete();
}
return $removed;
}
/** 主口径:发货单(dlv 镜像) × 每件商品。 */
private function syncExpress(bool $windowed, ?string $fromS, ?string $toS, int $limit, $now, ?callable $progress = null): int
{
$batch = 1500;
$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.id 翻页(有索引,毫秒级),send_time 窗口过滤放到 PHP,
// 避免每页都对无索引的 send_time 做全表扫描(那是"卡死"的真凶)。
$fromTs = $fromS !== null ? strtotime($fromS) : null;
$toTs = $toS !== null ? strtotime($toS) : null;
$total = 0;
$scanned = 0;
$lastId = null;
$entered = false;
for ($b = 0; $b < $maxBatches; $b++) {
$where = $windowed ? '1 = 1' : 'e.send_time IS NOT NULL';
$bind = [];
if ($lastId !== null) {
$where .= ' AND e.id < ?';
$bind[] = $lastId;
}
$sql = "SELECT TOP {$batch} {$cols} FROM rpt.jst_order_express_details e "
. 'LEFT JOIN rpt.jst_Orders jo ON jo.o_id = e.inner_order_id '
. "WHERE {$where} ORDER BY e.id DESC";
$rows = $this->up->select($sql, $bind);
if ($rows === []) {
break;
}
$cnt = count($rows);
$scanned += $cnt;
foreach ($rows as $r) {
$lastId = $r['id'];
}
$pageNewest = null;
$pageHit = false;
if ($windowed) {
$kept = [];
foreach ($rows as $r) {
$st = $r['send_time'] ?? null;
$ts = ($st !== null && $st !== '') ? strtotime((string) $st) : null;
if ($ts === null) {
$jd = $r['jo_send'] ?? null;
$ts = ($jd !== null && $jd !== '') ? strtotime((string) $jd) : null;
}
if ($ts !== null && ($pageNewest === null || $ts > $pageNewest)) {
$pageNewest = $ts;
}
if ($ts !== null && $ts >= $fromTs && $ts < $toTs) {
$kept[] = $r;
$pageHit = true;
$entered = true;
}
}
$rows = $kept;
}
$oids = [];
$soIds = [];
$logs = [];
foreach ($rows as $r) {
$so = (string) ($r['so_id'] ?? '');
if ($so !== '') {
$soIds[$so] = true;
}
$oid = (string) ($r['inner_order_id'] ?? '');
if ($oid !== '') {
$oids[$oid] = true;
}
$lg = (string) ($r['logistic_id'] ?? '');
if ($lg !== '') {
$logs[$lg] = true;
}
}
[$byOid, $bySo] = $this->loadAllItems(array_keys($oids), array_keys($soIds));
$dws = [];
foreach (array_keys($soIds) as $mso) {
if (empty($bySo[$mso]) && ! empty($this->dwsCache[$mso])) {
$dws[$mso] = $this->dwsCache[$mso];
}
}
// 主口径:发货数据视图按出库单(io_id)取实发商品
$ioMap = $this->loadIoMap(array_keys($logs), array_keys($oids));
$ioIds = [];
foreach ($rows as $r) {
$io = $this->ioFor($ioMap, (string) ($r['logistic_id'] ?? ''), (string) ($r['inner_order_id'] ?? ''));
if ($io !== '') {
$ioIds[$io] = true;
}
}
$viewItems = $this->loadViewItems(array_keys($ioIds));
$skus = $this->loadSkus($this->collectSkuIds($byOid, $bySo, $dws, $viewItems));
$freight = $this->loadFreight(array_keys($soIds));
foreach ($rows as $r) {
$pkgId = (int) ($r['id'] ?? 0);
if ($pkgId === 0) {
continue;
}
if ($this->excludedByLabel($r['order_labels'] ?? null)) {
continue; // 线上发货(平台代发/云仓):工作平台不拉,对齐排除
}
$so = (string) ($r['so_id'] ?? '');
$oid = (string) ($r['inner_order_id'] ?? '');
$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);
$pkg = $this->pkgFromExpress($r, $tracking, $shipTime);
$total += $this->emitPackage($pkgId, $pkg, $items, $skus, $freight, $so, $tracking, $now);
}
unset($rows, $byOid, $bySo, $dws, $skus, $freight);
if ($b % 20 === 0) {
gc_collect_cycles();
}
if ($progress && $b % 20 === 0) {
$progress(sprintf('发货:已扫描 %d 行,命中窗口 %d 件', $scanned, $total));
}
// 表远大于窗口时提前结束:已进入窗口,且本页整页都早于窗口(id≈时间倒序)。
if ($windowed && $entered && ! $pageHit && $pageNewest !== null && $fromTs !== null && $pageNewest < $fromTs) {
break;
}
if ($cnt < $batch) {
break;
}
if (! $windowed && $total >= $limit) {
break;
}
}
if ($progress) {
$progress(sprintf('发货完成:扫描 %d 行,写入 %d 件', $scanned, $total));
}
$this->flush();
return $total;
}
/** 并入自动发货仓:jst_Orders 已发货但不在发货明细表。 */
private function syncAutoShip(string $fromS, string $toS, $now, ?callable $progress = null): int
{
$batch = 400;
$maxBatches = 600;
$cols = 'o.Id AS rid, o.o_id, o.so_id, o.l_id, o.logistics_company, o.order_from, o.labels, '
. 'o.shop_name, o.send_date, o.pay_date, o.weight, o.buyer_message, '
. 'o.receiver_state, o.receiver_city, o.receiver_district, o.receiver_address, '
. 'o.receiver_name, o.receiver_mobile, o.receiver_phone';
$total = 0;
$lastRid = null;
for ($b = 0; $b < $maxBatches; $b++) {
$where = "o.status = 'Sent' AND o.send_date >= ? AND o.send_date < ? "
. 'AND NOT EXISTS (SELECT 1 FROM rpt.jst_order_express_details e WHERE e.inner_order_id = o.o_id)';
$bind = [$fromS, $toS];
if ($lastRid !== null) {
$where .= ' AND o.Id < ?';
$bind[] = $lastRid;
}
$sql = "SELECT TOP {$batch} {$cols} FROM rpt.jst_Orders o WHERE {$where} ORDER BY o.Id DESC";
$rows = $this->up->select($sql, $bind);
if ($rows === []) {
break;
}
$oids = [];
$soIds = [];
$logs = [];
foreach ($rows as $r) {
$so = (string) ($r['so_id'] ?? '');
if ($so !== '') {
$soIds[$so] = true;
}
$oid = (string) ($r['o_id'] ?? '');
if ($oid !== '') {
$oids[$oid] = true;
}
$lg = (string) ($r['l_id'] ?? '');
if ($lg !== '') {
$logs[$lg] = true;
}
$lastRid = $r['rid'];
}
[$byOid, $bySo] = $this->loadAllItems(array_keys($oids), array_keys($soIds));
$dws = [];
foreach (array_keys($soIds) as $mso) {
if (empty($bySo[$mso]) && ! empty($this->dwsCache[$mso])) {
$dws[$mso] = $this->dwsCache[$mso];
}
}
$ioMap = $this->loadIoMap(array_keys($logs), array_keys($oids));
$ioIds = [];
foreach ($rows as $r) {
$io = $this->ioFor($ioMap, (string) ($r['l_id'] ?? ''), (string) ($r['o_id'] ?? ''));
if ($io !== '') {
$ioIds[$io] = true;
}
}
$viewItems = $this->loadViewItems(array_keys($ioIds));
$skus = $this->loadSkus($this->collectSkuIds($byOid, $bySo, $dws, $viewItems));
$freight = $this->loadFreight(array_keys($soIds));
$cnt = count($rows);
foreach ($rows as $r) {
$oid = (string) ($r['o_id'] ?? '');
if ($oid === '') {
continue;
}
if ($this->excludedByLabel($r['labels'] ?? null)) {
continue; // 线上发货:对齐排除
}
$so = (string) ($r['so_id'] ?? '');
$io = $this->ioFor($ioMap, (string) ($r['l_id'] ?? ''), $oid);
$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);
$pkg = $this->pkgFromOrder($r, $tracking, $shipTime);
$pkgId = self::AUTOSHIP_OFFSET + (int) $oid;
$total += $this->emitPackage($pkgId, $pkg, $items, $skus, $freight, $so, $tracking, $now, true);
}
unset($rows, $byOid, $bySo, $dws, $skus, $freight);
if ($b % 30 === 0) {
gc_collect_cycles();
}
if ($cnt < $batch) {
break;
}
}
if ($progress) {
$progress(sprintf('代发仓完成:写入 %d 件', $total));
}
$this->flush();
return $total;
}
/**
* 输出一个包裹的所有明细行(每件一行;无商品则一行空商品)。
* @return int 写入行数
*/
private function emitPackage(int $pkgId, array $pkg, array $items, array $skus, array $freight, string $so, ?string $tracking, $now, bool $auto = false): int
{
$items = array_values(array_filter($items, fn ($it) => ! $this->isPricePatch($it['name'] ?? null)));
$n = count($items);
$f = $freight[$so . '|' . ($tracking ?? '')] ?? ($freight[$so . '|'] ?? []);
if ($n === 0) {
$this->push($pkgId, $pkgId, $this->buildRow($pkg, [], [], $f, true, '1/1', $now, $auto));
return 1;
}
$count = 0;
foreach ($items as $idx => $it) {
$first = ($idx === 0);
$sk = (! empty($it['sku']) && isset($skus[(string) $it['sku']])) ? $skus[(string) $it['sku']] : [];
$skuSid = (int) ($it['id'] ?? ($pkgId * 100 + $idx));
$this->push($pkgId, $skuSid, $this->buildRow($pkg, $it, $sk, $f, $first, '1/' . $n, $now, $auto));
$count++;
}
return $count;
}
/** 组装一件明细行。包裹级字段(实称重量/运费)仅首件携带。 */
private function buildRow(array $pkg, array $it, array $sk, array $f, bool $first, string $countStr, $now, bool $auto): array
{
$qty = (float) ($it['qty'] ?? 0);
$skuW = (float) (($it['weight'] ?? null) ?: ($sk['weight'] ?? 0));
$onlineW = $skuW > 0 ? round($skuW * ($qty > 0 ? $qty : 1), 4) : null;
$L = ($it['l'] ?? null) ?: ($sk['l'] ?? null);
$W = ($it['w'] ?? null) ?: ($sk['w'] ?? null);
$H = ($it['h'] ?? null) ?: ($sk['h'] ?? null);
$vol = ($it['vol'] ?? null) ? round((float) $it['vol'], 4) : (($L && $W && $H) ? round((float) $L * (float) $W * (float) $H, 4) : null);
$pkgWeight = (float) ($pkg['weight'] ?? 0) ?: null;
$freightW = (float) ($f['w'] ?? 0);
$settleW = $freightW > 0 ? $freightW : ($pkgWeight ?: $onlineW);
return $this->cleanPayload([
'express_no' => $pkg['tracking'],
'trade_no' => $pkg['so_id'],
'item_id' => $it['shopid'] ?? null,
'barcode' => $it['sku'] ?? null,
'product_name' => ($it['name'] ?? null) ?: ($sk['name'] ?? null),
'qty' => $it['qty'] ?? null,
'pkg_count' => $countStr,
'product_code' => ($it['iid'] ?? null) ?: ($sk['iid'] ?? null),
'express_name1' => $pkg['express_name1'],
'express_name2' => ($f['name'] ?? null) ?: $pkg['express_name1'],
'ship_time' => $pkg['ship_time'],
'pay_time' => $pkg['pay_time'],
'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),
'receiver_state' => $pkg['r_state'],
'receiver_city' => $pkg['r_city'],
'receiver_district' => $pkg['r_district'],
'receiver_address' => $pkg['r_address'],
'receiver_name' => $pkg['r_name'],
'receiver_mobile' => $pkg['r_mobile'],
'receiver_phone' => $pkg['r_phone'],
'online_weight' => $onlineW,
'actual_weight' => $first ? $pkgWeight : null,
'length_cm' => $L,
'width_cm' => $W,
'height_cm' => $H,
'volume_cm3' => $vol,
'weight_kg' => $first ? $settleW : null,
'settle_weight_kg' => $first ? $settleW : null,
'amount' => $it['amount'] ?? null,
'freight' => $first ? ($f['cost'] ?? null) : null,
'settle_freight' => $first ? ($f['cost'] ?? null) : null,
'buyer_memo' => $pkg['buyer_memo'],
'modified' => $pkg['ship_time'],
'synced_at' => $now,
]);
}
/** 发货明细表行 → 包裹级字段(省市区从地址拆) */
private function pkgFromExpress(array $r, ?string $tracking, ?string $shipTime): array
{
$addr = (string) ($r['receiver_address'] ?? '');
$parts = $addr !== '' ? array_values(array_filter(explode('-', $addr))) : [];
return [
'tracking' => $tracking,
'so_id' => $r['so_id'] ?? null,
'express_name1' => $r['logistic_company_name'] ?? null,
'ship_time' => $shipTime,
'pay_time' => $this->ts($r['pay_time'] ?? null),
'shop_name' => $r['shop_name'] ?? null,
'platform' => $r['platform_name'] ?? null,
'dis_co_name' => $r['dis_co_name'] ?? null,
'warehouse_name' => $r['warehouse_name'] ?? null,
'r_state' => $parts[0] ?? null,
'r_city' => $parts[1] ?? null,
'r_district' => $parts[2] ?? null,
'r_address' => $addr,
'r_name' => null,
'r_mobile' => null,
'r_phone' => null,
'buyer_memo' => $r['buyer_message'] ?? null,
'weight' => $r['weight'] ?? null,
];
}
/** jst_Orders 行(自动发货仓) → 包裹级字段 */
private function pkgFromOrder(array $r, ?string $tracking, ?string $shipTime): array
{
return [
'tracking' => $tracking,
'so_id' => $r['so_id'] ?? null,
'express_name1' => $r['logistics_company'] ?? null,
'ship_time' => $shipTime,
'pay_time' => $this->ts($r['pay_date'] ?? null),
'shop_name' => $r['shop_name'] ?? null,
'platform' => $r['order_from'] ?? 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' => $r['receiver_address'] ?? '',
'r_name' => $r['receiver_name'] ?? null,
'r_mobile' => $r['receiver_mobile'] ?? null,
'r_phone' => $r['receiver_phone'] ?? null,
'buyer_memo' => $r['buyer_message'] ?? null,
'weight' => $r['weight'] ?? null,
];
}
private function tracking(string $logisticId, string $joLid, array $freight, string $so): ?string
{
if ($logisticId !== '') {
return $logisticId;
}
if ($joLid !== '') {
return $joLid;
}
$f = $freight[$so . '|'] ?? [];
$no = (string) ($f['no'] ?? '');
return $no !== '' ? $no : null;
}
/** @return array{0:array<string,array>,1:array<string,array>} [byOid, bySo] */
private function loadAllItems(array $oids, array $soIds): array
{
$byOid = [];
$bySo = [];
$sel = 'o_id, raw_so_id, sku_id, i_id, shop_i_id, name, qty, amount, send_warehouse, Id';
foreach (array_chunk($oids, 800) as $chunk) {
if ($chunk === []) {
continue;
}
$ph = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->up->select("SELECT {$sel} FROM rpt.jst_OrderItem WHERE o_id IN ({$ph}) ORDER BY Id", $chunk) as $r) {
$k = (string) ($r['o_id'] ?? '');
if ($k !== '') {
$byOid[$k][] = $this->itemArr($r);
}
}
}
foreach (array_chunk($soIds, 800) as $chunk) {
if ($chunk === []) {
continue;
}
$ph = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->up->select("SELECT {$sel} FROM rpt.jst_OrderItem WHERE raw_so_id IN ({$ph}) ORDER BY Id", $chunk) as $r) {
$k = (string) ($r['raw_so_id'] ?? '');
if ($k !== '') {
$bySo[$k][] = $this->itemArr($r);
}
}
}
return [$byOid, $bySo];
}
/** 开跑前按天预加载窗口内 dws 销售全量到内存,避免逐批扫表。 */
private function preloadDws(\Illuminate\Support\Carbon $from, \Illuminate\Support\Carbon $to): void
{
$this->dwsCache = [];
$seen = [];
$day = $from->copy()->subDay()->startOfDay();
$end = $to->copy();
while ($day < $end) {
$d = $day->format('Y-m-d');
$day = $day->addDay();
try {
$rows = $this->up->select("SELECT sourceno, item_id, skuoid, goods_count, sales_amount FROM rpt.dws_sn_sales_full_info_di WHERE stat_date = ?", [$d]);
} catch (Throwable) {
continue;
}
foreach ($rows as $r) {
$so = (string) ($r['sourceno'] ?? '');
$bc = (string) ($r['skuoid'] ?? '');
if ($so === '' || $bc === '') {
continue;
}
$k = $so.'|'.$bc;
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$this->dwsCache[$so][] = [
'id' => null, 'sku' => $bc, 'iid' => null, 'shopid' => $r['item_id'] ?? null,
'name' => null, 'qty' => $r['goods_count'] ?? null, 'amount' => $r['sales_amount'] ?? null, 'warehouse' => null,
];
}
unset($rows);
}
}
/** jst_OrderItem 缺失时的兜底:dws 销售全量按 sourceno 取商品(条码/商品ID/数量/金额)。 */
private function loadDwsItems(array $soIds): array
{
$out = [];
$seen = [];
foreach (array_chunk($soIds, 500) as $chunk) {
if ($chunk === []) {
continue;
}
$ph = implode(',', array_fill(0, count($chunk), '?'));
$sql = "SELECT sourceno, item_id, skuoid, goods_count, sales_amount FROM rpt.dws_sn_sales_full_info_di WHERE sourceno IN ({$ph})";
foreach ($this->up->select($sql, $chunk) as $r) {
$so = (string) ($r['sourceno'] ?? '');
$barcode = (string) ($r['skuoid'] ?? '');
if ($so === '' || $barcode === '') {
continue;
}
$k = $so.'|'.$barcode;
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$out[$so][] = [
'id' => null, 'sku' => $barcode, 'iid' => null, 'shopid' => $r['item_id'] ?? null,
'name' => null, 'qty' => $r['goods_count'] ?? null, 'amount' => $r['sales_amount'] ?? null, 'warehouse' => null,
];
}
}
return $out;
}
private function itemArr(array $r): array
{
return [
'id' => $r['Id'] ?? null, 'sku' => $r['sku_id'] ?? null, 'iid' => $r['i_id'] ?? null,
'shopid' => $r['shop_i_id'] ?? null, 'name' => $r['name'] ?? null,
'qty' => $r['qty'] ?? null, 'amount' => $r['amount'] ?? null, 'warehouse' => $r['send_warehouse'] ?? null,
];
}
/** 该包裹的商品:优先 o_id 精确,取不到回落 so_id。 */
private function itemsFor(array $byOid, array $bySo, array $dws, string $oid, string $so): array
{
if ($oid !== '' && ! empty($byOid[$oid])) {
return $byOid[$oid];
}
if ($so !== '' && ! empty($bySo[$so])) {
return $bySo[$so];
}
if ($so !== '' && ! empty($dws[$so])) {
return $dws[$so]; // jst_OrderItem 取不到时,用 dws 销售全量兜底
}
return [];
}
/** 快递单号/内部单号 -> 出库单 io_id(优先快递单号,精确到该次出库)。 */
private function loadIoMap(array $logisticIds, array $oIds): array
{
$byL = [];
$byO = [];
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'] ?? '');
}
}
}
foreach (array_chunk(array_values(array_unique(array_filter($oIds))), 800) as $chunk) {
if ($chunk === []) {
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) {
$o = (string) ($r['o_id'] ?? '');
if ($o !== '' && ! isset($byO[$o])) {
$byO[$o] = (string) ($r['io_id'] ?? '');
}
}
}
return ['byL' => $byL, 'byO' => $byO];
}
private function ioFor(array $ioMap, string $logisticId, string $oid): string
{
if ($logisticId !== '' && ! empty($ioMap['byL'][$logisticId])) {
return (string) $ioMap['byL'][$logisticId];
}
if ($oid !== '' && ! empty($ioMap['byO'][$oid])) {
return (string) $ioMap['byO'][$oid];
}
return '';
}
/** io_id -> 实发商品[itemArr](发货数据视图,按(io_id,条码)去重,type=0 之和优先)。 */
private function loadViewItems(array $ioIds): array
{
$agg = [];
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 '
. self::VIEW . " WHERE io_id IN ({$ph})";
foreach ($this->up->select($sql, $chunk) as $r) {
$io = (string) ($r['io_id'] ?? '');
$skuIds = (string) ($r['sku_ids'] ?? '');
if ($io === '' || $skuIds === '') {
continue;
}
// 一行可能逗号拼接多个条码 —— 对齐工作平台导出明细:拆成每条码一件;
// 数量与体积按条码数均摊,保证合并求和后与整行值一致(工作平台合并体积"整箱算一次")。
$bcs = array_values(array_filter(array_map('trim', explode(',', $skuIds)), fn ($x) => $x !== ''));
if ($bcs === []) {
continue;
}
$n = count($bcs);
$isT0 = ((string) ($r['type'] ?? '') === '0');
$perQty = (float) ($r['qty'] ?? 0) / $n;
$rowVol = ($r['volume'] !== null && $r['volume'] !== '') ? (float) $r['volume'] : null;
$perVol = ($rowVol !== null) ? $rowVol / $n : null;
foreach ($bcs as $bc) {
$k = $io . '|' . mb_strtolower($bc);
if (! isset($agg[$k])) {
$agg[$k] = [
'io' => $io, 'sku' => $bc, 't0' => 0.0, 't0n' => 0, 'any' => 0.0, 'anyn' => 0,
't0vol' => 0.0, 't0voln' => 0, 'anyvol' => 0.0, 'anyvoln' => 0,
'shopid' => null, 'name' => null, 'wh' => null,
'l' => null, 'w' => null, 'h' => null,
'weight' => null, 'supplier' => null,
];
}
if ($isT0) {
$agg[$k]['t0'] += $perQty;
$agg[$k]['t0n']++;
if ($perVol !== null) {
$agg[$k]['t0vol'] += $perVol;
$agg[$k]['t0voln']++;
}
}
$agg[$k]['any'] += $perQty;
$agg[$k]['anyn']++;
if ($perVol !== null) {
$agg[$k]['anyvol'] += $perVol;
$agg[$k]['anyvoln']++;
}
// 名称/长宽高/供应商沿用整行值(共享):任意行回填一次
if (empty($agg[$k]['name']) && ! empty($r['sku_name'])) {
$agg[$k]['name'] = $r['sku_name'];
}
if (empty($agg[$k]['shopid']) && ! empty($r['shop_i_id'])) {
$agg[$k]['shopid'] = $r['shop_i_id'];
}
if (empty($agg[$k]['wh']) && ! empty($r['wareHoursName'])) {
$agg[$k]['wh'] = $r['wareHoursName'];
}
foreach (['l', 'w', 'h', 'weight'] as $ff) {
if (empty($agg[$k][$ff]) && ! empty($r[$ff])) {
$agg[$k][$ff] = $r[$ff];
}
}
if (empty($agg[$k]['supplier']) && ! empty($r['supplier_name'])) {
$agg[$k]['supplier'] = $r['supplier_name'];
}
}
}
}
$out = [];
foreach ($agg as $a) {
$qty = $a['t0n'] > 0 ? $a['t0'] : ($a['anyn'] > 0 ? $a['any'] / $a['anyn'] : 0);
$qty = (int) round($qty);
if ($qty < 1) {
$qty = 1;
}
// 体积:type=0 各条码分摊之和优先;否则取任一 type 的均值(去 type 重复)
$vol = $a['t0voln'] > 0
? round($a['t0vol'], 4)
: ($a['anyvoln'] > 0 ? round($a['anyvol'] / $a['anyvoln'], 4) : null);
$out[$a['io']][] = [
'id' => null, 'sku' => $a['sku'], 'iid' => null,
'shopid' => $a['shopid'], 'name' => $a['name'],
'qty' => $qty, 'amount' => null, 'warehouse' => $a['wh'],
'l' => $a['l'], 'w' => $a['w'], 'h' => $a['h'], 'vol' => $vol,
'weight' => $a['weight'], 'supplier' => $a['supplier'],
];
}
return $out;
}
private function collectSkuIds(array ...$maps): array
{
$ids = [];
foreach ($maps as $map) {
foreach ($map as $items) {
foreach ($items as $it) {
if (! empty($it['sku'])) {
$ids[(string) $it['sku']] = true;
}
}
}
}
return array_keys($ids);
}
private function push(int $sourceId, int $skuSourceId, array $payload): void
{
$payload['source_id'] = $sourceId;
$payload['sku_source_id'] = $skuSourceId;
$this->buffer[] = $payload;
if (count($this->buffer) >= self::FLUSH) {
$this->flush();
}
}
private function flush(): void
{
if ($this->buffer === []) {
return;
}
$this->db->table('jst_oms_shipping')->insertOrIgnore($this->buffer);
$this->buffer = [];
}
private function isPricePatch(?string $name): bool
{
if ($name === null || $name === '') {
return false;
}
return str_contains($name, '补收差价') || str_contains($name, '差价专用');
}
/** 线上发货(平台代发/云仓)标签:工作平台不拉取,caiwu 对齐排除。 */
private function excludedByLabel(?string $labels): bool
{
if ($labels === null || $labels === '') {
return false;
}
return str_contains($labels, '线上发货');
}
/** @return array<string,array> sku_id => 尺寸/重量/供应商 */
private function loadSkus(array $skuIds): array
{
$out = [];
foreach (array_chunk($skuIds, 800) as $chunk) {
if ($chunk === []) {
continue;
}
$ph = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->up->select("SELECT sku_id, l, w, h, pack_volume, weight, supplier_name, name, i_id FROM rpt.jst_sku WHERE sku_id IN ({$ph})", $chunk) as $r) {
$out[(string) $r['sku_id']] = [
'l' => $r['l'] ?? null, 'w' => $r['w'] ?? null, 'h' => $r['h'] ?? null,
'vol' => $r['pack_volume'] ?? null, 'weight' => $r['weight'] ?? null,
'supplier' => $r['supplier_name'] ?? null,
'name' => $r['name'] ?? null, 'iid' => $r['i_id'] ?? null,
];
}
}
return $out;
}
/** @return array<string,array> so_id|expressNo => 运费 (含 so_id| 兜底键) */
private function loadFreight(array $soIds): array
{
$out = [];
foreach (array_chunk($soIds, 800) as $chunk) {
if ($chunk === []) {
continue;
}
$ph = implode(',', array_fill(0, count($chunk), '?'));
$sql = 'SELECT so_id, expressNo, SUM(freightCost) AS cost, SUM(weight) AS w, '
. 'MAX(expressName) AS name, MAX(receiver_state) AS state '
. "FROM rpt.fys_jstFreightCostDetail WHERE so_id IN ({$ph}) GROUP BY so_id, expressNo";
foreach ($this->up->select($sql, $chunk) as $r) {
$expno = (string) ($r['expressNo'] ?? '');
$val = ['cost' => $r['cost'] ?? null, 'w' => $r['w'] ?? null, 'name' => $r['name'] ?? null, 'state' => $r['state'] ?? null, 'no' => $expno ?: null];
$out[((string) $r['so_id']) . '|' . $expno] = $val;
$out[((string) $r['so_id']) . '|'] ??= $val;
}
}
return $out;
}
private function cleanPayload(array $data): array
{
foreach ($data as $k => $v) {
if (is_string($v)) {
$data[$k] = $this->cleanStr($v);
}
}
return $data;
}
private function cleanStr(string $v): string
{
$v = str_replace("\0", '', $v);
$clean = @iconv('UTF-8', 'UTF-8//IGNORE', $v);
if ($clean === false) {
$clean = mb_convert_encoding($v, 'UTF-8', 'UTF-8');
}
return $clean;
}
private function ts(mixed $v): ?string
{
if ($v === null || $v === '') {
return null;
}
try {
return Carbon::parse((string) $v)->format('Y-m-d H:i:s');
} catch (Throwable) {
return null;
}
}
private function markRunning(): void
{
$this->db->table('jst_sync_state')->updateOrInsert(
['dataset_key' => self::DATASET],
['status' => 'running', 'last_started_at' => now(), 'updated_at' => now(), 'created_at' => now()]
);
}
private function markSucceeded(): void
{
$this->db->table('jst_sync_state')->where('dataset_key', self::DATASET)->update([
'status' => 'succeeded', 'last_succeeded_at' => now(), 'last_error' => null, 'watermark' => now(), 'updated_at' => now(),
]);
}
private function markFailed(string $err): void
{
$this->db->table('jst_sync_state')->where('dataset_key', self::DATASET)->update([
'status' => 'failed', 'last_error' => mb_substr($err, 0, 1000), 'updated_at' => now(),
]);
}
}

View File

@ -11,6 +11,7 @@
['barcode','条形码','left','text'],
['product_name','商品名称','left','text'],
['qty','数量','right','int'],
['pkg_count','件数','center','text'],
['receiver_state','省','left','text'],
['buyer_nick','买家昵称','left','text'],
['receiver_name','收货姓名','left','text'],
@ -85,6 +86,31 @@
.shipping-data-page .sd-table tbody tr:hover { background: #fcfaf7; }
.shipping-data-page .sd-mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: .72rem; color: #4b3a24; }
.shipping-data-page .sd-scroll { overflow-x: auto; max-width: 100%; }
.shipping-data-page .sd-ac-field { position: relative; }
.shipping-data-page .sd-ac-list { position: absolute; top: 100%; left: 0; right: 0; z-index: 40; margin-top: 2px; max-height: 15rem; overflow-y: auto; list-style: none; padding: 4px; background: #fff; border: 1px solid #d8d2c7; border-radius: .5rem; box-shadow: 0 8px 24px rgba(0,0,0,.10); }
.shipping-data-page .sd-ac-item { padding: .35rem .5rem; font-size: .8rem; border-radius: .375rem; cursor: pointer; color: #29251f; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.shipping-data-page .sd-ac-item:hover, .shipping-data-page .sd-ac-item.is-active { background: #f3ece0; }
.shipping-data-page .sd-ac-empty { padding: .35rem .5rem; font-size: .75rem; color: #9a9182; }
.shipping-data-page .sd-dd { position: relative; }
.shipping-data-page .sd-dd-toggle { display: flex; align-items: center; justify-content: space-between; gap: .25rem; width: 100%; height: 2.125rem; border: 1px solid #d8d2c7; border-radius: .5rem; padding: 0 .35rem 0 .625rem; background: #fff; color: #171717; font-size: .8125rem; cursor: pointer; text-align: left; }
.shipping-data-page .sd-dd.is-open .sd-dd-toggle, .shipping-data-page .sd-dd-toggle:focus { outline: none; border-color: #b18a4a; box-shadow: 0 0 0 2px rgba(177,138,74,.15); }
.shipping-data-page .sd-dd-summary { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shipping-data-page .sd-dd-summary.is-empty { color: #9a9182; }
.shipping-data-page .sd-dd-caret { font-size: 1.1rem; color: #856632; flex: none; }
.shipping-data-page .sd-dd-panel { position: absolute; top: 100%; left: 0; z-index: 50; margin-top: 2px; min-width: 100%; width: max-content; max-width: 20rem; background: #fff; border: 1px solid #d8d2c7; border-radius: .5rem; box-shadow: 0 8px 24px rgba(0,0,0,.12); padding: 6px; }
.shipping-data-page .sd-dd-search { display: flex; gap: 4px; margin-bottom: 4px; }
.shipping-data-page .sd-dd-q { flex: 1; height: 1.9rem; border: 1px solid #d8d2c7; border-radius: .375rem; padding: 0 .5rem; font-size: .8rem; outline: none; }
.shipping-data-page .sd-dd-q:focus { border-color: #b18a4a; }
.shipping-data-page .sd-dd-clear { border: 1px solid #d8d2c7; border-radius: .375rem; background: #f7f3ec; color: #4b3a24; font-size: .75rem; padding: 0 .6rem; cursor: pointer; white-space: nowrap; }
.shipping-data-page .sd-dd-clear:hover { background: #efe7d8; }
.shipping-data-page .sd-dd-head { display: flex; align-items: center; gap: .4rem; padding: .3rem .4rem; border-bottom: 1px solid #eee8de; font-size: .78rem; font-weight: 600; color: #6f675c; cursor: pointer; }
.shipping-data-page .sd-dd-list { list-style: none; margin: 0; padding: 2px 0 0; max-height: 14rem; overflow-y: auto; }
.shipping-data-page .sd-dd-opt { display: flex; align-items: center; gap: .4rem; padding: .3rem .4rem; font-size: .8rem; color: #29251f; border-radius: .375rem; cursor: pointer; white-space: nowrap; }
.shipping-data-page .sd-dd-opt:hover { background: #f3ece0; }
.shipping-data-page .sd-dd-opt > span { overflow: hidden; text-overflow: ellipsis; }
.shipping-data-page .sd-dd-empty { padding: .4rem; color: #9a9182; font-size: .75rem; }
.shipping-data-page .sd-tab { padding: .3rem .7rem; border: 1px solid #d8d2c7; border-radius: 999px; color: #6f675c; background: #fff; }
.shipping-data-page .sd-tab--on { background: #856632; color: #fff; border-color: #856632; }
</style>
<div class="flex flex-col gap-4 mb-5 sm:flex-row sm:items-start sm:justify-between">
@ -103,19 +129,56 @@
@endif
<div class="mb-5 rounded-lg border border-[#e7e0d5] bg-white p-4 shadow-sm">
<form method="GET" class="flex flex-wrap items-end gap-3" aria-label="发货数据筛选">
@php $mode = $shippingMode ?? 'merged'; $modeQuery = array_merge(request()->query(), []); @endphp
<div class="mb-3 flex items-center gap-2 text-xs">
<span class="text-gray-500">口径:</span>
<a href="{{ url()->current() }}?{{ http_build_query(array_merge(request()->except('page','mode'), ['mode'=>'merged'])) }}"
class="sd-tab {{ $mode==='merged' ? 'sd-tab--on' : '' }}">合并(一个快递单号一行)</a>
<a href="{{ url()->current() }}?{{ http_build_query(array_merge(request()->except('page','mode'), ['mode'=>'detail'])) }}"
class="sd-tab {{ $mode==='detail' ? 'sd-tab--on' : '' }}">明细(每件商品一行)</a>
</div>
<form method="GET" class="flex flex-wrap items-end gap-3" aria-label="发货数据筛选" data-suggest-url="{{ route('business.courier-payables.shipping-data.suggest') }}">
<input type="hidden" name="mode" value="{{ $shippingMode ?? 'merged' }}">
<label class="sd-filter sd-filter--wide flex flex-col gap-1 text-xs text-gray-500" for="shipping-express-no"><span>快递单号</span><input id="shipping-express-no" class="sd-control" type="text" name="express_no" value="{{ $filters['express_no'] ?? '' }}" placeholder="精确匹配"></label>
<label class="sd-filter sd-filter--wide flex flex-col gap-1 text-xs text-gray-500" for="shipping-trade-no"><span>交易单号</span><input id="shipping-trade-no" class="sd-control" type="text" name="sourceno" value="{{ $filters['sourceno'] ?? '' }}" placeholder="精确匹配"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-barcode"><span>条形码</span><input id="shipping-barcode" class="sd-control" type="text" name="barcode" value="{{ $filters['barcode'] ?? '' }}" placeholder="条形码关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-supplier"><span>供应商</span><input id="shipping-supplier" class="sd-control" type="text" name="supplier_name" value="{{ $filters['supplier_name'] ?? '' }}" placeholder="名称关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-shop"><span>店铺名称</span><input id="shipping-shop" class="sd-control" type="text" name="shop_name" value="{{ $filters['shop_name'] ?? '' }}" placeholder="名称关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-warehouse"><span>仓库</span><input id="shipping-warehouse" class="sd-control" type="text" name="warehouse_name" value="{{ $filters['warehouse_name'] ?? '' }}" placeholder="仓库关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-courier"><span>快递公司</span><input id="shipping-courier" class="sd-control" type="text" name="express_company" value="{{ $filters['express_company'] ?? '' }}" placeholder="匹配快递名称1/2"></label>
@php
$msFields = [
['supplier_name', '供应商'],
['shop_name', '店铺名称'],
['warehouse_name', '仓库'],
['express_company', '快递公司'],
];
$msVal = static function ($key) use ($filters) {
$v = $filters[$key] ?? [];
if (is_string($v)) { $v = $v === '' ? [] : [$v]; }
return array_values(array_filter(array_map('strval', (array) $v), static fn ($x) => $x !== ''));
};
@endphp
@foreach ($msFields as [$msKey, $msLabel])
<div class="sd-filter sd-dd-field flex flex-col gap-1 text-xs text-gray-500"><span>{{ $msLabel }}</span>
<div class="sd-dd" data-suggest="{{ $msKey }}" data-name="{{ $msKey }}" data-ph="选择{{ $msLabel }}">
<button type="button" class="sd-dd-toggle"><span class="sd-dd-summary"></span><span class="sd-dd-caret material-icons-outlined" aria-hidden="true">expand_more</span></button>
<div class="sd-dd-panel" hidden>
<div class="sd-dd-search"><input type="text" class="sd-dd-q" placeholder="回车检索..."><button type="button" class="sd-dd-clear">清除</button></div>
<label class="sd-dd-head"><input type="checkbox" class="sd-dd-all"><span>{{ $msLabel }}</span></label>
<ul class="sd-dd-list"></ul>
</div>
<span class="sd-dd-hidden">
@foreach ($msVal($msKey) as $chip)
<input type="hidden" name="{{ $msKey }}[]" value="{{ $chip }}">
@endforeach
</span>
</div>
</div>
@endforeach
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-from"><span>发货时间起</span><span class="sd-date-picker"><input id="shipping-from" class="sd-control" type="date" name="ship_from" value="{{ $filters['ship_from'] ?? '' }}"><button type="button" data-date-picker-input="shipping-from" aria-label="选择发货开始日期" title="选择发货开始日期"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button></span></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-to"><span>发货时间止</span><span class="sd-date-picker"><input id="shipping-to" class="sd-control" type="date" name="ship_to" value="{{ $filters['ship_to'] ?? '' }}"><button type="button" data-date-picker-input="shipping-to" aria-label="选择发货结束日期" title="选择发货结束日期"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button></span></label>
<div class="flex gap-2">
<button class="sd-button sd-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span><span>查询</span></button>
<a class="sd-button sd-button--secondary" href="{{ $shippingExportUrl ?? route('business.courier-payables.shipping-data.export') }}?{{ http_build_query(array_filter($filters, static fn ($value) => $value !== '')) }}" aria-label="导出当前筛选结果" title="导出当前筛选结果"><span class="material-icons-outlined" aria-hidden="true">download</span><span>导出</span></a>
@php $expBase = ($shippingExportUrl ?? route('business.courier-payables.shipping-data.export')) . '?' . http_build_query(array_filter($filters, static fn ($value) => is_array($value) ? $value !== [] : $value !== '')); @endphp
<a class="sd-button sd-button--secondary" href="{{ $expBase }}&mode=merged" title="导出合并(一个快递单号一行)"><span class="material-icons-outlined" aria-hidden="true">download</span><span>导出</span></a>
<a class="sd-button sd-button--secondary" href="{{ $expBase }}&mode=detail" title="导出明细(每件商品一行)"><span class="material-icons-outlined" aria-hidden="true">list</span><span>导出明细</span></a>
<a class="sd-button sd-button--secondary" href="{{ url()->current() }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span><span>重置</span></a>
</div>
</form>
@ -151,11 +214,11 @@
<script>
(() => {
// 日期选择触发
document.querySelectorAll('.shipping-data-page [data-date-picker-input]').forEach((trigger) => {
if (trigger.dataset.datePickerBound === 'true') return;
const input = document.getElementById(trigger.dataset.datePickerInput || '');
if (!input) return;
trigger.dataset.datePickerBound = 'true';
trigger.addEventListener('click', () => {
input.focus();
@ -163,5 +226,87 @@
else input.click();
});
});
// 勾选式多选下拉
const form = document.querySelector('.shipping-data-page form[aria-label="发货数据筛选"]');
const url = form ? form.dataset.suggestUrl : '';
if (!form || !url) return;
form.querySelectorAll('.sd-dd').forEach((dd) => {
const field = dd.dataset.suggest;
const name = dd.dataset.name;
const ph = dd.dataset.ph || '请选择';
const toggle = dd.querySelector('.sd-dd-toggle');
const summary = dd.querySelector('.sd-dd-summary');
const panel = dd.querySelector('.sd-dd-panel');
const q = dd.querySelector('.sd-dd-q');
const clearBtn = dd.querySelector('.sd-dd-clear');
const allBox = dd.querySelector('.sd-dd-all');
const listEl = dd.querySelector('.sd-dd-list');
const hiddenWrap = dd.querySelector('.sd-dd-hidden');
let timer = null, seq = 0;
const selected = new Set([...hiddenWrap.querySelectorAll('input')].map((i) => i.value));
const syncHidden = () => {
hiddenWrap.innerHTML = '';
selected.forEach((v) => {
const h = document.createElement('input');
h.type = 'hidden'; h.name = name + '[]'; h.value = v;
hiddenWrap.appendChild(h);
});
};
const syncSummary = () => {
if (selected.size === 0) { summary.textContent = ph; summary.classList.add('is-empty'); }
else if (selected.size === 1) { summary.textContent = [...selected][0]; summary.classList.remove('is-empty'); }
else { summary.textContent = '已选 ' + selected.size + ' 项'; summary.classList.remove('is-empty'); }
};
const syncAll = () => {
const boxes = [...listEl.querySelectorAll('.sd-dd-cb')];
allBox.checked = boxes.length > 0 && boxes.every((b) => b.checked);
allBox.indeterminate = !allBox.checked && boxes.some((b) => b.checked);
};
const render = (items) => {
listEl.innerHTML = '';
if (!items.length) { listEl.innerHTML = '<li class="sd-dd-empty">无匹配项</li>'; syncAll(); return; }
items.forEach((it) => {
const li = document.createElement('li');
li.className = 'sd-dd-opt';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.className = 'sd-dd-cb'; cb.value = it; cb.checked = selected.has(it);
const sp = document.createElement('span'); sp.textContent = it; sp.title = it;
cb.addEventListener('change', () => {
if (cb.checked) selected.add(it); else selected.delete(it);
syncHidden(); syncSummary(); syncAll();
});
li.addEventListener('click', (e) => { if (e.target !== cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); } });
li.append(cb, sp);
listEl.appendChild(li);
});
syncAll();
};
const load = () => {
const my = ++seq;
const kw = q.value.trim();
fetch(url + '?field=' + encodeURIComponent(field) + '&q=' + encodeURIComponent(kw) + '&limit=300', { headers: { 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin' })
.then((r) => r.ok ? r.json() : { items: [] })
.then((d) => { if (my === seq) render(Array.isArray(d.items) ? d.items : []); })
.catch(() => { if (my === seq) render([]); });
};
const open = () => { if (!panel.hidden) return; dd.classList.add('is-open'); panel.hidden = false; load(); setTimeout(() => q.focus(), 0); };
const close = () => { panel.hidden = true; dd.classList.remove('is-open'); };
toggle.addEventListener('click', () => { panel.hidden ? open() : close(); });
q.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(load, 250); });
q.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); clearTimeout(timer); load(); } });
clearBtn.addEventListener('click', () => { q.value = ''; load(); q.focus(); });
allBox.addEventListener('change', () => {
const boxes = [...listEl.querySelectorAll('.sd-dd-cb')];
boxes.forEach((cb) => { cb.checked = allBox.checked; if (allBox.checked) selected.add(cb.value); else selected.delete(cb.value); });
syncHidden(); syncSummary(); syncAll();
});
document.addEventListener('click', (e) => { if (!dd.contains(e.target)) close(); });
syncSummary();
});
})();
</script>

View File

@ -201,6 +201,10 @@ Route::get('workspace/courier-payables/shipping-data/export', 'Business\Pages@ex
->middleware('business.page:courier-payables,shipping-data')
->name('business.courier-payables.shipping-data.export');
Route::get('workspace/courier-payables/shipping-data/suggest', 'Business\\Pages@suggestShippingData')
->middleware('business.page:courier-payables,shipping-data')
->name('business.courier-payables.shipping-data.suggest');
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');

View File

@ -14,18 +14,18 @@ class ShippingDataPageTest extends FeatureTestCase
$this->grantPermission();
$repository = Mockery::mock(ShippingOrderRepository::class);
$repository->shouldReceive('paginate')->once()->withArgs(function (array $filters, int $page, int $limit): bool {
$repository->shouldReceive('paginate')->once()->withArgs(function (array $filters, int $page, int $limit, string $mode): bool {
return $filters === [
'express_no' => 'EXP-001',
'sourceno' => 'TRADE-001',
'barcode' => 'BAR-001',
'supplier_name' => '新北供应商',
'shop_name' => '测试店铺',
'warehouse_name' => '一号仓',
'express_company' => '顺丰',
'supplier_name' => ['新北供应商'],
'shop_name' => ['测试店铺'],
'warehouse_name' => ['一号仓'],
'express_company' => ['顺丰'],
'ship_from' => '2026-09-01',
'ship_to' => '2026-09-16',
] && $page === 1 && $limit > 0;
] && $page === 1 && $limit > 0 && $mode === 'detail';
})->andReturn(['data' => [], 'total' => 0]);
$repository->shouldReceive('syncStatus')->once()->andReturn([
'code' => 'succeeded',
@ -45,31 +45,35 @@ class ShippingDataPageTest extends FeatureTestCase
'express_company' => '顺丰',
'ship_from' => '2026-09-01',
'ship_to' => '2026-09-16',
'mode' => 'detail',
]))
->assertOk()
->assertSee('name="barcode"', false)
->assertSee('name="supplier_name"', false)
->assertSee('name="shop_name"', false)
->assertSee('name="warehouse_name"', false)
->assertSee('name="express_company"', false)
->assertSee('name="supplier_name[]"', false)
->assertSee('name="shop_name[]"', false)
->assertSee('name="warehouse_name[]"', false)
->assertSee('name="express_company[]"', false)
->assertSee('type="date" name="ship_from"', false)
->assertSee('data-date-picker-input="shipping-from"', false)
->assertSeeText('导出');
->assertSeeText('导出')
->assertSeeText('导出明细');
}
public function test_export_uses_current_filters_and_preserves_identifiers_as_text(): void
public function test_export_uses_current_filters_and_keeps_identifiers_unprefixed(): void
{
$this->grantPermission();
$repository = Mockery::mock(ShippingOrderRepository::class);
$repository->shouldReceive('exportRows')->once()->withArgs(function (array $filters): bool {
$repository->shouldReceive('exportRows')->once()->withArgs(function (array $filters, int $batchSize, string $mode): bool {
return $filters['barcode'] === '6901234567890'
&& $filters['supplier_name'] === '新北供应商'
&& $filters['shop_name'] === '测试店铺'
&& $filters['warehouse_name'] === '一号仓'
&& $filters['express_company'] === '顺丰'
&& $filters['supplier_name'] === ['新北供应商']
&& $filters['shop_name'] === ['测试店铺']
&& $filters['warehouse_name'] === ['一号仓']
&& $filters['express_company'] === ['顺丰']
&& $filters['ship_from'] === '2026-09-01'
&& $filters['ship_to'] === '2026-09-16';
&& $filters['ship_to'] === '2026-09-16'
&& $batchSize === 1000
&& $mode === 'merged';
})->andReturn((static function (): \Generator {
yield [
'express_no' => '01234567890123456789',
@ -101,8 +105,10 @@ class ShippingDataPageTest extends FeatureTestCase
$content = $response->streamedContent();
$this->assertStringStartsWith(chr(0xEF) . chr(0xBB) . chr(0xBF), $content);
$this->assertStringContainsString('快递单号', $content);
$this->assertStringContainsString("'01234567890123456789", $content);
$this->assertStringContainsString("'6901234567890", $content);
$this->assertStringContainsString('01234567890123456789', $content);
$this->assertStringContainsString('6901234567890', $content);
$this->assertStringNotContainsString("'01234567890123456789", $content);
$this->assertStringNotContainsString("'6901234567890", $content);
$this->assertStringContainsString('新北供应商', $content);
}

View File

@ -0,0 +1,39 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\Jushuitan\ShippingReplicaSynchronizer;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Tests\TestCase;
class ShippingReplicaSynchronizerTest extends TestCase
{
public function test_sync_removes_only_the_known_demo_source_ids(): void
{
$db = DB::connection('business_mirror');
$db->table('jst_oms_shipping')->insert([
['source_id' => 9000001, 'sku_source_id' => 9000001],
['source_id' => 9000006, 'sku_source_id' => 9000006],
['source_id' => 9000007, 'sku_source_id' => 9000007],
['source_id' => 999999999, 'sku_source_id' => 999999999],
]);
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
$upstream->expects($this->once())->method('select')->willReturn([]);
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->expects($this->once())
->method('forSource')
->with('operations_replica')
->willReturn($upstream);
$result = (new ShippingReplicaSynchronizer($factory))->sync();
$this->assertSame(['processed' => 0, 'autoship' => 0], $result);
$this->assertSame(
[9000007, 999999999],
$db->table('jst_oms_shipping')->orderBy('source_id')->pluck('source_id')->map(fn ($id) => (int) $id)->all(),
);
}
}