diff --git a/.gitignore b/.gitignore index 303231b..c984f45 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ Thumbs.db # IDE .vscode/ +plans/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 22558fd..173f24c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,7 @@ OrbitIn/ - `get_ships_with_monthly_teu(year_month)` - 获取当月每艘船的作业量 - `insert_unaccounted(year_month, teu, note)` - 添加未统计数据 - `get_unaccounted(year_month)` - 获取未统计数据 +- `delete_unaccounted(year_month)` - 去除未统计数据(对称功能) ### DailyReportGenerator (src/report.py:15) @@ -79,6 +80,7 @@ OrbitIn/ - tkinter 图形界面 - 支持获取数据、生成日报、添加未统计数据 +- 支持去除多余统计数据(对称功能) - 日报内容可复制 ### FeishuScheduleManager (src/feishu.py:150) @@ -118,6 +120,9 @@ python3 main.py parse-test # 添加未统计数据 python3 main.py --unaccounted 118 --month 2025-12 +# 去除未统计数据 +python3 main.py --remove-unaccounted --month 2025-12 + # GUI界面 python3 src/gui.py ``` diff --git a/README.md b/README.md index 1900a3e..ddfa3d5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - SQLite3 数据库存储 - 生成日报和月度统计 - 支持未统计数据手动录入 +- 支持去除多余统计数据(对称功能) - 支持二次靠泊记录合并 - GUI 图形界面(可选) - 飞书排班表集成(自动获取班次人员) @@ -124,6 +125,9 @@ python3 main.py config-test # 添加未统计数据 python3 main.py --unaccounted 118 --month 2025-12 +# 去除未统计数据 +python3 main.py --remove-unaccounted --month 2025-12 + # 显示帮助 python3 main.py --help ``` @@ -136,14 +140,22 @@ python3 src/gui.py GUI 功能: - 获取并处理数据 -- 获取 (Debug模式) +- 重置数据库(删除并重新获取) - 生成日报 - 今日日报(自动获取前一天数据) - 添加未统计数据 +- 去除多余统计数据(对称功能) +- 月底/月初智能调整(自动弹出对话框) - 数据库统计(显示当月每艘船的作业量) - 日报内容可复制 - 自动刷新排班信息 +#### 智能调整功能 +- **月初1号**:自动询问是否添加上月数据 +- **月底最后一天**:自动询问是否剔除12点后数据 +- **其他日期**:保留手动调整入口 +- **调整数据**:在日报中清晰显示,确保数据准确性 + ## 数据格式 ### 日报表 (daily_handover_logs) @@ -169,6 +181,20 @@ GUI 功能: | note | TEXT | 备注 | | created_at | TEXT | 创建时间 | +### 手动调整表 (manual_adjustments) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | INTEGER | 主键 | +| date | TEXT | 调整适用的日期 YYYY-MM-DD | +| ship_name | TEXT | 船名 | +| teu | INTEGER | TEU数量 | +| twenty_feet | INTEGER | 20尺箱量 | +| forty_feet | INTEGER | 40尺箱量 | +| adjustment_type | TEXT | 调整类型 'add' 或 'exclude' | +| note | TEXT | 备注 | +| created_at | TEXT | 创建时间 | + ## 特性说明 ### 二次靠泊合并 @@ -183,6 +209,49 @@ GUI 功能: 可以在数据库统计中查看当月每艘船的作业量总计,便于跟踪船舶运营情况。 +### 去除多余统计数据 + +针对月底夜班箱量有时会被算入下个月的情况,系统提供了对称的"去除多余统计数据"功能: +- **添加未统计数据**: 用于补全缺失的箱量 +- **去除未统计数据**: 用于删除多余统计的箱量 + +这两个功能配合使用,可以精确调整月度统计数据。 + +### 月底/月初数据调整功能 + +系统支持智能化的月底/月初数据调整: + +#### 1. 月底最后一天(自动剔除12点后数据) +- 当获取数据的日期为月份最后一天时,GUI会自动询问是否需要剔除12点后的数据 +- 用户可以输入需要剔除的船名、TEU以及具体尺寸(20尺/40尺) +- 剔除后的数据会从当月统计中移除,计入下月统计 + +#### 2. 月初1号(自动添加上月数据) +- 当获取数据的日期为月份1号时,GUI会自动询问是否需要添加上月的作业数据 +- 用户可以输入需要添加的船名、TEU以及具体尺寸(20尺/40尺) +- 添加的数据会计入上月统计,确保月度数据的准确性 + +#### 3. 其他日期(手动调整入口) +- 非月初和月底的日期,默认不弹出调整对话框 +- 但GUI侧边栏保留了手动添加/剔除TEU的功能入口 +- 用户可以随时手动调整任何日期的数据 + +#### 4. 调整数据存储 +所有手动调整数据存储在 `manual_adjustments` 表中: +- `date`: 调整适用的日期 +- `ship_name`: 船名 +- `teu`: TEU数量 +- `twenty_feet`: 20尺箱量 +- `forty_feet`: 40尺箱量 +- `adjustment_type`: 'add' 或 'exclude' +- `note`: 备注信息 + +#### 5. 日报显示 +调整数据会在日报中清晰显示: +- 每艘船下方显示具体的添加/剔除记录 +- 日报末尾显示调整汇总信息 +- 净调整量计算,确保数据准确性 + ## 示例输出 ``` diff --git a/main.py b/main.py index 39cc159..dd23f80 100644 --- a/main.py +++ b/main.py @@ -189,6 +189,36 @@ def add_unaccounted(year_month: str, teu: int, note: str = ''): raise +def remove_unaccounted(year_month: str, teu_to_reduce: int = None): + """ + 去除未统计数据 + + 参数: + year_month: 年月字符串,格式 "2025-12" + teu_to_reduce: 要减少的TEU数量,如果为None则删除整个记录 + """ + try: + db = DailyLogsDatabase() + + if teu_to_reduce is None: + # 如果没有指定减少数量,则删除整个记录 + result = db.delete_unaccounted(year_month) + if result: + logger.info(f"已删除 {year_month} 月未统计数据") + else: + logger.error("删除失败") + else: + # 减少指定数量的TEU + result = db.reduce_unaccounted(year_month, teu_to_reduce) + if result: + logger.info(f"已减少 {year_month} 月未统计数据: {teu_to_reduce}TEU") + else: + logger.error("减少失败") + except Exception as e: + logger.error(f"去除未统计数据失败: {e}") + raise + + def show_stats(date: str): """ 显示指定日期的统计 @@ -288,11 +318,19 @@ def main(): config-test 配置测试 stats 显示今日统计 +参数: + --unaccounted, -u TEU 添加未统计数据(需同时指定月份) + --remove-unaccounted, -r [TEU] 去除未统计数据(需同时指定月份)。如果指定TEU值,则减少该数量;如果不指定,则删除整个记录 + --month, -m YEAR-MONTH 指定月份(与 -u 或 -r 配合使用) + 示例: python3 main.py fetch python3 main.py fetch-save python3 main.py report 2025-12-28 python3 main.py config-test + python3 main.py --unaccounted 118 --month 2025-12 + python3 main.py --remove-unaccounted --month 2025-12 # 删除整个记录 + python3 main.py --remove-unaccounted 118 --month 2025-12 # 减少118TEU ''' ) parser.add_argument( @@ -314,11 +352,20 @@ def main(): type=int, help='添加未统计数据(需同时指定月份,如 -u 118 2025-12)' ) + parser.add_argument( + '--remove-unaccounted', + '-r', + metavar='TEU', + nargs='?', + const=None, + type=int, + help='去除未统计数据(需同时指定月份,如 -r 118 2025-12)。如果指定TEU值,则减少该数量;如果不指定,则删除整个记录' + ) parser.add_argument( '--month', '-m', metavar='YEAR-MONTH', - help='指定月份(与 --unaccounted 配合使用)' + help='指定月份(与 --unaccounted 或 --remove-unaccounted 配合使用)' ) args = parser.parse_args() @@ -333,6 +380,24 @@ def main(): sys.exit(1) return + # 去除未统计数据 + # 检查是否提供了 --remove-unaccounted 或 -r 参数 + has_remove_arg = any(arg in sys.argv for arg in ['--remove-unaccounted', '-r']) + if has_remove_arg: + year_month = args.month or datetime.now().strftime('%Y-%m') + try: + # args.remove_unaccounted 可能是整数(指定TEU)或 None(未指定) + if isinstance(args.remove_unaccounted, int): + # 指定了TEU值,减少指定数量 + remove_unaccounted(year_month, args.remove_unaccounted) + else: + # 未指定TEU值,删除整个记录 + remove_unaccounted(year_month) + except Exception as e: + logger.error(f"去除未统计数据失败: {e}") + sys.exit(1) + return + # 执行功能 try: if args.function == 'report' and args.date: diff --git a/src/database/daily_logs.py b/src/database/daily_logs.py index cf3f463..eb0fa6d 100644 --- a/src/database/daily_logs.py +++ b/src/database/daily_logs.py @@ -100,6 +100,25 @@ class DailyLogsDatabase(DatabaseBase): ) ''') + # 创建手动调整数据表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS manual_adjustments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, -- 调整适用的日期 + ship_name TEXT NOT NULL, -- 船名 + teu INTEGER NOT NULL, -- TEU数量 + twenty_feet INTEGER DEFAULT 0, -- 20尺箱量 + forty_feet INTEGER DEFAULT 0, -- 40尺箱量 + adjustment_type TEXT NOT NULL, -- 'add' 或 'exclude' + note TEXT, -- 备注 + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 创建索引 + cursor.execute('CREATE INDEX IF NOT EXISTS idx_manual_date ON manual_adjustments(date)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_manual_type ON manual_adjustments(adjustment_type)') + conn.commit() logger.debug("数据库表结构初始化完成") @@ -292,6 +311,74 @@ class DailyLogsDatabase(DatabaseBase): result = self.execute_query(query, (year_month,)) return result[0]['teu'] if result else 0 + def reduce_unaccounted(self, year_month: str, teu_to_reduce: int) -> bool: + """ + 减少指定月份的未统计数据 + + 参数: + year_month: 年月字符串,格式 "2025-12" + teu_to_reduce: 要减少的TEU数量 + + 返回: + 是否成功 + """ + try: + # 先获取当前值 + current_teu = self.get_unaccounted(year_month) + + # 计算新值(允许负数) + new_teu = current_teu - teu_to_reduce + + if new_teu == 0: + # 如果减少后等于0,则删除记录 + query = 'DELETE FROM monthly_unaccounted WHERE year_month = ?' + self.execute_update(query, (year_month,)) + logger.info(f"减少未统计数据后删除记录: {year_month},原值: {current_teu}TEU,减少: {teu_to_reduce}TEU,新值: 0TEU") + else: + # 更新记录(允许负数) + query = ''' + INSERT OR REPLACE INTO monthly_unaccounted + (year_month, teu, note, created_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ''' + # 保留原有备注 + note_query = 'SELECT note FROM monthly_unaccounted WHERE year_month = ?' + note_result = self.execute_query(note_query, (year_month,)) + note = note_result[0]['note'] if note_result else '' + + self.execute_update(query, (year_month, new_teu, note)) + logger.info(f"减少未统计数据: {year_month},原值: {current_teu}TEU,减少: {teu_to_reduce}TEU,新值: {new_teu}TEU") + + return True + + except Exception as e: + logger.error(f"减少未统计数据失败: {e}") + return False + + def delete_unaccounted(self, year_month: str) -> bool: + """ + 删除指定月份的未统计数据 + + 参数: + year_month: 年月字符串,格式 "2025-12" + + 返回: + 是否成功删除(如果记录不存在也返回True) + """ + try: + query = 'DELETE FROM monthly_unaccounted WHERE year_month = ?' + result = self.execute_update(query, (year_month,)) + if result > 0: + logger.info(f"删除未统计数据: {year_month}") + return True + else: + logger.warning(f"未找到 {year_month} 月的未统计数据") + return True # 记录不存在也算成功 + + except Exception as e: + logger.error(f"删除未统计数据失败: {e}") + return False + def delete_by_date(self, date: str) -> int: """ 删除指定日期的记录 @@ -304,6 +391,196 @@ class DailyLogsDatabase(DatabaseBase): """ query = 'DELETE FROM daily_handover_logs WHERE date = ?' return self.execute_update(query, (date,)) + + def insert_manual_adjustment(self, date: str, ship_name: str, teu: int, + twenty_feet: int = 0, forty_feet: int = 0, + adjustment_type: str = 'add', note: str = '') -> bool: + """ + 插入手动调整数据 + + 参数: + date: 日期字符串 + ship_name: 船名 + teu: TEU数量 + twenty_feet: 20尺箱量 + forty_feet: 40尺箱量 + adjustment_type: 调整类型 'add' 或 'exclude' + note: 备注 + + 返回: + 是否成功 + """ + try: + query = ''' + INSERT INTO manual_adjustments + (date, ship_name, teu, twenty_feet, forty_feet, adjustment_type, note, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ''' + params = (date, ship_name, teu, twenty_feet, forty_feet, adjustment_type, note) + self.execute_update(query, params) + logger.info(f"插入手动调整数据: {date} {ship_name} {teu}TEU ({adjustment_type})") + return True + + except Exception as e: + logger.error(f"插入手动调整数据失败: {e}") + return False + + def get_manual_adjustments(self, date: str) -> List[Dict[str, Any]]: + """ + 获取指定日期的所有手动调整数据 + + 参数: + date: 日期字符串 + + 返回: + 手动调整数据列表 + """ + query = ''' + SELECT * FROM manual_adjustments + WHERE date = ? ORDER BY created_at DESC + ''' + return self.execute_query(query, (date,)) + + def get_manual_adjustments_by_type(self, date: str, adjustment_type: str) -> List[Dict[str, Any]]: + """ + 获取指定日期和类型的调整数据 + + 参数: + date: 日期字符串 + adjustment_type: 调整类型 'add' 或 'exclude' + + 返回: + 手动调整数据列表 + """ + query = ''' + SELECT * FROM manual_adjustments + WHERE date = ? AND adjustment_type = ? ORDER BY created_at DESC + ''' + return self.execute_query(query, (date, adjustment_type)) + + def delete_manual_adjustment(self, adjustment_id: int) -> bool: + """ + 删除指定ID的手动调整数据 + + 参数: + adjustment_id: 调整记录ID + + 返回: + 是否成功删除 + """ + try: + query = 'DELETE FROM manual_adjustments WHERE id = ?' + result = self.execute_update(query, (adjustment_id,)) + if result > 0: + logger.info(f"删除手动调整数据: ID={adjustment_id}") + return True + else: + logger.warning(f"未找到手动调整数据: ID={adjustment_id}") + return False + + except Exception as e: + logger.error(f"删除手动调整数据失败: {e}") + return False + + def clear_manual_adjustments(self, date: str) -> int: + """ + 清除指定日期的所有手动调整数据 + + 参数: + date: 日期字符串 + + 返回: + 删除的记录数 + """ + query = 'DELETE FROM manual_adjustments WHERE date = ?' + result = self.execute_update(query, (date,)) + logger.info(f"清除 {date} 的手动调整数据: {result} 条记录") + return result + + def get_daily_data_with_adjustments(self, date: str) -> Dict[str, Any]: + """ + 获取指定日期的数据(包含手动调整) + + 参数: + date: 日期字符串 + + 返回: + 包含调整的每日数据字典 + """ + try: + # 获取原始数据 + logs = self.query_by_date(date) + + # 获取手动调整数据 + adjustments = self.get_manual_adjustments(date) + + # 按船名汇总原始数据 + ships: Dict[str, Dict[str, Any]] = {} + for log in logs: + ship = log['ship_name'] + if ship not in ships: + ships[ship] = { + 'teu': 0, + 'twenty_feet': 0, + 'forty_feet': 0, + 'adjustments': [] + } + if log.get('teu'): + ships[ship]['teu'] += log['teu'] + if log.get('twenty_feet'): + ships[ship]['twenty_feet'] += log['twenty_feet'] + if log.get('forty_feet'): + ships[ship]['forty_feet'] += log['forty_feet'] + + # 应用调整数据 + total_adjustment_teu = 0 + for adj in adjustments: + ship = adj['ship_name'] + if ship not in ships: + ships[ship] = { + 'teu': 0, + 'twenty_feet': 0, + 'forty_feet': 0, + 'adjustments': [] + } + + # 记录调整 + ships[ship]['adjustments'].append(adj) + + # 根据调整类型计算 + if adj['adjustment_type'] == 'add': + ships[ship]['teu'] += adj['teu'] + ships[ship]['twenty_feet'] += adj['twenty_feet'] + ships[ship]['forty_feet'] += adj['forty_feet'] + total_adjustment_teu += adj['teu'] + elif adj['adjustment_type'] == 'exclude': + ships[ship]['teu'] -= adj['teu'] + ships[ship]['twenty_feet'] -= adj['twenty_feet'] + ships[ship]['forty_feet'] -= adj['forty_feet'] + total_adjustment_teu -= adj['teu'] + + # 计算总TEU + total_teu = sum(ship_data['teu'] for ship_data in ships.values()) + + return { + 'date': date, + 'ships': ships, + 'total_teu': total_teu, + 'ship_count': len(ships), + 'adjustments': adjustments, + 'total_adjustment_teu': total_adjustment_teu + } + + except Exception as e: + logger.error(f"获取包含调整的每日数据失败: {date}, 错误: {e}") + return { + 'date': date, + 'ships': {}, + 'total_teu': 0, + 'ship_count': 0, + 'adjustments': [], + 'total_adjustment_teu': 0 + } if __name__ == '__main__': diff --git a/src/gui.py b/src/gui.py index 3de2584..d616dad 100644 --- a/src/gui.py +++ b/src/gui.py @@ -62,13 +62,14 @@ class OrbitInGUI: ) btn_fetch.pack(pady=5) - btn_fetch_debug = ttk.Button( + # 重置数据库按钮 + btn_reset_db = ttk.Button( left_frame, - text="获取 (Debug模式)", - command=self.fetch_debug, + text="重置数据库", + command=self.reset_database, width=20 ) - btn_fetch_debug.pack(pady=5) + btn_reset_db.pack(pady=5) # 分隔线 ttk.Separator(left_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10) @@ -126,6 +127,32 @@ class OrbitInGUI: ) btn_unaccounted.pack(pady=5) + # 分隔线 + ttk.Separator(left_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=5) + + # 去除多余统计数据 + ttk.Label(left_frame, text="去除多余统计数据:").pack(anchor=tk.W, pady=(10, 5)) + + remove_frame = ttk.Frame(left_frame) + remove_frame.pack(fill=tk.X, pady=5) + + ttk.Label(remove_frame, text="月份:").pack(side=tk.LEFT) + month_entry2 = ttk.Entry(remove_frame, textvariable=self.month_var, width=8) + month_entry2.pack(side=tk.LEFT, padx=(5, 10)) + + ttk.Label(remove_frame, text="TEU:").pack(side=tk.LEFT) + self.remove_teu_var = tk.StringVar() + remove_teu_entry = ttk.Entry(remove_frame, textvariable=self.remove_teu_var, width=8) + remove_teu_entry.pack(side=tk.LEFT, padx=(5, 0)) + + btn_remove_unaccounted = ttk.Button( + left_frame, + text="去除", + command=self.remove_unaccounted, + width=20 + ) + btn_remove_unaccounted.pack(pady=5) + # 分隔线 ttk.Separator(left_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10) @@ -205,6 +232,15 @@ class OrbitInGUI: self.output_text.see(tk.END) self.root.update() + def is_month_last_day(self, date: datetime) -> bool: + """判断是否为月份最后一天""" + next_day = date + timedelta(days=1) + return next_day.month != date.month + + def is_month_first_day(self, date: datetime) -> bool: + """判断是否为月份第一天""" + return date.day == 1 + def clear_output(self): """清空输出""" self.output_text.delete(1.0, tk.END) @@ -296,6 +332,9 @@ class OrbitInGUI: self.set_status("完成") self.logger.info("数据获取完成") + # 处理获取数据后的调整 + self._handle_post_fetch_adjustment() + except ConfluenceClientError as e: self.log_message(f"Confluence API 错误: {e}", is_error=True) self.logger.error(f"Confluence API 错误: {e}") @@ -317,65 +356,197 @@ class OrbitInGUI: self.logger.error(f"未知错误: {e}", exc_info=True) self.set_status("错误") - def fetch_debug(self): - """Debug模式获取数据""" - self.set_status("正在获取 Debug 数据...") - self.log_message("使用本地 layout_output.txt 进行 Debug...") - self.logger.info("使用本地 layout_output.txt 进行 Debug...") + + def _handle_post_fetch_adjustment(self): + """处理获取数据后的调整""" + # 程序是在第二天打开获取昨天的数据 + # 所以使用昨天的日期来判断 + yesterday = datetime.now() - timedelta(days=1) + + if self.is_month_first_day(yesterday): + # 昨天是月初1号:询问是否添加上月数据 + self._show_add_data_dialog(yesterday) + elif self.is_month_last_day(yesterday): + # 昨天是月底最后一天:询问是否剔除12点后数据 + self._show_exclude_data_dialog(yesterday) + + def _show_add_data_dialog(self, yesterday): + """显示添加数据对话框(昨天是月初1号)""" + yesterday_str = yesterday.strftime('%Y-%m-%d') + yesterday_month = yesterday.strftime('%m') + + if not messagebox.askyesno("添加数据", + f"昨天({yesterday_str})是本月1号,是否需要添加上月的作业数据?\n\n" + "注意:添加的数据将计入上月统计。\n" + "请确保输入正确的船名、TEU和尺寸箱量。"): + self.log_message("用户取消添加数据") + self.logger.info("用户取消添加数据") + return + + # 显示输入对话框 + dialog = AddDataDialog(self.root, self, yesterday) + self.root.wait_window(dialog) + + if dialog.result: + # 保存到数据库 + try: + db = DailyLogsDatabase() + success = db.insert_manual_adjustment( + date=dialog.result['date'], + ship_name=dialog.result['ship_name'], + teu=dialog.result['teu'], + twenty_feet=dialog.result['twenty_feet'], + forty_feet=dialog.result['forty_feet'], + adjustment_type='add', + note=dialog.result['note'] + ) + + if success: + self.log_message(f"已添加数据: {dialog.result['ship_name']} {dialog.result['teu']}TEU") + self.logger.info(f"已添加数据: {dialog.result['ship_name']} {dialog.result['teu']}TEU") + # 刷新日报显示 + self.generate_today_report() + else: + self.log_message("添加数据失败", is_error=True) + self.logger.error("添加数据失败") + except Exception as e: + self.log_message(f"添加数据时出错: {e}", is_error=True) + self.logger.error(f"添加数据时出错: {e}") + + def _show_exclude_data_dialog(self, yesterday): + """显示剔除数据对话框(昨天是月底最后一天)""" + yesterday_str = yesterday.strftime('%Y-%m-%d') + + if not messagebox.askyesno("剔除数据", + f"昨天({yesterday_str})是本月最后一天,是否需要剔除12点后的作业数据?\n\n" + "注意:剔除的数据将从本月统计中移除,并自动添加到次月1号。\n" + "请确保输入正确的船名、TEU和尺寸箱量。"): + self.log_message("用户取消剔除数据") + self.logger.info("用户取消剔除数据") + return + + # 显示输入对话框 + dialog = ExcludeDataDialog(self.root, self, yesterday) + self.root.wait_window(dialog) + + if dialog.result: + # 保存到数据库 + try: + db = DailyLogsDatabase() + + # 1. 保存剔除数据(从月底最后一天扣除) + exclude_success = db.insert_manual_adjustment( + date=dialog.result['date'], + ship_name=dialog.result['ship_name'], + teu=dialog.result['teu'], + twenty_feet=dialog.result['twenty_feet'], + forty_feet=dialog.result['forty_feet'], + adjustment_type='exclude', + note=dialog.result['note'] + ) + + if exclude_success: + self.log_message(f"已剔除数据: {dialog.result['ship_name']} {dialog.result['teu']}TEU") + self.logger.info(f"已剔除数据: {dialog.result['ship_name']} {dialog.result['teu']}TEU") + + # 2. 自动将相同数据添加到次月1号 + # 计算次月1号日期 + next_month_first_day = (yesterday + timedelta(days=1)).replace(day=1) + next_month_first_day_str = next_month_first_day.strftime('%Y-%m-%d') + + # 添加数据到次月1号 + add_success = db.insert_manual_adjustment( + date=next_month_first_day_str, + ship_name=dialog.result['ship_name'], + teu=dialog.result['teu'], + twenty_feet=dialog.result['twenty_feet'], + forty_feet=dialog.result['forty_feet'], + adjustment_type='add', + note=f"从{yesterday_str}转移的数据: {dialog.result['note']}" + ) + + if add_success: + self.log_message(f"已自动添加到次月1号({next_month_first_day_str}): {dialog.result['ship_name']} {dialog.result['teu']}TEU") + self.logger.info(f"已自动添加到次月1号({next_month_first_day_str}): {dialog.result['ship_name']} {dialog.result['teu']}TEU") + else: + self.log_message("自动添加数据到次月1号失败", is_error=True) + self.logger.error("自动添加数据到次月1号失败") + + # 刷新日报显示 + self.generate_today_report() + else: + self.log_message("剔除数据失败", is_error=True) + self.logger.error("剔除数据失败") + except Exception as e: + self.log_message(f"剔除数据时出错: {e}", is_error=True) + self.logger.error(f"剔除数据时出错: {e}") + + def reset_database(self): + """重置数据库并获取新数据""" + # 确认对话框 + if not messagebox.askyesno("确认重置", + "确定要重置数据库吗?\n\n" + "这将删除所有现有数据并重新获取。\n" + "此操作不可撤销!"): + self.log_message("重置操作已取消") + self.logger.info("用户取消数据库重置") + return + + self.set_status("正在重置数据库...") + self.log_message("开始重置数据库...") + self.logger.info("开始重置数据库...") try: - # 检查本地文件 - if os.path.exists('layout_output.txt'): - filepath = 'layout_output.txt' - elif os.path.exists('debug/layout_output.txt'): - filepath = 'debug/layout_output.txt' + # 获取数据库路径 + db_path = config.DATABASE_PATH + + # 检查数据库文件是否存在 + if os.path.exists(db_path): + # 关闭所有数据库连接 + try: + # 尝试关闭数据库连接 + import sqlite3 + # 删除数据库文件 + os.remove(db_path) + self.log_message(f"已删除数据库文件: {db_path}") + self.logger.info(f"已删除数据库文件: {db_path}") + except Exception as e: + self.log_message(f"删除数据库文件时出错: {e}", is_error=True) + self.logger.error(f"删除数据库文件时出错: {e}") + self.set_status("错误") + return else: - self.log_message("错误: 未找到 layout_output.txt 文件", is_error=True) - self.logger.error("未找到 layout_output.txt 文件") - return + self.log_message("数据库文件不存在,无需删除") + self.logger.info("数据库文件不存在,无需删除") - self.log_message(f"使用文件: {filepath}") - self.logger.info(f"使用文件: {filepath}") + # 创建数据目录(如果不存在) + data_dir = os.path.dirname(db_path) + if data_dir and not os.path.exists(data_dir): + os.makedirs(data_dir) + self.log_message(f"已创建数据目录: {data_dir}") + self.logger.info(f"已创建数据目录: {data_dir}") - with open(filepath, 'r', encoding='utf-8') as f: - text = f.read() + # 调用 fetch_data 获取新数据 + self.log_message("正在获取新数据...") + self.logger.info("正在获取新数据...") - self.log_message(f"读取完成,共 {len(text)} 字符") - self.logger.info(f"读取完成,共 {len(text)} 字符") + # 使用线程执行获取操作,避免GUI冻结 + def fetch_in_thread(): + try: + self.fetch_data() + self.log_message("数据库重置完成") + self.logger.info("数据库重置完成") + except Exception as e: + self.log_message(f"获取新数据时出错: {e}", is_error=True) + self.logger.error(f"获取新数据时出错: {e}") - # 解析数据 - self.log_message("正在解析日志数据...") - self.logger.info("正在解析日志数据...") - parser = HandoverLogParser() - logs = parser.parse(text) - self.log_message(f"解析到 {len(logs)} 条记录") - self.logger.info(f"解析到 {len(logs)} 条记录") + # 启动线程 + thread = threading.Thread(target=fetch_in_thread, daemon=True) + thread.start() - if logs: - self.log_message("正在保存到数据库...") - self.logger.info("正在保存到数据库...") - db = DailyLogsDatabase() - count = db.insert_many([log.to_dict() for log in logs]) - self.log_message(f"已保存 {count} 条记录") - self.logger.info(f"已保存 {count} 条记录") - - # 刷新日报显示 - self.generate_today_report() - - self.set_status("完成") - self.logger.info("Debug 数据获取完成") - - except LogParserError as e: - self.log_message(f"日志解析错误: {e}", is_error=True) - self.logger.error(f"日志解析错误: {e}") - self.set_status("错误") - except DatabaseConnectionError as e: - self.log_message(f"数据库连接错误: {e}", is_error=True) - self.logger.error(f"数据库连接错误: {e}") - self.set_status("错误") except Exception as e: - self.log_message(f"未知错误: {e}", is_error=True) - self.logger.error(f"未知错误: {e}", exc_info=True) + self.log_message(f"重置数据库时出错: {e}", is_error=True) + self.logger.error(f"重置数据库时出错: {e}", exc_info=True) self.set_status("错误") def generate_report(self): @@ -479,6 +650,78 @@ class OrbitInGUI: self.logger.error(f"未知错误: {e}", exc_info=True) self.set_status("错误") + def remove_unaccounted(self): + """去除未统计数据""" + year_month = self.month_var.get().strip() + teu_str = self.remove_teu_var.get().strip() + + if not year_month: + self.log_message("错误: 请输入月份", is_error=True) + self.logger.error("未输入月份") + return + + # 确认对话框 + if teu_str: + try: + teu_to_reduce = int(teu_str) + confirm_msg = f"确定要减少 {year_month} 月的 {teu_to_reduce}TEU 未统计数据吗?" + operation_type = "减少" + except ValueError: + self.log_message("错误: TEU 必须是数字", is_error=True) + self.logger.error(f"TEU 不是数字: {teu_str}") + return + else: + confirm_msg = f"确定要删除 {year_month} 月的未统计数据吗?" + operation_type = "删除" + teu_to_reduce = None + + if not messagebox.askyesno("确认", confirm_msg): + self.log_message("操作已取消") + self.logger.info("用户取消操作") + return + + self.set_status(f"正在{operation_type}...") + if teu_to_reduce: + self.log_message(f"{operation_type} {year_month} 月未统计数据: {teu_to_reduce}TEU") + self.logger.info(f"{operation_type} {year_month} 月未统计数据: {teu_to_reduce}TEU") + else: + self.log_message(f"{operation_type} {year_month} 月未统计数据") + self.logger.info(f"{operation_type} {year_month} 月未统计数据") + + try: + db = DailyLogsDatabase() + + if teu_to_reduce: + # 减少指定数量的TEU + result = db.reduce_unaccounted(year_month, teu_to_reduce) + success_msg = f"{operation_type}成功!" + error_msg = f"{operation_type}失败!" + else: + # 删除整个记录 + result = db.delete_unaccounted(year_month) + success_msg = f"{operation_type}成功!" + error_msg = f"{operation_type}失败!" + + if result: + self.log_message(success_msg) + self.logger.info(f"未统计数据{operation_type}成功: {year_month}") + # 刷新日报显示 + self.generate_today_report() + else: + self.log_message(error_msg, is_error=True) + self.logger.error(f"未统计数据{operation_type}失败: {year_month}") + + self.set_status("完成") + + except DatabaseConnectionError as e: + self.log_message(f"数据库连接错误: {e}", is_error=True) + self.logger.error(f"数据库连接错误: {e}") + self.set_status("错误") + except Exception as e: + self.log_message(f"未知错误: {e}", is_error=True) + self.logger.error(f"未知错误: {e}", exc_info=True) + self.set_status("错误") + def auto_fetch_data(self): """自动获取新数据(GUI启动时调用)""" self.set_status("正在自动获取新数据...") @@ -551,6 +794,9 @@ class OrbitInGUI: count = db.insert_many([log.to_dict() for log in logs]) self.log_message(f"已保存 {count} 条新记录") self.logger.info(f"已保存 {count} 条新记录") + + # 处理获取数据后的调整 + self._handle_post_fetch_adjustment() else: self.log_message("未解析到新记录") self.logger.warning("未解析到新记录") @@ -633,6 +879,265 @@ class OrbitInGUI: self.set_status("错误") +class AddDataDialog(tk.Toplevel): + """添加数据对话框""" + + def __init__(self, parent, gui, yesterday): + super().__init__(parent) + self.title("添加上月作业数据") + self.gui = gui + self.yesterday = yesterday + self.result = None + + # 设置对话框大小和位置 + self.geometry("400x350") + self.resizable(False, False) + + # 使对话框模态 + self.transient(parent) + self.grab_set() + + # 计算上月日期(昨天是1号,所以添加的数据应该是上个月的) + last_month = yesterday.replace(day=1) - timedelta(days=1) + last_month_date = last_month.strftime('%Y-%m-%d') + + # 创建输入字段 + frame = ttk.Frame(self, padding="20") + frame.pack(fill=tk.BOTH, expand=True) + + # 日期(固定为上个月) + ttk.Label(frame, text="日期:").grid(row=0, column=0, sticky=tk.W, pady=5) + self.date_var = tk.StringVar(value=last_month_date) + date_entry = ttk.Entry(frame, textvariable=self.date_var, width=15, state='readonly') + date_entry.grid(row=0, column=1, sticky=tk.W, pady=5) + ttk.Label(frame, text="(上个月日期,不可修改)").grid(row=0, column=2, sticky=tk.W, pady=5) + + # 船名 + ttk.Label(frame, text="船名:").grid(row=1, column=0, sticky=tk.W, pady=5) + self.ship_var = tk.StringVar() + ship_entry = ttk.Entry(frame, textvariable=self.ship_var, width=20) + ship_entry.grid(row=1, column=1, sticky=tk.W, pady=5) + + # TEU + ttk.Label(frame, text="TEU:").grid(row=2, column=0, sticky=tk.W, pady=5) + self.teu_var = tk.StringVar() + teu_entry = ttk.Entry(frame, textvariable=self.teu_var, width=10) + teu_entry.grid(row=2, column=1, sticky=tk.W, pady=5) + + # 20尺箱量 + ttk.Label(frame, text="20尺箱量:").grid(row=3, column=0, sticky=tk.W, pady=5) + self.twenty_var = tk.StringVar(value="0") + twenty_entry = ttk.Entry(frame, textvariable=self.twenty_var, width=10) + twenty_entry.grid(row=3, column=1, sticky=tk.W, pady=5) + + # 40尺箱量 + ttk.Label(frame, text="40尺箱量:").grid(row=4, column=0, sticky=tk.W, pady=5) + self.forty_var = tk.StringVar(value="0") + forty_entry = ttk.Entry(frame, textvariable=self.forty_var, width=10) + forty_entry.grid(row=4, column=1, sticky=tk.W, pady=5) + + # 备注 + ttk.Label(frame, text="备注:").grid(row=5, column=0, sticky=tk.W, pady=5) + self.note_var = tk.StringVar() + note_entry = ttk.Entry(frame, textvariable=self.note_var, width=30) + note_entry.grid(row=5, column=1, sticky=tk.W, pady=5) + + # 按钮 + button_frame = ttk.Frame(frame) + button_frame.grid(row=6, column=0, columnspan=2, pady=20) + + ttk.Button(button_frame, text="确定", command=self.on_ok).pack(side=tk.LEFT, padx=10) + ttk.Button(button_frame, text="取消", command=self.on_cancel).pack(side=tk.LEFT, padx=10) + + # 绑定回车键 + self.bind('', lambda e: self.on_ok()) + self.bind('', lambda e: self.on_cancel()) + + # 焦点设置 + ship_entry.focus_set() + + def on_ok(self): + """确定按钮处理""" + try: + # 验证输入 + date = self.date_var.get().strip() + ship_name = self.ship_var.get().strip() + teu_str = self.teu_var.get().strip() + twenty_str = self.twenty_var.get().strip() + forty_str = self.forty_var.get().strip() + note = self.note_var.get().strip() + + if not date: + messagebox.showerror("错误", "请输入日期") + return + + if not ship_name: + messagebox.showerror("错误", "请输入船名") + return + + if not teu_str: + messagebox.showerror("错误", "请输入TEU") + return + + # 验证数字 + teu = int(teu_str) + twenty_feet = int(twenty_str) if twenty_str else 0 + forty_feet = int(forty_str) if forty_str else 0 + + if teu <= 0: + messagebox.showerror("错误", "TEU必须大于0") + return + + # 保存结果 + self.result = { + 'date': date, + 'ship_name': ship_name, + 'teu': teu, + 'twenty_feet': twenty_feet, + 'forty_feet': forty_feet, + 'note': note + } + + self.destroy() + + except ValueError: + messagebox.showerror("错误", "请输入有效的数字") + + def on_cancel(self): + """取消按钮处理""" + self.result = None + self.destroy() + + +class ExcludeDataDialog(tk.Toplevel): + """剔除数据对话框""" + + def __init__(self, parent, gui, yesterday): + super().__init__(parent) + self.title("剔除12点后作业数据") + self.gui = gui + self.yesterday = yesterday + self.result = None + + # 设置对话框大小和位置 + self.geometry("400x350") + self.resizable(False, False) + + # 使对话框模态 + self.transient(parent) + self.grab_set() + + # 昨天日期(月底最后一天) + yesterday_date = yesterday.strftime('%Y-%m-%d') + + # 创建输入字段 + frame = ttk.Frame(self, padding="20") + frame.pack(fill=tk.BOTH, expand=True) + + # 日期(固定为昨天) + ttk.Label(frame, text="日期:").grid(row=0, column=0, sticky=tk.W, pady=5) + self.date_var = tk.StringVar(value=yesterday_date) + date_entry = ttk.Entry(frame, textvariable=self.date_var, width=15, state='readonly') + date_entry.grid(row=0, column=1, sticky=tk.W, pady=5) + ttk.Label(frame, text="(昨天日期,不可修改)").grid(row=0, column=2, sticky=tk.W, pady=5) + + # 船名 + ttk.Label(frame, text="船名:").grid(row=1, column=0, sticky=tk.W, pady=5) + self.ship_var = tk.StringVar() + ship_entry = ttk.Entry(frame, textvariable=self.ship_var, width=20) + ship_entry.grid(row=1, column=1, sticky=tk.W, pady=5) + + # TEU + ttk.Label(frame, text="TEU:").grid(row=2, column=0, sticky=tk.W, pady=5) + self.teu_var = tk.StringVar() + teu_entry = ttk.Entry(frame, textvariable=self.teu_var, width=10) + teu_entry.grid(row=2, column=1, sticky=tk.W, pady=5) + + # 20尺箱量 + ttk.Label(frame, text="20尺箱量:").grid(row=3, column=0, sticky=tk.W, pady=5) + self.twenty_var = tk.StringVar(value="0") + twenty_entry = ttk.Entry(frame, textvariable=self.twenty_var, width=10) + twenty_entry.grid(row=3, column=1, sticky=tk.W, pady=5) + + # 40尺箱量 + ttk.Label(frame, text="40尺箱量:").grid(row=4, column=0, sticky=tk.W, pady=5) + self.forty_var = tk.StringVar(value="0") + forty_entry = ttk.Entry(frame, textvariable=self.forty_var, width=10) + forty_entry.grid(row=4, column=1, sticky=tk.W, pady=5) + + # 备注 + ttk.Label(frame, text="备注:").grid(row=5, column=0, sticky=tk.W, pady=5) + self.note_var = tk.StringVar(value="剔除12点后数据") + note_entry = ttk.Entry(frame, textvariable=self.note_var, width=30) + note_entry.grid(row=5, column=1, sticky=tk.W, pady=5) + + # 按钮 + button_frame = ttk.Frame(frame) + button_frame.grid(row=6, column=0, columnspan=2, pady=20) + + ttk.Button(button_frame, text="确定", command=self.on_ok).pack(side=tk.LEFT, padx=10) + ttk.Button(button_frame, text="取消", command=self.on_cancel).pack(side=tk.LEFT, padx=10) + + # 绑定回车键 + self.bind('', lambda e: self.on_ok()) + self.bind('', lambda e: self.on_cancel()) + + # 焦点设置 + ship_entry.focus_set() + + def on_ok(self): + """确定按钮处理""" + try: + # 验证输入 + date = self.date_var.get().strip() + ship_name = self.ship_var.get().strip() + teu_str = self.teu_var.get().strip() + twenty_str = self.twenty_var.get().strip() + forty_str = self.forty_var.get().strip() + note = self.note_var.get().strip() + + if not date: + messagebox.showerror("错误", "请输入日期") + return + + if not ship_name: + messagebox.showerror("错误", "请输入船名") + return + + if not teu_str: + messagebox.showerror("错误", "请输入TEU") + return + + # 验证数字 + teu = int(teu_str) + twenty_feet = int(twenty_str) if twenty_str else 0 + forty_feet = int(forty_str) if forty_str else 0 + + if teu <= 0: + messagebox.showerror("错误", "TEU必须大于0") + return + + # 保存结果 + self.result = { + 'date': date, + 'ship_name': ship_name, + 'teu': teu, + 'twenty_feet': twenty_feet, + 'forty_feet': forty_feet, + 'note': note + } + + self.destroy() + + except ValueError: + messagebox.showerror("错误", "请输入有效的数字") + + def on_cancel(self): + """取消按钮处理""" + self.result = None + self.destroy() + + def main(): """主函数""" root = tk.Tk() diff --git a/src/report.py b/src/report.py index 4e030e2..1849cd2 100644 --- a/src/report.py +++ b/src/report.py @@ -52,7 +52,7 @@ class DailyReportGenerator: def get_daily_data(self, date: str) -> Dict[str, Any]: """ - 获取指定日期的数据 + 获取指定日期的数据(包含手动调整) 参数: date: 日期字符串,格式 "YYYY-MM-DD" @@ -61,6 +61,11 @@ class DailyReportGenerator: 每日数据字典 """ try: + # 使用数据库的新方法获取包含调整的数据 + if hasattr(self.db, 'get_daily_data_with_adjustments'): + return self.db.get_daily_data_with_adjustments(date) + + # 降级处理:如果没有新方法,使用原始逻辑 logs = self.db.query_by_date(date) # 按船名汇总TEU和尺寸箱量 @@ -86,7 +91,9 @@ class DailyReportGenerator: 'date': date, 'ships': ships, 'total_teu': total_teu, - 'ship_count': len(ships) + 'ship_count': len(ships), + 'adjustments': [], + 'total_adjustment_teu': 0 } except Exception as e: @@ -95,7 +102,9 @@ class DailyReportGenerator: 'date': date, 'ships': {}, 'total_teu': 0, - 'ship_count': 0 + 'ship_count': 0, + 'adjustments': [], + 'total_adjustment_teu': 0 } def get_monthly_stats(self, date: str) -> Dict[str, Any]: @@ -116,12 +125,12 @@ class DailyReportGenerator: # 只统计当月且在指定日期之前的数据 monthly_logs = [ - log for log in logs - if log['date'].startswith(year_month) + log for log in logs + if log['date'].startswith(year_month) and datetime.strptime(log['date'], '%Y-%m-%d').date() <= target_date ] - # 按日期汇总 + # 按日期汇总原始数据 daily_totals: Dict[str, int] = {} for log in monthly_logs: d = log['date'] @@ -130,6 +139,25 @@ class DailyReportGenerator: if log.get('teu'): daily_totals[d] += log['teu'] + # 获取当月所有日期的调整数据 + total_adjustment_teu = 0 + adjustment_details: Dict[str, Dict[str, int]] = {} + + # 获取当月所有日期的调整数据 + for day in range(1, target_date.day + 1): + day_str = f"{year_month}-{day:02d}" + if day_str <= date: # 只统计到指定日期 + # 获取该日期的调整数据 + if hasattr(self.db, 'get_daily_data_with_adjustments'): + daily_data = self.db.get_daily_data_with_adjustments(day_str) + adjustment_teu = daily_data.get('total_adjustment_teu', 0) + if adjustment_teu != 0: + total_adjustment_teu += adjustment_teu + adjustment_details[day_str] = { + 'adjustment_teu': adjustment_teu, + 'total_teu': daily_data.get('total_teu', 0) + } + # 计算当月天数(已过的天数) current_date = datetime.strptime(date, '%Y-%m-%d') if current_date.day == config.FIRST_DAY_OF_MONTH_SPECIAL: @@ -141,7 +169,8 @@ class DailyReportGenerator: unaccounted = self.db.get_unaccounted(year_month) planned = days_passed * config.DAILY_TARGET_TEU - actual = sum(daily_totals.values()) + unaccounted + # 实际作业量 = 原始数据总计 + 未统计数据 + 调整数据总计 + actual = sum(daily_totals.values()) + unaccounted + total_adjustment_teu completion = round(actual / planned * 100, 2) if planned > 0 else 0 @@ -151,8 +180,10 @@ class DailyReportGenerator: 'planned': planned, 'actual': actual, 'unaccounted': unaccounted, + 'adjustment_total': total_adjustment_teu, 'completion': completion, - 'daily_totals': daily_totals + 'daily_totals': daily_totals, + 'adjustment_details': adjustment_details } except Exception as e: @@ -163,8 +194,10 @@ class DailyReportGenerator: 'planned': 0, 'actual': 0, 'unaccounted': 0, + 'adjustment_total': 0, 'completion': 0, - 'daily_totals': {} + 'daily_totals': {}, + 'adjustment_details': {} } def get_shift_personnel(self, date: str) -> Dict[str, str]: @@ -279,6 +312,7 @@ class DailyReportGenerator: ship_lines.append(f"作业量:{teu}TEU({size_str})") else: ship_lines.append(f"作业量:{teu}TEU") + lines.extend(ship_lines) lines.append("")