Files
mileage-bonus/_gen_intervention_v3.py
T

411 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""生成 租赁任务考核_2026年5月_干预情况说明.xlsx — 公式计算 + %格式 + 完整匹配参考样式"""
import os, openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side, numbers
from openpyxl.utils import get_column_letter
from collections import defaultdict
os.chdir('/Users/kkfluous/Downloads')
SRC = '租赁任务考核_2026年5月.xlsx'
BAK = '租赁任务考核_2026年5月_原版备份.xlsx'
# ─── Styles ───
DARK_BLUE = PatternFill('solid', fgColor='1F4E78')
LIGHT_BLUE = PatternFill('solid', fgColor='D9E1F2')
HD_BLUE = PatternFill('solid', fgColor='4472C4')
YELLOW_BG = PatternFill('solid', fgColor='FFF2CC')
ALT_GRAY = PatternFill('solid', fgColor='F2F2F2')
YELLOW_HL = PatternFill('solid', fgColor='FFFF00')
NO_FILL = PatternFill('solid', fgColor='00000000')
TITLE_F = Font(bold=True, size=14, color='FFFFFF')
SEC_F = Font(bold=True, size=12, color='1F4E78')
SUB_F = Font(bold=True, size=11, color='1F4E78')
HDR_F = Font(bold=True, size=11, color='FFFFFF')
BODY_F = Font(size=11)
BOLD_F = Font(bold=True, size=11)
RED_F = Font(size=10, color='FF0000')
DESC_F = Font(size=10)
THIN = Side('thin', color='B4B4B4')
BORD = Border(THIN, THIN, THIN, THIN)
CC = Alignment(horizontal='center', vertical='center', wrap_text=True)
LL = Alignment(horizontal='left', vertical='center', wrap_text=True)
LT = Alignment(horizontal='left', vertical='top', wrap_text=True)
# ─── Load yellow intervention plates ───
wb_i = openpyxl.load_workbook('标黄为5月干预车辆.xlsx')
ws_i = wb_i['Sheet1']
YELLOW_PLATES = set()
for row in ws_i.iter_rows(min_row=2, values_only=False):
cell = row[0]
fill = cell.fill
is_y = (fill and fill.patternType == 'solid' and fill.start_color
and fill.start_color.rgb and 'FFFF00' in str(fill.start_color.rgb))
if is_y:
p = str(cell.value).strip() if cell.value else ''
if p: YELLOW_PLATES.add(p)
wb_i.close()
# 手动移除4辆已确认非干预
YELLOW_PLATES.difference_update({'粤A03186F', '粤A01128F', '粤A08391F', '粤A01396F'})
print(f"Yellow plates: {len(YELLOW_PLATES)} (removed 4)")
# ─── Load data ───
def load_recs(fp):
wb = openpyxl.load_workbook(fp, data_only=True)
ws = wb['业务考核视图']
h = [c.value for c in next(ws.iter_rows(min_row=1, max_row=1))]
recs = []
for row in ws.iter_rows(min_row=2, values_only=True):
if row[0]: recs.append(dict(zip(h, row)))
wb.close()
return recs, h
src_recs, src_h = load_recs(SRC)
bak_recs, bak_h = load_recs(BAK)
print(f"Source: {len(src_recs)} rows, cols={len(src_h)}")
print(f"Backup: {len(bak_recs)} rows, cols={len(bak_h)}")
# Identify intervention records by plate (yellow list)
intv_recs = []
for sr in src_recs:
plate = str(sr['车牌号']).strip()
if plate not in YELLOW_PLATES:
continue
gps = float(sr.get('实际行驶里程(km)', 0) or 0)
tbox = float(sr.get('原TBOX里程(km)', 0) or 0)
test = float(sr.get('测试里程(km)', 0) or 0)
tgt = float(sr.get('应考核里程(km)', 0) or 0)
# Find backup counterpart
key = (plate, str(sr.get('销售经理','')), str(sr.get('项目名称','')))
br = None
for b in bak_recs:
bk = (str(b['车牌号']).strip(), str(b.get('销售经理','')), str(b.get('项目名称','')))
if bk == key: br = b; break
old_actual = float(br['实际行驶里程(km)'] or 0) if br else 0
old_pass = str(br['是否达标']).strip() if br else '?'
new_pass = str(sr.get('是否达标','')).strip()
flip = ''
if old_pass == '达标' and new_pass == '未达标': flip = '达→未达标'
elif old_pass == '未达标' and new_pass == '达标': flip = '未→达标'
intv_recs.append(dict(plate=plate, dept=sr.get('部门名称',''), sales=sr.get('销售经理',''),
client=sr.get('客户名称',''), target_name=sr.get('考核目标',''),
tgt_km=tgt, tbox=tbox, gps=gps, test=test, flip=flip,
days=sr.get('考核天数'), rate=sr.get('完成率(%)'),
new_pass=new_pass, old_pass=old_pass))
print(f"Intervention records: {len(intv_recs)}")
flip_down = [r for r in intv_recs if r['flip'] == '达→未达标']
flip_up = [r for r in intv_recs if r['flip'] == '未→达标']
print(f"达→未达标: {len(flip_down)}, 未→达标: {len(flip_up)}")
# ─── Reference column layout (O=GPS, P=TBOX, Q=Test) ───
# Build full records with columns in reference order
REF_HEADERS = [
'车牌号', '考核年份', '考核月份', '部门名称', '销售经理', '客户名称',
'合同编号', '项目名称', '考核目标ID', '考核目标', '考核开始日期',
'考核结束日期', '考核天数', '应考核里程(km)',
'实际行驶里程(km)', # O = GPS (考核口径)
'原TBOX里程(km)', # P = TBOX (仪表盘)
'测试里程(km)', # Q = test
'完成率(%)', '是否达标', '考核状态',
'交车日期', '预计还车日期', '实际还车日期',
]
def reorder_rec(src_rec):
"""Map source record to reference column order. O=GPS, P=TBOX, Q=Test.
For non-intv: O=仪表盘, P=空, Q=0. For intv: O=GPS, P=仪表盘, Q=GPS-仪表盘."""
out = {}
plate = str(src_rec.get('车牌号','')).strip()
is_intv = plate in YELLOW_PLATES
for h in REF_HEADERS:
if h == '实际行驶里程(km)':
# O column: always use source's actual (which is仪表盘 for non-intv, GPS for intv)
out[h] = src_rec.get('实际行驶里程(km)')
elif h == '原TBOX里程(km)':
# P column: only fill if intv, else empty
out[h] = src_rec.get('原TBOX里程(km)') if is_intv else None
elif h == '测试里程(km)':
out[h] = src_rec.get('测试里程(km)') if is_intv else 0
else:
out[h] = src_rec.get(h)
return out
all_ref = [reorder_rec(r) for r in src_recs]
intv_ref = [reorder_rec(r) for r in src_recs if str(r['车牌号']).strip() in YELLOW_PLATES]
# ─── Build workbook ───
wb = openpyxl.Workbook()
def hdr_row(ws, row, ncols):
for c in range(1, ncols+1):
cell = ws.cell(row=row, column=c)
cell.fill = HD_BLUE; cell.font = HDR_F; cell.alignment = CC; cell.border = BORD
def dat_row(ws, row, ncols, alt=False):
for c in range(1, ncols+1):
cell = ws.cell(row=row, column=c)
cell.font = BODY_F; cell.alignment = CC; cell.border = BORD
if alt: cell.fill = ALT_GRAY
# ═══════════════════════════════
# Sheets 2,3,4 first (so Sheet1 formulas can reference them)
# ═══════════════════════════════
def write_data_sheet(wb, name, records, highlight_intv=False):
ws = wb.create_sheet(name)
for ci, h in enumerate(REF_HEADERS, 1):
ws.cell(row=1, column=ci, value=h)
hdr_row(ws, 1, len(REF_HEADERS))
for ri, rec in enumerate(records):
r = ri + 2
is_intv = str(rec.get('车牌号','')).strip() in YELLOW_PLATES
for ci, h in enumerate(REF_HEADERS, 1):
val = rec.get(h)
if val is not None:
ws.cell(row=r, column=ci, value=val)
ws.cell(row=r, column=ci).border = BORD
ws.cell(row=r, column=ci).font = Font(size=10)
ws.cell(row=r, column=ci).alignment = CC
if highlight_intv and is_intv:
for ci in range(1, len(REF_HEADERS)+1):
ws.cell(row=r, column=ci).fill = YELLOW_HL
for ci in range(1, len(REF_HEADERS)+1):
ws.column_dimensions[get_column_letter(ci)].width = 12
return ws
# Sheet 2: 业务考核视图 (all records, intv highlighted yellow)
ws2 = write_data_sheet(wb, '业务考核视图', all_ref, highlight_intv=True)
# Sheet 3: 5月业务考核车辆总览 (all records, no highlight)
ws3 = write_data_sheet(wb, '5月业务考核车辆总览', all_ref, highlight_intv=False)
# Sheet 4: 涉及测试的业务考核车辆 (intv only)
ws4 = write_data_sheet(wb, '涉及测试的业务考核车辆', intv_ref, highlight_intv=False)
# ═══════════════════════════════
# Sheet 1: 测试与考核情况说明 (with formulas)
# ═══════════════════════════════
ws1 = wb.active
ws1.title = '测试与考核情况说明'
MAX_R = 500 # generous row range for formulas
# R1: Title
ws1.merge_cells('A1:G1')
c = ws1.cell(row=1, column=1, value='2026年5月 业务考核 — 测试与考核情况说明')
c.fill = DARK_BLUE; c.font = TITLE_F; c.alignment = CC
ws1.row_dimensions[1].height = 32
# R2-R3: Description
ws1.merge_cells('A2:G3')
desc = ('5月业务考核车辆中,部分车辆当月存在测试行为(仪表盘里程 > GPS 实际行驶里程),其差额作为测试里程剥离,不参与考核。\n'
'对于有测试行为的车辆,使用GPS里程作为实际业务考核里程并参与绩效计算。\n'
'对于无5月GPS数据的干预车辆,实际行驶里程记为0,判定未达标。\n'
'下表数据由公式实时计算,源sheet数据更新后自动刷新。')
c = ws1.cell(row=2, column=1, value=desc)
c.fill = YELLOW_BG; c.font = DESC_F; c.alignment = LT
ws1.row_dimensions[2].height = 72
# R4: empty spacer
# R5: Section 一
ws1.merge_cells('A5:G5')
c = ws1.cell(row=5, column=1, value='一、5月业务考核与干预情况')
c.fill = LIGHT_BLUE; c.font = SEC_F; c.alignment = LL
ws1.row_dimensions[5].height = 24
# R6: Table header
for ci, h in enumerate(['项目', '5月业务考核车辆', '涉及测试的车辆', '占比', '', '', ''], 1):
ws1.cell(row=6, column=ci, value=h)
hdr_row(ws1, 6, 4)
ws1.row_dimensions[6].height = 24
# R7-R11: Formula rows
# Sheet references for formulas (openpyxl writes them as-is)
S3 = "'5月业务考核车辆总览'"
S4 = "'涉及测试的业务考核车辆'"
formula_rows = [
# (row, label, B_formula, C_formula, B_fmt, C_fmt, D_fmt)
(7, '车辆数(辆)',
f'=SUMPRODUCT(({S3}!A2:A{MAX_R}<>"")/(COUNTIF({S3}!A2:A{MAX_R},{S3}!A2:A{MAX_R})+({S3}!A2:A{MAX_R}="")))',
f'=SUMPRODUCT(({S4}!A2:A{MAX_R}<>"")/(COUNTIF({S4}!A2:A{MAX_R},{S4}!A2:A{MAX_R})+({S4}!A2:A{MAX_R}="")))',
'#,##0', '#,##0', '0.00%'),
(8, '产生考核记录数(条)',
f'=COUNTA({S3}!A2:A{MAX_R})',
f'=COUNTA({S4}!A2:A{MAX_R})',
'#,##0', '#,##0', '0.00%'),
(9, '仪表盘里程(km)',
f'=SUM({S3}!O2:O{MAX_R})+SUM({S4}!Q2:Q{MAX_R})',
f'=SUM({S4}!O2:O{MAX_R})+SUM({S4}!Q2:Q{MAX_R})',
'#,##0.0', '#,##0.0', '0.00%'),
(10, '业务考核里程 GPS(km)',
f'=SUM({S3}!O2:O{MAX_R})',
f'=SUM({S4}!O2:O{MAX_R})',
'#,##0.0', '#,##0.0', '0.00%'),
(11, '测试里程(km)',
f'=SUM({S4}!Q2:Q{MAX_R})',
f'=SUM({S4}!Q2:Q{MAX_R})',
'#,##0.0', '#,##0.0', '0.00%'),
]
for ri, (row_num, label, b_f, c_f, b_fmt, c_fmt, d_fmt) in enumerate(formula_rows):
r = row_num
ws1.cell(row=r, column=1, value=label).font = BOLD_F
ws1.cell(row=r, column=1).alignment = LL
ws1.cell(row=r, column=1).border = BORD
ws1.cell(row=r, column=2, value=b_f).number_format = b_fmt
ws1.cell(row=r, column=2).font = BODY_F
ws1.cell(row=r, column=2).alignment = CC
ws1.cell(row=r, column=2).border = BORD
ws1.cell(row=r, column=3, value=c_f).number_format = c_fmt
ws1.cell(row=r, column=3).font = BODY_F
ws1.cell(row=r, column=3).alignment = CC
ws1.cell(row=r, column=3).border = BORD
ws1.cell(row=r, column=4, value=f'=IFERROR(C{r}/B{r},0)').number_format = d_fmt
ws1.cell(row=r, column=4).font = BODY_F
ws1.cell(row=r, column=4).alignment = CC
ws1.cell(row=r, column=4).border = BORD
if ri % 2 == 1:
for c in range(1, 5):
ws1.cell(row=r, column=c).fill = ALT_GRAY
ws1.row_dimensions[r].height = 22
# R12: empty spacer
# R13: Section 二
ws1.merge_cells('A13:G13')
c = ws1.cell(row=13, column=1, value='二、干预车辆考核达标情况说明')
c.fill = LIGHT_BLUE; c.font = SEC_F; c.alignment = LL
ws1.row_dimensions[13].height = 24
# R14: Summary description
ws1.merge_cells('A14:G14')
flip_desc = (f'共 {len(intv_recs)} 条考核记录涉及里程干预;替换为GPS里程后,'
f'共 {len(flip_down)} 条考核车辆记录由"达标"转判为"未达标"。按车型分类如下:')
c = ws1.cell(row=14, column=1, value=flip_desc)
c.font = RED_F; c.alignment = LL
ws1.row_dimensions[14].height = 28
# Group by vehicle type
by_target = defaultdict(lambda: defaultdict(lambda: defaultdict(int)))
flip_by_target = defaultdict(lambda: defaultdict(lambda: defaultdict(int)))
for r in intv_recs:
by_target[r['target_name']][r['dept']][r['sales']] += 1
for r in flip_down:
flip_by_target[r['target_name']][r['dept']][r['sales']] += 1
target_order = sorted(by_target.keys(), key=lambda t: -sum(sum(s.values()) for s in flip_by_target.get(t, {}).values()))
row = 15
for tgt in target_order:
dept_sales = flip_by_target.get(tgt, {})
total_count = sum(sum(s.values()) for s in by_target[tgt].values())
flip_count = sum(sum(s.values()) for s in dept_sales.values())
if flip_count == 0: continue # only show types with flips
# Vehicle type title
if flip_count > 0:
title = f'▎{tgt}{flip_count} 辆)'
else:
title = f'▎{tgt}{total_count} 辆)'
ws1.merge_cells(start_row=row, start_column=1, end_row=row, end_column=7)
c = ws1.cell(row=row, column=1, value=title)
c.fill = LIGHT_BLUE; c.font = SUB_F; c.alignment = LL
for ci in range(1, 8): ws1.cell(row=row, column=ci).border = BORD
ws1.row_dimensions[row].height = 22
row += 1
# Headers (two columns side-by-side: A-C and E-G)
for base in [1, 5]:
for ci, h in enumerate(['部门', '销售经理', '数量'], base):
ws1.cell(row=row, column=ci, value=h)
for ci in range(1, 8):
ws1.cell(row=row, column=ci).fill = HD_BLUE
ws1.cell(row=row, column=ci).font = HDR_F
ws1.cell(row=row, column=ci).alignment = CC
ws1.cell(row=row, column=ci).border = BORD
ws1.row_dimensions[row].height = 22
row += 1
# Flatten sales list sorted by department then count
dept_order = sorted(dept_sales.keys(), key=lambda d: -sum(dept_sales[d].values()))
sales_list = []
for dept in dept_order:
for sales in sorted(dept_sales[dept].keys()):
sales_list.append((dept, sales, dept_sales[dept][sales]))
n = len(sales_list)
left = sales_list[:(n+1)//2]
right = sales_list[(n+1)//2:]
for ri2 in range(max(len(left), len(right))):
if ri2 < len(left):
d, s, cnt = left[ri2]
ws1.cell(row=row, column=1, value=d)
ws1.cell(row=row, column=2, value=s)
ws1.cell(row=row, column=3, value=cnt)
if ri2 < len(right):
d, s, cnt = right[ri2]
ws1.cell(row=row, column=5, value=d)
ws1.cell(row=row, column=6, value=s)
ws1.cell(row=row, column=7, value=cnt)
for ci in range(1, 8):
cell = ws1.cell(row=row, column=ci)
cell.border = BORD; cell.font = BODY_F; cell.alignment = CC
if ri2 % 2 == 1: cell.fill = ALT_GRAY
ws1.row_dimensions[row].height = 22
row += 1
row += 1 # blank between vehicle types
# Section 三
ws1.merge_cells(start_row=row, start_column=1, end_row=row, end_column=7)
c = ws1.cell(row=row, column=1, value='三、里程三列数据来源说明')
c.fill = LIGHT_BLUE; c.font = SEC_F; c.alignment = LL
ws1.row_dimensions[row].height = 24
row += 1
for ci, h in enumerate(['列', '字段名', '数据来源', '说明', '', '', ''], 1):
ws1.cell(row=row, column=ci, value=h)
hdr_row(ws1, row, 4)
ws1.row_dimensions[row].height = 24
row += 1
ref_data = [
('O', '实际行驶里程(km)', 'GPS 轨迹还原',
'基于 G7 GPS 定位轨迹按考核期逐日汇总计算的运营里程(total_mileage/100000=km),本月里程考核口径。干预车辆已替换为GPS里程,非干预车辆沿用原仪表盘里程。'),
('P', '原TBOX里程(km)', '车辆 T-Box / 仪表盘读数',
'当月仪表总里程(业务系统原始数据),含运营+测试+调试里程。仅干预车辆填充,非干预车辆为空。'),
('Q', '测试里程(km)', 'P O(原TBOX GPS,差额)',
'仪表盘跑了但 GPS 未记录到的部分,判定为厂内测试/调试/路试等非运营里程,不计入考核。仅干预车辆填充,非干预车辆为0。'),
]
for ri, (col_letter, name, source, note) in enumerate(ref_data):
ws1.cell(row=row, column=1, value=col_letter).font = BOLD_F
ws1.cell(row=row, column=1).alignment = CC
ws1.cell(row=row, column=2, value=name)
ws1.cell(row=row, column=3, value=source)
ws1.cell(row=row, column=4, value=note).alignment = LL
dat_row(ws1, row, 4, alt=(ri%2==1))
ws1.cell(row=row, column=1).font = BOLD_F
ws1.row_dimensions[row].height = 42
row += 1
# Column widths
for ci, w in enumerate([12, 22, 13, 12, 13, 22, 13], 1):
ws1.column_dimensions[get_column_letter(ci)].width = w
# ─── Save ───
out = 'new-kpi-202605_1/租赁任务考核_2026年5月_干预情况说明_Final.xlsx'
wb.save(out)
print(f'✅ {out} ({len(wb.sheetnames)} sheets)')