feat: 完善WCS调度、库存追溯和出库分配

This commit is contained in:
zgw
2026-07-15 16:24:07 +08:00
parent 9d3aa7a3bd
commit 66ac268524
101 changed files with 12596 additions and 669 deletions

View File

@@ -0,0 +1,60 @@
const fs = require('fs')
const path = require('path')
const root = path.resolve(__dirname, '..')
function read(file) {
return fs.readFileSync(path.join(root, file), 'utf8')
}
function assertMatch(message, content, pattern) {
if (!pattern.test(content)) {
throw new Error(message)
}
}
for (const file of ['ruoyi-ui/src/views/in/add/index.vue', 'ruoyi-ui/src/views/out/create/index.vue']) {
const content = read(file)
assertMatch(`${file} 新建单据来源必须默认自建单`, content, /stockSource:\s*'1'/)
assertMatch(`${file} 表单栅格必须使用稳定类名`, content, /class="stable-form-grid"/)
assertMatch(`${file} 表单控件必须固定为满宽`, content, /\.create-(stockin|stockout)[\s\S]*\.el-select[\s\S]*width:\s*100%/)
assertMatch(`${file} 表单项必须预留错误提示高度防止选择时抖动`, content, /\.create-(stockin|stockout)[\s\S]*\.el-form-item[\s\S]*margin-bottom:\s*22px/)
}
const pdaMapper = read('ruoyi-admin/src/main/resources/mapper/pda/PdaMapper.xml')
assertMatch(
'PDA 必须先回滚同托盘同仓库残留待绑数量',
pdaMapper,
/id="rollbackPendingTrayBindings"[\s\S]*scan_num[\s\S]*in_tray_msg/
)
assertMatch(
'PDA 必须删除同托盘同仓库残留待绑记录',
pdaMapper,
/id="deletePendingTrayBindings"[\s\S]*delete\s+from\s+in_tray_msg/
)
const pdService = read('ruoyi-admin/src/main/java/com/ruoyi/pd/stockpd/service/impl/PdStockpdServiceImpl.java')
assertMatch(
'盘点状态过程返回值必须用 Convert.toStr 兼容 INTEGER OUT 参数',
pdService,
/Convert\.toStr\(map\.get\("v_result"\)\)/
)
const inventorySql = read('sql/20260626_inventory_status_proc.sql')
assertMatch(
'盘点状态过程脚本必须创建 proc_update_pdbill_status',
inventorySql,
/CREATE\s+PROCEDURE\s+dbo\.proc_update_pdbill_status/i
)
assertMatch(
'盘点完成必须归档主单',
inventorySql,
/INSERT\s+INTO\s+dbo\.pd_stockpd_finish/i
)
assertMatch(
'盘点完成必须归档明细',
inventorySql,
/INSERT\s+INTO\s+dbo\.pd_stockpd_detail_finish/i
)
console.log('Goal fix checks passed')

View File

