feat: 增加OA已等待区间筛选 (#793)

以已等待时长替代进入时间日期筛选,支持不足1小时至2天以上区间。
This commit is contained in:
yueyang 2026-10-30 16:58:18 +08:00
parent 7484b25e46
commit a67d458310
7 changed files with 115 additions and 98 deletions

View File

@ -63,6 +63,7 @@ class ProcessDetails extends Controller
'statuses' => $options['statuses'],
'paymentStates' => $options['payment_states'],
'currentApprovers' => $options['current_approvers'] ?? [],
'waitingBuckets' => DingProcessDetailMirrorRepository::WAITING_BUCKET_LABELS,
'mirrorStatus' => $result['status'],
'mirrorSyncedAt' => $result['last_synced_at'],
'mirrorStale' => $result['sync_stale'] ?? false,
@ -91,6 +92,7 @@ class ProcessDetails extends Controller
'statuses' => [],
'paymentStates' => [],
'currentApprovers' => [],
'waitingBuckets' => DingProcessDetailMirrorRepository::WAITING_BUCKET_LABELS,
'mirrorStatus' => 'unavailable',
'mirrorSyncedAt' => null,
'mirrorStale' => false,

View File

@ -22,6 +22,14 @@ class DingProcessDetailMirrorRepository
'不涉及付款',
];
public const WAITING_BUCKET_LABELS = [
'lt_1h' => '不足1小时',
'1_12h' => '1–12小时',
'12_24h' => '12–24小时',
'1_2d' => '1–2天',
'gte_2d' => '2天以上',
];
public const LEGACY_PAYMENT_STATE = '未确认';
public const LEGACY_PAYMENT_STATE_MAP = [
@ -78,6 +86,13 @@ class DingProcessDetailMirrorRepository
return self::LEGACY_STATUS_MAP[$status] ?? $status;
}
public static function normalizeWaitingBucketFilter(mixed $value): string
{
$bucket = trim((string) $value);
return array_key_exists($bucket, self::WAITING_BUCKET_LABELS) ? $bucket : '';
}
public function datasetKey(): string
{
return self::DATASET_PREFIX . $this->scope();
@ -384,26 +399,31 @@ class DingProcessDetailMirrorRepository
);
}
$entryStart = $this->filterDate($filters['entry_start'] ?? '');
$entryEnd = $this->filterDate($filters['entry_end'] ?? '');
if ($entryStart !== '' && $entryEnd !== '' && $entryStart > $entryEnd) {
[$entryStart, $entryEnd] = [$entryEnd, $entryStart];
}
if ($entryStart !== '') {
$query->where(
'ding_process_detail_mirror.current_task_started_at',
'>=',
$entryStart . ' 00:00:00',
);
}
if ($entryEnd !== '') {
$query->where(
'ding_process_detail_mirror.current_task_started_at',
'<',
Carbon::createFromFormat('Y-m-d', $entryEnd)
->addDay()
->format('Y-m-d 00:00:00'),
);
$waitingBucket = self::normalizeWaitingBucketFilter($filters['waiting_bucket'] ?? '');
if ($waitingBucket !== '') {
$now = OaProcessTime::now();
$column = 'ding_process_detail_mirror.current_task_started_at';
$query
->whereNotNull($column)
->where($column, '<=', $now->format('Y-m-d H:i:s'));
if ($waitingBucket === 'lt_1h') {
$query->where($column, '>', $now->subHour()->format('Y-m-d H:i:s'));
} elseif ($waitingBucket === '1_12h') {
$query
->where($column, '>', $now->subHours(12)->format('Y-m-d H:i:s'))
->where($column, '<=', $now->subHour()->format('Y-m-d H:i:s'));
} elseif ($waitingBucket === '12_24h') {
$query
->where($column, '>', $now->subHours(24)->format('Y-m-d H:i:s'))
->where($column, '<=', $now->subHours(12)->format('Y-m-d H:i:s'));
} elseif ($waitingBucket === '1_2d') {
$query
->where($column, '>', $now->subHours(48)->format('Y-m-d H:i:s'))
->where($column, '<=', $now->subHours(24)->format('Y-m-d H:i:s'));
} else {
$query->where($column, '<=', $now->subHours(48)->format('Y-m-d H:i:s'));
}
}
foreach ([

View File

@ -89,6 +89,7 @@ class ProcessDetailsPageData
'statuses' => $options['statuses'],
'paymentStates' => $options['payment_states'],
'currentApprovers' => $options['current_approvers'] ?? [],
'waitingBuckets' => DingProcessDetailMirrorRepository::WAITING_BUCKET_LABELS,
'mirrorStatus' => $result['status'],
'mirrorSyncedAt' => $result['last_synced_at'],
'mirrorStale' => $result['sync_stale'] ?? false,
@ -115,6 +116,7 @@ class ProcessDetailsPageData
'statuses' => [],
'paymentStates' => [],
'currentApprovers' => [],
'waitingBuckets' => DingProcessDetailMirrorRepository::WAITING_BUCKET_LABELS,
'mirrorStatus' => 'unavailable',
'mirrorSyncedAt' => null,
'mirrorStale' => false,
@ -136,13 +138,12 @@ class ProcessDetailsPageData
$request->query('paid_end'),
$request->query('paid_date'),
);
[$entryStart, $entryEnd] = self::normalizeDateRange(
$request->query('entry_start'),
$request->query('entry_end'),
);
$paymentState = DingProcessDetailMirrorRepository::normalizePaymentStateFilter(
$request->query('payment_state'),
);
$waitingBucket = DingProcessDetailMirrorRepository::normalizeWaitingBucketFilter(
$request->query('waiting_bucket'),
);
return [
'paid_start' => $paidStart,
@ -153,8 +154,7 @@ class ProcessDetailsPageData
'business_id' => mb_substr(trim((string) $request->query('business_id', '')), 0, 32),
'originator_name' => mb_substr(trim((string) $request->query('originator_name', '')), 0, 100),
'current_approver_name' => mb_substr(trim((string) $request->query('current_approver_name', '')), 0, 100),
'entry_start' => $entryStart,
'entry_end' => $entryEnd,
'waiting_bucket' => $waitingBucket,
'keyword' => mb_substr(trim((string) $request->query('keyword', '')), 0, 100),
];
}

View File

@ -4,6 +4,7 @@
$documentTypes ??= [];
$statuses ??= [];
$currentApprovers ??= [];
$waitingBuckets ??= [];
$error ??= null;
$paymentStates = $paymentStates ?? ($pageData['paymentStates'] ?? []);
$mirrorStale = $mirrorStale ?? ($pageData['mirrorStale'] ?? false);
@ -203,6 +204,10 @@
grid-column: span 5;
}
.process-details-field.is-waiting {
grid-column: span 5;
}
.process-details-field.is-business {
grid-column: span 3;
}
@ -799,6 +804,7 @@
}
.process-details-field.is-date-range,
.process-details-field.is-waiting,
.process-details-field.is-keyword,
.process-details-actions {
grid-column: span 6;
@ -848,6 +854,7 @@
.process-details-field.is-business,
.process-details-field.is-approver,
.process-details-field.is-date-range,
.process-details-field.is-waiting,
.process-details-field.is-keyword,
.process-details-actions {
grid-column: 1;
@ -1063,51 +1070,16 @@
</span>
</div>
</div>
<div class="process-details-field is-date-range">
<label for="process-details-entry-start">进入时间</label>
<div class="upstream-data-date-range" role="group" aria-label="审批进入开始至结束时间">
<span class="upstream-data-date" data-date-picker>
<input
id="process-details-entry-start"
type="text"
name="entry_start"
value="{{ $filters['entry_start'] ?? '' }}"
readonly
autocomplete="off"
placeholder="开始日期"
aria-label="审批进入开始日期"
data-date-input
>
<button type="button" class="upstream-data-date__clear" data-date-clear hidden aria-label="清除审批进入开始日期">
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
<button type="button" class="upstream-data-date__pick" data-date-toggle aria-label="选择审批进入开始日期">
<span class="material-icons-outlined" aria-hidden="true">calendar_today</span>
</button>
<div class="upstream-data-calendar" role="dialog" aria-label="审批进入开始日期日历" data-date-calendar hidden></div>
</span>
<b class="upstream-data-date-range__sep">至</b>
<span class="upstream-data-date" data-date-picker>
<input
id="process-details-entry-end"
type="text"
name="entry_end"
value="{{ $filters['entry_end'] ?? '' }}"
readonly
autocomplete="off"
placeholder="结束日期"
aria-label="审批进入结束日期"
data-date-input
>
<button type="button" class="upstream-data-date__clear" data-date-clear hidden aria-label="清除审批进入结束日期">
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
<button type="button" class="upstream-data-date__pick" data-date-toggle aria-label="选择审批进入结束日期">
<span class="material-icons-outlined" aria-hidden="true">calendar_today</span>
</button>
<div class="upstream-data-calendar" role="dialog" aria-label="审批进入结束日期日历" data-date-calendar hidden></div>
</span>
</div>
<div class="process-details-field is-waiting">
<label for="process-details-waiting-bucket">已等待</label>
<select id="process-details-waiting-bucket" name="waiting_bucket">
<option value="">全部时长</option>
@foreach ($waitingBuckets as $bucket => $label)
<option value="{{ $bucket }}" @selected(($filters['waiting_bucket'] ?? '') === $bucket)>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div class="process-details-field">
<label for="process-details-originator">发起人</label>

View File

@ -89,8 +89,7 @@ class ProcessDetailsPageTest extends FeatureTestCase
'per_page' => 100,
'payment_state' => '待付款',
'current_approver_name' => '王五',
'entry_start' => '2026-10-20',
'entry_end' => '2026-10-30',
'waiting_bucket' => '1_2d',
]))->assertOk()
->assertSee('流程数据')
->assertSee('OA流程')
@ -106,15 +105,15 @@ class ProcessDetailsPageTest extends FeatureTestCase
->assertSee('role="combobox"', false)
->assertSee('placeholder="输入姓名或展开选择"', false)
->assertSee('data-value="赵六"', false)
->assertSee('name="entry_start"', false)
->assertSee('name="entry_end"', false)
->assertSee('name="waiting_bucket"', false)
->assertSee('value="1_2d" selected', false)
->assertSee('2天以上', false)
->assertDontSee('name="entry_start"', false)
->assertDontSee('name="entry_end"', false)
->assertSee('placeholder="开始日期"', false)
->assertSee('placeholder="结算日期"', false)
->assertSee('placeholder="结束日期"', false)
->assertSee('aria-label="选择打款开始日期"', false)
->assertSee('aria-label="选择打款结算日期"', false)
->assertSee('aria-label="选择审批进入开始日期"', false)
->assertSee('aria-label="选择审批进入结束日期"', false)
->assertSee('CaiWuDatePicker.setup', false)
->assertSee('class="process-details-filter-grid"', false)
->assertSee('class="process-details-note-disclosure"', false)

