接入聚水潭采购单到线上代发价格 #244

Merged
limeng merged 1 commits from feature/limeng-jst-purchase-online-daifa into main 2026-09-22 18:50:26 +08:00
15 changed files with 650 additions and 78 deletions

View File

@ -16,7 +16,7 @@ class SyncProductPurchasePriceMirror extends Command
{--batch= : 每批最多读取行数}
{--max-batches= : 本轮最多批次数,0 表示直到完成}';
protected $description = '同步采购单管理的普通商品单价,并保留可断点的共享业务镜像';
protected $description = '同步采购单管理明细和普通商品单价,并保留可断点的共享业务镜像';
public function handle(
ProductPurchasePriceMirrorRepository $mirror,

View File

@ -1513,6 +1513,7 @@ class Pages extends Controller
'onlineDaifaStatus' => $result['status'] ?? 'unknown',
'onlineDaifaSyncedAt' => $result['last_synced_at'] ?? null,
'onlineDaifaSummary' => $result['summary'] ?? [],
'onlineDaifaSources' => $result['source_statuses'] ?? [],
'onlineDaifaError' => null,
];
} catch (Throwable $exception) {
@ -1527,6 +1528,7 @@ class Pages extends Controller
'onlineDaifaStatus' => 'unavailable',
'onlineDaifaSyncedAt' => null,
'onlineDaifaSummary' => [],
'onlineDaifaSources' => [],
'onlineDaifaError' => '线上代发价格镜像尚未完成同步,请稍后重试。',
];
}
@ -1557,9 +1559,11 @@ class Pages extends Controller
$originator = trim((string) ($workflow['originatorUserName'] ?? ''))
?: trim((string) ($workflow['originatorUserId'] ?? ''));
$matchedProduct = (array) ($row['matched_product'] ?? []);
$productName = ($matchedProduct['matched'] ?? false)
? (string) ($matchedProduct['name'] ?? '')
: '(未匹配)';
$productName = ($row['source_type'] ?? 'ding') === 'purchase'
? ''
: (($matchedProduct['matched'] ?? false)
? (string) ($matchedProduct['name'] ?? '')
: '(未匹配)');
fputcsv($csv, [
$this->csvIdentifier($approvalNumber),

View File

@ -27,6 +27,7 @@ return new class extends Migration
$table->string('source_object', 191);
$table->string('status', 24)->default('pending');
$table->string('run_mode', 24)->nullable();
$table->unsignedTinyInteger('projection_version')->default(2);
$table->uuid('generation')->nullable();
$table->dateTime('window_started_at', 6)->nullable();
$table->dateTime('window_ends_at', 6)->nullable();
@ -47,11 +48,15 @@ return new class extends Migration
$schema->create('product_purchase_price_mirror_rows', function (Blueprint $table): void {
$table->unsignedBigInteger('item_id')->primary();
$table->unsignedBigInteger('purchase_order_id')->index();
$table->unsignedBigInteger('source_order_no')->nullable()->index();
$table->string('supplier_name', 255)->nullable();
$table->string('sku_id', 255)->nullable()->index();
$table->string('style_id', 255)->nullable()->index();
$table->decimal('quantity', 20, 4)->nullable();
$table->decimal('unit_price', 20, 4)->nullable();
$table->string('document_status', 50)->nullable()->index();
$table->text('labels')->nullable();
$table->dateTime('document_created_at', 6)->nullable()->index();
$table->dateTime('finished_at', 6)->nullable()->index();
$table->dateTime('purchase_updated_at', 6)->index();
$table->uuid('_mirror_generation')->nullable()->index();
@ -63,6 +68,10 @@ return new class extends Migration
['document_status', 'sku_id', 'finished_at'],
'product_purchase_price_latest_lookup',
);
$table->index(
['document_status', 'document_created_at', 'item_id'],
'product_purchase_online_daifa_lookup',
);
});
}
}

View File

@ -0,0 +1,94 @@
<?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 (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->connection());
if ($schema->hasTable('product_purchase_price_sync_states')
&& ! $schema->hasColumn('product_purchase_price_sync_states', 'projection_version')) {
$schema->table('product_purchase_price_sync_states', function (Blueprint $table): void {
// Existing rows use projection 1 and must complete a fresh full pass
// before purchase-management rows are exposed on the page.
$table->unsignedTinyInteger('projection_version')->default(1)->after('run_mode');
});
}
if (! $schema->hasTable('product_purchase_price_mirror_rows')) {
return;
}
$missing = array_filter([
'source_order_no' => ! $schema->hasColumn('product_purchase_price_mirror_rows', 'source_order_no'),
'supplier_name' => ! $schema->hasColumn('product_purchase_price_mirror_rows', 'supplier_name'),
'quantity' => ! $schema->hasColumn('product_purchase_price_mirror_rows', 'quantity'),
'document_created_at' => ! $schema->hasColumn('product_purchase_price_mirror_rows', 'document_created_at'),
]);
if ($missing === []) {
return;
}
$schema->table('product_purchase_price_mirror_rows', function (Blueprint $table) use ($missing): void {
if (isset($missing['source_order_no'])) {
$table->unsignedBigInteger('source_order_no')->nullable()->after('purchase_order_id');
}
if (isset($missing['supplier_name'])) {
$table->string('supplier_name', 255)->nullable()->after('source_order_no');
}
if (isset($missing['quantity'])) {
$table->decimal('quantity', 20, 4)->nullable()->after('style_id');
}
if (isset($missing['document_created_at'])) {
$table->dateTime('document_created_at', 6)->nullable()->after('labels');
}
});
$schema->table('product_purchase_price_mirror_rows', function (Blueprint $table): void {
$table->index('source_order_no', 'product_purchase_source_order_index');
$table->index(
['document_status', 'document_created_at', 'item_id'],
'product_purchase_online_daifa_lookup',
);
});
}
public function down(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->connection());
if ($schema->hasTable('product_purchase_price_mirror_rows')) {
$schema->table('product_purchase_price_mirror_rows', function (Blueprint $table): void {
$table->dropIndex('product_purchase_source_order_index');
$table->dropIndex('product_purchase_online_daifa_lookup');
$table->dropColumn(['source_order_no', 'supplier_name', 'quantity', 'document_created_at']);
});
}
if ($schema->hasTable('product_purchase_price_sync_states')) {
$schema->table('product_purchase_price_sync_states', function (Blueprint $table): void {
$table->dropColumn('projection_version');
});
}
}
};

