feat(approval): Approval Workflow v2 Phase 4 monitoring + pending-review badges
Approval Workflow v2 — Phase 4: monitoring + UX visibility.
Backend metrics (Micrometer → Prometheus):
- Gauge nsi_draft_pending_total — global queue depth (polled на scrape).
- Counter nsi_draft_submit_total{dictionary,operation,outcome=ok|already_pending|error}
— submit attempts с разбивкой по outcome. Phase 3 lessons: error outcome
выявит schema bugs которые ранее маскировались.
- Counter nsi_draft_decision_total{dictionary,operation,outcome=approved|rejected|withdrawn}
— counts decisions, разбит по типу.
- Timer nsi_draft_review_duration_seconds — time-to-decide histogram (от
submit до approve/reject/withdraw). publishPercentileHistogram() — для
Prometheus histogram_quantile p95/p99.
DraftService:
- Optional<MeterRegistry> — null-safe в test ctor'ах.
- @PostConstruct registerGauges() — Gauge.builder polled от
RecordDraftRepository.countPending().
- recordDecision() helper в approve/reject/withdraw — записывает Timer +
Counter одной точкой.
- 4-arg ctor compat для Phase 1 unit tests.
UI (admin):
- useDictPendingDrafts — per-dict pending list, refetch 30s, gated на
detailQuery.data.approvalRequired (no-op для не-approval dicts).
- "На review" badge в businessKey cell records table — моментальная
видимость что есть pending draft. Open /reviews для act'а.
- i18n RU/EN: dict.pendingReview.label + tooltip.
Domain:
- RecordDraftRepository.countPending() — для Gauge polled count.
Tests: ordinis-rest-api unit 119/119, ordinis-app e2e 28/28, vitest 89/89.
This commit is contained in:
@@ -408,6 +408,28 @@ export const reviewQueueQuery = (page = 0, size = 50) =>
|
|||||||
export const useReviewQueue = (page = 0, size = 50) =>
|
export const useReviewQueue = (page = 0, size = 50) =>
|
||||||
useQuery(reviewQueueQuery(page, size))
|
useQuery(reviewQueueQuery(page, size))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending drafts на конкретный dict — для бейджа "На review" в records list.
|
||||||
|
* Лёгкий poll каждые 30s. Backend возвращает только PENDING (фильтрация в SQL).
|
||||||
|
*/
|
||||||
|
export const dictPendingDraftsQuery = (dictName: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['drafts', 'by-dict', dictName] as const,
|
||||||
|
queryFn: async (): Promise<DraftResponse[]> => {
|
||||||
|
const { data } = await apiClient.get<DraftResponse[]>(
|
||||||
|
`/dictionaries/${encodeURIComponent(dictName)}/records/drafts`,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useDictPendingDrafts = (dictName: string | undefined) =>
|
||||||
|
useQuery({
|
||||||
|
...dictPendingDraftsQuery(dictName ?? ''),
|
||||||
|
enabled: Boolean(dictName),
|
||||||
|
})
|
||||||
|
|
||||||
/** Single draft by id — for diff viewer drawer. */
|
/** Single draft by id — for diff viewer drawer. */
|
||||||
export const draftQuery = (id: string) =>
|
export const draftQuery = (id: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ i18n
|
|||||||
'dict.col.validFrom': 'Действует с',
|
'dict.col.validFrom': 'Действует с',
|
||||||
'dict.col.locale': 'Локаль',
|
'dict.col.locale': 'Локаль',
|
||||||
'dict.col.actions': 'Действия',
|
'dict.col.actions': 'Действия',
|
||||||
|
'dict.pendingReview.label': 'На review',
|
||||||
|
'dict.pendingReview.tooltip': 'Есть pending draft на эту запись. Откройте /reviews для approve/reject.',
|
||||||
'dict.list.records': 'записей',
|
'dict.list.records': 'записей',
|
||||||
'dict.action.create': 'Создать запись',
|
'dict.action.create': 'Создать запись',
|
||||||
'dict.action.edit': 'Изменить',
|
'dict.action.edit': 'Изменить',
|
||||||
@@ -577,6 +579,8 @@ i18n
|
|||||||
'dict.col.validFrom': 'Valid from',
|
'dict.col.validFrom': 'Valid from',
|
||||||
'dict.col.locale': 'Locale',
|
'dict.col.locale': 'Locale',
|
||||||
'dict.col.actions': 'Actions',
|
'dict.col.actions': 'Actions',
|
||||||
|
'dict.pendingReview.label': 'Pending review',
|
||||||
|
'dict.pendingReview.tooltip': 'A draft is pending for this record. Open /reviews to approve/reject.',
|
||||||
'dict.list.records': 'records',
|
'dict.list.records': 'records',
|
||||||
'dict.action.create': 'Create record',
|
'dict.action.create': 'Create record',
|
||||||
'dict.action.edit': 'Edit',
|
'dict.action.edit': 'Edit',
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
} from '@nstart/ui'
|
} from '@nstart/ui'
|
||||||
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, DownloadSimpleIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
|
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, DownloadSimpleIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
|
||||||
import { useDictionaryDetail, useRecordRaw, useRecords, type RecordsFilter } from '@/api/queries'
|
import {
|
||||||
|
useDictionaryDetail,
|
||||||
|
useDictPendingDrafts,
|
||||||
|
useRecordRaw,
|
||||||
|
useRecords,
|
||||||
|
type RecordsFilter,
|
||||||
|
} from '@/api/queries'
|
||||||
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
|
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
|
||||||
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
|
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
|
||||||
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
||||||
@@ -117,6 +123,19 @@ function DictionaryDetail() {
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter)
|
const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter)
|
||||||
|
/**
|
||||||
|
* Approval Workflow v2 Phase 4: pending drafts на этот dict — для бейджа
|
||||||
|
* "На review" в каждой строке records table. Polled 30s. Bypass'нем если
|
||||||
|
* dict не approval-required (нет смысла грузить).
|
||||||
|
*/
|
||||||
|
const pendingDraftsQuery = useDictPendingDrafts(
|
||||||
|
detailQuery.data?.approvalRequired ? name : undefined,
|
||||||
|
)
|
||||||
|
const pendingByKey = useMemo(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const d of pendingDraftsQuery.data ?? []) set.add(d.businessKey)
|
||||||
|
return set
|
||||||
|
}, [pendingDraftsQuery.data])
|
||||||
const createMut = useCreateRecord(name)
|
const createMut = useCreateRecord(name)
|
||||||
const updateMut = useUpdateRecord(name)
|
const updateMut = useUpdateRecord(name)
|
||||||
const closeMut = useCloseRecord(name)
|
const closeMut = useCloseRecord(name)
|
||||||
@@ -672,7 +691,14 @@ function DictionaryDetail() {
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-mono text-2xs">{r.businessKey}</span>
|
<span className="font-mono text-2xs">{r.businessKey}</span>
|
||||||
|
{pendingByKey.has(r.businessKey) ? (
|
||||||
|
<span title={t('dict.pendingReview.tooltip')}>
|
||||||
|
<Badge variant="warning">{t('dict.pendingReview.label')}</Badge>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{recordDisplayName(
|
{recordDisplayName(
|
||||||
|
|||||||
+7
@@ -47,4 +47,11 @@ public interface RecordDraftRepository extends JpaRepository<RecordDraft, UUID>
|
|||||||
ORDER BY d.submittedAt DESC
|
ORDER BY d.submittedAt DESC
|
||||||
""")
|
""")
|
||||||
Page<RecordDraft> findByMaker(@Param("makerId") String makerId, Pageable pageable);
|
Page<RecordDraft> findByMaker(@Param("makerId") String makerId, Pageable pageable);
|
||||||
|
|
||||||
|
/** Global pending queue depth для Micrometer Gauge (Phase 4 monitoring). */
|
||||||
|
@Query("""
|
||||||
|
SELECT COUNT(d) FROM RecordDraft d
|
||||||
|
WHERE d.status = cloud.nstart.terravault.ordinis.domain.draft.DraftStatus.PENDING
|
||||||
|
""")
|
||||||
|
long countPending();
|
||||||
}
|
}
|
||||||
|
|||||||
+90
-6
@@ -11,6 +11,11 @@ import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest;
|
|||||||
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
|
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
|
||||||
|
import io.micrometer.core.instrument.Gauge;
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
|
import io.micrometer.core.instrument.Tags;
|
||||||
|
import io.micrometer.core.instrument.Timer;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
import org.locationtech.jts.geom.Geometry;
|
import org.locationtech.jts.geom.Geometry;
|
||||||
import org.locationtech.jts.io.ParseException;
|
import org.locationtech.jts.io.ParseException;
|
||||||
import org.locationtech.jts.io.WKTReader;
|
import org.locationtech.jts.io.WKTReader;
|
||||||
@@ -24,8 +29,10 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Isolation;
|
import org.springframework.transaction.annotation.Isolation;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,25 +60,51 @@ public class DraftService {
|
|||||||
private final DictionaryDefinitionService definitionService;
|
private final DictionaryDefinitionService definitionService;
|
||||||
private final DictionaryRecordService recordService;
|
private final DictionaryRecordService recordService;
|
||||||
private final ScopeContext scopeContext;
|
private final ScopeContext scopeContext;
|
||||||
|
/** Phase 4 monitoring. Optional — null в test ctor'ах (no metrics in unit tests). */
|
||||||
|
private final Optional<MeterRegistry> meterRegistry;
|
||||||
|
|
||||||
@org.springframework.beans.factory.annotation.Autowired
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
public DraftService(
|
public DraftService(
|
||||||
RecordDraftRepository draftRepo,
|
RecordDraftRepository draftRepo,
|
||||||
DictionaryDefinitionService definitionService,
|
DictionaryDefinitionService definitionService,
|
||||||
DictionaryRecordService recordService,
|
DictionaryRecordService recordService,
|
||||||
ScopeContext scopeContext) {
|
ScopeContext scopeContext,
|
||||||
|
Optional<MeterRegistry> meterRegistry) {
|
||||||
this.draftRepo = draftRepo;
|
this.draftRepo = draftRepo;
|
||||||
this.definitionService = definitionService;
|
this.definitionService = definitionService;
|
||||||
this.recordService = recordService;
|
this.recordService = recordService;
|
||||||
this.scopeContext = scopeContext;
|
this.scopeContext = scopeContext;
|
||||||
|
this.meterRegistry = meterRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test-friendly ctor — без recordService для unit tests submit/list flow. */
|
/** Test-friendly ctor — без recordService + без metrics для unit tests submit/list flow. */
|
||||||
public DraftService(
|
public DraftService(
|
||||||
RecordDraftRepository draftRepo,
|
RecordDraftRepository draftRepo,
|
||||||
DictionaryDefinitionService definitionService,
|
DictionaryDefinitionService definitionService,
|
||||||
ScopeContext scopeContext) {
|
ScopeContext scopeContext) {
|
||||||
this(draftRepo, definitionService, null, scopeContext);
|
this(draftRepo, definitionService, null, scopeContext, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-friendly ctor — с recordService но без metrics. Phase 1 + Phase 4 ctor compat. */
|
||||||
|
public DraftService(
|
||||||
|
RecordDraftRepository draftRepo,
|
||||||
|
DictionaryDefinitionService definitionService,
|
||||||
|
DictionaryRecordService recordService,
|
||||||
|
ScopeContext scopeContext) {
|
||||||
|
this(draftRepo, definitionService, recordService, scopeContext, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Регистрирует Gauge для pending queue depth. Polled на scrape — Micrometer
|
||||||
|
* вызывает {@link RecordDraftRepository#countPending()} раз в Prometheus tick.
|
||||||
|
* Phase 4 monitoring: queue depth → soak observability + alerting.
|
||||||
|
*/
|
||||||
|
@PostConstruct
|
||||||
|
void registerGauges() {
|
||||||
|
meterRegistry.ifPresent(reg ->
|
||||||
|
Gauge.builder("nsi_draft_pending_total", draftRepo, RecordDraftRepository::countPending)
|
||||||
|
.description("Approval workflow: pending drafts ожидающих review (D3 pool model)")
|
||||||
|
.register(reg));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,6 +141,7 @@ public class DraftService {
|
|||||||
RecordDraft saved = draftRepo.saveAndFlush(draft);
|
RecordDraft saved = draftRepo.saveAndFlush(draft);
|
||||||
log.info("Draft submitted: dict={} bk={} op={} maker={}",
|
log.info("Draft submitted: dict={} bk={} op={} maker={}",
|
||||||
dictionaryName, req.businessKey(), req.operation(), makerId);
|
dictionaryName, req.businessKey(), req.operation(), makerId);
|
||||||
|
countSubmit(dictionaryName, req.operation(), "ok");
|
||||||
return saved;
|
return saved;
|
||||||
} catch (DataIntegrityViolationException ex) {
|
} catch (DataIntegrityViolationException ex) {
|
||||||
// SQLSTATE 23P01 = exclusion_violation (record_drafts_one_pending_per_key).
|
// SQLSTATE 23P01 = exclusion_violation (record_drafts_one_pending_per_key).
|
||||||
@@ -115,10 +149,12 @@ public class DraftService {
|
|||||||
// иначе скрываем баги схемы (Phase 3 lessons learned: enum case mismatch
|
// иначе скрываем баги схемы (Phase 3 lessons learned: enum case mismatch
|
||||||
// прятался в маске draft_already_pending).
|
// прятался в маске draft_already_pending).
|
||||||
if (isExclusionViolation(ex)) {
|
if (isExclusionViolation(ex)) {
|
||||||
|
countSubmit(dictionaryName, req.operation(), "already_pending");
|
||||||
throw OrdinisException.conflict(
|
throw OrdinisException.conflict(
|
||||||
"draft_already_pending",
|
"draft_already_pending",
|
||||||
"На запись business_key=" + req.businessKey() + " уже есть pending draft.");
|
"На запись business_key=" + req.businessKey() + " уже есть pending draft.");
|
||||||
}
|
}
|
||||||
|
countSubmit(dictionaryName, req.operation(), "error");
|
||||||
throw ex;
|
throw ex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,12 +239,14 @@ public class DraftService {
|
|||||||
|
|
||||||
// Mark approved (kept в таблице — D4 audit trail). Delete не делаем —
|
// Mark approved (kept в таблице — D4 audit trail). Delete не делаем —
|
||||||
// draft.id остаётся ссылочно валидным (audit log → draft_id).
|
// draft.id остаётся ссылочно валидным (audit log → draft_id).
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
draft.setStatus(DraftStatus.APPROVED);
|
draft.setStatus(DraftStatus.APPROVED);
|
||||||
draft.setReviewerId(reviewerId);
|
draft.setReviewerId(reviewerId);
|
||||||
draft.setReviewedAt(OffsetDateTime.now());
|
draft.setReviewedAt(now);
|
||||||
draft.setReviewComment(reviewComment);
|
draft.setReviewComment(reviewComment);
|
||||||
RecordDraft saved = draftRepo.saveAndFlush(draft);
|
RecordDraft saved = draftRepo.saveAndFlush(draft);
|
||||||
|
|
||||||
|
recordDecision(def.getName(), draft.getOperation(), "approved", draft.getSubmittedAt(), now);
|
||||||
log.info("Draft approved: id={} dict={} bk={} op={} reviewer={}",
|
log.info("Draft approved: id={} dict={} bk={} op={} reviewer={}",
|
||||||
draft.getId(), def.getName(), draft.getBusinessKey(),
|
draft.getId(), def.getName(), draft.getBusinessKey(),
|
||||||
draft.getOperation(), reviewerId);
|
draft.getOperation(), reviewerId);
|
||||||
@@ -240,11 +278,14 @@ public class DraftService {
|
|||||||
"Maker '" + reviewerId + "' не может reject свой draft.");
|
"Maker '" + reviewerId + "' не может reject свой draft.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
draft.setStatus(DraftStatus.REJECTED);
|
draft.setStatus(DraftStatus.REJECTED);
|
||||||
draft.setReviewerId(reviewerId);
|
draft.setReviewerId(reviewerId);
|
||||||
draft.setReviewedAt(OffsetDateTime.now());
|
draft.setReviewedAt(now);
|
||||||
draft.setReviewComment(reviewComment);
|
draft.setReviewComment(reviewComment);
|
||||||
RecordDraft saved = draftRepo.saveAndFlush(draft);
|
RecordDraft saved = draftRepo.saveAndFlush(draft);
|
||||||
|
DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
|
||||||
|
recordDecision(def.getName(), draft.getOperation(), "rejected", draft.getSubmittedAt(), now);
|
||||||
log.info("Draft rejected: id={} reviewer={} reason='{}'",
|
log.info("Draft rejected: id={} reviewer={} reason='{}'",
|
||||||
draftId, reviewerId, reviewComment);
|
draftId, reviewerId, reviewComment);
|
||||||
return saved;
|
return saved;
|
||||||
@@ -272,13 +313,56 @@ public class DraftService {
|
|||||||
+ ", maker: " + draft.getMakerId());
|
+ ", maker: " + draft.getMakerId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
draft.setStatus(DraftStatus.WITHDRAWN);
|
draft.setStatus(DraftStatus.WITHDRAWN);
|
||||||
draft.setReviewedAt(OffsetDateTime.now());
|
draft.setReviewedAt(now);
|
||||||
RecordDraft saved = draftRepo.saveAndFlush(draft);
|
RecordDraft saved = draftRepo.saveAndFlush(draft);
|
||||||
|
DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
|
||||||
|
recordDecision(def.getName(), draft.getOperation(), "withdrawn", draft.getSubmittedAt(), now);
|
||||||
log.info("Draft withdrawn by maker: id={} maker={}", draftId, userId);
|
log.info("Draft withdrawn by maker: id={} maker={}", draftId, userId);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Phase 4 metrics helpers ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counter: nsi_draft_submit_total{dictionary, operation, outcome}.
|
||||||
|
* Outcome: ok / already_pending / error. Помогает алертить аномальный
|
||||||
|
* процент already_pending (concurrent edits) или error (schema bugs).
|
||||||
|
*/
|
||||||
|
private void countSubmit(String dictName, DraftOperation op, String outcome) {
|
||||||
|
meterRegistry.ifPresent(reg ->
|
||||||
|
reg.counter("nsi_draft_submit_total",
|
||||||
|
Tags.of("dictionary", dictName, "operation", op.name(), "outcome", outcome))
|
||||||
|
.increment());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counter: nsi_draft_decision_total{dictionary, operation, outcome=approved|rejected|withdrawn}.
|
||||||
|
* Timer: nsi_draft_review_duration_seconds — distribution time-to-decide.
|
||||||
|
* Outcome=withdrawn ≠ review (maker сам отозвал), но включаем — нужно для
|
||||||
|
* full lifecycle visibility и сравнения throughput.
|
||||||
|
*/
|
||||||
|
private void recordDecision(
|
||||||
|
String dictName, DraftOperation op, String outcome,
|
||||||
|
OffsetDateTime submittedAt, OffsetDateTime decidedAt) {
|
||||||
|
meterRegistry.ifPresent(reg -> {
|
||||||
|
Tags tags = Tags.of("dictionary", dictName, "operation", op.name(), "outcome", outcome);
|
||||||
|
reg.counter("nsi_draft_decision_total", tags).increment();
|
||||||
|
if (submittedAt != null && decidedAt != null) {
|
||||||
|
Duration d = Duration.between(submittedAt.toInstant(), decidedAt.toInstant());
|
||||||
|
if (!d.isNegative()) {
|
||||||
|
Timer t = Timer.builder("nsi_draft_review_duration_seconds")
|
||||||
|
.description("Approval workflow: время от submit до decision")
|
||||||
|
.tags(tags)
|
||||||
|
.publishPercentileHistogram()
|
||||||
|
.register(reg);
|
||||||
|
t.record(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Drafts автора: tracking "что я отправил" в admin UI. */
|
/** Drafts автора: tracking "что я отправил" в admin UI. */
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public Page<RecordDraft> listByMaker(String makerId, Pageable pageable) {
|
public Page<RecordDraft> listByMaker(String makerId, Pageable pageable) {
|
||||||
|
|||||||
+7
@@ -361,6 +361,13 @@ class DraftServiceTest {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long countPending() {
|
||||||
|
return store.values().stream()
|
||||||
|
.filter(d -> d.getStatus() == DraftStatus.PENDING)
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public org.springframework.data.domain.Page<RecordDraft> findByMaker(
|
public org.springframework.data.domain.Page<RecordDraft> findByMaker(
|
||||||
String makerId, org.springframework.data.domain.Pageable pageable) {
|
String makerId, org.springframework.data.domain.Pageable pageable) {
|
||||||
|
|||||||
Reference in New Issue
Block a user