View File

@ -197,20 +197,43 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
2,
$this->repository->paginate(['current_approver_name' => '王五'], 1, 50)['total'],
);
$this->assertSame(
2,
$this->repository->paginate([
'entry_start' => '2026-02-01',
'entry_end' => '2026-02-28',
], 1, 50)['total'],
);
$this->assertSame(
2,
$this->repository->paginate([
'entry_start' => '2026-02-28',
'entry_end' => '2026-02-01',
], 1, 50)['total'],
);
}
public function test_waiting_bucket_filter_uses_real_elapsed_hours(): void
{
try {
Carbon::setTestNow(Carbon::parse('2026-02-03 10:00:00', 'Asia/Shanghai'));
$this->assertSame(
2,
$this->repository->paginate(['waiting_bucket' => '1_12h'], 1, 50)['total'],
);
$this->assertSame(
2,
$this->repository->paginate(['waiting_bucket' => 'gte_2d'], 1, 50)['total'],
);
Carbon::setTestNow(Carbon::parse('2026-01-02 21:00:00', 'Asia/Shanghai'));
$this->assertSame(
2,
$this->repository->paginate(['waiting_bucket' => '12_24h'], 1, 50)['total'],
);
$this->assertSame(
0,
$this->repository->paginate(['waiting_bucket' => '1_12h'], 1, 50)['total'],
);
Carbon::setTestNow(Carbon::parse('2026-01-02 09:30:00', 'Asia/Shanghai'));
$this->assertSame(
2,
$this->repository->paginate(['waiting_bucket' => 'lt_1h'], 1, 50)['total'],
);
$this->assertSame(
'',
DingProcessDetailMirrorRepository::normalizeWaitingBucketFilter('invalid'),
);
} finally {
Carbon::setTestNow();
}
}
public function test_pending_cashier_status_without_payment_facts_is_waiting_for_payment(): void