View File

@ -14,7 +14,7 @@
| 已完成 | `operations_replica/rpt.jst_sku` 已进入 `ops_jst_sku`,普通商品资料页面固定读取 `sku_type = normal` 的共享镜像,并支持稳定分页和业务筛选。 |
| 本次接入 | 聚水潭进销存共享镜像读取 `jiansun/dbo.jst_inoutWater + dbo.jst_inoutWaterItem + dbo.jst_inventoryInfo` 的全部仓库,保留 2026-08-01 起的已确认流水与库存锚点。“快递应付 / 三方仓进销存”在查询层继续固定 `wms_co_id = 15045364`;“商品价格管理 / 进销存报表”按所有仓库、商品和月份汇总。 |
| 已接入校准 | 聚水潭“普通商品资料”完整 XLSX 导出通过 `product-catalog:import-authoritative-export` 流式导入;导出中不存在的经营分析副本历史行写入 `product_catalog_exclusions`,页面与导出统一排除,但不删除 `ops_jst_sku` 原始镜像行。 |
| 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价,并与原商品采购价分别标注来源。 |
| 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价。线上代发价格页面另读取 `Confirmed` / `Finished` 采购明细,以 `source_o_id` 关联 `ops_jst_orders.o_id`,展示线上单号、店铺、供应商、商品编码、数量、单价并标记“采购单管理”;页面请求不直连上游。 |
| 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 |
| 本次接入 | 聚水潭聚工单协同工单登记 `work_order_main`、`work_order_sub`、`work_order_form_config`、`work_order_form_value` 四张表;工单 API 固定筛选类型“仓内未发”,首次同步完成前返回同步未完成状态。 |
| 待首次同步 | 聚水潭聚工单“小额打款”读取 `jiansun.dbo.jst_workOrderInfo` 与已解析映射 `dbo.jst_small_payment_order_map`;财务经营报表的小额打款页面固定筛选工单类型 `82D94F5C54544EE3833CD8A3AB8B9F73`,页面只读共享镜像,首次同步完成前明确显示待同步状态。 |
@ -94,7 +94,7 @@
4. 普通商品在最近完整校准超过 168 小时后,于周日 02:00-08:00 低峰窗口启动全量校准;一旦启动,后续每分钟从稳定游标继续,直至完整结束。首次全量由 Codex 备份后自主执行,定时任务保持既有初始化开关。
5. 除专用数据集外,通用任务每轮默认只处理 1 个数据集、每个数据集 2 批,每批 500 行,避免同时压迫两个旧系统。
6. 页面切换必须以“总记录数、第一页、第二页、末页、筛选、汇总、空数据和更新时间”作为验收,不能只看首页有数据。
7. 普通商品的采购单价同步以采购主表 `updateTime` 加明细 `Id` 作为稳定游标;页面按商品编码选择最近完成的 1688 一键代发价格。同步保存全部状态,使完成单后续取消时也能撤回旧价格;首次全量前只显示普通商品自身采购价。
7. 普通商品的采购单同步以采购主表 `updateTime` 加明细 `Id` 作为稳定游标;镜像保存来源单号、供应商、数量、单价和全部状态。普通商品页面按商品编码选择最近完成的 1688 一键代发价格;线上代发价格页面只展示 `Confirmed` / `Finished`,并在共享镜像内关联订单。完成单后续取消时可撤回旧价格;新增投影字段完成新一轮全量前不展示采购单管理数据。
8. 聚水潭官方接口尚未接入时,普通商品历史残留排除由负责人提供的最新完整 XLSX 导出刷新。只排除“导出生成前已创建、但导出中缺失”的镜像行,导出生成后的新增商品保持可见。导入使用新同步代次,只有完整解析成功才原子替换排除表;中断或错误保留上一版。后续接入聚水潭官方“普通商品资料查询”及删除/恢复记录后,由官方接口取代人工导出校准。
9. 店铺维度报表是已批准的中央专用数据集:生产节点按 `Id` 断点推进并回看最近 50000 行;原始账单同步后增量维护按店铺/日期汇总的 `store_report_daily_rows`,页面不得现场扫描千万级原始账单。开发机不运行刷新 worker。
10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 淘系支付宝账户账单当前仍读取此 MariaDB 镜像,统一调度须持续增量,不因 ClickHouse 尚未启用而暂停。账单冲减则复用“订单数据 → 原始数据 → 订单售后费用明细”,不按该表费用名称推断 500112。

View File

