diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index 470d580f..d2fa293c 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -169,6 +169,78 @@ return [ ], 'operations_replica.rpt.jst_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_shopInfo', 'target' => 'ops_jst_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], + // 聚水潭聚工单:协同工单列表。只复制工单查询需要的非敏感字段, + // 正式 API 从共享镜像关联“仓内未发”类型,不在请求中直连上游。 + 'operations_replica.rpt.work_order_main' => [ + 'source_key' => 'operations_replica', + 'schema' => 'rpt', + 'object' => 'work_order_main', + 'target' => 'ops_work_order_main', + 'keys' => ['id'], + 'watermark' => 'update_time', + 'columns' => [ + 'id', 'work_type', 'order_no', 'title', 'status', 'create_user', + 'expect_minute', 'submit_time', 'expect_end_time', 'end_time', + 'score', 'update_user', 'description', 'is_deleted', 'create_time', + 'update_time', + ], + 'indexes' => [ + ['work_type', 'id'], + ['create_time', 'id'], + ], + ], + 'operations_replica.rpt.work_order_sub' => [ + 'source_key' => 'operations_replica', + 'schema' => 'rpt', + 'object' => 'work_order_sub', + 'target' => 'ops_work_order_sub', + 'keys' => ['id'], + 'watermark' => 'update_time', + 'columns' => [ + 'id', 'sub_order_no', 'main_order_no', 'title', 'assign_user', + 'status', 'expect_minute', 'submit_time', 'expect_end_time', + 'finish_time', 'content', 'notify_submit', 'notify_soon', + 'notify_overdue', 'overdue_notify_count', 'is_deleted', + 'create_time', 'update_time', 'taskid', + ], + 'indexes' => [ + ['id'], + ], + ], + 'operations_replica.rpt.work_order_form_config' => [ + 'source_key' => 'operations_replica', + 'schema' => 'rpt', + 'object' => 'work_order_form_config', + 'target' => 'ops_work_order_form_config', + 'keys' => ['id'], + 'watermark' => 'update_time', + 'columns' => [ + 'id', 'work_type', 'work_type_name', 'group_name', 'icon', + 'icon_color', 'time_limit_options', 'is_enabled', 'form_fields', + 'sort', 'remark', 'create_time', 'update_time', + 'timeout_reminder_count', + ], + 'indexes' => [ + ['work_type', 'id'], + ['work_type_name', 'id'], + ], + ], + 'operations_replica.rpt.work_order_form_value' => [ + 'source_key' => 'operations_replica', + 'schema' => 'rpt', + 'object' => 'work_order_form_value', + 'target' => 'ops_work_order_form_value', + 'keys' => ['id'], + 'watermark' => null, + 'columns' => [ + 'id', 'main_order_no', 'field_code', 'field_name', 'field_value', + 'field_type', 'sort', 'is_deleted', 'create_time', + ], + 'indexes' => [ + ['sort', 'id'], + ], + ], + // JianSun 旧业务库:营销账单、供应商应付、板材、封边条和库存。 'jiansun.dbo.tmall_spread_bills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'tmall_spread_bills', 'target' => 'js_tmall_spread_bills', 'keys' => ['id']], 'jiansun.dbo.pdd_spread_bills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pdd_spread_bills', 'target' => 'js_pdd_spread_bills', 'keys' => ['id'], 'watermark' => 'createTime'], diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 20f98557..b6136f79 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -15,6 +15,7 @@ | 已接入校准 | 聚水潭“普通商品资料”完整 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一键代发”标签的最近采购单价,并与原商品采购价分别标注来源。 | | 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 | +| 本次接入 | 聚水潭聚工单协同工单登记 `work_order_main`、`work_order_sub`、`work_order_form_config`、`work_order_form_value` 四张表;工单 API 固定筛选类型“仓内未发”,首次同步完成前返回同步未完成状态。 | | 本次建立 | `config/upstream_mirrors.php` 中登记全部当前代码引用的原表;通用表级同步框架负责建表、稳定游标、断点续传、增量重叠和全量校准。 | | 已开始逐域切换 | 公司、店铺、商标页面在对应镜像完成首次全量后自动改读完整镜像表;首次全量前保留旧快照作为兼容回滚。其他业务域仍需逐项切换,不得宣称全项目已经完成。 | | 中央同步接入 | 店铺维度报表从 `jiansun.dbo.jst_originalBillInfo` 和 `dbo.fys_shopInfo_flow` 自动、可断点同步;不再依赖开发者电脑、SQLite 或人工 XLSX。 | @@ -39,6 +40,7 @@ | 退款退货 | `rpt.jst_returnAndRefundInfo`、`rpt.jst_returnAndRefundItem` | `ops_jst_return_refund_*` | | 聚水潭店铺 | `rpt.jst_shopInfo` | `ops_jst_shop_info` | | 店铺维度报表 | `jiansun/dbo.jst_originalBillInfo + dbo.jst_shopInfo + dbo.fys_shopInfo_flow` | `store_report_bill_rows`、`store_report_lifecycle_rows`、`store_report_daily_rows` | +| 聚水潭聚工单(仓内未发) | `rpt.work_order_main`、`rpt.work_order_sub`、`rpt.work_order_form_config`、`rpt.work_order_form_value` | `ops_work_order_main`、`ops_work_order_sub`、`ops_work_order_form_config`、`ops_work_order_form_value` | 商品价格 `rpt.Bulk_Price_Limit` 继续使用已经上线的类型化同步器,不在通用原表任务中重复同步。 @@ -79,3 +81,16 @@ 6. 板材、封边条和库存的跨表查询。 每个阶段都必须先完成首次全量和数量核对,再将对应 Repository 切到镜像;任一阶段失败时保留上一版镜像和旧只读链路用于回滚。 + +聚工单四个数据集首次同步需分别显式执行,完成后 API 地址为 +`GET /api/ecommerce-finance/work-orders`,默认只返回工单类型“仓内未发”,并支持 +`order_no`、`status`、`create_user`、`assign_user`、`created_from`、`created_to` 等筛选。 +未完成四张表首次全量时,接口返回 `503` 和逐表同步状态,不把空镜像解释为没有工单: +表单值表没有可靠更新时间,日常同步只按稳定 `id` 追加;若上游允许修改既有表单值,需在低峰期重新执行该数据集全量校准。 + +```bash +php artisan business-mirror:sync-tables operations_replica.rpt.work_order_main --full +php artisan business-mirror:sync-tables operations_replica.rpt.work_order_sub --full +php artisan business-mirror:sync-tables operations_replica.rpt.work_order_form_config --full +php artisan business-mirror:sync-tables operations_replica.rpt.work_order_form_value --full +``` diff --git a/modules/EcommerceFinance/Http/Controllers/Api/WorkOrders.php b/modules/EcommerceFinance/Http/Controllers/Api/WorkOrders.php new file mode 100644 index 00000000..1b9919ce --- /dev/null +++ b/modules/EcommerceFinance/Http/Controllers/Api/WorkOrders.php @@ -0,0 +1,57 @@ +validate([ + 'work_type' => ['nullable', 'string', 'max:191'], + 'order_no' => ['nullable', 'string', 'max:191'], + 'status' => ['nullable', 'string', 'max:64'], + 'create_user' => ['nullable', 'string', 'max:191'], + 'assign_user' => ['nullable', 'string', 'max:191'], + 'created_from' => ['nullable', 'date_format:Y-m-d'], + 'created_to' => ['nullable', 'date_format:Y-m-d'], + 'page' => ['nullable', 'integer', 'min:1'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + if (! empty($filters['created_from']) && ! empty($filters['created_to']) + && $filters['created_to'] < $filters['created_from']) { + return response()->json([ + 'message' => '创建结束日期不能早于开始日期。', + 'errors' => ['created_to' => ['创建结束日期不能早于开始日期。']], + ], 422); + } + + $page = (int) ($filters['page'] ?? 1); + $limit = (int) ($filters['limit'] ?? 25); + unset($filters['page'], $filters['limit']); + + $result = $this->repository->paginate($page, $limit, $filters); + if (! $result['ready']) { + return response()->json([ + 'message' => '聚水潭协同工单镜像尚未完成首次同步,请先完成共享镜像同步;页面不会临时访问上游。', + 'ready' => false, + 'sync_status' => $result['sync_status'], + 'data' => [], + 'total' => 0, + 'page' => $page, + 'per_page' => $limit, + 'last_page' => 1, + ], 503, ['Retry-After' => '30']); + } + + return response()->json($result); + } +} diff --git a/modules/EcommerceFinance/Repositories/WorkOrderMirrorRepository.php b/modules/EcommerceFinance/Repositories/WorkOrderMirrorRepository.php new file mode 100644 index 00000000..ebc190ec --- /dev/null +++ b/modules/EcommerceFinance/Repositories/WorkOrderMirrorRepository.php @@ -0,0 +1,261 @@ + self::MAIN_DATASET, + 'sub' => self::SUB_DATASET, + 'config' => self::CONFIG_DATASET, + 'values' => self::VALUE_DATASET, + ]; + + private readonly ConnectionInterface $connection; + + public function __construct( + private readonly BusinessMirrorDatasetRegistry $datasets, + private readonly GenericBusinessMirrorRepository $mirror, + ) { + $this->connection = $mirror->connection(); + } + + /** + * @return array{ + * ready:bool, + * code:string, + * last_succeeded_at:?string, + * datasets:array + * } + */ + public function syncStatus(): array + { + $ready = true; + $lastSucceeded = []; + $states = []; + + foreach (self::DATASETS as $name => $key) { + try { + $dataset = $this->datasets->get($key); + $state = $this->mirror->state($dataset); + $datasetReady = $this->mirror->ready($dataset); + } catch (Throwable) { + $state = null; + $datasetReady = false; + } + + $last = $this->dateValue($state['last_succeeded_at'] ?? null); + if ($last !== null) { + $lastSucceeded[] = $last; + } + + $states[$name] = [ + 'dataset' => $key, + 'ready' => $datasetReady, + 'status' => (string) ($state['status'] ?? 'pending'), + 'last_succeeded_at' => $last, + ]; + $ready = $ready && $datasetReady; + } + + return [ + 'ready' => $ready, + 'code' => $ready ? 'ready' : 'initial_sync_pending', + // The oldest successful component is the freshness of the joined result. + 'last_succeeded_at' => $lastSucceeded === [] ? null : min($lastSucceeded), + 'datasets' => $states, + ]; + } + + /** + * @param array $filters + * @return array{data:list>,total:int,page:int,per_page:int,last_page:int,ready:bool,sync_status:array} + */ + public function paginate(int $page, int $perPage, array $filters = []): array + { + $page = max($page, 1); + $perPage = min(max($perPage, 1), 100); + $syncStatus = $this->syncStatus(); + + if (! $syncStatus['ready']) { + return [ + 'data' => [], + 'total' => 0, + 'page' => $page, + 'per_page' => $perPage, + 'last_page' => 1, + 'ready' => false, + 'sync_status' => $syncStatus, + ]; + } + + $main = $this->datasets->get(self::MAIN_DATASET)->targetTable(); + $config = $this->datasets->get(self::CONFIG_DATASET)->targetTable(); + $latestConfigs = $this->connection->table($config . ' AS config_versions') + ->selectRaw('work_type, MAX(id) AS id') + ->groupBy('work_type'); + + $query = $this->connection->table($main . ' AS m') + ->joinSub($latestConfigs, 'config_versions', static function ($join): void { + $join->on('config_versions.work_type', '=', 'm.work_type'); + }) + ->join($config . ' AS c', 'c.id', '=', 'config_versions.id') + ->where('c.work_type_name', self::DEFAULT_WORK_TYPE_NAME) + ->where(static function (Builder $query): void { + $query->whereNull('m.is_deleted')->orWhere('m.is_deleted', 0); + }); + + $this->applyFilters($query, $filters); + $total = (clone $query)->count(); + $rows = $query + ->select([ + 'm.id', 'm.work_type', 'c.work_type_name', 'c.group_name', + 'm.order_no', 'm.title', 'm.status', 'm.create_user', + 'm.expect_minute', 'm.submit_time', 'm.expect_end_time', + 'm.end_time', 'm.score', 'm.update_user', 'm.description', + 'm.create_time', 'm.update_time', + ]) + ->orderByDesc('m.create_time') + ->orderByDesc('m.id') + ->forPage($page, $perPage) + ->get() + ->map(static fn (object $row): array => (array) $row) + ->all(); + + $this->hydrateRelatedRows($rows); + + return [ + 'data' => $rows, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'last_page' => max((int) ceil($total / $perPage), 1), + 'ready' => true, + 'sync_status' => $syncStatus, + ]; + } + + /** @param array $filters */ + private function applyFilters(Builder $query, array $filters): void + { + if (($filters['work_type'] ?? '') !== '') { + $query->where('m.work_type', $filters['work_type']); + } + if (($filters['order_no'] ?? '') !== '') { + $query->whereRaw("m.order_no LIKE ? ESCAPE '!'", [$this->contains($filters['order_no'])]); + } + if (($filters['status'] ?? '') !== '') { + $query->where('m.status', $filters['status']); + } + if (($filters['create_user'] ?? '') !== '') { + $query->where('m.create_user', $filters['create_user']); + } + if (($filters['created_from'] ?? '') !== '') { + $query->where('m.create_time', '>=', $filters['created_from'] . ' 00:00:00'); + } + if (($filters['created_to'] ?? '') !== '') { + $query->where('m.create_time', '<', Carbon::parse($filters['created_to'])->addDay()->toDateString()); + } + if (($filters['assign_user'] ?? '') !== '') { + $subTable = $this->datasets->get(self::SUB_DATASET)->targetTable(); + $assignUser = $filters['assign_user']; + $query->whereExists(static function (Builder $subQuery) use ($subTable, $assignUser): void { + $subQuery->selectRaw('1') + ->from($subTable . ' AS assigned_sub') + ->whereColumn('assigned_sub.main_order_no', 'm.order_no') + ->where('assigned_sub.assign_user', $assignUser) + ->where(static function (Builder $active): void { + $active->whereNull('assigned_sub.is_deleted')->orWhere('assigned_sub.is_deleted', 0); + }); + }); + } + } + + /** @param list> $rows */ + private function hydrateRelatedRows(array &$rows): void + { + $orderNumbers = array_values(array_unique(array_filter(array_map( + static fn (array $row): string => trim((string) ($row['order_no'] ?? '')), + $rows, + )))); + + if ($orderNumbers === []) { + return; + } + + $subTable = $this->datasets->get(self::SUB_DATASET)->targetTable(); + $subOrders = $this->connection->table($subTable) + ->select([ + 'id', 'sub_order_no', 'main_order_no', 'title', 'assign_user', + 'status', 'expect_minute', 'submit_time', 'expect_end_time', + 'finish_time', 'content', 'notify_submit', 'notify_soon', + 'notify_overdue', 'overdue_notify_count', 'create_time', + 'update_time', 'taskid', + ]) + ->whereIn('main_order_no', $orderNumbers) + ->where(static function (Builder $query): void { + $query->whereNull('is_deleted')->orWhere('is_deleted', 0); + }) + ->orderBy('id') + ->get() + ->groupBy(static fn (object $row): string => (string) $row->main_order_no); + + $valueTable = $this->datasets->get(self::VALUE_DATASET)->targetTable(); + $formValues = $this->connection->table($valueTable) + ->select([ + 'id', 'main_order_no', 'field_code', 'field_name', 'field_value', + 'field_type', 'sort', 'create_time', + ]) + ->whereIn('main_order_no', $orderNumbers) + ->where(static function (Builder $query): void { + $query->whereNull('is_deleted')->orWhere('is_deleted', 0); + }) + ->orderBy('sort') + ->orderBy('id') + ->get() + ->groupBy(static fn (object $row): string => (string) $row->main_order_no); + + foreach ($rows as &$row) { + $orderNumber = (string) ($row['order_no'] ?? ''); + $row['sub_orders'] = $subOrders->get($orderNumber, collect()) + ->map(static fn (object $subOrder): array => (array) $subOrder) + ->values() + ->all(); + $row['form_values'] = $formValues->get($orderNumber, collect()) + ->map(static fn (object $value): array => (array) $value) + ->values() + ->all(); + } + unset($row); + } + + private function contains(string $value): string + { + return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%'; + } + + private function dateValue(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + return $value instanceof \DateTimeInterface + ? $value->format('Y-m-d H:i:s') + : (string) $value; + } +} diff --git a/modules/EcommerceFinance/Routes/api.php b/modules/EcommerceFinance/Routes/api.php index 5ab5e98d..c4894aa2 100644 --- a/modules/EcommerceFinance/Routes/api.php +++ b/modules/EcommerceFinance/Routes/api.php @@ -9,6 +9,9 @@ Route::api('ecommerce-finance', function () { Route::apiResource('import-batches', 'ImportBatches')->only(['index', 'show', 'store']); Route::get('reconciliation-exceptions', 'ReconciliationExceptions@index')->name('reconciliation-exceptions.index'); + // 聚水潭聚工单 - 协同工单 - 仓内未发 + Route::get('work-orders', 'WorkOrders@index')->name('work-orders.index'); + // 货款对账 Route::get('goods-reconciliations', 'GoodsReconciliations@index')->name('goods-reconciliations.index'); Route::get('goods-reconciliations/summary', 'GoodsReconciliations@summary')->name('goods-reconciliations.summary'); diff --git a/tests/Feature/Business/WorkOrderApiTest.php b/tests/Feature/Business/WorkOrderApiTest.php new file mode 100644 index 00000000..7959070e --- /dev/null +++ b/tests/Feature/Business/WorkOrderApiTest.php @@ -0,0 +1,73 @@ +shouldReceive('paginate') + ->once() + ->with(2, 10, ['order_no' => 'WO']) + ->andReturn([ + 'data' => [['id' => 9, 'order_no' => 'WO-009', 'work_type_name' => '仓内未发', 'sub_orders' => [], 'form_values' => []]], + 'total' => 11, + 'page' => 2, + 'per_page' => 10, + 'last_page' => 2, + 'ready' => true, + 'sync_status' => ['ready' => true, 'code' => 'ready', 'last_succeeded_at' => '2026-08-31 12:00:00', 'datasets' => []], + ]); + $this->app->instance(WorkOrderMirrorRepository::class, $repository); + + $this->loginAs(); + $this->withHeaders($this->apiHeaders())->getJson(route('api.ecommerce-finance.work-orders.index', [ + 'page' => 2, + 'limit' => 10, + 'order_no' => 'WO', + ])) + ->assertOk() + ->assertJsonPath('data.0.work_type_name', '仓内未发') + ->assertJsonPath('page', 2) + ->assertJsonPath('per_page', 10); + } + + public function test_index_exposes_sync_pending_as_503_instead_of_zero_records(): void + { + $repository = Mockery::mock(WorkOrderMirrorRepository::class); + $repository->shouldReceive('paginate')->once()->andReturn([ + 'data' => [], + 'total' => 0, + 'page' => 1, + 'per_page' => 25, + 'last_page' => 1, + 'ready' => false, + 'sync_status' => [ + 'ready' => false, + 'code' => 'initial_sync_pending', + 'last_succeeded_at' => null, + 'datasets' => [], + ], + ]); + $this->app->instance(WorkOrderMirrorRepository::class, $repository); + + $this->loginAs(); + $this->withHeaders($this->apiHeaders())->getJson(route('api.ecommerce-finance.work-orders.index')) + ->assertStatus(503) + ->assertJsonPath('ready', false) + ->assertJsonPath('sync_status.code', 'initial_sync_pending') + ->assertJsonPath('total', 0); + } + + private function apiHeaders(): array + { + return [ + 'Authorization' => 'Basic ' . base64_encode('test@company.com:123456'), + ]; + } +} diff --git a/tests/Unit/EcommerceFinance/WorkOrderMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/WorkOrderMirrorRepositoryTest.php new file mode 100644 index 00000000..706e37f0 --- /dev/null +++ b/tests/Unit/EcommerceFinance/WorkOrderMirrorRepositoryTest.php @@ -0,0 +1,157 @@ + [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + 'upstream_mirrors.datasets' => [ + WorkOrderMirrorRepository::MAIN_DATASET => $this->definition('work_order_main', 'fixture_work_order_main', ['id'], 'update_time'), + WorkOrderMirrorRepository::SUB_DATASET => $this->definition('work_order_sub', 'fixture_work_order_sub', ['id'], 'update_time'), + WorkOrderMirrorRepository::CONFIG_DATASET => $this->definition('work_order_form_config', 'fixture_work_order_config', ['id'], 'update_time'), + WorkOrderMirrorRepository::VALUE_DATASET => $this->definition('work_order_form_value', 'fixture_work_order_values', ['id'], 'create_time'), + ], + ]); + DB::purge('business_mirror'); + + $schema = Schema::connection('business_mirror'); + $schema->create('business_mirror_table_states', function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('status')->nullable(); + $table->dateTime('initial_completed_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + }); + $this->createTables($schema); + + foreach (array_keys(config('upstream_mirrors.datasets')) as $key) { + DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ + 'dataset_key' => $key, + 'status' => 'succeeded', + 'initial_completed_at' => '2026-08-01 00:00:00', + 'last_succeeded_at' => '2026-08-31 12:00:00', + ]); + } + + DB::connection('business_mirror')->table('fixture_work_order_config')->insert([ + ['id' => 1, 'work_type' => 'WAREHOUSE', 'work_type_name' => '仓内未发', 'group_name' => '协同工单', 'is_enabled' => 1], + ['id' => 2, 'work_type' => 'OTHER', 'work_type_name' => '其他', 'group_name' => '其他', 'is_enabled' => 1], + ]); + DB::connection('business_mirror')->table('fixture_work_order_main')->insert([ + ['id' => 1, 'work_type' => 'WAREHOUSE', 'order_no' => 'WO-001', 'title' => '仓内未发一', 'status' => '待处理', 'create_user' => '张三', 'create_time' => '2026-08-20 10:00:00', 'update_time' => '2026-08-20 10:00:00', 'is_deleted' => 0], + ['id' => 2, 'work_type' => 'WAREHOUSE', 'order_no' => 'WO-002', 'title' => '仓内未发二', 'status' => '处理中', 'create_user' => '李四', 'create_time' => '2026-08-21 10:00:00', 'update_time' => '2026-08-21 10:00:00', 'is_deleted' => 0], + ['id' => 3, 'work_type' => 'OTHER', 'order_no' => 'WO-003', 'title' => '其他类型', 'status' => '待处理', 'create_user' => '张三', 'create_time' => '2026-08-22 10:00:00', 'update_time' => '2026-08-22 10:00:00', 'is_deleted' => 0], + ['id' => 4, 'work_type' => 'WAREHOUSE', 'order_no' => 'WO-004', 'title' => '已删除', 'status' => '待处理', 'create_user' => '张三', 'create_time' => '2026-08-23 10:00:00', 'update_time' => '2026-08-23 10:00:00', 'is_deleted' => 1], + ]); + DB::connection('business_mirror')->table('fixture_work_order_sub')->insert([ + ['id' => 1, 'sub_order_no' => 'SUB-001', 'main_order_no' => 'WO-002', 'title' => '拣货任务', 'assign_user' => '王五', 'status' => '执行中', 'is_deleted' => 0], + ['id' => 2, 'sub_order_no' => 'SUB-002', 'main_order_no' => 'WO-002', 'title' => '已删除任务', 'assign_user' => '赵六', 'status' => '已取消', 'is_deleted' => 1], + ]); + DB::connection('business_mirror')->table('fixture_work_order_values')->insert([ + ['id' => 1, 'main_order_no' => 'WO-002', 'field_code' => 'reason', 'field_name' => '未发原因', 'field_value' => '缺货', 'field_type' => 'text', 'sort' => 1, 'is_deleted' => 0], + ['id' => 2, 'main_order_no' => 'WO-002', 'field_code' => 'secret', 'field_name' => '删除字段', 'field_value' => '不应显示', 'field_type' => 'text', 'sort' => 2, 'is_deleted' => 1], + ]); + + $this->repository = new WorkOrderMirrorRepository( + new BusinessMirrorDatasetRegistry(), + new GenericBusinessMirrorRepository(app('db')), + ); + } + + public function test_default_type_delete_filter_pagination_and_related_rows(): void + { + $page = $this->repository->paginate(1, 1); + + $this->assertTrue($page['ready']); + $this->assertSame(2, $page['total']); + $this->assertSame('WO-002', $page['data'][0]['order_no']); + $this->assertCount(1, $page['data'][0]['sub_orders']); + $this->assertSame('SUB-001', $page['data'][0]['sub_orders'][0]['sub_order_no']); + $this->assertCount(1, $page['data'][0]['form_values']); + $this->assertSame('缺货', $page['data'][0]['form_values'][0]['field_value']); + + $filtered = $this->repository->paginate(1, 50, ['assign_user' => '王五']); + $this->assertSame(1, $filtered['total']); + $this->assertSame('WO-002', $filtered['data'][0]['order_no']); + + $secondPage = $this->repository->paginate(2, 1); + $this->assertSame('WO-001', $secondPage['data'][0]['order_no']); + } + + public function test_unready_state_is_explicit_and_never_looks_like_empty_data(): void + { + DB::connection('business_mirror')->table('business_mirror_table_states') + ->where('dataset_key', WorkOrderMirrorRepository::SUB_DATASET) + ->update(['initial_completed_at' => null, 'status' => 'running']); + + $result = $this->repository->paginate(1, 25); + + $this->assertFalse($result['ready']); + $this->assertSame([], $result['data']); + $this->assertSame('initial_sync_pending', $result['sync_status']['code']); + $this->assertFalse($result['sync_status']['datasets']['sub']['ready']); + } + + private function definition(string $object, string $target, array $keys, ?string $watermark): array + { + return [ + 'source_key' => 'operations_replica', + 'schema' => 'rpt', + 'object' => $object, + 'target' => $target, + 'keys' => $keys, + 'watermark' => $watermark, + ]; + } + + private function createTables($schema): void + { + $schema->create('fixture_work_order_main', function (Blueprint $table): void { + $table->integer('id'); + foreach (['work_type', 'order_no', 'title', 'status', 'create_user', 'expect_minute', 'submit_time', 'expect_end_time', 'end_time', 'score', 'update_user', 'description', 'create_time', 'update_time'] as $column) { + $table->string($column)->nullable(); + } + $table->integer('is_deleted')->nullable(); + }); + $schema->create('fixture_work_order_sub', function (Blueprint $table): void { + $table->integer('id'); + foreach (['sub_order_no', 'main_order_no', 'title', 'assign_user', 'status', 'expect_minute', 'submit_time', 'expect_end_time', 'finish_time', 'content', 'notify_submit', 'notify_soon', 'notify_overdue', 'overdue_notify_count', 'create_time', 'update_time', 'taskid'] as $column) { + $table->string($column)->nullable(); + } + $table->integer('is_deleted')->nullable(); + }); + $schema->create('fixture_work_order_config', function (Blueprint $table): void { + $table->integer('id'); + foreach (['work_type', 'work_type_name', 'group_name', 'icon', 'icon_color', 'time_limit_options', 'form_fields', 'sort', 'remark', 'create_time', 'update_time', 'timeout_reminder_count'] as $column) { + $table->string($column)->nullable(); + } + $table->integer('is_enabled')->nullable(); + }); + $schema->create('fixture_work_order_values', function (Blueprint $table): void { + $table->integer('id'); + foreach (['main_order_no', 'field_code', 'field_name', 'field_value', 'field_type', 'sort', 'create_time'] as $column) { + $table->string($column)->nullable(); + } + $table->integer('is_deleted')->nullable(); + }); + } +}