generate_production_materials_seed_sql.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. #!/usr/bin/env python3
  2. """从 生产物料.txt 生成期初导入 SQL(biz_production_material*)。"""
  3. from __future__ import annotations
  4. import re
  5. from collections import OrderedDict
  6. from decimal import Decimal, ROUND_HALF_UP
  7. from pathlib import Path
  8. ROOT = Path(__file__).resolve().parents[2]
  9. TXT_PATH = ROOT / "生产物料.txt"
  10. OUT_PATH = ROOT / "docs/sql/seed_production_materials_init.sql"
  11. TENANT = "zkxw"
  12. USER_ID = "d3000000000000000000000000000001"
  13. TS = "2026-06-18 10:00:00"
  14. STOCK_IN_DATE = "2026-06-18"
  15. EXPIRE_DATE = "2029-06-18"
  16. REMARK = "期初库存导入(生产物料.txt)"
  17. MONTHS = ["2", "3", "4", "5", "6", "7", "8", "9"]
  18. REAGENT_KEYWORDS = (
  19. "培养基",
  20. "血清",
  21. "胰酶",
  22. "冻存",
  23. "PBS",
  24. "DPBS",
  25. "DMSO",
  26. "双抗",
  27. "链霉素",
  28. "试剂",
  29. "添加剂",
  30. "激活剂",
  31. "活化剂",
  32. "因子",
  33. "消毒液",
  34. "酒精",
  35. "新洁尔灭",
  36. "除孢子",
  37. "鲎试剂",
  38. "台盼蓝",
  39. "Ficoll",
  40. "DEPC",
  41. "血替",
  42. "白蛋白",
  43. "HES",
  44. "氯化钠",
  45. "沉降液",
  46. "MSC",
  47. "NK",
  48. "Cryosure",
  49. "保存液",
  50. "羟乙基",
  51. "青霉素",
  52. "青/链霉素",
  53. "Rh血型",
  54. "琼脂",
  55. "血平皿",
  56. )
  57. def parse_num(raw: str | None) -> Decimal | None:
  58. if raw is None:
  59. return None
  60. s = str(raw).strip()
  61. if not s:
  62. return None
  63. try:
  64. return Decimal(s)
  65. except Exception:
  66. return None
  67. def q4(val: Decimal) -> str:
  68. return format(val.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP), "f")
  69. def sql_str(val: str | None) -> str:
  70. if val is None:
  71. return "NULL"
  72. s = str(val).replace("\\", "\\\\").replace("'", "''")
  73. return f"'{s}'"
  74. def norm_spec(spec: str) -> str | None:
  75. s = (spec or "").strip()
  76. if not s or s == "0":
  77. return None
  78. return s
  79. def norm_batch(batch: str, row_no: int) -> str:
  80. s = (batch or "").strip()
  81. if not s or s in {"0", "/"}:
  82. return f"INIT-ROW{row_no:04d}"
  83. return s
  84. def classify_type(name: str) -> str:
  85. for kw in REAGENT_KEYWORDS:
  86. if kw in name:
  87. return "reagent"
  88. return "consumable"
  89. def calc_balance_and_in_qty(cols: dict[str, str]) -> tuple[Decimal, Decimal]:
  90. opening = parse_num(cols.get("期初")) or Decimal("0")
  91. balance: Decimal | None = None
  92. total_in = opening
  93. for m in MONTHS:
  94. v_in = parse_num(cols.get(f"{m}月入库")) or Decimal("0")
  95. v_out = parse_num(cols.get(f"{m}月出库")) or Decimal("0")
  96. v_bal = parse_num(cols.get(f"{m}月结存"))
  97. if v_in == 0 and v_out == 0 and v_bal is None:
  98. continue
  99. if balance is None:
  100. balance = opening
  101. total_in += v_in
  102. if v_bal is not None:
  103. balance = v_bal
  104. else:
  105. balance = balance + v_in - v_out
  106. if balance is None:
  107. balance = opening
  108. if total_in <= 0 and balance <= 0:
  109. in_qty = Decimal("0")
  110. else:
  111. in_qty = max(total_in, balance)
  112. return balance, in_qty
  113. def parse_rows() -> list[dict]:
  114. text = TXT_PATH.read_text(encoding="utf-8")
  115. lines = [ln.rstrip("\n") for ln in text.splitlines() if ln.strip()]
  116. headers = lines[0].split("\t")
  117. rows: list[dict] = []
  118. for idx, line in enumerate(lines[1:], start=2):
  119. parts = line.split("\t")
  120. if len(parts) < len(headers):
  121. parts.extend([""] * (len(headers) - len(parts)))
  122. cols = dict(zip(headers, parts))
  123. name = (cols.get("名称") or "").strip()
  124. if not name:
  125. continue
  126. remaining, in_qty = calc_balance_and_in_qty(cols)
  127. rows.append(
  128. {
  129. "row_no": idx,
  130. "name": name,
  131. "spec": norm_spec(cols.get("规格型号", "")),
  132. "unit": (cols.get("单位") or "").strip() or "个",
  133. "batch_no": norm_batch(cols.get("批号", ""), idx),
  134. "remaining": remaining,
  135. "in_qty": in_qty,
  136. "material_type": classify_type(name),
  137. }
  138. )
  139. return rows
  140. def build_materials(rows: list[dict]) -> OrderedDict[tuple, dict]:
  141. materials: OrderedDict[tuple, dict] = OrderedDict()
  142. for row in rows:
  143. key = (row["name"], row["spec"], row["unit"])
  144. if key not in materials:
  145. seq = len(materials) + 1
  146. code = f"ZKXW-PROD-{seq:05d}"
  147. mat_id = f"pm{seq:032d}"
  148. materials[key] = {
  149. "key": key,
  150. "seq": seq,
  151. "code": code,
  152. "mat_id": mat_id,
  153. "name": row["name"],
  154. "spec": row["spec"],
  155. "unit": row["unit"],
  156. "material_type": row["material_type"],
  157. "stock_qty": Decimal("0"),
  158. "batches": [],
  159. }
  160. mat = materials[key]
  161. mat["stock_qty"] += row["remaining"]
  162. if row["material_type"] == "reagent":
  163. mat["material_type"] = "reagent"
  164. batch_seq = len(mat["batches"]) + 1
  165. batch_id = f"pb{mat['seq']:03d}{batch_seq:029d}"
  166. stock_in_no = f"INIT-20260618-{mat['seq']:05d}-{batch_seq:02d}"
  167. mat["batches"].append(
  168. {
  169. **row,
  170. "batch_id": batch_id,
  171. "stock_in_no": stock_in_no,
  172. "usage_id": f"pu{mat['seq']:03d}{batch_seq:029d}",
  173. "material_code": mat["code"],
  174. "material_type": mat["material_type"],
  175. "mat_id": mat["mat_id"],
  176. }
  177. )
  178. return materials
  179. def gen_unit_inserts(units: set[str]) -> list[str]:
  180. # 生产物料单位直接存 unit_name;此处仅确保 biz_item_unit 存在常用单位
  181. unit_code_map = {
  182. "个": "ge",
  183. "件": "jian",
  184. "套": "tao",
  185. "台": "tai",
  186. "支": "zhi",
  187. "张": "zhang",
  188. "片": "pian",
  189. "把": "ba",
  190. "双": "shuang",
  191. "盒": "he",
  192. "包": "bao",
  193. "袋": "dai",
  194. "瓶": "ping",
  195. "桶": "tong",
  196. "卷": "juan",
  197. "箱": "xiang",
  198. "L": "l",
  199. }
  200. lines: list[str] = []
  201. uid = 0x60
  202. for unit in sorted(units):
  203. code = unit_code_map.get(unit)
  204. if not code:
  205. continue
  206. uid += 1
  207. unit_id = f"e400000000000000000000000000{uid:04x}"
  208. lines.append(
  209. f"INSERT INTO `biz_item_unit` (`id`, `unit_code`, `unit_name`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)\n"
  210. f"SELECT {sql_str(unit_id)}, {sql_str(code)}, {sql_str(unit)}, {sql_str(TENANT)}, 500, '生产物料导入', {sql_str(USER_ID)}, '', {sql_str(TS)}, {sql_str(USER_ID)}, {sql_str(TS)}, 0 FROM DUAL\n"
  211. f"WHERE NOT EXISTS (SELECT 1 FROM `biz_item_unit` WHERE `unit_code`={sql_str(code)} AND `tenant_no`={sql_str(TENANT)} AND `del_flag`=0);"
  212. )
  213. return lines
  214. def gen_material_insert(mat: dict) -> str:
  215. spec_sql = sql_str(mat["spec"]) if mat["spec"] else "NULL"
  216. return (
  217. f"INSERT INTO `biz_production_material` (`id`, `material_type`, `material_code`, `material_name`, `spec_model`, `unit`, `stock_qty`, `warning_qty`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)\n"
  218. f"SELECT {sql_str(mat['mat_id'])}, {sql_str(mat['material_type'])}, {sql_str(mat['code'])}, {sql_str(mat['name'])}, {spec_sql}, {sql_str(mat['unit'])}, {q4(mat['stock_qty'])}, 0.0000, '1', {sql_str(TENANT)}, {mat['seq']}, {sql_str(REMARK)}, {sql_str(USER_ID)}, '', {sql_str(TS)}, {sql_str(USER_ID)}, {sql_str(TS)}, 0 FROM DUAL\n"
  219. f"WHERE NOT EXISTS (SELECT 1 FROM `biz_production_material` WHERE `material_code`={sql_str(mat['code'])} AND `tenant_no`={sql_str(TENANT)} AND `del_flag`=0);"
  220. )
  221. def gen_batch_insert(batch: dict) -> str:
  222. spec_sql = sql_str(batch["spec"]) if batch["spec"] else "NULL"
  223. return (
  224. f"INSERT INTO `biz_production_material_stock_batch` (`id`, `material_id`, `material_type`, `material_code`, `material_name`, `spec_model`, `unit`, `stock_in_no`, `stock_in_date`, `expire_date`, `catalog_no`, `batch_no`, `manufacturer`, `unit_price`, `amount`, `in_qty`, `remaining_qty`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)\n"
  225. f"SELECT {sql_str(batch['batch_id'])}, (SELECT `id` FROM `biz_production_material` WHERE `material_code`={sql_str(batch['material_code'])} AND `tenant_no`={sql_str(TENANT)} AND `del_flag`=0 LIMIT 1), {sql_str(batch['material_type'])}, {sql_str(batch['material_code'])}, {sql_str(batch['name'])}, {spec_sql}, {sql_str(batch['unit'])}, {sql_str(batch['stock_in_no'])}, {sql_str(STOCK_IN_DATE)}, {sql_str(EXPIRE_DATE)}, {sql_str(batch['material_code'])}, {sql_str(batch['batch_no'])}, '期初导入', 0.00, 0.00, {q4(batch['in_qty'])}, {q4(batch['remaining'])}, {sql_str(TENANT)}, {batch['row_no']}, {sql_str(REMARK)}, {sql_str(USER_ID)}, '', {sql_str(TS)}, {sql_str(USER_ID)}, {sql_str(TS)}, 0 FROM DUAL\n"
  226. f"WHERE NOT EXISTS (SELECT 1 FROM `biz_production_material_stock_batch` WHERE `stock_in_no`={sql_str(batch['stock_in_no'])} AND `tenant_no`={sql_str(TENANT)} AND `del_flag`=0);"
  227. )
  228. def gen_usage_insert(batch: dict) -> str:
  229. if batch["in_qty"] <= 0:
  230. return ""
  231. return (
  232. f"INSERT INTO `biz_production_material_usage` (`id`, `material_id`, `material_type`, `material_code`, `material_name`, `batch_id`, `stock_in_no`, `catalog_no`, `batch_no`, `manufacturer`, `unit_price`, `amount`, `change_type`, `change_qty`, `usage_unit`, `stock_before`, `stock_after`, `source_type`, `tenant_no`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)\n"
  233. f"SELECT {sql_str(batch['usage_id'])}, (SELECT `id` FROM `biz_production_material` WHERE `material_code`={sql_str(batch['material_code'])} AND `tenant_no`={sql_str(TENANT)} AND `del_flag`=0 LIMIT 1), {sql_str(batch['material_type'])}, {sql_str(batch['material_code'])}, {sql_str(batch['name'])}, {sql_str(batch['batch_id'])}, {sql_str(batch['stock_in_no'])}, {sql_str(batch['material_code'])}, {sql_str(batch['batch_no'])}, '期初导入', 0.00, 0.00, 'in', {q4(batch['in_qty'])}, {sql_str(batch['unit'])}, 0.0000, {q4(batch['in_qty'])}, 'manual_in', {sql_str(TENANT)}, {sql_str(REMARK)}, {sql_str(USER_ID)}, '', {sql_str(TS)}, {sql_str(USER_ID)}, {sql_str(TS)}, 0 FROM DUAL\n"
  234. f"WHERE NOT EXISTS (SELECT 1 FROM `biz_production_material_usage` WHERE `id`={sql_str(batch['usage_id'])});"
  235. )
  236. def main() -> None:
  237. rows = parse_rows()
  238. materials = build_materials(rows)
  239. units = {m["unit"] for m in materials.values()}
  240. parts = [
  241. "-- 生产物料期初数据导入(来源:生产物料.txt)",
  242. "-- 剩余数量:按台账逐月推算(期初 + 各月入库 - 出库,优先采用各月结存列)",
  243. "-- 幂等:按 material_code / stock_in_no 跳过已存在记录",
  244. f"-- 执行:mysql -u root -p zkxw < docs/sql/seed_production_materials_init.sql",
  245. "",
  246. "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;",
  247. "",
  248. "-- ── 物品单位 biz_item_unit(确保本批物料所需单位存在)──",
  249. "",
  250. *gen_unit_inserts(units),
  251. "",
  252. "-- ── 物料主表 biz_production_material ──────────────────────",
  253. "",
  254. ]
  255. for mat in materials.values():
  256. parts.append(gen_material_insert(mat))
  257. parts.append("")
  258. parts.append("-- ── 入库批次 biz_production_material_stock_batch ─────────")
  259. parts.append("")
  260. all_batches: list[dict] = []
  261. for mat in materials.values():
  262. all_batches.extend(mat["batches"])
  263. for batch in all_batches:
  264. parts.append(gen_batch_insert(batch))
  265. parts.append("")
  266. parts.append("-- ── 变动记录 biz_production_material_usage(期初入库)──────")
  267. parts.append("")
  268. for batch in all_batches:
  269. usage_sql = gen_usage_insert(batch)
  270. if usage_sql:
  271. parts.append(usage_sql)
  272. parts.append("")
  273. OUT_PATH.write_text("\n".join(parts), encoding="utf-8")
  274. print(f"Generated {OUT_PATH}")
  275. print(f" txt rows: {len(rows)}")
  276. print(f" materials: {len(materials)}")
  277. print(f" batches: {len(all_batches)}")
  278. nonzero = sum(1 for r in rows if r["remaining"] > 0)
  279. print(f" batches with remaining > 0: {nonzero}")
  280. if __name__ == "__main__":
  281. main()