接入聚水潭采购单到线上代发价格 #244
@ -16,7 +16,7 @@ class SyncProductPurchasePriceMirror extends Command
|
||||
{--batch= : 每批最多读取行数}
|
||||
{--max-batches= : 本轮最多批次数,0 表示直到完成}';
|
||||
|
||||
protected $description = '同步采购单管理的普通商品单价,并保留可断点的共享业务镜像';
|
||||
protected $description = '同步采购单管理明细和普通商品单价,并保留可断点的共享业务镜像';
|
||||
|
||||
public function handle(
|
||||
ProductPurchasePriceMirrorRepository $mirror,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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。
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 '
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user