@@ -0,0 +1,102 @@
const fs = require('fs')
const path = require('path')
const root = path.resolve(__dirname, '..')
function read(file) {
return fs.readFileSync(path.join(root, file), 'utf8')
}
function assertMatch(message, content, pattern) {
if (!pattern.test(content)) {
throw new Error(message)
}
}
function assertNotMatch(message, content, pattern) {
if (pattern.test(content)) {
throw new Error(message)
}
}
const inventoryProc = read('sql/20260626_inventory_status_proc.sql')
assertMatch(
'盘点作废必须把主单插入历史表',
inventoryProc,
/IF\s+@target_status\s*=\s*N'5'[\s\S]*INSERT\s+INTO\s+dbo\.pd_stockpd_finish/i
)
assertMatch(
'盘点作废必须把明细插入历史表',
inventoryProc,
/IF\s+@target_status\s*=\s*N'5'[\s\S]*INSERT\s+INTO\s+dbo\.pd_stockpd_detail_finish/i
)
assertMatch(
'盘点作废必须删除业务明细',
inventoryProc,
/IF\s+@target_status\s*=\s*N'5'[\s\S]*DELETE\s+FROM\s+dbo\.pd_stockpd_detail/i
)
assertMatch(
'盘点作废必须删除业务主单',
inventoryProc,
/IF\s+@target_status\s*=\s*N'5'[\s\S]*DELETE\s+FROM\s+dbo\.pd_stockpd/i
)
assertNotMatch(
'盘点作废不能只更新业务表状态',
inventoryProc,
/IF\s+@target_status\s*=\s*N'5'[\s\S]*UPDATE\s+dbo\.pd_stockpd[\s\S]*SET\s+status\s*=\s*N'5'/i
)
const pdInController = read('ruoyi-admin/src/main/java/com/ruoyi/pd/job/controller/PdOutMydoJobController.java')
assertMatch(
'/pd/inJob 当前控制器必须查询盘点入库作业列表',
pdInController,
/@RequestMapping\("\/pd\/inJob"\)[\s\S]*selectInventoryInMydoJobList\(mydoJob\)/
)
assertMatch(
'/pd/inJob 当前控制器必须查询盘点入库历史作业列表',
pdInController,
/@RequestMapping\("\/pd\/inJob"\)[\s\S]*selectInventoryInMydoJobFinishList\(mydoJob\)/
)
assertNotMatch(
'/pd/inJob 当前控制器不能使用出库列表口径',
pdInController,
/@RequestMapping\("\/pd\/inJob"\)[\s\S]*selectOutMydoJobList\(mydoJob\)/
)
assertMatch(
'/pd/inJob 手动作废必须走入库作业过程',
pdInController,
/cancelJob\(jobId,\s*"1"\)/
)
assertMatch(
'/pd/inJob 手动完成必须走入库作业过程',
pdInController,
/completeJob\(jobId,\s*"1"\)/
)
const pdOutController = read('ruoyi-admin/src/main/java/com/ruoyi/pd/job/controller/PdInMydoJobController.java')
assertMatch(
'/pd/outJob 当前控制器必须查询盘点出库作业列表',
pdOutController,
/@RequestMapping\("\/pd\/outJob"\)[\s\S]*selectInventoryOutMydoJobList\(mydoJob\)/
)
assertMatch(
'/pd/outJob 当前控制器必须查询盘点出库历史作业列表',
pdOutController,
/@RequestMapping\("\/pd\/outJob"\)[\s\S]*selectInventoryOutMydoJobFinishList\(mydoJob\)/
)
const mydoJobMapper = read('ruoyi-admin/src/main/resources/mapper/in/MydoJobMapper.xml')
assertMatch('盘点入库作业列表必须限定 in_or_out in (10, 11)', mydoJobMapper, /id="selectInventoryInMydoJobList"[\s\S]*in_or_out\s+in\s+\(10,\s*11\)/i)
assertMatch('盘点入库历史作业列表必须限定 in_or_out in (10, 11)', mydoJobMapper, /id="selectInventoryInMydoJobFinishList"[\s\S]*in_or_out\s+in\s+\(10,\s*11\)/i)
assertMatch('盘点出库作业列表必须限定 in_or_out in (30, 31)', mydoJobMapper, /id="selectInventoryOutMydoJobList"[\s\S]*in_or_out\s+in\s+\(30,\s*31\)/i)
assertMatch('盘点出库历史作业列表必须限定 in_or_out in (30, 31)', mydoJobMapper, /id="selectInventoryOutMydoJobFinishList"[\s\S]*in_or_out\s+in\s+\(30,\s*31\)/i)
const pdInPage = read('ruoyi-ui/src/views/pd/job/in/index.vue')
assertMatch('盘点入库页面必须加载盘点入库任务类型字典', pdInPage, /dicts:\s*\[[^\]]*'pd_in_job_type'/)
assertMatch('盘点入库页面任务类型筛选必须使用盘点入库字典', pdInPage, /dict\.type\.pd_in_job_type/)
const pdOutPage = read('ruoyi-ui/src/views/pd/job/out/index.vue')
assertMatch('盘点出库页面必须加载盘点出库任务类型字典', pdOutPage, /dicts:\s*\[[^\]]*'pd_out_job_type'/)
assertMatch('盘点出库页面任务类型筛选必须使用盘点出库字典', pdOutPage, /dict\.type\.pd_out_job_type/)
console.log('Inventory cancel and inbound job checks passed')

View File

