feat: 发货数据页接入(caiwu 直连聚水潭/奇门)
- 聚水潭奇门客户端与发货同步器骨架:QimenClient(签名+POST)、ShippingDataSynchronizer(erp.sctrade.query 列表 + erp.sradesku.query 明细 + upsert + modified 水位) - 发货明细扁平展示表 jst_oms_shipping(对齐工作平台发货数据 47 列/顺序,用于快递运费核算);原始表 jst_sctrade/_items/_skus/_receiver + jst_sync_state - 快递应付新增「发货数据」页:Pages.contentForShippingData + 视图 + business_navigation 导航项(slug=shipping-data) - ShippingOrderRepository 读展示表;artisan jushuitan:sync-shipping + 每10分钟增量调度(JST_SYNC_ENABLED 默认关) - 落库连接 jushuitan_local(本地容器,env 可覆盖);演示 Seeder(6 条家具发货样例);.env.jushuitan.example Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf
This commit is contained in:
parent
769d9a6ad5
commit
b038909cb0
30
.env.jushuitan.example
Normal file
30
.env.jushuitan.example
Normal file
@ -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
|
||||
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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' => '赔偿明细'],
|
||||
|
||||
@ -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'),
|
||||
|
||||
46
config/jushuitan.php
Normal file
46
config/jushuitan.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 聚水潭(聚石塔/奇门)开放平台接入配置。
|
||||
*
|
||||
* caiwu 自建奇门客户端直连聚水潭拉取原始销售出库单,脱离 jiansun。
|
||||
* 复用工作平台现有授权(同一套 app_key / app_secret / customerId / v)。
|
||||
*
|
||||
* 凭证只放 runtime/.env 或系统数据库私有配置,权限 600,不入 Git / PR / 日志。
|
||||
* 未填 app_key / app_secret 前,客户端拒绝发起请求,不会同步任何数据。
|
||||
*/
|
||||
return [
|
||||
// 奇门(聚石塔)网关地址,对应工作平台配置 ErpJSTUrl。
|
||||
'gateway_url' => 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)
|
||||
],
|
||||
];
|
||||
68
database/seeds/JushuitanShippingDemoSeeder.php
Normal file
68
database/seeds/JushuitanShippingDemoSeeder.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeds;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 发货明细演示数据(仅本地看界面用)。写入 jst_oms_shipping(47 列)。
|
||||
* php artisan db:seed --class="Database\Seeds\JushuitanShippingDemoSeeder"
|
||||
* 联调真实接口后清空即可(source_id 以 9000000 起)。
|
||||
*/
|
||||
class JushuitanShippingDemoSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
|
||||
$now = now();
|
||||
|
||||
$samples = [
|
||||
// 快递单号, 交易单号, 商品ID, 条码, 商品名称, 数量, 省, 市, 区, 收货人, 手机, 店铺, 快递, 仓库, 供应商, 平台, 货号, 运费, 线上重, 实称重, 长,宽,高, 结算运费
|
||||
['JDV028245430835','3545486005885966','10035038189982','GNSF000504','折叠床 单人 1.2m 奶白色',2,'北京','北京','朝阳区','张三','138****0001','京喜自营工厂店','京喜京东快运','浙江华韵代发仓','华韵','京东商城','ZDC-001',12.00,18.5,19.0,80,78,68,15.20],
|
||||
['YT7788990011223','3310398519776020','10062860613','GNSF000208','藤椅躺椅 加大款 棕色',1,'浙江省','杭州市','余杭区','李四','139****0002','优选家居旗舰店','圆通速递','南昌樵舍仓','简尚集采','天猫','TYY-001',6.50,8.0,8.2,60,55,40,7.80],
|
||||
['SF1234567890123','3310398951698084','10061046499','GNSF000112','康米特椅 户外折叠 军绿',1,'广东省','深圳市','南山区','王五','137****0003','京选家具专营','顺丰速运','常州总仓','固图家居','京东','TYK-002',9.20,3.2,3.4,55,42,12,9.20],
|
||||
['ZTO5566778899001','3310851253260175','10061042791','GNSF000377','儿童学习椅 可升降 粉',1,'四川省','成都市','武侯区','赵六','135****0004','儿童学习专营店','中通快递','南昌樵舍仓','绒灿家居','拼多多','EXX-006',6.40,6.4,6.6,52,40,45,6.40],
|
||||
['YD9900112233445','3311032908970014','10061082943','GNSF000501','折叠床 双人 1.5m 胡桃',1,'山东省','青岛市','市南区','孙七','136****0005','家具工厂直营店','韵达快递','常州总仓','华韵','淘宝','ZDC-002',15.80,15.8,16.1,90,70,20,16.50],
|
||||
['JD0088123456789','3545486009911223','10035038777','GNSF000733','鳄鱼椅 网布透气 黑',2,'江苏省','常州市','武进区','周八','133****0006','亿家木旗舰店','京东快递','浙江华韵代发仓','简尚集采','京东商城','EYY-009',10.00,9.0,9.3,58,46,48,10.00],
|
||||
];
|
||||
|
||||
$i = 0;
|
||||
foreach ($samples as $r) {
|
||||
[$expno,$tradeno,$itemid,$barcode,$pname,$qty,$prov,$city,$dist,$rname,$mobile,$shop,$express,$wh,$supplier,$platform,$procode,$freight,$onlineW,$actualW,$L,$W,$H,$settleFreight] = $r;
|
||||
$shipTime = $now->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 起)。');
|
||||
}
|
||||
}
|
||||
37
modules/EcommerceFinance/Console/SyncJushuitanShipping.php
Normal file
37
modules/EcommerceFinance/Console/SyncJushuitanShipping.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Modules\EcommerceFinance\Services\Jushuitan\ShippingDataSynchronizer;
|
||||
|
||||
/**
|
||||
* 聚水潭发货数据同步命令。
|
||||
*
|
||||
* php artisan jushuitan:sync-shipping # 增量(按水位 - 重叠窗口)
|
||||
* php artisan jushuitan:sync-shipping --from=... --to=... # 指定时间窗(首次全量分批用)
|
||||
*/
|
||||
class SyncJushuitanShipping extends Command
|
||||
{
|
||||
protected $signature = 'jushuitan:sync-shipping {--from=} {--to=}';
|
||||
|
||||
protected $description = '从聚水潭(奇门)拉取销售出库单发货数据到 caiwu 共享镜像库';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$from = $this->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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 聚水潭销售出库单原始表(caiwu 直连奇门拉取,脱离 jiansun)。
|
||||
*
|
||||
* 对应聚水潭 erp.sradesku.query 返回的四个层级:
|
||||
* 主单 erp_sctrade / 平台商品明细 items / SKU 明细 skus / 收货人 receiver。
|
||||
* 表落在共享业务镜像库(caiwu_business_mirror)。
|
||||
*
|
||||
* 说明:这里只显式建“发货明细加工需要 + 常用筛选”的字段,完整报文存 raw_json
|
||||
* 保真;后续要用到更多字段时按需补列即可(不必回拉上游)。
|
||||
* 稳定键 source_id(聚水潭单据 id);增量列 modified。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): ?string
|
||||
{
|
||||
return config('jushuitan.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 聚水潭同步状态表:记录每个数据集的增量水位与最近同步状态,
|
||||
* 供页面显示“同步中/最近完成”,与共享镜像状态语义一致。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): ?string
|
||||
{
|
||||
return config('jushuitan.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::connection($this->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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 发货数据扁平展示表(对齐工作平台 fys_omsDataDay 的 47 列,按界面顺序)。
|
||||
* 页面直接读本表;真实同步时由 erp_sctrade 原始表加工写入。
|
||||
* 落 jushuitan_local(本地容器),字段全部可空。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private function conn(): ?string { return config('jushuitan.connection', 'jushuitan_local'); }
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->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');
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 发货明细只读仓库。读取扁平表 jst_oms_shipping(对齐工作平台发货数据 47 列),
|
||||
* 供“发货数据”页面展示与后续快递运费核算使用。
|
||||
*/
|
||||
class ShippingOrderRepository
|
||||
{
|
||||
private readonly ConnectionInterface $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $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];
|
||||
}
|
||||
}
|
||||
}
|
||||
130
modules/EcommerceFinance/Services/Jushuitan/QimenClient.php
Normal file
130
modules/EcommerceFinance/Services/Jushuitan/QimenClient.php
Normal file
@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services\Jushuitan;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 聚水潭(聚石塔 / 阿里巴巴奇门)开放平台客户端。
|
||||
*
|
||||
* 复刻工作平台 JSCrm 的鉴权与调用方式:
|
||||
* - 基础参数:app_key, customerId, format, method, simplify, timestamp, v, sign
|
||||
* - 签名(奇门 MD5):MD5( secret + 依次拼接每个基础参数的 键名+键值(跳过 sign) + body + secret ),转大写十六进制
|
||||
* - 发送:基础参数放 URL query,业务参数 body 作为 JSON 请求体
|
||||
*
|
||||
* 只读取数用途,带超时与重试。凭证来自 config('jushuitan'),缺失即拒绝请求。
|
||||
*/
|
||||
class QimenClient
|
||||
{
|
||||
/** 参与签名的基础参数顺序,必须与工作平台 BaseRequestModel 声明顺序一致。 */
|
||||
private const SIGN_ORDER = ['app_key', 'customerId', 'format', 'method', 'simplify', 'timestamp', 'v'];
|
||||
|
||||
public function __construct(private readonly array $config)
|
||||
{
|
||||
}
|
||||
|
||||
public static function fromConfig(): self
|
||||
{
|
||||
return new self((array) config('jushuitan'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用一个奇门接口,返回解析后的数组。
|
||||
*
|
||||
* @param string $method 接口方法名,如 erp.sctrade.query
|
||||
* @param array $body 业务参数
|
||||
* @return array 聚水潭返回的 JSON 解析结果
|
||||
*/
|
||||
public function call(string $method, array $body): array
|
||||
{
|
||||
$this->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<string,string> */
|
||||
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},已拒绝请求(不会同步任何数据)。");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services\Jushuitan;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 聚水潭发货(销售出库单)同步器。
|
||||
*
|
||||
* 复刻工作平台 ErpTradeData 的两步拉取:
|
||||
* 1) erp.sctrade.query 按修改时间窗口分页拉出库单列表 -> 得到每单 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<int,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] ?? '';
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
<div class="shipping-data-page">
|
||||
<style>
|
||||
.shipping-data-page { color: #29251f; }
|
||||
.shipping-data-page .sd-control { border: 1px solid #d9d0c2; border-radius: .375rem; padding: .5rem .7rem; font-size: .8125rem; background: #fff; outline: none; }
|
||||
.shipping-data-page .sd-control:focus { border-color: #b18a4a; box-shadow: 0 0 0 2px rgba(177,138,74,.15); }
|
||||
.shipping-data-page .sd-button { border-radius: .375rem; padding: .5rem 1rem; font-size: .8125rem; }
|
||||
.shipping-data-page .sd-button--primary { background: #5d492c; color: #fff; }
|
||||
.shipping-data-page .sd-button--secondary { border: 1px solid #d9d0c2; color: #655b4e; background: #fff; }
|
||||
.shipping-data-page .sd-table th { background: #f5f1ea; color: #6f675c; font-weight: 600; position: sticky; top: 0; }
|
||||
.shipping-data-page .sd-table td, .shipping-data-page .sd-table th { border-bottom: 1px solid #eee8de; border-right: 1px solid #f1ece3; padding: .5rem .6rem; white-space: nowrap; font-size: .78rem; }
|
||||
.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%; }
|
||||
</style>
|
||||
|
||||
<div class="flex flex-col gap-4 mb-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-800">{{ $page['title'] ?? '发货数据' }}</h1>
|
||||
<p class="mt-1 text-xs text-gray-500">数据来源:聚水潭(聚石塔/奇门)· 销售出库单 · caiwu 直连拉取 · 用于快递运费核算</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="rounded-full border border-[#d9d0c2] bg-white px-3 py-1.5 text-[#6f675c]">{{ $statusLabels[$sync['code'] ?? 'unavailable'] ?? '状态未知' }}</span>
|
||||
@if (!empty($sync['last_succeeded_at'])) <span class="text-gray-500">最近同步 {{ $sync['last_succeeded_at'] }}</span> @endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!empty($shippingError))
|
||||
<div class="mb-4 rounded border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $shippingError }}</div>
|
||||
@endif
|
||||
|
||||
<div class="mb-5 rounded-lg border border-[#e7e0d5] bg-white p-4 shadow-sm">
|
||||
<form method="GET" class="flex flex-wrap items-end gap-3" aria-label="发货数据筛选">
|
||||
<label class="flex min-w-[160px] flex-col gap-1 text-xs text-gray-500"><span>快递单号</span><input class="sd-control" type="text" name="express_no" value="{{ $filters['express_no'] ?? '' }}" placeholder="精确匹配"></label>
|
||||
<label class="flex min-w-[160px] flex-col gap-1 text-xs text-gray-500"><span>交易单号</span><input class="sd-control" type="text" name="sourceno" value="{{ $filters['sourceno'] ?? '' }}" placeholder="精确匹配"></label>
|
||||
<label class="flex min-w-[145px] flex-col gap-1 text-xs text-gray-500"><span>发货时间起</span><input class="sd-control" type="date" name="ship_from" value="{{ $filters['ship_from'] ?? '' }}"></label>
|
||||
<label class="flex min-w-[145px] flex-col gap-1 text-xs text-gray-500"><span>发货时间止</span><input class="sd-control" type="date" name="ship_to" value="{{ $filters['ship_to'] ?? '' }}"></label>
|
||||
<div class="flex gap-2"><button class="sd-button sd-button--primary" type="submit">查询</button><a class="sd-button sd-button--secondary" href="{{ url()->current() }}">重置</a></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-[#e7e0d5] bg-white shadow-sm">
|
||||
<div class="sd-scroll">
|
||||
<table class="sd-table min-w-full">
|
||||
<thead><tr>
|
||||
@foreach ($columns as $c)
|
||||
<th class="text-{{ $c[2] }}">{{ $c[1] }}</th>
|
||||
@endforeach
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@forelse ($shippingRows ?? [] as $row)
|
||||
<tr>
|
||||
@foreach ($columns as $c)
|
||||
@php $val = $fmt($row[$c[0]] ?? null, $c[3]); @endphp
|
||||
<td class="text-{{ $c[2] }} {{ $c[3] === 'mono' ? 'sd-mono' : '' }} {{ in_array($c[3], ['num','int','money']) ? 'tabular-nums' : '' }}">{{ $val === '' ? '-' : $val }}</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="{{ count($columns) }}" class="px-4 py-16 text-center text-sm text-gray-500">暂无发货数据(填好聚水潭凭证并运行同步后显示;或先运行演示数据 Seeder 查看界面)</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (($shippingRows ?? null) instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator && $shippingRows->hasPages())
|
||||
<div class="flex items-center justify-between border-t border-[#eee8de] px-4 py-3 text-sm text-gray-500"><span>共 {{ $shippingRows->total() }} 条记录</span><div>{{ $shippingRows->onEachSide(1)->links() }}</div></div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
Loading…
x
Reference in New Issue
Block a user