diff --git a/app/Console/Commands/DedupAt.php b/app/Console/Commands/DedupAt.php new file mode 100644 index 00000000..6d2d6a11 --- /dev/null +++ b/app/Console/Commands/DedupAt.php @@ -0,0 +1,27 @@ +dedupAtPrefix(); + $this->info('去除@重复单号:' . $removed . ' 条'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/JstCount.php b/app/Console/Commands/JstCount.php new file mode 100644 index 00000000..e36b4007 --- /dev/null +++ b/app/Console/Commands/JstCount.php @@ -0,0 +1,39 @@ + $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; + } +} diff --git a/app/Console/Commands/JstDump.php b/app/Console/Commands/JstDump.php new file mode 100644 index 00000000..72b36b91 --- /dev/null +++ b/app/Console/Commands/JstDump.php @@ -0,0 +1,45 @@ +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; + } +} diff --git a/app/Console/Commands/JstRow.php b/app/Console/Commands/JstRow.php new file mode 100644 index 00000000..7466e5e6 --- /dev/null +++ b/app/Console/Commands/JstRow.php @@ -0,0 +1,20 @@ +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; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 805820ce..d05d9baf 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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(); } diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 0acc8414..6c93ab1f 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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 */ + 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 */ 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'], diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index 6e77564f..1c24071e 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -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 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index f8a60b05..9cf0655c 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -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 diff --git a/docs/发货数据_工作平台还原_逻辑对照.md b/docs/发货数据_工作平台还原_逻辑对照.md new file mode 100644 index 00000000..52aedc18 --- /dev/null +++ b/docs/发货数据_工作平台还原_逻辑对照.md @@ -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 发货数据还原的对照基准。_ diff --git a/modules/EcommerceFinance/Console/SyncJushuitanShipping.php b/modules/EcommerceFinance/Console/SyncJushuitanShipping.php index b782fccd..021124d4 100644 --- a/modules/EcommerceFinance/Console/SyncJushuitanShipping.php +++ b/modules/EcommerceFinance/Console/SyncJushuitanShipping.php @@ -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; } } diff --git a/modules/EcommerceFinance/Database/Migrations/2026_09_21_000001_add_pkg_count_to_jst_oms_shipping.php b/modules/EcommerceFinance/Database/Migrations/2026_09_21_000001_add_pkg_count_to_jst_oms_shipping.php new file mode 100644 index 00000000..78e71427 --- /dev/null +++ b/modules/EcommerceFinance/Database/Migrations/2026_09_21_000001_add_pkg_count_to_jst_oms_shipping.php @@ -0,0 +1,36 @@ +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'); + }); + } + } +}; diff --git a/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php b/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php index e2605f67..e6bfc714 100644 --- a/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php +++ b/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php @@ -23,50 +23,114 @@ class ShippingOrderRepository * @param array $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 $filters * @return \Generator> */ - 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 + */ + 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 + */ + 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 { diff --git a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php new file mode 100644 index 00000000..a1d9b42c --- /dev/null +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php @@ -0,0 +1,943 @@ + */ + private array $buffer = []; + + /** @var 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,1: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 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 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(), + ]); + } +} diff --git a/resources/views/business/pages/courier-payables/shipping-data.blade.php b/resources/views/business/pages/courier-payables/shipping-data.blade.php index 75344ff6..3f1f6f6e 100644 --- a/resources/views/business/pages/courier-payables/shipping-data.blade.php +++ b/resources/views/business/pages/courier-payables/shipping-data.blade.php @@ -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; }
@@ -103,19 +129,56 @@ @endif
-
+@php $mode = $shippingMode ?? 'merged'; $modeQuery = array_merge(request()->query(), []); @endphp + + + - - - - + @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]) +
{{ $msLabel }} +
+ + + + @foreach ($msVal($msKey) as $chip) + + @endforeach + +
+
+ @endforeach
- 导出 + @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 + 导出 + 导出明细 重置
@@ -151,11 +214,11 @@ diff --git a/routes/admin.php b/routes/admin.php index 60474ac4..f255aa7d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); diff --git a/tests/Feature/Business/ShippingDataPageTest.php b/tests/Feature/Business/ShippingDataPageTest.php index 943caade..11bf8619 100644 --- a/tests/Feature/Business/ShippingDataPageTest.php +++ b/tests/Feature/Business/ShippingDataPageTest.php @@ -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); } diff --git a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php new file mode 100644 index 00000000..b55584e2 --- /dev/null +++ b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php @@ -0,0 +1,39 @@ +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(), + ); + } +}