View File

@ -53,8 +53,7 @@ class ProcessDetailsPageDataTest extends TestCase
&& $filters['paid_end'] === '2026-10-24'
&& $filters['payment_state'] === '已付款'
&& $filters['current_approver_name'] === '王五'
&& $filters['entry_start'] === '2026-10-21'
&& $filters['entry_end'] === '2026-10-25'
&& $filters['waiting_bucket'] === '12_24h'
), 2, 50)
->willReturn([
'data' => [$row],
@ -92,8 +91,7 @@ class ProcessDetailsPageDataTest extends TestCase
'paid_end' => '2026-10-24',
'payment_state' => '出纳已打款',
'current_approver_name' => '王五',
'entry_start' => '2026-10-21',
'entry_end' => '2026-10-25',
'waiting_bucket' => '12_24h',
'page' => 2,
],
));
@ -109,8 +107,7 @@ class ProcessDetailsPageDataTest extends TestCase
$this->assertSame('2026-10-24', $data['filters']['paid_end']);
$this->assertSame('已付款', $data['filters']['payment_state']);
$this->assertSame('王五', $data['filters']['current_approver_name']);
$this->assertSame('2026-10-21', $data['filters']['entry_start']);
$this->assertSame('2026-10-25', $data['filters']['entry_end']);
$this->assertSame('12_24h', $data['filters']['waiting_bucket']);
$this->assertSame(
[
'待付款',
@ -120,6 +117,10 @@ class ProcessDetailsPageDataTest extends TestCase
$data['paymentStates'],
);
$this->assertSame(['王五', '赵六'], $data['currentApprovers']);
$this->assertSame(
DingProcessDetailMirrorRepository::WAITING_BUCKET_LABELS,
$data['waitingBuckets'],
);
$this->assertTrue($data['mirrorStale']);
$this->assertSame(15, $data['mirrorStaleAfterMinutes']);
$this->assertArrayNotHasKey('months', $data);