@@ -0,0 +1,61 @@
const fs = require('fs')
const path = require('path')
const root = path.resolve(__dirname, '..')
function read(file) {
return fs.readFileSync(path.join(root, file), 'utf8')
}
function assertMatch(message, content, pattern) {
if (!pattern.test(content)) {
throw new Error(message)
}
}
function assertNotMatch(message, content, pattern) {
if (pattern.test(content)) {
throw new Error(message)
}
}
function section(content, startMarker, endMarker) {
const start = content.indexOf(startMarker)
if (start < 0) {
throw new Error(`未找到开始标记: ${startMarker}`)
}
const end = content.indexOf(endMarker, start)
if (end < 0) {
throw new Error(`未找到结束标记: ${endMarker}`)
}
return content.slice(start, end)
}
const stockView = read('ruoyi-ui/src/views/bas/stock/index.vue')
const detailDialog = section(stockView, '<!-- 添加或修改库存物料明细对话框 -->', '</el-dialog>')
assertMatch('库存子表必须显示项目号', stockView, /<el-table-column[^>]*label="项目号"[^>]*prop="projectNo"/)
assertMatch('子作业 ID 输入框必须只读', detailDialog, /v-model="detailForm\.jobDetailId"[^>]*readonly/)
assertMatch('入库子单 ID 输入框必须只读', detailDialog, /v-model="detailForm\.stockinDetailId"[^>]*readonly/)
assertMatch('库存明细物料状态必须使用开关', detailDialog, /<el-switch[\s\S]*v-model="detailForm\.productStatus"/)
assertMatch('物料状态开关正常值必须为字符串 1', detailDialog, /active-value="1"/)
assertMatch('物料状态开关异常值必须为字符串 2', detailDialog, /inactive-value="2"/)
assertMatch('新增库存明细物料状态必须默认正常', stockView, /resetDetail\(\)[\s\S]*productStatus:\s*'1'/)
const stockDetailMapper = read('ruoyi-admin/src/main/resources/mapper/bas/BasStockDetailMapper.xml')
const updateDetail = section(stockDetailMapper, '<update id="updateBasStockDetail"', '</update>')
assertNotMatch('库存明细更新 SQL 不得更新子作业 ID', updateDetail, /job_detail_id\s*=/)
assertNotMatch('库存明细更新 SQL 不得更新入库子单 ID', updateDetail, /stockin_detail_id\s*=/)
const createStockoutView = read('ruoyi-ui/src/views/out/create/index.vue')
const createStockQuery = section(createStockoutView, 'stockQuery: {', 'selectedStocks:')
assertMatch('出库创建页候选库存必须固定筛选正常库存状态', createStockQuery, /stockStatus:\s*'1'/)
assertMatch('出库创建页候选库存必须固定筛选正常物料状态', createStockQuery, /productStatus:\s*'1'/)
assertMatch('出库创建页必须使用双正常筛选对象查询库存', createStockoutView, /listStockDetail\(this\.stockQuery\)/)
const stockoutView = read('ruoyi-ui/src/views/out/stockout/index.vue')
const manualStockQuery = section(stockoutView, 'listStockDetail({', '}).then(res =>')
assertMatch('手动分配候选库存必须筛选正常库存状态', manualStockQuery, /stockStatus:\s*'1'/)
assertMatch('手动分配候选库存必须筛选正常物料状态', manualStockQuery, /productStatus:\s*'1'/)
console.log('WCS、库存与出库需求静态检查通过')

View File