@ -4,16 +4,17 @@ namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\DingPayableWorkflowStatus;
use RuntimeException;
/**
* 线上代发业务报销流程的共享镜像读取与按商品ID关联。
* 线上代发价格的统一镜像查询。
*
* 数据由 DingPayableWorkflowMirrorSynchronizer(ding_online_daifa_price 配置)
* 从钉钉 OA 审批同步到 ding_online_daifa_price_mirror 宽表。本仓库只在镜像库
* 读取,并依据 商品ID(product_id)关联共享业务镜像的商品目录,页面不直连上游。
* 钉钉线上代发审批和聚水潭采购单先分别同步到共享业务镜像,再在这里合并。
* 页面请求只读共享镜像,不跨来源实时查询上游。
*/
class DingOnlineDaifaPriceMirrorRepository
{
@ -21,8 +22,13 @@ class DingOnlineDaifaPriceMirrorRepository
public const TARGET_TABLE = 'ding_online_daifa_price_mirror';
private const ORDER_DATASET_KEY = 'operations_replica.rpt.jst_Orders';
private readonly ConnectionInterface $connection;
/** @var array<string,array<string,mixed>>|null */
private ?array $sourceStatusCache = null;
public function __construct(GenericBusinessMirrorRepository $mirror)
{
$this->connection = $mirror->connection();
@ -30,18 +36,12 @@ class DingOnlineDaifaPriceMirrorRepository
public function ready(): bool
{
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable(self::TARGET_TABLE) || ! $schema->hasTable('business_mirror_table_states')) {
return false;
}
$state = $this->connection->table('business_mirror_table_states')
->where('dataset_key', self::DATASET_KEY)->first();
return $state !== null && $state->initial_completed_at !== null;
return collect($this->sourceStatuses())->contains(
static fn (array $source): bool => (bool) ($source['ready'] ?? false),
);
}
/** @return array{data:list<array<string,mixed>>,total:int,status:string,last_synced_at:?string,summary:array<string,mixed>} */
/** @return array{data:list<array<string,mixed>>,total:int,status:string,last_synced_at:?string,summary:array<string,mixed>,source_statuses:array<string,array<string,mixed>>} */
public function paginate(array $filters, int $page, int $perPage): array
{
if (! $this->ready()) {
@ -50,38 +50,37 @@ class DingOnlineDaifaPriceMirrorRepository
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$state = $this->connection->table('business_mirror_table_states')->where('dataset_key', self::DATASET_KEY)->first();
// The synchronized dataset normally contains one configured status, so
// count, totals, and paging can stay in SQL instead of loading all rows.
$onlyStatus = strtoupper(trim((string) config('upstream_mirrors.ding_online_daifa_price.only_status', '')));
$statusFilter = strtoupper(trim((string) ($filters['status'] ?? '')));
$statusMismatch = $statusFilter !== '' && $onlyStatus !== '' && $statusFilter !== $onlyStatus;
$total = $statusMismatch ? 0 : (int) $this->filteredQuery($filters)->count();
$total = (int) $this->filteredQuery($filters)->count();
$summary = [
'count' => 0,
'matched_count' => 0,
'matchable_count' => 0,
'purchase_count' => 0,
'daifa_amount' => 0.0,
'paid_amount' => 0.0,
'price_diff' => 0.0,
];
$pageRows = [];
if (! $statusMismatch) {
if ($total > 0) {
$aggregate = $this->filteredQuery($filters)
->selectRaw(
'COUNT(*) AS row_count, '
. "SUM(CASE WHEN product_id IS NOT NULL AND product_id <> '' AND product_name IS NOT NULL AND product_name <> '' THEN 1 ELSE 0 END) AS matched_count, "
. 'COALESCE(SUM(daifa_amount), 0) AS daifa_amount, '
. 'COALESCE(SUM(paid_amount), 0) AS paid_amount, '
. 'COALESCE(SUM(price_diff), 0) AS price_diff'
. "SUM(CASE WHEN source_type = 'purchase' THEN 1 ELSE 0 END) AS purchase_count, "
. "SUM(CASE WHEN source_type = 'ding' THEN 1 ELSE 0 END) AS matchable_count, "
. "SUM(CASE WHEN source_type = 'ding' AND product_id IS NOT NULL AND product_id <> '' "
. "AND product_name IS NOT NULL AND product_name <> '' THEN 1 ELSE 0 END) AS matched_count, "
. "COALESCE(SUM(CASE WHEN source_type = 'ding' THEN daifa_amount ELSE 0 END), 0) AS daifa_amount, "
. "COALESCE(SUM(CASE WHEN source_type = 'ding' THEN paid_amount ELSE 0 END), 0) AS paid_amount, "
. "COALESCE(SUM(CASE WHEN source_type = 'ding' THEN price_diff ELSE 0 END), 0) AS price_diff"
)
->first();
$summary = [
'count' => (int) ($aggregate->row_count ?? 0),
'matched_count' => (int) ($aggregate->matched_count ?? 0),
'matchable_count' => (int) ($aggregate->matchable_count ?? 0),
'purchase_count' => (int) ($aggregate->purchase_count ?? 0),
'daifa_amount' => round((float) ($aggregate->daifa_amount ?? 0), 2),
'paid_amount' => round((float) ($aggregate->paid_amount ?? 0), 2),
'price_diff' => round((float) ($aggregate->price_diff ?? 0), 2),
@ -97,16 +96,22 @@ class DingOnlineDaifaPriceMirrorRepository
$data = [];
foreach ($pageRows as $row) {
$workflow = $this->decode((string) $row->workflow_payload, []);
$data[] = $this->presentRow($row, $workflow);
$data[] = $this->presentRow($row);
}
$sources = $this->sourceStatuses();
$readySources = array_filter($sources, static fn (array $source): bool => (bool) ($source['ready'] ?? false));
$synced = array_values(array_filter(array_column($readySources, 'last_synced_at')));
return [
'data' => $data,
'total' => $total,
'status' => (string) ($state->status ?? 'unknown'),
'last_synced_at' => $state->last_succeeded_at ? (string) $state->last_succeeded_at : null,
'status' => count($readySources) > 1
? 'available'
: (string) (reset($readySources)['status'] ?? 'unknown'),
'last_synced_at' => $synced === [] ? null : max($synced),
'summary' => $summary,
'source_statuses' => $sources,
];
}
@ -117,7 +122,6 @@ class DingOnlineDaifaPriceMirrorRepository
throw new RuntimeException('business_mirror_dataset_not_ready');
}
$statusFilter = strtoupper(trim((string) ($filters['status'] ?? '')));
$rows = $this->filteredQuery($filters)
->orderByDesc('workflow_created_at')
->orderByDesc('process_instance_id')
@ -125,18 +129,13 @@ class DingOnlineDaifaPriceMirrorRepository
->cursor();
foreach ($rows as $row) {
$workflow = $this->decode((string) $row->workflow_payload, []);
if (! $this->matchesStatus($workflow, $statusFilter)) {
continue;
}
yield $this->presentRow($row, $workflow);
yield $this->presentRow($row);
}
}
private function filteredQuery(array $filters): Builder
{
$query = $this->connection->table(self::TARGET_TABLE);
$query = $this->combinedQuery();
if (($filters['product_id'] ?? '') !== '') {
$query->where('product_id', (string) $filters['product_id']);
@ -155,58 +154,236 @@ class DingOnlineDaifaPriceMirrorRepository
$query->where(function ($q) use ($keyword): void {
$q->where('process_instance_id', 'like', '%' . $keyword . '%')
->orWhere('daifa_trade_no', 'like', '%' . $keyword . '%')
->orWhere('shop_trade_no', 'like', '%' . $keyword . '%');
->orWhere('shop_trade_no', 'like', '%' . $keyword . '%')
->orWhere('source_order_no', 'like', '%' . $keyword . '%');
});
}
if (($filters['status'] ?? '') !== '') {
$query->where('status_key', strtoupper((string) $filters['status']));
}
if (($filters['created_from'] ?? '') !== '') {
$query->where('workflow_created_at', '>=', (string) $filters['created_from']);
}
if (($filters['created_to'] ?? '') !== '') {
$query->where('workflow_created_at', '<', \Illuminate\Support\Carbon::parse((string) $filters['created_to'])->addDay()->startOfDay()->format('Y-m-d H:i:s'));
$query->where(
'workflow_created_at',
'<',
Carbon::parse((string) $filters['created_to'])->addDay()->startOfDay()->format('Y-m-d H:i:s'),
);
}
return $query;
}
private function matchesStatus(array $workflow, string $statusFilter): bool
private function combinedQuery(): Builder
{
return $statusFilter === '' || DingPayableWorkflowStatus::key($workflow) === $statusFilter;
$statuses = $this->sourceStatuses();
$queries = [];
if ((bool) ($statuses['ding']['ready'] ?? false)) {
$queries[] = $this->dingQuery();
}
if ((bool) ($statuses['purchase']['ready'] ?? false)) {
$queries[] = $this->purchaseQuery();
}
if ($queries === []) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
$union = array_shift($queries);
foreach ($queries as $query) {
$union->unionAll($query);
}
return $this->connection->query()->fromSub($union, 'online_daifa_rows');
}
private function dingQuery(): Builder
{
$status = strtoupper(trim((string) config(
'upstream_mirrors.ding_online_daifa_price.only_status',
'COMPLETED',
))) ?: 'COMPLETED';
return $this->connection->table(self::TARGET_TABLE . ' as d')->select([
DB::raw("'ding' AS source_type"),
'd.process_instance_id',
'd.detail_index',
DB::raw('NULL AS purchase_order_id'),
DB::raw('NULL AS source_order_no'),
'd.product_id',
'd.product_name',
'd.daifa_trade_no',
'd.shop_trade_no',
'd.shop_name',
'd.payee_name',
DB::raw("CASE WHEN d.payee_name = '宁波顾家望业家居有限公司' THEN '刘萧代发' ELSE '1688代发' END AS tag"),
'd.supplier_name',
'd.material_no',
'd.ship_qty',
'd.paid_amount',
'd.daifa_amount',
'd.price_diff',
'd.workflow_payload',
'd.scheme_payload',
'd.task_payload',
DB::raw("'" . $status . "' AS status_key"),
'd.workflow_created_at',
]);
}
private function purchaseQuery(): Builder
{
return $this->connection->table('product_purchase_price_mirror_rows as p')
->leftJoin('ops_jst_orders as o', 'o.o_id', '=', 'p.source_order_no')
->whereIn('p.document_status', ['Confirmed', 'Finished'])
->select([
DB::raw("'purchase' AS source_type"),
'p.purchase_order_id as process_instance_id',
'p.item_id as detail_index',
'p.purchase_order_id',
'p.source_order_no',
DB::raw("'' AS product_id"),
DB::raw('NULL AS product_name'),
DB::raw("'' AS daifa_trade_no"),
'o.so_id as shop_trade_no',
'o.shop_name',
DB::raw("'' AS payee_name"),
DB::raw("'采购单管理' AS tag"),
'p.supplier_name',
'p.sku_id as material_no',
'p.quantity as ship_qty',
DB::raw('NULL AS paid_amount'),
'p.unit_price as daifa_amount',
DB::raw('NULL AS price_diff'),
DB::raw('NULL AS workflow_payload'),
DB::raw('NULL AS scheme_payload'),
DB::raw('NULL AS task_payload'),
DB::raw('UPPER(p.document_status) AS status_key'),
DB::raw('COALESCE(p.document_created_at, p.purchase_updated_at) AS workflow_created_at'),
]);
}
/** @return array<string,array<string,mixed>> */
private function sourceStatuses(): array
{
if ($this->sourceStatusCache !== null) {
return $this->sourceStatusCache;
}
$schema = Schema::connection($this->connection->getName());
$dingState = null;
if ($schema->hasTable(self::TARGET_TABLE) && $schema->hasTable('business_mirror_table_states')) {
$dingState = $this->connection->table('business_mirror_table_states')
->where('dataset_key', self::DATASET_KEY)
->first();
}
$dingReady = $dingState !== null && $dingState->initial_completed_at !== null;
$purchaseState = null;
$orderState = null;
$requiredPurchaseColumns = [
'source_order_no', 'supplier_name', 'quantity', 'document_created_at',
];
$purchaseSchemaReady = $schema->hasTable('product_purchase_price_mirror_rows')
&& $schema->hasTable('product_purchase_price_sync_states')
&& $schema->hasTable('ops_jst_orders')
&& $schema->hasColumns('product_purchase_price_mirror_rows', $requiredPurchaseColumns);
if ($purchaseSchemaReady) {
$purchaseState = $this->connection->table('product_purchase_price_sync_states')
->where('dataset_key', ProductPurchasePriceMirrorRepository::DATASET_KEY)
->first();
$orderState = $schema->hasTable('business_mirror_table_states')
? $this->connection->table('business_mirror_table_states')
->where('dataset_key', self::ORDER_DATASET_KEY)
->first()
: null;
}
$purchaseReady = $purchaseState !== null
&& $purchaseState->initial_completed_at !== null
&& (int) ($purchaseState->projection_version ?? 1)
>= ProductPurchasePriceMirrorRepository::CURRENT_PROJECTION_VERSION
&& $orderState !== null
&& $orderState->initial_completed_at !== null;
return $this->sourceStatusCache = [
'ding' => [
'label' => '钉钉线上代发审批',
'ready' => $dingReady,
'status' => $dingState === null ? 'pending' : (string) $dingState->status,
'last_synced_at' => $dingState?->last_succeeded_at === null
? null
: (string) $dingState->last_succeeded_at,
],
'purchase' => [
'label' => '聚水潭采购单管理',
'ready' => $purchaseReady,
'status' => $purchaseReady
? (string) $purchaseState->status
: ($purchaseSchemaReady ? 'requires_full_sync' : 'pending'),
'last_synced_at' => $purchaseState?->last_succeeded_at === null
? null
: (string) $purchaseState->last_succeeded_at,
],
];
}
/** @return array<string,mixed> */
private function presentRow(object $row, array $workflow): array
private function presentRow(object $row): array
{
$sourceType = (string) ($row->source_type ?? 'ding');
$purchase = $sourceType === 'purchase';
$workflow = $purchase
? [
'businessId' => (string) ($row->purchase_order_id ?? ''),
'title' => '采购单管理',
'status' => (string) ($row->status_key ?? ''),
]
: $this->decode((string) ($row->workflow_payload ?? ''), []);
$productId = (string) ($row->product_id ?? '');
// 商品名称已在同步阶段按 商品ID 关联商品目录并写入镜像宽表 product_name 列,
// 页面直接读取,避免对 2500 万行的 ops_jst_sku_map 做临时 JOIN 导致超时。
$resolvedName = $this->cleanName($row->product_name ?? null);
$product = $productId !== '' && $resolvedName !== null
$product = ! $purchase && $productId !== '' && $resolvedName !== null
? ['matched' => true, 'name' => $resolvedName, 'sku_id' => null, 'i_id' => null]
: ['matched' => false, 'name' => null, 'sku_id' => null, 'i_id' => null];
$statusKey = $purchase
? strtoupper((string) ($row->status_key ?? ''))
: DingPayableWorkflowStatus::key($workflow);
return [
'source_type' => $sourceType,
'process_instance_id' => (string) $row->process_instance_id,
'detail_index' => (int) $row->detail_index,
'purchase_order_id' => $row->purchase_order_id === null ? null : (int) $row->purchase_order_id,
'source_order_no' => $row->source_order_no === null ? null : (string) $row->source_order_no,
'product_id' => $productId,
'daifa_trade_no' => (string) ($row->daifa_trade_no ?? ''),
'shop_trade_no' => (string) ($row->shop_trade_no ?? ''),
'shop_name' => (string) ($row->shop_name ?? ''),
'payee_name' => (string) ($row->payee_name ?? ''),
'tag' => ((string) ($row->payee_name ?? '')) === '宁波顾家望业家居有限公司'
? '刘萧代发'
: '1688代发',
'tag' => (string) ($row->tag ?? ''),
'supplier_name' => (string) ($row->supplier_name ?? ''),
'material_no' => (string) ($row->material_no ?? ''),
'ship_qty' => $row->ship_qty === null ? null : (int) $row->ship_qty,
'ship_qty' => $row->ship_qty === null ? null : (float) $row->ship_qty,
'paid_amount' => $row->paid_amount === null ? null : (float) $row->paid_amount,
'daifa_amount' => $row->daifa_amount === null ? null : (float) $row->daifa_amount,
'price_diff' => $row->price_diff === null ? null : (float) $row->price_diff,
'workflow' => $workflow,
'scheme' => $this->decode((string) ($row->scheme_payload ?? '[]'), []),
'tasks' => $this->decode((string) ($row->task_payload ?? '[]'), []),
'status_key' => DingPayableWorkflowStatus::key($workflow),
'status_label' => DingPayableWorkflowStatus::label($workflow),
'scheme' => $purchase ? [] : $this->decode((string) ($row->scheme_payload ?? ''), []),
'tasks' => $purchase ? [] : $this->decode((string) ($row->task_payload ?? ''), []),
'status_key' => $statusKey,
'status_label' => $purchase
? match ($statusKey) {
'CONFIRMED' => '已确认',
'FINISHED' => '已完成',
default => $statusKey,
}
: DingPayableWorkflowStatus::label($workflow),
'matched_product' => $product,
'order_matched' => ! $purchase
|| ((string) ($row->shop_trade_no ?? '') !== '' && (string) ($row->shop_name ?? '') !== ''),
'workflow_created_at' => (string) ($row->workflow_created_at ?? ''),
];
}
@ -222,6 +399,7 @@ class DingOnlineDaifaPriceMirrorRepository
return null;
}
$name = trim((string) $value);
return $name === '' ? null : $name;
}
@ -231,6 +409,7 @@ class DingOnlineDaifaPriceMirrorRepository
return $fallback;
}
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : $fallback;
}
}

