"""状态 / 分类常量(前后端共用语义)。""" import re # (code, 中文名, 主题色) STATUSES = [ ("not_started", "未开始", "#94a3b8"), ("in_progress", "进行中", "#2563eb"), ("done", "已完成", "#16a34a"), ("at_risk", "风险", "#d97706"), ("blocked", "阻塞", "#dc2626"), ("paused", "暂停/终止", "#6b7280"), ] STATUS_MAP = {code: (name, color) for code, name, color in STATUSES} STATUS_CODES = [c for c, _, _ in STATUSES] NEGATIVE_STATUS = ("at_risk", "blocked", "paused") # 项目优先级 PRIORITIES = [ ("p0", "P0 最高", "#dc2626"), ("p1", "P1 高", "#d97706"), ("p2", "P2 中", "#2563eb"), ("p3", "P3 低", "#6b7280"), ] PRIORITY_MAP = {code: (name, color) for code, name, color in PRIORITIES} # 台账类别(来自《项目明细表》) LEDGER_CATEGORIES = [ "新增项目", "重点项目", "验收项目", "交付项目", "提前交付", "异常项目", ] # -------------------------------------------------------------------------- # 定制项目(交付制,与平台产品类项目分开管理) # (code, 名称, 颜色, 收入确认基准比例) # 开发中阶段:base + 0.4 * progress/100 # 其余阶段:直接使用 base # 手动比例 manual_ratio 非空时优先使用 # -------------------------------------------------------------------------- CUSTOM_STAGES = [ ("lead", "商机/报备", "#94a3b8", 0.00), ("signed", "已签单/下单", "#0891b2", 0.10), ("developing", "开发中", "#2563eb", 0.10), ("delivered", "交付完成", "#7c3aed", 0.70), ("accepted", "已验收", "#d97706", 0.90), ("revenue", "已计收", "#16a34a", 1.00), ("paused", "暂停/异常", "#dc2626", 0.30), ("closed", "已关闭", "#6b7280", 1.00), ] CUSTOM_STAGE_MAP = {c: (n, col, r) for c, n, col, r in CUSTOM_STAGES} CUSTOM_STAGE_CODES = [c for c, _, _, _ in CUSTOM_STAGES] # 阶段先后顺序(用于看板列排序与阶段流转判断) CUSTOM_STAGE_ORDER = {c: i for i, (c, _, _, _) in enumerate(CUSTOM_STAGES)} def custom_revenue_ratio(stage: str, progress: float | None, manual_ratio=None) -> float: """按阶段 + 开发进度计算收入确认比例(0~1)。""" if manual_ratio is not None: try: return max(0.0, min(1.0, float(manual_ratio))) except (TypeError, ValueError): pass base = CUSTOM_STAGE_MAP.get(stage, ("", "#94a3b8", 0.0))[2] if stage == "developing": p = float(progress or 0) return round(min(1.0, base + 0.4 * (p / 100.0)), 4) return round(base, 4) # -------------------------------------------------------------------------- # 定制项目:文本 → 阶段 / 金额 识别 # -------------------------------------------------------------------------- CUSTOM_STAGE_RULES = [ (("暂停", "终止", "未达成一致", "搁置"), "paused"), (("已计收", "已核销", "完成计收", "计收完成"), "revenue"), (("验收报告", "已验收", "完成验收", "待验收"), "accepted"), (("交付完成", "已交付", "需求交付完成"), "delivered"), (("开发中", "开发交付中", "启动开发", "开发进行"), "developing"), (("已下单", "已报单", "已签订", "合同签订", "合同已签"), "signed"), (("商机", "报备", "投标"), "lead"), ] CUSTOM_CAT_STAGE = { "验收项目": "accepted", "交付项目": "delivered", "提前交付": "delivered", "异常项目": "paused", "新增项目": "signed", "重点项目": "developing", } _CUSTOM_AMT_TAIL = re.compile(r"[((]\s*([0-9][0-9,,]*\.?[0-9]*)\s*[))]?\s*$") _CUSTOM_WS = re.compile(r"\s+") def normalize_custom_name(name: str) -> tuple[str, float | None]: """『龙江交投高速大模型分析项目(39,400.00 )』→ ('龙江交投高速大模型分析项目', 39400.0)""" s = _CUSTOM_WS.sub(" ", (name or "").strip()) m = _CUSTOM_AMT_TAIL.search(s) amount = None if m: try: amount = float(m.group(1).replace(",", "").replace(",", "")) except ValueError: amount = None s = s[: m.start()].strip().strip("(( ") return s.strip(" 、-—"), amount _PROGRESS_RE = re.compile(r"(\d{1,3}(?:\.\d+)?)\s*%") def extract_percent(text: str | None) -> int | None: """取文本中最大的百分比数值(0-100)。""" nums = [float(x) for x in _PROGRESS_RE.findall(text or "")] nums = [n for n in nums if 0 <= n <= 100] return int(round(max(nums))) if nums else None # -------------------------------------------------------------------------- # 周期标签解析(『9月第1周』『8月第四周』『2025/08-第1周』) # -------------------------------------------------------------------------- _CN_NUM = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, "十": 10} _PERIOD_LABEL_RE = re.compile( r"(?:(\d{4})\s*[/\-年]\s*)?(\d{1,2})\s*月?\s*[-—~~至]?\s*第?\s*([一二三四五六七八九十]{1,2}|\d{1,2})\s*周" ) def parse_period_label(label: str, year_hint: int = 0) -> tuple[int, int, int] | None: """解析周期标签 → (year, month, week)。 year_hint 为 0 表示调用方希望区分『标签里到底有没有写年份』: 没写时返回的 year 就是 0,由调用方决定用什么年份。 """ m = _PERIOD_LABEL_RE.search(str(label or "")) if not m: return None year = int(m.group(1)) if m.group(1) else int(year_hint or 0) month = int(m.group(2)) w = m.group(3) if w.isdigit(): week = int(w) elif w == "十": week = 10 elif w.startswith("十"): week = 10 + _CN_NUM.get(w[1:], 0) elif w.endswith("十"): week = _CN_NUM.get(w[:-1], 1) * 10 else: week = _CN_NUM.get(w, 1) return year, month, week def guess_custom_stage(text: str | None, category: str | None = None) -> str: """按『负面 → 完成 → 在制 → 商务』的顺序判定阶段。 关键点:文本里同时出现『已计收』和『进度 60%』时,应判定为开发中, 计收金额另由 revenue_delta 字段承载。 """ t = text or "" if any(w in t for w in ("暂停", "终止", "未达成一致", "搁置")): return "paused" if any(w in t for w in ("验收报告", "已验收", "完成验收")): return "accepted" if any(w in t for w in ("交付完成", "已交付", "需求交付完成")): return "delivered" pct = extract_percent(t) in_progress_words = ("进度", "开发", "联调", "部署", "实施", "测试中", "未完成", "待完成") if pct is not None and pct < 100 and any(w in t for w in in_progress_words): return "developing" for words, code in CUSTOM_STAGE_RULES: if any(w in t for w in words): return code if pct is not None and pct < 100: return "developing" if category and category in CUSTOM_CAT_STAGE: return CUSTOM_CAT_STAGE[category] return "signed" # 省/直辖市 → 下辖市县关键词(用于从项目名反推区域) REGION_KEYWORDS: list[tuple[str, list[str]]] = [ ("重庆", ["重庆", "渝北", "渝中", "南岸", "江北", "巴南", "黔江", "青白江", "天宫殿", "两江", "大足", "綦江", "长寿", "垫江"]), ("四川", ["四川", "成都", "乐山", "德阳", "荥经", "简阳", "名山", "绵阳", "宜宾", "泸州", "广元", "眉山", "资阳", "内江", "自贡", "攀枝花", "达州", "南充", "遂宁", "广安", "雅安", "凉山", "宣汉", "沙湾"]), ("内蒙古", ["内蒙古", "内蒙", "呼和浩特", "赤峰", "包头", "鄂尔多斯", "呼伦贝尔", "兴安盟", "阿拉善", "巴彦淖尔", "通辽", "乌兰察布", "锡林郭勒", "乌海", "满洲里", "杭锦旗"]), ("江苏", ["江苏", "南京", "南通", "苏州", "无锡", "常州", "徐州", "扬州", "镇江", "盐城", "淮安", "连云港", "泰州", "宿迁", "昆山", "花桥", "江宁", "通州湾"]), ("山东", ["山东", "青岛", "济南", "烟台", "潍坊", "临沂", "淄博", "济宁", "威海", "东营", "泰安", "德州", "聊城", "菏泽", "枣庄", "日照", "滨州"]), ("黑龙江", ["黑龙江", "哈尔滨", "大庆", "齐齐哈尔", "牡丹江", "佳木斯", "绥化", "鸡西", "双鸭山", "伊春", "七台河", "鹤岗", "黑河", "抚远", "龙江"]), ("吉林", ["吉林", "长春", "四平", "辽源", "通化", "白山", "松原", "白城", "延边"]), ("辽宁", ["辽宁", "沈阳", "大连", "鞍山", "抚顺", "本溪", "丹东", "锦州", "营口", "阜新", "辽阳", "盘锦", "铁岭", "朝阳", "葫芦岛"]), ("甘肃", ["甘肃", "兰州", "金昌", "天水", "白银", "酒泉", "张掖", "武威", "定西", "平凉", "庆阳", "陇南"]), ("青海", ["青海", "西宁", "海东", "格尔木", "化隆"]), ("宁夏", ["宁夏", "银川", "石嘴山", "吴忠", "固原", "中卫"]), ("陕西", ["陕西", "西安", "咸阳", "宝鸡", "渭南", "延安", "汉中", "榆林", "安康", "商洛"]), ("广西", ["广西", "南宁", "柳州", "桂林", "梧州", "北海", "贺州", "玉林", "百色", "河池", "来宾", "崇左", "钦州", "贵港", "防城港"]), ("云南", ["云南", "昆明", "曲靖", "玉溪", "昭通", "楚雄", "红河", "文山", "普洱", "大理", "丽江", "临沧", "林草", "滇东南"]), ("江西", ["江西", "南昌", "赣州", "九江", "上饶", "宜春", "吉安", "抚州", "景德镇", "萍乡", "新余", "鹰潭", "洪都"]), ("湖南", ["湖南", "长沙", "株洲", "湘潭", "衡阳", "邵阳", "岳阳", "常德", "张家界", "益阳", "郴州", "永州", "怀化", "娄底"]), ("湖北", ["湖北", "武汉", "黄石", "襄阳", "宜昌", "荆州", "十堰", "孝感", "荆门", "鄂州", "黄冈", "咸宁", "随州"]), ("河南", ["河南", "郑州", "开封", "洛阳", "平顶山", "安阳", "鹤壁", "新乡", "焦作", "濮阳", "许昌", "漯河", "三门峡", "南阳", "商丘", "信阳", "周口", "驻马店"]), ("广东", ["广东", "广州", "深圳", "东莞", "佛山", "珠海", "中山", "惠州", "汕头", "江门", "湛江", "肇庆", "清远", "潮州", "揭阳", "韶关", "梅州", "汕尾", "河源", "阳江", "茂名"]), ("北京", ["北京"]), ("天津", ["天津", "滨海"]), ("上海", ["上海", "浦东"]), ("浙江", ["浙江", "杭州", "宁波", "温州", "嘉兴", "湖州", "绍兴", "金华", "衢州", "舟山", "台州", "丽水"]), ("福建", ["福建", "福州", "厦门", "泉州", "漳州", "莆田", "三明", "南平", "龙岩", "宁德"]), ("安徽", ["安徽", "合肥", "芜湖", "蚌埠", "淮南", "马鞍山", "安庆", "黄山", "阜阳", "宿州", "六安", "亳州", "池州", "宣城", "铜陵", "滁州"]), ("贵州", ["贵州", "贵阳", "遵义", "六盘水", "安顺", "毕节", "铜仁"]), ("山西", ["山西", "太原", "大同", "阳泉", "长治", "晋城", "朔州", "晋中", "运城", "忻州", "临汾", "吕梁"]), ("河北", ["河北", "石家庄", "唐山", "秦皇岛", "邯郸", "邢台", "保定", "张家口", "承德", "沧州", "廊坊", "衡水", "雄安"]), ("新疆", ["新疆", "乌鲁木齐", "克拉玛依", "喀什", "伊犁"]), ("西藏", ["西藏", "拉萨"]), ] def infer_region(name: str | None) -> str | None: """从项目名称反推所属区域(省/直辖市)。""" s = name or "" for region, keys in REGION_KEYWORDS: for k in keys: if k in s: return region return None # -------------------------------------------------------------------------- # 规范的省级行政区列表(用于下拉选择与筛选) # 采用与历史数据一致的简称写法,避免不必要的迁移 # -------------------------------------------------------------------------- REGION_GROUPS: list[tuple[str, list[str]]] = [ ("华北", ["北京", "天津", "河北", "山西", "内蒙古"]), ("东北", ["辽宁", "吉林", "黑龙江"]), ("华东", ["上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "台湾"]), ("华中", ["河南", "湖北", "湖南"]), ("华南", ["广东", "广西", "海南", "香港", "澳门"]), ("西南", ["重庆", "四川", "贵州", "云南", "西藏"]), ("西北", ["陕西", "甘肃", "青海", "宁夏", "新疆"]), ] ALL_REGIONS: list[str] = [r for _, lst in REGION_GROUPS for r in lst] # 非规范写法 → 规范值 REGION_ALIAS = { "内蒙古自治区": "内蒙古", "广西壮族自治区": "广西", "宁夏回族自治区": "宁夏", "新疆维吾尔自治区": "新疆", "西藏自治区": "西藏", "北京市": "北京", "天津市": "天津", "上海市": "上海", "重庆市": "重庆", "深圳": "广东", "深圳市": "广东", "未标注": None, "": None, } def normalize_region(value: str | None) -> str | None: """把区域值规范到标准列表。""" if value is None: return None v = str(value).strip() if not v: return None if v in ALL_REGIONS: return v if v in REGION_ALIAS: return REGION_ALIAS[v] return v # -------------------------------------------------------------------------- # 周期 → 日期(用于推算「入库时间」) # -------------------------------------------------------------------------- def month_week1(year: int, month: int): """该月第 1 周的周一。 口径:周一到周日算一周,一周归属『它的周四落在哪个月』; 第一个周四落在本月的周就是该月第 1 周(等价于「这一周至少 4 天在本月」)。 """ import datetime first = datetime.date(int(year), int(month), 1) first_thu = first + datetime.timedelta(days=(3 - first.weekday()) % 7) return first_thu - datetime.timedelta(days=3) def period_first_day(year: int, month: int, week: int | None = None) -> str: """取该周期第一天的日期。 规则:第 1 周 = 第一个周四落在本月的那一周(见 month_week1), 第 N 周在其基础上顺延 7×(N-1) 天。week 为空或 0 时直接取该月 1 日。 """ import datetime if not week: return datetime.date(int(year), int(month), 1).isoformat() day = month_week1(year, month) + datetime.timedelta(days=(max(int(week), 1) - 1) * 7) return day.isoformat() def week_of_date(value) -> tuple[int, int, int]: """日期 → 该日所属的 (年, 月, 第几周),与 period_first_day 互为逆运算。""" import datetime d = datetime.date.fromisoformat(str(value)[:10]) if isinstance(value, str) else value monday = d - datetime.timedelta(days=d.weekday()) thursday = monday + datetime.timedelta(days=3) # 周四在哪个月,这一周就算哪个月的 year, month = thursday.year, thursday.month return year, month, (monday - month_week1(year, month)).days // 7 + 1 def period_last_day(year: int, month: int, week: int | None = None) -> str: """取该周期最后一天的日期。""" import calendar import datetime if not week: last = calendar.monthrange(int(year), int(month))[1] return datetime.date(int(year), int(month), last).isoformat() d = datetime.date.fromisoformat(period_first_day(year, month, week)) return (d + datetime.timedelta(days=6)).isoformat() # 默认分类配色 DEFAULT_CATEGORY_COLORS = [ "#2563eb", "#7c3aed", "#0891b2", "#16a34a", "#d97706", "#dc2626", "#db2777", "#0d9488", ] # -------------------------------------------------------------------------- # 定制项目:全过程流水线(入库 → 签单 → 交付 → 验收 → 计收) # key 环节代码(与 CUSTOM_STAGES 的阶段代码对齐,入库单独用 entry) # name 环节名 # date_field 该环节的日期字段(CustomProject 上的属性名) # stage 完成该环节后项目所处的阶段 # -------------------------------------------------------------------------- CUSTOM_PIPELINE = [ ("entry", "入库", "entry_date", "lead", "#94a3b8"), ("signed", "签单", "sign_date", "signed", "#0891b2"), ("delivered", "交付", "deliver_date", "delivered", "#7c3aed"), ("accepted", "验收", "accept_date", "accepted", "#d97706"), ("revenue", "计收", "revenue_date", "revenue", "#16a34a"), ] # 年度计收目标(万元)默认值,来源《年度任务.xlsx》;页面「年度任务」里可改,改后落库覆盖 CUSTOM_ANNUAL_TARGETS_DEFAULT = {1: 35.0, 2: 225.0, 3: 160.0, 4: 260.0} # 「在途」阶段:还没走完 入库→签单→交付→验收 这条主线(已计收 / 暂停 / 已关闭不算在途) CUSTOM_INFLIGHT_STAGES = {"lead", "signed", "developing", "delivered", "accepted"} # 环节顺序索引,用于判断项目当前卡在哪一环 CUSTOM_STEP_ORDER = {key: i for i, (key, _, _, _, _) in enumerate(CUSTOM_PIPELINE)} # 环节停滞天数超过该值视为「卡住」,需要在看板上提示 CUSTOM_STEP_AGING_DAYS = 45 def custom_current_step(dates: dict[str, str | None]) -> int: """按已完成的环节日期,返回项目当前所处的环节下标(-1 表示连入库都没有)。""" step = -1 for i, (_key, _name, field, _stage, _color) in enumerate(CUSTOM_PIPELINE): if (dates.get(field) or "").strip(): step = i return step # -------------------------------------------------------------------------- # 人力资源库 # -------------------------------------------------------------------------- # 人员归属(用工主体) STAFF_AFFILIATIONS = ["汇智", "云数", "外服"] # 归属小组(标准口径) STAFF_GROUPS = ["管理", "Nex产品组", "定制产品组", "AI产品组", "综合"] # 历史数据里的小组别名 → 标准口径 STAFF_GROUP_ALIAS = {"AI项目组": "AI产品组"} def normalize_staff_group(value: str | None) -> str | None: v = (value or "").strip() if not v: return None return STAFF_GROUP_ALIAS.get(v, v) def normalize_staff_affiliation(value: str | None) -> str | None: v = (value or "").strip() if not v: return None for a in STAFF_AFFILIATIONS: if a in v: return a return v