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:
limeng 2026-09-15 06:32:45 +00:00
parent 769d9a6ad5
commit b038909cb0
16 changed files with 1134 additions and 0 deletions

30
.env.jushuitan.example Normal file
View 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

View File

@ -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();
}
}
/**

View File

@ -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);

View File

@ -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' => '赔偿明细'],

View File

@ -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
View 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)
],
];

View 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 起)。');
}
}

View 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;
}
}

View File

@ -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');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 聚水潭同步状态表:记录每个数据集的增量水位与最近同步状态,
* 供页面显示“同步中/最近完成”,与共享镜像状态语义一致。
*/
return new class extends Migration
{
private function 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');
}
};

View File

@ -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');
}
};

View File

@ -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

View File

@ -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];
}
}
}

View 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},已拒绝请求(不会同步任何数据)。");
}
}
}
}

View File

@ -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] ?? '';
}
}

View File

@ -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>