View File

@ -12,6 +12,8 @@ class ProductPurchasePriceMirrorRepository
{
public const DATASET_KEY = 'product-inventory.purchase-order-prices';
public const CURRENT_PROJECTION_VERSION = 2;
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
@ -33,7 +35,9 @@ class ProductPurchasePriceMirrorRepository
{
$state = $this->state();
return is_array($state) && ($state['initial_completed_at'] ?? null) !== null;
return is_array($state)
&& ($state['initial_completed_at'] ?? null) !== null
&& (int) ($state['projection_version'] ?? 1) >= self::CURRENT_PROJECTION_VERSION;
}
/** @return array<string,mixed>|null */
@ -67,6 +71,7 @@ class ProductPurchasePriceMirrorRepository
'status' => 'running',
'run_mode' => $mode,
'generation' => $generation,
'projection_version' => self::CURRENT_PROJECTION_VERSION,
'last_attempted_at' => $now,
'last_error_code' => null,
'updated_at' => $now,
@ -104,11 +109,15 @@ class ProductPurchasePriceMirrorRepository
$values = [
'item_id' => (int) $row['item_id'],
'purchase_order_id' => (int) $row['purchase_order_id'],
'source_order_no' => $this->nullablePositiveInteger($row['source_order_no'] ?? null),
'supplier_name' => $this->nullableString($row['supplier_name'] ?? null),
'sku_id' => $this->nullableString($row['sku_id'] ?? null),
'style_id' => $this->nullableString($row['style_id'] ?? null),
'quantity' => $row['quantity'] ?? null,
'unit_price' => $row['unit_price'] ?? null,
'document_status' => $this->nullableString($row['document_status'] ?? null),
'labels' => $this->nullableString($row['labels'] ?? null),
'document_created_at' => $row['document_created_at'] ?? null,
'finished_at' => $row['finished_at'] ?? null,
'purchase_updated_at' => $row['purchase_updated_at'],
];
@ -130,8 +139,9 @@ class ProductPurchasePriceMirrorRepository
$mapped,
['item_id'],
[
'purchase_order_id', 'sku_id', 'style_id', 'unit_price',
'document_status', 'labels', 'finished_at', 'purchase_updated_at',
'purchase_order_id', 'source_order_no', 'supplier_name',
'sku_id', 'style_id', 'quantity', 'unit_price',
'document_status', 'labels', 'document_created_at', 'finished_at', 'purchase_updated_at',
'_mirror_generation', 'row_hash', '_mirror_synced_at', 'updated_at',
],
);
@ -211,4 +221,15 @@ class ProductPurchasePriceMirrorRepository
return $value === '' ? null : $value;
}
private function nullablePositiveInteger(mixed $value): ?int
{
if ($value === null || $value === '' || ! is_numeric($value)) {
return null;
}
$value = (int) $value;
return $value > 0 ? $value : null;
}
}

