Merge remote-tracking branch 'origin/feature/limeng-jushuitan-work-orders' into feature/WuYongJie-integrate-remote-20260901
This commit is contained in:
commit
b0507880ed
@ -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'],
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
57
modules/EcommerceFinance/Http/Controllers/Api/WorkOrders.php
Normal file
57
modules/EcommerceFinance/Http/Controllers/Api/WorkOrders.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
|
||||
73
tests/Feature/Business/WorkOrderApiTest.php
Normal file
73
tests/Feature/Business/WorkOrderApiTest.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
157
tests/Unit/EcommerceFinance/WorkOrderMirrorRepositoryTest.php
Normal file
157
tests/Unit/EcommerceFinance/WorkOrderMirrorRepositoryTest.php
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user