feat: add Jushuitan shipping data mirror
This commit is contained in:
parent
fe2c56a9c2
commit
2017d6520b
27
app/Console/Commands/DedupAt.php
Normal file
27
app/Console/Commands/DedupAt.php
Normal 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;
|
||||
}
|
||||
}
|
||||
39
app/Console/Commands/JstCount.php
Normal file
39
app/Console/Commands/JstCount.php
Normal 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;
|
||||
}
|
||||
}
|
||||
45
app/Console/Commands/JstDump.php
Normal file
45
app/Console/Commands/JstDump.php
Normal 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;
|
||||
}
|
||||
}
|
||||
20
app/Console/Commands/JstRow.php
Normal file
20
app/Console/Commands/JstRow.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
102
docs/发货数据_工作平台还原_逻辑对照.md
Normal file
102
docs/发货数据_工作平台还原_逻辑对照.md
Normal 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 发货数据还原的对照基准。_
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user