View File

@ -34,8 +34,11 @@ class ProductPurchasePriceRepository
$sql = 'SELECT TOP (' . $limit . ') '
. 'i.[Id] AS item_id, p.[po_id] AS purchase_order_id, '
. 'i.[sku_id] AS sku_id, i.[i_id] AS style_id, i.[price] AS unit_price, '
. 'CONVERT(varchar(64), CONVERT(decimal(38, 0), p.[source_o_id])) AS source_order_no, '
. 'p.[seller] AS supplier_name, '
. 'i.[sku_id] AS sku_id, i.[i_id] AS style_id, i.[qty] AS quantity, i.[price] AS unit_price, '
. 'p.[status] AS document_status, p.[labels] AS labels, '
. 'CONVERT(varchar(27), p.[createTime], 126) AS document_created_at, '
. 'CONVERT(varchar(27), p.[finish_time], 126) AS finished_at, '
. 'CONVERT(varchar(27), p.[updateTime], 126) AS purchase_updated_at '
. 'FROM [dbo].[jst_purchase] p '

View File

@ -25,7 +25,10 @@ class ProductPurchasePriceMirrorSynchronizer
}
$state = $this->mirror->state();
if (! $full && ($state['initial_completed_at'] ?? null) === null) {
if (! $full && (
($state['initial_completed_at'] ?? null) === null
|| (int) ($state['projection_version'] ?? 1) < ProductPurchasePriceMirrorRepository::CURRENT_PROJECTION_VERSION
)) {
return ['status' => 'requires_full_sync', 'mode' => 'incremental', 'batches' => 0, 'rows' => 0];
}
@ -33,6 +36,7 @@ class ProductPurchasePriceMirrorSynchronizer
$resume = is_array($state)
&& ($state['run_mode'] ?? null) === $mode
&& in_array($state['status'] ?? null, ['running', 'failed'], true)
&& (int) ($state['projection_version'] ?? 1) >= ProductPurchasePriceMirrorRepository::CURRENT_PROJECTION_VERSION
&& ($state['window_started_at'] ?? null) !== null
&& ($state['window_ends_at'] ?? null) !== null;
$generation = $resume && is_string($state['generation'] ?? null)

View File

@ -4,13 +4,19 @@
$status = (string) ($onlineDaifaStatus ?? 'unknown');
$syncedAt = $onlineDaifaSyncedAt ?? null;
$summary = $onlineDaifaSummary ?? [];
$sources = $onlineDaifaSources ?? [];
$error = $onlineDaifaError ?? null;
$statusOptions = \Modules\EcommerceFinance\Services\DingPayableWorkflowStatus::filterOptions();
$statusOptions = \Modules\EcommerceFinance\Services\DingPayableWorkflowStatus::filterOptions() + [
'CONFIRMED' => '已确认(采购单)',
'FINISHED' => '已完成(采购单)',
];
$num = static fn (mixed $value): string => $value === null || $value === '' ? '-' : (string) $value;
$money = static fn (mixed $value): string => $value === null ? '-' : number_format((float) $value, 2);
$productName = static fn (array $row): string => $row['matched_product']['matched'] ?? false
? (string) ($row['matched_product']['name'] ?? '-')
: '(未匹配)';
$productName = static fn (array $row): string => ($row['source_type'] ?? 'ding') === 'purchase'
? '-'
: (($row['matched_product']['matched'] ?? false)
? (string) ($row['matched_product']['name'] ?? '-')
: '(未匹配)');
$payeeScope = trim((string) config('upstream_mirrors.ding_online_daifa_price.row_filters.payee_name', ''));
@endphp
@ -49,7 +55,7 @@
<form method="GET" class="online-daifa-toolbar" aria-label="线上代发价格筛选">
<div class="online-daifa-field">
<label for="daifa-keyword">代发/店铺交易单号或审批编号</label>
<label for="daifa-keyword">交易单号、采购单号或审批编号</label>
<input id="daifa-keyword" name="keyword" value="{{ $filters['keyword'] ?? '' }}" placeholder="输入单据号或审批编号">
</div>
<div class="online-daifa-field">
@ -96,10 +102,13 @@
@endif
<div class="online-daifa-meta">
<span>固定流程:线上代发业务报销流程(钉钉 OA 审批)</span>
<span>数据来源:钉钉线上代发审批、聚水潭采购单管理(已确认/已完成)</span>
@if ($payeeScope !== '') <span>固定收款人:<b>{{ $payeeScope }}</b></span> @endif
<span>镜像状态:{{ $status }}</span>
@if ($syncedAt) <span>最近同步:{{ $syncedAt }}</span> @endif
@foreach ($sources as $source)
<span>{{ $source['label'] ?? '数据源' }}:<b>{{ ($source['ready'] ?? false) ? ($source['status'] ?? 'available') : '同步准备中' }}</b></span>
@endforeach
<span>共 <b>{{ $rows?->total() ?? 0 }}</b> 条</span>
</div>
@ -107,7 +116,8 @@
<div class="item"><span>代发订单金额合计</span><b>{{ $money($summary['daifa_amount'] ?? null) }}</b></div>
<div class="item"><span>订单实付金额合计</span><b>{{ $money($summary['paid_amount'] ?? null) }}</b></div>
<div class="item"><span>差价合计</span><b>{{ $money($summary['price_diff'] ?? null) }}</b></div>
<div class="item"><span>已匹配商品目录</span><b>{{ (int) ($summary['matched_count'] ?? 0) }} / {{ (int) ($summary['count'] ?? 0) }}</b></div>
<div class="item"><span>采购单管理</span><b>{{ (int) ($summary['purchase_count'] ?? 0) }}</b></div>
<div class="item"><span>已匹配商品目录</span><b>{{ (int) ($summary['matched_count'] ?? 0) }} / {{ (int) ($summary['matchable_count'] ?? $summary['count'] ?? 0) }}</b></div>
</div>
<div class="online-daifa-table-wrap">
@ -133,9 +143,9 @@
<td>{{ $row['tag'] ?? '' }}</td>
<td>{{ $num($row['product_id']) }}</td>
<td class="{{ ($row['matched_product']['matched'] ?? false) ? '' : 'unmatched' }}">{{ $productName($row) }}</td>
<td>{{ $num($row['shop_name']) }}</td>
<td class="{{ (($row['source_type'] ?? '') === 'purchase' && !($row['order_matched'] ?? false)) ? 'unmatched' : '' }}">{{ (($row['source_type'] ?? '') === 'purchase' && !($row['order_matched'] ?? false)) ? '(未匹配订单)' : $num($row['shop_name']) }}</td>
<td>{{ $num($row['daifa_trade_no']) }}</td>
<td>{{ $num($row['shop_trade_no']) }}</td>
<td class="{{ (($row['source_type'] ?? '') === 'purchase' && !($row['order_matched'] ?? false)) ? 'unmatched' : '' }}">{{ (($row['source_type'] ?? '') === 'purchase' && !($row['order_matched'] ?? false)) ? '(未匹配订单)' : $num($row['shop_trade_no']) }}</td>
<td class="num">{{ $num($row['ship_qty']) }}</td>
<td class="num">{{ $money($row['paid_amount']) }}</td>
<td class="num">{{ $money($row['daifa_amount']) }}</td>

View File

@ -62,7 +62,7 @@ class OnlineDaifaPricePageTest extends FeatureTestCase
'page' => 'online-daifa-price',
]))->assertOk()
->assertSee('线上代发价格')
->assertSee('固定流程:线上代发业务报销流程(钉钉 OA 审批)')
->assertSee('数据来源:钉钉线上代发审批、聚水潭采购单管理(已确认/已完成)')
->assertDontSee('固定收款人:')
->assertSee('宁波顾家望业家居有限公司')
->assertSee('镜像状态:succeeded')
@ -79,6 +79,57 @@ class OnlineDaifaPricePageTest extends FeatureTestCase
->assertDontSee('展开表单');
}
public function test_online_daifa_price_page_places_purchase_fields_in_existing_columns(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(DingOnlineDaifaPriceMirrorRepository::class);
$repository->shouldReceive('paginate')->once()->andReturn([
'data' => [[
'source_type' => 'purchase',
'process_instance_id' => '907329',
'detail_index' => 1,
'product_id' => '',
'daifa_trade_no' => '',
'shop_trade_no' => '110212448119316609',
'shop_name' => '得物-米客米家家居旗舰店',
'payee_name' => '',
'tag' => '采购单管理',
'supplier_name' => '霸州市亚锦五金制品厂',
'material_no' => 'DF-C1ZDY000202',
'ship_qty' => 1,
'paid_amount' => null,
'daifa_amount' => 19.3,
'price_diff' => null,
'workflow' => ['businessId' => '907329', 'title' => '采购单管理'],
'status_label' => '已确认',
'matched_product' => ['matched' => false, 'name' => null],
'order_matched' => true,
'workflow_created_at' => '2026-09-22 16:30:03',
]],
'total' => 1,
'status' => 'available',
'last_synced_at' => '2026-09-22 17:35:11',
'summary' => ['count' => 1, 'purchase_count' => 1, 'matchable_count' => 0],
'source_statuses' => [
'purchase' => ['label' => '聚水潭采购单管理', 'ready' => true, 'status' => 'succeeded'],
],
]);
$this->app->instance(DingOnlineDaifaPriceMirrorRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'product-inventory',
'page' => 'online-daifa-price',
]))->assertOk()
->assertSee('110212448119316609')
->assertSee('得物-米客米家家居旗舰店')
->assertSee('霸州市亚锦五金制品厂')
->assertSee('DF-C1ZDY000202')
->assertSee('19.30')
->assertSee('采购单管理')
->assertSee('已确认')
->assertDontSee('(未匹配)');
}
public function test_online_daifa_price_page_keeps_an_unavailable_mirror_readable(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());

