Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in
Toggle navigation
R
rentos
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
accounting
rentos
Commits
57cf814d
Commit
57cf814d
authored
Oct 14, 2026
by
wenwen.tang
😕
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
test(ui): ui_verify1-7 加 RENTOS_BASE 闸门并适配账期折叠,新增 ui_verify11
parent
9326844a
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
698 additions
and
41 deletions
+698
-41
ui_verify.py
tools/ui_verify.py
+15
-4
ui_verify11.py
tools/ui_verify11.py
+568
-0
ui_verify2.py
tools/ui_verify2.py
+38
-19
ui_verify3.py
tools/ui_verify3.py
+15
-4
ui_verify4.py
tools/ui_verify4.py
+18
-6
ui_verify5.py
tools/ui_verify5.py
+27
-6
ui_verify6.py
tools/ui_verify6.py
+13
-2
ui_verify7.py
tools/ui_verify7.py
+4
-0
No files found.
tools/ui_verify.py
View file @
57cf814d
...
...
@@ -4,9 +4,9 @@
====================================
台账(房租车位费)→ 改单元格 → 增行 → 删行 → 搜索 → 电费手工录入 → 还原现场。
运行:
python tools/ui_verify.py
RENTOS_
BASE=http://127.0.0.1:8901 python tools/ui_verify.py
运行
(该脚本会写数据,默认目标 8899 是【真实台账】,必须走沙箱)
:
python tools/ui_verify
_runner.py tools/ui_verify
.py
RENTOS_
ALLOW_REAL=1 python tools/ui_verify.py # 确需直连真实服务才这样用
覆盖的改造点:
* 只有一张台账「房租车位费」(旧的两张已退休)
...
...
@@ -21,7 +21,18 @@ warnings.filterwarnings("ignore")
from
playwright.sync_api
import
sync_playwright
BASE
=
os
.
environ
.
get
(
"RENTOS_BASE"
,
"http://127.0.0.1:8899"
)
BASE
=
os
.
environ
.
get
(
"RENTOS_BASE"
,
""
)
if
not
BASE
:
# 该脚本会写数据;未注入 RENTOS_BASE 时默认目标 8899 是【真实台账】,
# 直连会污染权威数据(2026-10-13 曾误写入 17 个测试批次)。
# 正常路径:tools/ui_verify_runner.py(沙箱 + 注入 RENTOS_BASE)。
if
os
.
environ
.
get
(
"RENTOS_ALLOW_REAL"
)
==
"1"
:
BASE
=
"http://127.0.0.1:8899"
else
:
_me
=
os
.
path
.
basename
(
__file__
)
sys
.
exit
(
"已阻止:
%
s 未设置 RENTOS_BASE(默认 8899 是【真实台账】)。
\n
"
" 沙箱运行: python tools/ui_verify_runner.py tools/
%
s
\n
"
" 确需打真实服务:RENTOS_ALLOW_REAL=1 python tools/
%
s"
%
(
_me
,
_me
,
_me
))
OUT
=
os
.
path
.
join
(
os
.
path
.
dirname
(
os
.
path
.
abspath
(
__file__
)),
"shots"
)
os
.
makedirs
(
OUT
,
exist_ok
=
True
)
...
...
tools/ui_verify11.py
0 → 100644
View file @
57cf814d
# -*- coding: utf-8 -*-
"""
「电费批次重编 + 逐室核对 + 电费手动发送 + 3F/6F/7F 总表」端到端验证(2026-10-13)
=================================================================================
对应需求(巴特菲):
C. 批次台账里的数据要能「重新编辑」→ 回录入页改完覆盖原批次;批次台账加「核对」
按钮逐室 ✔;批次详情显示核对状态;**未确认核对不能发送**;电费**全部手动发送**
(设置里的电费自动发送去掉)。
D. 新增 3F / 6F / 7F 总表,在录入本期电费时一并录入(明细类目 / 上期 / 本期 /
用量 / 电价 / 系数),**不发账单,只支持导出**。
做法:**自带沙箱**(复制 data/ + 改写路径常量 + 另起端口),真浏览器跑一遍。
真实 data/ 零改动;沙箱内的 config.json 是副本,企微发送不会被触发
(发送只验闸门,不真的点发送)。
运行:python tools/ui_verify11.py
"""
import
io
import
os
import
shutil
import
sys
import
tempfile
import
threading
import
urllib.request
from
http.server
import
ThreadingHTTPServer
sys
.
stdout
=
io
.
TextIOWrapper
(
sys
.
stdout
.
buffer
,
encoding
=
"utf-8"
)
ROOT
=
os
.
path
.
dirname
(
os
.
path
.
dirname
(
os
.
path
.
abspath
(
__file__
)))
sys
.
path
.
insert
(
0
,
ROOT
)
os
.
environ
[
"no_proxy"
]
=
"*"
os
.
environ
[
"NO_PROXY"
]
=
"*"
from
rentos
import
config
as
C
# noqa: E402
from
rentos
import
server
as
S
# noqa: E402
from
rentos
import
tables
# noqa: E402
PORT
=
int
(
os
.
environ
.
get
(
"RENTOS_VERIFY_PORT"
,
"8907"
))
BASE
=
"http://127.0.0.1:
%
d"
%
PORT
OUT
=
os
.
path
.
join
(
ROOT
,
"output"
,
"ui_shots"
)
OK
,
FAIL
=
[],
[]
def
ck
(
name
,
cond
,
extra
=
""
):
(
OK
if
cond
else
FAIL
)
.
append
(
name
)
print
(
" [
%
s]
%
s
%
s"
%
(
"ok"
if
cond
else
"FAIL"
,
name
,
(
" →
%
s"
%
extra
)
if
extra
!=
""
else
""
))
def
start_sandbox
():
tmp
=
tempfile
.
mkdtemp
(
prefix
=
"rentos-verify11-"
)
shutil
.
copytree
(
os
.
path
.
join
(
ROOT
,
"data"
),
os
.
path
.
join
(
tmp
,
"data"
))
data
=
os
.
path
.
join
(
tmp
,
"data"
)
for
name
,
val
in
{
"DATA_DIR"
:
data
,
"BATCH_DIR"
:
os
.
path
.
join
(
data
,
"batches"
),
"TABLES_DIR"
:
os
.
path
.
join
(
data
,
"tables"
),
"ATTACH_DIR"
:
os
.
path
.
join
(
data
,
"attachments"
),
"CONFIG_FILE"
:
os
.
path
.
join
(
data
,
"config.json"
),
"SEND_LOG_FILE"
:
os
.
path
.
join
(
data
,
"send_log.json"
),
"SESSION_FILE"
:
os
.
path
.
join
(
data
,
"sessions.json"
),
"UNIT_MAP_FILE"
:
os
.
path
.
join
(
data
,
"unit_map.json"
),
"UPLOAD_DIR"
:
os
.
path
.
join
(
tmp
,
"uploads"
),
"OUTPUT_DIR"
:
os
.
path
.
join
(
tmp
,
"output"
,
"elec"
),
"OUTPUT_RENT_DIR"
:
os
.
path
.
join
(
tmp
,
"output"
,
"rent"
),
"TABLE_EXPORT_DIR"
:
os
.
path
.
join
(
tmp
,
"output"
,
"tables"
),
"BATCH_EXPORT_DIR"
:
os
.
path
.
join
(
tmp
,
"output"
,
"exports"
),
"LOG_DIR"
:
os
.
path
.
join
(
tmp
,
"logs"
),
}
.
items
():
setattr
(
C
,
name
,
val
)
C
.
ensure_dirs
()
srv
=
ThreadingHTTPServer
((
"127.0.0.1"
,
PORT
),
S
.
Handler
)
srv
.
daemon_threads
=
True
threading
.
Thread
(
target
=
srv
.
serve_forever
,
daemon
=
True
)
.
start
()
return
srv
,
tmp
def
pick_billable_unit
(
want
=
3
):
"""沙箱台账里挑几个「房间 + 有租客 + 有出租方」的行。"""
out
=
[]
t
=
tables
.
load_table
(
tables
.
RENT_KEY
)
for
r
in
(
t
or
{})
.
get
(
"rows"
)
or
[]:
unit
=
str
(
r
.
get
(
tables
.
BILL_COLS
[
"unit"
])
or
""
)
.
strip
()
if
not
unit
or
"车位"
in
unit
:
continue
if
not
str
(
r
.
get
(
tables
.
BILL_COLS
[
"tenant"
])
or
""
)
.
strip
():
continue
if
"空置"
in
str
(
r
.
get
(
tables
.
BILL_COLS
[
"tenant"
])
or
""
):
continue
if
not
str
(
r
.
get
(
tables
.
BILL_COLS
[
"owner"
])
or
""
)
.
strip
():
continue
if
unit
not
in
out
:
out
.
append
(
unit
)
if
len
(
out
)
>=
want
:
break
return
out
def
main
():
os
.
makedirs
(
OUT
,
exist_ok
=
True
)
srv
,
tmp
=
start_sandbox
()
print
(
"沙箱数据目录:
%
s"
%
tmp
)
print
(
"沙箱服务:
%
s(真实 data/ 未被触碰)"
%
BASE
)
units
=
pick_billable_unit
(
3
)
print
(
"沙箱造数:台账房间
%
s"
%
"、"
.
join
(
units
))
assert
units
,
"沙箱台账里找不到可出单的房间"
U1
,
U2
=
units
[
0
],
(
units
[
1
]
if
len
(
units
)
>
1
else
units
[
0
])
PERIOD_U
=
"2099年01月"
# 专属账期:核对分组里只会有本次这张批次
from
playwright.sync_api
import
sync_playwright
errors
=
[]
try
:
with
sync_playwright
()
as
pw
:
b
=
pw
.
chromium
.
launch
()
p
=
b
.
new_page
(
viewport
=
{
"width"
:
1680
,
"height"
:
1000
})
p
.
on
(
"console"
,
lambda
m
:
errors
.
append
(
"console:"
+
m
.
text
)
if
(
m
.
type
==
"error"
and
"favicon"
not
in
m
.
text
.
lower
()
and
"Failed to load resource"
not
in
m
.
text
)
else
None
)
p
.
on
(
"pageerror"
,
lambda
e
:
errors
.
append
(
"pageerror:"
+
str
(
e
)))
p
.
on
(
"dialog"
,
lambda
d
:
d
.
accept
())
# 原生 confirm 一律确认
def
js
(
fn
,
*
a
):
return
p
.
evaluate
(
fn
,
*
a
)
if
a
else
p
.
evaluate
(
fn
)
def
api_call
(
method
,
path
,
body
=
None
):
return
js
(
"""async ([m, path, body]) => {
const t = localStorage.getItem('rentos_token');
const r = await fetch(path, {method: m,
headers: Object.assign({'Content-Type': 'application/json'},
t ? {Authorization: 'Bearer ' + t} : {}),
body: body === null ? undefined : JSON.stringify(body)});
let d = {}; try { d = await r.json(); } catch (e) {}
return [r.status, d];
}"""
,
[
method
,
path
,
body
])
def
goto
(
h
):
p
.
evaluate
(
"(h) => { location.hash = '#/' + h; }"
,
h
)
p
.
wait_for_timeout
(
1200
)
def
login
():
p
.
goto
(
BASE
+
"/login.html"
)
p
.
fill
(
"#u"
,
"admin"
)
p
.
fill
(
"#p"
,
"1989@rent05"
)
p
.
click
(
"button[type=submit]"
)
p
.
wait_for_url
(
"**/admin.html"
)
p
.
wait_for_function
(
"() => location.hash.length > 2"
,
timeout
=
20000
)
p
.
wait_for_timeout
(
800
)
# ==================================================== 1 总表渲染
print
(
"
\n
1. 总表(3F/6F/7F)录入表格"
)
login
()
goto
(
"elec"
)
p
.
wait_for_timeout
(
800
)
nrows
=
js
(
"() => document.querySelectorAll('#mBody tr').length"
)
ck
(
"总表共 9 行(3 层 × 3 类目)"
,
nrows
==
9
,
str
(
nrows
))
floors
=
js
(
"() => [...document.querySelectorAll('#mBody td[rowspan]')]"
".map(td => td.textContent.trim())"
)
ck
(
"楼层按 3F / 6F / 7F 分组(合并单元格)"
,
floors
==
[
"3F"
,
"6F"
,
"7F"
],
str
(
floors
))
labels
=
js
(
"""() => [...document.querySelectorAll('#mBody tr')].map(tr => {
const tds = [...tr.children];
const lab = tds.find(td => !td.hasAttribute('rowspan')
&& !td.querySelector('input') && !td.hasAttribute('data-mqty')
&& !td.hasAttribute('data-mamt'));
return lab ? lab.textContent.trim() : '';
})"""
)
ck
(
"类目依次是 公共 / 套内 / 空调总"
,
labels
==
[
"公共"
,
"套内"
,
"空调总"
]
*
3
,
str
(
labels
))
spec
=
api_call
(
"GET"
,
"/api/masters"
)[
1
]
ck
(
"接口 /api/masters 下发规格(3 层 × 3 类目 + 默认系数)"
,
spec
.
get
(
"floors"
)
==
[
"3F"
,
"6F"
,
"7F"
]
and
[
i
[
"factor"
]
for
i
in
spec
.
get
(
"items"
,
[])]
==
[
12.25
,
1.0
,
12.25
],
str
(
spec
.
get
(
"items"
)))
# 3F 公共:12000 → 12500 = 500 度 × 1.2 × 12.25
p
.
fill
(
'#mBody tr[data-mi="0"] [data-mk="prev"]'
,
"12000"
)
p
.
fill
(
'#mBody tr[data-mi="0"] [data-mk="cur"]'
,
"12500"
)
p
.
wait_for_timeout
(
400
)
q
=
p
.
eval_on_selector
(
'#mBody tr[data-mi="0"] [data-mqty]'
,
"e => e.innerText.trim()"
)
amt
=
p
.
eval_on_selector
(
'#mBody tr[data-mi="0"] [data-mamt]'
,
"e => e.innerText.trim()"
)
ck
(
"3F 公共:用量 = 本期 − 上期 = 500"
,
q
==
"500"
,
q
)
ck
(
"3F 公共:金额 = 500 × 1.2 × 12.25 = ¥7,350.00"
,
"7,350"
in
amt
,
amt
)
# 7F 套内(系数 1.0):0 → 100
p
.
fill
(
'#mBody tr[data-mi="7"] [data-mk="prev"]'
,
"0"
)
p
.
fill
(
'#mBody tr[data-mi="7"] [data-mk="cur"]'
,
"100"
)
p
.
wait_for_timeout
(
400
)
amt2
=
p
.
eval_on_selector
(
'#mBody tr[data-mi="7"] [data-mamt]'
,
"e => e.innerText.trim()"
)
ck
(
"7F 套内:系数 1.0 → 金额 ¥120.00"
,
"120"
in
amt2
,
amt2
)
# 6F 公共:只填上期 → 未填本期
p
.
fill
(
'#mBody tr[data-mi="3"] [data-mk="prev"]'
,
"999"
)
p
.
wait_for_timeout
(
400
)
q3
=
p
.
eval_on_selector
(
'#mBody tr[data-mi="3"] [data-mqty]'
,
"e => e.innerText.trim()"
)
ck
(
"只填上期 → 标「未填本期」"
,
"未填本期"
in
q3
,
q3
)
s
=
p
.
inner_text
(
"#mSum"
)
ck
(
"总表小计:3 行、¥7,470.00"
,
"3"
in
s
and
"7,470"
in
s
,
s
.
replace
(
"
\n
"
,
" "
))
p
.
screenshot
(
path
=
os
.
path
.
join
(
OUT
,
"V11-1-masters.png"
),
full_page
=
True
)
p
.
click
(
"#btn-m-clear"
)
p
.
wait_for_timeout
(
400
)
left
=
js
(
"() => [...document.querySelectorAll('#mBody [data-mk=
\"
cur
\"
]')]"
".filter(i => i.value !== '').length"
)
ck
(
"「清空总表」把已填行清空"
,
left
==
0
,
str
(
left
))
# ==================================================== 2 一并录入 → 批次
print
(
"
\n
2. 与本期电费一并录入 → 出单(总表不出单)"
)
goto
(
"elec"
)
p
.
click
(
"#btn-e-clear"
)
p
.
wait_for_timeout
(
300
)
# 用一个专属账期,保证该账期的核对分组里只有本次这一张批次(沙箱里还留着真实台账的历史批次)
p
.
fill
(
"#in-period"
,
PERIOD_U
)
p
.
click
(
"#btn-e-row"
)
p
.
click
(
"#btn-e-row"
)
# 两行:同一房间的「照明 + 空调」→ 可按行分别核对
p
.
wait_for_timeout
(
300
)
row
=
'#eBody tr[data-i="0"] '
p
.
fill
(
row
+
'[data-k="unit"]'
,
U1
)
p
.
fill
(
row
+
'[data-k="meter"]'
,
"照明"
)
p
.
fill
(
row
+
'[data-k="prev"]'
,
"100"
)
p
.
fill
(
row
+
'[data-k="cur"]'
,
"180"
)
row2
=
'#eBody tr[data-i="1"] '
p
.
fill
(
row2
+
'[data-k="unit"]'
,
U1
)
p
.
fill
(
row2
+
'[data-k="meter"]'
,
"空调"
)
p
.
fill
(
row2
+
'[data-k="prev"]'
,
"100"
)
p
.
fill
(
row2
+
'[data-k="cur"]'
,
"200"
)
p
.
wait_for_timeout
(
300
)
# 总表:3F 公共 0 → 500、7F 空调总 0 → 10
p
.
fill
(
'#mBody tr[data-mi="0"] [data-mk="prev"]'
,
"0"
)
p
.
fill
(
'#mBody tr[data-mi="0"] [data-mk="cur"]'
,
"500"
)
p
.
fill
(
'#mBody tr[data-mi="8"] [data-mk="prev"]'
,
"0"
)
p
.
fill
(
'#mBody tr[data-mi="8"] [data-mk="cur"]'
,
"10"
)
p
.
wait_for_timeout
(
300
)
before
=
api_call
(
"GET"
,
"/api/batches?kind=elec"
)[
1
]
.
get
(
"batches"
)
or
[]
p
.
click
(
"#btn-e-submit"
)
p
.
wait_for_timeout
(
3000
)
res
=
p
.
inner_text
(
"#uploadResult"
)
if
p
.
query_selector
(
"#uploadResult"
)
else
""
ck
(
"生成成功(提示里带总表信息)"
,
"已生成"
in
res
and
"楼层总表已存"
in
res
,
res
.
replace
(
"
\n
"
,
" | "
)[:
160
])
after
=
api_call
(
"GET"
,
"/api/batches?kind=elec"
)[
1
]
.
get
(
"batches"
)
or
[]
ck
(
"批次数量 +1"
,
len
(
after
)
==
len
(
before
)
+
1
,
"
%
d →
%
d"
%
(
len
(
before
),
len
(
after
)))
bid
=
after
[
0
][
"batch_id"
]
me
=
after
[
0
]
ck
(
"列表里标出「总表×2」"
,
me
.
get
(
"master_rows"
)
==
2
,
str
(
me
.
get
(
"master_rows"
)))
st
,
det
=
api_call
(
"GET"
,
"/api/batches/"
+
bid
)
bt
=
det
.
get
(
"batch"
)
or
{}
docs
=
bt
.
get
(
"documents"
)
or
[]
ck
(
"总表 2 行随批次留档"
,
len
(
bt
.
get
(
"masters"
)
or
[])
==
2
,
str
(
len
(
bt
.
get
(
"masters"
)
or
[])))
ck
(
"总表不产生通知书(只有房间那份)"
,
len
(
docs
)
==
1
,
"
%
d 份"
%
len
(
docs
))
exp_master
=
round
(
500
*
1.2
*
12.25
+
10
*
1.2
*
12.25
,
2
)
ck
(
"汇总:总表金额单列、不并入出单金额"
,
abs
(
float
(
bt
[
"totals"
]
.
get
(
"master_amount"
)
or
0
)
-
exp_master
)
<
0.01
and
abs
(
float
(
bt
[
"totals"
][
"document_amount"
])
-
float
(
bt
[
"totals"
][
"total_amount"
]))
<
0.01
,
"master=
%
s doc=
%
s"
%
(
bt
[
"totals"
]
.
get
(
"master_amount"
),
bt
[
"totals"
]
.
get
(
"document_amount"
)))
ck
(
"通知书里没有总表类目"
,
all
(
"公共"
not
in
""
.
join
(
d
.
get
(
"units"
)
or
[])
for
d
in
docs
),
str
(
docs
[:
1
]))
# 批次详情展示
goto
(
"elec"
)
p
.
wait_for_timeout
(
1200
)
bodytxt
=
p
.
inner_text
(
"#detailBody"
)
alltxt
=
js
(
"() => (document.querySelector('#detailBody') || {}).textContent || ''"
)
ck
(
"批次详情能看到「查看楼层总表」"
,
"查看楼层总表"
in
bodytxt
,
bodytxt
[:
80
]
.
replace
(
"
\n
"
,
" "
))
ck
(
"详情里能看到 3F / 公共 行(展开后可见)"
,
"3F"
in
alltxt
and
"公共"
in
alltxt
,
[
s
for
s
in
(
"3F"
,
"公共"
,
"空调总"
)
if
s
in
alltxt
])
p
.
click
(
"#detailBody details summary"
)
p
.
wait_for_timeout
(
600
)
p
.
screenshot
(
path
=
os
.
path
.
join
(
OUT
,
"V11-2-detail.png"
),
full_page
=
True
)
# 导出 xlsx:应有「总表」Sheet
import
openpyxl
tok
=
js
(
"() => localStorage.getItem('rentos_token')"
)
req
=
urllib
.
request
.
Request
(
BASE
+
"/api/batches/
%
s/export"
%
bid
,
headers
=
{
"Authorization"
:
"Bearer "
+
tok
})
xp
=
os
.
path
.
join
(
tmp
,
"export.xlsx"
)
with
open
(
xp
,
"wb"
)
as
f
:
f
.
write
(
urllib
.
request
.
urlopen
(
req
)
.
read
())
wb
=
openpyxl
.
load_workbook
(
xp
)
sheets
=
list
(
wb
.
sheetnames
)
ws
=
wb
[
"总表"
]
if
"总表"
in
sheets
else
None
ck
(
"导出的 xlsx 多一个「总表」Sheet"
,
sheets
==
[
"单据"
,
"明细"
,
"总表"
,
"汇总"
],
str
(
sheets
))
ok_sheet
=
False
if
ws
:
head
=
[
ws
.
cell
(
3
,
i
)
.
value
for
i
in
range
(
1
,
11
)]
ok_sheet
=
(
head
[
0
]
==
"序号"
and
head
[
1
]
==
"楼层"
and
head
[
2
]
==
"明细类目"
and
abs
(
float
(
ws
.
cell
(
4
,
9
)
.
value
or
0
)
-
round
(
500
*
1.2
*
12.25
,
2
))
<
0.01
)
ck
(
"「总表」Sheet 表头与金额正确"
,
ok_sheet
,
str
(
head
)
if
ws
else
"缺 Sheet"
)
wb
.
close
()
# ==================================================== 3 批次台账汇总 + 重新编辑
print
(
"
\n
3. 批次台账按账期(月)汇总;重新编辑挪到批次详情"
)
goto
(
"elec"
)
p
.
wait_for_timeout
(
1500
)
grp
=
js
(
"""() => {
const tbs = document.querySelectorAll('#batchList tbody.pgrp');
return {n: tbs.length,
collapsed: document.querySelectorAll('#batchList tbody.pgrp.collapsed').length,
first: tbs[0] ? (tbs[0].dataset.pgrp || '') : '',
rows: tbs[0] ? tbs[0].querySelectorAll('tr.prow').length : 0};
}"""
)
ck
(
"批次台账按账期汇总成分组(与「按账期月份留档统计」一致)"
,
grp
[
"n"
]
>=
1
,
str
(
grp
))
ck
(
"同一个账期只汇总成一行(不再一行行摊开)"
,
grp
[
"n"
]
==
js
(
"() => document.querySelectorAll('#batchList tr.grp').length"
),
"分组
%
s / 汇总头
%
s"
%
(
grp
[
"n"
],
js
(
"() => document.querySelectorAll('#batchList tr.grp').length"
)))
ck
(
"默认折叠:只显示账期汇总行"
,
grp
[
"collapsed"
]
==
grp
[
"n"
],
str
(
grp
))
ck
(
"折叠时批次行不渲染出来"
,
js
(
"""() => { const t = document.querySelector('#batchList tbody.pgrp.collapsed tr.prow');
return !t || getComputedStyle(t).display === 'none'; }"""
)
is
True
)
ck
(
"汇总头写明账期 / 批次数 / 核对进度"
,
js
(
"""() => { const g = document.querySelector('#batchList tr.grp');
return g && /账期/.test(g.textContent) && /个批次/.test(g.textContent)
&& /核对
\\
d+
\\
/
\\
d+ 行/.test(g.textContent); }"""
)
is
True
,
js
(
"() => (document.querySelector('#batchList tr.grp') || {}).textContent || ''"
)
.
replace
(
"
\n
"
,
" "
)[:
120
])
ck
(
"列表里不再有「重新编辑」按钮"
,
js
(
"() => document.querySelectorAll('#batchList [data-reingest]').length"
)
==
0
)
ck
(
"列表里不再有「核对」按钮"
,
js
(
"() => document.querySelectorAll('#batchList [data-verify-period]').length"
)
==
0
)
ck
(
"批次行仍有「详情」入口"
,
js
(
"() => document.querySelectorAll('#batchList tr.prow [data-open]').length"
)
>=
1
)
p
.
screenshot
(
path
=
os
.
path
.
join
(
OUT
,
"V11-6-period-summary.png"
),
full_page
=
True
)
# 展开账期 → 打开批次详情 → 在详情里重新编辑
p
.
click
(
'#batchList tbody.pgrp tr.grp'
)
p
.
wait_for_timeout
(
500
)
ck
(
"点汇总头可展开该账期的批次"
,
js
(
"() => document.querySelectorAll('#batchList tbody.pgrp.collapsed').length"
)
<
grp
[
"n"
])
p
.
click
(
'#batchList tr.prow[data-open="
%
s"]'
%
bid
)
p
.
wait_for_timeout
(
2200
)
ck
(
"批次详情已打开(详情卡可见)"
,
js
(
"() => document.querySelector('#detailCard').style.display !== 'none'"
)
is
True
)
ck
(
"详情页有「核对」按钮"
,
js
(
"() => !!document.querySelector('#btn-detail-verify')"
)
is
True
)
ck
(
"详情页有「重新编辑」按钮"
,
js
(
"() => !!document.querySelector('#btn-detail-reingest')"
)
is
True
)
p
.
click
(
"#btn-detail-reingest"
)
p
.
wait_for_timeout
(
2200
)
bar
=
p
.
inner_text
(
"#eModeBar"
)
ck
(
"详情里点「重新编辑」→ 进入「重新编辑中」模式条"
,
"重新编辑中"
in
bar
,
bar
.
replace
(
"
\n
"
,
" "
)[:
100
])
n_room
=
js
(
"() => document.querySelectorAll('#eBody tr[data-i]').length"
)
ck
(
"录入表格载入了原批次明细(2 行)"
,
n_room
==
2
,
str
(
n_room
))
mv
=
js
(
"() => document.querySelector('#mBody tr[data-mi=
\"
0
\"
] [data-mk=
\"
cur
\"
]').value"
)
ck
(
"总表也被载回(3F 公共 本期 = 500)"
,
str
(
mv
)
==
"500"
,
str
(
mv
))
ck
(
"提交按钮文案变「保存重编」"
,
p
.
inner_text
(
"#btn-e-submit"
)
.
strip
()
==
"保存重编"
,
p
.
inner_text
(
"#btn-e-submit"
))
p
.
fill
(
'#eBody tr[data-i="0"] [data-k="cur"]'
,
"200"
)
p
.
wait_for_timeout
(
300
)
p
.
click
(
"#btn-e-submit"
)
p
.
wait_for_timeout
(
3000
)
res2
=
p
.
inner_text
(
"#uploadResult"
)
ck
(
"重编成功(提示带「重编」)"
,
"重编"
in
res2
,
res2
.
replace
(
"
\n
"
,
" | "
)[:
120
])
st
,
d2
=
api_call
(
"GET"
,
"/api/batches/"
+
bid
)
b2
=
d2
.
get
(
"batch"
)
or
{}
ck
(
"批次号不变(覆盖式重编)"
,
b2
.
get
(
"batch_id"
)
==
bid
,
str
(
b2
.
get
(
"batch_id"
)))
ck
(
"重编次数记 1"
,
int
(
b2
.
get
(
"reingest_count"
)
or
0
)
==
1
,
str
(
b2
.
get
(
"reingest_count"
)))
ck
(
"总表随重编保留(没传就沿用)"
,
len
(
b2
.
get
(
"masters"
)
or
[])
==
2
,
str
(
len
(
b2
.
get
(
"masters"
)
or
[])))
ck
(
"重编后核对状态被重置"
,
(
d2
.
get
(
"verify"
)
or
{})
.
get
(
"done"
)
is
False
,
str
(
d2
.
get
(
"verify"
)))
# ==================================================== 4 账期核对(入口在批次详情)
print
(
"
\n
4. 账期级核对:入口在批次详情,未确认不允许发送,支持部分核对"
)
st
,
snd
=
api_call
(
"POST"
,
"/api/batches/
%
s/send"
%
bid
,
{})
ck
(
"未确认核对就发送 → 409"
,
st
==
409
,
"HTTP
%
s
%
s"
%
(
st
,
snd
.
get
(
"error"
,
""
)))
goto
(
"elec"
)
p
.
wait_for_timeout
(
1500
)
p
.
click
(
'#batchList tbody.pgrp tr.grp'
)
# 展开账期
p
.
wait_for_timeout
(
400
)
p
.
click
(
'#batchList tr.prow[data-open="
%
s"]'
%
bid
)
p
.
wait_for_timeout
(
2200
)
bodytxt
=
p
.
inner_text
(
"#detailBody"
)
ck
(
"批次详情显示核对状态条(未完成)"
,
"核对状态"
in
bodytxt
,
bodytxt
[:
90
]
.
replace
(
"
\n
"
,
" "
))
ck
(
"详情提示去「核对」"
,
"去核对"
in
bodytxt
or
"核对"
in
bodytxt
,
bodytxt
[:
120
]
.
replace
(
"
\n
"
,
" "
))
p
.
click
(
"#btn-detail-verify"
)
p
.
wait_for_timeout
(
2400
)
bar2
=
p
.
inner_text
(
"#eModeBar"
)
ck
(
"进入「账期核对中」模式条"
,
"账期核对中"
in
bar2
,
bar2
.
replace
(
"
\n
"
,
" "
)[:
110
])
ck
(
"模式条写明账期与批次数"
,
PERIOD_U
in
bar2
,
bar2
.
replace
(
"
\n
"
,
" "
)[:
110
])
ck
(
"模式条说明按行核对、可部分核对"
,
"按行勾选"
in
bar2
and
"只核对照明或空调"
in
bar2
,
bar2
.
replace
(
"
\n
"
,
" "
)[:
160
])
ck
(
"总表区块在核对模式下隐藏"
,
js
(
"""() => { const b = document.querySelector('#masterBlock');
return !b || b.style.display === 'none'; }"""
)
is
True
)
ck
(
"核对模式下列是只读的(没有删除按钮)"
,
js
(
"() => !!document.querySelector('#eBody [data-delrow]')"
)
is
False
)
ck
(
"有「列显示」按钮(可只展示想核对的列)"
,
js
(
"() => !!document.querySelector('#btn-vcols')"
)
is
True
)
ck
(
"一行都没勾时「确认核对」禁用"
,
p
.
is_disabled
(
"#e-vf-done"
)
is
True
)
# ---- 列筛选:只留 房间号 / 类目 / 上期 / 本期,其余隐藏
p
.
click
(
"#btn-vcols"
)
p
.
wait_for_timeout
(
300
)
ck
(
"列显示菜单弹出"
,
js
(
"() => !document.querySelector('#vcolsMenu').hidden"
)
is
True
)
for
k
in
(
"qty"
,
"price"
,
"factor"
,
"company"
,
"owner"
,
"note"
,
"amt"
,
"act"
):
p
.
click
(
'[data-vcol="
%
s"]'
%
k
)
p
.
wait_for_timeout
(
300
)
vis
=
js
(
"""() => {
const shown = [...document.querySelectorAll('#eGrid thead th[data-col]')]
.filter(th => th.style.display !== 'none').map(th => th.dataset.col);
return shown;
}"""
)
ck
(
"只展示勾选的列(房间号 / 类目 / 上期 / 本期 / 核对)"
,
sorted
(
vis
)
==
sorted
([
"unit"
,
"meter"
,
"prev"
,
"cur"
,
"vcol"
]),
str
(
vis
))
ck
(
"隐藏的列在表体里也不渲染"
,
js
(
"""() => { const td = document.querySelector('#eBody td[data-col="price"]');
return !td || td.style.display === 'none'; }"""
)
is
True
)
ck
(
"列选择已存 localStorage(重渲染后仍在)"
,
js
(
"() => (localStorage.getItem('rentos_vcols') || '').indexOf('unit') >= 0"
)
is
True
)
p
.
screenshot
(
path
=
os
.
path
.
join
(
OUT
,
"V11-7-vcols.png"
),
full_page
=
True
)
p
.
click
(
"#btn-vcols"
)
# 收起菜单
p
.
wait_for_timeout
(
200
)
# ---- 逐行核对:只勾第一行(部分核对 / 只核对某一类目)
nrow
=
js
(
"() => document.querySelectorAll('#eBody [data-vrow]').length"
)
ck
(
"核对列按行给复选框(不是按房间)"
,
nrow
>=
1
,
str
(
nrow
))
rk
=
js
(
"""() => { const c = document.querySelector('#eBody [data-vrow]');
return c ? c.dataset.vrow : ''; }"""
)
ck
(
"行键是「单元|类目」"
,
"|"
in
rk
,
rk
)
p
.
click
(
'#eBody [data-vrow="
%
s"]'
%
rk
)
p
.
wait_for_timeout
(
1600
)
sm
=
p
.
inner_text
(
"#eSum"
)
ck
(
"只勾一行 → 提示部分核对"
,
"部分核对"
in
sm
,
sm
.
replace
(
"
\n
"
,
" "
))
ck
(
"部分核对时「确认核对」可点"
,
p
.
is_disabled
(
"#e-vf-done"
)
is
False
)
st
,
d2b
=
api_call
(
"GET"
,
"/api/batches/"
+
bid
)
ck
(
"进度已落到服务端(1 行)"
,
len
((
d2b
.
get
(
"verify"
)
or
{})
.
get
(
"rows"
)
or
[])
==
1
,
str
((
d2b
.
get
(
"verify"
)
or
{})
.
get
(
"rows"
)))
p
.
click
(
"#e-vf-done"
)
p
.
wait_for_timeout
(
900
)
ck
(
"弹出确认框(.modal-mask.show)"
,
js
(
"() => !!document.querySelector('#cfm.show')"
)
is
True
)
cfm
=
js
(
"() => (document.querySelector('#cfm') || {}).textContent || ''"
)
ck
(
"确认框标明「部分核对」并列出未核对行数"
,
"部分核对"
in
cfm
and
"未核对"
in
cfm
,
cfm
.
replace
(
"
\n
"
,
" "
)[:
140
])
p
.
screenshot
(
path
=
os
.
path
.
join
(
OUT
,
"V11-8-partial-confirm.png"
),
full_page
=
True
)
p
.
click
(
"#cfmOk"
)
p
.
wait_for_timeout
(
2800
)
st
,
d3
=
api_call
(
"GET"
,
"/api/batches/"
+
bid
)
vs
=
d3
.
get
(
"verify"
)
or
{}
ck
(
"部分核对也确认成功(ok = True)"
,
vs
.
get
(
"ok"
)
is
True
,
str
(
vs
))
ck
(
"标记为部分核对(partial = True)"
,
vs
.
get
(
"partial"
)
is
True
,
str
(
vs
))
ck
(
"核对人 / 时间已记录"
,
bool
(
vs
.
get
(
"by"
))
and
bool
(
vs
.
get
(
"at"
)),
"
%
s @
%
s"
%
(
vs
.
get
(
"by"
),
vs
.
get
(
"at"
)))
st
,
ps
=
api_call
(
"GET"
,
"/api/periods?kind=elec"
)
mine
=
next
((
x
for
x
in
(
ps
.
get
(
"periods"
)
or
[])
if
x
[
"period"
]
==
PERIOD_U
),
{})
ck
(
"接口 /api/periods 该账期 ok=True 且 partial=True"
,
mine
.
get
(
"ok"
)
is
True
and
mine
.
get
(
"partial"
)
is
True
,
"ok=
%
s partial=
%
s"
%
(
mine
.
get
(
"ok"
),
mine
.
get
(
"partial"
)))
bodytxt2
=
p
.
inner_text
(
"#detailBody"
)
ck
(
"批次详情显示「已核对」并注明部分核对"
,
"已核对"
in
bodytxt2
and
"部分核对"
in
bodytxt2
,
bodytxt2
[:
110
]
.
replace
(
"
\n
"
,
" "
))
ck
(
"详情状态条注明「按账期统一核对」"
,
"按账期统一核对"
in
bodytxt2
,
bodytxt2
[:
110
]
.
replace
(
"
\n
"
,
" "
))
ck
(
"发送按钮已解禁"
,
p
.
is_disabled
(
"#btn-send-all"
)
is
False
)
st2
,
snd2
=
api_call
(
"POST"
,
"/api/batches/
%
s/send"
%
bid
,
{})
ck
(
"部分核对后发送闸门也放行(不再 409)"
,
st2
!=
409
,
"HTTP
%
s
%
s"
%
(
st2
,
snd2
.
get
(
"error"
,
""
)))
# ---- 部分核对发送:前端再提醒一次
p
.
click
(
"#btn-send-all"
)
p
.
wait_for_timeout
(
900
)
ck
(
"部分核对时发送前弹出二次提醒"
,
js
(
"() => !!document.querySelector('#cfm.show')"
)
is
True
)
cfm2
=
js
(
"() => (document.querySelector('#cfm') || {}).textContent || ''"
)
ck
(
"提醒里写明还有几行未核对"
,
"部分核对"
in
cfm2
and
"未核对"
in
cfm2
,
cfm2
.
replace
(
"
\n
"
,
" "
)[:
140
])
p
.
click
(
"#cfmCancel"
)
p
.
wait_for_timeout
(
600
)
p
.
screenshot
(
path
=
os
.
path
.
join
(
OUT
,
"V11-9-verified.png"
),
full_page
=
True
)
# ---- 核对回退:取消勾选 → 确认自动撤销
p
.
click
(
"#btn-detail-verify"
)
p
.
wait_for_timeout
(
2200
)
ck
(
"重新进入核对时保留已勾选的行"
,
js
(
"""() => { const cs = [...document.querySelectorAll('#eBody [data-vrow]')];
return cs.length > 0 && cs[0].checked; }"""
)
is
True
)
p
.
click
(
"#e-vf-none"
)
p
.
wait_for_timeout
(
1600
)
st
,
d4
=
api_call
(
"GET"
,
"/api/batches/"
+
bid
)
ck
(
"取消勾选后确认自动撤销"
,
(
d4
.
get
(
"verify"
)
or
{})
.
get
(
"ok"
)
is
False
,
str
(
d4
.
get
(
"verify"
)))
st
,
ps2
=
api_call
(
"GET"
,
"/api/periods?kind=elec"
)
mine2
=
next
((
x
for
x
in
(
ps2
.
get
(
"periods"
)
or
[])
if
x
[
"period"
]
==
PERIOD_U
),
{})
ck
(
"接口 /api/periods 同步回落到未确认"
,
mine2
.
get
(
"ok"
)
is
False
and
mine2
.
get
(
"verified"
)
==
0
,
"
%
s/
%
s"
%
(
mine2
.
get
(
"verified"
),
mine2
.
get
(
"total"
)))
p
.
click
(
"#e-mode-cancel"
)
p
.
wait_for_timeout
(
1200
)
# ==================================================== 5 定时发送设置不再出现电费
print
(
"
\n
5. 设置页:电费已从「定时发送设置」里删掉,无冗余展示"
)
goto
(
"settings"
)
p
.
wait_for_timeout
(
1800
)
keys
=
js
(
"""() => [...document.querySelectorAll('#planRows tr[data-plan]')]
.map(tr => tr.dataset.plan)"""
)
ck
(
"计划表里只剩「房租车位费」一行"
,
keys
==
[
"rent_parking"
],
str
(
keys
))
ck
(
"电费行已彻底删除(不是灰掉占位)"
,
js
(
"() => !document.querySelector('#planRows tr[data-plan=
\"
elec
\"
]')"
)
is
True
)
setxt
=
p
.
inner_text
(
"#page-settings"
)
ck
(
"设置页不再出现「出租方 · 汇款公司」卡片"
,
"汇款公司"
not
in
setxt
,
"仍出现"
if
"汇款公司"
in
setxt
else
"已移除"
)
ck
(
"定时发送说明里也不再提电费配置"
,
"电费通知单"
not
in
setxt
or
"不在此配置"
in
setxt
)
st
,
sc
=
api_call
(
"GET"
,
"/api/scheduler"
)
sched
=
sc
.
get
(
"scheduler"
)
or
{}
by
=
{
x
[
"key"
]:
x
for
x
in
(
sched
.
get
(
"plans"
)
or
[])}
ck
(
"接口:电费 auto=False / state=manual(后端仍是手动口径)"
,
by
.
get
(
"elec"
,
{})
.
get
(
"auto"
)
is
False
and
by
.
get
(
"elec"
,
{})
.
get
(
"state"
)
==
"manual"
,
str
(
by
.
get
(
"elec"
))[:
120
])
ck
(
"接口:房租车位费仍是自动排期"
,
by
.
get
(
"rent_parking"
,
{})
.
get
(
"auto"
)
is
True
,
str
(
by
.
get
(
"rent_parking"
,
{})
.
get
(
"auto"
)))
ck
(
"manual_count = 1"
,
sched
.
get
(
"manual_count"
)
==
1
,
str
(
sched
.
get
(
"manual_count"
)))
p
.
screenshot
(
path
=
os
.
path
.
join
(
OUT
,
"V11-5-settings.png"
),
full_page
=
True
)
goto
(
"companies"
)
p
.
wait_for_timeout
(
1600
)
cotxt
=
p
.
inner_text
(
"#page-companies"
)
ck
(
"「公司管理」页仍保留出租方 · 汇款公司(唯一入口)"
,
"汇款公司"
in
cotxt
and
"出租方"
in
cotxt
,
cotxt
[:
60
]
.
replace
(
"
\n
"
,
" "
))
# ==================================================== 6 无 JS 报错
print
(
"
\n
6. 前端无 JS 报错"
)
ck
(
"浏览器控制台无 error / pageerror"
,
not
errors
,
" | "
.
join
(
errors
[:
3
])
if
errors
else
"0 条"
)
b
.
close
()
finally
:
srv
.
shutdown
()
shutil
.
rmtree
(
tmp
,
ignore_errors
=
True
)
print
(
"
\n
"
+
"="
*
72
)
print
(
"通过
%
d 项,失败
%
d 项"
%
(
len
(
OK
),
len
(
FAIL
)))
for
f
in
FAIL
:
print
(
" FAIL:
%
s"
%
f
)
print
(
"截图:
%
s"
%
OUT
)
return
1
if
FAIL
else
0
if
__name__
==
"__main__"
:
sys
.
exit
(
main
())
tools/ui_verify2.py
View file @
57cf814d
...
...
@@ -2,19 +2,31 @@
"""
界面验证:两张账单发送计划 / 免租期 / 台账锁定与二次确认 / 撤销 / 无「序号」列
运行:
python tools/ui_verify2.py
RENTOS_
BASE=http://127.0.0.1:8901 python tools/ui_verify2.py
运行
(该脚本会写数据,默认目标 8899 是【真实台账】,必须走沙箱)
:
python tools/ui_verify
_runner.py tools/ui_verify
2.py
RENTOS_
ALLOW_REAL=1 python tools/ui_verify2.py # 确需直连真实服务才这样用
"""
import
json
import
os
import
re
import
sys
import
warnings
warnings
.
filterwarnings
(
"ignore"
)
from
playwright.sync_api
import
sync_playwright
# noqa: E402
BASE
=
os
.
environ
.
get
(
"RENTOS_BASE"
,
"http://127.0.0.1:8899"
)
BASE
=
os
.
environ
.
get
(
"RENTOS_BASE"
,
""
)
if
not
BASE
:
# 该脚本会写数据;未注入 RENTOS_BASE 时默认目标 8899 是【真实台账】,
# 直连会污染权威数据(2026-10-13 曾误写入 17 个测试批次)。
# 正常路径:tools/ui_verify_runner.py(沙箱 + 注入 RENTOS_BASE)。
if
os
.
environ
.
get
(
"RENTOS_ALLOW_REAL"
)
==
"1"
:
BASE
=
"http://127.0.0.1:8899"
else
:
_me
=
os
.
path
.
basename
(
__file__
)
sys
.
exit
(
"已阻止:
%
s 未设置 RENTOS_BASE(默认 8899 是【真实台账】)。
\n
"
" 沙箱运行: python tools/ui_verify_runner.py tools/
%
s
\n
"
" 确需打真实服务:RENTOS_ALLOW_REAL=1 python tools/
%
s"
%
(
_me
,
_me
,
_me
))
OUT
=
os
.
path
.
join
(
os
.
path
.
dirname
(
os
.
path
.
abspath
(
__file__
)),
"shots2"
)
os
.
makedirs
(
OUT
,
exist_ok
=
True
)
...
...
@@ -49,13 +61,14 @@ def main():
page
.
wait_for_url
(
"**/admin.html"
)
page
.
wait_for_timeout
(
1200
)
# ============================================ 1. 设置页:
两张账单
print
(
"
\n
== 1. 设置页:
两张账单发送计划
=="
)
# ============================================ 1. 设置页:
只剩房租车位费
print
(
"
\n
== 1. 设置页:
只配置「房租车位费」(电费已删)
=="
)
page
.
goto
(
f
"{BASE}/admin.html#/settings"
)
page
.
wait_for_selector
(
"#planRows tr[data-plan]"
,
timeout
=
20000
)
page
.
wait_for_timeout
(
800
)
rows
=
page
.
eval_on_selector_all
(
"#planRows tr[data-plan]"
,
"els=>els.map(e=>e.dataset.plan)"
)
ck
(
"两张账单计划各一行"
,
rows
==
[
"elec"
,
"rent_parking"
],
str
(
rows
))
ck
(
"电费通知单已从定时发送设置里删掉,只剩房租车位费"
,
rows
==
[
"rent_parking"
],
str
(
rows
))
ck
(
"退休的旧账单键已消失"
,
"rent_summary_367"
not
in
rows
and
"rent_contract_8f11f"
not
in
rows
,
str
(
rows
))
for
k
in
rows
:
...
...
@@ -71,24 +84,30 @@ def main():
ck
(
f
"{k} 四个控件齐全"
,
got
[
"en"
]
and
got
[
"adv"
]
and
got
[
"tm"
]
and
got
[
"rf"
],
json
.
dumps
(
got
,
ensure_ascii
=
False
))
names
=
page
.
eval_on_selector_all
(
"#planRows .bill-name"
,
"e=>e.map(x=>x.innerText)"
)
ck
(
"账单名称
= 电费 / 房租车位费
"
,
names
==
[
"
电费收费通知书"
,
"
房租车位费收费通知书"
],
str
(
names
))
ck
(
"账单名称
只剩「房租车位费收费通知书」
"
,
names
==
[
"房租车位费收费通知书"
],
str
(
names
))
ck
(
"页面不再出现「三张账单」"
,
"三张账单"
not
in
page
.
eval_on_selector
(
"#page-settings"
,
"e=>e.innerText"
))
ck
(
"设置页不再重复展示「出租方 · 汇款公司」"
,
"汇款公司"
not
in
page
.
eval_on_selector
(
"#page-settings"
,
"e=>e.innerText"
))
page
.
screenshot
(
path
=
os
.
path
.
join
(
OUT
,
"S1-plans.png"
),
full_page
=
True
)
print
(
"
\n
== 2. 免租期卡片 =="
)
rf
=
page
.
eval_on_selector
(
"#rentfreeBox"
,
"e=>e.innerText"
)
ck
(
"免租期卡片列出两种免租类型"
,
"首N月免租"
in
rf
and
"每年8-9月免租"
in
rf
)
ck
(
"免租期列出 3 段(315室 / 302室 / 303室)"
,
"共 3 段"
in
rf
,
rf
.
split
(
"
\n
"
)[
0
][:
80
])
# 段数跟着台账走(不写死):801/802 登记「每年8-9月免租」后会按年展开
ck
(
"免租期卡片给出「共 N 段」汇总"
,
re
.
search
(
r"共 \d+ 段"
,
rf
)
is
not
None
,
rf
.
split
(
"
\n
"
)[
0
][:
80
])
ck
(
"免租期卡片列出真实台账里的免租房间"
,
all
(
u
in
rf
for
u
in
(
"315室"
,
"302室"
,
"303室"
)),
rf
[:
120
])
ck
(
"免租期表格有 302室"
,
"302室"
in
rf
and
"2026-08-16"
in
rf
)
ck
(
"免租期来源标注为房租车位费台账"
,
"房租车位费"
in
rf
or
"台账"
in
rf
)
page
.
screenshot
(
path
=
os
.
path
.
join
(
OUT
,
"S2-rentfree.png"
),
full_page
=
True
)
# 记下初始计划,最后原样还原(脚本不该改变现场配置)
adv
=
'#planRows tr[data-plan="
elec
"] input[data-f="advance_days"]'
tm
=
'#planRows tr[data-plan="
elec
"] input[data-f="send_time"]'
en
=
'#planRows tr[data-plan="
elec
"] input[data-f="enabled"]'
adv
=
'#planRows tr[data-plan="
rent_parking
"] input[data-f="advance_days"]'
tm
=
'#planRows tr[data-plan="
rent_parking
"] input[data-f="send_time"]'
en
=
'#planRows tr[data-plan="
rent_parking
"] input[data-f="enabled"]'
init
=
{
"master"
:
page
.
input_value
(
"#st-enabled"
),
"adv"
:
page
.
input_value
(
adv
),
"tm"
:
page
.
input_value
(
tm
),
"en"
:
page
.
is_checked
(
en
)}
...
...
@@ -128,10 +147,10 @@ def main():
ck
(
"确认后提前天数=7 已落盘"
,
page
.
input_value
(
adv
)
==
"7"
,
page
.
input_value
(
adv
))
ck
(
"确认后发送时点=07:30 已落盘"
,
page
.
input_value
(
tm
)
==
"07:30"
,
page
.
input_value
(
tm
))
st_txt
=
page
.
eval_on_selector
(
'#planRows tr[data-plan="
elec
"] .plan-state'
,
"e=>e.innerText"
)
ck
(
"状态列给出运行状态(排期说明 /
暂无通知书 / 未启用
)"
,
(
"
缴费期限"
in
st_txt
)
or
(
"还没有可发送的通知书"
in
st_txt
)
or
(
"未启用"
in
st_txt
),
st_txt
.
replace
(
"
\n
"
,
" | "
)[:
110
])
'#planRows tr[data-plan="
rent_parking
"] .plan-state'
,
"e=>e.innerText"
)
ck
(
"状态列给出运行状态(排期说明 /
未启用 / 无待发
)"
,
(
"
推送日"
in
st_txt
)
or
(
"待出单"
in
st_txt
)
or
(
"未启用"
in
st_txt
)
or
(
"无待发"
in
st_txt
),
st_txt
.
replace
(
"
\n
"
,
" | "
)[:
110
])
page
.
screenshot
(
path
=
os
.
path
.
join
(
OUT
,
"S4-saved.png"
),
full_page
=
True
)
print
(
"
\n
== 4b. 关闭总开关时不再排期 =="
)
...
...
@@ -143,10 +162,10 @@ def main():
page
.
reload
(
wait_until
=
"networkidle"
)
page
.
wait_for_selector
(
"#planRows tr[data-plan]"
,
timeout
=
20000
)
page
.
wait_for_timeout
(
600
)
ck
(
"总开关关闭 →
两张账单状态均为 off
"
,
ck
(
"总开关关闭 →
计划状态为「总开关关闭」
"
,
page
.
eval_on_selector_all
(
"#planRows tr[data-plan] .plan-state .l1"
,
"e=>e.map(x=>x.innerText)"
)
==
[
"总开关关闭"
,
"总开关关闭"
],
==
[
"总开关关闭"
],
str
(
page
.
eval_on_selector_all
(
"#planRows tr[data-plan] .plan-state .l1"
,
"e=>e.map(x=>x.innerText)"
)))
...
...
tools/ui_verify3.py
View file @
57cf814d
...
...
@@ -10,9 +10,9 @@
5. 「房租车位费」台账:8F/11F 付款周期 = 3 个月
6. 全程页面无 JS 报错
运行:
python tools/ui_verify
3.py # 默认 http://127.0.0.1:8899
RENTOS_
BASE=http://127.0.0.1:8901 python tools/ui_verify3.py
运行
(该脚本会写数据,默认目标 8899 是【真实台账】,必须走沙箱)
:
python tools/ui_verify
_runner.py tools/ui_verify3.py
RENTOS_
ALLOW_REAL=1 python tools/ui_verify3.py # 确需直连真实服务才这样用
"""
import
base64
import
io
...
...
@@ -24,7 +24,18 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
from
playwright.sync_api
import
sync_playwright
# noqa: E402
BASE
=
os
.
environ
.
get
(
"RENTOS_BASE"
,
"http://127.0.0.1:8899"
)
BASE
=
os
.
environ
.
get
(
"RENTOS_BASE"
,
""
)
if
not
BASE
:
# 该脚本会写数据;未注入 RENTOS_BASE 时默认目标 8899 是【真实台账】,
# 直连会污染权威数据(2026-10-13 曾误写入 17 个测试批次)。
# 正常路径:tools/ui_verify_runner.py(沙箱 + 注入 RENTOS_BASE)。
if
os
.
environ
.
get
(
"RENTOS_ALLOW_REAL"
)
==
"1"
:
BASE
=
"http://127.0.0.1:8899"
else
:
_me
=
os
.
path
.
basename
(
__file__
)
sys
.
exit
(
"已阻止:
%
s 未设置 RENTOS_BASE(默认 8899 是【真实台账】)。
\n
"
" 沙箱运行: python tools/ui_verify_runner.py tools/
%
s
\n
"
" 确需打真实服务:RENTOS_ALLOW_REAL=1 python tools/
%
s"
%
(
_me
,
_me
,
_me
))
USER
,
PWD
=
"admin"
,
"1989@rent05"
OUT
=
os
.
path
.
join
(
os
.
path
.
dirname
(
os
.
path
.
abspath
(
__file__
)),
"shots3"
)
PNG_B64
=
(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGA"
...
...
tools/ui_verify4.py
View file @
57cf814d
...
...
@@ -6,14 +6,14 @@
1. 导航出现「公司管理」,深链 #/companies 可直达,共 5 项
2. 租客 / 往来单位名册:加载、类型标签、搜索过滤、录入、行内编辑改名、删除
3. 出租方 · 汇款公司:加载、引用单元数、录入、改名(提示会同步台账)、删除
4. 设置页不再有旧的「汇款公司」编辑卡片
(只剩指引
)
4. 设置页不再有旧的「汇款公司」编辑卡片
,也不再重复展示(名单只在「公司管理」维护
)
5. 选择场景接入:电费录入「单位名称 / 出租方」有候选并在填房间号时自动带出
6. 台账「现租客」列编辑时挂上名册候选
7. 全程页面无 JS 报错,且测试用完即清理(不留脏数据)
运行:
python tools/ui_verify
4.py # 默认 8899
RENTOS_
BASE=http://127.0.0.1:8901 python tools/ui_verify4.py
运行
(该脚本会写数据,默认目标 8899 是【真实台账】,必须走沙箱)
:
python tools/ui_verify
_runner.py tools/ui_verify4.py
RENTOS_
ALLOW_REAL=1 python tools/ui_verify4.py # 确需直连真实服务才这样用
"""
import
io
import
os
...
...
@@ -24,7 +24,18 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
from
playwright.sync_api
import
sync_playwright
# noqa: E402
BASE
=
os
.
environ
.
get
(
"RENTOS_BASE"
,
"http://127.0.0.1:8899"
)
BASE
=
os
.
environ
.
get
(
"RENTOS_BASE"
,
""
)
if
not
BASE
:
# 该脚本会写数据;未注入 RENTOS_BASE 时默认目标 8899 是【真实台账】,
# 直连会污染权威数据(2026-10-13 曾误写入 17 个测试批次)。
# 正常路径:tools/ui_verify_runner.py(沙箱 + 注入 RENTOS_BASE)。
if
os
.
environ
.
get
(
"RENTOS_ALLOW_REAL"
)
==
"1"
:
BASE
=
"http://127.0.0.1:8899"
else
:
_me
=
os
.
path
.
basename
(
__file__
)
sys
.
exit
(
"已阻止:
%
s 未设置 RENTOS_BASE(默认 8899 是【真实台账】)。
\n
"
" 沙箱运行: python tools/ui_verify_runner.py tools/
%
s
\n
"
" 确需打真实服务:RENTOS_ALLOW_REAL=1 python tools/
%
s"
%
(
_me
,
_me
,
_me
))
USER
,
PWD
=
"admin"
,
"1989@rent05"
OUT
=
os
.
path
.
join
(
os
.
path
.
dirname
(
os
.
path
.
abspath
(
__file__
)),
"shots4"
)
...
...
@@ -278,7 +289,8 @@ def main():
ck
(
"旧汇款公司编辑区已移除"
,
page
.
eval_on_selector_all
(
"#ownersBox, #btn-save-owners, #btn-reset-owners"
,
"e => e.length"
)
==
0
)
txt
=
page
.
inner_text
(
"#page-settings"
)
ck
(
"留有指向公司管理的指引"
,
"公司管理"
in
txt
)
ck
(
"设置页不再重复展示「出租方 · 汇款公司」(已迁走,不留冗余指引)"
,
"汇款公司"
not
in
txt
,
txt
[:
80
]
.
replace
(
"
\n
"
,
" "
))
page
.
screenshot
(
path
=
os
.
path
.
join
(
OUT
,
"C4-settings.png"
),
full_page
=
True
)
# ============================================================ 5
...
...
tools/ui_verify5.py
View file @
57cf814d
...
...
@@ -23,9 +23,9 @@
* 结束时逐格核对台账「账单起始日」与跑前一致,不一致就报 FAIL;
* 本次生成的产物文件会被清理(批次 JSON 由删除步骤清掉)。
运行:
python tools/ui_verify
5.py # 默认 8899
RENTOS_
BASE=http://127.0.0.1:8901 python tools/ui_verify5.py
运行
(该脚本会写数据,默认目标 8899 是【真实台账】,必须走沙箱)
:
python tools/ui_verify
_runner.py tools/ui_verify5.py
RENTOS_
ALLOW_REAL=1 python tools/ui_verify5.py # 确需直连真实服务才这样用
"""
import
io
import
os
...
...
@@ -37,7 +37,18 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
sys
.
path
.
insert
(
0
,
os
.
path
.
dirname
(
os
.
path
.
dirname
(
os
.
path
.
abspath
(
__file__
))))
from
playwright.sync_api
import
sync_playwright
# noqa: E402
BASE
=
os
.
environ
.
get
(
"RENTOS_BASE"
,
"http://127.0.0.1:8899"
)
BASE
=
os
.
environ
.
get
(
"RENTOS_BASE"
,
""
)
if
not
BASE
:
# 该脚本会写数据;未注入 RENTOS_BASE 时默认目标 8899 是【真实台账】,
# 直连会污染权威数据(2026-10-13 曾误写入 17 个测试批次)。
# 正常路径:tools/ui_verify_runner.py(沙箱 + 注入 RENTOS_BASE)。
if
os
.
environ
.
get
(
"RENTOS_ALLOW_REAL"
)
==
"1"
:
BASE
=
"http://127.0.0.1:8899"
else
:
_me
=
os
.
path
.
basename
(
__file__
)
sys
.
exit
(
"已阻止:
%
s 未设置 RENTOS_BASE(默认 8899 是【真实台账】)。
\n
"
" 沙箱运行: python tools/ui_verify_runner.py tools/
%
s
\n
"
" 确需打真实服务:RENTOS_ALLOW_REAL=1 python tools/
%
s"
%
(
_me
,
_me
,
_me
))
OUT
=
os
.
environ
.
get
(
"RENTOS_SHOTS"
,
os
.
path
.
join
(
os
.
path
.
dirname
(
os
.
path
.
abspath
(
__file__
)),
".."
,
"output"
,
"ui_shots"
))
USER
,
PWD
=
"admin"
,
"1989@rent05"
...
...
@@ -275,14 +286,21 @@ def main():
# ============================================ 7. 批次台账 / 类别隔离
print
(
"
\n
7. 批次台账 · 类别隔离"
)
kinds
=
p
.
eval_on_selector_all
(
"#rpBatchList tbody tr td:first-child"
,
# 批次行默认按账期折叠;只展开仍折叠的分组(hash 导航不刷新页面,状态会延续)
js
(
"""() => { document.querySelectorAll('#rpBatchList tbody.pgrp.collapsed tr.grp')
.forEach(tr => tr.click()); }"""
)
p
.
wait_for_timeout
(
500
)
kinds
=
p
.
eval_on_selector_all
(
"#rpBatchList tbody tr.prow td:first-child"
,
"e=>e.map(x=>x.innerText.trim())"
)
ck
(
"房租批次表的「账单」列标出「房租车位费」"
,
kinds
and
all
(
k
==
"房租车位费"
for
k
in
kinds
),
str
(
kinds
[:
3
]))
p
.
goto
(
BASE
+
"/admin.html#/elec"
)
p
.
wait_for_selector
(
"#eGrid"
,
timeout
=
15000
)
p
.
wait_for_timeout
(
1800
)
elec_ids
=
p
.
eval_on_selector_all
(
"#batchList tbody tr td:nth-child(2)"
,
js
(
"""() => { document.querySelectorAll('#batchList tbody.pgrp.collapsed tr.grp')
.forEach(tr => tr.click()); }"""
)
p
.
wait_for_timeout
(
400
)
elec_ids
=
p
.
eval_on_selector_all
(
"#batchList tbody tr.prow td:nth-child(2)"
,
"e=>e.map(x=>x.innerText.trim())"
)
ck
(
"电费页的批次列表不含刚生成的房租批次"
,
(
created_batch
or
"x"
)
not
in
elec_ids
,
str
(
elec_ids
))
...
...
@@ -292,6 +310,9 @@ def main():
p
.
goto
(
BASE
+
"/admin.html#/table/rent_parking"
)
p
.
wait_for_selector
(
"#rpWrap table"
,
timeout
=
20000
)
p
.
wait_for_timeout
(
1200
)
js
(
"""() => { document.querySelectorAll('#rpBatchList tbody.pgrp.collapsed tr.grp')
.forEach(tr => tr.click()); }"""
)
p
.
wait_for_timeout
(
400
)
if
created_batch
:
p
.
click
(
'#rpBatchList button[data-open="
%
s"]'
%
created_batch
)
p
.
wait_for_timeout
(
1500
)
...
...
tools/ui_verify6.py
View file @
57cf814d
...
...
@@ -15,7 +15,7 @@
* 只出 1 行单,跑完**必定删除该批次并回滚账期**(try/finally 兜底);
* 结束时核对台账 AC/AJ 与跑前一致。
运行:python tools/ui_verify
6.py (需先起服务
)
运行:python tools/ui_verify
_runner.py tools/ui_verify6.py (该脚本会写数据,沙箱隔离
)
"""
import
io
import
os
...
...
@@ -25,7 +25,18 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
sys
.
path
.
insert
(
0
,
os
.
path
.
dirname
(
os
.
path
.
dirname
(
os
.
path
.
abspath
(
__file__
))))
from
playwright.sync_api
import
sync_playwright
# noqa: E402
BASE
=
os
.
environ
.
get
(
"RENTOS_BASE"
,
"http://127.0.0.1:8899"
)
BASE
=
os
.
environ
.
get
(
"RENTOS_BASE"
,
""
)
if
not
BASE
:
# 该脚本会写数据;未注入 RENTOS_BASE 时默认目标 8899 是【真实台账】,
# 直连会污染权威数据(2026-10-13 曾误写入 17 个测试批次)。
# 正常路径:tools/ui_verify_runner.py(沙箱 + 注入 RENTOS_BASE)。
if
os
.
environ
.
get
(
"RENTOS_ALLOW_REAL"
)
==
"1"
:
BASE
=
"http://127.0.0.1:8899"
else
:
_me
=
os
.
path
.
basename
(
__file__
)
sys
.
exit
(
"已阻止:
%
s 未设置 RENTOS_BASE(默认 8899 是【真实台账】)。
\n
"
" 沙箱运行: python tools/ui_verify_runner.py tools/
%
s
\n
"
" 确需打真实服务:RENTOS_ALLOW_REAL=1 python tools/
%
s"
%
(
_me
,
_me
,
_me
))
OUT
=
os
.
environ
.
get
(
"RENTOS_SHOTS"
,
os
.
path
.
join
(
os
.
path
.
dirname
(
os
.
path
.
abspath
(
__file__
)),
".."
,
"output"
,
"ui_shots"
))
USER
,
PWD
=
"admin"
,
"1989@rent05"
...
...
tools/ui_verify7.py
View file @
57cf814d
...
...
@@ -247,6 +247,10 @@ def main():
_st
,
_bl
=
api_call
(
"GET"
,
"/api/batches?kind=elec"
)
_elec
=
(
_bl
.
get
(
"batches"
)
or
[])
if
_elec
:
# 批次行默认按账期折叠;只展开仍折叠的分组
js
(
"""() => { document.querySelectorAll('#batchList tbody.pgrp.collapsed tr.grp')
.forEach(tr => tr.click()); }"""
)
p
.
wait_for_timeout
(
500
)
ck
(
"guest 看得到电费批次的「删除」按钮"
,
p
.
eval_on_selector_all
(
"#batchList tbody tr button[data-del]"
,
"es=>es.length>0 && es.every(b=>b.offsetParent!==null)"
))
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment