'''
对于存在重复值的电子版记账凭证汇总需要合计总数和总余额、抽取数值、和另一个系统的查重合并后对比:
使用tkinter制作的python数据清洗之查重程序,名叫"凭证重复值汇总抽取"。功能是xlsx中获取要查询的值,从各个xlsx中分别获取查找项并获得对应的序号,随后查重。windows系统中字体均为宋体否则为Fangsong Ti,字号12,窗口尺寸1500*500,底色淡绿色,有执行按键,对比复选项,停止按键。适应大量数据,至少11万行。windows系统默认打开位置为desktop,否则为/etc/huanghe/desktop/,通过json记录上次打开的文件,使用标准的Tkinter字体配置方法,避免处理过程中出错: self.tk.call(_tkinter.TclError: unknown option "-font"。避免处理过程中出错: '<' not supported between instances of 'int' and 'NoneType'。
文件选择框及选择按钮。应该支持可框选一个或者多个文件。
界面中设置查找项1输入框,默认为"备注",余额值输入框,默认为"余额",数量输入框,默认为空,如果为空则不汇总,工作表数输入框,默认为1代表第一个工作表,如果为空则代表所有工作表,序号1输入框,默认为空,序号2输入框,默认为空,序号3输入框,默认为空,字号输入框,默认为12,用于设置所有写入的字的字号。通过json记录所有输入的值。
选择保存路径,输入框默认为空,如果不选择就是空值,则在第一个源文件内右侧的空值+1列处写入数据列。如果选择保存位置并指定了保存文件名,则新建该xlsx文件并写入数据列,并自动调整列宽。
选中后选择框中显示被选中的文件。
查找项2(可选)输入框,默认为空,如果输入了信息,则各个xlsx文件都按照查找项的读取方法,将各个信息以文本格式(注意如原来是数字则保持其小数点的位数原样不变)加入查找项中,作为查找项一部分参与数据重复查询并输出保存等处理,意思是如果查找项2有信息,则查找项=查找项1+查找项2(文本格式)
查找项3(可选)输入框,默认为空,如果输入了信息,则同上,各个xlsx文件都按照查找项的读取方法,将各个信息以文本格式按顺序继续加入查找项,作为查找项一部分参与数据重复查询并输出保存等处理,意思是如果查找项2有信息,则查找项=查找项1+查找项2(文本格式)+查找项3(文本格式)注意:查找项2、查找项3均为文本格式并入查找项。
数据清洗功能:
删除空格选项,默认选中,所有数据自动删除空格。
删除括号选项,默认选中,所有数据自动判断中文、英文格式括号,删除括号及括号内内容。
删除信息输入框,默认为空,如果输入信息,则所有数据处理时自动删除相应信息数据内容,例如输入"设置",则所有数据保存时删除"设置"。
删除空值,空值可能单独存在,也可能与其他内容一起,删除所有空值。
查找项功能:
点击执行按钮后,判断如果未选择比对选项,将选中读取的单个xlsx文件的默认第一个工作表中0至20行150列范围内查找包含查找项的单元,如果发现该文件有数据筛选则弹窗提示。
找到有查找项内容的单元格后,以该单元以下的单元格内容合并为逐个查找项的项目,随后执行查找项的数据清洗功能。
设置区域的可选"转文本"选项,默认选中。各个单元格内容作为查找项,如果选中了转文本选项,则内容不管是不是数字都转换为文本格式作为查找项。
在各个xlsx的0至20行150列范围内查找包含余额值内容的单元。找到余额所在单元格后,以该单元格以下的单元格内容为逐个余额值项目,内容如果是文本格式的数,自动转为数值格式,小数点位数按照实际有的小数,而不是固定小数位数。例如0还是0,不是0.0。
同样的找到数量输入框的值所在单元格后,以该单元格以下的单元格内容为逐个数量值项目,内容如果是文本格式的数,自动转为数值格式,小数点位数按照实际有的小数,而不是固定小数位数。例如0还是0,不是0.0。
2.重复项合并选项,默认不选中:
如果选中重复项合并选项,则点击执行按钮后,对查找项、余额值、数量进行以查找项为准的查重,其他项随动,将该列中查找项的各个相同的查找项分别视为一个个单独的组,获得各个组内重复的查找项的个数,将个数记为重复数,如果无重复则改行单独是一个组,且重复数是1,只保留该小组行号最小的第一个查找项,同时在该小组所有查找项所在行对应的余额值进行合计,记为"合计值",该值行号与保留的查找项行号相同。
接着写入,根据选择保存路径的信息,首先在xlsx表中最近的空值列向右+1列输出每组保留的唯一查找项,注意:表头字体均加粗,内容为源文件名(不含扩展名)+查找项。其次,+2列输出查重合并后的重复数据,表头加粗,内容为重复数:维持其行号与每组保留的查找项行号相同,然后,+3列输出数量合计值(如果数量输入框不为空),表头为数量。然后+4列输出余额的"合计值",表头为余额合计。如果选中了多个文件,随后的文件数据,在同一xlsx文件内的第一个文件输出查重合并后的数据右侧空值右侧进行保存,以此类推。注意:表头字体均加粗。
注意:数据结果如果是0,应检查原因,如果查明原因应给出提示。如果在文件中找不到查找项、余额、数量之类的数据,则应提示。
注意:对比结果应该同在一个xlsx文件的同一个工作表,后续文件的数据应在之前文件数据右侧放置,而不应该分别写入不同的工作表,以便同时看到结果。
3.设置区域的负值减2选项。
默认不选中,用于对余额值进行负值判断,如果一个数量或余额组中只要出现负值意味着撤销或退库,应减少重复两倍的数量。例如余额中有1个负值,虽然仍然正常合计余额,但是数量自动减2,2个负值数量就减4,出现重复数时数量底色加浅黄色。注意:不增加其他额外的负值提示。
4.排序选项,默认不选中。
如果选中则输出查重合并后的数据时增加组内排序:以各组保留的唯一查找项为准,从小到大排序,排序后的内容自动跳过空行,注意重复数、数量合计值、余额合计值的输出内容同步跟着唯一查找项调整。(其中内容中如果存在数字文字混排,要单独将数字排序,例如1月应在11月之前)。
同样的,从大到小排序选项,默认不选中,逻辑与从小到大雷同,顺序相反。
5.近似度对比选项,颜色绿色,默认不选中,与执行按钮同一列且在其右侧。
是增加排序后对比功能的选项,默认不选中。若选中,则在点击执行按钮后首先判断是否选中对比选项,接着判断是否选中了多个文件,如果选中一个文件且只有一个工作表,则弹窗提示单文件无法比对,
如果选中的多个文件,首先将第一个文件输出查重合并后的数据按排序功能小到大排序,随后按顺序将第2个文件输出查重合并后的数据向右空一行保存随后与第一个文件输出的唯一查找项进行逐个比较,对比分为两个阶段:第1阶段额为基础性比对阶段。查找完全一致的唯一查找项,找到后则将其及相关的第二文件的唯一查找项、重复数、数量合计值、余额合计值均调整到与第一个文件的该值的相同行以此实现匹配,匹配的单元格底色设置为淡蓝色,其他行内容等待下一阶段比对后写入。注意:向右间隔一列写入并维持其表头与单个文件的要求相同。注意:这一步是个简单的排序后匹配,只要获取完全匹配一致的值,而不用判断匹配度。注意第1阶段所获得的匹配的内容有更高的优先级,不可因为第2阶段的匹配而变更。
接下来是第2阶段:对于其他未完全匹配第一个文件值的查找值,进行简单的相似度匹配,例如按照相同字段占该唯一查找项内容的比例来作为相似度高低依据,按相似度从高到底匹配顺序写入,注意:不能出现第二个文件的两个唯一查找项匹配第一个文件的同一个唯一查找值的逻辑错误。对于相似度低的写入的优先级应该较低(无法获得相似度时或者该数据相似度为0的也在参加匹配判断后在本列后写入),随着优先级降低数据的底色从黄到淡黄色改变。注意:再次确认是向右空一列写入。最终完成所有数据匹配写入。注意:如果完全没有匹配或者相似度为0或没有找到匹配项的数据也都要在数据下边按顺序写入,不能另起一列吸入。
注意:不能遗漏数据,这样将调整完毕的数据,再次确认是在第一个文件输出的数据右侧空值+1列保存,数据结构与第一个文件的数据结构相同。按照这个办法,同样将第三个文件输出查重合并后的数据与第一个文件输出查重合并后的数据同样处理,结果在第二个文件输出查重合并后的数据右侧空值空1列保存。以后的多个文件数据以此类推。注意这些数据都是保存在同一xls中,且保存时自动适应数据宽度。
注意:检查所有功能是否实现,不可忽略。
6.组内排序编号下拉菜单。通过json记录选中的值。作用是点击执行按钮后,按主要、次要的优先级来查找到序号1、序号2、序号3的输入框内容所在单元格后,再以其单元格以下的单元格内容为逐个排序依据,然后从1开始编号。其下拉菜单中:首先,"升序排列编号"选项为默认值,选中后从小到大从1开始编号。其次,"降序排列编号"选项,选中后从大到小从1开始编号,然后将各个组的编号在表中最近的空值列向右+1列输出,其表头为组内编号。最后,"不排序编号"选项,选中后不进行组内排序编号。注意:每组编号不可重复,即使各个序号都相同也增加序号。每组编号最大值不可超过各组的重复数。
'''
-*- coding: utf-8 -*-
"""
凭证重复值汇总 - 数据清洗查重程序
功能:从xlsx文件中获取查找项,执行数据清洗、查重合并、排序、近似度对比、组内排序编号
"""
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import threading, json, os, sys, re, platform
from decimal import Decimal
try:
import openpyxl
from openpyxl.styles import Font, PatternFill
from openpyxl.utils import get_column_letter
except ImportError:
import subprocess
subprocess.check_call([sys.executable, "-m", "pip", "install", "openpyxl"])
import openpyxl
from openpyxl.styles import Font, PatternFill
from openpyxl.utils import get_column_letter
─────────────────────────────────────────────
全局常量
─────────────────────────────────────────────
IS_WINDOWS = platform.system() == "Windows"
FONT_FAMILY = "SimSun" if IS_WINDOWS else "FangSong"
FONT_SIZE = 12
WIN_W, WIN_H = 1500, 560
BG_COLOR = "#e8f5e9"
if IS_WINDOWS:
DEFAULT_DIR = os.path.join(os.path.expanduser("~"), "Desktop")
else:
DEFAULT_DIR = "/home/huanghe/desktop"
SCRIPT_DIR = os.path.dirname(os.path.abspath(file))
CONFIG_FILE = os.path.join(SCRIPT_DIR, "凭证重复值汇总_config.json")
─────────────────────────────────────────────
配置读写
─────────────────────────────────────────────
DEFAULT_CFG = {
"files": [], "save_path": "", "field1": "备注", "field2": "", "field3": "",
"balance_col": "余额", "qty_col": "", "sheet_num": "1",
"seq1": "", "seq2": "", "seq3": "", "font_size": "12",
"del_space": True, "del_bracket": True, "del_text": "", "to_text": True,
"merge_dup": False, "neg_minus2": False,
"sort_asc": False, "sort_desc": False, "compare": False,
"seq_order": "升序排列编号", "last_open_dir": DEFAULT_DIR,
}
def load_config():
cfg = dict(DEFAULT_CFG)
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
cfg.update(json.load(f))
except Exception:
pass
return cfg
def save_config(cfg):
try:
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
except Exception:
pass
─────────────────────────────────────────────
数字处理
─────────────────────────────────────────────
def smart_num(val):
"""转数字,失败返回None。bool不转。"""
if val is None or isinstance(val, bool):
return None
if isinstance(val, (int, float)):
try:
return float(Decimal(str(val)))
except Exception:
return float(val)
s = str(val).strip()
if s == "":
return None
try:
return float(Decimal(s))
except Exception:
return None
def format_num_preserve(val):
"""数字转字符串,保持实际小数位,去多余0。0->'0', 1.5->'1.5', 3.10->'3.1'"""
if val is None:
return ""
try:
d = Decimal(str(val))
int_part = int(d.to_integral_value())
if d - Decimal(int_part) == 0:
return str(int_part)
return f"{d:f}".rstrip('0').rstrip('.')
except Exception:
return str(val)
def cell_raw_text(val):
"""单元格值转文本,数字保持小数位"""
if val is None or isinstance(val, bool):
return str(val) if val is not None else ""
if isinstance(val, int):
return str(val)
if isinstance(val, float):
return format_num_preserve(val)
return str(val)
─────────────────────────────────────────────
数据清洗
─────────────────────────────────────────────
def clean_text(s, del_space=True, del_bracket=True, del_text=""):
if not isinstance(s, str):
s = str(s) if s is not None else ""
if s.strip() == "":
return ""
if del_space:
s = s.replace(" ", "").replace("\u3000", "").replace("\t", "")
if del_bracket:
s = re.sub(r'\([^)]*\)', '', s)
s = re.sub(r'([^)]*)', '', s)
if del_text:
s = s.replace(del_text, "")
return s.strip()
def make_key(val, to_text, del_space, del_bracket, del_text):
"""查找项文本:to_text=True则强制文本,False则数字保持小数位"""
if val is None:
return ""
if to_text:
raw = cell_raw_text(val)
else:
raw = cell_raw_text(val) if isinstance(val, (int, float)) else str(val)
return clean_text(raw, del_space, del_bracket, del_text)
─────────────────────────────────────────────
查找标题
─────────────────────────────────────────────
def find_header_cell(ws, keyword, max_row=20, max_col=150):
if not keyword:
return None, None
for row in ws.iter_rows(min_row=1, max_row=max_row, min_col=1, max_col=max_col):
for cell in row:
if cell.value is not None and keyword in str(cell.value):
return cell.row, cell.column
return None, None
def has_autofilter(ws):
return bool(ws.auto_filter and ws.auto_filter.ref)
─────────────────────────────────────────────
读取工作表
─────────────────────────────────────────────
def read_sheet_data(ws, cfg, stop_flag):
field1 = cfg.get("field1", "备注")
field2 = cfg.get("field2", "")
field3 = cfg.get("field3", "")
bal_col = cfg.get("balance_col", "余额")
qty_col = cfg.get("qty_col", "")
seq1col = cfg.get("seq1", "")
seq2col = cfg.get("seq2", "")
seq3col = cfg.get("seq3", "")
dsp = cfg.get("del_space", True)
dbr = cfg.get("del_bracket", True)
dtxt = cfg.get("del_text", "")
totxt = cfg.get("to_text", True)
r1, c1 = find_header_cell(ws, field1)
if r1 is None:
return None, f"找不到查找项列 [{field1}]"
_, c2 = find_header_cell(ws, field2) if field2 else (None, None)
_, c3 = find_header_cell(ws, field3) if field3 else (None, None)
_, cb = find_header_cell(ws, bal_col) if bal_col else (None, None)
_, cq = find_header_cell(ws, qty_col) if qty_col else (None, None)
_, cs1 = find_header_cell(ws, seq1col) if seq1col else (None, None)
_, cs2 = find_header_cell(ws, seq2col) if seq2col else (None, None)
_, cs3 = find_header_cell(ws, seq3col) if seq3col else (None, None)
data = []
for row_idx in range(r1 + 1, ws.max_row + 1):
if stop_flag[0]:
return None, "已停止"
def cv(col):
return None if col is None else ws.cell(row=row_idx, column=col).value
key = make_key(cv(c1), totxt, dsp, dbr, dtxt)
if field2 and c2 is not None:
key += make_key(cv(c2), True, dsp, dbr, dtxt)
if field3 and c3 is not None:
key += make_key(cv(c3), True, dsp, dbr, dtxt)
bal = smart_num(cv(cb)) if cb is not None else None
qty = smart_num(cv(cq)) if cq is not None else None
if key == "" and bal is None and qty is None:
continue
data.append({
"key": key, "balance": bal, "qty": qty,
"seq1": cv(cs1), "seq2": cv(cs2), "seq3": cv(cs3),
"row": row_idx,
})
return data, None
─────────────────────────────────────────────
重复项合并
─────────────────────────────────────────────
def merge_duplicates(data, neg_minus2=False):
from collections import OrderedDict
groups = OrderedDict()
for item in data:
k = item["key"]
if k not in groups:
groups[k] = dict(key=k, balance_sum=None, qty_sum=None, count=0,
min_row=item["row"], neg_count=0,
seq1=item["seq1"], seq2=item["seq2"], seq3=item["seq3"],
items=[])
g = groups[k]
g["count"] += 1
g["items"].append(item)
if item["row"] < g["min_row"]:
g["min_row"] = item["row"]
g["seq1"], g["seq2"], g["seq3"] = item["seq1"], item["seq2"], item["seq3"]
if item["balance"] is not None:
b = item["balance"]
if g["balance_sum"] is None:
g["balance_sum"] = 0.0
g["balance_sum"] = float(Decimal(repr(g["balance_sum"])) + Decimal(repr(b)))
if b < 0:
g["neg_count"] += 1
if item["qty"] is not None:
q = item["qty"]
if g["qty_sum"] is None:
g["qty_sum"] = 0.0
g["qty_sum"] = float(Decimal(repr(g["qty_sum"])) + Decimal(repr(q)))
result = []
for g in groups.values():
if neg_minus2 and g["neg_count"] > 0:
ded = Decimal(repr(g["neg_count"] * 2))
g["qty_sum"] = float((Decimal(repr(g["qty_sum"])) - ded) if g["qty_sum"] is not None else -ded)
result.append(g)
return result
─────────────────────────────────────────────
排序
─────────────────────────────────────────────
def natural_sort_key(s):
if s is None:
return [(1, 0, "")]
parts = re.split(r'(\d+)', str(s))
result = []
for p in parts:
result.append((0, int(p), "") if p.isdigit() else (1, 0, p))
return result
def sort_groups(groups, ascending=True):
non_empty = [g for g in groups if g.get("key")]
empty = [g for g in groups if not g.get("key")]
non_empty.sort(key=lambda g: natural_sort_key(g["key"]), reverse=not ascending)
return non_empty + empty
─────────────────────────────────────────────
组内排序编号
─────────────────────────────────────────────
def assign_ingroup_numbers(groups, seq_order):
"""按seq1/2/3排序后为每组的items分配编号(1起),写入g['ingroup_nums']"""
asc = (seq_order == "升序排列编号")
for g in groups:
items = g["items"]
if seq_order == "不排序编号":
g["ingroup_nums"] = list(range(1, len(items) + 1))
else:
def sk(item):
return (natural_sort_key(item.get("seq1")),
natural_sort_key(item.get("seq2")),
natural_sort_key(item.get("seq3")))
sorted_items = sorted(items, key=sk, reverse=not asc)
pos = {item["row"]: i + 1 for i, item in enumerate(sorted_items)}
g["ingroup_nums"] = [pos[item["row"]] for item in items]
return groups
─────────────────────────────────────────────
相似度
─────────────────────────────────────────────
def similarity(s1, s2):
from collections import Counter
if not s1 or not s2:
return 0.0
s1, s2 = str(s1), str(s2)
c1, c2 = Counter(s1), Counter(s2)
common = sum(min(c1[ch], c2[ch]) for ch in c1 if ch in c2)
return common / max(len(s1), len(s2))
─────────────────────────────────────────────
填充色
─────────────────────────────────────────────
FILL_LIGHT_BLUE = PatternFill(start_color="ADD8E6", end_color="ADD8E6", fill_type="solid")
FILL_NEG_QTY = PatternFill(start_color="FFFFCC", end_color="FFFFCC", fill_type="solid")
def grad_fill(ratio):
"""ratio 0=高相似度(黄) → 1=低相似度(淡黄)"""
r1, g1, b1 = 0xFF, 0xFF, 0x00
r2, g2, b2 = 0xFF, 0xFA, 0xCD
r = int(r1 + (r2 - r1) * ratio)
g = int(g1 + (g2 - g1) * ratio)
b = int(b1 + (b2 - b1) * ratio)
h = f"{r:02X}{g:02X}{b:02X}"
return PatternFill(start_color=h, end_color=h, fill_type="solid")
def bf(size): return Font(name=FONT_FAMILY, bold=True, size=size)
def nf(size): return Font(name=FONT_FAMILY, bold=False, size=size)
─────────────────────────────────────────────
列宽
─────────────────────────────────────────────
def auto_col_width(ws):
for col in ws.columns:
max_len = 0
letter = get_column_letter(col[0].column)
for cell in col:
if cell.value is not None:
w = sum(2 if ord(c) > 127 else 1 for c in str(cell.value))
if w > max_len:
max_len = w
ws.column_dimensions[letter].width = max(max_len + 2, 8)
def last_col(ws):
mc = 0
for row in ws.iter_rows():
for cell in row:
if cell.value is not None and cell.column > mc:
mc = cell.column
return mc
─────────────────────────────────────────────
写入合并数据
─────────────────────────────────────────────
def write_group_data(ws, groups, start_col, label, fsize, has_qty,
neg_minus2, seq_order, seq1_name, seq2_name, seq3_name):
bf0, nf0 = bf(fsize), nf(fsize)
col_key = start_col
col_cnt = start_col + 1
col_qty = (start_col + 2) if has_qty else None
col_bal = (start_col + 3) if has_qty else (start_col + 2)
show_ing = (seq_order != "不排序编号") and bool(seq1_name or seq2_name or seq3_name)
col_ing = (col_bal + 1) if show_ing else None
def hdr(col, val):
c = ws.cell(row=1, column=col)
c.value = val; c.font = bf0
hdr(col_key, f"{label}查找项")
hdr(col_cnt, "重复数")
if col_qty: hdr(col_qty, "数量")
hdr(col_bal, "余额合计")
if col_ing: hdr(col_ing, "组内编号")
if show_ing:
assign_ingroup_numbers(groups, seq_order)
for dr, g in enumerate(groups, start=2):
ing_num = None
if col_ing and "ingroup_nums" in g:
items = g["items"]
min_row = min(item["row"] for item in items)
nums = g["ingroup_nums"]
for idx, item in enumerate(items):
if item["row"] == min_row:
ing_num = nums[idx]
break
if ing_num is None:
ing_num = nums[0] if nums else 1
def wc(col, val, fnt=None, fl=None):
c = ws.cell(row=dr, column=col)
c.value = val
if fnt: c.font = fnt
if fl: c.fill = fl
wc(col_key, g["key"], nf0)
wc(col_cnt, g["count"], nf0)
if col_qty:
fl = FILL_NEG_QTY if (neg_minus2 and g.get("neg_count", 0) > 0 and g["count"] > 1) else None
wc(col_qty, g["qty_sum"], nf0, fl)
wc(col_bal, g["balance_sum"], nf0)
if col_ing and ing_num is not None:
wc(col_ing, ing_num, nf0)
return col_ing if col_ing else col_bal
─────────────────────────────────────────────
近似度对比写入
─────────────────────────────────────────────
def write_compared_data(ws, ref_groups, cmp_groups, start_col,
label, fsize, has_qty, neg_minus2, seq_order,
seq1_name, seq2_name, seq3_name):
bf0, nf0 = bf(fsize), nf(fsize)
col_key = start_col
col_cnt = start_col + 1
col_qty = (start_col + 2) if has_qty else None
col_bal = (start_col + 3) if has_qty else (start_col + 2)
def hdr(col, val):
c = ws.cell(row=1, column=col)
c.value = val; c.font = bf0
hdr(col_key, f"{label}查找项")
hdr(col_cnt, "重复数")
if col_qty: hdr(col_qty, "数量")
hdr(col_bal, "余额合计")
ref_map = {g["key"]: i + 2 for i, g in enumerate(ref_groups)}
第1阶段:完全匹配
placed = {} # ci -> row
phase1 = set() # 第1阶段匹配的ci
used_ref = set()
for ci, cg in enumerate(cmp_groups):
rr = ref_map.get(cg["key"])
if rr is not None and rr not in used_ref:
placed[ci] = rr
phase1.add(ci)
used_ref.add(rr)
第2阶段:相似度匹配
free_ref = [r for r in range(2, len(ref_groups) + 2) if r not in used_ref]
cand = []
for ci in range(len(cmp_groups)):
if ci in phase1:
continue
ck = cmp_groups[ci]["key"]
for rr in free_ref:
rk = ref_groups[rr - 2]["key"]
s = similarity(ck, rk)
if s > 0:
cand.append((s, ci, rr))
cand.sort(key=lambda x: -x[0])
sim_sc = {}
asn_ci, asn_rr = set(phase1), set(used_ref)
for s, ci, rr in cand:
if ci not in asn_ci and rr not in asn_rr:
placed[ci] = rr
asn_ci.add(ci); asn_rr.add(rr)
sim_sc[ci] = s
剩余追加到末尾
max_r = max(list(ref_map.values()) + list(placed.values())) if (ref_map or placed) else 1
nxt = max_r + 1
for ci in range(len(cmp_groups)):
if ci not in placed:
placed[ci] = nxt
sim_sc[ci] = 0.0
nxt += 1
nonzero = [v for v in sim_sc.values() if v > 0]
max_sim = max(nonzero) if nonzero else 1.0
def get_fill(ci):
if ci in phase1:
return FILL_LIGHT_BLUE
s = sim_sc.get(ci, 0.0)
if s <= 0 or max_sim <= 0:
return grad_fill(1.0)
return grad_fill(1.0 - s / max_sim)
for ci, g in enumerate(cmp_groups):
dr = placed[ci]
fl = get_fill(ci)
def wc(col, val, fnt=None, flll=None):
c = ws.cell(row=dr, column=col)
c.value = val
if fnt: c.font = fnt
if flll: c.fill = flll
wc(col_key, g["key"], nf0, fl)
wc(col_cnt, g["count"], nf0, fl)
if col_qty: wc(col_qty, g["qty_sum"], nf0, fl)
wc(col_bal, g["balance_sum"], nf0, fl)
return col_bal
─────────────────────────────────────────────
主处理
─────────────────────────────────────────────
def process_files(cfg, files, stop_flag, log_cb):
fsize = int(cfg.get("font_size", 12) or 12)
merge_dup = cfg.get("merge_dup", False)
neg_minus2 = cfg.get("neg_minus2", False)
sort_asc = cfg.get("sort_asc", False)
sort_desc = cfg.get("sort_desc", False)
do_compare = cfg.get("compare", False)
seq_order = cfg.get("seq_order", "升序排列编号")
has_qty = bool(cfg.get("qty_col", "").strip())
save_path = cfg.get("save_path", "").strip()
seq1_name = cfg.get("seq1", "")
seq2_name = cfg.get("seq2", "")
seq3_name = cfg.get("seq3", "")
sheet_str = cfg.get("sheet_num", "1").strip()
if do_compare and not merge_dup:
log_cb("近似度对比需要同时选中[重复项合并]选项。"); return
if do_compare and len(files) < 2:
messagebox.showwarning("提示", "近似度对比需要选择多个文件。"); return
all_results = []
warn_msgs = []
for fi, fp in enumerate(files):
if stop_flag[0]: log_cb("已停止"); return
fname = os.path.splitext(os.path.basename(fp))[0]
log_cb(f"读取文件 {fi+1}/{len(files)}:{fname} ...")
try:
wb = openpyxl.load_workbook(fp, data_only=True)
except Exception as e:
log_cb(f" 无法打开 {fname}:{e}"); continue
if sheet_str == "":
wss = list(wb.worksheets)
else:
try:
wss = [wb.worksheets[int(sheet_str) - 1]]
except (IndexError, ValueError):
wss = list(wb.worksheets)
fdata = []
for ws in wss:
if stop_flag[0]: wb.close(); log_cb("已停止"); return
if not do_compare and has_autofilter(ws):
messagebox.showwarning("提示", f"文件 {fname} 工作表 [{ws.title}] 存在数据筛选!")
sd, err = read_sheet_data(ws, cfg, stop_flag)
if stop_flag[0]: wb.close(); log_cb("已停止"); return
if err:
warn_msgs.append(f"{fname}/{ws.title}:{err}")
log_cb(f" {err}"); continue
if sd: fdata.extend(sd)
wb.close()
if not fdata:
log_cb(f" 文件 {fname} 无有效数据"); continue
log_cb(f" 读取到 {len(fdata)} 行")
if merge_dup:
groups = merge_duplicates(fdata, neg_minus2)
log_cb(f" 合并后 {len(groups)} 组")
if sort_asc:
groups = sort_groups(groups, ascending=True)
elif sort_desc:
groups = sort_groups(groups, ascending=False)
zb = [g for g in groups if g["balance_sum"] == 0 and g["count"] > 1]
if zb: log_cb(f" 注意:有 {len(zb)} 组合计余额为0")
all_results.append((fname, groups, fdata))
else:
all_results.append((fname, None, fdata))
if not all_results:
log_cb("没有有效数据"); return
打开输出文件
if save_path:
out_wb = openpyxl.Workbook()
out_ws = out_wb.active
out_ws.title = "查重结果"
out_file = save_path
else:
out_file = files[0]
try:
out_wb = openpyxl.load_workbook(out_file)
except Exception as e:
log_cb(f"无法打开输出文件:{e}"); return
if sheet_str == "":
out_ws = out_wb.active
else:
try:
out_ws = out_wb.worksheets[int(sheet_str) - 1]
except (IndexError, ValueError):
out_ws = out_wb.active
cur_col = last_col(out_ws) + 2
ref_groups = None
for fi, (fname, groups, rdata) in enumerate(all_results):
if stop_flag[0]: log_cb("已停止"); break
if merge_dup and groups is not None:
if do_compare:
if fi == 0:
ref_groups = sort_groups(groups, ascending=True)
lc = write_group_data(out_ws, ref_groups, cur_col, fname,
fsize, has_qty, neg_minus2, seq_order,
seq1_name, seq2_name, seq3_name)
cur_col = lc + 2
else:
cmp_sorted = sort_groups(groups, ascending=True)
lc = write_compared_data(out_ws, ref_groups, cmp_sorted, cur_col,
fname, fsize, has_qty, neg_minus2, seq_order,
seq1_name, seq2_name, seq3_name)
cur_col = lc + 2
else:
lc = write_group_data(out_ws, groups, cur_col, fname,
fsize, has_qty, neg_minus2, seq_order,
seq1_name, seq2_name, seq3_name)
cur_col = lc + 2
else:
bf0, nf0 = bf(fsize), nf(fsize)
ck, cb2, cq = cur_col, cur_col + 1, (cur_col + 2) if has_qty else None
ws.cell(row=1, column=ck, value=f"{fname}查找项").font = bf0
ws.cell(row=1, column=cb2, value="余额").font = bf0
if cq: ws.cell(row=1, column=cq, value="数量").font = bf0
for di, item in enumerate(rdata):
r = di + 2
ws.cell(row=r, column=ck, value=item["key"]).font = nf0
ws.cell(row=r, column=cb2, value=item["balance"]).font = nf0
if cq: ws.cell(row=r, column=cq, value=item["qty"]).font = nf0
lc = cq if cq else cb2
cur_col = lc + 2
log_cb(f" 文件 {fname} 写入完成")
log_cb("自动调整列宽...")
auto_col_width(out_ws)
try:
out_wb.save(out_file)
log_cb(f"已保存:{out_file}")
except PermissionError:
messagebox.showerror("保存失败", f"文件被占用:\n{out_file}"); return
except Exception as e:
messagebox.showerror("保存失败", str(e)); return
if warn_msgs:
messagebox.showinfo("处理提示", "\n".join(warn_msgs))
messagebox.showinfo("完成", f"处理完成!\n结果保存至:\n{out_file}")
─────────────────────────────────────────────
GUI
─────────────────────────────────────────────
class App:
def init(self, root):
self.root = root
self.cfg = load_config()
self.stop_flag = [False]
self.thread = None
self._setup_window()
self._build_ui()
self._load_values()
def _setup_window(self):
self.root.title("凭证重复值汇总")
self.root.geometry(f"{WIN_W}x{WIN_H}")
self.root.configure(bg=BG_COLOR)
self.root.resizable(True, True)
def _lbl(self, p, t, **kw):
return tk.Label(p, text=t, bg=BG_COLOR, font=(FONT_FAMILY, FONT_SIZE), **kw)
def _ent(self, p, v, w=10):
return tk.Entry(p, textvariable=v, font=(FONT_FAMILY, FONT_SIZE), width=w)
def _btn(self, p, t, c, bg="#4CAF50", fg="white", w=10, **kw):
return tk.Button(p, text=t, command=c, font=(FONT_FAMILY, FONT_SIZE),
bg=bg, fg=fg, width=w, relief=tk.RAISED, **kw)
def _chk(self, p, t, v, cmd=None, fg=None, **kw):
kw2 = dict(bg=BG_COLOR, font=(FONT_FAMILY, FONT_SIZE), **kw)
if fg: kw2["fg"] = fg; kw2["activeforeground"] = fg
if cmd: kw2["command"] = cmd
return tk.Checkbutton(p, text=t, variable=v, **kw2)
def _build_ui(self):
r = self.root
文件选择
ff = tk.LabelFrame(r, text="文件选择", bg=BG_COLOR, font=(FONT_FAMILY, FONT_SIZE))
ff.pack(fill=tk.X, padx=5, pady=2)
btn_col = tk.Frame(ff, bg=BG_COLOR)
btn_col.pack(side=tk.LEFT, padx=4, pady=3)
self._btn(btn_col, "选择文件", self._select_files, w=10).pack(pady=2)
self._btn(btn_col, "清除文件", self._clear_files, bg="#FF9800", w=10).pack(pady=2)
self.file_lb = tk.Listbox(ff, font=(FONT_FAMILY, FONT_SIZE - 1),
height=3, selectmode=tk.EXTENDED, width=100)
self.file_lb.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=4, pady=3)
sb = tk.Scrollbar(ff, orient=tk.VERTICAL, command=self.file_lb.yview)
sb.pack(side=tk.RIGHT, fill=tk.Y)
self.file_lb.config(yscrollcommand=sb.set)
设置区
sf = tk.LabelFrame(r, text="设置", bg=BG_COLOR, font=(FONT_FAMILY, FONT_SIZE))
sf.pack(fill=tk.X, padx=5, pady=2)
第1行
r1 = tk.Frame(sf, bg=BG_COLOR)
r1.pack(fill=tk.X, padx=4, pady=2)
self.v_f1 = tk.StringVar(); self.v_f2 = tk.StringVar()
self.v_f3 = tk.StringVar(); self.v_bal = tk.StringVar()
self.v_qty = tk.StringVar(); self.v_sht = tk.StringVar()
self.v_s1 = tk.StringVar(); self.v_s2 = tk.StringVar()
self.v_s3 = tk.StringVar(); self.v_fsz = tk.StringVar()
for txt, var, w in [
("查找项1:", self.v_f1, 10), ("查找项2:", self.v_f2, 10),
("查找项3:", self.v_f3, 10), ("余额列:", self.v_bal, 8),
("数量列:", self.v_qty, 8), ("工作表:", self.v_sht, 4),
("组内排序1:", self.v_s1, 8), ("组内排序2:", self.v_s2, 8),
("组内排序3:", self.v_s3, 8), ("字号:", self.v_fsz, 4),
]:
self._lbl(r1, txt).pack(side=tk.LEFT)
self._ent(r1, var, w).pack(side=tk.LEFT, padx=2)
第2行
r2 = tk.Frame(sf, bg=BG_COLOR)
r2.pack(fill=tk.X, padx=4, pady=2)
self.v_del_sp = tk.BooleanVar(); self.v_del_br = tk.BooleanVar()
self.v_del_txt = tk.StringVar(); self.v_totxt = tk.BooleanVar()
self.v_mdup = tk.BooleanVar(); self.v_neg2 = tk.BooleanVar()
self.v_sasc = tk.BooleanVar(); self.v_sdesc = tk.BooleanVar()
self.v_seqord = tk.StringVar()
self._chk(r2, "删除空格", self.v_del_sp).pack(side=tk.LEFT, padx=3)
self._chk(r2, "删除括号", self.v_del_br).pack(side=tk.LEFT, padx=3)
self._lbl(r2, "删除信息:").pack(side=tk.LEFT)
self._ent(r2, self.v_del_txt, 10).pack(side=tk.LEFT, padx=2)
self._chk(r2, "=重复项合并=", self.v_mdup).pack(side=tk.LEFT, padx=3)
self._chk(r2, "转文本", self.v_totxt).pack(side=tk.LEFT, padx=3)
self._chk(r2, "负值减2", self.v_neg2).pack(side=tk.LEFT, padx=3)
self._chk(r2, "从小到大排序", self.v_sasc, cmd=self._on_asc).pack(side=tk.LEFT, padx=3)
self._chk(r2, "从大到小排序", self.v_sdesc, cmd=self._on_desc).pack(side=tk.LEFT, padx=3)
self._lbl(r2, "组内排序:").pack(side=tk.LEFT)
self.seq_cb = ttk.Combobox(r2, textvariable=self.v_seqord,
values=["升序排列编号", "降序排列编号", "不排序编号"],
width=13, state="readonly")
self.seq_cb.pack(side=tk.LEFT, padx=2)
保存路径
spf = tk.LabelFrame(r, text="保存路径(留空则写入第一个源文件右侧空列)",
bg=BG_COLOR, font=(FONT_FAMILY, FONT_SIZE))
spf.pack(fill=tk.X, padx=5, pady=2)
self.v_save = tk.StringVar()
tk.Entry(spf, textvariable=self.v_save,
font=(FONT_FAMILY, FONT_SIZE), width=90).pack(
side=tk.LEFT, padx=5, pady=2, fill=tk.X, expand=True)
self._btn(spf, "选择保存路径", self._select_save, bg="#2196F3", w=12).pack(side=tk.LEFT, padx=3)
self._btn(spf, "清除路径", self._clear_save, bg="#FF9800", w=8).pack(side=tk.LEFT, padx=3)
操作区
opf = tk.Frame(r, bg=BG_COLOR)
opf.pack(fill=tk.X, padx=5, pady=3)
self._btn(opf, "执行", self._run, bg="#4CAF50", fg="white", w=10).pack(side=tk.LEFT, padx=5)
self.v_cmp = tk.BooleanVar()
self._chk(opf, "近似度对比", self.v_cmp, fg="#2E7D32").pack(side=tk.LEFT, padx=5)
self._btn(opf, "停止", self._stop, bg="#F44336", fg="white", w=10).pack(side=tk.LEFT, padx=5)
日志
lf = tk.LabelFrame(r, text="日志", bg=BG_COLOR, font=(FONT_FAMILY, FONT_SIZE))
lf.pack(fill=tk.BOTH, expand=True, padx=5, pady=2)
self.log = tk.Text(lf, font=(FONT_FAMILY, FONT_SIZE - 1),
height=5, wrap=tk.WORD, state=tk.DISABLED, bg="#F1F8E9")
self.log.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
lsb = tk.Scrollbar(lf, orient=tk.VERTICAL, command=self.log.yview)
lsb.pack(side=tk.RIGHT, fill=tk.Y)
self.log.config(yscrollcommand=lsb.set)
def _on_asc(self):
if self.v_sasc.get(): self.v_sdesc.set(False)
def _on_desc(self):
if self.v_sdesc.get(): self.v_sasc.set(False)
def _load_values(self):
c = self.cfg
self.v_f1.set(c.get("field1", "备注"))
self.v_f2.set(c.get("field2", ""))
self.v_f3.set(c.get("field3", ""))
self.v_bal.set(c.get("balance_col", "余额"))
self.v_qty.set(c.get("qty_col", ""))
self.v_sht.set(c.get("sheet_num", "1"))
self.v_s1.set(c.get("seq1", ""))
self.v_s2.set(c.get("seq2", ""))
self.v_s3.set(c.get("seq3", ""))
self.v_fsz.set(c.get("font_size", "12"))
self.v_del_sp.set(c.get("del_space", True))
self.v_del_br.set(c.get("del_bracket", True))
self.v_del_txt.set(c.get("del_text", ""))
self.v_totxt.set(c.get("to_text", True))
self.v_mdup.set(c.get("merge_dup", False))
self.v_neg2.set(c.get("neg_minus2", False))
self.v_sasc.set(c.get("sort_asc", False))
self.v_sdesc.set(c.get("sort_desc", False))
self.v_cmp.set(c.get("compare", False))
self.v_seqord.set(c.get("seq_order", "升序排列编号"))
self.v_save.set(c.get("save_path", ""))
for f in c.get("files", []):
if os.path.exists(f):
self.file_lb.insert(tk.END, f)
def _collect(self):
return {
"files": list(self.file_lb.get(0, tk.END)),
"save_path": self.v_save.get().strip(),
"field1": self.v_f1.get().strip(),
"field2": self.v_f2.get().strip(),
"field3": self.v_f3.get().strip(),
"balance_col": self.v_bal.get().strip(),
"qty_col": self.v_qty.get().strip(),
"sheet_num": self.v_sht.get().strip(),
"seq1": self.v_s1.get().strip(),
"seq2": self.v_s2.get().strip(),
"seq3": self.v_s3.get().strip(),
"font_size": self.v_fsz.get().strip(),
"del_space": self.v_del_sp.get(),
"del_bracket": self.v_del_br.get(),
"del_text": self.v_del_txt.get().strip(),
"to_text": self.v_totxt.get(),
"merge_dup": self.v_mdup.get(),
"neg_minus2": self.v_neg2.get(),
"sort_asc": self.v_sasc.get(),
"sort_desc": self.v_sdesc.get(),
"compare": self.v_cmp.get(),
"seq_order": self.v_seqord.get(),
"last_open_dir": self.cfg.get("last_open_dir", DEFAULT_DIR),
}
def _select_files(self):
init_dir = self.cfg.get("last_open_dir", DEFAULT_DIR)
paths = filedialog.askopenfilenames(
title="选择xlsx文件", initialdir=init_dir,
filetypes=[("Excel文件", "*.xlsx *.xlsm"), ("所有文件", "*.*")])
if paths:
ex = set(self.file_lb.get(0, tk.END))
for p in paths:
if p not in ex:
self.file_lb.insert(tk.END, p)
self.cfg["last_open_dir"] = os.path.dirname(paths[0])
save_config(self._collect())
def _clear_files(self):
self.file_lb.delete(0, tk.END)
def _select_save(self):
init_dir = self.cfg.get("last_open_dir", DEFAULT_DIR)
path = filedialog.asksaveasfilename(
title="选择保存路径", initialdir=init_dir,
defaultextension=".xlsx",
filetypes=[("Excel文件", "*.xlsx"), ("所有文件", "*.*")])
if path:
if not path.lower().endswith(".xlsx"):
path += ".xlsx"
self.v_save.set(path)
save_config(self._collect())
def _clear_save(self):
self.v_save.set("")
def _log(self, msg):
self.log.config(state=tk.NORMAL)
self.log.insert(tk.END, msg + "\n")
self.log.see(tk.END)
self.log.config(state=tk.DISABLED)
def _log_safe(self, msg):
self.root.after(0, self._log, msg)
def _run(self):
if self.thread and self.thread.is_alive():
messagebox.showwarning("提示", "正在处理中,请等待或点击停止。"); return
cfg = self._collect()
save_config(cfg)
files = cfg["files"]
if not files:
messagebox.showwarning("提示", "请先选择文件!"); return
missing = [f for f in files if not os.path.exists(f)]
if missing:
messagebox.showerror("错误", "以下文件不存在:\n" + "\n".join(missing)); return
self.stop_flag[0] = False
self.log.config(state=tk.NORMAL)
self.log.delete(1.0, tk.END)
self.log.config(state=tk.DISABLED)
def run_it():
try:
process_files(cfg, files, self.stop_flag, self._log_safe)
except Exception as e:
import traceback
self._log_safe(f"处理出错:{e}\n{traceback.format_exc()}")
self.thread = threading.Thread(target=run_it, daemon=True)
self.thread.start()
def _stop(self):
self.stop_flag[0] = True
self._log("正在停止...")
─────────────────────────────────────────────
入口
─────────────────────────────────────────────
def main():
root = tk.Tk()
App(root)
root.mainloop()
if name == "main":
main()