refactor: extract cleaner navigation service
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
import { ERP_LOCATORS } from './locators'
|
|
||||||
import { ErpAuthService } from './erp-auth'
|
import { ErpAuthService } from './erp-auth'
|
||||||
import { DeletionErrorCategory, DeletionOutcome } from '../../types/cleaner.types'
|
import { DeletionErrorCategory, DeletionOutcome } from '../../types/cleaner.types'
|
||||||
import type { CleanerInput, CleanerResult, OrderCleanDetail } from '../../types/cleaner.types'
|
import type { CleanerInput, CleanerResult, OrderCleanDetail } from '../../types/cleaner.types'
|
||||||
@@ -10,6 +9,7 @@ import { capturePageState } from './page-state'
|
|||||||
import { AsyncMutex, ConcurrencyTracker } from './cleaner/concurrency'
|
import { AsyncMutex, ConcurrencyTracker } from './cleaner/concurrency'
|
||||||
import { evaluateDeletionSignals as evaluateDeletionSignalsHelper } from './cleaner/deletion-signals'
|
import { evaluateDeletionSignals as evaluateDeletionSignalsHelper } from './cleaner/deletion-signals'
|
||||||
import { MaterialDeletionVerifier } from './cleaner/material-deletion-verifier'
|
import { MaterialDeletionVerifier } from './cleaner/material-deletion-verifier'
|
||||||
|
import { CleanerNavigation } from './cleaner/navigation'
|
||||||
import { getSkipReason, shouldDeleteMaterial } from './cleaner/rules'
|
import { getSkipReason, shouldDeleteMaterial } from './cleaner/rules'
|
||||||
import {
|
import {
|
||||||
clampNumber,
|
clampNumber,
|
||||||
@@ -45,11 +45,15 @@ export class CleanerService {
|
|||||||
private authService: ErpAuthService
|
private authService: ErpAuthService
|
||||||
private dryRun: boolean
|
private dryRun: boolean
|
||||||
private deletionVerifier: MaterialDeletionVerifier
|
private deletionVerifier: MaterialDeletionVerifier
|
||||||
|
private navigation: CleanerNavigation
|
||||||
|
|
||||||
constructor(authService: ErpAuthService, options: CleanerOptions = {}) {
|
constructor(authService: ErpAuthService, options: CleanerOptions = {}) {
|
||||||
this.authService = authService
|
this.authService = authService
|
||||||
this.dryRun = options.dryRun ?? false
|
this.dryRun = options.dryRun ?? false
|
||||||
this.deletionVerifier = new MaterialDeletionVerifier(log)
|
this.deletionVerifier = new MaterialDeletionVerifier(log)
|
||||||
|
this.navigation = new CleanerNavigation(log, (page, step, snapshotOptions) =>
|
||||||
|
this.logPageStateSnapshot(page, step, snapshotOptions)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -456,141 +460,11 @@ export class CleanerService {
|
|||||||
async navigateToCleanerPage(
|
async navigateToCleanerPage(
|
||||||
session: ErpSession
|
session: ErpSession
|
||||||
): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
|
): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
|
||||||
const { page, mainFrame } = session
|
return this.navigation.navigateToCleanerPage(session)
|
||||||
const navStartTime = Date.now()
|
|
||||||
|
|
||||||
log.info('开始导航到清理页面', {
|
|
||||||
sessionAvailable: !!session,
|
|
||||||
hasPage: !!page,
|
|
||||||
hasMainFrame: !!mainFrame
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 1: Click menu icon
|
|
||||||
log.debug('[导航 Step 1] 准备点击菜单图标')
|
|
||||||
await mainFrame.locator('i').first().click()
|
|
||||||
log.debug('[导航 Step 1 完成] 菜单图标已点击', { elapsedMs: Date.now() - navStartTime })
|
|
||||||
|
|
||||||
// Step 2: Wait for popup and click menu item
|
|
||||||
log.debug('[导航 Step 2] 等待弹出窗口并点击菜单项')
|
|
||||||
const popupPromise = page.waitForEvent('popup')
|
|
||||||
await mainFrame.getByTitle('离散生产订单维护', { exact: true }).first().click()
|
|
||||||
const popupPage = await popupPromise
|
|
||||||
log.debug('[导航 Step 2 完成] 弹出窗口已打开', {
|
|
||||||
elapsedMs: Date.now() - navStartTime,
|
|
||||||
popupOpened: !!popupPage
|
|
||||||
})
|
|
||||||
await this.logPageStateSnapshot(popupPage, 'nav.popup_opened', {
|
|
||||||
level: 'info',
|
|
||||||
elapsedMs: Date.now() - navStartTime
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 3: Get forward frame
|
|
||||||
log.debug('[导航 Step 3] 获取 forwardFrame 框架')
|
|
||||||
const forwardFrameLocator = popupPage.locator('#forwardFrame')
|
|
||||||
const fFrame = forwardFrameLocator.contentFrame()
|
|
||||||
log.debug('[导航 Step 3 完成] forwardFrame 已获取', {
|
|
||||||
elapsedMs: Date.now() - navStartTime,
|
|
||||||
frameExists: !!fFrame
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!fFrame) {
|
|
||||||
log.error('[导航失败] forwardFrame 为空', {
|
|
||||||
elapsedMs: Date.now() - navStartTime,
|
|
||||||
pageUrl: popupPage.url(),
|
|
||||||
contextData: await capturePageContext(
|
|
||||||
popupPage,
|
|
||||||
undefined,
|
|
||||||
'nav.forwardFrame',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
'nav_forward_frame'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
throw new Error('无法访问弹出窗口的 forwardFrame')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Get inner work frame
|
|
||||||
log.debug('[导航 Step 4] 等待并获取 mainiframe 内部框架', { timeout: 30000 })
|
|
||||||
const innerFrameLocator = fFrame.locator('#mainiframe')
|
|
||||||
await innerFrameLocator.waitFor({ state: 'visible', timeout: 30000 })
|
|
||||||
const workFrame = innerFrameLocator.contentFrame()
|
|
||||||
log.debug('[导航 Step 4 完成] 内部工作框架已获取', {
|
|
||||||
elapsedMs: Date.now() - navStartTime,
|
|
||||||
frameExists: !!workFrame
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!workFrame) {
|
|
||||||
log.error('[导航失败] workFrame 为空', {
|
|
||||||
elapsedMs: Date.now() - navStartTime,
|
|
||||||
pageUrl: popupPage.url(),
|
|
||||||
contextData: await capturePageContext(
|
|
||||||
popupPage,
|
|
||||||
undefined,
|
|
||||||
'nav.workFrame',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
'nav_work_frame'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
throw new Error('无法访问内部工作框架')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Wait for page ready
|
|
||||||
log.debug('[导航 Step 5] 等待页面就绪标志', { selector: '#hot-key-head_list', timeout: 30000 })
|
|
||||||
await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 })
|
|
||||||
const totalNavTime = Date.now() - navStartTime
|
|
||||||
await this.logPageStateSnapshot(popupPage, 'nav.cleaner_page_ready', {
|
|
||||||
level: 'info',
|
|
||||||
elapsedMs: totalNavTime
|
|
||||||
})
|
|
||||||
log.info('[导航完成] 已导航到清理页面', {
|
|
||||||
totalNavTimeMs: totalNavTime,
|
|
||||||
isSlow: totalNavTime > 5000
|
|
||||||
})
|
|
||||||
|
|
||||||
return { popupPage, workFrame }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setupQueryInterface(innerFrame: FrameLocator, popupPage: Page): Promise<void> {
|
private async setupQueryInterface(innerFrame: FrameLocator, popupPage: Page): Promise<void> {
|
||||||
const setupStartTime = Date.now()
|
await this.navigation.setupQueryInterface(innerFrame, popupPage)
|
||||||
log.debug('[查询界面设置开始] 准备配置查询界面')
|
|
||||||
await this.logPageStateSnapshot(popupPage, 'query.setup.start', {
|
|
||||||
level: 'debug',
|
|
||||||
elapsedMs: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 1: Click search icon
|
|
||||||
log.debug('[查询设置 Step 1] 点击搜索图标')
|
|
||||||
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
|
|
||||||
log.debug('[查询设置 Step 1 完成] 搜索图标已点击', { elapsedMs: Date.now() - setupStartTime })
|
|
||||||
|
|
||||||
// Step 2: Select order number query mode
|
|
||||||
log.debug('[查询设置 Step 2] 选择订单号查询模式')
|
|
||||||
await innerFrame.getByText('订单号查询').click()
|
|
||||||
log.debug('[查询设置 Step 2 完成] 已切换到订单号查询', {
|
|
||||||
elapsedMs: Date.now() - setupStartTime
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 3: Switch to "All" tab
|
|
||||||
log.debug('[查询设置 Step 3] 切换到全部标签页')
|
|
||||||
await innerFrame.getByRole('tab', { name: '全部' }).click()
|
|
||||||
log.debug('[查询设置 Step 3 完成] 已全部标签页激活', { elapsedMs: Date.now() - setupStartTime })
|
|
||||||
|
|
||||||
// Step 4: Set query limit to 5000
|
|
||||||
log.debug('[查询设置 Step 4] 设置查询数量限制为 5000')
|
|
||||||
const inputEl = innerFrame.locator('#rc_select_0')
|
|
||||||
await inputEl.fill('5000')
|
|
||||||
await inputEl.press('Enter')
|
|
||||||
const totalSetupTime = Date.now() - setupStartTime
|
|
||||||
await this.logPageStateSnapshot(popupPage, 'query.setup.ready', {
|
|
||||||
level: 'info',
|
|
||||||
elapsedMs: totalSetupTime
|
|
||||||
})
|
|
||||||
log.debug('[查询设置完成] 查询界面配置完毕', {
|
|
||||||
totalSetupTimeMs: totalSetupTime,
|
|
||||||
queryLimit: 5000,
|
|
||||||
isSlow: totalSetupTime > 2000
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async queryOrders(
|
private async queryOrders(
|
||||||
@@ -598,115 +472,14 @@ export class CleanerService {
|
|||||||
popupPage: Page,
|
popupPage: Page,
|
||||||
orderNumbers: string[]
|
orderNumbers: string[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const queryStartTime = Date.now()
|
await this.navigation.queryOrders(workFrame, popupPage, orderNumbers)
|
||||||
log.debug('[订单查询开始]', {
|
|
||||||
orderCount: orderNumbers.length,
|
|
||||||
orderNumbers: orderNumbers
|
|
||||||
.slice(0, 5)
|
|
||||||
.concat(orderNumbers.length > 5 ? [`... (${orderNumbers.length - 5} more)`] : []),
|
|
||||||
isPreview: orderNumbers.length > 5
|
|
||||||
})
|
|
||||||
await this.logPageStateSnapshot(popupPage, 'query.before_submit', {
|
|
||||||
level: 'debug',
|
|
||||||
elapsedMs: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const textbox = workFrame.getByRole('textbox', { name: '生产订单号' })
|
|
||||||
log.debug('[订单查询] 准备填入订单号')
|
|
||||||
await textbox.fill(orderNumbers.join(','))
|
|
||||||
log.debug('[订单查询] 订单号已填入', {
|
|
||||||
elapsedMs: Date.now() - queryStartTime,
|
|
||||||
charCount: orderNumbers.join(',').length
|
|
||||||
})
|
|
||||||
|
|
||||||
log.debug('[订单查询] 点击搜索按钮')
|
|
||||||
await workFrame.locator('.search-component-searchBtn').click()
|
|
||||||
log.info('[订单查询完成] 查询请求已发送', {
|
|
||||||
elapsedMs: Date.now() - queryStartTime,
|
|
||||||
orderCount: orderNumbers.length
|
|
||||||
})
|
|
||||||
await this.logPageStateSnapshot(popupPage, 'query.after_submit', {
|
|
||||||
level: 'info',
|
|
||||||
elapsedMs: Date.now() - queryStartTime
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async collectQueryResultRows(
|
private async collectQueryResultRows(
|
||||||
workFrame: FrameLocator,
|
workFrame: FrameLocator,
|
||||||
popupPage: Page
|
popupPage: Page
|
||||||
): Promise<QueryResultRow[]> {
|
): Promise<QueryResultRow[]> {
|
||||||
const collectStartTime = Date.now()
|
return this.navigation.collectQueryResultRows(workFrame, popupPage)
|
||||||
log.debug('[查询结果收集开始] 准备读取查询结果表格')
|
|
||||||
|
|
||||||
const rows = workFrame.locator('tbody tr')
|
|
||||||
const rowCount = await rows.count()
|
|
||||||
log.debug('[查询结果收集] 检测到表格行数', { rowCount })
|
|
||||||
|
|
||||||
const result: QueryResultRow[] = []
|
|
||||||
let validOrderCount = 0
|
|
||||||
let invalidOrderCount = 0
|
|
||||||
|
|
||||||
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
|
|
||||||
const row = rows.nth(rowIndex)
|
|
||||||
const orderNumber = await this.extractOrderNumberFromQueryRow(row)
|
|
||||||
if (!this.isOrderNumber(orderNumber)) {
|
|
||||||
invalidOrderCount++
|
|
||||||
if (invalidOrderCount <= 3) {
|
|
||||||
log.warn('[查询结果] 跳过无效订单号行', { rowIndex, extractedValue: orderNumber })
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
validOrderCount++
|
|
||||||
result.push({ rowIndex, orderNumber })
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCollectTime = Date.now() - collectStartTime
|
|
||||||
await this.logPageStateSnapshot(popupPage, 'query.results.collected', {
|
|
||||||
level: 'info',
|
|
||||||
elapsedMs: totalCollectTime
|
|
||||||
})
|
|
||||||
log.info('[查询结果收集完成]', {
|
|
||||||
totalRowsScanned: rowCount,
|
|
||||||
validOrderCount,
|
|
||||||
invalidOrderCount,
|
|
||||||
elapsedMs: totalCollectTime,
|
|
||||||
isSlow: totalCollectTime > 3000
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private async extractOrderNumberFromQueryRow(row: Locator): Promise<string> {
|
|
||||||
try {
|
|
||||||
const cell = row.locator('td[colkey="vbillcode"]')
|
|
||||||
const codeLink = cell.locator('.code-detail-link').first()
|
|
||||||
const linkCount = await codeLink.count()
|
|
||||||
|
|
||||||
log.verbose('[提取订单号] 尝试从行提取订单号', {
|
|
||||||
hasLink: linkCount > 0,
|
|
||||||
extractionMethod: linkCount > 0 ? 'link' : 'cellText'
|
|
||||||
})
|
|
||||||
|
|
||||||
const rawValue = linkCount > 0 ? await codeLink.innerText() : await cell.innerText()
|
|
||||||
const value = rawValue.trim()
|
|
||||||
const match = value.match(/SC\d{14}/)
|
|
||||||
const extracted = match ? match[0] : value
|
|
||||||
|
|
||||||
log.verbose('[提取订单号完成]', {
|
|
||||||
rawValue,
|
|
||||||
extracted,
|
|
||||||
matched: !!match
|
|
||||||
})
|
|
||||||
|
|
||||||
return extracted
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
log.warn('[提取订单号失败] 提取过程发生错误', {
|
|
||||||
error: message,
|
|
||||||
fallback: 'empty string'
|
|
||||||
})
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openDetailPageFromRow(
|
private async openDetailPageFromRow(
|
||||||
@@ -720,53 +493,7 @@ export class CleanerService {
|
|||||||
workerId?: number
|
workerId?: number
|
||||||
}
|
}
|
||||||
): Promise<Page> {
|
): Promise<Page> {
|
||||||
const openStartTime = Date.now()
|
return this.navigation.openDetailPageFromRow(workFrame, popupPage, rowIndex, options)
|
||||||
const row = workFrame.locator('tbody tr').nth(rowIndex)
|
|
||||||
await row.waitFor({ state: 'visible', timeout: 15000 })
|
|
||||||
log.info('[NAV_EVENT] 准备从查询结果打开详情页', {
|
|
||||||
step: 'detail.open.from_query_row',
|
|
||||||
rowIndex,
|
|
||||||
orderNumber: options?.orderNumber,
|
|
||||||
orderIndex: options?.orderIndex,
|
|
||||||
orderPosition: options?.orderPosition,
|
|
||||||
workerId: options?.workerId
|
|
||||||
})
|
|
||||||
await this.logPageStateSnapshot(popupPage, 'detail.open.from_query_row.before_click', {
|
|
||||||
level: 'debug',
|
|
||||||
orderNumber: options?.orderNumber,
|
|
||||||
orderIndex: options?.orderIndex,
|
|
||||||
orderPosition: options?.orderPosition,
|
|
||||||
workerId: options?.workerId,
|
|
||||||
elapsedMs: Date.now() - openStartTime
|
|
||||||
})
|
|
||||||
|
|
||||||
const moreButton = row.locator('a.row-more').first()
|
|
||||||
await moreButton.scrollIntoViewIfNeeded()
|
|
||||||
|
|
||||||
const detailPagePromise = popupPage.waitForEvent('popup')
|
|
||||||
await moreButton.click()
|
|
||||||
await this.clickMaterialPlanMenu(workFrame, popupPage, options)
|
|
||||||
|
|
||||||
const detailPage = await detailPagePromise
|
|
||||||
log.info('[POPUP_EVENT] 详情页弹窗已创建', {
|
|
||||||
step: 'detail.popup.opened',
|
|
||||||
rowIndex,
|
|
||||||
orderNumber: options?.orderNumber,
|
|
||||||
orderIndex: options?.orderIndex,
|
|
||||||
orderPosition: options?.orderPosition,
|
|
||||||
workerId: options?.workerId,
|
|
||||||
popupUrl: detailPage.url(),
|
|
||||||
elapsedMs: Date.now() - openStartTime
|
|
||||||
})
|
|
||||||
await this.logPageStateSnapshot(detailPage, 'detail.popup.opened', {
|
|
||||||
level: 'info',
|
|
||||||
orderNumber: options?.orderNumber,
|
|
||||||
orderIndex: options?.orderIndex,
|
|
||||||
orderPosition: options?.orderPosition,
|
|
||||||
workerId: options?.workerId,
|
|
||||||
elapsedMs: Date.now() - openStartTime
|
|
||||||
})
|
|
||||||
return detailPage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openDetailPageFromCurrentQuery(
|
private async openDetailPageFromCurrentQuery(
|
||||||
@@ -774,87 +501,7 @@ export class CleanerService {
|
|||||||
popupPage: Page,
|
popupPage: Page,
|
||||||
orderNumber?: string
|
orderNumber?: string
|
||||||
): Promise<Page> {
|
): Promise<Page> {
|
||||||
const openStartTime = Date.now()
|
return this.navigation.openDetailPageFromCurrentQuery(workFrame, popupPage, orderNumber)
|
||||||
const firstRow = workFrame.locator('tbody tr').first()
|
|
||||||
await firstRow.waitFor({ state: 'visible', timeout: 10000 })
|
|
||||||
log.info('[NAV_EVENT] 准备从当前查询结果打开详情页', {
|
|
||||||
step: 'detail.open.from_current_query',
|
|
||||||
orderNumber
|
|
||||||
})
|
|
||||||
await this.logPageStateSnapshot(popupPage, 'detail.open.from_current_query.before_click', {
|
|
||||||
level: 'debug',
|
|
||||||
orderNumber,
|
|
||||||
elapsedMs: Date.now() - openStartTime
|
|
||||||
})
|
|
||||||
|
|
||||||
const moreButton = firstRow.locator('a.row-more').first()
|
|
||||||
const detailPagePromise = popupPage.waitForEvent('popup')
|
|
||||||
await moreButton.click()
|
|
||||||
await this.clickMaterialPlanMenu(workFrame, popupPage, { orderNumber })
|
|
||||||
|
|
||||||
const detailPage = await detailPagePromise
|
|
||||||
log.info('[POPUP_EVENT] 详情页弹窗已创建', {
|
|
||||||
step: 'detail.popup.opened.retry',
|
|
||||||
orderNumber,
|
|
||||||
popupUrl: detailPage.url(),
|
|
||||||
elapsedMs: Date.now() - openStartTime
|
|
||||||
})
|
|
||||||
await this.logPageStateSnapshot(detailPage, 'detail.popup.opened.retry', {
|
|
||||||
level: 'info',
|
|
||||||
orderNumber,
|
|
||||||
elapsedMs: Date.now() - openStartTime
|
|
||||||
})
|
|
||||||
return detailPage
|
|
||||||
}
|
|
||||||
|
|
||||||
private async clickMaterialPlanMenu(
|
|
||||||
workFrame: FrameLocator,
|
|
||||||
popupPage: Page,
|
|
||||||
options?: {
|
|
||||||
orderNumber?: string
|
|
||||||
orderIndex?: number
|
|
||||||
orderPosition?: string
|
|
||||||
workerId?: number
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
|
||||||
const candidates = [
|
|
||||||
workFrame.locator('li:visible, a:visible, span:visible, div:visible').filter({
|
|
||||||
hasText: /^备料计划$/
|
|
||||||
}),
|
|
||||||
workFrame.getByRole('menuitem', { name: '备料计划' }),
|
|
||||||
workFrame.getByText('备料计划', { exact: true }),
|
|
||||||
workFrame.getByText('备料计划')
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const target = candidate.last()
|
|
||||||
try {
|
|
||||||
await target.waitFor({ state: 'visible', timeout: 2000 })
|
|
||||||
log.info('[NAV_EVENT] 点击备料计划菜单', {
|
|
||||||
step: 'detail.menu.material_plan',
|
|
||||||
orderNumber: options?.orderNumber,
|
|
||||||
orderIndex: options?.orderIndex,
|
|
||||||
orderPosition: options?.orderPosition,
|
|
||||||
workerId: options?.workerId
|
|
||||||
})
|
|
||||||
await target.click()
|
|
||||||
await this.logPageStateSnapshot(popupPage, 'detail.menu.material_plan.clicked', {
|
|
||||||
level: 'debug',
|
|
||||||
orderNumber: options?.orderNumber,
|
|
||||||
orderIndex: options?.orderIndex,
|
|
||||||
orderPosition: options?.orderPosition,
|
|
||||||
workerId: options?.workerId
|
|
||||||
})
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
// Try next locator candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.error('Failed to locate material plan menu item', {
|
|
||||||
selectorsAttempted: candidates.length
|
|
||||||
})
|
|
||||||
throw new Error('无法定位”备料计划”菜单项(可能菜单结构已变化)')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBrowserContext(): BrowserContext | undefined {
|
private getBrowserContext(): BrowserContext | undefined {
|
||||||
@@ -1583,17 +1230,7 @@ export class CleanerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async waitForLoading(frame: FrameLocator): Promise<void> {
|
private async waitForLoading(frame: FrameLocator): Promise<void> {
|
||||||
const loadingLocator = frame
|
await this.navigation.waitForLoading(frame)
|
||||||
.locator('div')
|
|
||||||
.filter({ hasText: ERP_LOCATORS.extractor.loadingText })
|
|
||||||
.nth(1)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await loadingLocator.waitFor({ state: 'visible', timeout: 3000 })
|
|
||||||
await loadingLocator.waitFor({ state: 'hidden', timeout: 60000 })
|
|
||||||
} catch {
|
|
||||||
// Loading completed quickly or never appeared
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getInputValue(
|
private async getInputValue(
|
||||||
|
|||||||
450
src/main/services/erp/cleaner/navigation.ts
Normal file
450
src/main/services/erp/cleaner/navigation.ts
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
import type { FrameLocator, Locator, Page } from 'playwright'
|
||||||
|
import type { ErpSession } from '../../../types/erp.types'
|
||||||
|
import { ERP_LOCATORS } from '../locators'
|
||||||
|
import { capturePageContext } from '../erp-error-context'
|
||||||
|
import type { QueryResultRow } from './types'
|
||||||
|
|
||||||
|
type CleanerLogger = {
|
||||||
|
debug: (message: string, meta?: Record<string, unknown>) => void
|
||||||
|
info: (message: string, meta?: Record<string, unknown>) => void
|
||||||
|
warn: (message: string, meta?: Record<string, unknown>) => void
|
||||||
|
error: (message: string, meta?: Record<string, unknown>) => void
|
||||||
|
verbose: (message: string, meta?: Record<string, unknown>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailOpenOptions {
|
||||||
|
orderNumber?: string
|
||||||
|
orderIndex?: number
|
||||||
|
orderPosition?: string
|
||||||
|
workerId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageStateSnapshotOptions extends DetailOpenOptions {
|
||||||
|
level?: 'info' | 'warn' | 'error' | 'debug'
|
||||||
|
elapsedMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogPageStateSnapshot = (
|
||||||
|
page: Page,
|
||||||
|
step: string,
|
||||||
|
options?: PageStateSnapshotOptions
|
||||||
|
) => Promise<unknown>
|
||||||
|
|
||||||
|
export class CleanerNavigation {
|
||||||
|
constructor(
|
||||||
|
private readonly log: CleanerLogger,
|
||||||
|
private readonly logPageStateSnapshot: LogPageStateSnapshot
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async navigateToCleanerPage(
|
||||||
|
session: ErpSession
|
||||||
|
): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
|
||||||
|
const { page, mainFrame } = session
|
||||||
|
const navStartTime = Date.now()
|
||||||
|
|
||||||
|
this.log.info('开始导航到清理页面', {
|
||||||
|
sessionAvailable: !!session,
|
||||||
|
hasPage: !!page,
|
||||||
|
hasMainFrame: !!mainFrame
|
||||||
|
})
|
||||||
|
|
||||||
|
this.log.debug('[导航 Step 1] 准备点击菜单图标')
|
||||||
|
await mainFrame.locator('i').first().click()
|
||||||
|
this.log.debug('[导航 Step 1 完成] 菜单图标已点击', {
|
||||||
|
elapsedMs: Date.now() - navStartTime
|
||||||
|
})
|
||||||
|
|
||||||
|
this.log.debug('[导航 Step 2] 等待弹出窗口并点击菜单项')
|
||||||
|
const popupPromise = page.waitForEvent('popup')
|
||||||
|
await mainFrame.getByTitle('离散生产订单维护', { exact: true }).first().click()
|
||||||
|
const popupPage = await popupPromise
|
||||||
|
this.log.debug('[导航 Step 2 完成] 弹出窗口已打开', {
|
||||||
|
elapsedMs: Date.now() - navStartTime,
|
||||||
|
popupOpened: !!popupPage
|
||||||
|
})
|
||||||
|
await this.logPageStateSnapshot(popupPage, 'nav.popup_opened', {
|
||||||
|
level: 'info',
|
||||||
|
elapsedMs: Date.now() - navStartTime
|
||||||
|
})
|
||||||
|
|
||||||
|
this.log.debug('[导航 Step 3] 获取 forwardFrame 框架')
|
||||||
|
const forwardFrameLocator = popupPage.locator('#forwardFrame')
|
||||||
|
const fFrame = forwardFrameLocator.contentFrame()
|
||||||
|
this.log.debug('[导航 Step 3 完成] forwardFrame 已获取', {
|
||||||
|
elapsedMs: Date.now() - navStartTime,
|
||||||
|
frameExists: !!fFrame
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!fFrame) {
|
||||||
|
this.log.error('[导航失败] forwardFrame 为空', {
|
||||||
|
elapsedMs: Date.now() - navStartTime,
|
||||||
|
pageUrl: popupPage.url(),
|
||||||
|
contextData: await capturePageContext(
|
||||||
|
popupPage,
|
||||||
|
undefined,
|
||||||
|
'nav.forwardFrame',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'nav_forward_frame'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
throw new Error('无法访问弹出窗口的 forwardFrame')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log.debug('[导航 Step 4] 等待并获取 mainiframe 内部框架', { timeout: 30000 })
|
||||||
|
const innerFrameLocator = fFrame.locator('#mainiframe')
|
||||||
|
await innerFrameLocator.waitFor({ state: 'visible', timeout: 30000 })
|
||||||
|
const workFrame = innerFrameLocator.contentFrame()
|
||||||
|
this.log.debug('[导航 Step 4 完成] 内部工作框架已获取', {
|
||||||
|
elapsedMs: Date.now() - navStartTime,
|
||||||
|
frameExists: !!workFrame
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!workFrame) {
|
||||||
|
this.log.error('[导航失败] workFrame 为空', {
|
||||||
|
elapsedMs: Date.now() - navStartTime,
|
||||||
|
pageUrl: popupPage.url(),
|
||||||
|
contextData: await capturePageContext(
|
||||||
|
popupPage,
|
||||||
|
undefined,
|
||||||
|
'nav.workFrame',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'nav_work_frame'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
throw new Error('无法访问内部工作框架')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log.debug('[导航 Step 5] 等待页面就绪标志', {
|
||||||
|
selector: '#hot-key-head_list',
|
||||||
|
timeout: 30000
|
||||||
|
})
|
||||||
|
await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 })
|
||||||
|
const totalNavTime = Date.now() - navStartTime
|
||||||
|
await this.logPageStateSnapshot(popupPage, 'nav.cleaner_page_ready', {
|
||||||
|
level: 'info',
|
||||||
|
elapsedMs: totalNavTime
|
||||||
|
})
|
||||||
|
this.log.info('[导航完成] 已导航到清理页面', {
|
||||||
|
totalNavTimeMs: totalNavTime,
|
||||||
|
isSlow: totalNavTime > 5000
|
||||||
|
})
|
||||||
|
|
||||||
|
return { popupPage, workFrame }
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupQueryInterface(innerFrame: FrameLocator, popupPage: Page): Promise<void> {
|
||||||
|
const setupStartTime = Date.now()
|
||||||
|
this.log.debug('[查询界面设置开始] 准备配置查询界面')
|
||||||
|
await this.logPageStateSnapshot(popupPage, 'query.setup.start', {
|
||||||
|
level: 'debug',
|
||||||
|
elapsedMs: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
this.log.debug('[查询设置 Step 1] 点击搜索图标')
|
||||||
|
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
|
||||||
|
this.log.debug('[查询设置 Step 1 完成] 搜索图标已点击', {
|
||||||
|
elapsedMs: Date.now() - setupStartTime
|
||||||
|
})
|
||||||
|
|
||||||
|
this.log.debug('[查询设置 Step 2] 选择订单号查询模式')
|
||||||
|
await innerFrame.getByText('订单号查询').click()
|
||||||
|
this.log.debug('[查询设置 Step 2 完成] 已切换到订单号查询', {
|
||||||
|
elapsedMs: Date.now() - setupStartTime
|
||||||
|
})
|
||||||
|
|
||||||
|
this.log.debug('[查询设置 Step 3] 切换到全部标签页')
|
||||||
|
await innerFrame.getByRole('tab', { name: '全部' }).click()
|
||||||
|
this.log.debug('[查询设置 Step 3 完成] 已全部标签页激活', {
|
||||||
|
elapsedMs: Date.now() - setupStartTime
|
||||||
|
})
|
||||||
|
|
||||||
|
this.log.debug('[查询设置 Step 4] 设置查询数量限制为 5000')
|
||||||
|
const inputEl = innerFrame.locator('#rc_select_0')
|
||||||
|
await inputEl.fill('5000')
|
||||||
|
await inputEl.press('Enter')
|
||||||
|
const totalSetupTime = Date.now() - setupStartTime
|
||||||
|
await this.logPageStateSnapshot(popupPage, 'query.setup.ready', {
|
||||||
|
level: 'info',
|
||||||
|
elapsedMs: totalSetupTime
|
||||||
|
})
|
||||||
|
this.log.debug('[查询设置完成] 查询界面配置完毕', {
|
||||||
|
totalSetupTimeMs: totalSetupTime,
|
||||||
|
queryLimit: 5000,
|
||||||
|
isSlow: totalSetupTime > 2000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryOrders(
|
||||||
|
workFrame: FrameLocator,
|
||||||
|
popupPage: Page,
|
||||||
|
orderNumbers: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
const queryStartTime = Date.now()
|
||||||
|
this.log.debug('[订单查询开始]', {
|
||||||
|
orderCount: orderNumbers.length,
|
||||||
|
orderNumbers: orderNumbers
|
||||||
|
.slice(0, 5)
|
||||||
|
.concat(orderNumbers.length > 5 ? [`... (${orderNumbers.length - 5} more)`] : []),
|
||||||
|
isPreview: orderNumbers.length > 5
|
||||||
|
})
|
||||||
|
await this.logPageStateSnapshot(popupPage, 'query.before_submit', {
|
||||||
|
level: 'debug',
|
||||||
|
elapsedMs: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const textbox = workFrame.getByRole('textbox', { name: '生产订单号' })
|
||||||
|
this.log.debug('[订单查询] 准备填入订单号')
|
||||||
|
await textbox.fill(orderNumbers.join(','))
|
||||||
|
this.log.debug('[订单查询] 订单号已填入', {
|
||||||
|
elapsedMs: Date.now() - queryStartTime,
|
||||||
|
charCount: orderNumbers.join(',').length
|
||||||
|
})
|
||||||
|
|
||||||
|
this.log.debug('[订单查询] 点击搜索按钮')
|
||||||
|
await workFrame.locator('.search-component-searchBtn').click()
|
||||||
|
this.log.info('[订单查询完成] 查询请求已发送', {
|
||||||
|
elapsedMs: Date.now() - queryStartTime,
|
||||||
|
orderCount: orderNumbers.length
|
||||||
|
})
|
||||||
|
await this.logPageStateSnapshot(popupPage, 'query.after_submit', {
|
||||||
|
level: 'info',
|
||||||
|
elapsedMs: Date.now() - queryStartTime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async collectQueryResultRows(
|
||||||
|
workFrame: FrameLocator,
|
||||||
|
popupPage: Page
|
||||||
|
): Promise<QueryResultRow[]> {
|
||||||
|
const collectStartTime = Date.now()
|
||||||
|
this.log.debug('[查询结果收集开始] 准备读取查询结果表格')
|
||||||
|
|
||||||
|
const rows = workFrame.locator('tbody tr')
|
||||||
|
const rowCount = await rows.count()
|
||||||
|
this.log.debug('[查询结果收集] 检测到表格行数', { rowCount })
|
||||||
|
|
||||||
|
const result: QueryResultRow[] = []
|
||||||
|
let validOrderCount = 0
|
||||||
|
let invalidOrderCount = 0
|
||||||
|
|
||||||
|
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
|
||||||
|
const row = rows.nth(rowIndex)
|
||||||
|
const orderNumber = await this.extractOrderNumberFromQueryRow(row)
|
||||||
|
if (!this.isOrderNumber(orderNumber)) {
|
||||||
|
invalidOrderCount++
|
||||||
|
if (invalidOrderCount <= 3) {
|
||||||
|
this.log.warn('[查询结果] 跳过无效订单号行', { rowIndex, extractedValue: orderNumber })
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
validOrderCount++
|
||||||
|
result.push({ rowIndex, orderNumber })
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCollectTime = Date.now() - collectStartTime
|
||||||
|
await this.logPageStateSnapshot(popupPage, 'query.results.collected', {
|
||||||
|
level: 'info',
|
||||||
|
elapsedMs: totalCollectTime
|
||||||
|
})
|
||||||
|
this.log.info('[查询结果收集完成]', {
|
||||||
|
totalRowsScanned: rowCount,
|
||||||
|
validOrderCount,
|
||||||
|
invalidOrderCount,
|
||||||
|
elapsedMs: totalCollectTime,
|
||||||
|
isSlow: totalCollectTime > 3000
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async openDetailPageFromRow(
|
||||||
|
workFrame: FrameLocator,
|
||||||
|
popupPage: Page,
|
||||||
|
rowIndex: number,
|
||||||
|
options?: DetailOpenOptions
|
||||||
|
): Promise<Page> {
|
||||||
|
const openStartTime = Date.now()
|
||||||
|
const row = workFrame.locator('tbody tr').nth(rowIndex)
|
||||||
|
await row.waitFor({ state: 'visible', timeout: 15000 })
|
||||||
|
this.log.info('[NAV_EVENT] 准备从查询结果打开详情页', {
|
||||||
|
step: 'detail.open.from_query_row',
|
||||||
|
rowIndex,
|
||||||
|
orderNumber: options?.orderNumber,
|
||||||
|
orderIndex: options?.orderIndex,
|
||||||
|
orderPosition: options?.orderPosition,
|
||||||
|
workerId: options?.workerId
|
||||||
|
})
|
||||||
|
await this.logPageStateSnapshot(popupPage, 'detail.open.from_query_row.before_click', {
|
||||||
|
level: 'debug',
|
||||||
|
orderNumber: options?.orderNumber,
|
||||||
|
orderIndex: options?.orderIndex,
|
||||||
|
orderPosition: options?.orderPosition,
|
||||||
|
workerId: options?.workerId,
|
||||||
|
elapsedMs: Date.now() - openStartTime
|
||||||
|
})
|
||||||
|
|
||||||
|
const moreButton = row.locator('a.row-more').first()
|
||||||
|
await moreButton.scrollIntoViewIfNeeded()
|
||||||
|
|
||||||
|
const detailPagePromise = popupPage.waitForEvent('popup')
|
||||||
|
await moreButton.click()
|
||||||
|
await this.clickMaterialPlanMenu(workFrame, popupPage, options)
|
||||||
|
|
||||||
|
const detailPage = await detailPagePromise
|
||||||
|
this.log.info('[POPUP_EVENT] 详情页弹窗已创建', {
|
||||||
|
step: 'detail.popup.opened',
|
||||||
|
rowIndex,
|
||||||
|
orderNumber: options?.orderNumber,
|
||||||
|
orderIndex: options?.orderIndex,
|
||||||
|
orderPosition: options?.orderPosition,
|
||||||
|
workerId: options?.workerId,
|
||||||
|
popupUrl: detailPage.url(),
|
||||||
|
elapsedMs: Date.now() - openStartTime
|
||||||
|
})
|
||||||
|
await this.logPageStateSnapshot(detailPage, 'detail.popup.opened', {
|
||||||
|
level: 'info',
|
||||||
|
orderNumber: options?.orderNumber,
|
||||||
|
orderIndex: options?.orderIndex,
|
||||||
|
orderPosition: options?.orderPosition,
|
||||||
|
workerId: options?.workerId,
|
||||||
|
elapsedMs: Date.now() - openStartTime
|
||||||
|
})
|
||||||
|
return detailPage
|
||||||
|
}
|
||||||
|
|
||||||
|
async openDetailPageFromCurrentQuery(
|
||||||
|
workFrame: FrameLocator,
|
||||||
|
popupPage: Page,
|
||||||
|
orderNumber?: string
|
||||||
|
): Promise<Page> {
|
||||||
|
const openStartTime = Date.now()
|
||||||
|
const firstRow = workFrame.locator('tbody tr').first()
|
||||||
|
await firstRow.waitFor({ state: 'visible', timeout: 10000 })
|
||||||
|
this.log.info('[NAV_EVENT] 准备从当前查询结果打开详情页', {
|
||||||
|
step: 'detail.open.from_current_query',
|
||||||
|
orderNumber
|
||||||
|
})
|
||||||
|
await this.logPageStateSnapshot(popupPage, 'detail.open.from_current_query.before_click', {
|
||||||
|
level: 'debug',
|
||||||
|
orderNumber,
|
||||||
|
elapsedMs: Date.now() - openStartTime
|
||||||
|
})
|
||||||
|
|
||||||
|
const moreButton = firstRow.locator('a.row-more').first()
|
||||||
|
const detailPagePromise = popupPage.waitForEvent('popup')
|
||||||
|
await moreButton.click()
|
||||||
|
await this.clickMaterialPlanMenu(workFrame, popupPage, { orderNumber })
|
||||||
|
|
||||||
|
const detailPage = await detailPagePromise
|
||||||
|
this.log.info('[POPUP_EVENT] 详情页弹窗已创建', {
|
||||||
|
step: 'detail.popup.opened.retry',
|
||||||
|
orderNumber,
|
||||||
|
popupUrl: detailPage.url(),
|
||||||
|
elapsedMs: Date.now() - openStartTime
|
||||||
|
})
|
||||||
|
await this.logPageStateSnapshot(detailPage, 'detail.popup.opened.retry', {
|
||||||
|
level: 'info',
|
||||||
|
orderNumber,
|
||||||
|
elapsedMs: Date.now() - openStartTime
|
||||||
|
})
|
||||||
|
return detailPage
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForLoading(frame: FrameLocator): Promise<void> {
|
||||||
|
const loadingLocator = frame
|
||||||
|
.locator('div')
|
||||||
|
.filter({ hasText: ERP_LOCATORS.extractor.loadingText })
|
||||||
|
.nth(1)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadingLocator.waitFor({ state: 'visible', timeout: 3000 })
|
||||||
|
await loadingLocator.waitFor({ state: 'hidden', timeout: 60000 })
|
||||||
|
} catch {
|
||||||
|
// Loading completed quickly or never appeared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async extractOrderNumberFromQueryRow(row: Locator): Promise<string> {
|
||||||
|
try {
|
||||||
|
const cell = row.locator('td[colkey="vbillcode"]')
|
||||||
|
const codeLink = cell.locator('.code-detail-link').first()
|
||||||
|
const linkCount = await codeLink.count()
|
||||||
|
|
||||||
|
this.log.verbose('[提取订单号] 尝试从行提取订单号', {
|
||||||
|
hasLink: linkCount > 0,
|
||||||
|
extractionMethod: linkCount > 0 ? 'link' : 'cellText'
|
||||||
|
})
|
||||||
|
|
||||||
|
const rawValue = linkCount > 0 ? await codeLink.innerText() : await cell.innerText()
|
||||||
|
const value = rawValue.trim()
|
||||||
|
const match = value.match(/SC\d{14}/)
|
||||||
|
const extracted = match ? match[0] : value
|
||||||
|
|
||||||
|
this.log.verbose('[提取订单号完成]', {
|
||||||
|
rawValue,
|
||||||
|
extracted,
|
||||||
|
matched: !!match
|
||||||
|
})
|
||||||
|
|
||||||
|
return extracted
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
this.log.warn('[提取订单号失败] 提取过程发生错误', {
|
||||||
|
error: message,
|
||||||
|
fallback: 'empty string'
|
||||||
|
})
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clickMaterialPlanMenu(
|
||||||
|
workFrame: FrameLocator,
|
||||||
|
popupPage: Page,
|
||||||
|
options?: DetailOpenOptions
|
||||||
|
): Promise<void> {
|
||||||
|
const candidates = [
|
||||||
|
workFrame.locator('li:visible, a:visible, span:visible, div:visible').filter({
|
||||||
|
hasText: /^备料计划$/
|
||||||
|
}),
|
||||||
|
workFrame.getByRole('menuitem', { name: '备料计划' }),
|
||||||
|
workFrame.getByText('备料计划', { exact: true }),
|
||||||
|
workFrame.getByText('备料计划')
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const target = candidate.last()
|
||||||
|
try {
|
||||||
|
await target.waitFor({ state: 'visible', timeout: 2000 })
|
||||||
|
this.log.info('[NAV_EVENT] 点击备料计划菜单', {
|
||||||
|
step: 'detail.menu.material_plan',
|
||||||
|
orderNumber: options?.orderNumber,
|
||||||
|
orderIndex: options?.orderIndex,
|
||||||
|
orderPosition: options?.orderPosition,
|
||||||
|
workerId: options?.workerId
|
||||||
|
})
|
||||||
|
await target.click()
|
||||||
|
await this.logPageStateSnapshot(popupPage, 'detail.menu.material_plan.clicked', {
|
||||||
|
level: 'debug',
|
||||||
|
orderNumber: options?.orderNumber,
|
||||||
|
orderIndex: options?.orderIndex,
|
||||||
|
orderPosition: options?.orderPosition,
|
||||||
|
workerId: options?.workerId
|
||||||
|
})
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// Try next locator candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log.error('Failed to locate material plan menu item', {
|
||||||
|
selectorsAttempted: candidates.length
|
||||||
|
})
|
||||||
|
throw new Error('无法定位”备料计划”菜单项(可能菜单结构已变化)')
|
||||||
|
}
|
||||||
|
|
||||||
|
private isOrderNumber(value: string): boolean {
|
||||||
|
return /^SC\d{14}$/.test(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user