CaiWuXiTong/modules/EcommerceFinance/Services/Jushuitan/ShippingDataSynchronizer.php
limeng b038909cb0 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
2026-09-16 10:57:12 +08:00

279 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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