View File

@ -42,6 +42,7 @@ class ProductPurchasePriceMirrorSyncCommandTest extends TestCase
'run_mode' => 'incremental',
'initial_completed_at' => '2026-08-14 10:00:00',
'last_full_succeeded_at' => '2026-08-17 02:00:00',
'projection_version' => ProductPurchasePriceMirrorRepository::CURRENT_PROJECTION_VERSION,
]);
$synchronizer = Mockery::mock(ProductPurchasePriceMirrorSynchronizer::class);
$synchronizer->shouldReceive('sync')
@ -80,7 +81,7 @@ class ProductPurchasePriceMirrorSyncCommandTest extends TestCase
$this->app->instance(ProductPurchasePriceMirrorSynchronizer::class, $synchronizer);
$this->artisan('business-mirror:sync-product-purchase-prices')
->expectsOutputToContain('需经授权后显式使用 --full')
->expectsOutputToContain('完成备份后显式使用 --full')
->assertSuccessful();
}
}

View File

@ -0,0 +1,176 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository;
use Modules\EcommerceFinance\Repositories\ProductPurchasePriceMirrorRepository;
use Tests\TestCase;
class OnlineDaifaPurchaseMirrorRepositoryTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
]);
DB::purge('business_mirror');
$this->createSchema();
$this->seedRows();
}
public function test_it_maps_confirmed_and_finished_purchase_rows_and_keeps_missing_orders(): void
{
$result = app(DingOnlineDaifaPriceMirrorRepository::class)->paginate([], 1, 25);
$this->assertSame(2, $result['total']);
$this->assertSame(2, $result['summary']['purchase_count']);
$this->assertSame(0.0, $result['summary']['daifa_amount']);
$confirmed = collect($result['data'])->firstWhere('purchase_order_id', 907329);
$this->assertSame('110212448119316609', $confirmed['shop_trade_no']);
$this->assertSame('得物-米客米家家居旗舰店', $confirmed['shop_name']);
$this->assertSame('霸州市亚锦五金制品厂', $confirmed['supplier_name']);
$this->assertSame('DF-C1ZDY000202', $confirmed['material_no']);
$this->assertSame(1.0, $confirmed['ship_qty']);
$this->assertSame(19.3, $confirmed['daifa_amount']);
$this->assertSame('采购单管理', $confirmed['tag']);
$this->assertSame('已确认', $confirmed['status_label']);
$this->assertTrue($confirmed['order_matched']);
$missing = collect($result['data'])->firstWhere('purchase_order_id', 907330);
$this->assertNotNull($missing);
$this->assertSame('', $missing['shop_trade_no']);
$this->assertFalse($missing['order_matched']);
}
public function test_purchase_filters_apply_to_page_and_export_with_the_same_scope(): void
{
$filters = [
'status' => 'CONFIRMED',
'shop_name' => '得物-米客米家家居旗舰店',
'supplier_name' => '亚锦',
'material_no' => 'DF-C1ZDY000202',
];
$repository = app(DingOnlineDaifaPriceMirrorRepository::class);
$page = $repository->paginate($filters, 1, 25);
$export = iterator_to_array($repository->exportRows($filters));
$this->assertSame(1, $page['total']);
$this->assertSame([907329], array_column($page['data'], 'purchase_order_id'));
$this->assertSame([907329], array_column($export, 'purchase_order_id'));
}
private function createSchema(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->string('status');
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
});
$schema->create('product_purchase_price_sync_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->string('status');
$table->unsignedTinyInteger('projection_version')->default(1);
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
});
$schema->create('product_purchase_price_mirror_rows', function (Blueprint $table): void {
$table->unsignedBigInteger('item_id')->primary();
$table->unsignedBigInteger('purchase_order_id');
$table->unsignedBigInteger('source_order_no')->nullable();
$table->string('supplier_name')->nullable();
$table->string('sku_id')->nullable();
$table->string('style_id')->nullable();
$table->decimal('quantity', 20, 4)->nullable();
$table->decimal('unit_price', 20, 4)->nullable();
$table->string('document_status')->nullable();
$table->text('labels')->nullable();
$table->dateTime('document_created_at')->nullable();
$table->dateTime('finished_at')->nullable();
$table->dateTime('purchase_updated_at');
});
$schema->create('ops_jst_orders', function (Blueprint $table): void {
$table->unsignedBigInteger('Id')->primary();
$table->unsignedBigInteger('o_id')->index();
$table->string('so_id')->nullable();
$table->string('shop_name')->nullable();
});
}
private function seedRows(): void
{
$db = DB::connection('business_mirror');
$db->table('business_mirror_table_states')->insert([
'dataset_key' => 'operations_replica.rpt.jst_Orders',
'status' => 'succeeded',
'initial_completed_at' => '2026-09-20 00:00:00',
'last_succeeded_at' => '2026-09-22 16:40:00',
]);
$db->table('product_purchase_price_sync_states')->insert([
'dataset_key' => ProductPurchasePriceMirrorRepository::DATASET_KEY,
'status' => 'succeeded',
'projection_version' => ProductPurchasePriceMirrorRepository::CURRENT_PROJECTION_VERSION,
'initial_completed_at' => '2026-09-22 17:30:00',
'last_succeeded_at' => '2026-09-22 17:35:11',
]);
$db->table('ops_jst_orders')->insert([
'Id' => 1,
'o_id' => 9416447,
'so_id' => '110212448119316609',
'shop_name' => '得物-米客米家家居旗舰店',
]);
$db->table('product_purchase_price_mirror_rows')->insert([
[
'item_id' => 1,
'purchase_order_id' => 907329,
'source_order_no' => 9416447,
'supplier_name' => '霸州市亚锦五金制品厂',
'sku_id' => 'DF-C1ZDY000202',
'quantity' => 1,
'unit_price' => 19.3,
'document_status' => 'Confirmed',
'document_created_at' => '2026-09-22 16:30:03',
'purchase_updated_at' => '2026-09-22 16:30:04',
],
[
'item_id' => 2,
'purchase_order_id' => 907330,
'source_order_no' => 9999999,
'supplier_name' => '待匹配供应商',
'sku_id' => 'SKU-FINISHED',
'quantity' => 2,
'unit_price' => 8.5,
'document_status' => 'Finished',
'document_created_at' => '2026-09-22 17:00:00',
'purchase_updated_at' => '2026-09-22 17:01:00',
],
[
'item_id' => 3,
'purchase_order_id' => 896492,
'source_order_no' => 9416447,
'supplier_name' => '霸州市亚锦五金制品厂',
'sku_id' => 'DF-C1ZDY000202',
'quantity' => 1,
'unit_price' => 19.3,
'document_status' => 'Cancelled',
'document_created_at' => '2026-09-17 16:15:04',
'purchase_updated_at' => '2026-09-17 16:15:05',
],
]);
}
}

