diff --git a/.env.jushuitan.example b/.env.jushuitan.example new file mode 100644 index 00000000..3d483848 --- /dev/null +++ b/.env.jushuitan.example @@ -0,0 +1,30 @@ +# ============================================================ +# 聚水潭(聚石塔 / 奇门)开放平台接入 —— caiwu 直连发货数据 +# 复用工作平台现有授权。把下列键补进 runtime/.env(勿提交 Git)。 +# 值留空时同步器会安全拒绝请求,不会误拉。 +# ============================================================ + +# 奇门网关地址(对应工作平台配置 ErpJSTUrl) +JST_QIMEN_URL= + +# 授权凭证(复用现有一套) +JST_APP_KEY= +JST_APP_SECRET= +JST_CUSTOMER_ID= + +# 奇门固定参数(一般无需改) +JST_API_VERSION=2 +JST_FORMAT=json +JST_SIMPLIFY=true + +# 同步参数(可选,均有默认值) +JST_SYNC_PAGE_LIMIT=100 +JST_SYNC_OVERLAP_HOURS=48 +JST_SYNC_WINDOW_HOURS=24 +JST_SYNC_MAX_PAGES=0 +JST_REQUEST_TIMEOUT=30 +JST_RETRY_TIMES=5 +JST_RETRY_SLEEP_MS=1000 + +# 定时增量总开关:凭证填好、迁移跑完、联调校准后再设为 true +JST_SYNC_ENABLED=false diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index d7558795..7ec03ff1 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -55,6 +55,15 @@ 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) + ->runInBackground(); + } + } /** diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index ba531d40..36db9cfe 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -32,6 +32,7 @@ use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\View\BillOffsetPageData; use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository; use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository; +use Modules\EcommerceFinance\Repositories\ShippingOrderRepository; use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; use Modules\EcommerceFinance\Repositories\InventoryRepository; @@ -94,6 +95,7 @@ class Pages extends Controller private readonly CourierSurchargeQuoteRepository $courierSurchargeQuotes, private readonly CourierSurchargeTypeRepository $courierSurchargeTypes, private readonly JushuitanShopBalanceRepository $jushuitanShopBalances, + private readonly ShippingOrderRepository $shippingOrders, ) { $this->middleware('auth'); $this->middleware('company.identify'); @@ -632,6 +634,7 @@ class Pages extends Controller 'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(), 'online-daifa-price' => $this->contentForOnlineDaifaPrice(), 'courier-surcharge' => $this->contentForCourierSurcharge(), + 'shipping-data' => $this->contentForShippingData(), 'withdrawal-management' => $this->contentForWithdrawalManagement(), 'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(), default => null, @@ -750,6 +753,52 @@ class Pages extends Controller * 报价维护与后续附加费结算分开:本页只负责渠道、类型、结算方式、 * 计价标准和生效时间的维护展示,结算页将来根据这些规则读取业务镜像。 */ + protected function contentForShippingData(): array + { + $request = request(); + $date = static function (mixed $value): string { + $value = trim((string) $value); + return preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $value) === 1 ? $value : ''; + }; + $filters = [ + 'express_no' => mb_substr(trim((string) $request->query('express_no', '')), 0, 80), + 'sourceno' => mb_substr(trim((string) $request->query('sourceno', '')), 0, 80), + 'ship_from' => $date($request->query('ship_from', '')), + 'ship_to' => $date($request->query('ship_to', '')), + ]; + $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, [ + 'path' => $request->url(), + 'query' => $request->except('page'), + ]); + $sync = ['code' => 'unavailable', 'last_succeeded_at' => null]; + $error = null; + + try { + $result = $this->shippingOrders->paginate($filters, $page, $limit); + $rows = new LengthAwarePaginator( + Collection::make($result['data'] ?? []), + (int) ($result['total'] ?? 0), + $limit, + $page, + ['path' => $request->url(), 'query' => $request->except('page')], + ); + $sync = $this->shippingOrders->syncStatus(); + } catch (Throwable $exception) { + report($exception); + $error = '发货数据暂时无法读取(可能尚未运行迁移或同步)。'; + } + + return [ + 'view' => 'business.pages.courier-payables.shipping-data', + 'shippingRows' => $rows, + 'shippingFilters' => $filters, + 'shippingSync' => $sync, + 'shippingError' => $error, + ]; + } + protected function contentForCourierSurcharge(): array { $clean = static fn (mixed $value, int $length = 60): string => mb_substr(trim((string) $value), 0, $length); diff --git a/config/business_navigation.php b/config/business_navigation.php index 3f2e14c5..3c1d337d 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -498,6 +498,7 @@ $navigation = [ ['slug' => 'courier-information', 'title' => '快递资料'], ['slug' => 'agreement-signing', 'title' => '协议签署'], ['slug' => 'freight-reconciliation', 'title' => '运费对账'], + ['slug' => 'shipping-data', 'title' => '发货数据'], ['slug' => 'third-party-warehouse-storage', 'title' => '三方仓进销存'], ['slug' => 'bookkeeping-details', 'title' => '记账明细'], ['slug' => 'compensation-details', 'title' => '赔偿明细'], diff --git a/config/database.php b/config/database.php index 723b4175..8ba5dcc7 100644 --- a/config/database.php +++ b/config/database.php @@ -153,6 +153,24 @@ return [ 'options' => $mysqlSslOptions(), ], + // 聚水潭发货数据专用落库连接。默认指向 docker-compose.local.yml 的本地 + // MySQL 容器(网络别名 mysql / 库 akaunting),与远程共享镜像库隔离, + // 便于先本地验证真实数据。上生产时用 env 覆盖到目标库即可。 + 'jushuitan_local' => [ + 'driver' => 'mysql', + 'host' => env('JST_DB_HOST', 'mysql'), + 'port' => env('JST_DB_PORT', '3306'), + 'database' => env('JST_DB_DATABASE', 'akaunting'), + 'username' => env('JST_DB_USERNAME', 'akaunting'), + 'password' => env('JST_DB_PASSWORD', 'akaunting123'), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => 'InnoDB ROW_FORMAT=DYNAMIC', + ], + 'mariadb' => [ 'driver' => 'mysql', 'url' => env('DATABASE_URL'), diff --git a/config/jushuitan.php b/config/jushuitan.php new file mode 100644 index 00000000..afbd0882 --- /dev/null +++ b/config/jushuitan.php @@ -0,0 +1,46 @@ + env('JST_QIMEN_URL', ''), + + // 授权凭证(复用现有)。 + 'app_key' => env('JST_APP_KEY', ''), + 'app_secret' => env('JST_APP_SECRET', ''), + 'customer_id' => env('JST_CUSTOMER_ID', ''), + + // 奇门固定参数。 + 'format' => env('JST_FORMAT', 'json'), + 'version' => env('JST_API_VERSION', '2'), + 'simplify' => env('JST_SIMPLIFY', 'true'), + + // 落地存储连接:默认本地 MySQL 容器(jushuitan_local,见 config/database.php), + // 与远程共享镜像库隔离;上生产时用 env JST_DB_CONNECTION 覆盖。 + 'connection' => env('JST_DB_CONNECTION', 'jushuitan_local'), + + // 同步参数。 + 'sync' => [ + 'page_limit' => (int) env('JST_SYNC_PAGE_LIMIT', 100), // 每页条数(奇门 limit) + 'overlap_hours' => (int) env('JST_SYNC_OVERLAP_HOURS', 48), // 增量重叠窗口,补迟到更新 + 'window_hours' => (int) env('JST_SYNC_WINDOW_HOURS', 24), // 单次拉取时间窗口 + 'max_pages' => (int) env('JST_SYNC_MAX_PAGES', 0), // 单次最多翻页,0=不限 + 'request_timeout' => (int) env('JST_REQUEST_TIMEOUT', 30), // 秒 + 'retry_times' => (int) env('JST_RETRY_TIMES', 5), + 'retry_sleep_ms' => (int) env('JST_RETRY_SLEEP_MS', 1000), + ], + + // 接口方法名(对应工作平台 ErpTradeData 中的调用)。 + 'methods' => [ + 'sctrade_list' => 'erp.sctrade.query', // 出库单列表(按时间窗分页) + 'sctrade_detail' => 'erp.sradesku.query', // 单据完整明细(按 tradeId) + ], +]; diff --git a/database/seeds/JushuitanShippingDemoSeeder.php b/database/seeds/JushuitanShippingDemoSeeder.php new file mode 100644 index 00000000..d79c9166 --- /dev/null +++ b/database/seeds/JushuitanShippingDemoSeeder.php @@ -0,0 +1,68 @@ +copy()->subHours(++$i); + $vol = $L * $W * $H; + $sid = 9000000 + $i; + + $db->table('jst_oms_shipping')->updateOrInsert( + ['source_id' => $sid, 'sku_source_id' => $sid], + [ + 'express_no' => $expno, 'trade_no' => $tradeno, 'item_id' => $itemid, 'barcode' => $barcode, + 'product_name' => $pname, 'qty' => $qty, 'receiver_state' => $prov, 'buyer_nick' => '', + 'receiver_name' => $rname, 'shop_name' => $shop, 'express_name1' => $express, 'warehouse_name' => $wh, + 'ship_time' => $shipTime, 'freight' => $freight, 'online_weight' => $onlineW, 'actual_weight' => $actualW, + 'length_cm' => $L, 'width_cm' => $W, 'height_cm' => $H, 'volume_cm3' => $vol, + 'receiver_city' => $city, 'receiver_district' => $dist, 'supplier_name' => $supplier, + 'receiver_address' => $prov.$city.$dist.'示例路'.($i*7).'号', 'receiver_mobile' => $mobile, 'receiver_phone' => '', + 'express_name2' => $express, 'platform' => $platform, 'buyer_memo' => '', 'seller_memo' => '', + 'package_no' => 'PH'.$shipTime->format('Ymd').str_pad((string)$i,4,'0',STR_PAD_LEFT), + 'amount' => round($actualW * 9.9 + 39, 2), 'product_code' => $procode, + 'download_time' => $shipTime->copy()->addMinutes(30), 'pay_time' => $shipTime->copy()->subHours(3), + 'freight_rate' => 1.0, 'freight_formula' => '首重+续重', + 'weight_kg' => $actualW, 'settle_weight_kg' => max($actualW, round($vol/6000, 4)), + 'settle_volume_cm3' => $vol, 'recon_weight_kg' => $actualW, 'recon_volume_cm3' => $vol, + 'finished_barcode' => '', 'finished_name' => $pname, 'finished_supplier' => $supplier, + 'settle_freight' => $settleFreight, 'front_no' => '', + 'modified' => $shipTime, 'synced_at' => $now, + ] + ); + } + + $db->table('jst_sync_state')->updateOrInsert(['dataset_key' => 'jst.sctrade'], [ + 'watermark' => $now, 'status' => 'succeeded', 'last_succeeded_at' => $now, + 'updated_at' => $now, 'created_at' => $now, + ]); + + $this->command?->info('已写入 ' . count($samples) . ' 条发货明细演示数据(47 列,source_id 9000001 起)。'); + } +} diff --git a/modules/EcommerceFinance/Console/SyncJushuitanShipping.php b/modules/EcommerceFinance/Console/SyncJushuitanShipping.php new file mode 100644 index 00000000..b782fccd --- /dev/null +++ b/modules/EcommerceFinance/Console/SyncJushuitanShipping.php @@ -0,0 +1,37 @@ +option('from') ? Carbon::parse($this->option('from')) : null; + $to = $this->option('to') ? Carbon::parse($this->option('to')) : null; + + $this->info('开始同步聚水潭发货数据...'); + try { + $result = ShippingDataSynchronizer::make()->syncIncremental($from, $to); + } catch (\Throwable $e) { + $this->error('同步失败:' . $e->getMessage()); + return self::FAILURE; + } + + $this->info(sprintf('完成:处理 %d 单,水位推进到 %s', $result['processed'], $result['watermark'] ?? '(未变)')); + return self::SUCCESS; + } +} diff --git a/modules/EcommerceFinance/Database/Migrations/2026_09_15_100001_create_jst_sctrade_tables.php b/modules/EcommerceFinance/Database/Migrations/2026_09_15_100001_create_jst_sctrade_tables.php new file mode 100644 index 00000000..3f1774a4 --- /dev/null +++ b/modules/EcommerceFinance/Database/Migrations/2026_09_15_100001_create_jst_sctrade_tables.php @@ -0,0 +1,129 @@ +connection()); + + // —— 主单 —— + if (! $schema->hasTable('jst_sctrade')) $schema->create('jst_sctrade', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('source_id')->comment('聚水潭单据 id'); // 稳定键 + $table->string('sourceno', 64)->nullable()->comment('交易/线上单号'); + $table->string('io_id', 64)->nullable()->comment('出库单号'); + $table->string('shop_id', 32)->nullable()->comment('店铺 id'); + $table->string('shopcode', 64)->nullable()->comment('店铺编码'); + $table->string('wms_co_id', 32)->nullable()->comment('分仓/仓库 id'); + $table->string('platform', 64)->nullable()->comment('平台'); + $table->string('status', 32)->nullable()->comment('单据状态'); + $table->dateTime('paytime')->nullable(); + $table->dateTime('sended')->nullable()->comment('发货时间'); + $table->dateTime('created')->nullable(); + $table->dateTime('modified')->nullable()->comment('修改时间(增量水位)'); + // 收货人(主单上冗余的部分,明细以 receiver 表为准) + $table->string('receiverstate', 64)->nullable(); + $table->string('receivercity', 64)->nullable(); + $table->string('receiverdistrict', 64)->nullable(); + $table->string('receivername', 128)->nullable(); + $table->string('receivermobile', 64)->nullable(); + $table->string('receiverphone', 64)->nullable(); + $table->longText('receiveraddress')->nullable(); + $table->decimal('weight', 12, 4)->nullable(); + $table->decimal('freight', 12, 2)->nullable(); + $table->longText('raw_json')->nullable()->comment('完整报文保真'); + $table->dateTime('synced_at', 6)->nullable(); + + $table->unique('source_id'); + $table->index('sourceno'); + $table->index('modified'); + $table->index('sended'); + }); + + // —— 平台商品明细 —— + if (! $schema->hasTable('jst_sctrade_items')) $schema->create('jst_sctrade_items', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('source_id')->comment('明细 id'); + $table->unsignedBigInteger('hid')->comment('主单 source_id'); + $table->string('oid', 64)->nullable()->comment('平台子订单号'); + $table->string('shopproid', 64)->nullable(); + $table->string('shoprocode', 128)->nullable(); + $table->string('shopskucode', 128)->nullable(); + $table->string('shoproname', 512)->nullable(); + $table->integer('qty')->nullable(); + $table->longText('raw_json')->nullable(); + $table->dateTime('synced_at', 6)->nullable(); + + $table->unique('source_id'); + $table->index('hid'); + }); + + // —— SKU 明细 —— + if (! $schema->hasTable('jst_sctrade_skus')) $schema->create('jst_sctrade_skus', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('source_id')->comment('SKU 明细 id'); + $table->unsignedBigInteger('hid')->comment('主单 source_id'); + $table->string('procode', 128)->nullable()->comment('商品编码/货号'); + $table->string('sku_id', 128)->nullable(); + $table->string('proname', 512)->nullable(); + $table->string('scmname', 512)->nullable()->comment('成品名'); + $table->string('postcode', 64)->nullable()->comment('快递单号'); + $table->string('logistics_company', 128)->nullable()->comment('快递公司'); + $table->integer('qty')->nullable(); + $table->dateTime('sended')->nullable(); + $table->longText('raw_json')->nullable(); + $table->dateTime('synced_at', 6)->nullable(); + + $table->unique('source_id'); + $table->index('hid'); + $table->index('postcode'); + }); + + // —— 收货人明细 —— + if (! $schema->hasTable('jst_sctrade_receiver')) $schema->create('jst_sctrade_receiver', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('hid')->comment('主单 source_id'); + $table->string('receivername', 128)->nullable(); + $table->string('receiverstate', 64)->nullable(); + $table->string('receivercity', 64)->nullable(); + $table->string('receiverdistrict', 64)->nullable(); + $table->longText('receiveraddress')->nullable(); + $table->string('receivermobile', 64)->nullable(); + $table->string('receiverphone', 64)->nullable(); + $table->string('receiverzip', 32)->nullable(); + $table->longText('raw_json')->nullable(); + $table->dateTime('synced_at', 6)->nullable(); + + $table->unique('hid'); + }); + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + $schema->dropIfExists('jst_sctrade_receiver'); + $schema->dropIfExists('jst_sctrade_skus'); + $schema->dropIfExists('jst_sctrade_items'); + $schema->dropIfExists('jst_sctrade'); + } +}; diff --git a/modules/EcommerceFinance/Database/Migrations/2026_09_15_100002_create_jst_sync_state_table.php b/modules/EcommerceFinance/Database/Migrations/2026_09_15_100002_create_jst_sync_state_table.php new file mode 100644 index 00000000..547cc346 --- /dev/null +++ b/modules/EcommerceFinance/Database/Migrations/2026_09_15_100002_create_jst_sync_state_table.php @@ -0,0 +1,36 @@ +connection())->hasTable('jst_sync_state')) return; + Schema::connection($this->connection())->create('jst_sync_state', function (Blueprint $table) { + $table->string('dataset_key', 128)->primary(); // 如 jst.sctrade + $table->dateTime('watermark')->nullable(); // 已同步到的 modified 时间 + $table->string('status', 20)->default('pending'); // pending/running/succeeded/failed + $table->dateTime('last_started_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('jst_sync_state'); + } +}; diff --git a/modules/EcommerceFinance/Database/Migrations/2026_09_15_110001_create_jst_oms_shipping_table.php b/modules/EcommerceFinance/Database/Migrations/2026_09_15_110001_create_jst_oms_shipping_table.php new file mode 100644 index 00000000..c4bd82c0 --- /dev/null +++ b/modules/EcommerceFinance/Database/Migrations/2026_09_15_110001_create_jst_oms_shipping_table.php @@ -0,0 +1,89 @@ +conn()); + if ($schema->hasTable('jst_oms_shipping')) return; + + $schema->create('jst_oms_shipping', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('source_id')->nullable()->comment('聚水潭单据 id'); + $table->unsignedBigInteger('sku_source_id')->nullable()->comment('聚水潭 SKU 明细 id'); + + // —— 按界面顺序的 47 列 —— + $table->string('express_no', 64)->nullable()->comment('1 快递单号'); + $table->string('trade_no', 64)->nullable()->comment('2 交易单号'); + $table->string('item_id', 64)->nullable()->comment('3 商品ID'); + $table->string('barcode', 128)->nullable()->comment('4 条形码'); + $table->string('product_name', 512)->nullable()->comment('5 商品名称'); + $table->integer('qty')->nullable()->comment('6 数量'); + $table->string('receiver_state', 64)->nullable()->comment('7 省'); + $table->string('buyer_nick', 128)->nullable()->comment('8 买家昵称'); + $table->string('receiver_name', 128)->nullable()->comment('9 收货姓名'); + $table->string('shop_name', 255)->nullable()->comment('10 店铺名称'); + $table->string('express_name1', 128)->nullable()->comment('11 快递名称1'); + $table->string('warehouse_name', 128)->nullable()->comment('12 仓库'); + $table->dateTime('ship_time')->nullable()->comment('13 发货时间'); + $table->decimal('freight', 18, 2)->nullable()->comment('14 运费'); + $table->decimal('online_weight', 18, 4)->nullable()->comment('15 线上重量'); + $table->decimal('actual_weight', 18, 4)->nullable()->comment('16 实称重量'); + $table->decimal('length_cm', 18, 2)->nullable()->comment('17 长'); + $table->decimal('width_cm', 18, 2)->nullable()->comment('18 宽'); + $table->decimal('height_cm', 18, 2)->nullable()->comment('19 高'); + $table->decimal('volume_cm3', 18, 4)->nullable()->comment('20 体积'); + $table->string('receiver_city', 64)->nullable()->comment('21 市'); + $table->string('receiver_district', 64)->nullable()->comment('22 区'); + $table->string('supplier_name', 128)->nullable()->comment('23 供应商'); + $table->longText('receiver_address')->nullable()->comment('24 收货详细地址'); + $table->string('receiver_mobile', 64)->nullable()->comment('25 手机号码'); + $table->string('receiver_phone', 64)->nullable()->comment('26 座机号码'); + $table->string('express_name2', 128)->nullable()->comment('27 快递名称2'); + $table->string('platform', 64)->nullable()->comment('28 平台'); + $table->longText('buyer_memo')->nullable()->comment('29 买家留言'); + $table->longText('seller_memo')->nullable()->comment('30 卖家备注'); + $table->string('package_no', 64)->nullable()->comment('31 配货单号'); + $table->decimal('amount', 18, 2)->nullable()->comment('32 金额'); + $table->string('product_code', 128)->nullable()->comment('33 货号'); + $table->dateTime('download_time')->nullable()->comment('34 下载时间'); + $table->dateTime('pay_time')->nullable()->comment('35 付款时间'); + $table->decimal('freight_rate', 18, 4)->nullable()->comment('36 费率'); + $table->string('freight_formula', 255)->nullable()->comment('37 公式'); + $table->decimal('weight_kg', 18, 4)->nullable()->comment('38 重量'); + $table->decimal('settle_weight_kg', 18, 4)->nullable()->comment('39 结算重量'); + $table->decimal('settle_volume_cm3', 18, 4)->nullable()->comment('40 结算体积'); + $table->decimal('recon_weight_kg', 18, 4)->nullable()->comment('41 对账重量'); + $table->decimal('recon_volume_cm3', 18, 4)->nullable()->comment('42 对账体积'); + $table->string('finished_barcode', 128)->nullable()->comment('43 成品条码'); + $table->string('finished_name', 512)->nullable()->comment('44 成品名称'); + $table->string('finished_supplier', 128)->nullable()->comment('45 成品供应商'); + $table->decimal('settle_freight', 18, 2)->nullable()->comment('46 结算运费'); + $table->string('front_no', 64)->nullable()->comment('47 换单前单号'); + + $table->dateTime('modified')->nullable()->comment('增量水位'); + $table->dateTime('synced_at', 6)->nullable(); + + $table->index('express_no'); + $table->index('trade_no'); + $table->index('ship_time'); + $table->unique(['source_id', 'sku_source_id'], 'jst_oms_uniq'); + }); + } + + public function down(): void + { + Schema::connection($this->conn())->dropIfExists('jst_oms_shipping'); + } +}; diff --git a/modules/EcommerceFinance/Providers/Main.php b/modules/EcommerceFinance/Providers/Main.php index d3f51ced..c2564bd6 100644 --- a/modules/EcommerceFinance/Providers/Main.php +++ b/modules/EcommerceFinance/Providers/Main.php @@ -3,6 +3,7 @@ namespace Modules\EcommerceFinance\Providers; use Illuminate\Support\ServiceProvider as Provider; +use Modules\EcommerceFinance\Console\SyncJushuitanShipping; use Modules\EcommerceFinance\Services\JianSunSnapshotRefresher; use Modules\EcommerceFinance\Services\OperationsReplicaSnapshotRefresher; use Modules\EcommerceFinance\Services\UpstreamSnapshotRefresherRegistry; @@ -14,6 +15,12 @@ class Main extends Provider { $this->loadTranslationsFrom(__DIR__ . '/../Resources/lang', 'ecommerce-finance'); $this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations'); + + if ($this->app->runningInConsole()) { + $this->commands([ + SyncJushuitanShipping::class, + ]); + } } public function register(): void diff --git a/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php b/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php new file mode 100644 index 00000000..d2cb5af6 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php @@ -0,0 +1,73 @@ +db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local')); + } + + /** + * @param array $filters express_no / sourceno / ship_from / ship_to + * @return array{data:array,total:int} + */ + public function paginate(array $filters, int $page, int $perPage): array + { + $page = max($page, 1); + $perPage = min(max($perPage, 1), 100); + + $q = $this->db->table('jst_oms_shipping'); + if (($filters['express_no'] ?? '') !== '') { + $q->where('express_no', '=', trim((string) $filters['express_no'])); + } + if (($filters['sourceno'] ?? '') !== '') { + $q->where('trade_no', '=', trim((string) $filters['sourceno'])); + } + if (($filters['ship_from'] ?? '') !== '') { + $q->where('ship_time', '>=', $filters['ship_from'] . ' 00:00:00'); + } + if (($filters['ship_to'] ?? '') !== '') { + $q->where('ship_time', '<', date('Y-m-d 00:00:00', strtotime($filters['ship_to'] . ' +1 day'))); + } + + $total = (clone $q)->count(); + $rows = (clone $q) + ->orderByDesc('ship_time') + ->orderByDesc('id') + ->forPage($page, $perPage) + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + + return ['data' => $rows, 'total' => $total]; + } + + /** 同步状态(供页面显示同步中/最近完成)。 */ + public function syncStatus(): array + { + try { + $row = $this->db->table('jst_sync_state')->where('dataset_key', 'jst.sctrade')->first(); + if (! $row) { + return ['code' => 'unavailable', 'last_succeeded_at' => null]; + } + + return [ + 'code' => (string) ($row->status ?? 'unavailable'), + 'last_succeeded_at' => $row->last_succeeded_at ?? null, + ]; + } catch (\Throwable) { + return ['code' => 'unavailable', 'last_succeeded_at' => null]; + } + } +} diff --git a/modules/EcommerceFinance/Services/Jushuitan/QimenClient.php b/modules/EcommerceFinance/Services/Jushuitan/QimenClient.php new file mode 100644 index 00000000..40a26167 --- /dev/null +++ b/modules/EcommerceFinance/Services/Jushuitan/QimenClient.php @@ -0,0 +1,130 @@ +assertConfigured(); + + $bodyJson = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($bodyJson === false) { + throw new RuntimeException('聚水潭请求体序列化失败。'); + } + + $baseParams = $this->baseParams($method); + $baseParams['sign'] = $this->sign($baseParams, $bodyJson); + + $sync = $this->config['sync'] ?? []; + $timeout = (int) ($sync['request_timeout'] ?? 30); + $retries = max((int) ($sync['retry_times'] ?? 5), 1); + $retrySleep = (int) ($sync['retry_sleep_ms'] ?? 1000); + + $response = Http::withOptions(['query' => $baseParams]) + ->withHeaders(['Content-Type' => 'application/json']) + ->timeout($timeout) + ->retry($retries, $retrySleep) + ->withBody($bodyJson, 'application/json') + ->post($this->gatewayUrl()); + + if ($response->failed()) { + Log::warning('聚水潭奇门接口 HTTP 失败。', ['method' => $method, 'status' => $response->status()]); + throw new RuntimeException("聚水潭接口 {$method} 请求失败:HTTP {$response->status()}"); + } + + $data = $response->json(); + if (! is_array($data)) { + throw new RuntimeException("聚水潭接口 {$method} 返回非 JSON。"); + } + + // 奇门通常以 code=0 / issuccess 标识成功,具体字段以实际返回为准,此处仅做基础校验。 + return $data; + } + + /** @return array */ + private function baseParams(string $method): array + { + return [ + 'app_key' => (string) ($this->config['app_key'] ?? ''), + 'customerId' => (string) ($this->config['customer_id'] ?? ''), + 'format' => (string) ($this->config['format'] ?? 'json'), + 'method' => $method, + 'simplify' => (string) ($this->config['simplify'] ?? 'true'), + 'timestamp' => now()->format('Y-m-d H:i:s'), + 'v' => (string) ($this->config['version'] ?? '2'), + ]; + } + + /** + * 奇门 MD5 签名。等价于工作平台 ErpApiHelper.SignTopRequest: + * raw = secret + Σ(key+value,按固定顺序,跳过 sign) + body + secret + */ + private function sign(array $baseParams, string $bodyJson): string + { + $secret = (string) ($this->config['app_secret'] ?? ''); + $raw = $secret; + foreach (self::SIGN_ORDER as $key) { + $value = $baseParams[$key] ?? null; + if ($value !== null && $value !== '') { + $raw .= $key . $value; + } + } + $raw .= $bodyJson . $secret; + + return strtoupper(md5($raw)); + } + + private function gatewayUrl(): string + { + $url = trim((string) ($this->config['gateway_url'] ?? '')); + if ($url === '') { + throw new RuntimeException('未配置聚水潭奇门网关地址(JST_QIMEN_URL)。'); + } + + return $url; + } + + private function assertConfigured(): void + { + foreach (['app_key' => 'JST_APP_KEY', 'app_secret' => 'JST_APP_SECRET', 'customer_id' => 'JST_CUSTOMER_ID'] as $key => $env) { + if (trim((string) ($this->config[$key] ?? '')) === '') { + throw new RuntimeException("未配置聚水潭凭证 {$env},已拒绝请求(不会同步任何数据)。"); + } + } + } +} diff --git a/modules/EcommerceFinance/Services/Jushuitan/ShippingDataSynchronizer.php b/modules/EcommerceFinance/Services/Jushuitan/ShippingDataSynchronizer.php new file mode 100644 index 00000000..4b18ec49 --- /dev/null +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingDataSynchronizer.php @@ -0,0 +1,278 @@ + 得到每单 id / modified + * 2) erp.sradesku.query 按 tradeId 拉单据完整明细 -> 主单 + items + skus + receiver + * 增量以 modified 为水位,带重叠窗口;按 source_id upsert 去重。 + * + * 注意:奇门返回的 JSON 字段路径以聚水潭实际返回为准,本骨架的取值点已用 + * pluck() 集中,联调时按真实返回调整即可,不影响主流程结构。 + */ +class ShippingDataSynchronizer +{ + private const DATASET = 'jst.sctrade'; + + private readonly ConnectionInterface $db; + + public function __construct( + private readonly QimenClient $client, + private readonly array $config, + ) { + $this->db = DB::connection($config['connection'] ?? 'business_mirror'); + } + + public static function make(): self + { + return new self(QimenClient::fromConfig(), (array) config('jushuitan')); + } + + /** + * 执行一次增量同步。 + * + * @param ?Carbon $from 起始修改时间;默认取水位 - overlap + * @param ?Carbon $to 截止修改时间;默认 now + */ + public function syncIncremental(?Carbon $from = null, ?Carbon $to = null): array + { + $sync = $this->config['sync'] ?? []; + $overlap = max((int) ($sync['overlap_hours'] ?? 48), 0); + $limit = max((int) ($sync['page_limit'] ?? 100), 1); + $maxPages = (int) ($sync['max_pages'] ?? 0); + + $state = $this->state(); + $from ??= isset($state['watermark']) && $state['watermark'] + ? Carbon::parse($state['watermark'])->subHours($overlap) + : now()->subHours(max((int) ($sync['window_hours'] ?? 24), 1)); + $to ??= now(); + + $this->markRunning(); + $processed = 0; + $maxModified = $from; + + try { + $page = 1; + do { + $list = $this->client->call($this->method('sctrade_list'), [ + 'updateSt' => $from->format('Y-m-d H:i:s'), + 'updateEt' => $to->format('Y-m-d H:i:s'), + 'limit' => $limit, + 'page' => $page, + ]); + + $orders = $this->pluckOrders($list); + foreach ($orders as $order) { + $id = $this->pluckId($order); + if ($id === null) { + continue; + } + $detail = $this->client->call($this->method('sctrade_detail'), ['tradeId' => $id]); + $data = $detail['data'] ?? $detail; + $this->upsertOrder($data); + $processed++; + + $modified = $this->pluckModified($data); + if ($modified && (! $maxModified || $modified->gt($maxModified))) { + $maxModified = $modified; + } + } + + $hasNext = count($orders) >= $limit && ($maxPages === 0 || $page < $maxPages); + $page++; + } while ($hasNext); + + $this->markSucceeded($maxModified); + } catch (Throwable $e) { + $this->markFailed($e->getMessage()); + report($e); + throw $e; + } + + return ['processed' => $processed, 'watermark' => optional($maxModified)->toDateTimeString()]; + } + + /* ---------------- upsert(按 source_id 去重) ---------------- */ + + private function upsertOrder(array $data): void + { + $sourceId = $this->pluckId($data); + if ($sourceId === null) { + return; + } + $now = now(); + + $this->db->table('jst_sctrade')->updateOrInsert( + ['source_id' => $sourceId], + [ + 'sourceno' => $data['so_id'] ?? ($data['sourceno'] ?? null), + 'io_id' => $data['io_id'] ?? null, + 'shop_id' => $data['shop_id'] ?? null, + 'shopcode' => $data['shopcode'] ?? null, + 'wms_co_id' => $data['wms_co_id'] ?? null, + 'platform' => $data['platform'] ?? null, + 'status' => $data['status'] ?? null, + 'paytime' => $this->ts($data['pay_date'] ?? ($data['paytime'] ?? null)), + 'sended' => $this->ts($data['sended'] ?? null), + 'created' => $this->ts($data['created'] ?? null), + 'modified' => $this->ts($data['modified'] ?? null), + 'receiverstate' => $data['receiver_state'] ?? ($data['receiverstate'] ?? null), + 'receivercity' => $data['receiver_city'] ?? ($data['receivercity'] ?? null), + 'receiverdistrict' => $data['receiver_district'] ?? ($data['receiverdistrict'] ?? null), + 'receivername' => $data['receiver_name'] ?? ($data['receivername'] ?? null), + 'receivermobile' => $data['receiver_mobile'] ?? ($data['receivermobile'] ?? null), + 'receiverphone' => $data['receiver_phone'] ?? ($data['receiverphone'] ?? null), + 'receiveraddress' => $data['receiver_address'] ?? ($data['receiveraddress'] ?? null), + 'weight' => $data['weight'] ?? null, + 'freight' => $data['freight'] ?? null, + 'raw_json' => json_encode($data, JSON_UNESCAPED_UNICODE), + 'synced_at' => $now, + ] + ); + + foreach (($data['items'] ?? []) as $item) { + $iid = $item['id'] ?? null; + if ($iid === null) { + continue; + } + $this->db->table('jst_sctrade_items')->updateOrInsert( + ['source_id' => $iid], + [ + 'hid' => $sourceId, + 'oid' => $item['oid'] ?? null, + 'shopproid' => $item['shop_i_id'] ?? ($item['shopproid'] ?? null), + 'shoprocode' => $item['sku_id'] ?? ($item['shoprocode'] ?? null), + 'shopskucode' => $item['shopskucode'] ?? null, + 'shoproname' => $item['name'] ?? ($item['shoproname'] ?? null), + 'qty' => $item['qty'] ?? null, + 'raw_json' => json_encode($item, JSON_UNESCAPED_UNICODE), + 'synced_at' => $now, + ] + ); + } + + foreach (($data['tradeskus'] ?? ($data['skus'] ?? [])) as $sku) { + $sid = $sku['id'] ?? null; + if ($sid === null) { + continue; + } + $this->db->table('jst_sctrade_skus')->updateOrInsert( + ['source_id' => $sid], + [ + 'hid' => $sourceId, + 'procode' => $sku['sku_id'] ?? ($sku['procode'] ?? null), + 'sku_id' => $sku['sku_id'] ?? null, + 'proname' => $sku['name'] ?? ($sku['proname'] ?? null), + 'scmname' => $sku['scmname'] ?? null, + 'postcode' => $sku['postcode'] ?? ($sku['l_id'] ?? null), + 'logistics_company' => $sku['logistics_company'] ?? null, + 'qty' => $sku['qty'] ?? null, + 'sended' => $this->ts($sku['sended'] ?? null), + 'raw_json' => json_encode($sku, JSON_UNESCAPED_UNICODE), + 'synced_at' => $now, + ] + ); + } + + $receiver = $data['tradeReceiver'] ?? ($data['receiver'] ?? null); + if (is_array($receiver)) { + $this->db->table('jst_sctrade_receiver')->updateOrInsert( + ['hid' => $sourceId], + [ + 'receivername' => $receiver['receivername'] ?? ($receiver['name'] ?? null), + 'receiverstate' => $receiver['receiverstate'] ?? null, + 'receivercity' => $receiver['receivercity'] ?? null, + 'receiverdistrict' => $receiver['receiverdistrict'] ?? null, + 'receiveraddress' => $receiver['receiveraddress'] ?? null, + 'receivermobile' => $receiver['receivermobile'] ?? null, + 'receiverphone' => $receiver['receiverphone'] ?? null, + 'receiverzip' => $receiver['receiverzip'] ?? null, + 'raw_json' => json_encode($receiver, JSON_UNESCAPED_UNICODE), + 'synced_at' => $now, + ] + ); + } + } + + /* ---------------- 返回体取值点(按聚水潭实际返回调整) ---------------- */ + + /** @return array */ + private function pluckOrders(array $list): array + { + $data = $list['data'] ?? $list; + return $data['datas'] ?? ($data['orders'] ?? ($data['list'] ?? [])); + } + + private function pluckId(array $order): ?int + { + $id = $order['id'] ?? ($order['io_id'] ?? null); + return $id === null ? null : (int) $id; + } + + private function pluckModified(array $data): ?Carbon + { + return $this->ts($data['modified'] ?? null); + } + + private function ts(mixed $value): ?Carbon + { + if ($value === null || $value === '' ) { + return null; + } + try { + if (is_numeric($value)) { + $v = (int) $value; + return Carbon::createFromTimestamp($v > 9999999999 ? intdiv($v, 1000) : $v); + } + return Carbon::parse((string) $value); + } catch (Throwable) { + return null; + } + } + + /* ---------------- 同步状态 ---------------- */ + + private function state(): array + { + $row = $this->db->table('jst_sync_state')->where('dataset_key', self::DATASET)->first(); + return $row ? (array) $row : []; + } + + 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(?Carbon $watermark): void + { + $update = ['status' => 'succeeded', 'last_succeeded_at' => now(), 'last_error' => null, 'updated_at' => now()]; + if ($watermark) { + $update['watermark'] = $watermark; + } + $this->db->table('jst_sync_state')->where('dataset_key', self::DATASET)->update($update); + } + + private function markFailed(string $error): void + { + $this->db->table('jst_sync_state')->where('dataset_key', self::DATASET)->update([ + 'status' => 'failed', 'last_error' => mb_substr($error, 0, 1000), 'updated_at' => now(), + ]); + } + + private function method(string $key): string + { + return $this->config['methods'][$key] ?? ''; + } +} diff --git a/resources/views/business/pages/courier-payables/shipping-data.blade.php b/resources/views/business/pages/courier-payables/shipping-data.blade.php new file mode 100644 index 00000000..e7ad2b9d --- /dev/null +++ b/resources/views/business/pages/courier-payables/shipping-data.blade.php @@ -0,0 +1,134 @@ +@php + $filters = $shippingFilters ?? []; + $sync = $shippingSync ?? ['code' => 'unavailable', 'last_succeeded_at' => null]; + $statusLabels = ['succeeded' => '数据新鲜', 'running' => '同步中', 'pending' => '等待同步', 'failed' => '同步失败', 'unavailable' => '尚未同步']; + + // 按工作平台发货数据界面顺序的 47 列: [列key, 表头, 对齐, 类型] + $columns = [ + ['express_no','快递单号','left','mono'], + ['trade_no','交易单号','left','mono'], + ['item_id','商品ID','left','mono'], + ['barcode','条形码','left','text'], + ['product_name','商品名称','left','text'], + ['qty','数量','right','int'], + ['receiver_state','省','left','text'], + ['buyer_nick','买家昵称','left','text'], + ['receiver_name','收货姓名','left','text'], + ['shop_name','店铺名称','left','text'], + ['express_name1','快递名称1','left','text'], + ['warehouse_name','仓库','left','text'], + ['ship_time','发货时间','left','text'], + ['freight','运费','right','money'], + ['online_weight','线上重量','right','num'], + ['actual_weight','实称重量','right','num'], + ['length_cm','长(cm)','right','num'], + ['width_cm','宽(cm)','right','num'], + ['height_cm','高(cm)','right','num'], + ['volume_cm3','体积(cm³)','right','num'], + ['receiver_city','市','left','text'], + ['receiver_district','区','left','text'], + ['supplier_name','供应商','left','text'], + ['receiver_address','收货详细地址','left','text'], + ['receiver_mobile','手机号码','left','text'], + ['receiver_phone','座机号码','left','text'], + ['express_name2','快递名称2','left','text'], + ['platform','平台','left','text'], + ['buyer_memo','买家留言','left','text'], + ['seller_memo','卖家备注','left','text'], + ['package_no','配货单号','left','mono'], + ['amount','金额','right','money'], + ['product_code','货号','left','text'], + ['download_time','下载时间','left','text'], + ['pay_time','付款时间','left','text'], + ['freight_rate','费率','right','num'], + ['freight_formula','公式','left','text'], + ['weight_kg','重量(kg)','right','num'], + ['settle_weight_kg','结算重量(kg)','right','num'], + ['settle_volume_cm3','结算体积(cm³)','right','num'], + ['recon_weight_kg','对账重量(kg)','right','num'], + ['recon_volume_cm3','对账体积(cm³)','right','num'], + ['finished_barcode','成品条码','left','text'], + ['finished_name','成品名称','left','text'], + ['finished_supplier','成品供应商','left','text'], + ['settle_freight','结算运费','right','money'], + ['front_no','换单前单号','left','mono'], + ]; + + $fmt = function ($v, $type) { + if ($v === null || $v === '') return ''; + return match ($type) { + 'money' => number_format((float) $v, 2), + 'num' => rtrim(rtrim(number_format((float) $v, 4, '.', ''), '0'), '.'), + 'int' => (string) (int) $v, + default => (string) $v, + }; + }; +@endphp + +
+ + +
+
+

