Merge remote-tracking branch 'origin/feature/limeng-jushuitan-work-orders' into feature/WuYongJie-integrate-remote-20260901

This commit is contained in:
WuYongJie 2026-09-01 15:31:45 +08:00
commit b0507880ed
7 changed files with 638 additions and 0 deletions

View File

@ -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'],

View File

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

View File

@ -0,0 +1,57 @@
<?php
namespace Modules\EcommerceFinance\Http\Controllers\Api;
use App\Abstracts\Http\ApiController;
use Illuminate\Http\JsonResponse;
use Modules\EcommerceFinance\Repositories\WorkOrderMirrorRepository;
class WorkOrders extends ApiController
{
public function __construct(private readonly WorkOrderMirrorRepository $repository)
{
}
public function index(): JsonResponse
{
$filters = request()->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);
}
}

View File

@ -0,0 +1,261 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Throwable;
/** 只读聚水潭协同工单镜像;HTTP 请求不得回退直连上游。 */
class WorkOrderMirrorRepository
{
public const DEFAULT_WORK_TYPE_NAME = '仓内未发';
public const MAIN_DATASET = 'operations_replica.rpt.work_order_main';
public const SUB_DATASET = 'operations_replica.rpt.work_order_sub';
public const CONFIG_DATASET = 'operations_replica.rpt.work_order_form_config';
public const VALUE_DATASET = 'operations_replica.rpt.work_order_form_value';
private const DATASETS = [
'main' => 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<string,array{dataset:string,ready:bool,status:string,last_succeeded_at:?string}>
* }
*/
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<string,string> $filters
* @return array{data:list<array<string,mixed>>,total:int,page:int,per_page:int,last_page:int,ready:bool,sync_status:array<string,mixed>}
*/
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<string,string> $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<array<string,mixed>> $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;
}
}

View File

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

View File

@ -0,0 +1,73 @@
<?php
namespace Tests\Feature\Business;
use Mockery;
use Modules\EcommerceFinance\Repositories\WorkOrderMirrorRepository;
use Tests\Feature\FeatureTestCase;
class WorkOrderApiTest extends FeatureTestCase
{
public function test_index_uses_warehouse_unshipped_filter_and_returns_paged_rows(): void
{
$repository = Mockery::mock(WorkOrderMirrorRepository::class);
$repository->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'),
];
}
}

View File

@ -0,0 +1,157 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\WorkOrderMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class WorkOrderMirrorRepositoryTest extends TestCase
{
private WorkOrderMirrorRepository $repository;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'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();
});
}
}