View File

@ -29,6 +29,22 @@ class ProductPurchasePriceMirrorSynchronizerTest extends TestCase
$this->assertSame('requires_full_sync', $result['status']);
}
public function test_incremental_sync_requires_a_fresh_full_pass_for_the_online_daifa_projection(): void
{
$upstream = $this->createMock(ProductPurchasePriceRepository::class);
$upstream->expects($this->never())->method('mirrorBatch');
$mirror = $this->createMock(ProductPurchasePriceMirrorRepository::class);
$mirror->method('available')->willReturn(true);
$mirror->method('state')->willReturn([
'initial_completed_at' => '2026-09-20 04:10:03',
'projection_version' => 1,
]);
$result = (new ProductPurchasePriceMirrorSynchronizer($upstream, $mirror))->sync();
$this->assertSame('requires_full_sync', $result['status']);
}
public function test_full_sync_uses_stable_update_time_and_item_id_cursor(): void
{
Carbon::setTestNow('2026-08-21 12:00:00');

View File

@ -44,6 +44,10 @@ class ProductPurchasePriceRepositoryTest extends TestCase
$this->assertStringContainsString('[dbo].[jst_purchase] p', $query->sql);
$this->assertStringContainsString('[dbo].[jst_purchaseItem] i', $query->sql);
$this->assertStringContainsString('i.[po_id] = p.[po_id]', $query->sql);
$this->assertStringContainsString('p.[source_o_id]', $query->sql);
$this->assertStringContainsString('p.[seller] AS supplier_name', $query->sql);
$this->assertStringContainsString('i.[qty] AS quantity', $query->sql);
$this->assertStringContainsString('p.[createTime]', $query->sql);
$this->assertStringContainsString('p.[updateTime] >= ?', $query->sql);
$this->assertStringContainsString('p.[updateTime] = ? AND i.[Id] > ?', $query->sql);
$this->assertStringContainsString('ORDER BY p.[updateTime], i.[Id]', $query->sql);