411 lines
17 KiB
Python
411 lines
17 KiB
Python
#!/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)')
|