Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e97f47b06a | ||
|
|
a4defb2c94 | ||
|
|
8717702b58 | ||
|
|
d30dbad788 | ||
|
|
e92112e9fd | ||
|
|
d6dc768efb | ||
|
|
96518c08fb | ||
|
|
54a34b24ba |
No files matched your search
@@ -240,6 +240,10 @@ class Tracker:
|
||||
if not value:
|
||||
return records
|
||||
records.extend(value)
|
||||
# Gitea comment pages may ignore page and keep returning the same short
|
||||
# list; a short page is the conventional last-page signal.
|
||||
if len(value) < PAGE_SIZE:
|
||||
return records
|
||||
raise InstallError('issue tracker pagination limit exceeded')
|
||||
|
||||
def issues(self):
|
||||
|
||||
@@ -984,11 +984,14 @@ def list_protocols(
|
||||
for row in cell_rows
|
||||
)
|
||||
if source in (None, SOURCE_DONATION):
|
||||
if status or collected_from or collected_until:
|
||||
donation_enabled = False
|
||||
else:
|
||||
donation_enabled = True
|
||||
if not donation_enabled:
|
||||
# 混合列表(source=None)下状态/采集时间只覆盖细胞存储,捐赠侧跳过。
|
||||
# 科研捐赠单源列表支持 protocolStatus→donation_status;采集时间筛仍不支持捐赠。
|
||||
skip_donation = False
|
||||
if source is None and (status or collected_from or collected_until):
|
||||
skip_donation = True
|
||||
elif source == SOURCE_DONATION and (collected_from or collected_until):
|
||||
skip_donation = True
|
||||
if skip_donation:
|
||||
query = None
|
||||
else:
|
||||
query = apply_donation_scope(
|
||||
@@ -1001,6 +1004,8 @@ def list_protocols(
|
||||
if query is None:
|
||||
donation_rows = []
|
||||
else:
|
||||
if status and source == SOURCE_DONATION:
|
||||
query = query.filter(BizResearchDonation.donation_status == status)
|
||||
if created_from:
|
||||
query = query.filter(BizResearchDonation.create_time >= created_from)
|
||||
if created_until:
|
||||
|
||||
@@ -3733,6 +3733,29 @@ def test_protocol_list_filters_status_create_time_and_sample_collect_time():
|
||||
assert [item['id'] for item in items] == ['hit']
|
||||
|
||||
|
||||
def test_donation_list_filters_by_protocol_status_alias():
|
||||
from app.services.market_protocol import list_protocols
|
||||
|
||||
db = _session()
|
||||
_masters(db)
|
||||
hit = _donation(db, 'don-hit', created=datetime(2026, 10, 8, 9, 0))
|
||||
hit.donation_status = 'sxz'
|
||||
miss = _donation(db, 'don-miss', created=datetime(2026, 10, 8, 9, 0))
|
||||
miss.donation_status = 'dsx'
|
||||
db.commit()
|
||||
|
||||
items, total = list_protocols(
|
||||
db,
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
source='research_donation',
|
||||
protocol_status='sxz',
|
||||
)
|
||||
|
||||
assert total == 1
|
||||
assert [item['id'] for item in items] == ['don-hit']
|
||||
|
||||
|
||||
def test_cell_create_rolls_back_main_customer_and_children_on_child_failure():
|
||||
from app.services.market_protocol import create_protocol
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ export function createListFilters(module, source = '') {
|
||||
result.source = SOURCES.has(text(source)) ? text(source) : 'cell_storage'
|
||||
return {
|
||||
source: result.source,
|
||||
protocolStatus: result.protocolStatus
|
||||
protocolStatus: result.protocolStatus,
|
||||
projectId: ''
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -15,11 +15,13 @@ test('creates isolated defaults for every market module', () => {
|
||||
|
||||
assert.deepEqual(first, {
|
||||
source: 'cell_storage',
|
||||
protocolStatus: 'sxz'
|
||||
protocolStatus: 'sxz',
|
||||
projectId: ''
|
||||
})
|
||||
assert.deepEqual(second, {
|
||||
source: 'research_donation',
|
||||
protocolStatus: ''
|
||||
protocolStatus: '',
|
||||
projectId: ''
|
||||
})
|
||||
assert.deepEqual(createListFilters('presign'), { orderStatus: '' })
|
||||
assert.deepEqual(createListFilters('payment-chase'), { chaseType: '', payMode: '' })
|
||||
@@ -66,8 +68,14 @@ test('drops incomplete ranges and counts only user-selectable filters', () => {
|
||||
}), { progressStatus: 'yjq' })
|
||||
assert.equal(activeFilterCount('protocol', {
|
||||
source: 'cell_storage',
|
||||
protocolStatus: 'sxz'
|
||||
protocolStatus: 'sxz',
|
||||
projectId: ''
|
||||
}), 1)
|
||||
assert.equal(activeFilterCount('protocol', {
|
||||
source: 'research_donation',
|
||||
protocolStatus: 'sxz',
|
||||
projectId: 'p1'
|
||||
}), 2)
|
||||
assert.equal(activeFilterCount('freight', {
|
||||
transportMode: '', sampleSource: 'research_donation'
|
||||
}), 1)
|
||||
|
||||
@@ -313,6 +313,7 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
|
||||
|
||||
assert.match(protocol, /inlineDropdownFilters/)
|
||||
assert.match(protocol, /全部协议状态/)
|
||||
assert.match(protocol, /source:\s*'research_donation'/)
|
||||
assert.match(protocol, /filterFields:\s*\[\]/)
|
||||
assert.doesNotMatch(protocol, /createTimeRange/)
|
||||
assert.doesNotMatch(protocol, /sampleCollectTimeRange/)
|
||||
@@ -320,6 +321,9 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
|
||||
assert.doesNotMatch(filters, /createTimeRange/)
|
||||
assert.match(genericList, /inlineDropdownFilters/)
|
||||
assert.match(genericList, /market-list__filter-row/)
|
||||
assert.match(genericList, /showProtocolProjectFilter/)
|
||||
assert.match(genericList, /全部项目/)
|
||||
assert.doesNotMatch(genericList, /ProjectAssignmentField/)
|
||||
})
|
||||
|
||||
test('protocol list exposes mobile batch selection and sequential authorized deletion', () => {
|
||||
|
||||
@@ -478,6 +478,12 @@ export default {
|
||||
allText: '全部协议状态',
|
||||
source: 'cell_storage',
|
||||
options: CELL_PROTOCOL_STATUS_OPTIONS.map((item) => ({ text: item.label, value: item.value }))
|
||||
},
|
||||
{
|
||||
key: 'protocolStatus',
|
||||
allText: '全部协议状态',
|
||||
source: 'research_donation',
|
||||
options: DONATION_PROTOCOL_STATUS_OPTIONS.map((item) => ({ text: item.label, value: item.value }))
|
||||
}
|
||||
],
|
||||
statusLabel(detail) {
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
:show-header="false"
|
||||
>
|
||||
<div class="market-list__filters">
|
||||
<ProjectAssignmentField v-if="context.module === 'protocol'" v-model="listFilters.projectId"
|
||||
:biz-type="listStatus" filter :context="context" @update:model-value="search" />
|
||||
<div
|
||||
v-if="listFilterTabs.length"
|
||||
class="market-source-tabs"
|
||||
@@ -59,15 +57,24 @@
|
||||
<span v-if="activeFilters" class="market-list__filter-count">{{ activeFilters }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="inlineDropdownFilters.length" class="market-list__filter-row">
|
||||
<div
|
||||
v-if="inlineDropdownFilters.length || showProtocolProjectFilter"
|
||||
class="market-list__filter-row"
|
||||
>
|
||||
<van-dropdown-menu active-color="#0a9f6d">
|
||||
<van-dropdown-item
|
||||
v-for="field in inlineDropdownFilters"
|
||||
:key="field.key"
|
||||
:key="`${field.source || 'all'}:${field.key}`"
|
||||
:model-value="listFilters[field.key]"
|
||||
:options="inlineFilterOptionsByKey[field.key] || []"
|
||||
@change="(value) => onInlineFilterChange(field.key, value)"
|
||||
/>
|
||||
<van-dropdown-item
|
||||
v-if="showProtocolProjectFilter"
|
||||
:model-value="listFilters.projectId || ''"
|
||||
:options="protocolProjectFilterOptions"
|
||||
@change="(value) => onInlineFilterChange('projectId', value)"
|
||||
/>
|
||||
</van-dropdown-menu>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,11 +161,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProjectAssignmentField from '../../market/components/ProjectAssignmentField.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchMarketFormOptions, fetchMarketList, performMarketAction } from '../../api/market.js'
|
||||
import {
|
||||
fetchMarketFormOptions,
|
||||
fetchMarketList,
|
||||
fetchProjectOptions,
|
||||
performMarketAction
|
||||
} from '../../api/market.js'
|
||||
import { intersectAllowedActions } from '../../market/action-policy.js'
|
||||
import { deleteMarketItemsSequentially } from '../../market/batch-delete.js'
|
||||
import {
|
||||
@@ -169,6 +180,7 @@ import {
|
||||
import { marketSearchPlaceholder } from '../../market/list-presenter.js'
|
||||
import { marketErrorMessage } from '../../market/market-error.js'
|
||||
import { getMarketModule } from '../../market/module-registry.js'
|
||||
import { projectOptions } from '../../market/project-assignment.js'
|
||||
import {
|
||||
getMarketRouteContext,
|
||||
MARKET_MODULE_TITLES,
|
||||
@@ -247,6 +259,12 @@ const inlineDropdownFilters = computed(() => {
|
||||
const source = listFilters.value.source || listStatus.value
|
||||
return rows.filter((field) => !field.source || field.source === source)
|
||||
})
|
||||
const showProtocolProjectFilter = computed(() => context.value.module === 'protocol')
|
||||
const protocolProjectOptions = ref([])
|
||||
const protocolProjectFilterOptions = computed(() => [
|
||||
{ text: '全部项目', value: '' },
|
||||
...protocolProjectOptions.value
|
||||
])
|
||||
const activeFilters = computed(() =>
|
||||
activeFilterCount(context.value.module, listFilters.value)
|
||||
)
|
||||
@@ -299,6 +317,7 @@ const allDeletableSelected = computed(() =>
|
||||
)
|
||||
let requestSequence = 0
|
||||
let abortController = null
|
||||
let projectOptionsController = null
|
||||
let searchTimer = null
|
||||
|
||||
function normalizeList(result) {
|
||||
@@ -360,7 +379,32 @@ function queueSearch() {
|
||||
searchTimer = window.setTimeout(search, 350)
|
||||
}
|
||||
|
||||
async function loadProtocolProjectOptions() {
|
||||
projectOptionsController?.abort()
|
||||
if (!showProtocolProjectFilter.value) {
|
||||
protocolProjectOptions.value = []
|
||||
return
|
||||
}
|
||||
const current = new AbortController()
|
||||
projectOptionsController = current
|
||||
const bizType = listFilters.value.source || listStatus.value || 'cell_storage'
|
||||
try {
|
||||
const data = await fetchProjectOptions(bizType, {
|
||||
signal: current.signal,
|
||||
filter: true,
|
||||
context: context.value
|
||||
})
|
||||
if (projectOptionsController !== current) return
|
||||
protocolProjectOptions.value = projectOptions(data)
|
||||
} catch (error) {
|
||||
if (projectOptionsController === current && error?.name !== 'AbortError') {
|
||||
protocolProjectOptions.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadListFilterOptions() {
|
||||
await loadProtocolProjectOptions()
|
||||
const optionKeys = [
|
||||
...sheetFilterFields.value.map((field) => field.optionKey),
|
||||
...inlineDropdownFilters.value.map((field) => field.optionKey)
|
||||
@@ -596,6 +640,7 @@ function switchListStatus(value) {
|
||||
tab: next
|
||||
}
|
||||
router.replace({ query: nextQuery }).catch(() => {})
|
||||
loadProtocolProjectOptions()
|
||||
loadPage(true)
|
||||
}
|
||||
|
||||
@@ -644,6 +689,7 @@ onBeforeUnmount(() => {
|
||||
const requestId = ++requestSequence
|
||||
listState.value = invalidateMarketRequestState(listState.value, requestId)
|
||||
abortController?.abort()
|
||||
projectOptionsController?.abort()
|
||||
window.clearTimeout(searchTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -20,7 +20,13 @@
|
||||
v-for="option in displayOptions"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
:label="team ? option.teamName : `${option.projectName} · ${option.teamName}`"
|
||||
:label="
|
||||
team
|
||||
? option.teamName
|
||||
: teamId
|
||||
? option.projectName
|
||||
: `${option.projectName} · ${option.teamName}`
|
||||
"
|
||||
:disabled="!filter && option.disabled"
|
||||
/>
|
||||
</el-select>
|
||||
@@ -43,6 +49,8 @@
|
||||
const props = defineProps<{
|
||||
filter?: boolean
|
||||
team?: boolean
|
||||
/** When selecting projects, keep options under this team. */
|
||||
teamId?: string
|
||||
modelValue?: string
|
||||
bizType?: BizType
|
||||
disabled?: boolean
|
||||
@@ -54,15 +62,18 @@
|
||||
ready: [value: boolean]
|
||||
}>()
|
||||
const options = ref<ProjectOption[]>([])
|
||||
const displayOptions = computed(() =>
|
||||
props.team
|
||||
? [
|
||||
...new Map(
|
||||
options.value.map((option) => [option.teamId, { ...option, id: option.teamId }])
|
||||
).values()
|
||||
]
|
||||
const displayOptions = computed(() => {
|
||||
if (props.team) {
|
||||
return [
|
||||
...new Map(
|
||||
options.value.map((option) => [option.teamId, { ...option, id: option.teamId }])
|
||||
).values()
|
||||
]
|
||||
}
|
||||
return props.teamId
|
||||
? options.value.filter((option) => option.teamId === props.teamId)
|
||||
: options.value
|
||||
)
|
||||
})
|
||||
const loading = ref(false)
|
||||
const error = ref(false),
|
||||
retry = ref(0)
|
||||
|
||||
@@ -143,57 +143,58 @@
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="项目归属维护"
|
||||
width="720px"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
append-to-body
|
||||
modal-class="protocol-dialog-mask"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form label-position="top"
|
||||
><el-form-item label="目标项目" required
|
||||
><ProjectSelect
|
||||
v-model="target"
|
||||
:key="selected.id"
|
||||
<el-form class="form-grid-2" label-position="top">
|
||||
<el-form-item label="原所属项目组">
|
||||
<el-input :model-value="selected.teamName || '未归属'" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="原所属项目">
|
||||
<el-input :model-value="selected.projectName || '未归属'" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="新项目组" required>
|
||||
<ProjectSelect
|
||||
:key="`team-${selected.id}`"
|
||||
team
|
||||
v-model="targetTeamId"
|
||||
:biz-type="selected.bizType"
|
||||
@update:model-value="previewRows = []" /></el-form-item
|
||||
><el-form-item label="调整原因" required
|
||||
><el-input v-model="reason" type="textarea" :maxlength="500" /></el-form-item
|
||||
></el-form>
|
||||
<el-alert
|
||||
title="请先预览全部关联单据,再确认调整。关联集合或归属发生变化时需要重新预览。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/>
|
||||
<el-table :data="previewRows" class="list-table" stripe
|
||||
><el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
min-width="68"
|
||||
align="center"
|
||||
header-align="center" />
|
||||
<el-table-column prop="bizNo" label="关联单据" min-width="200" /><el-table-column
|
||||
prop="projectName"
|
||||
label="原项目"
|
||||
min-width="160"
|
||||
/></el-table>
|
||||
<template #footer
|
||||
><el-button @click="visible = false"
|
||||
@update:model-value="onTargetTeamChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新项目" required>
|
||||
<ProjectSelect
|
||||
:key="`project-${selected.id}-${targetTeamId}`"
|
||||
v-model="target"
|
||||
:team-id="targetTeamId"
|
||||
:biz-type="selected.bizType"
|
||||
:disabled="!targetTeamId"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="调整原因" required class="form-col-span-2">
|
||||
<el-input v-model="reason" type="textarea" :maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false"
|
||||
><el-icon><Close /></el-icon>取消</el-button
|
||||
><el-button :loading="busy" @click="preview"
|
||||
><el-icon><View /></el-icon>预览关联单据</el-button
|
||||
><el-button
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="!previewRows.length || !reason.trim()"
|
||||
:disabled="!target || !reason.trim()"
|
||||
:loading="busy"
|
||||
@click="commit"
|
||||
><el-icon><Check /></el-icon>确认调整</el-button
|
||||
></template
|
||||
>
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog
|
||||
v-model="historyVisible"
|
||||
title="归属审计记录"
|
||||
width="800px"
|
||||
width="1100px"
|
||||
append-to-body
|
||||
class="dialog-form-layout"
|
||||
><el-table :data="historyRows" class="list-table" stripe
|
||||
@@ -233,7 +234,7 @@
|
||||
presignOrderStatusTagType,
|
||||
applicationStatusTagType
|
||||
} from '@/utils/status-tag-registry'
|
||||
import { Search, Edit, Clock, Close, View, Check, RefreshRight } from '@element-plus/icons-vue'
|
||||
import { Search, Edit, Clock, Close, Check, RefreshRight } from '@element-plus/icons-vue'
|
||||
import { assignmentTabs, assignmentColumns, assignmentKeywordHints } from './assignment-columns'
|
||||
import ProjectSelect from '@/components/business/ProjectSelect.vue'
|
||||
import { assignmentSnapshot } from '@/utils/project-management'
|
||||
@@ -268,10 +269,10 @@
|
||||
loadError = ref(false),
|
||||
visible = ref(false),
|
||||
busy = ref(false),
|
||||
targetTeamId = ref(''),
|
||||
target = ref(''),
|
||||
reason = ref(''),
|
||||
selected = ref<ProjectRecord>({}),
|
||||
previewRows = ref<ProjectRecord[]>([]),
|
||||
historyVisible = ref(false),
|
||||
historyRows = ref<ProjectRecord[]>([])
|
||||
const visibleColumns = computed(() =>
|
||||
@@ -361,47 +362,54 @@
|
||||
}
|
||||
function open(row: ProjectRecord) {
|
||||
selected.value = row
|
||||
targetTeamId.value = ''
|
||||
target.value = ''
|
||||
reason.value = ''
|
||||
previewRows.value = []
|
||||
visible.value = true
|
||||
}
|
||||
async function preview() {
|
||||
function onTargetTeamChange() {
|
||||
target.value = ''
|
||||
}
|
||||
async function commit() {
|
||||
if (!targetTeamId.value) {
|
||||
ElMessage.warning('请选择新项目组')
|
||||
return
|
||||
}
|
||||
if (!target.value) {
|
||||
ElMessage.warning('请选择目标项目')
|
||||
ElMessage.warning('请选择新项目')
|
||||
return
|
||||
}
|
||||
if (!reason.value.trim()) {
|
||||
ElMessage.warning('请填写调整原因')
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
const previewTarget = target.value
|
||||
const previewRecord = selected.value
|
||||
const commitTarget = target.value
|
||||
const commitRecord = selected.value
|
||||
try {
|
||||
const result = await previewAssignment({
|
||||
const preview = await previewAssignment({
|
||||
bizType: selected.value.bizType,
|
||||
id: selected.value.id,
|
||||
projectId: target.value
|
||||
})
|
||||
if (selected.value === previewRecord && target.value === previewTarget && visible.value) {
|
||||
previewRows.value = result.records
|
||||
if (selected.value !== commitRecord || target.value !== commitTarget || !visible.value) {
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
async function commit() {
|
||||
busy.value = true
|
||||
try {
|
||||
await saveAssignment({
|
||||
bizType: selected.value.bizType,
|
||||
id: selected.value.id,
|
||||
projectId: target.value,
|
||||
reason: reason.value.trim(),
|
||||
expectedAssignments: assignmentSnapshot(previewRows.value)
|
||||
expectedAssignments: assignmentSnapshot(preview.records)
|
||||
})
|
||||
if (selected.value !== commitRecord || target.value !== commitTarget || !visible.value) {
|
||||
return
|
||||
}
|
||||
visible.value = false
|
||||
ElMessage.success('归属已调整')
|
||||
await load()
|
||||
} catch {
|
||||
previewRows.value = []
|
||||
// API layer already surfaces the conflict / validation message.
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user