@@ -0,0 +1,140 @@
import os
from pathlib import Path
from urllib.parse import urlparse
from playwright.sync_api import sync_playwright
FRONTEND_URL = "http://localhost"
BACKEND_URL = os.environ.get("DENSE_PLAN_BACKEND_URL", "http://127.0.0.1:8080")
TEST_USERNAME = os.environ.get("DENSE_PLAN_USERNAME")
TEST_PASSWORD = os.environ.get("DENSE_PLAN_PASSWORD")
SCREENSHOT_PATH = Path(
os.environ.get(
"DENSE_PLAN_SCREENSHOT",
r"C:\Users\13623\.codex\visualizations\2026\07\14\019f5e36-a2f0-7751-86cc-3d8ac317f956\dense-cargo-space-plan.png",
)
)
if not TEST_USERNAME or not TEST_PASSWORD:
raise RuntimeError("请通过 DENSE_PLAN_USERNAME 和 DENSE_PLAN_PASSWORD 提供测试账号")
def choose_warehouse(page, warehouse_name):
warehouse_field = page.locator(".el-form-item").filter(has_text="仓库").first
warehouse_field.locator(".el-select").click()
page.locator(".el-select-dropdown:visible .el-select-dropdown__item").filter(
has_text=warehouse_name
).click()
def assert_no_internal_overflow(page):
for selector in (".dense-map__viewport", ".dense-grid"):
metrics = page.locator(selector).evaluate(
"node => ({ scrollWidth: node.scrollWidth, clientWidth: node.clientWidth, "
"scrollHeight: node.scrollHeight, clientHeight: node.clientHeight })"
)
assert metrics["scrollWidth"] <= metrics["clientWidth"] + 1, (selector, metrics)
assert metrics["scrollHeight"] <= metrics["clientHeight"] + 1, (selector, metrics)
with sync_playwright() as playwright:
browser = playwright.chromium.launch(
headless=True,
executable_path=r"C:\Program Files\Google\Chrome\Application\chrome.exe",
)
context = browser.new_context(viewport={"width": 1500, "height": 1050})
page = context.new_page()
console_errors = []
page.on("console", lambda message: console_errors.append(message.text) if message.type == "error" else None)
login_response = page.request.post(
f"{BACKEND_URL}/login",
data={"username": TEST_USERNAME, "password": TEST_PASSWORD, "code": "", "uuid": ""},
)
login_data = login_response.json()
assert login_data.get("code") == 200, login_data
token = login_data["token"]
context.add_cookies(
[{"name": "Admin-Token", "value": token, "url": FRONTEND_URL}]
)
def route_backend(route):
request = route.request
parsed = urlparse(request.url)
backend_path = parsed.path.removeprefix("/dev-api")
target_url = f"{BACKEND_URL}{backend_path}"
if parsed.query:
target_url += f"?{parsed.query}"
response = page.request.fetch(
target_url,
method=request.method,
headers=request.headers,
data=request.post_data,
)
route.fulfill(response=response)
page.route("**/dev-api/**", route_backend)
page.goto(f"{FRONTEND_URL}/kpi/planKpi")
page.wait_for_load_state("networkidle")
choose_warehouse(page, "密集库")
dense_map = page.get_by_test_id("dense-cargo-space-map")
dense_map.wait_for(state="visible")
page.wait_for_function(
"document.querySelectorAll('[data-testid=dense-location]').length === 259"
)
floor_one_count = page.get_by_test_id("dense-location").count()
mother_count = page.locator('[data-testid="dense-location"][data-status="9"]').count()
lift_count = page.locator('[data-testid="dense-location"][data-status="7"]').count()
conveyor_count = page.locator('[data-testid="dense-location"][data-status="8"]').count()
charging_count = page.locator('[data-testid="dense-location"][data-status="10"]').count()
assert (floor_one_count, mother_count, lift_count, conveyor_count, charging_count) == (259, 49, 2, 2, 1)
assert page.get_by_text("充电桩", exact=True).count() >= 1
assert page.locator(".axis-label--column").count() == 24
assert page.locator(".axis-label--column", has_text="25").count() == 0
assert page.locator(".search-box").get_by_test_id("floor-switch").count() == 1
assert page.locator(".search-box").get_by_test_id("dense-refresh").count() == 1
assert page.get_by_text("密集库模式:按层展示完整平面坐标", exact=True).count() == 0
assert page.get_by_text("密集库货位平面图", exact=True).count() == 0
assert_no_internal_overflow(page)
assert page.locator('[data-testid="dense-location"][data-platoon="7"][data-column="13"]').count() == 1
assert page.locator('[data-testid="dense-location"][data-platoon="1"][data-column="13"]').count() == 1
assert page.locator('[data-testid="dense-location"][data-platoon="13"][data-column="13"]').count() == 1
page.locator('[data-testid="dense-location"][data-platoon="1"][data-column="1"]').click()
assert page.get_by_text("货区 A", exact=True).count() == 1
assert page.get_by_text("区域 Z01", exact=True).count() == 0
page.get_by_test_id("floor-switch").get_by_text("2层", exact=True).click()
page.wait_for_function(
"Array.from(document.querySelectorAll('[data-testid=dense-location]')).every(node => node.textContent.trim().endsWith('02'))"
)
floor_two_count = page.get_by_test_id("dense-location").count()
assert floor_two_count == 259
assert page.locator('[data-testid="dense-location"][data-status="10"]').count() == 1
page.set_viewport_size({"width": 1280, "height": 800})
page.wait_for_timeout(300)
assert_no_internal_overflow(page)
SCREENSHOT_PATH.parent.mkdir(parents=True, exist_ok=True)
dense_map.screenshot(path=str(SCREENSHOT_PATH))
choose_warehouse(page, "托盘库1")
page.locator(".chart-container--normal").wait_for(state="visible")
page.locator(".chart-container--normal canvas").first.wait_for(state="visible")
assert page.get_by_test_id("dense-cargo-space-map").count() == 0
relevant_errors = [
error for error in console_errors
if "favicon" not in error.lower() and "sockjs" not in error.lower()
]
assert not relevant_errors, relevant_errors
print(
"dense-plan-ok "
f"floor1={floor_one_count} floor2={floor_two_count} "
f"mother={mother_count} lift={lift_count} conveyor={conveyor_count} charging={charging_count} "
"legacy-chart=visible console-errors=0"
)
browser.close()