{{ $page['title'] ?? '发货数据' }}

+

数据来源:聚水潭(聚石塔/奇门)· 销售出库单 · caiwu 直连拉取 · 用于快递运费核算

+
+
+ {{ $statusLabels[$sync['code'] ?? 'unavailable'] ?? '状态未知' }} + @if (!empty($sync['last_succeeded_at'])) 最近同步 {{ $sync['last_succeeded_at'] }} @endif +
+
+ + @if (!empty($shippingError)) +
{{ $shippingError }}
+ @endif + +
+
+ + + + +
重置
+
+
+ +
+
+ + + @foreach ($columns as $c) + + @endforeach + + + @forelse ($shippingRows ?? [] as $row) + + @foreach ($columns as $c) + @php $val = $fmt($row[$c[0]] ?? null, $c[3]); @endphp + + @endforeach + + @empty + + @endforelse + +
{{ $c[1] }}
{{ $val === '' ? '-' : $val }}
暂无发货数据(填好聚水潭凭证并运行同步后显示;或先运行演示数据 Seeder 查看界面)
+
+ @if (($shippingRows ?? null) instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator && $shippingRows->hasPages()) +
共 {{ $shippingRows->total() }} 条记录
{{ $shippingRows->onEachSide(1)->links() }}
+ @endif +
+