角色业务范围收紧工单与模板;报价日期不当费用;未选舱不迁已报价;另开一票可清违禁 hold

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-15 21:00:01 +08:00
co-authored by Cursor
parent a0c705683f
commit 277cdc1c28
39 changed files with 2083 additions and 217 deletions
@@ -5,6 +5,7 @@
* - 版式由上传 Excel 决定;列表展示「映射点数」而非假占位符
* - 预览:原表截图(LibreOffice 转图)
* - 解析状态列可点:弹窗看已解析 / 未解析清单
* - 业务下拉和列表按主账返回的 allowedBizTypes 收窄:陆运角色不出现海运、空运
*/
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import { Modal, message } from "ant-design-vue";
@@ -55,6 +56,29 @@ const keyword = ref("");
const bizFilter = ref("");
const statusFilter = ref("");
/** 报价模板只有这三条业务。主账按角色收窄后,下拉只留账号能看的。 */
const ALL_BIZ = ["空运", "海运", "陆运"] as const;
type BizName = (typeof ALL_BIZ)[number];
const bizOptions = ref<BizName[]>([...ALL_BIZ]);
function defaultBiz(): TemplateRow["bizType"] {
return bizOptions.value[0] || "海运";
}
/** 主账没带回范围时保持三项,避免旧接口把下拉清空。 */
function applyAllowedBiz(raw: unknown) {
const incoming = Array.isArray(raw) ? raw.map((item) => String(item)) : [];
const next = ALL_BIZ.filter((biz) => incoming.includes(biz));
if (!next.length) return;
bizOptions.value = [...next];
if (bizFilter.value && !bizOptions.value.includes(bizFilter.value as BizName)) {
bizFilter.value = "";
}
if (!bizOptions.value.includes(form.bizType as BizName)) {
form.bizType = defaultBiz();
}
}
const formOpen = ref(false);
const editing = ref<TemplateRow | null>(null);
const previewOpen = ref(false);
@@ -364,6 +388,7 @@ async function loadList() {
pageSize: 200,
});
const records = res?.records || res?.result?.records || [];
applyAllowedBiz(res?.allowedBizTypes ?? res?.result?.allowedBizTypes);
rows.value = (records as any[]).map(mapApiRow);
scheduleParsePoll();
} catch (e: any) {
@@ -397,6 +422,7 @@ function scheduleParsePoll() {
pageSize: 200,
});
const records = res?.records || res?.result?.records || [];
applyAllowedBiz(res?.allowedBizTypes ?? res?.result?.allowedBizTypes);
rows.value = (records as any[]).map(mapApiRow);
} catch {
/* 轮询失败不打断页面 */
@@ -420,7 +446,7 @@ onUnmounted(() => {
function resetForm() {
Object.assign(form, {
name: "",
bizType: "海运" as TemplateRow["bizType"],
bizType: defaultBiz(),
mode: "auto" as const,
keywordItems: [""],
isDefault: false,
@@ -708,9 +734,7 @@ async function downloadPreviewOrOriginal() {
</div>
<select v-model="bizFilter" @change="loadList">
<option value="">全部业务</option>
<option value="空运">空运</option>
<option value="海运">海运</option>
<option value="陆运">陆运</option>
<option v-for="biz in bizOptions" :key="biz" :value="biz">{{ biz }}</option>
</select>
<select v-model="statusFilter" @change="loadList">
<option value="">全部状态</option>
@@ -874,9 +898,7 @@ async function downloadPreviewOrOriginal() {
<div class="qt-create-row">
<label>业务</label>
<select v-model="form.bizType" class="field">
<option value="海运">海运</option>
<option value="空运">空运</option>
<option value="陆运">陆运</option>
<option v-for="biz in bizOptions" :key="biz" :value="biz">{{ biz }}</option>
</select>
</div>
<div class="qt-create-row qt-create-row--keywords">
@@ -2,6 +2,7 @@
/**
* 询价工单列表:对接主账 /inquiry/workOrder。
* 六态关单/重试必须调主账;禁止本地改 status 冒充成功。
* 业务下拉按主账 allowedBizTypes 收窄:陆运角色不出现海运、空运。
* 详情/关单弹窗必须用 AdminModal:本工程 ant-design-vue 3 的 a-modal 只认 visible,
* v-model:open 不会弹出(账号/角色页已统一走 AdminModal)。
*/
@@ -112,6 +113,20 @@ const filters = reactive({
dateTo: "",
});
/** 工单业务下拉。主账按角色收窄后,陆运账号不再出现海运、空运、多段。 */
const ALL_TICKET_BIZ = ["空运", "海运", "陆运", "多段联运"] as const;
const ticketBizOptions = ref<string[]>([...ALL_TICKET_BIZ]);
function applyTicketBizOptions(raw: unknown) {
const incoming = Array.isArray(raw) ? raw.map((item) => String(item)) : [];
const next = ALL_TICKET_BIZ.filter((biz) => incoming.includes(biz));
if (!next.length) return;
ticketBizOptions.value = [...next];
if (filters.bizType && !ticketBizOptions.value.includes(filters.bizType)) {
filters.bizType = "";
}
}
const detailOpen = ref(false);
const closeOpen = ref(false);
const current = ref<TicketRow | null>(null);
@@ -460,6 +475,20 @@ function requirementSummaryLines(t: any): string[] {
return lines;
}
/**
* 空运尚未选定舱位/线路:payload 有 airOptions 且无 selected,或 pending_cabin_pick。
* 此时后台不应展示 TMS 费用,避免「未选舱却已报价」。
*/
function isPendingAirCabinQuote(q: any): boolean {
const payload = asObject(q?.payloadJson) || asObject(q?.payload) || {};
if (String(payload.pending_cabin_pick || "") === "1") return true;
const selected =
asObject(payload.selected_air_option) || asObject(payload.selectedAirOption);
if (selected) return false;
const opts = asFeeArray(payload.airOptions || payload.air_options);
return opts.length > 0;
}
function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline: any[] = []): TicketRow {
const attachRows: TicketAttach[] = (attachments || [])
.map((a) => ({
@@ -479,7 +508,9 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
initiator,
demandTitle: reqLines[0] || "—",
demandDetail: reqLines.slice(1).join("\n"),
quotes: (quotes || []).map((q) => ({
quotes: (quotes || [])
.filter((q) => !isPendingAirCabinQuote(q))
.map((q) => ({
source: quoteSourceLabel(q),
price: q.priceText || "-",
time: fmtTime(q.createTime),
@@ -587,6 +618,7 @@ async function loadList() {
});
const page = res?.records ? res : res?.result || {};
const records = page.records || [];
applyTicketBizOptions(page.allowedBizTypes);
total.value = Number(page.total || records.length || 0);
rows.value = (records as any[]).map((t) => mapTicket(t, t.quotes || []));
// 删减后当前页可能越界,回退到最后一页
@@ -766,10 +798,7 @@ async function exportTickets() {
</select>
<select v-model="filters.bizType">
<option value="">业务类型 · 全部</option>
<option value="空运">空运</option>
<option value="海运">海运</option>
<option value="陆运">陆运</option>
<option value="多段联运">多段联运</option>
<option v-for="biz in ticketBizOptions" :key="biz" :value="biz">{{ biz }}</option>
</select>
<div class="toolbar-date-range" title="按创建时间筛选">
<a-range-picker