sophhub 0.4.19 → 0.4.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (633) hide show
  1. package/README.md +199 -187
  2. package/agents/ai-cs-admin/.config.json +51 -51
  3. package/agents/ai-cs-admin/AGENTS.md +293 -293
  4. package/agents/ai-cs-admin/HEARTBEAT.md +18 -18
  5. package/agents/ai-cs-qa/.config.json +47 -47
  6. package/agents/ai-cs-qa/BOOTSTRAP.md +22 -22
  7. package/agents/ai-cs-qa/scripts/setup_links.sh +39 -39
  8. package/agents/beauty/.config.json +17 -17
  9. package/agents/beauty/AGENTS.md +234 -234
  10. package/agents/beauty/BOOTSTRAP.md +55 -55
  11. package/agents/beauty/HEARTBEAT.md +5 -5
  12. package/agents/beauty/IDENTITY.md +5 -5
  13. package/agents/beauty/MEMORY.md +44 -44
  14. package/agents/beauty/SOUL.md +64 -64
  15. package/agents/beauty/TOOLS.md +160 -160
  16. package/agents/beauty/USER.md +114 -114
  17. package/agents/intern-admin/.config.json +60 -60
  18. package/agents/intern-admin/AGENTS.md +267 -267
  19. package/agents/intern-admin/BOOTSTRAP.md +21 -21
  20. package/agents/intern-admin/HEARTBEAT.md +3 -3
  21. package/agents/intern-admin/IDENTITY.md +6 -6
  22. package/agents/intern-admin/MEMORY.md +21 -21
  23. package/agents/intern-admin/SOUL.md +23 -23
  24. package/agents/intern-admin/TOOLS.md +93 -93
  25. package/agents/intern-admin/USER.md +16 -16
  26. package/agents/intern-admin/scripts/init_workspace.sh +27 -27
  27. package/agents/intern-qa/.config.json +46 -46
  28. package/agents/intern-qa/AGENTS.md +303 -303
  29. package/agents/intern-qa/BOOTSTRAP.md +16 -16
  30. package/agents/intern-qa/HEARTBEAT.md +3 -3
  31. package/agents/intern-qa/IDENTITY.md +6 -6
  32. package/agents/intern-qa/MEMORY.md +22 -22
  33. package/agents/intern-qa/SOUL.md +24 -24
  34. package/agents/intern-qa/TOOLS.md +24 -24
  35. package/agents/intern-qa/USER.md +27 -27
  36. package/agents/intern-qa/scripts/setup_links.sh +54 -54
  37. package/agents/parent-toddler/.config.json +37 -37
  38. package/agents/parent-toddler/AGENTS.md +51 -51
  39. package/agents/parent-toddler/BOOTSTRAP.md +55 -55
  40. package/agents/parent-toddler/HEARTBEAT.md +5 -5
  41. package/agents/parent-toddler/IDENTITY.md +5 -5
  42. package/agents/parent-toddler/MEMORY.md +22 -22
  43. package/agents/parent-toddler/SOUL.md +35 -35
  44. package/agents/parent-toddler/TOOLS.md +31 -31
  45. package/agents/parent-toddler/USER.md +44 -44
  46. package/agents/vip-admin/.config.json +51 -51
  47. package/agents/vip-admin/AGENTS.md +314 -314
  48. package/agents/vip-admin/BOOTSTRAP.md +21 -21
  49. package/agents/vip-admin/HEARTBEAT.md +19 -19
  50. package/agents/vip-admin/IDENTITY.md +6 -6
  51. package/agents/vip-admin/MEMORY.md +30 -30
  52. package/agents/vip-admin/SOUL.md +25 -25
  53. package/agents/vip-admin/TOOLS.md +108 -108
  54. package/agents/vip-admin/USER.md +31 -31
  55. package/agents/vip-qa/.config.json +58 -58
  56. package/agents/vip-qa/AGENTS.md +319 -319
  57. package/agents/vip-qa/BOOTSTRAP.md +73 -73
  58. package/agents/vip-qa/HEARTBEAT.md +23 -23
  59. package/agents/vip-qa/IDENTITY.md +7 -7
  60. package/agents/vip-qa/MEMORY.md +23 -23
  61. package/agents/vip-qa/SOUL.md +34 -34
  62. package/agents/vip-qa/TOOLS.md +41 -41
  63. package/agents/vip-qa/USER.md +16 -16
  64. package/agents/vip-qa/scripts/setup_links.sh +39 -39
  65. package/bin/sophhub.js +25 -25
  66. package/package.json +35 -33
  67. package/skills/agent-install/skill.json +34 -34
  68. package/skills/agent-install/src/SKILL.md +240 -240
  69. package/skills/agent-install/src/pyproject.toml +6 -6
  70. package/skills/agent-install/src/scripts/backup_agent.py +120 -120
  71. package/skills/agent-install/src/scripts/check_installed.py +479 -479
  72. package/skills/agent-install/src/scripts/common.py +568 -568
  73. package/skills/agent-install/src/scripts/copy_agent_files.py +59 -59
  74. package/skills/agent-install/src/scripts/list_agents.py +285 -285
  75. package/skills/agent-install/src/scripts/resolve_install_params.py +90 -90
  76. package/skills/agent-install/src/scripts/update_agent_md.py +76 -76
  77. package/skills/agent-install/src/scripts/update_openclaw.py +193 -193
  78. package/skills/agent-install/src/scripts/verify_download.py +148 -148
  79. package/skills/aippt/skill.json +20 -20
  80. package/skills/aippt/src/SKILL.md +235 -235
  81. package/skills/aippt/src/pyproject.toml +8 -8
  82. package/skills/aippt/src/scripts/auth.py +122 -122
  83. package/skills/aippt/src/scripts/ppt.py +361 -361
  84. package/skills/aippt/src/scripts/provider_docmee.py +299 -299
  85. package/skills/beauty-salon-inventory/skill.json +16 -16
  86. package/skills/beauty-salon-inventory/src/SKILL.md +69 -69
  87. package/skills/beauty-salon-inventory/src/scripts/init_salon_inventory.py +39 -39
  88. package/skills/beauty-salon-inventory/src/scripts/init_salon_inventory.sh +4 -4
  89. package/skills/beauty-salon-inventory/src/scripts/salon_inventory_cli.py +244 -244
  90. package/skills/beauty-salon-marketing/skill.json +10 -10
  91. package/skills/beauty-salon-marketing/src/SKILL.md +36 -36
  92. package/skills/beauty-salon-marketing/src/playbooks/beauty-salon-festival.md +19 -19
  93. package/skills/beauty-salon-marketing/src/playbooks/beauty-salon-segment.md +18 -18
  94. package/skills/beauty-salon-marketing/src/scripts/beauty_marketing_cli.py +99 -99
  95. package/skills/beauty-salon-marketing/src/scripts/member_segment.py +114 -114
  96. package/skills/beauty-salon-member-appointment/skill.json +10 -10
  97. package/skills/beauty-salon-member-appointment/src/SKILL.md +36 -36
  98. package/skills/beauty-salon-member-appointment/src/pyproject.toml +9 -9
  99. package/skills/beauty-salon-member-appointment/src/scripts/run_e2e_smoke.py +160 -160
  100. package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/__init__.py +1 -1
  101. package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/__main__.py +4 -4
  102. package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/cli.py +921 -921
  103. package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/db.py +30 -30
  104. package/skills/beauty-salon-membership/skill.json +20 -20
  105. package/skills/beauty-salon-membership/src/SKILL.md +67 -67
  106. package/skills/beauty-salon-product-service/skill.json +12 -12
  107. package/skills/beauty-salon-product-service/src/SKILL.md +42 -42
  108. package/skills/beauty-salon-product-service/src/pyproject.toml +9 -9
  109. package/skills/beauty-salon-product-service/src/src/product_service_cli/__init__.py +1 -1
  110. package/skills/beauty-salon-product-service/src/src/product_service_cli/__main__.py +4 -4
  111. package/skills/beauty-salon-product-service/src/src/product_service_cli/cli.py +329 -329
  112. package/skills/beauty-salon-product-service/src/src/product_service_cli/db.py +29 -29
  113. package/skills/beauty-salon-staff/skill.json +10 -10
  114. package/skills/beauty-salon-staff/src/SKILL.md +37 -37
  115. package/skills/beauty-salon-staff/src/pyproject.toml +9 -9
  116. package/skills/beauty-salon-staff/src/src/staff_cli/__init__.py +1 -1
  117. package/skills/beauty-salon-staff/src/src/staff_cli/__main__.py +4 -4
  118. package/skills/beauty-salon-staff/src/src/staff_cli/cli.py +479 -479
  119. package/skills/beauty-salon-staff/src/src/staff_cli/db.py +28 -28
  120. package/skills/beauty-salon-suite/skill.json +13 -13
  121. package/skills/beauty-salon-suite/src/SKILL.md +18 -18
  122. package/skills/beauty-salon-suite/src/beauty_db/__init__.py +2 -2
  123. package/skills/beauty-salon-suite/src/beauty_db/db.py +249 -249
  124. package/skills/beauty-salon-traffic/skill.json +20 -20
  125. package/skills/beauty-salon-traffic/src/SKILL.md +84 -84
  126. package/skills/bing-image-search/skill.json +20 -20
  127. package/skills/bing-image-search/src/SKILL.md +105 -105
  128. package/skills/bot-api-status/skill.json +44 -44
  129. package/skills/bot-api-status/src/SKILL.md +99 -99
  130. package/skills/bot-api-status/src/pyproject.toml +5 -5
  131. package/skills/bot-api-status/src/scripts/secret.py +496 -496
  132. package/skills/bot-secret/skill.json +35 -35
  133. package/skills/bot-secret/src/SKILL.md +51 -51
  134. package/skills/bot-secret/src/pyproject.toml +5 -5
  135. package/skills/bot-secret/src/scripts/secret.py +120 -120
  136. package/skills/cake-flower-holiday-campaign/skill.json +20 -20
  137. package/skills/cake-flower-holiday-campaign/src/SKILL.md +68 -68
  138. package/skills/cake-flower-order-sop/skill.json +20 -20
  139. package/skills/cake-flower-order-sop/src/SKILL.md +65 -65
  140. package/skills/claw-agent-get-send/skill.json +32 -32
  141. package/skills/claw-agent-get-send/src/SKILL.md +43 -43
  142. package/skills/claw-agent-get-send/src/pyproject.toml +5 -5
  143. package/skills/claw-agent-get-send/src/scripts/appia_claw.py +379 -379
  144. package/skills/compact-context/skill.json +20 -20
  145. package/skills/compact-context/src/SKILL.md +133 -133
  146. package/skills/compact-context/src/scripts/check.sh +381 -381
  147. package/skills/compact-context/src/scripts/set-keep-recent.mjs +1337 -1337
  148. package/skills/compact-context/src/scripts/setup.sh +96 -96
  149. package/skills/consensus/skill.json +20 -20
  150. package/skills/consensus/src/SKILL.md +93 -93
  151. package/skills/deepwiki/skill.json +20 -20
  152. package/skills/deepwiki/src/SKILL.md +45 -45
  153. package/skills/deepwiki/src/_meta.json +5 -5
  154. package/skills/deepwiki/src/scripts/deepwiki.js +135 -135
  155. package/skills/didi-ride/skill.json +20 -20
  156. package/skills/didi-ride/src/SKILL.md +309 -309
  157. package/skills/didi-ride/src/_meta.json +5 -5
  158. package/skills/didi-ride/src/assets/PREFERENCE.md +58 -58
  159. package/skills/didi-ride/src/package.json +15 -15
  160. package/skills/didi-ride/src/references/api_references.md +171 -171
  161. package/skills/didi-ride/src/references/error_handling.md +68 -68
  162. package/skills/didi-ride/src/references/setup.md +73 -73
  163. package/skills/didi-ride/src/references/workflow.md +150 -150
  164. package/skills/feishu-bitable/skill.json +20 -20
  165. package/skills/feishu-bitable/src/CHECKLIST.md +149 -149
  166. package/skills/feishu-bitable/src/README.md +177 -177
  167. package/skills/feishu-bitable/src/SKILL.md +113 -113
  168. package/skills/feishu-bitable/src/_meta.json +5 -5
  169. package/skills/feishu-bitable/src/api.js +380 -380
  170. package/skills/feishu-bitable/src/bin/cli.js +283 -283
  171. package/skills/feishu-bitable/src/description.md +142 -142
  172. package/skills/feishu-bitable/src/examples/create-records.json +51 -51
  173. package/skills/feishu-bitable/src/examples/create-table.json +63 -63
  174. package/skills/feishu-bitable/src/package-lock.json +324 -324
  175. package/skills/feishu-bitable/src/package.json +32 -32
  176. package/skills/feishu-bitable/src/publish-config.json +13 -13
  177. package/skills/feishu-bitable/src/test-simple.js +60 -60
  178. package/skills/feishu-bitable/src/utils.js +260 -260
  179. package/skills/feishu-notes-assistant-universal/skill.json +20 -20
  180. package/skills/feishu-notes-assistant-universal/src/README.md +55 -55
  181. package/skills/feishu-notes-assistant-universal/src/SKILL.md +159 -159
  182. package/skills/feishu-notes-assistant-universal/src/scripts/_resolve_lark_cli.py +58 -58
  183. package/skills/feishu-notes-assistant-universal/src/scripts/openclaw_meeting_minutes.py +462 -462
  184. package/skills/feishu-notes-assistant-universal/src/scripts/openclaw_notes_crud.py +547 -547
  185. package/skills/feishu-notes-assistant-universal/src/scripts/openclaw_notes_crud_test.py +181 -181
  186. package/skills/feishu-notes-assistant-universal/src/scripts/run_meeting_minutes.py +80 -80
  187. package/skills/feishu-notes-assistant-universal/src/scripts/run_meeting_minutes.sh +5 -5
  188. package/skills/feishu-notes-assistant-universal/src/scripts/run_note_crud.py +32 -32
  189. package/skills/feishu-notes-assistant-universal/src/scripts/run_note_crud.sh +5 -5
  190. package/skills/flight-booking/skill.json +36 -36
  191. package/skills/flight-booking/src/SKILL.md +288 -288
  192. package/skills/flight-booking/src/scripts/flight_booking.py +1237 -1237
  193. package/skills/flyai/skill.json +20 -20
  194. package/skills/flyai/src/SKILL.md +119 -119
  195. package/skills/flyai/src/references/fliggy-fast-search.md +53 -53
  196. package/skills/flyai/src/references/search-flight.md +89 -89
  197. package/skills/flyai/src/references/search-hotels.md +57 -57
  198. package/skills/flyai/src/references/search-poi.md +48 -48
  199. package/skills/google-maps/skill.json +20 -20
  200. package/skills/google-maps/src/SKILL.md +237 -237
  201. package/skills/google-maps/src/_meta.json +5 -5
  202. package/skills/google-maps/src/lib/map_helper.py +912 -912
  203. package/skills/image-classify/skill.json +42 -42
  204. package/skills/image-classify/src/SKILL.md +368 -368
  205. package/skills/image-classify/src/references/config.json +4 -4
  206. package/skills/image-classify/src/scripts/face_search.py +1276 -1276
  207. package/skills/image-description/skill.json +34 -34
  208. package/skills/image-description/src/SKILL.md +33 -33
  209. package/skills/image-description/src/pyproject.toml +8 -8
  210. package/skills/image-description/src/scripts/ana_image.py +112 -112
  211. package/skills/image-identify-world/skill.json +20 -20
  212. package/skills/image-identify-world/src/SKILL.md +40 -40
  213. package/skills/image-identify-world/src/pyproject.toml +8 -8
  214. package/skills/image-identify-world/src/scripts/identify_world.py +115 -115
  215. package/skills/insurance-policy-review/skill.json +27 -27
  216. package/skills/insurance-policy-review/src/SKILL.md +75 -75
  217. package/skills/insurance-sales-playbook/skill.json +20 -20
  218. package/skills/insurance-sales-playbook/src/SKILL.md +58 -58
  219. package/skills/inventory-management/skill.json +20 -20
  220. package/skills/inventory-management/src/SKILL.md +241 -241
  221. package/skills/inventory-management/src/scripts/inventory.py +1844 -1844
  222. package/skills/large-task-router/skill.json +20 -20
  223. package/skills/large-task-router/src/SKILL.md +79 -79
  224. package/skills/large-task-router/src/templates/plan.md +74 -74
  225. package/skills/lawding-contract-review/skill.json +20 -20
  226. package/skills/lawding-contract-review/src/SKILL.md +284 -284
  227. package/skills/lawding-contract-review/src/references/legal-language-library.md +1385 -1385
  228. package/skills/lawding-contract-review/src/scripts/build_reminders.py +471 -471
  229. package/skills/lawding-contract-review/src/scripts/register_contract_cron.py +457 -457
  230. package/skills/md2pdf-converter/skill.json +20 -20
  231. package/skills/md2pdf-converter/src/SKILL.md +244 -244
  232. package/skills/md2pdf-converter/src/_meta.json +5 -5
  233. package/skills/md2pdf-converter/src/scripts/generate_emoji_mapping.py +74 -74
  234. package/skills/md2pdf-converter/src/scripts/md2pdf-local.sh +291 -291
  235. package/skills/notes-hub-assistant/skill.json +20 -20
  236. package/skills/notes-hub-assistant/src/SKILL.md +233 -233
  237. package/skills/notes-hub-assistant/src/scripts/_resolve_lark_cli.py +48 -48
  238. package/skills/notes-hub-assistant/src/scripts/openclaw_meeting_minutes.py +473 -473
  239. package/skills/notes-hub-assistant/src/scripts/openclaw_notes_crud.py +596 -596
  240. package/skills/notes-hub-assistant/src/scripts/openclaw_wolai_notes_crud.py +364 -364
  241. package/skills/notes-hub-assistant/src/scripts/run_meeting_minutes.py +79 -79
  242. package/skills/notes-hub-assistant/src/scripts/run_note_crud.py +37 -37
  243. package/skills/notes-hub-assistant/src/scripts/run_notionbot.py +36 -36
  244. package/skills/notes-hub-assistant/src/scripts/run_wolai_note_crud.py +27 -27
  245. package/skills/schedule-reminder/skill.json +20 -20
  246. package/skills/schedule-reminder/src/SKILL.md +619 -619
  247. package/skills/schedule-reminder/src/schedule_template.md +68 -68
  248. package/skills/schedule-reminder/src/scripts/append_event.py +204 -204
  249. package/skills/schedule-reminder/src/scripts/create_reminders.sh +163 -163
  250. package/skills/schedule-reminder/src/scripts/daily_activate.sh +175 -175
  251. package/skills/schedule-reminder/src/scripts/parse_schedule.py +704 -704
  252. package/skills/schedule-reminder/src/scripts/setup.sh +242 -242
  253. package/skills/schedule-reminder/src//347/224/250/346/210/267/346/214/207/345/215/227.md +311 -311
  254. package/skills/sessions-analysis/skill.json +34 -34
  255. package/skills/sessions-analysis/src/SKILL.md +81 -81
  256. package/skills/sessions-analysis/src/pyproject.toml +5 -5
  257. package/skills/sessions-analysis/src/scripts/ana_logs.py +205 -205
  258. package/skills/share-skill/skill.json +20 -20
  259. package/skills/share-skill/src/SKILL.md +261 -261
  260. package/skills/share-skill/src/scripts/share_skill_to_friend.py +1031 -1031
  261. package/skills/skill-creator/skill.json +20 -20
  262. package/skills/skill-creator/src/SKILL.md +370 -370
  263. package/skills/skill-creator/src/license.txt +202 -202
  264. package/skills/skill-creator/src/scripts/init_skill.py +378 -378
  265. package/skills/skill-creator/src/scripts/package_skill.py +111 -111
  266. package/skills/skill-creator/src/scripts/quick_validate.py +101 -101
  267. package/skills/skillhub/skill.json +27 -27
  268. package/skills/skillhub/src/SKILL.md +121 -121
  269. package/skills/sophnet-age-appearance/skill.json +20 -20
  270. package/skills/sophnet-age-appearance/src/SKILL.md +83 -83
  271. package/skills/sophnet-age-appearance/src/pyproject.toml +10 -10
  272. package/skills/sophnet-age-appearance/src/scripts/age_appearance.py +395 -395
  273. package/skills/sophnet-age-appearance/src/scripts/age_face_crop.py +313 -313
  274. package/skills/sophnet-bot-client/skill.json +20 -20
  275. package/skills/sophnet-bot-client/src/SKILL.md +255 -255
  276. package/skills/sophnet-bot-client/src/pyproject.toml +13 -13
  277. package/skills/sophnet-bot-client/src/scripts/bot_client_proxy.py +165 -165
  278. package/skills/sophnet-bot-client/src/scripts/bot_client_safe.sh +29 -29
  279. package/skills/sophnet-bot-client/src/scripts/bot_client_setup.py +502 -502
  280. package/skills/sophnet-bot-client/src/tests/test_bot_client_proxy.py +255 -255
  281. package/skills/sophnet-bot-client/src/tests/test_bot_client_setup.py +679 -679
  282. package/skills/sophnet-bot-client/src/uv.lock +8 -8
  283. package/skills/sophnet-customer-management/skill.json +20 -20
  284. package/skills/sophnet-customer-management/src/SKILL.md +270 -270
  285. package/skills/sophnet-customer-management/src/pyproject.toml +15 -15
  286. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/__init__.py +2 -2
  287. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/__main__.py +5 -5
  288. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/cli.py +67 -67
  289. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/__init__.py +2 -2
  290. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/customer.py +60 -60
  291. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/export_file.py +18 -18
  292. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/import_file.py +15 -15
  293. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/reminder.py +26 -26
  294. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/schema.py +28 -28
  295. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/config.py +54 -54
  296. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/__init__.py +2 -2
  297. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/exporter.py +85 -85
  298. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/models.py +84 -84
  299. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/normalizer.py +144 -144
  300. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/parser.py +241 -241
  301. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/query.py +109 -109
  302. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/reminder.py +121 -121
  303. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/repository.py +397 -397
  304. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/schema.py +106 -106
  305. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/service.py +565 -565
  306. package/skills/sophnet-customer-management/src/uv.lock +48 -48
  307. package/skills/sophnet-customized-marketing/skill.json +28 -28
  308. package/skills/sophnet-customized-marketing/src/SKILL.md +144 -144
  309. package/skills/sophnet-customized-marketing/src/playbooks/campaign-planning.md +187 -187
  310. package/skills/sophnet-customized-marketing/src/playbooks/content-generation.md +124 -124
  311. package/skills/sophnet-customized-marketing/src/playbooks/marketing-calendar.md +59 -59
  312. package/skills/sophnet-customized-marketing/src/playbooks/multi-channel-bundle.md +94 -94
  313. package/skills/sophnet-customized-marketing/src/playbooks/poster-generation.md +182 -182
  314. package/skills/sophnet-customized-marketing/src/playbooks/style-profile-workflow.md +103 -103
  315. package/skills/sophnet-customized-marketing/src/pyproject.toml +8 -8
  316. package/skills/sophnet-customized-marketing/src/references/campaign-mechanics.md +168 -168
  317. package/skills/sophnet-customized-marketing/src/references/content-safety.md +26 -26
  318. package/skills/sophnet-customized-marketing/src/references/marketing-date-checklist.md +99 -99
  319. package/skills/sophnet-customized-marketing/src/references/platform-writing-guidelines.md +88 -88
  320. package/skills/sophnet-customized-marketing/src/references/quality-checklist.md +44 -44
  321. package/skills/sophnet-customized-marketing/src/scripts/generate_poster.py +572 -572
  322. package/skills/sophnet-customized-marketing/src/scripts/style_profile.py +215 -215
  323. package/skills/sophnet-dailynews/skill.json +20 -20
  324. package/skills/sophnet-dailynews/src/SKILL.md +179 -179
  325. package/skills/sophnet-dailynews/src/cache.json +150 -150
  326. package/skills/sophnet-dailynews/src/sources.json +230 -230
  327. package/skills/sophnet-docx/skill.json +20 -20
  328. package/skills/sophnet-docx/src/SKILL.md +463 -463
  329. package/skills/sophnet-docx/src/package-lock.json +208 -208
  330. package/skills/sophnet-docx/src/package.json +16 -16
  331. package/skills/sophnet-docx/src/pyproject.toml +11 -11
  332. package/skills/sophnet-docx/src/scripts/__init__.py +1 -1
  333. package/skills/sophnet-docx/src/scripts/accept_changes.py +135 -135
  334. package/skills/sophnet-docx/src/scripts/comment.py +318 -318
  335. package/skills/sophnet-docx/src/scripts/ensure_uv_env.sh +68 -68
  336. package/skills/sophnet-docx/src/scripts/office/helpers/merge_runs.py +199 -199
  337. package/skills/sophnet-docx/src/scripts/office/helpers/simplify_redlines.py +197 -197
  338. package/skills/sophnet-docx/src/scripts/office/pack.py +159 -159
  339. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -1499
  340. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -146
  341. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -1085
  342. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -11
  343. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -3081
  344. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -23
  345. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -185
  346. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -287
  347. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -1676
  348. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -28
  349. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -144
  350. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -174
  351. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -25
  352. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -18
  353. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -59
  354. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -56
  355. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -195
  356. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -582
  357. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -25
  358. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -4439
  359. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -570
  360. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -509
  361. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -12
  362. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -108
  363. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -96
  364. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -3646
  365. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -116
  366. package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -42
  367. package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -50
  368. package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -49
  369. package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -33
  370. package/skills/sophnet-docx/src/scripts/office/schemas/mce/mc.xsd +75 -75
  371. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-2010.xsd +560 -560
  372. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-2012.xsd +67 -67
  373. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-2018.xsd +14 -14
  374. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -20
  375. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -13
  376. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -4
  377. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -8
  378. package/skills/sophnet-docx/src/scripts/office/soffice.py +183 -183
  379. package/skills/sophnet-docx/src/scripts/office/unpack.py +132 -132
  380. package/skills/sophnet-docx/src/scripts/office/validate.py +111 -111
  381. package/skills/sophnet-docx/src/scripts/office/validators/__init__.py +15 -15
  382. package/skills/sophnet-docx/src/scripts/office/validators/base.py +847 -847
  383. package/skills/sophnet-docx/src/scripts/office/validators/docx.py +446 -446
  384. package/skills/sophnet-docx/src/scripts/office/validators/pptx.py +275 -275
  385. package/skills/sophnet-docx/src/scripts/office/validators/redlining.py +247 -247
  386. package/skills/sophnet-docx/src/scripts/templates/comments.xml +3 -3
  387. package/skills/sophnet-docx/src/scripts/templates/commentsExtended.xml +3 -3
  388. package/skills/sophnet-docx/src/scripts/templates/commentsExtensible.xml +3 -3
  389. package/skills/sophnet-docx/src/scripts/templates/commentsIds.xml +3 -3
  390. package/skills/sophnet-docx/src/scripts/templates/people.xml +3 -3
  391. package/skills/sophnet-docx/src/scripts/upload_file.sh +96 -96
  392. package/skills/sophnet-docx/src/uv.lock +320 -320
  393. package/skills/sophnet-face-search/skill.json +20 -20
  394. package/skills/sophnet-face-search/src/SKILL.md +115 -115
  395. package/skills/sophnet-face-search/src/pyproject.toml +11 -11
  396. package/skills/sophnet-face-search/src/scripts/face_search.py +335 -335
  397. package/skills/sophnet-face-search/src/uv.lock +508 -508
  398. package/skills/sophnet-id-photo/skill.json +20 -20
  399. package/skills/sophnet-id-photo/src/SKILL.md +107 -107
  400. package/skills/sophnet-id-photo/src/pyproject.toml +10 -10
  401. package/skills/sophnet-id-photo/src/scripts/id_photo.py +540 -540
  402. package/skills/sophnet-id-photo/src/scripts/id_photo_compliance.py +215 -215
  403. package/skills/sophnet-id-photo/src/scripts/id_photo_face_crop.py +313 -313
  404. package/skills/sophnet-image-edit/skill.json +20 -20
  405. package/skills/sophnet-image-edit/src/SKILL.md +140 -140
  406. package/skills/sophnet-image-edit/src/pyproject.toml +9 -9
  407. package/skills/sophnet-image-edit/src/scripts/edit_and_preview.sh +68 -68
  408. package/skills/sophnet-image-edit/src/scripts/edit_image.py +279 -279
  409. package/skills/sophnet-image-edit/src/uv.lock +234 -234
  410. package/skills/sophnet-image-generate/skill.json +20 -20
  411. package/skills/sophnet-image-generate/src/SKILL.md +62 -62
  412. package/skills/sophnet-image-generate/src/pyproject.toml +9 -9
  413. package/skills/sophnet-image-generate/src/scripts/generate_image.py +156 -156
  414. package/skills/sophnet-image-generate/src/uv.lock +234 -234
  415. package/skills/sophnet-image-ocr/skill.json +20 -20
  416. package/skills/sophnet-image-ocr/src/SKILL.md +167 -167
  417. package/skills/sophnet-image-ocr/src/pyproject.toml +13 -13
  418. package/skills/sophnet-image-ocr/src/scripts/ocr.py +225 -225
  419. package/skills/sophnet-image-ocr/src/uv.lock +234 -234
  420. package/skills/sophnet-infinite-talk/skill.json +20 -20
  421. package/skills/sophnet-infinite-talk/src/SKILL.md +140 -140
  422. package/skills/sophnet-infinite-talk/src/pyproject.toml +9 -9
  423. package/skills/sophnet-infinite-talk/src/scripts/gen.py +172 -172
  424. package/skills/sophnet-oss/skill.json +27 -27
  425. package/skills/sophnet-oss/src/SKILL.md +118 -118
  426. package/skills/sophnet-oss/src/pyproject.toml +8 -8
  427. package/skills/sophnet-oss/src/scripts/upload_file.py +43 -43
  428. package/skills/sophnet-pdf/skill.json +20 -20
  429. package/skills/sophnet-pdf/src/SKILL.md +413 -413
  430. package/skills/sophnet-pdf/src/forms.md +297 -297
  431. package/skills/sophnet-pdf/src/pyproject.toml +14 -14
  432. package/skills/sophnet-pdf/src/reference.md +611 -611
  433. package/skills/sophnet-pdf/src/scripts/check_bounding_boxes.py +65 -65
  434. package/skills/sophnet-pdf/src/scripts/check_fillable_fields.py +11 -11
  435. package/skills/sophnet-pdf/src/scripts/convert_pdf_to_images.py +33 -33
  436. package/skills/sophnet-pdf/src/scripts/create_validation_image.py +37 -37
  437. package/skills/sophnet-pdf/src/scripts/enhance_tutorial.py +557 -557
  438. package/skills/sophnet-pdf/src/scripts/ensure_uv_env.sh +68 -68
  439. package/skills/sophnet-pdf/src/scripts/extract_form_field_info.py +122 -122
  440. package/skills/sophnet-pdf/src/scripts/extract_form_structure.py +115 -115
  441. package/skills/sophnet-pdf/src/scripts/extract_pdf_content.py +34 -34
  442. package/skills/sophnet-pdf/src/scripts/fill_fillable_fields.py +98 -98
  443. package/skills/sophnet-pdf/src/scripts/fill_pdf_form_with_annotations.py +107 -107
  444. package/skills/sophnet-pdf/src/scripts/upload_file.sh +88 -88
  445. package/skills/sophnet-pdf/src/uv.lock +537 -537
  446. package/skills/sophnet-qa-install/skill.json +27 -27
  447. package/skills/sophnet-qa-install/src/SKILL.md +210 -210
  448. package/skills/sophnet-qa-install/src/pyproject.toml +6 -6
  449. package/skills/sophnet-qa-install/src/scripts/backup_md.py +35 -35
  450. package/skills/sophnet-qa-install/src/scripts/check_installed.py +143 -143
  451. package/skills/sophnet-qa-install/src/scripts/update_config.py +142 -142
  452. package/skills/sophnet-qa-install/src/scripts/update_md.py +73 -73
  453. package/skills/sophnet-schedule/skill.json +20 -20
  454. package/skills/sophnet-schedule/src/ARCHITECTURE.md +321 -321
  455. package/skills/sophnet-schedule/src/IMPROVEMENTS.md +145 -145
  456. package/skills/sophnet-schedule/src/SKILL.md +1050 -1050
  457. package/skills/sophnet-schedule/src/_meta.json +6 -6
  458. package/skills/sophnet-schedule/src/api/models.py +245 -245
  459. package/skills/sophnet-schedule/src/apps/add_event.py +237 -237
  460. package/skills/sophnet-schedule/src/apps/check_reminders.py +112 -112
  461. package/skills/sophnet-schedule/src/apps/check_roc.py +246 -246
  462. package/skills/sophnet-schedule/src/apps/generate_daily_plan.py +342 -342
  463. package/skills/sophnet-schedule/src/apps/import_events.py +216 -216
  464. package/skills/sophnet-schedule/src/apps/monitor_calendar_changes.py +140 -140
  465. package/skills/sophnet-schedule/src/apps/register_tasks.py +169 -169
  466. package/skills/sophnet-schedule/src/apps/sync_roc_to_gcal.py +174 -174
  467. package/skills/sophnet-schedule/src/compat.py +66 -66
  468. package/skills/sophnet-schedule/src/config/reminder_rules.yaml +96 -96
  469. package/skills/sophnet-schedule/src/config/roc_events.yaml +44 -44
  470. package/skills/sophnet-schedule/src/config/settings.py +133 -133
  471. package/skills/sophnet-schedule/src/config/task_registry.yaml +92 -92
  472. package/skills/sophnet-schedule/src/docs/FRONTEND_INTEGRATION_GUIDE.md +437 -437
  473. package/skills/sophnet-schedule/src/gcal/client.py +374 -374
  474. package/skills/sophnet-schedule/src/gcal/models.py +91 -91
  475. package/skills/sophnet-schedule/src/requirements.txt +6 -6
  476. package/skills/sophnet-schedule/src/scripts/setup_gcal_token.py +85 -85
  477. package/skills/sophnet-schedule/src/server.py +669 -669
  478. package/skills/sophnet-schedule/src/services/calendar_backend.py +139 -139
  479. package/skills/sophnet-schedule/src/services/conflict_detector.py +96 -96
  480. package/skills/sophnet-schedule/src/services/datetime_utils.py +117 -117
  481. package/skills/sophnet-schedule/src/services/event_classifier.py +100 -100
  482. package/skills/sophnet-schedule/src/services/event_diff.py +160 -160
  483. package/skills/sophnet-schedule/src/services/google_integration.py +500 -500
  484. package/skills/sophnet-schedule/src/services/job_store.py +100 -100
  485. package/skills/sophnet-schedule/src/services/local_event_store.py +266 -266
  486. package/skills/sophnet-schedule/src/services/reminder_planner.py +116 -116
  487. package/skills/sophnet-schedule/src/services/runtime_utils.py +31 -31
  488. package/skills/sophnet-schedule/src/services/table_parser.py +286 -286
  489. package/skills/sophnet-schedule/src/services/task_builder.py +167 -167
  490. package/skills/sophnet-schedule/src/services/time_window.py +72 -72
  491. package/skills/sophnet-sticker-edit/skill.json +27 -27
  492. package/skills/sophnet-sticker-edit/src/SKILL.md +80 -80
  493. package/skills/sophnet-sticker-edit/src/pyproject.toml +9 -9
  494. package/skills/sophnet-sticker-edit/src/scripts/edit_sticker_image.py +403 -403
  495. package/skills/sophnet-stock/skill.json +20 -20
  496. package/skills/sophnet-stock/src/App-Plan.md +442 -442
  497. package/skills/sophnet-stock/src/README.md +214 -214
  498. package/skills/sophnet-stock/src/SKILL.md +236 -236
  499. package/skills/sophnet-stock/src/TODO.md +394 -394
  500. package/skills/sophnet-stock/src/_meta.json +5 -5
  501. package/skills/sophnet-stock/src/docs/ARCHITECTURE.md +408 -408
  502. package/skills/sophnet-stock/src/docs/CONCEPT.md +233 -233
  503. package/skills/sophnet-stock/src/docs/HOT_SCANNER.md +288 -288
  504. package/skills/sophnet-stock/src/docs/README.md +95 -95
  505. package/skills/sophnet-stock/src/docs/USAGE.md +465 -465
  506. package/skills/sophnet-stock/src/scripts/analyze_stock.py +2565 -2565
  507. package/skills/sophnet-stock/src/scripts/dividends.py +365 -365
  508. package/skills/sophnet-stock/src/scripts/hot_scanner.py +582 -582
  509. package/skills/sophnet-stock/src/scripts/portfolio.py +548 -548
  510. package/skills/sophnet-stock/src/scripts/rumor_scanner.py +342 -342
  511. package/skills/sophnet-stock/src/scripts/test_stock_analysis.py +409 -409
  512. package/skills/sophnet-stock/src/scripts/watchlist.py +336 -336
  513. package/skills/sophnet-training-install/skill.json +27 -27
  514. package/skills/sophnet-training-install/src/SKILL.md +211 -211
  515. package/skills/sophnet-training-install/src/pyproject.toml +6 -6
  516. package/skills/sophnet-training-install/src/scripts/backup_md.py +35 -35
  517. package/skills/sophnet-training-install/src/scripts/check_installed.py +144 -144
  518. package/skills/sophnet-training-install/src/scripts/update_config.py +142 -142
  519. package/skills/sophnet-training-install/src/scripts/update_md.py +73 -73
  520. package/skills/sophnet-tts/skill.json +20 -20
  521. package/skills/sophnet-tts/src/SKILL.md +79 -79
  522. package/skills/sophnet-tts/src/pyproject.toml +9 -9
  523. package/skills/sophnet-tts/src/scripts/gen_tts.py +130 -130
  524. package/skills/sophnet-video-generate/skill.json +37 -37
  525. package/skills/sophnet-video-generate/src/SKILL.md +117 -117
  526. package/skills/sophnet-video-generate/src/scripts/gen_video.py +321 -321
  527. package/skills/sophnet-xlsx/skill.json +20 -20
  528. package/skills/sophnet-xlsx/src/SKILL.md +399 -399
  529. package/skills/sophnet-xlsx/src/pyproject.toml +11 -11
  530. package/skills/sophnet-xlsx/src/scripts/ensure_uv_env.sh +68 -68
  531. package/skills/sophnet-xlsx/src/scripts/office/helpers/merge_runs.py +199 -199
  532. package/skills/sophnet-xlsx/src/scripts/office/helpers/simplify_redlines.py +197 -197
  533. package/skills/sophnet-xlsx/src/scripts/office/pack.py +159 -159
  534. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -1499
  535. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -146
  536. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -1085
  537. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -11
  538. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -3081
  539. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -23
  540. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -185
  541. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -287
  542. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -1676
  543. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -28
  544. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -144
  545. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -174
  546. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -25
  547. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -18
  548. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -59
  549. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -56
  550. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -195
  551. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -582
  552. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -25
  553. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -4439
  554. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -570
  555. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -509
  556. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -12
  557. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -108
  558. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -96
  559. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -3646
  560. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -116
  561. package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -42
  562. package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -50
  563. package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -49
  564. package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -33
  565. package/skills/sophnet-xlsx/src/scripts/office/schemas/mce/mc.xsd +75 -75
  566. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-2010.xsd +560 -560
  567. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-2012.xsd +67 -67
  568. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-2018.xsd +14 -14
  569. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -20
  570. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -13
  571. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -4
  572. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -8
  573. package/skills/sophnet-xlsx/src/scripts/office/soffice.py +183 -183
  574. package/skills/sophnet-xlsx/src/scripts/office/unpack.py +132 -132
  575. package/skills/sophnet-xlsx/src/scripts/office/validate.py +111 -111
  576. package/skills/sophnet-xlsx/src/scripts/office/validators/__init__.py +15 -15
  577. package/skills/sophnet-xlsx/src/scripts/office/validators/base.py +847 -847
  578. package/skills/sophnet-xlsx/src/scripts/office/validators/docx.py +446 -446
  579. package/skills/sophnet-xlsx/src/scripts/office/validators/pptx.py +275 -275
  580. package/skills/sophnet-xlsx/src/scripts/office/validators/redlining.py +247 -247
  581. package/skills/sophnet-xlsx/src/scripts/recalc.py +184 -184
  582. package/skills/sophnet-xlsx/src/scripts/upload_file.sh +96 -96
  583. package/skills/sophnet-xlsx/src/uv.lock +319 -319
  584. package/skills/ui-ux-pro-max/skill.json +20 -20
  585. package/skills/ui-ux-pro-max/src/SKILL.md +377 -377
  586. package/skills/ui-ux-pro-max/src/data/icons.csv +101 -101
  587. package/skills/ui-ux-pro-max/src/data/react-performance.csv +45 -45
  588. package/skills/ui-ux-pro-max/src/data/stacks/astro.csv +54 -54
  589. package/skills/ui-ux-pro-max/src/data/stacks/jetpack-compose.csv +53 -53
  590. package/skills/ui-ux-pro-max/src/data/stacks/nuxt-ui.csv +51 -51
  591. package/skills/ui-ux-pro-max/src/data/stacks/nuxtjs.csv +59 -59
  592. package/skills/ui-ux-pro-max/src/data/stacks/shadcn.csv +61 -61
  593. package/skills/ui-ux-pro-max/src/data/typography.csv +57 -57
  594. package/skills/ui-ux-pro-max/src/data/ui-reasoning.csv +101 -101
  595. package/skills/ui-ux-pro-max/src/data/web-interface.csv +31 -31
  596. package/skills/ui-ux-pro-max/src/scripts/core.py +253 -253
  597. package/skills/ui-ux-pro-max/src/scripts/design_system.py +1067 -1067
  598. package/skills/video-understand/skill.json +20 -20
  599. package/skills/video-understand/src/SKILL.md +79 -79
  600. package/skills/video-understand/src/scripts/video_understand.py +204 -204
  601. package/skills/weather/skill.json +19 -19
  602. package/skills/weather/src/SKILL.md +112 -112
  603. package/skills/web-scraper/skill.json +20 -20
  604. package/skills/web-scraper/src/SKILL.md +101 -101
  605. package/skills/web-scraper/src/scripts/scrape.py +270 -270
  606. package/skills/website-builder/skill.json +20 -20
  607. package/skills/website-builder/src/SKILL.md +266 -266
  608. package/skills/website-builder/src/scripts/deploy_site.sh +46 -46
  609. package/skills/wechat-article-publisher/skill.json +20 -20
  610. package/skills/wechat-article-publisher/src/SKILL.md +60 -60
  611. package/skills/wechat-article-publisher/src/config.json +6 -6
  612. package/skills/wechat-article-publisher/src/pyproject.toml +12 -12
  613. package/skills/wechat-article-publisher/src/scripts/publish_wechat.py +825 -825
  614. package/skills/xiaohongshu/skill.json +20 -20
  615. package/skills/xiaohongshu/src/SKILL.md +91 -91
  616. package/skills/xiaohongshu/src/_meta.json +5 -5
  617. package/skills/xiaohongshu/src/assets/card.html +216 -216
  618. package/skills/xiaohongshu/src/assets/cover.html +82 -82
  619. package/skills/xiaohongshu/src/assets/example.md +84 -84
  620. package/skills/xiaohongshu/src/assets/styles.css +318 -318
  621. package/skills/xiaohongshu/src/scripts/render_xhs_v2.py +737 -737
  622. package/skills/xiaohongshu/src/scripts/sign_server.py +158 -158
  623. package/skills/xiaohongshu/src/scripts/stealth.min.js +6 -6
  624. package/skills/xiaohongshu/src/scripts/xhs_tool.py +186 -186
  625. package/skills/xiaohongshu/src/workflow.py +185 -185
  626. package/src/commands/agent.js +112 -112
  627. package/src/commands/download.js +101 -101
  628. package/src/commands/info.js +58 -58
  629. package/src/commands/list.js +71 -71
  630. package/src/utils/agents.js +36 -36
  631. package/src/utils/config.js +22 -22
  632. package/src/utils/paths.js +31 -31
  633. package/src/utils/versions.js +57 -57
@@ -1,1844 +1,1844 @@
1
- #!/usr/bin/env python3
2
- """
3
- 库存管理脚本:支持多项目、自定义字段、CSV/XLSX 导入、出入库、库存查询、变动记录、导出。
4
- 数据存储在本地 SQLite,字段配置从用户上传的文件表头自动解析。
5
- """
6
-
7
- import argparse
8
- import csv
9
- import io
10
- import json
11
- import os
12
- import re
13
- import sqlite3
14
- import sys
15
- import time
16
- from datetime import datetime
17
- from pathlib import Path
18
-
19
- SCRIPT_DIR = Path(__file__).resolve().parent
20
- WORKSPACE_DIR = Path.home() / ".openclaw" / "workspace" / "inventory-management"
21
- DEFAULT_DB_PATH = WORKSPACE_DIR / "inventory.db"
22
- DB_PATH = Path(os.environ.get("INVENTORY_DB_PATH", str(DEFAULT_DB_PATH)))
23
-
24
- # ---------- 关键字段自动推断关键词 ----------
25
-
26
- KEY_FIELD_HINTS = ["sku", "编码", "编号", "货号", "条码", "物料号", "id", "代码", "code"]
27
- STOCK_FIELD_HINTS = ["库存", "数量", "stock", "qty", "quantity", "余量", "存量"]
28
-
29
- # ---------- 数据库初始化 ----------
30
-
31
-
32
- def get_db() -> sqlite3.Connection:
33
- DB_PATH.parent.mkdir(parents=True, exist_ok=True)
34
- conn = sqlite3.connect(str(DB_PATH))
35
- conn.row_factory = sqlite3.Row
36
- conn.execute("PRAGMA journal_mode=WAL")
37
- conn.execute("PRAGMA foreign_keys=ON")
38
- _init_tables(conn)
39
- return conn
40
-
41
-
42
- def _init_tables(conn: sqlite3.Connection) -> None:
43
- conn.executescript("""
44
- CREATE TABLE IF NOT EXISTS projects (
45
- id INTEGER PRIMARY KEY AUTOINCREMENT,
46
- name TEXT NOT NULL UNIQUE,
47
- field_config TEXT NOT NULL DEFAULT '[]',
48
- key_field TEXT NOT NULL DEFAULT '',
49
- stock_field TEXT NOT NULL DEFAULT '',
50
- low_stock_threshold INTEGER NOT NULL DEFAULT 10,
51
- created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
52
- );
53
- CREATE TABLE IF NOT EXISTS products (
54
- id INTEGER PRIMARY KEY AUTOINCREMENT,
55
- project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
56
- key_value TEXT NOT NULL,
57
- stock_qty REAL NOT NULL DEFAULT 0,
58
- data TEXT NOT NULL DEFAULT '{}',
59
- status TEXT NOT NULL DEFAULT 'active',
60
- created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
61
- updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
62
- UNIQUE(project_id, key_value)
63
- );
64
- CREATE INDEX IF NOT EXISTS idx_products_project ON products(project_id, status);
65
- CREATE TABLE IF NOT EXISTS stock_logs (
66
- id INTEGER PRIMARY KEY AUTOINCREMENT,
67
- project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
68
- key_value TEXT NOT NULL,
69
- direction TEXT NOT NULL CHECK(direction IN ('in','out')),
70
- type TEXT NOT NULL DEFAULT '',
71
- quantity REAL NOT NULL,
72
- before_qty REAL NOT NULL,
73
- after_qty REAL NOT NULL,
74
- remark TEXT NOT NULL DEFAULT '',
75
- created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
76
- );
77
- CREATE INDEX IF NOT EXISTS idx_logs_project ON stock_logs(project_id, key_value);
78
- CREATE TABLE IF NOT EXISTS stocktakes (
79
- id INTEGER PRIMARY KEY AUTOINCREMENT,
80
- project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
81
- status TEXT NOT NULL DEFAULT 'pending',
82
- created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
83
- confirmed_at TEXT
84
- );
85
- CREATE TABLE IF NOT EXISTS stocktake_items (
86
- id INTEGER PRIMARY KEY AUTOINCREMENT,
87
- stocktake_id INTEGER NOT NULL REFERENCES stocktakes(id) ON DELETE CASCADE,
88
- key_value TEXT NOT NULL,
89
- system_qty REAL NOT NULL,
90
- actual_qty REAL,
91
- diff REAL
92
- );
93
- """)
94
- conn.commit()
95
-
96
-
97
- # ---------- 文件解析 ----------
98
-
99
-
100
- def _detect_csv_encoding(filepath: str) -> str:
101
- for enc in ("utf-8-sig", "utf-8", "gbk", "gb2312", "gb18030", "latin-1"):
102
- try:
103
- with open(filepath, "r", encoding=enc) as f:
104
- f.read(4096)
105
- return enc
106
- except (UnicodeDecodeError, UnicodeError):
107
- continue
108
- return "utf-8"
109
-
110
-
111
- def parse_file(filepath: str) -> tuple[list[str], list[dict]]:
112
- """解析 CSV 或 XLSX 文件,返回 (表头列表, 数据行列表[dict])"""
113
- p = Path(filepath)
114
- if not p.is_file():
115
- raise FileNotFoundError(f"文件不存在: {filepath}")
116
- suffix = p.suffix.lower()
117
- if suffix == ".csv":
118
- return _parse_csv(filepath)
119
- elif suffix in (".xlsx", ".xls"):
120
- return _parse_xlsx(filepath)
121
- else:
122
- raise ValueError(f"不支持的文件格式: {suffix},仅支持 .csv / .xlsx")
123
-
124
-
125
- def _parse_csv(filepath: str) -> tuple[list[str], list[dict]]:
126
- enc = _detect_csv_encoding(filepath)
127
- with open(filepath, "r", encoding=enc, newline="") as f:
128
- reader = csv.DictReader(f)
129
- headers = reader.fieldnames or []
130
- headers = [h.strip() for h in headers if h and h.strip()]
131
- rows = []
132
- for row in reader:
133
- cleaned = {k.strip(): (v.strip() if isinstance(v, str) else v) for k, v in row.items() if k and k.strip()}
134
- rows.append(cleaned)
135
- return headers, rows
136
-
137
-
138
- def _parse_xlsx(filepath: str) -> tuple[list[str], list[dict]]:
139
- try:
140
- from openpyxl import load_workbook
141
- except ImportError:
142
- raise ImportError("解析 XLSX 文件需要 openpyxl,请执行: pip install openpyxl")
143
- wb = load_workbook(filepath, read_only=True, data_only=True)
144
- ws = wb.active
145
- rows_iter = ws.iter_rows(values_only=True)
146
- header_row = next(rows_iter, None)
147
- if not header_row:
148
- raise ValueError("XLSX 文件为空或无表头")
149
- headers = [str(h).strip() for h in header_row if h is not None and str(h).strip()]
150
- data = []
151
- for row in rows_iter:
152
- if all(c is None for c in row):
153
- continue
154
- record = {}
155
- for i, h in enumerate(headers):
156
- val = row[i] if i < len(row) else None
157
- record[h] = str(val).strip() if val is not None else ""
158
- data.append(record)
159
- wb.close()
160
- return headers, data
161
-
162
-
163
- # ---------- 字段推断 ----------
164
-
165
-
166
- def _guess_field(headers: list[str], hints: list[str]) -> str | None:
167
- lower_headers = {h: h.lower() for h in headers}
168
- for h, lh in lower_headers.items():
169
- for hint in hints:
170
- if hint.lower() in lh:
171
- return h
172
- return None
173
-
174
-
175
- def guess_key_field(headers: list[str]) -> str | None:
176
- return _guess_field(headers, KEY_FIELD_HINTS)
177
-
178
-
179
- def guess_stock_field(headers: list[str]) -> str | None:
180
- return _guess_field(headers, STOCK_FIELD_HINTS)
181
-
182
-
183
- def infer_field_type(values: list[str]) -> str:
184
- """从一列数据的样本推断类型: number / date / text"""
185
- samples = [v for v in values if v and v.strip()][:50]
186
- if not samples:
187
- return "text"
188
- num_count = 0
189
- date_count = 0
190
- date_re = re.compile(r"^\d{4}[-/]\d{1,2}[-/]\d{1,2}")
191
- for s in samples:
192
- s = s.strip()
193
- try:
194
- float(s.replace(",", ""))
195
- num_count += 1
196
- continue
197
- except ValueError:
198
- pass
199
- if date_re.match(s):
200
- date_count += 1
201
- if num_count > len(samples) * 0.7:
202
- return "number"
203
- if date_count > len(samples) * 0.7:
204
- return "date"
205
- return "text"
206
-
207
-
208
- NUMBER_NAME_HINTS = ["数量", "库存", "价", "金额", "成本", "重量", "amount", "price", "cost", "qty", "stock", "weight", "count"]
209
- DATE_NAME_HINTS = ["日期", "时间", "date", "time", "到期", "生产", "保质期"]
210
-
211
-
212
- def infer_type_from_name(field_name: str) -> str:
213
- """仅根据字段名推断类型(用于自然语言建表,无数据样本时)。"""
214
- lower = field_name.lower()
215
- for hint in NUMBER_NAME_HINTS:
216
- if hint in lower:
217
- return "number"
218
- for hint in DATE_NAME_HINTS:
219
- if hint in lower:
220
- return "date"
221
- return "text"
222
-
223
-
224
- def parse_fields_arg(fields_str: str) -> list[dict]:
225
- """解析 --fields 参数,支持两种格式:
226
- 1. 简单格式: "SKU,名称,颜色,库存数量,售价" 或 "SKU:text,库存数量:number"
227
- 2. JSON 格式: '[{"name":"SKU","type":"text"},...]'
228
- 返回 [{"name":..., "type":...}, ...]
229
- """
230
- s = fields_str.strip()
231
- if s.startswith("["):
232
- try:
233
- items = json.loads(s)
234
- except json.JSONDecodeError as e:
235
- raise ValueError(
236
- f"--fields JSON 解析失败: {e}。"
237
- f"JSON 格式示例: '[{{\"name\":\"SKU\",\"type\":\"text\"}},{{\"name\":\"库存\",\"type\":\"number\"}}]'"
238
- )
239
- if not isinstance(items, list):
240
- raise ValueError("--fields JSON 必须是数组格式 [...]")
241
- result = []
242
- for i, item in enumerate(items):
243
- if isinstance(item, str):
244
- result.append({"name": item.strip(), "type": infer_type_from_name(item.strip())})
245
- elif isinstance(item, dict):
246
- name = item.get("name", "").strip()
247
- if not name:
248
- raise ValueError(f"--fields JSON 第{i+1}项缺少 name 字段")
249
- ft = item.get("type", "").strip() or infer_type_from_name(name)
250
- if ft not in ("text", "number", "date"):
251
- raise ValueError(f"字段「{name}」的 type「{ft}」无效,可选: text / number / date")
252
- result.append({"name": name, "type": ft})
253
- else:
254
- raise ValueError(f"--fields JSON 第{i+1}项格式不正确,应为对象 {{\"name\":...,\"type\":...}} 或字符串")
255
- return result
256
-
257
- parts = [p.strip() for p in s.split(",") if p.strip()]
258
- if not parts:
259
- raise ValueError("--fields 不能为空")
260
- result = []
261
- for part in parts:
262
- if ":" in part:
263
- name, ft = part.rsplit(":", 1)
264
- name = name.strip()
265
- ft = ft.strip().lower()
266
- if ft not in ("text", "number", "date"):
267
- raise ValueError(
268
- f"字段「{name}」的类型「{ft}」无效,可选: text / number / date。"
269
- f"示例: \"SKU:text,库存数量:number,生产日期:date\""
270
- )
271
- else:
272
- name = part.strip()
273
- ft = infer_type_from_name(name)
274
- if not name:
275
- continue
276
- result.append({"name": name, "type": ft})
277
- return result
278
-
279
-
280
- # ---------- 项目辅助 ----------
281
-
282
-
283
- def resolve_project(conn: sqlite3.Connection, project_name: str | None) -> sqlite3.Row:
284
- if project_name:
285
- row = conn.execute("SELECT * FROM projects WHERE name = ?", (project_name,)).fetchone()
286
- if not row:
287
- names = _list_project_names(conn)
288
- msg = f"项目「{project_name}」不存在"
289
- if names:
290
- msg += f",已有项目: {names}"
291
- else:
292
- msg += ",当前无任何项目,请先用 init 命令创建"
293
- raise ValueError(msg)
294
- return row
295
- rows = conn.execute("SELECT * FROM projects").fetchall()
296
- if len(rows) == 0:
297
- raise ValueError(
298
- "尚无任何项目,请先用 init 命令创建项目。"
299
- "用法: init --project <项目名> --file <CSV或XLSX文件路径>"
300
- )
301
- if len(rows) == 1:
302
- return rows[0]
303
- names = [r["name"] for r in rows]
304
- raise ValueError(
305
- f"存在多个项目 {names},请用 --project <项目名> 指定要操作的项目"
306
- )
307
-
308
-
309
- def _get_field_config(project: sqlite3.Row) -> list[dict]:
310
- return json.loads(project["field_config"] or "[]")
311
-
312
-
313
- # ---------- 终端表格打印 ----------
314
-
315
-
316
- def _display_width(s: str) -> int:
317
- w = 0
318
- for c in s:
319
- w += 2 if "\u4e00" <= c <= "\u9fff" or "\u3000" <= c <= "\u303f" or "\uff00" <= c <= "\uffef" else 1
320
- return w
321
-
322
-
323
- def _pad(s: str, width: int, align: str = "left") -> str:
324
- s = s or ""
325
- cur = _display_width(s)
326
- if cur >= width:
327
- return s
328
- pad = " " * (width - cur)
329
- return (s + pad) if align == "left" else (pad + s)
330
-
331
-
332
- def print_table(headers: list[str], rows: list[list[str]], aligns: list[str] | None = None) -> None:
333
- if not headers:
334
- return
335
- if aligns is None:
336
- aligns = ["left"] * len(headers)
337
- col_widths = [max(_display_width(h) + 2, 6) for h in headers]
338
- for row in rows:
339
- for i, cell in enumerate(row):
340
- if i < len(col_widths):
341
- col_widths[i] = max(col_widths[i], _display_width(str(cell)) + 2)
342
- cap = 40
343
- col_widths = [min(w, cap) for w in col_widths]
344
- sep = " "
345
- print(sep.join(_pad(h, col_widths[i], aligns[i]) for i, h in enumerate(headers)))
346
- print(sep.join("-" * w for w in col_widths))
347
- for row in rows:
348
- cells = []
349
- for i in range(len(headers)):
350
- val = str(row[i]) if i < len(row) else ""
351
- if _display_width(val) > col_widths[i]:
352
- val = val[: col_widths[i] - 1] + "…"
353
- cells.append(_pad(val, col_widths[i], aligns[i] if i < len(aligns) else "left"))
354
- print(sep.join(cells))
355
-
356
-
357
- def _ok(data: dict | None = None, **kwargs) -> None:
358
- out = {"ok": True}
359
- if data:
360
- out.update(data)
361
- out.update(kwargs)
362
- print(json.dumps(out, ensure_ascii=False, indent=2))
363
-
364
-
365
- def _fail(error: str, *, usage: str | None = None, hint: str | None = None,
366
- valid_values: list | dict | None = None, example: str | None = None, **kwargs) -> None:
367
- """输出结构化错误信息,帮助调用方(LLM)快速定位问题并修正参数。
368
- - error: 错误描述
369
- - usage: 正确的命令用法示例
370
- - hint: 修复建议
371
- - valid_values: 合法取值范围(如项目列表、字段列表、商品标识列表)
372
- - example: 正确参数的完整示例
373
- """
374
- out: dict = {"ok": False, "error": error}
375
- if usage:
376
- out["usage"] = usage
377
- if hint:
378
- out["hint"] = hint
379
- if valid_values is not None:
380
- out["valid_values"] = valid_values
381
- if example:
382
- out["example"] = example
383
- out.update(kwargs)
384
- print(json.dumps(out, ensure_ascii=False))
385
-
386
-
387
- # ---------- 校验辅助 ----------
388
-
389
-
390
- def _list_project_names(conn: sqlite3.Connection) -> list[str]:
391
- return [r["name"] for r in conn.execute("SELECT name FROM projects ORDER BY name").fetchall()]
392
-
393
-
394
- def _list_product_keys(conn: sqlite3.Connection, project_id: int, limit: int = 20) -> list[str]:
395
- rows = conn.execute(
396
- "SELECT key_value FROM products WHERE project_id = ? AND status = 'active' ORDER BY key_value LIMIT ?",
397
- (project_id, limit),
398
- ).fetchall()
399
- return [r["key_value"] for r in rows]
400
-
401
-
402
- def _product_not_found(conn: sqlite3.Connection, proj: sqlite3.Row, key: str) -> None:
403
- """商品不存在时,输出详细错误:列出该项目已有的商品标识供参考。"""
404
- pid = proj["id"]
405
- keys = _list_product_keys(conn, pid)
406
- total = conn.execute(
407
- "SELECT COUNT(*) as c FROM products WHERE project_id = ? AND status = 'active'", (pid,)
408
- ).fetchone()["c"]
409
- suffix = f"(共 {total} 条,仅展示前 {len(keys)} 条)" if total > len(keys) else ""
410
- _fail(
411
- f"商品「{key}」在项目「{proj['name']}」中不存在",
412
- hint=f"请检查商品标识是否正确。该项目的唯一标识字段为「{proj['key_field']}」",
413
- valid_values={"existing_keys": keys, "note": suffix} if keys else None,
414
- usage=f"stock-query --project {proj['name']} # 查看全部商品",
415
- )
416
-
417
-
418
- def _validate_json_data(data: dict, proj: sqlite3.Row, purpose: str = "add") -> str | None:
419
- """校验 JSON 数据的字段是否与项目字段配置匹配。返回 None 表示通过,否则返回错误描述。"""
420
- fields = _get_field_config(proj)
421
- config_names = {f["name"] for f in fields}
422
- unknown = [k for k in data.keys() if k not in config_names]
423
- if unknown:
424
- return (f"JSON 中包含未知字段 {unknown},"
425
- f"该项目可用字段为: {sorted(config_names)}")
426
- return None
427
-
428
-
429
- def _validate_date(date_str: str, param_name: str) -> str | None:
430
- """校验日期格式 YYYY-MM-DD,返回 None 通过,否则返回错误描述。"""
431
- if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str):
432
- return f"参数 {param_name} 日期格式不正确:「{date_str}」,正确格式为 YYYY-MM-DD(如 2026-01-15)"
433
- try:
434
- datetime.strptime(date_str, "%Y-%m-%d")
435
- except ValueError:
436
- return f"参数 {param_name} 日期无效:「{date_str}」,请输入合法日期(如 2026-01-15)"
437
- return None
438
-
439
-
440
- def _validate_filter(filter_str: str, proj: sqlite3.Row) -> str | None:
441
- """校验 --filter 格式和字段是否存在。"""
442
- if "=" not in filter_str:
443
- return (f"--filter 格式不正确:「{filter_str}」,正确格式为「字段名=值」(如 --filter 分类=配件)")
444
- fk, _ = filter_str.split("=", 1)
445
- fk = fk.strip()
446
- fields = _get_field_config(proj)
447
- config_names = [f["name"] for f in fields]
448
- if fk not in config_names:
449
- return f"--filter 中的字段名「{fk}」不存在,该项目可用字段为: {config_names}"
450
- return None
451
-
452
-
453
- # ========== 命令实现 ==========
454
-
455
- # ---------- create(自然语言建表) ----------
456
-
457
-
458
- def cmd_create(args: argparse.Namespace) -> int:
459
- """通过直接指定字段来创建项目(无需上传文件)。适用于用户用自然语言描述表结构的场景。"""
460
- project_name = args.project
461
- fields_str = args.fields
462
- key_field = args.key_field
463
- stock_field = args.stock_field
464
- threshold = getattr(args, "low_stock_threshold", 10)
465
-
466
- if not project_name or not project_name.strip():
467
- _fail("--project 项目名称不能为空",
468
- usage='create --project <项目名> --fields "字段1,字段2,..." --key-field <标识字段> --stock-field <库存字段>',
469
- example='create --project 服装仓库 --fields "SKU,名称,颜色,尺码,库存数量,售价" --key-field SKU --stock-field 库存数量')
470
- return 1
471
-
472
- if not fields_str or not fields_str.strip():
473
- _fail("--fields 字段定义不能为空",
474
- hint="支持两种格式:\n"
475
- " 1. 简单格式(逗号分隔): \"SKU,名称,颜色,库存数量,售价\" 或 \"SKU:text,库存数量:number\"\n"
476
- " 2. JSON 格式: '[{\"name\":\"SKU\",\"type\":\"text\"},{\"name\":\"库存数量\",\"type\":\"number\"}]'",
477
- example='create --project 服装仓库 --fields "SKU,名称,颜色,尺码,库存数量,售价" --key-field SKU --stock-field 库存数量')
478
- return 1
479
-
480
- try:
481
- field_list = parse_fields_arg(fields_str)
482
- except ValueError as e:
483
- _fail(str(e),
484
- hint="--fields 支持两种格式:\n"
485
- " 1. 简单: \"SKU,名称,颜色,库存数量:number,售价:number\"\n"
486
- " 2. JSON: '[{\"name\":\"SKU\",\"type\":\"text\"},...]'\n"
487
- " 类型可选: text(默认) / number / date,不指定时根据字段名自动推断")
488
- return 1
489
-
490
- if len(field_list) < 2:
491
- _fail("至少需要 2 个字段(一个唯一标识 + 一个库存数量)",
492
- example='--fields "SKU,名称,库存数量,售价"')
493
- return 1
494
-
495
- field_names = [f["name"] for f in field_list]
496
- dup = [n for n in field_names if field_names.count(n) > 1]
497
- if dup:
498
- _fail(f"字段名重复: {list(set(dup))},每个字段名必须唯一")
499
- return 1
500
-
501
- if not key_field:
502
- key_field = guess_key_field(field_names)
503
- if not stock_field:
504
- stock_field = guess_stock_field(field_names)
505
-
506
- guessed = []
507
- if key_field and not args.key_field:
508
- guessed.append(f"唯一标识字段 → {key_field}")
509
- if stock_field and not args.stock_field:
510
- guessed.append(f"库存数量字段 → {stock_field}")
511
-
512
- if not key_field:
513
- _fail("无法自动推断唯一标识字段,请用 --key-field 指定",
514
- hint="请从字段列表中选择一个作为商品唯一标识(如 SKU、编号)",
515
- valid_values=field_names,
516
- example=f'create --project {project_name} --fields "{fields_str}" --key-field {field_names[0]} --stock-field <库存字段>')
517
- return 1
518
- if not stock_field:
519
- _fail("无法自动推断库存数量字段,请用 --stock-field 指定",
520
- hint="请从字段列表中选择一个作为库存数量字段(值须为数字)",
521
- valid_values=field_names,
522
- example=f'create --project {project_name} --fields "{fields_str}" --key-field {key_field} --stock-field {field_names[-1]}')
523
- return 1
524
- if key_field not in field_names:
525
- _fail(f"--key-field「{key_field}」不在字段列表中",
526
- valid_values=field_names)
527
- return 1
528
- if stock_field not in field_names:
529
- _fail(f"--stock-field「{stock_field}」不在字段列表中",
530
- valid_values=field_names)
531
- return 1
532
- if key_field == stock_field:
533
- _fail(f"唯一标识字段和库存数量字段不能相同(都是「{key_field}」)",
534
- valid_values=field_names)
535
- return 1
536
-
537
- for f in field_list:
538
- if f["name"] == key_field:
539
- f["role"] = "key"
540
- elif f["name"] == stock_field:
541
- f["role"] = "stock"
542
- f["type"] = "number"
543
-
544
- conn = get_db()
545
- existing = conn.execute("SELECT id FROM projects WHERE name = ?", (project_name,)).fetchone()
546
- if existing:
547
- _fail(f"项目「{project_name}」已存在,不能重复创建",
548
- hint="若要向已有项目添加商品,请使用 product-add 命令",
549
- valid_values={"existing_projects": _list_project_names(conn)})
550
- conn.close()
551
- return 1
552
-
553
- conn.execute(
554
- "INSERT INTO projects (name, field_config, key_field, stock_field, low_stock_threshold) VALUES (?,?,?,?,?)",
555
- (project_name, json.dumps(field_list, ensure_ascii=False), key_field, stock_field, threshold),
556
- )
557
- conn.commit()
558
- conn.close()
559
-
560
- print(f"项目「{project_name}」创建成功(空表,无初始数据)")
561
- if guessed:
562
- print("自动推断:" + ",".join(guessed))
563
- print(f"字段({len(field_list)}个):")
564
- for f in field_list:
565
- role = f.get("role", "")
566
- role_label = " ← 唯一标识" if role == "key" else (" ← 库存数量" if role == "stock" else "")
567
- print(f" - {f['name']} ({f['type']}){role_label}")
568
- print(f"\n可通过以下方式添加商品:")
569
- print(f" 1. 手动添加: product-add --project {project_name} --data '<JSON>'")
570
- print(f" 2. 文件导入: import --project {project_name} --file <CSV/XLSX路径>")
571
- _ok(project=project_name, fields=[f["name"] for f in field_list],
572
- key_field=key_field, stock_field=stock_field, product_count=0)
573
- return 0
574
-
575
-
576
- # ---------- init ----------
577
-
578
-
579
- def cmd_init(args: argparse.Namespace) -> int:
580
- project_name = args.project
581
- filepath = args.file
582
- key_field = getattr(args, "key_field", None)
583
- stock_field = getattr(args, "stock_field", None)
584
- threshold = getattr(args, "low_stock_threshold", 10)
585
-
586
- if not project_name or not project_name.strip():
587
- _fail("--project 项目名称不能为空",
588
- usage="init --project <项目名> --file <文件路径>",
589
- example='init --project 原材料仓库 --file /path/to/data.csv')
590
- return 1
591
-
592
- if not filepath or not filepath.strip():
593
- _fail("--file 文件路径不能为空",
594
- usage="init --project <项目名> --file <CSV或XLSX文件路径>",
595
- example='init --project 原材料仓库 --file /path/to/data.csv')
596
- return 1
597
-
598
- p = Path(filepath)
599
- if not p.exists():
600
- _fail(f"文件不存在: {filepath}",
601
- hint="请检查文件路径是否正确,路径需为绝对路径或相对于当前工作目录的路径",
602
- usage="init --project <项目名> --file <CSV或XLSX文件路径>")
603
- return 1
604
- if p.suffix.lower() not in (".csv", ".xlsx", ".xls"):
605
- _fail(f"不支持的文件格式: {p.suffix}",
606
- hint="仅支持 .csv 和 .xlsx 格式",
607
- example="init --project 仓库A --file /path/to/data.csv")
608
- return 1
609
-
610
- try:
611
- headers, data = parse_file(filepath)
612
- except Exception as e:
613
- _fail(f"文件解析失败: {e}",
614
- hint="请确认文件格式正确且非空。CSV 文件第一行为表头,XLSX 需要 openpyxl(pip install openpyxl)")
615
- return 1
616
-
617
- if not headers:
618
- _fail("文件无有效表头(第一行为空或无可识别的列名)",
619
- hint="CSV/XLSX 文件的第一行必须为列名表头,如: SKU编码,商品名称,库存数量,...")
620
- return 1
621
-
622
- if len(data) == 0:
623
- _fail("文件有表头但无数据行",
624
- hint=f"检测到表头列: {headers},但文件中没有数据行,请确认文件内容")
625
- return 1
626
-
627
- if not key_field:
628
- key_field = guess_key_field(headers)
629
- if not stock_field:
630
- stock_field = guess_stock_field(headers)
631
-
632
- guessed = []
633
- if key_field and getattr(args, "key_field", None) is None:
634
- guessed.append(f"唯一标识字段 → {key_field}")
635
- if stock_field and getattr(args, "stock_field", None) is None:
636
- guessed.append(f"库存数量字段 → {stock_field}")
637
-
638
- if not key_field:
639
- _fail("无法自动推断唯一标识字段(如 SKU/编号),请用 --key-field 指定",
640
- hint="请从以下列名中选择一个作为商品唯一标识",
641
- valid_values=headers,
642
- example=f'init --project {project_name} --file {filepath} --key-field {headers[0]} --stock-field <库存列名>')
643
- return 1
644
- if not stock_field:
645
- _fail("无法自动推断库存数量字段(如 库存/数量),请用 --stock-field 指定",
646
- hint="请从以下列名中选择一个作为库存数量字段(该字段的值须为数字)",
647
- valid_values=headers,
648
- example=f'init --project {project_name} --file {filepath} --key-field {key_field} --stock-field {headers[-1]}')
649
- return 1
650
- if key_field not in headers:
651
- _fail(f"指定的唯一标识字段「{key_field}」不在文件表头中",
652
- hint="--key-field 的值必须是文件表头中的某一列名",
653
- valid_values=headers)
654
- return 1
655
- if stock_field not in headers:
656
- _fail(f"指定的库存数量字段「{stock_field}」不在文件表头中",
657
- hint="--stock-field 的值必须是文件表头中的某一列名",
658
- valid_values=headers)
659
- return 1
660
- if key_field == stock_field:
661
- _fail(f"唯一标识字段和库存数量字段不能相同(都是「{key_field}」)",
662
- hint="--key-field 和 --stock-field 必须是不同的列",
663
- valid_values=headers)
664
- return 1
665
-
666
- col_values: dict[str, list[str]] = {h: [] for h in headers}
667
- for row in data:
668
- for h in headers:
669
- col_values[h].append(str(row.get(h, "")))
670
-
671
- field_config = []
672
- for h in headers:
673
- ft = infer_field_type(col_values[h])
674
- entry: dict = {"name": h, "type": ft}
675
- if h == key_field:
676
- entry["role"] = "key"
677
- elif h == stock_field:
678
- entry["role"] = "stock"
679
- entry["type"] = "number"
680
- field_config.append(entry)
681
-
682
- conn = get_db()
683
- existing = conn.execute("SELECT id FROM projects WHERE name = ?", (project_name,)).fetchone()
684
- if existing:
685
- _fail(f"项目「{project_name}」已存在,不能重复创建",
686
- hint="若要向该项目追加/更新数据,请使用 import 命令",
687
- usage=f'import --project {project_name} --file <新的CSV文件路径>',
688
- valid_values={"existing_projects": _list_project_names(conn)})
689
- conn.close()
690
- return 1
691
-
692
- conn.execute(
693
- "INSERT INTO projects (name, field_config, key_field, stock_field, low_stock_threshold) VALUES (?,?,?,?,?)",
694
- (project_name, json.dumps(field_config, ensure_ascii=False), key_field, stock_field, threshold),
695
- )
696
- conn.commit()
697
- proj = conn.execute("SELECT * FROM projects WHERE name = ?", (project_name,)).fetchone()
698
- pid = proj["id"]
699
-
700
- inserted = 0
701
- skipped = []
702
- for i, row in enumerate(data, start=2):
703
- kv = str(row.get(key_field, "")).strip()
704
- if not kv:
705
- skipped.append(f"第{i}行: 唯一标识为空")
706
- continue
707
- sq_raw = str(row.get(stock_field, "0")).strip().replace(",", "")
708
- try:
709
- sq = float(sq_raw) if sq_raw else 0
710
- except ValueError:
711
- sq = 0
712
- try:
713
- conn.execute(
714
- "INSERT INTO products (project_id, key_value, stock_qty, data) VALUES (?,?,?,?)",
715
- (pid, kv, sq, json.dumps(row, ensure_ascii=False)),
716
- )
717
- inserted += 1
718
- except sqlite3.IntegrityError:
719
- skipped.append(f"第{i}行: 标识「{kv}」重复")
720
- conn.commit()
721
- conn.close()
722
-
723
- print(f"项目「{project_name}」创建成功")
724
- if guessed:
725
- print("自动推断:" + ",".join(guessed))
726
- print(f"字段({len(field_config)}个): {', '.join(h for h in headers)}")
727
- print(f"导入: 成功 {inserted} 条" + (f",跳过 {len(skipped)} 条" if skipped else ""))
728
- if skipped:
729
- for s in skipped[:10]:
730
- print(f" - {s}")
731
- _ok(project=project_name, imported=inserted, skipped=len(skipped), fields=[f["name"] for f in field_config],
732
- key_field=key_field, stock_field=stock_field)
733
- return 0
734
-
735
-
736
- # ---------- import ----------
737
-
738
-
739
- def cmd_import(args: argparse.Namespace) -> int:
740
- if not getattr(args, "file", None) or not args.file.strip():
741
- _fail("--file 文件路径不能为空",
742
- usage="import --project <项目名> --file <CSV或XLSX文件路径>")
743
- return 1
744
-
745
- conn = get_db()
746
- try:
747
- proj = resolve_project(conn, args.project)
748
- except ValueError as e:
749
- _fail(str(e), usage="import --project <项目名> --file <文件路径>",
750
- valid_values={"existing_projects": _list_project_names(conn)})
751
- conn.close()
752
- return 1
753
-
754
- p = Path(args.file)
755
- if not p.exists():
756
- _fail(f"文件不存在: {args.file}",
757
- hint="请检查文件路径是否正确")
758
- conn.close()
759
- return 1
760
-
761
- try:
762
- headers, data = parse_file(args.file)
763
- except Exception as e:
764
- _fail(f"文件解析失败: {e}")
765
- conn.close()
766
- return 1
767
-
768
- pid = proj["id"]
769
- key_field = proj["key_field"]
770
- stock_field = proj["stock_field"]
771
- config_fields = {f["name"] for f in _get_field_config(proj)}
772
-
773
- unknown = [h for h in headers if h not in config_fields]
774
- if unknown:
775
- _fail(f"文件中存在项目「{proj['name']}」未定义的字段: {unknown}",
776
- hint="导入文件的列名必须与项目已有字段一致(允许缺列,不允许多出新列)。"
777
- "如需新增字段请先用 field-add 命令",
778
- valid_values={"project_fields": sorted(config_fields), "file_unknown_fields": unknown},
779
- usage=f"field-add --project {proj['name']} --name <新字段名> --type text|number|date")
780
- conn.close()
781
- return 1
782
-
783
- if key_field not in headers:
784
- _fail(f"文件缺少唯一标识字段「{key_field}」",
785
- hint=f"导入文件的表头中必须包含项目的唯一标识字段「{key_field}」",
786
- valid_values={"required_field": key_field, "file_headers": headers})
787
- conn.close()
788
- return 1
789
-
790
- inserted = 0
791
- updated = 0
792
- skipped = []
793
- for i, row in enumerate(data, start=2):
794
- kv = str(row.get(key_field, "")).strip()
795
- if not kv:
796
- skipped.append(f"第{i}行: 唯一标识为空")
797
- continue
798
- sq_raw = str(row.get(stock_field, "0")).strip().replace(",", "") if stock_field in row else None
799
- existing = conn.execute(
800
- "SELECT id, data FROM products WHERE project_id = ? AND key_value = ? AND status = 'active'",
801
- (pid, kv),
802
- ).fetchone()
803
- if existing:
804
- old_data = json.loads(existing["data"] or "{}")
805
- old_data.update(row)
806
- sq = float(sq_raw) if sq_raw else float(str(old_data.get(stock_field, 0)).replace(",", "") or 0)
807
- conn.execute(
808
- "UPDATE products SET data = ?, stock_qty = ?, updated_at = datetime('now','localtime') WHERE id = ?",
809
- (json.dumps(old_data, ensure_ascii=False), sq, existing["id"]),
810
- )
811
- updated += 1
812
- else:
813
- sq = float(sq_raw) if sq_raw else 0
814
- conn.execute(
815
- "INSERT INTO products (project_id, key_value, stock_qty, data) VALUES (?,?,?,?)",
816
- (pid, kv, sq, json.dumps(row, ensure_ascii=False)),
817
- )
818
- inserted += 1
819
- conn.commit()
820
- conn.close()
821
- print(f"导入完成: 新增 {inserted} 条,更新 {updated} 条" + (f",跳过 {len(skipped)} 条" if skipped else ""))
822
- if skipped:
823
- for s in skipped[:10]:
824
- print(f" - {s}")
825
- _ok(inserted=inserted, updated=updated, skipped=len(skipped))
826
- return 0
827
-
828
-
829
- # ---------- project-list ----------
830
-
831
-
832
- def cmd_project_list(args: argparse.Namespace) -> int:
833
- conn = get_db()
834
- projects = conn.execute("SELECT * FROM projects ORDER BY created_at").fetchall()
835
- if not projects:
836
- print("暂无项目,请先用 init 命令创建。")
837
- conn.close()
838
- return 0
839
- rows = []
840
- for p in projects:
841
- cnt = conn.execute(
842
- "SELECT COUNT(*) as c FROM products WHERE project_id = ? AND status = 'active'", (p["id"],)
843
- ).fetchone()["c"]
844
- fields = _get_field_config(p)
845
- rows.append([p["name"], str(cnt), str(len(fields)), p["key_field"], p["stock_field"], p["created_at"]])
846
- conn.close()
847
- print_table(["项目名称", "商品数", "字段数", "标识字段", "库存字段", "创建时间"], rows)
848
- return 0
849
-
850
-
851
- # ---------- project-delete ----------
852
-
853
-
854
- def cmd_project_delete(args: argparse.Namespace) -> int:
855
- conn = get_db()
856
- try:
857
- proj = resolve_project(conn, args.project)
858
- except ValueError as e:
859
- _fail(str(e), usage="project-delete --project <项目名>",
860
- valid_values={"existing_projects": _list_project_names(conn)})
861
- conn.close()
862
- return 1
863
- pid = proj["id"]
864
- cnt = conn.execute(
865
- "SELECT COUNT(*) as c FROM products WHERE project_id = ? AND status = 'active'", (pid,)
866
- ).fetchone()["c"]
867
- conn.execute("DELETE FROM products WHERE project_id = ?", (pid,))
868
- conn.execute("DELETE FROM stock_logs WHERE project_id = ?", (pid,))
869
- conn.execute("DELETE FROM stocktake_items WHERE stocktake_id IN (SELECT id FROM stocktakes WHERE project_id = ?)", (pid,))
870
- conn.execute("DELETE FROM stocktakes WHERE project_id = ?", (pid,))
871
- conn.execute("DELETE FROM projects WHERE id = ?", (pid,))
872
- conn.commit()
873
- conn.close()
874
- print(f"项目「{proj['name']}」已删除(含 {cnt} 条商品及所有变动记录)")
875
- _ok(project=proj["name"], deleted_products=cnt)
876
- return 0
877
-
878
-
879
- # ---------- field-list ----------
880
-
881
-
882
- def cmd_field_list(args: argparse.Namespace) -> int:
883
- conn = get_db()
884
- try:
885
- proj = resolve_project(conn, args.project)
886
- except ValueError as e:
887
- _fail(str(e), usage="field-list --project <项目名>",
888
- valid_values={"existing_projects": _list_project_names(conn)})
889
- conn.close()
890
- return 1
891
- conn.close()
892
- fields = _get_field_config(proj)
893
- rows = []
894
- for f in fields:
895
- role = f.get("role", "")
896
- role_zh = {"key": "唯一标识", "stock": "库存数量"}.get(role, "—")
897
- rows.append([f["name"], f.get("type", "text"), role_zh])
898
- print(f"项目「{proj['name']}」字段配置:")
899
- print_table(["字段名", "类型", "角色"], rows)
900
- return 0
901
-
902
-
903
- # ---------- field-add ----------
904
-
905
-
906
- def cmd_field_add(args: argparse.Namespace) -> int:
907
- conn = get_db()
908
- try:
909
- proj = resolve_project(conn, args.project)
910
- except ValueError as e:
911
- _fail(str(e), usage="field-add --project <项目名> --name <字段名> --type text|number|date",
912
- valid_values={"existing_projects": _list_project_names(conn)})
913
- conn.close()
914
- return 1
915
-
916
- if not getattr(args, "name", None) or not args.name.strip():
917
- _fail("--name 字段名不能为空",
918
- usage="field-add --project <项目名> --name <字段名> --type text|number|date",
919
- example=f"field-add --project {proj['name']} --name 保质期 --type date")
920
- conn.close()
921
- return 1
922
-
923
- fields = _get_field_config(proj)
924
- existing_names = {f["name"] for f in fields}
925
- if args.name in existing_names:
926
- _fail(f"字段「{args.name}」在项目「{proj['name']}」中已存在",
927
- valid_values={"existing_fields": sorted(existing_names)})
928
- conn.close()
929
- return 1
930
- fields.append({"name": args.name, "type": args.type or "text"})
931
- conn.execute("UPDATE projects SET field_config = ? WHERE id = ?",
932
- (json.dumps(fields, ensure_ascii=False), proj["id"]))
933
- conn.commit()
934
- conn.close()
935
- print(f"已为项目「{proj['name']}」新增字段「{args.name}」(类型: {args.type or 'text'})")
936
- _ok(field=args.name, type=args.type or "text")
937
- return 0
938
-
939
-
940
- # ---------- field-delete ----------
941
-
942
-
943
- def cmd_field_delete(args: argparse.Namespace) -> int:
944
- conn = get_db()
945
- try:
946
- proj = resolve_project(conn, args.project)
947
- except ValueError as e:
948
- _fail(str(e), usage="field-delete --project <项目名> --name <字段名>",
949
- valid_values={"existing_projects": _list_project_names(conn)})
950
- conn.close()
951
- return 1
952
-
953
- if not getattr(args, "name", None) or not args.name.strip():
954
- fields = _get_field_config(proj)
955
- _fail("--name 字段名不能为空",
956
- hint="请指定要删除的字段名",
957
- valid_values={"deletable_fields": [
958
- f["name"] for f in fields if f.get("role") not in ("key", "stock")
959
- ]},
960
- usage=f"field-delete --project {proj['name']} --name <字段名>")
961
- conn.close()
962
- return 1
963
-
964
- fields = _get_field_config(proj)
965
- field_names = [f["name"] for f in fields]
966
- deletable = [f["name"] for f in fields if f.get("role") not in ("key", "stock")]
967
-
968
- to_delete = [n.strip() for n in args.name.split(",") if n.strip()]
969
- if not to_delete:
970
- _fail("--name 解析后为空,请提供要删除的字段名",
971
- valid_values={"deletable_fields": deletable})
972
- conn.close()
973
- return 1
974
-
975
- blocked = []
976
- not_found = []
977
- will_delete = []
978
- for name in to_delete:
979
- match = next((f for f in fields if f["name"] == name), None)
980
- if not match:
981
- not_found.append(name)
982
- elif match.get("role") in ("key", "stock"):
983
- blocked.append(name)
984
- else:
985
- will_delete.append(name)
986
-
987
- if not_found:
988
- _fail(f"以下字段在项目「{proj['name']}」中不存在: {not_found}",
989
- hint="请检查字段名是否正确",
990
- valid_values={"existing_fields": field_names, "deletable_fields": deletable})
991
- conn.close()
992
- return 1
993
- if blocked:
994
- key_f = proj["key_field"]
995
- stock_f = proj["stock_field"]
996
- _fail(f"以下字段是核心字段,不能删除: {blocked}(唯一标识:「{key_f}」,库存数量:「{stock_f}」)",
997
- hint="唯一标识字段和库存数量字段是项目正常运行的基础,不可删除",
998
- valid_values={"deletable_fields": deletable})
999
- conn.close()
1000
- return 1
1001
- if not will_delete:
1002
- _fail("没有可删除的字段",
1003
- valid_values={"deletable_fields": deletable})
1004
- conn.close()
1005
- return 1
1006
-
1007
- new_fields = [f for f in fields if f["name"] not in will_delete]
1008
- conn.execute("UPDATE projects SET field_config = ? WHERE id = ?",
1009
- (json.dumps(new_fields, ensure_ascii=False), proj["id"]))
1010
-
1011
- pid = proj["id"]
1012
- products = conn.execute(
1013
- "SELECT id, data FROM products WHERE project_id = ? AND status = 'active'", (pid,)
1014
- ).fetchall()
1015
- for p in products:
1016
- data = json.loads(p["data"] or "{}")
1017
- changed = False
1018
- for name in will_delete:
1019
- if name in data:
1020
- del data[name]
1021
- changed = True
1022
- if changed:
1023
- conn.execute("UPDATE products SET data = ?, updated_at = datetime('now','localtime') WHERE id = ?",
1024
- (json.dumps(data, ensure_ascii=False), p["id"]))
1025
- conn.commit()
1026
- conn.close()
1027
-
1028
- remaining = [f["name"] for f in new_fields]
1029
- print(f"已从项目「{proj['name']}」删除字段: {will_delete}")
1030
- print(f"剩余字段({len(remaining)}个): {remaining}")
1031
- _ok(deleted_fields=will_delete, remaining_fields=remaining, affected_products=len(products))
1032
- return 0
1033
-
1034
-
1035
- # ---------- product-add ----------
1036
-
1037
-
1038
- def cmd_product_add(args: argparse.Namespace) -> int:
1039
- conn = get_db()
1040
- try:
1041
- proj = resolve_project(conn, args.project)
1042
- except ValueError as e:
1043
- _fail(str(e), usage="product-add --project <项目名> --data '<JSON>'",
1044
- valid_values={"existing_projects": _list_project_names(conn)})
1045
- conn.close()
1046
- return 1
1047
-
1048
- if not getattr(args, "data", None) or not args.data.strip():
1049
- fields = _get_field_config(proj)
1050
- field_names = [f["name"] for f in fields]
1051
- example_data = {fn: "<值>" for fn in field_names}
1052
- _fail("--data 参数不能为空,需传入 JSON 格式的商品数据",
1053
- hint=f"该项目的字段为: {field_names}",
1054
- example=f'product-add --project {proj["name"]} --data \'{json.dumps(example_data, ensure_ascii=False)}\'')
1055
- conn.close()
1056
- return 1
1057
-
1058
- try:
1059
- data = json.loads(args.data)
1060
- except json.JSONDecodeError as e:
1061
- fields = _get_field_config(proj)
1062
- field_names = [f["name"] for f in fields]
1063
- example_data = {fn: "<值>" for fn in field_names}
1064
- _fail(f"--data JSON 解析失败: {e}",
1065
- hint="--data 的值必须是合法的 JSON 字符串,注意使用英文双引号",
1066
- example=f'--data \'{json.dumps(example_data, ensure_ascii=False)}\'')
1067
- conn.close()
1068
- return 1
1069
-
1070
- if not isinstance(data, dict):
1071
- _fail("--data 必须是 JSON 对象(字典),不能是数组或其他类型",
1072
- example='--data \'{"SKU":"BT-003","商品名":"蓝牙音箱","库存":0}\'')
1073
- conn.close()
1074
- return 1
1075
-
1076
- field_err = _validate_json_data(data, proj, purpose="add")
1077
- if field_err:
1078
- fields = _get_field_config(proj)
1079
- field_names = [f["name"] for f in fields]
1080
- _fail(field_err,
1081
- hint="--data 中的 key 必须是项目已定义的字段名",
1082
- valid_values={"project_fields": field_names})
1083
- conn.close()
1084
- return 1
1085
-
1086
- key_field = proj["key_field"]
1087
- stock_field = proj["stock_field"]
1088
- kv = str(data.get(key_field, "")).strip()
1089
- if not kv:
1090
- fields = _get_field_config(proj)
1091
- field_names = [f["name"] for f in fields]
1092
- _fail(f"JSON 数据中缺少唯一标识字段「{key_field}」或其值为空",
1093
- hint=f"唯一标识字段「{key_field}」为必填,不能为空",
1094
- example=f'--data \'{{"{ key_field}":"XX-001",...}}\'',
1095
- valid_values={"key_field": key_field, "all_fields": field_names})
1096
- conn.close()
1097
- return 1
1098
-
1099
- sq_raw = str(data.get(stock_field, "0")).strip().replace(",", "")
1100
- try:
1101
- sq = float(sq_raw) if sq_raw else 0
1102
- except ValueError:
1103
- _fail(f"库存字段「{stock_field}」的值「{data.get(stock_field)}」不是有效数字",
1104
- hint=f"「{stock_field}」字段值必须为数字(如 0、100、50.5)")
1105
- conn.close()
1106
- return 1
1107
-
1108
- try:
1109
- conn.execute(
1110
- "INSERT INTO products (project_id, key_value, stock_qty, data) VALUES (?,?,?,?)",
1111
- (proj["id"], kv, sq, json.dumps(data, ensure_ascii=False)),
1112
- )
1113
- conn.commit()
1114
- except sqlite3.IntegrityError:
1115
- _fail(f"商品「{kv}」在项目「{proj['name']}」中已存在,不能重复添加",
1116
- hint="若要更新已有商品,请使用 product-edit 命令",
1117
- usage=f'product-edit --project {proj["name"]} --key {kv} --set \'{{...}}\'')
1118
- conn.close()
1119
- return 1
1120
- conn.close()
1121
- print(f"商品「{kv}」添加成功,库存: {sq}")
1122
- _ok(key=kv, stock=sq)
1123
- return 0
1124
-
1125
-
1126
- # ---------- product-edit ----------
1127
-
1128
-
1129
- def cmd_product_edit(args: argparse.Namespace) -> int:
1130
- conn = get_db()
1131
- try:
1132
- proj = resolve_project(conn, args.project)
1133
- except ValueError as e:
1134
- _fail(str(e), usage="product-edit --project <项目名> --key <商品标识> --set '<JSON>'",
1135
- valid_values={"existing_projects": _list_project_names(conn)})
1136
- conn.close()
1137
- return 1
1138
-
1139
- if not getattr(args, "key", None) or not args.key.strip():
1140
- _fail("--key 参数不能为空",
1141
- hint=f"请传入商品的唯一标识值(字段名:「{proj['key_field']}」)",
1142
- usage=f"product-edit --project {proj['name']} --key <标识值> --set '<JSON>'")
1143
- conn.close()
1144
- return 1
1145
-
1146
- if not getattr(args, "set", None) or not args.set.strip():
1147
- fields = _get_field_config(proj)
1148
- field_names = [f["name"] for f in fields]
1149
- _fail("--set 参数不能为空,需传入要更新的字段 JSON",
1150
- hint=f"该项目可更新的字段: {field_names}",
1151
- example=f'product-edit --project {proj["name"]} --key {args.key} --set \'{{"售价":299}}\'')
1152
- conn.close()
1153
- return 1
1154
-
1155
- try:
1156
- updates = json.loads(args.set)
1157
- except json.JSONDecodeError as e:
1158
- fields = _get_field_config(proj)
1159
- field_names = [f["name"] for f in fields]
1160
- _fail(f"--set JSON 解析失败: {e}",
1161
- hint="--set 的值必须是合法 JSON 对象,只需包含要修改的字段",
1162
- example=f'--set \'{{"售价":259,"商品名":"新名称"}}\'',
1163
- valid_values={"project_fields": field_names})
1164
- conn.close()
1165
- return 1
1166
-
1167
- if not isinstance(updates, dict):
1168
- _fail("--set 必须是 JSON 对象(字典),不能是数组或其他类型",
1169
- example='--set \'{"售价":259}\'')
1170
- conn.close()
1171
- return 1
1172
-
1173
- if not updates:
1174
- _fail("--set JSON 对象为空,没有需要更新的字段",
1175
- example='--set \'{"售价":259}\'')
1176
- conn.close()
1177
- return 1
1178
-
1179
- field_err = _validate_json_data(updates, proj, purpose="edit")
1180
- if field_err:
1181
- fields = _get_field_config(proj)
1182
- field_names = [f["name"] for f in fields]
1183
- _fail(field_err,
1184
- hint="--set 中的 key 必须是项目已定义的字段名",
1185
- valid_values={"project_fields": field_names})
1186
- conn.close()
1187
- return 1
1188
-
1189
- pid = proj["id"]
1190
- row = conn.execute(
1191
- "SELECT * FROM products WHERE project_id = ? AND key_value = ? AND status = 'active'",
1192
- (pid, args.key),
1193
- ).fetchone()
1194
- if not row:
1195
- _product_not_found(conn, proj, args.key)
1196
- conn.close()
1197
- return 1
1198
-
1199
- old_data = json.loads(row["data"] or "{}")
1200
- old_data.update(updates)
1201
-
1202
- stock_field = proj["stock_field"]
1203
- sq = row["stock_qty"]
1204
- if stock_field in updates:
1205
- raw = str(updates[stock_field]).strip().replace(",", "")
1206
- try:
1207
- sq = float(raw)
1208
- except ValueError:
1209
- _fail(f"库存字段「{stock_field}」的新值「{updates[stock_field]}」不是有效数字",
1210
- hint=f"通过 --set 修改「{stock_field}」时,值必须为数字")
1211
- conn.close()
1212
- return 1
1213
-
1214
- key_field = proj["key_field"]
1215
- new_kv = str(old_data.get(key_field, args.key)).strip()
1216
-
1217
- conn.execute(
1218
- "UPDATE products SET key_value = ?, stock_qty = ?, data = ?, updated_at = datetime('now','localtime') WHERE id = ?",
1219
- (new_kv, sq, json.dumps(old_data, ensure_ascii=False), row["id"]),
1220
- )
1221
- conn.commit()
1222
- conn.close()
1223
- print(f"商品「{args.key}」更新成功")
1224
- _ok(key=new_kv, updated_fields=list(updates.keys()))
1225
- return 0
1226
-
1227
-
1228
- # ---------- product-delete ----------
1229
-
1230
-
1231
- def cmd_product_delete(args: argparse.Namespace) -> int:
1232
- conn = get_db()
1233
- try:
1234
- proj = resolve_project(conn, args.project)
1235
- except ValueError as e:
1236
- _fail(str(e), usage="product-delete --project <项目名> --key <商品标识>",
1237
- valid_values={"existing_projects": _list_project_names(conn)})
1238
- conn.close()
1239
- return 1
1240
-
1241
- if not getattr(args, "key", None) or not args.key.strip():
1242
- _fail("--key 参数不能为空",
1243
- hint=f"请传入要删除商品的唯一标识值(字段名:「{proj['key_field']}」)",
1244
- valid_values={"existing_keys": _list_product_keys(conn, proj["id"])})
1245
- conn.close()
1246
- return 1
1247
-
1248
- pid = proj["id"]
1249
- row = conn.execute(
1250
- "SELECT * FROM products WHERE project_id = ? AND key_value = ? AND status = 'active'",
1251
- (pid, args.key),
1252
- ).fetchone()
1253
- if not row:
1254
- _product_not_found(conn, proj, args.key)
1255
- conn.close()
1256
- return 1
1257
- conn.execute(
1258
- "UPDATE products SET status = 'deleted', updated_at = datetime('now','localtime') WHERE id = ?",
1259
- (row["id"],),
1260
- )
1261
- conn.commit()
1262
- conn.close()
1263
- print(f"商品「{args.key}」已删除(软删除)")
1264
- _ok(key=args.key)
1265
- return 0
1266
-
1267
-
1268
- # ---------- product-list ----------
1269
-
1270
-
1271
- def cmd_product_list(args: argparse.Namespace) -> int:
1272
- conn = get_db()
1273
- try:
1274
- proj = resolve_project(conn, args.project)
1275
- except ValueError as e:
1276
- _fail(str(e), usage="product-list --project <项目名> [--search <关键词>] [--filter 字段名=值]",
1277
- valid_values={"existing_projects": _list_project_names(conn)})
1278
- conn.close()
1279
- return 1
1280
- pid = proj["id"]
1281
- fields = _get_field_config(proj)
1282
- field_names = [f["name"] for f in fields]
1283
-
1284
- filter_str = getattr(args, "filter", None)
1285
- if filter_str:
1286
- filter_err = _validate_filter(filter_str, proj)
1287
- if filter_err:
1288
- _fail(filter_err, valid_values={"project_fields": field_names})
1289
- conn.close()
1290
- return 1
1291
-
1292
- query = "SELECT * FROM products WHERE project_id = ? AND status = 'active'"
1293
- params: list = [pid]
1294
-
1295
- rows = conn.execute(query, params).fetchall()
1296
- conn.close()
1297
-
1298
- search = getattr(args, "search", None)
1299
-
1300
- result_rows = []
1301
- for r in rows:
1302
- data = json.loads(r["data"] or "{}")
1303
- if search:
1304
- matched = any(search.lower() in str(v).lower() for v in data.values())
1305
- if not matched:
1306
- continue
1307
- if filter_str and "=" in filter_str:
1308
- fk, fv = filter_str.split("=", 1)
1309
- if str(data.get(fk.strip(), "")).strip() != fv.strip():
1310
- continue
1311
- row_cells = [str(data.get(fn, "")) for fn in field_names]
1312
- result_rows.append(row_cells)
1313
-
1314
- if not result_rows:
1315
- print("无匹配商品")
1316
- return 0
1317
- print(f"项目「{proj['name']}」商品列表 ({len(result_rows)} 条):")
1318
- print_table(field_names, result_rows)
1319
- return 0
1320
-
1321
-
1322
- # ---------- product-detail ----------
1323
-
1324
-
1325
- def cmd_product_detail(args: argparse.Namespace) -> int:
1326
- conn = get_db()
1327
- try:
1328
- proj = resolve_project(conn, args.project)
1329
- except ValueError as e:
1330
- _fail(str(e), usage="product-detail --project <项目名> --key <商品标识>",
1331
- valid_values={"existing_projects": _list_project_names(conn)})
1332
- conn.close()
1333
- return 1
1334
-
1335
- if not getattr(args, "key", None) or not args.key.strip():
1336
- _fail("--key 参数不能为空",
1337
- hint=f"请传入商品的唯一标识值(字段名:「{proj['key_field']}」)",
1338
- valid_values={"existing_keys": _list_product_keys(conn, proj["id"])})
1339
- conn.close()
1340
- return 1
1341
-
1342
- pid = proj["id"]
1343
- row = conn.execute(
1344
- "SELECT * FROM products WHERE project_id = ? AND key_value = ? AND status = 'active'",
1345
- (pid, args.key),
1346
- ).fetchone()
1347
- if not row:
1348
- _product_not_found(conn, proj, args.key)
1349
- conn.close()
1350
- return 1
1351
-
1352
- data = json.loads(row["data"] or "{}")
1353
- fields = _get_field_config(proj)
1354
- print(f"---------- 商品详情 ----------")
1355
- for f in fields:
1356
- fn = f["name"]
1357
- val = data.get(fn, "")
1358
- role = f.get("role", "")
1359
- suffix = " ← 唯一标识" if role == "key" else (" ← 库存数量" if role == "stock" else "")
1360
- print(f" {fn}: {val}{suffix}")
1361
- print(f" [系统库存]: {row['stock_qty']}")
1362
- print(f" [状态]: {row['status']}")
1363
- print(f" [创建时间]: {row['created_at']}")
1364
- print(f" [更新时间]: {row['updated_at']}")
1365
-
1366
- logs = conn.execute(
1367
- "SELECT * FROM stock_logs WHERE project_id = ? AND key_value = ? ORDER BY created_at DESC LIMIT 10",
1368
- (pid, args.key),
1369
- ).fetchall()
1370
- conn.close()
1371
- if logs:
1372
- print(f"\n 最近 {len(logs)} 条变动记录:")
1373
- for lg in logs:
1374
- d = "入库" if lg["direction"] == "in" else "出库"
1375
- print(f" {lg['created_at']} {d}({lg['type']}) 数量:{lg['quantity']} {lg['before_qty']}→{lg['after_qty']} {lg['remark']}")
1376
- print(f"------------------------------")
1377
- return 0
1378
-
1379
-
1380
- # ---------- stock-in ----------
1381
-
1382
-
1383
- def cmd_stock_in(args: argparse.Namespace) -> int:
1384
- conn = get_db()
1385
- try:
1386
- proj = resolve_project(conn, args.project)
1387
- except ValueError as e:
1388
- _fail(str(e), usage="stock-in --project <项目名> --key <商品标识> --quantity <数量>",
1389
- valid_values={"existing_projects": _list_project_names(conn)})
1390
- conn.close()
1391
- return 1
1392
-
1393
- if not getattr(args, "key", None) or not args.key.strip():
1394
- _fail("--key 参数不能为空",
1395
- hint=f"请传入商品的唯一标识值(字段名:「{proj['key_field']}」)",
1396
- valid_values={"existing_keys": _list_product_keys(conn, proj["id"])},
1397
- usage=f"stock-in --project {proj['name']} --key <标识值> --quantity <数量>")
1398
- conn.close()
1399
- return 1
1400
-
1401
- pid = proj["id"]
1402
- row = conn.execute(
1403
- "SELECT * FROM products WHERE project_id = ? AND key_value = ? AND status = 'active'",
1404
- (pid, args.key),
1405
- ).fetchone()
1406
- if not row:
1407
- _product_not_found(conn, proj, args.key)
1408
- conn.close()
1409
- return 1
1410
- qty = float(args.quantity)
1411
- if qty <= 0:
1412
- _fail("入库数量必须为正数(大于0)",
1413
- hint=f"当前传入的 --quantity 为 {args.quantity},请传入正数",
1414
- example=f"stock-in --key {args.key} --quantity 100")
1415
- conn.close()
1416
- return 1
1417
- before = row["stock_qty"]
1418
- after = before + qty
1419
-
1420
- stock_field = proj["stock_field"]
1421
- data = json.loads(row["data"] or "{}")
1422
- data[stock_field] = after
1423
-
1424
- conn.execute(
1425
- "UPDATE products SET stock_qty = ?, data = ?, updated_at = datetime('now','localtime') WHERE id = ?",
1426
- (after, json.dumps(data, ensure_ascii=False), row["id"]),
1427
- )
1428
- conn.execute(
1429
- "INSERT INTO stock_logs (project_id, key_value, direction, type, quantity, before_qty, after_qty, remark) VALUES (?,?,?,?,?,?,?,?)",
1430
- (pid, args.key, "in", args.type or "purchase", qty, before, after, args.remark or ""),
1431
- )
1432
- conn.commit()
1433
- conn.close()
1434
- print(f"入库成功:商品「{args.key}」库存 {before} → {after}(+{qty})")
1435
- _ok(key=args.key, before=before, after=after, quantity=qty)
1436
- return 0
1437
-
1438
-
1439
- # ---------- stock-out ----------
1440
-
1441
-
1442
- def cmd_stock_out(args: argparse.Namespace) -> int:
1443
- conn = get_db()
1444
- try:
1445
- proj = resolve_project(conn, args.project)
1446
- except ValueError as e:
1447
- _fail(str(e), usage="stock-out --project <项目名> --key <商品标识> --quantity <数量>",
1448
- valid_values={"existing_projects": _list_project_names(conn)})
1449
- conn.close()
1450
- return 1
1451
-
1452
- if not getattr(args, "key", None) or not args.key.strip():
1453
- _fail("--key 参数不能为空",
1454
- hint=f"请传入商品的唯一标识值(字段名:「{proj['key_field']}」)",
1455
- valid_values={"existing_keys": _list_product_keys(conn, proj["id"])},
1456
- usage=f"stock-out --project {proj['name']} --key <标识值> --quantity <数量>")
1457
- conn.close()
1458
- return 1
1459
-
1460
- pid = proj["id"]
1461
- row = conn.execute(
1462
- "SELECT * FROM products WHERE project_id = ? AND key_value = ? AND status = 'active'",
1463
- (pid, args.key),
1464
- ).fetchone()
1465
- if not row:
1466
- _product_not_found(conn, proj, args.key)
1467
- conn.close()
1468
- return 1
1469
- qty = float(args.quantity)
1470
- if qty <= 0:
1471
- _fail("出库数量必须为正数(大于0)",
1472
- hint=f"当前传入的 --quantity 为 {args.quantity},请传入正数",
1473
- example=f"stock-out --key {args.key} --quantity 5")
1474
- conn.close()
1475
- return 1
1476
- before = row["stock_qty"]
1477
- if qty > before:
1478
- _fail(f"库存不足:商品「{args.key}」当前库存为 {before},请求出库 {qty}",
1479
- hint=f"出库数量不能超过当前库存 {before},请减少出库数量或先入库补货",
1480
- current_stock=before, requested=qty)
1481
- conn.close()
1482
- return 1
1483
- after = before - qty
1484
-
1485
- stock_field = proj["stock_field"]
1486
- data = json.loads(row["data"] or "{}")
1487
- data[stock_field] = after
1488
-
1489
- conn.execute(
1490
- "UPDATE products SET stock_qty = ?, data = ?, updated_at = datetime('now','localtime') WHERE id = ?",
1491
- (after, json.dumps(data, ensure_ascii=False), row["id"]),
1492
- )
1493
- conn.execute(
1494
- "INSERT INTO stock_logs (project_id, key_value, direction, type, quantity, before_qty, after_qty, remark) VALUES (?,?,?,?,?,?,?,?)",
1495
- (pid, args.key, "out", args.type or "sale", qty, before, after, args.remark or ""),
1496
- )
1497
- conn.commit()
1498
- conn.close()
1499
- print(f"出库成功:商品「{args.key}」库存 {before} → {after}(-{qty})")
1500
- _ok(key=args.key, before=before, after=after, quantity=qty)
1501
- return 0
1502
-
1503
-
1504
- # ---------- stock-query ----------
1505
-
1506
-
1507
- def cmd_stock_query(args: argparse.Namespace) -> int:
1508
- conn = get_db()
1509
- try:
1510
- proj = resolve_project(conn, args.project)
1511
- except ValueError as e:
1512
- _fail(str(e), usage="stock-query --project <项目名> [--key <商品标识>] [--low-stock] [--filter 字段名=值]",
1513
- valid_values={"existing_projects": _list_project_names(conn)})
1514
- conn.close()
1515
- return 1
1516
- pid = proj["id"]
1517
- fields = _get_field_config(proj)
1518
- field_names = [f["name"] for f in fields]
1519
- threshold = proj["low_stock_threshold"]
1520
-
1521
- filter_str = getattr(args, "filter", None)
1522
- if filter_str:
1523
- filter_err = _validate_filter(filter_str, proj)
1524
- if filter_err:
1525
- _fail(filter_err, valid_values={"project_fields": field_names})
1526
- conn.close()
1527
- return 1
1528
-
1529
- if getattr(args, "key", None):
1530
- row = conn.execute(
1531
- "SELECT * FROM products WHERE project_id = ? AND key_value = ? AND status = 'active'",
1532
- (pid, args.key),
1533
- ).fetchone()
1534
- if not row:
1535
- _product_not_found(conn, proj, args.key)
1536
- conn.close()
1537
- return 1
1538
- data = json.loads(row["data"] or "{}")
1539
- alert = "⚠ 低库存" if row["stock_qty"] <= threshold else ""
1540
- print(f"商品「{args.key}」库存: {row['stock_qty']} {alert}")
1541
- for fn in field_names:
1542
- print(f" {fn}: {data.get(fn, '')}")
1543
- return 0
1544
-
1545
- rows = conn.execute(
1546
- "SELECT * FROM products WHERE project_id = ? AND status = 'active' ORDER BY stock_qty ASC",
1547
- (pid,),
1548
- ).fetchall()
1549
- conn.close()
1550
-
1551
- low_stock = getattr(args, "low_stock", False)
1552
- filter_str = getattr(args, "filter", None)
1553
-
1554
- display_fields = [proj["key_field"], proj["stock_field"]]
1555
- for fn in field_names:
1556
- if fn not in display_fields:
1557
- display_fields.append(fn)
1558
- display_fields.append("预警")
1559
-
1560
- result_rows = []
1561
- for r in rows:
1562
- data = json.loads(r["data"] or "{}")
1563
- if low_stock and r["stock_qty"] > threshold:
1564
- continue
1565
- if filter_str and "=" in filter_str:
1566
- fk, fv = filter_str.split("=", 1)
1567
- if str(data.get(fk.strip(), "")).strip() != fv.strip():
1568
- continue
1569
- cells = []
1570
- for fn in display_fields[:-1]:
1571
- cells.append(str(data.get(fn, "")))
1572
- cells.append("⚠" if r["stock_qty"] <= threshold else "")
1573
- result_rows.append(cells)
1574
-
1575
- if not result_rows:
1576
- print("无匹配商品" + ("(低库存预警)" if low_stock else ""))
1577
- return 0
1578
- title = "低库存预警" if low_stock else "库存查询"
1579
- print(f"项目「{proj['name']}」{title} ({len(result_rows)} 条):")
1580
- print_table(display_fields, result_rows)
1581
- return 0
1582
-
1583
-
1584
- # ---------- stock-log ----------
1585
-
1586
-
1587
- def cmd_stock_log(args: argparse.Namespace) -> int:
1588
- conn = get_db()
1589
- try:
1590
- proj = resolve_project(conn, args.project)
1591
- except ValueError as e:
1592
- _fail(str(e), usage="stock-log --project <项目名> [--key <标识>] [--direction in|out] [--from YYYY-MM-DD] [--to YYYY-MM-DD]",
1593
- valid_values={"existing_projects": _list_project_names(conn)})
1594
- conn.close()
1595
- return 1
1596
- pid = proj["id"]
1597
-
1598
- if getattr(args, "from_date", None):
1599
- date_err = _validate_date(args.from_date, "--from")
1600
- if date_err:
1601
- _fail(date_err)
1602
- conn.close()
1603
- return 1
1604
- if getattr(args, "to_date", None):
1605
- date_err = _validate_date(args.to_date, "--to")
1606
- if date_err:
1607
- _fail(date_err)
1608
- conn.close()
1609
- return 1
1610
-
1611
- query = "SELECT * FROM stock_logs WHERE project_id = ?"
1612
- params: list = [pid]
1613
-
1614
- if getattr(args, "key", None):
1615
- query += " AND key_value = ?"
1616
- params.append(args.key)
1617
- if getattr(args, "direction", None):
1618
- query += " AND direction = ?"
1619
- params.append(args.direction)
1620
- if getattr(args, "from_date", None):
1621
- query += " AND created_at >= ?"
1622
- params.append(args.from_date)
1623
- if getattr(args, "to_date", None):
1624
- query += " AND created_at <= ?"
1625
- params.append(args.to_date + " 23:59:59")
1626
-
1627
- query += " ORDER BY created_at DESC LIMIT 100"
1628
- logs = conn.execute(query, params).fetchall()
1629
- conn.close()
1630
-
1631
- if not logs:
1632
- print("无变动记录")
1633
- return 0
1634
-
1635
- TYPE_ZH = {
1636
- "purchase": "采购入库", "return": "退货", "other": "其他",
1637
- "sale": "销售出库", "damage": "报损",
1638
- }
1639
- headers = ["时间", "商品标识", "方向", "类型", "数量", "变动前", "变动后", "备注"]
1640
- rows = []
1641
- for lg in logs:
1642
- d = "入库" if lg["direction"] == "in" else "出库"
1643
- t = TYPE_ZH.get(lg["type"], lg["type"])
1644
- rows.append([
1645
- lg["created_at"], lg["key_value"], d, t,
1646
- str(lg["quantity"]), str(lg["before_qty"]), str(lg["after_qty"]), lg["remark"],
1647
- ])
1648
- print(f"变动记录 ({len(rows)} 条):")
1649
- print_table(headers, rows)
1650
- return 0
1651
-
1652
-
1653
- # ---------- export ----------
1654
-
1655
-
1656
- def cmd_export(args: argparse.Namespace) -> int:
1657
- conn = get_db()
1658
- try:
1659
- proj = resolve_project(conn, args.project)
1660
- except ValueError as e:
1661
- _fail(str(e), usage="export --project <项目名> --type products|logs [--output <文件路径>]",
1662
- valid_values={"existing_projects": _list_project_names(conn)})
1663
- conn.close()
1664
- return 1
1665
- pid = proj["id"]
1666
- export_type = args.type or "products"
1667
- ts = datetime.now().strftime("%Y%m%d_%H%M%S")
1668
- default_name = f"export_{proj['name']}_{export_type}_{ts}.csv"
1669
- output = args.output or str(WORKSPACE_DIR / default_name)
1670
-
1671
- if export_type == "products":
1672
- fields = _get_field_config(proj)
1673
- field_names = [f["name"] for f in fields]
1674
- rows = conn.execute(
1675
- "SELECT * FROM products WHERE project_id = ? AND status = 'active'", (pid,)
1676
- ).fetchall()
1677
- conn.close()
1678
- with open(output, "w", encoding="utf-8-sig", newline="") as f:
1679
- writer = csv.DictWriter(f, fieldnames=field_names)
1680
- writer.writeheader()
1681
- for r in rows:
1682
- data = json.loads(r["data"] or "{}")
1683
- writer.writerow({fn: data.get(fn, "") for fn in field_names})
1684
- print(f"已导出 {len(rows)} 条商品数据 → {output}")
1685
- elif export_type == "logs":
1686
- logs = conn.execute(
1687
- "SELECT * FROM stock_logs WHERE project_id = ? ORDER BY created_at DESC", (pid,)
1688
- ).fetchall()
1689
- conn.close()
1690
- log_fields = ["created_at", "key_value", "direction", "type", "quantity", "before_qty", "after_qty", "remark"]
1691
- with open(output, "w", encoding="utf-8-sig", newline="") as f:
1692
- writer = csv.DictWriter(f, fieldnames=log_fields)
1693
- writer.writeheader()
1694
- for lg in logs:
1695
- writer.writerow({k: lg[k] for k in log_fields})
1696
- print(f"已导出 {len(logs)} 条变动记录 → {output}")
1697
- else:
1698
- _fail(f"不支持的导出类型: {export_type},可选 products / logs")
1699
- conn.close()
1700
- return 1
1701
- _ok(output=output, type=export_type)
1702
- return 0
1703
-
1704
-
1705
- # ========== CLI 入口 ==========
1706
-
1707
-
1708
- def main() -> int:
1709
- parser = argparse.ArgumentParser(description="库存管理工具(多项目、自定义字段)")
1710
- sub = parser.add_subparsers(dest="command", required=True)
1711
-
1712
- # create(自然语言建表)
1713
- p = sub.add_parser("create", help="通过指定字段名直接创建空项目(无需上传文件,用户用自然语言描述字段时使用)")
1714
- p.add_argument("--project", required=True, help="项目名称")
1715
- p.add_argument("--fields", required=True,
1716
- help='字段定义。简单格式: "SKU,名称,颜色,库存数量,售价" 或带类型: "SKU:text,库存数量:number";'
1717
- 'JSON 格式: \'[{"name":"SKU","type":"text"},...]\'。类型可选 text/number/date,不指定时自动推断')
1718
- p.add_argument("--key-field", default=None, help="唯一标识字段名(不传则自动推断)")
1719
- p.add_argument("--stock-field", default=None, help="库存数量字段名(不传则自动推断)")
1720
- p.add_argument("--low-stock-threshold", type=int, default=10, help="低库存预警阈值(默认10)")
1721
- p.set_defaults(func=cmd_create)
1722
-
1723
- # init(从文件创建)
1724
- p = sub.add_parser("init", help="从 CSV/XLSX 文件创建项目:解析表头创建项目并导入数据")
1725
- p.add_argument("--project", required=True, help="项目名称")
1726
- p.add_argument("--file", required=True, help="CSV 或 XLSX 文件路径")
1727
- p.add_argument("--key-field", default=None, help="唯一标识字段名(不传则自动推断)")
1728
- p.add_argument("--stock-field", default=None, help="库存数量字段名(不传则自动推断)")
1729
- p.add_argument("--low-stock-threshold", type=int, default=10, help="低库存预警阈值(默认10)")
1730
- p.set_defaults(func=cmd_init)
1731
-
1732
- # import
1733
- p = sub.add_parser("import", help="追加/更新导入数据到已有项目")
1734
- p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1735
- p.add_argument("--file", required=True, help="CSV 或 XLSX 文件路径")
1736
- p.set_defaults(func=cmd_import)
1737
-
1738
- # project-list
1739
- p = sub.add_parser("project-list", help="查看所有项目")
1740
- p.set_defaults(func=cmd_project_list)
1741
-
1742
- # project-delete
1743
- p = sub.add_parser("project-delete", help="删除项目及其所有数据")
1744
- p.add_argument("--project", required=True, help="项目名称")
1745
- p.set_defaults(func=cmd_project_delete)
1746
-
1747
- # field-list
1748
- p = sub.add_parser("field-list", help="查看项目字段配置")
1749
- p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1750
- p.set_defaults(func=cmd_field_list)
1751
-
1752
- # field-add
1753
- p = sub.add_parser("field-add", help="为项目新增自定义字段")
1754
- p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1755
- p.add_argument("--name", required=True, help="字段名")
1756
- p.add_argument("--type", default="text", choices=["text", "number", "date"], help="字段类型")
1757
- p.set_defaults(func=cmd_field_add)
1758
-
1759
- # field-delete
1760
- p = sub.add_parser("field-delete", help="删除字段(支持逗号分隔批量删除,唯一标识和库存字段不可删)")
1761
- p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1762
- p.add_argument("--name", required=True, help="要删除的字段名(多个用逗号分隔,如 \"条码,保质期,进价\")")
1763
- p.set_defaults(func=cmd_field_delete)
1764
-
1765
- # product-add
1766
- p = sub.add_parser("product-add", help="添加单个商品")
1767
- p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1768
- p.add_argument("--data", required=True, help="商品数据 JSON 字符串")
1769
- p.set_defaults(func=cmd_product_add)
1770
-
1771
- # product-edit
1772
- p = sub.add_parser("product-edit", help="编辑商品字段")
1773
- p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1774
- p.add_argument("--key", required=True, help="商品唯一标识值")
1775
- p.add_argument("--set", required=True, help="要更新的字段 JSON 字符串")
1776
- p.set_defaults(func=cmd_product_edit)
1777
-
1778
- # product-delete
1779
- p = sub.add_parser("product-delete", help="删除商品(软删除)")
1780
- p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1781
- p.add_argument("--key", required=True, help="商品唯一标识值")
1782
- p.set_defaults(func=cmd_product_delete)
1783
-
1784
- # product-list
1785
- p = sub.add_parser("product-list", help="查询商品列表")
1786
- p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1787
- p.add_argument("--search", default=None, help="模糊搜索关键词")
1788
- p.add_argument("--filter", default=None, help="精确筛选 字段名=值")
1789
- p.set_defaults(func=cmd_product_list)
1790
-
1791
- # product-detail
1792
- p = sub.add_parser("product-detail", help="查看商品详情")
1793
- p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1794
- p.add_argument("--key", required=True, help="商品唯一标识值")
1795
- p.set_defaults(func=cmd_product_detail)
1796
-
1797
- # stock-in
1798
- p = sub.add_parser("stock-in", help="入库")
1799
- p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1800
- p.add_argument("--key", required=True, help="商品唯一标识值")
1801
- p.add_argument("--quantity", required=True, type=float, help="入库数量")
1802
- p.add_argument("--type", default="purchase", choices=["purchase", "return", "other"], help="入库类型")
1803
- p.add_argument("--remark", default="", help="备注")
1804
- p.set_defaults(func=cmd_stock_in)
1805
-
1806
- # stock-out
1807
- p = sub.add_parser("stock-out", help="出库")
1808
- p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1809
- p.add_argument("--key", required=True, help="商品唯一标识值")
1810
- p.add_argument("--quantity", required=True, type=float, help="出库数量")
1811
- p.add_argument("--type", default="sale", choices=["sale", "return", "damage", "other"], help="出库类型")
1812
- p.add_argument("--remark", default="", help="备注")
1813
- p.set_defaults(func=cmd_stock_out)
1814
-
1815
- # stock-query
1816
- p = sub.add_parser("stock-query", help="库存查询")
1817
- p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1818
- p.add_argument("--key", default=None, help="商品唯一标识值(查单个)")
1819
- p.add_argument("--low-stock", action="store_true", help="仅显示低库存商品")
1820
- p.add_argument("--filter", default=None, help="精确筛选 字段名=值")
1821
- p.set_defaults(func=cmd_stock_query)
1822
-
1823
- # stock-log
1824
- p = sub.add_parser("stock-log", help="查看库存变动记录")
1825
- p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1826
- p.add_argument("--key", default=None, help="商品唯一标识值")
1827
- p.add_argument("--direction", default=None, choices=["in", "out"], help="方向筛选")
1828
- p.add_argument("--from", dest="from_date", default=None, help="起始日期 YYYY-MM-DD")
1829
- p.add_argument("--to", dest="to_date", default=None, help="结束日期 YYYY-MM-DD")
1830
- p.set_defaults(func=cmd_stock_log)
1831
-
1832
- # export
1833
- p = sub.add_parser("export", help="导出数据为 CSV")
1834
- p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1835
- p.add_argument("--type", default="products", choices=["products", "logs"], help="导出类型")
1836
- p.add_argument("--output", default=None, help="输出文件路径(默认自动生成)")
1837
- p.set_defaults(func=cmd_export)
1838
-
1839
- args = parser.parse_args()
1840
- return args.func(args)
1841
-
1842
-
1843
- if __name__ == "__main__":
1844
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ """
3
+ 库存管理脚本:支持多项目、自定义字段、CSV/XLSX 导入、出入库、库存查询、变动记录、导出。
4
+ 数据存储在本地 SQLite,字段配置从用户上传的文件表头自动解析。
5
+ """
6
+
7
+ import argparse
8
+ import csv
9
+ import io
10
+ import json
11
+ import os
12
+ import re
13
+ import sqlite3
14
+ import sys
15
+ import time
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+
19
+ SCRIPT_DIR = Path(__file__).resolve().parent
20
+ WORKSPACE_DIR = Path.home() / ".openclaw" / "workspace" / "inventory-management"
21
+ DEFAULT_DB_PATH = WORKSPACE_DIR / "inventory.db"
22
+ DB_PATH = Path(os.environ.get("INVENTORY_DB_PATH", str(DEFAULT_DB_PATH)))
23
+
24
+ # ---------- 关键字段自动推断关键词 ----------
25
+
26
+ KEY_FIELD_HINTS = ["sku", "编码", "编号", "货号", "条码", "物料号", "id", "代码", "code"]
27
+ STOCK_FIELD_HINTS = ["库存", "数量", "stock", "qty", "quantity", "余量", "存量"]
28
+
29
+ # ---------- 数据库初始化 ----------
30
+
31
+
32
+ def get_db() -> sqlite3.Connection:
33
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
34
+ conn = sqlite3.connect(str(DB_PATH))
35
+ conn.row_factory = sqlite3.Row
36
+ conn.execute("PRAGMA journal_mode=WAL")
37
+ conn.execute("PRAGMA foreign_keys=ON")
38
+ _init_tables(conn)
39
+ return conn
40
+
41
+
42
+ def _init_tables(conn: sqlite3.Connection) -> None:
43
+ conn.executescript("""
44
+ CREATE TABLE IF NOT EXISTS projects (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ name TEXT NOT NULL UNIQUE,
47
+ field_config TEXT NOT NULL DEFAULT '[]',
48
+ key_field TEXT NOT NULL DEFAULT '',
49
+ stock_field TEXT NOT NULL DEFAULT '',
50
+ low_stock_threshold INTEGER NOT NULL DEFAULT 10,
51
+ created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
52
+ );
53
+ CREATE TABLE IF NOT EXISTS products (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
56
+ key_value TEXT NOT NULL,
57
+ stock_qty REAL NOT NULL DEFAULT 0,
58
+ data TEXT NOT NULL DEFAULT '{}',
59
+ status TEXT NOT NULL DEFAULT 'active',
60
+ created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
61
+ updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
62
+ UNIQUE(project_id, key_value)
63
+ );
64
+ CREATE INDEX IF NOT EXISTS idx_products_project ON products(project_id, status);
65
+ CREATE TABLE IF NOT EXISTS stock_logs (
66
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
67
+ project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
68
+ key_value TEXT NOT NULL,
69
+ direction TEXT NOT NULL CHECK(direction IN ('in','out')),
70
+ type TEXT NOT NULL DEFAULT '',
71
+ quantity REAL NOT NULL,
72
+ before_qty REAL NOT NULL,
73
+ after_qty REAL NOT NULL,
74
+ remark TEXT NOT NULL DEFAULT '',
75
+ created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
76
+ );
77
+ CREATE INDEX IF NOT EXISTS idx_logs_project ON stock_logs(project_id, key_value);
78
+ CREATE TABLE IF NOT EXISTS stocktakes (
79
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
80
+ project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
81
+ status TEXT NOT NULL DEFAULT 'pending',
82
+ created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
83
+ confirmed_at TEXT
84
+ );
85
+ CREATE TABLE IF NOT EXISTS stocktake_items (
86
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
87
+ stocktake_id INTEGER NOT NULL REFERENCES stocktakes(id) ON DELETE CASCADE,
88
+ key_value TEXT NOT NULL,
89
+ system_qty REAL NOT NULL,
90
+ actual_qty REAL,
91
+ diff REAL
92
+ );
93
+ """)
94
+ conn.commit()
95
+
96
+
97
+ # ---------- 文件解析 ----------
98
+
99
+
100
+ def _detect_csv_encoding(filepath: str) -> str:
101
+ for enc in ("utf-8-sig", "utf-8", "gbk", "gb2312", "gb18030", "latin-1"):
102
+ try:
103
+ with open(filepath, "r", encoding=enc) as f:
104
+ f.read(4096)
105
+ return enc
106
+ except (UnicodeDecodeError, UnicodeError):
107
+ continue
108
+ return "utf-8"
109
+
110
+
111
+ def parse_file(filepath: str) -> tuple[list[str], list[dict]]:
112
+ """解析 CSV 或 XLSX 文件,返回 (表头列表, 数据行列表[dict])"""
113
+ p = Path(filepath)
114
+ if not p.is_file():
115
+ raise FileNotFoundError(f"文件不存在: {filepath}")
116
+ suffix = p.suffix.lower()
117
+ if suffix == ".csv":
118
+ return _parse_csv(filepath)
119
+ elif suffix in (".xlsx", ".xls"):
120
+ return _parse_xlsx(filepath)
121
+ else:
122
+ raise ValueError(f"不支持的文件格式: {suffix},仅支持 .csv / .xlsx")
123
+
124
+
125
+ def _parse_csv(filepath: str) -> tuple[list[str], list[dict]]:
126
+ enc = _detect_csv_encoding(filepath)
127
+ with open(filepath, "r", encoding=enc, newline="") as f:
128
+ reader = csv.DictReader(f)
129
+ headers = reader.fieldnames or []
130
+ headers = [h.strip() for h in headers if h and h.strip()]
131
+ rows = []
132
+ for row in reader:
133
+ cleaned = {k.strip(): (v.strip() if isinstance(v, str) else v) for k, v in row.items() if k and k.strip()}
134
+ rows.append(cleaned)
135
+ return headers, rows
136
+
137
+
138
+ def _parse_xlsx(filepath: str) -> tuple[list[str], list[dict]]:
139
+ try:
140
+ from openpyxl import load_workbook
141
+ except ImportError:
142
+ raise ImportError("解析 XLSX 文件需要 openpyxl,请执行: pip install openpyxl")
143
+ wb = load_workbook(filepath, read_only=True, data_only=True)
144
+ ws = wb.active
145
+ rows_iter = ws.iter_rows(values_only=True)
146
+ header_row = next(rows_iter, None)
147
+ if not header_row:
148
+ raise ValueError("XLSX 文件为空或无表头")
149
+ headers = [str(h).strip() for h in header_row if h is not None and str(h).strip()]
150
+ data = []
151
+ for row in rows_iter:
152
+ if all(c is None for c in row):
153
+ continue
154
+ record = {}
155
+ for i, h in enumerate(headers):
156
+ val = row[i] if i < len(row) else None
157
+ record[h] = str(val).strip() if val is not None else ""
158
+ data.append(record)
159
+ wb.close()
160
+ return headers, data
161
+
162
+
163
+ # ---------- 字段推断 ----------
164
+
165
+
166
+ def _guess_field(headers: list[str], hints: list[str]) -> str | None:
167
+ lower_headers = {h: h.lower() for h in headers}
168
+ for h, lh in lower_headers.items():
169
+ for hint in hints:
170
+ if hint.lower() in lh:
171
+ return h
172
+ return None
173
+
174
+
175
+ def guess_key_field(headers: list[str]) -> str | None:
176
+ return _guess_field(headers, KEY_FIELD_HINTS)
177
+
178
+
179
+ def guess_stock_field(headers: list[str]) -> str | None:
180
+ return _guess_field(headers, STOCK_FIELD_HINTS)
181
+
182
+
183
+ def infer_field_type(values: list[str]) -> str:
184
+ """从一列数据的样本推断类型: number / date / text"""
185
+ samples = [v for v in values if v and v.strip()][:50]
186
+ if not samples:
187
+ return "text"
188
+ num_count = 0
189
+ date_count = 0
190
+ date_re = re.compile(r"^\d{4}[-/]\d{1,2}[-/]\d{1,2}")
191
+ for s in samples:
192
+ s = s.strip()
193
+ try:
194
+ float(s.replace(",", ""))
195
+ num_count += 1
196
+ continue
197
+ except ValueError:
198
+ pass
199
+ if date_re.match(s):
200
+ date_count += 1
201
+ if num_count > len(samples) * 0.7:
202
+ return "number"
203
+ if date_count > len(samples) * 0.7:
204
+ return "date"
205
+ return "text"
206
+
207
+
208
+ NUMBER_NAME_HINTS = ["数量", "库存", "价", "金额", "成本", "重量", "amount", "price", "cost", "qty", "stock", "weight", "count"]
209
+ DATE_NAME_HINTS = ["日期", "时间", "date", "time", "到期", "生产", "保质期"]
210
+
211
+
212
+ def infer_type_from_name(field_name: str) -> str:
213
+ """仅根据字段名推断类型(用于自然语言建表,无数据样本时)。"""
214
+ lower = field_name.lower()
215
+ for hint in NUMBER_NAME_HINTS:
216
+ if hint in lower:
217
+ return "number"
218
+ for hint in DATE_NAME_HINTS:
219
+ if hint in lower:
220
+ return "date"
221
+ return "text"
222
+
223
+
224
+ def parse_fields_arg(fields_str: str) -> list[dict]:
225
+ """解析 --fields 参数,支持两种格式:
226
+ 1. 简单格式: "SKU,名称,颜色,库存数量,售价" 或 "SKU:text,库存数量:number"
227
+ 2. JSON 格式: '[{"name":"SKU","type":"text"},...]'
228
+ 返回 [{"name":..., "type":...}, ...]
229
+ """
230
+ s = fields_str.strip()
231
+ if s.startswith("["):
232
+ try:
233
+ items = json.loads(s)
234
+ except json.JSONDecodeError as e:
235
+ raise ValueError(
236
+ f"--fields JSON 解析失败: {e}。"
237
+ f"JSON 格式示例: '[{{\"name\":\"SKU\",\"type\":\"text\"}},{{\"name\":\"库存\",\"type\":\"number\"}}]'"
238
+ )
239
+ if not isinstance(items, list):
240
+ raise ValueError("--fields JSON 必须是数组格式 [...]")
241
+ result = []
242
+ for i, item in enumerate(items):
243
+ if isinstance(item, str):
244
+ result.append({"name": item.strip(), "type": infer_type_from_name(item.strip())})
245
+ elif isinstance(item, dict):
246
+ name = item.get("name", "").strip()
247
+ if not name:
248
+ raise ValueError(f"--fields JSON 第{i+1}项缺少 name 字段")
249
+ ft = item.get("type", "").strip() or infer_type_from_name(name)
250
+ if ft not in ("text", "number", "date"):
251
+ raise ValueError(f"字段「{name}」的 type「{ft}」无效,可选: text / number / date")
252
+ result.append({"name": name, "type": ft})
253
+ else:
254
+ raise ValueError(f"--fields JSON 第{i+1}项格式不正确,应为对象 {{\"name\":...,\"type\":...}} 或字符串")
255
+ return result
256
+
257
+ parts = [p.strip() for p in s.split(",") if p.strip()]
258
+ if not parts:
259
+ raise ValueError("--fields 不能为空")
260
+ result = []
261
+ for part in parts:
262
+ if ":" in part:
263
+ name, ft = part.rsplit(":", 1)
264
+ name = name.strip()
265
+ ft = ft.strip().lower()
266
+ if ft not in ("text", "number", "date"):
267
+ raise ValueError(
268
+ f"字段「{name}」的类型「{ft}」无效,可选: text / number / date。"
269
+ f"示例: \"SKU:text,库存数量:number,生产日期:date\""
270
+ )
271
+ else:
272
+ name = part.strip()
273
+ ft = infer_type_from_name(name)
274
+ if not name:
275
+ continue
276
+ result.append({"name": name, "type": ft})
277
+ return result
278
+
279
+
280
+ # ---------- 项目辅助 ----------
281
+
282
+
283
+ def resolve_project(conn: sqlite3.Connection, project_name: str | None) -> sqlite3.Row:
284
+ if project_name:
285
+ row = conn.execute("SELECT * FROM projects WHERE name = ?", (project_name,)).fetchone()
286
+ if not row:
287
+ names = _list_project_names(conn)
288
+ msg = f"项目「{project_name}」不存在"
289
+ if names:
290
+ msg += f",已有项目: {names}"
291
+ else:
292
+ msg += ",当前无任何项目,请先用 init 命令创建"
293
+ raise ValueError(msg)
294
+ return row
295
+ rows = conn.execute("SELECT * FROM projects").fetchall()
296
+ if len(rows) == 0:
297
+ raise ValueError(
298
+ "尚无任何项目,请先用 init 命令创建项目。"
299
+ "用法: init --project <项目名> --file <CSV或XLSX文件路径>"
300
+ )
301
+ if len(rows) == 1:
302
+ return rows[0]
303
+ names = [r["name"] for r in rows]
304
+ raise ValueError(
305
+ f"存在多个项目 {names},请用 --project <项目名> 指定要操作的项目"
306
+ )
307
+
308
+
309
+ def _get_field_config(project: sqlite3.Row) -> list[dict]:
310
+ return json.loads(project["field_config"] or "[]")
311
+
312
+
313
+ # ---------- 终端表格打印 ----------
314
+
315
+
316
+ def _display_width(s: str) -> int:
317
+ w = 0
318
+ for c in s:
319
+ w += 2 if "\u4e00" <= c <= "\u9fff" or "\u3000" <= c <= "\u303f" or "\uff00" <= c <= "\uffef" else 1
320
+ return w
321
+
322
+
323
+ def _pad(s: str, width: int, align: str = "left") -> str:
324
+ s = s or ""
325
+ cur = _display_width(s)
326
+ if cur >= width:
327
+ return s
328
+ pad = " " * (width - cur)
329
+ return (s + pad) if align == "left" else (pad + s)
330
+
331
+
332
+ def print_table(headers: list[str], rows: list[list[str]], aligns: list[str] | None = None) -> None:
333
+ if not headers:
334
+ return
335
+ if aligns is None:
336
+ aligns = ["left"] * len(headers)
337
+ col_widths = [max(_display_width(h) + 2, 6) for h in headers]
338
+ for row in rows:
339
+ for i, cell in enumerate(row):
340
+ if i < len(col_widths):
341
+ col_widths[i] = max(col_widths[i], _display_width(str(cell)) + 2)
342
+ cap = 40
343
+ col_widths = [min(w, cap) for w in col_widths]
344
+ sep = " "
345
+ print(sep.join(_pad(h, col_widths[i], aligns[i]) for i, h in enumerate(headers)))
346
+ print(sep.join("-" * w for w in col_widths))
347
+ for row in rows:
348
+ cells = []
349
+ for i in range(len(headers)):
350
+ val = str(row[i]) if i < len(row) else ""
351
+ if _display_width(val) > col_widths[i]:
352
+ val = val[: col_widths[i] - 1] + "…"
353
+ cells.append(_pad(val, col_widths[i], aligns[i] if i < len(aligns) else "left"))
354
+ print(sep.join(cells))
355
+
356
+
357
+ def _ok(data: dict | None = None, **kwargs) -> None:
358
+ out = {"ok": True}
359
+ if data:
360
+ out.update(data)
361
+ out.update(kwargs)
362
+ print(json.dumps(out, ensure_ascii=False, indent=2))
363
+
364
+
365
+ def _fail(error: str, *, usage: str | None = None, hint: str | None = None,
366
+ valid_values: list | dict | None = None, example: str | None = None, **kwargs) -> None:
367
+ """输出结构化错误信息,帮助调用方(LLM)快速定位问题并修正参数。
368
+ - error: 错误描述
369
+ - usage: 正确的命令用法示例
370
+ - hint: 修复建议
371
+ - valid_values: 合法取值范围(如项目列表、字段列表、商品标识列表)
372
+ - example: 正确参数的完整示例
373
+ """
374
+ out: dict = {"ok": False, "error": error}
375
+ if usage:
376
+ out["usage"] = usage
377
+ if hint:
378
+ out["hint"] = hint
379
+ if valid_values is not None:
380
+ out["valid_values"] = valid_values
381
+ if example:
382
+ out["example"] = example
383
+ out.update(kwargs)
384
+ print(json.dumps(out, ensure_ascii=False))
385
+
386
+
387
+ # ---------- 校验辅助 ----------
388
+
389
+
390
+ def _list_project_names(conn: sqlite3.Connection) -> list[str]:
391
+ return [r["name"] for r in conn.execute("SELECT name FROM projects ORDER BY name").fetchall()]
392
+
393
+
394
+ def _list_product_keys(conn: sqlite3.Connection, project_id: int, limit: int = 20) -> list[str]:
395
+ rows = conn.execute(
396
+ "SELECT key_value FROM products WHERE project_id = ? AND status = 'active' ORDER BY key_value LIMIT ?",
397
+ (project_id, limit),
398
+ ).fetchall()
399
+ return [r["key_value"] for r in rows]
400
+
401
+
402
+ def _product_not_found(conn: sqlite3.Connection, proj: sqlite3.Row, key: str) -> None:
403
+ """商品不存在时,输出详细错误:列出该项目已有的商品标识供参考。"""
404
+ pid = proj["id"]
405
+ keys = _list_product_keys(conn, pid)
406
+ total = conn.execute(
407
+ "SELECT COUNT(*) as c FROM products WHERE project_id = ? AND status = 'active'", (pid,)
408
+ ).fetchone()["c"]
409
+ suffix = f"(共 {total} 条,仅展示前 {len(keys)} 条)" if total > len(keys) else ""
410
+ _fail(
411
+ f"商品「{key}」在项目「{proj['name']}」中不存在",
412
+ hint=f"请检查商品标识是否正确。该项目的唯一标识字段为「{proj['key_field']}」",
413
+ valid_values={"existing_keys": keys, "note": suffix} if keys else None,
414
+ usage=f"stock-query --project {proj['name']} # 查看全部商品",
415
+ )
416
+
417
+
418
+ def _validate_json_data(data: dict, proj: sqlite3.Row, purpose: str = "add") -> str | None:
419
+ """校验 JSON 数据的字段是否与项目字段配置匹配。返回 None 表示通过,否则返回错误描述。"""
420
+ fields = _get_field_config(proj)
421
+ config_names = {f["name"] for f in fields}
422
+ unknown = [k for k in data.keys() if k not in config_names]
423
+ if unknown:
424
+ return (f"JSON 中包含未知字段 {unknown},"
425
+ f"该项目可用字段为: {sorted(config_names)}")
426
+ return None
427
+
428
+
429
+ def _validate_date(date_str: str, param_name: str) -> str | None:
430
+ """校验日期格式 YYYY-MM-DD,返回 None 通过,否则返回错误描述。"""
431
+ if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str):
432
+ return f"参数 {param_name} 日期格式不正确:「{date_str}」,正确格式为 YYYY-MM-DD(如 2026-01-15)"
433
+ try:
434
+ datetime.strptime(date_str, "%Y-%m-%d")
435
+ except ValueError:
436
+ return f"参数 {param_name} 日期无效:「{date_str}」,请输入合法日期(如 2026-01-15)"
437
+ return None
438
+
439
+
440
+ def _validate_filter(filter_str: str, proj: sqlite3.Row) -> str | None:
441
+ """校验 --filter 格式和字段是否存在。"""
442
+ if "=" not in filter_str:
443
+ return (f"--filter 格式不正确:「{filter_str}」,正确格式为「字段名=值」(如 --filter 分类=配件)")
444
+ fk, _ = filter_str.split("=", 1)
445
+ fk = fk.strip()
446
+ fields = _get_field_config(proj)
447
+ config_names = [f["name"] for f in fields]
448
+ if fk not in config_names:
449
+ return f"--filter 中的字段名「{fk}」不存在,该项目可用字段为: {config_names}"
450
+ return None
451
+
452
+
453
+ # ========== 命令实现 ==========
454
+
455
+ # ---------- create(自然语言建表) ----------
456
+
457
+
458
+ def cmd_create(args: argparse.Namespace) -> int:
459
+ """通过直接指定字段来创建项目(无需上传文件)。适用于用户用自然语言描述表结构的场景。"""
460
+ project_name = args.project
461
+ fields_str = args.fields
462
+ key_field = args.key_field
463
+ stock_field = args.stock_field
464
+ threshold = getattr(args, "low_stock_threshold", 10)
465
+
466
+ if not project_name or not project_name.strip():
467
+ _fail("--project 项目名称不能为空",
468
+ usage='create --project <项目名> --fields "字段1,字段2,..." --key-field <标识字段> --stock-field <库存字段>',
469
+ example='create --project 服装仓库 --fields "SKU,名称,颜色,尺码,库存数量,售价" --key-field SKU --stock-field 库存数量')
470
+ return 1
471
+
472
+ if not fields_str or not fields_str.strip():
473
+ _fail("--fields 字段定义不能为空",
474
+ hint="支持两种格式:\n"
475
+ " 1. 简单格式(逗号分隔): \"SKU,名称,颜色,库存数量,售价\" 或 \"SKU:text,库存数量:number\"\n"
476
+ " 2. JSON 格式: '[{\"name\":\"SKU\",\"type\":\"text\"},{\"name\":\"库存数量\",\"type\":\"number\"}]'",
477
+ example='create --project 服装仓库 --fields "SKU,名称,颜色,尺码,库存数量,售价" --key-field SKU --stock-field 库存数量')
478
+ return 1
479
+
480
+ try:
481
+ field_list = parse_fields_arg(fields_str)
482
+ except ValueError as e:
483
+ _fail(str(e),
484
+ hint="--fields 支持两种格式:\n"
485
+ " 1. 简单: \"SKU,名称,颜色,库存数量:number,售价:number\"\n"
486
+ " 2. JSON: '[{\"name\":\"SKU\",\"type\":\"text\"},...]'\n"
487
+ " 类型可选: text(默认) / number / date,不指定时根据字段名自动推断")
488
+ return 1
489
+
490
+ if len(field_list) < 2:
491
+ _fail("至少需要 2 个字段(一个唯一标识 + 一个库存数量)",
492
+ example='--fields "SKU,名称,库存数量,售价"')
493
+ return 1
494
+
495
+ field_names = [f["name"] for f in field_list]
496
+ dup = [n for n in field_names if field_names.count(n) > 1]
497
+ if dup:
498
+ _fail(f"字段名重复: {list(set(dup))},每个字段名必须唯一")
499
+ return 1
500
+
501
+ if not key_field:
502
+ key_field = guess_key_field(field_names)
503
+ if not stock_field:
504
+ stock_field = guess_stock_field(field_names)
505
+
506
+ guessed = []
507
+ if key_field and not args.key_field:
508
+ guessed.append(f"唯一标识字段 → {key_field}")
509
+ if stock_field and not args.stock_field:
510
+ guessed.append(f"库存数量字段 → {stock_field}")
511
+
512
+ if not key_field:
513
+ _fail("无法自动推断唯一标识字段,请用 --key-field 指定",
514
+ hint="请从字段列表中选择一个作为商品唯一标识(如 SKU、编号)",
515
+ valid_values=field_names,
516
+ example=f'create --project {project_name} --fields "{fields_str}" --key-field {field_names[0]} --stock-field <库存字段>')
517
+ return 1
518
+ if not stock_field:
519
+ _fail("无法自动推断库存数量字段,请用 --stock-field 指定",
520
+ hint="请从字段列表中选择一个作为库存数量字段(值须为数字)",
521
+ valid_values=field_names,
522
+ example=f'create --project {project_name} --fields "{fields_str}" --key-field {key_field} --stock-field {field_names[-1]}')
523
+ return 1
524
+ if key_field not in field_names:
525
+ _fail(f"--key-field「{key_field}」不在字段列表中",
526
+ valid_values=field_names)
527
+ return 1
528
+ if stock_field not in field_names:
529
+ _fail(f"--stock-field「{stock_field}」不在字段列表中",
530
+ valid_values=field_names)
531
+ return 1
532
+ if key_field == stock_field:
533
+ _fail(f"唯一标识字段和库存数量字段不能相同(都是「{key_field}」)",
534
+ valid_values=field_names)
535
+ return 1
536
+
537
+ for f in field_list:
538
+ if f["name"] == key_field:
539
+ f["role"] = "key"
540
+ elif f["name"] == stock_field:
541
+ f["role"] = "stock"
542
+ f["type"] = "number"
543
+
544
+ conn = get_db()
545
+ existing = conn.execute("SELECT id FROM projects WHERE name = ?", (project_name,)).fetchone()
546
+ if existing:
547
+ _fail(f"项目「{project_name}」已存在,不能重复创建",
548
+ hint="若要向已有项目添加商品,请使用 product-add 命令",
549
+ valid_values={"existing_projects": _list_project_names(conn)})
550
+ conn.close()
551
+ return 1
552
+
553
+ conn.execute(
554
+ "INSERT INTO projects (name, field_config, key_field, stock_field, low_stock_threshold) VALUES (?,?,?,?,?)",
555
+ (project_name, json.dumps(field_list, ensure_ascii=False), key_field, stock_field, threshold),
556
+ )
557
+ conn.commit()
558
+ conn.close()
559
+
560
+ print(f"项目「{project_name}」创建成功(空表,无初始数据)")
561
+ if guessed:
562
+ print("自动推断:" + ",".join(guessed))
563
+ print(f"字段({len(field_list)}个):")
564
+ for f in field_list:
565
+ role = f.get("role", "")
566
+ role_label = " ← 唯一标识" if role == "key" else (" ← 库存数量" if role == "stock" else "")
567
+ print(f" - {f['name']} ({f['type']}){role_label}")
568
+ print(f"\n可通过以下方式添加商品:")
569
+ print(f" 1. 手动添加: product-add --project {project_name} --data '<JSON>'")
570
+ print(f" 2. 文件导入: import --project {project_name} --file <CSV/XLSX路径>")
571
+ _ok(project=project_name, fields=[f["name"] for f in field_list],
572
+ key_field=key_field, stock_field=stock_field, product_count=0)
573
+ return 0
574
+
575
+
576
+ # ---------- init ----------
577
+
578
+
579
+ def cmd_init(args: argparse.Namespace) -> int:
580
+ project_name = args.project
581
+ filepath = args.file
582
+ key_field = getattr(args, "key_field", None)
583
+ stock_field = getattr(args, "stock_field", None)
584
+ threshold = getattr(args, "low_stock_threshold", 10)
585
+
586
+ if not project_name or not project_name.strip():
587
+ _fail("--project 项目名称不能为空",
588
+ usage="init --project <项目名> --file <文件路径>",
589
+ example='init --project 原材料仓库 --file /path/to/data.csv')
590
+ return 1
591
+
592
+ if not filepath or not filepath.strip():
593
+ _fail("--file 文件路径不能为空",
594
+ usage="init --project <项目名> --file <CSV或XLSX文件路径>",
595
+ example='init --project 原材料仓库 --file /path/to/data.csv')
596
+ return 1
597
+
598
+ p = Path(filepath)
599
+ if not p.exists():
600
+ _fail(f"文件不存在: {filepath}",
601
+ hint="请检查文件路径是否正确,路径需为绝对路径或相对于当前工作目录的路径",
602
+ usage="init --project <项目名> --file <CSV或XLSX文件路径>")
603
+ return 1
604
+ if p.suffix.lower() not in (".csv", ".xlsx", ".xls"):
605
+ _fail(f"不支持的文件格式: {p.suffix}",
606
+ hint="仅支持 .csv 和 .xlsx 格式",
607
+ example="init --project 仓库A --file /path/to/data.csv")
608
+ return 1
609
+
610
+ try:
611
+ headers, data = parse_file(filepath)
612
+ except Exception as e:
613
+ _fail(f"文件解析失败: {e}",
614
+ hint="请确认文件格式正确且非空。CSV 文件第一行为表头,XLSX 需要 openpyxl(pip install openpyxl)")
615
+ return 1
616
+
617
+ if not headers:
618
+ _fail("文件无有效表头(第一行为空或无可识别的列名)",
619
+ hint="CSV/XLSX 文件的第一行必须为列名表头,如: SKU编码,商品名称,库存数量,...")
620
+ return 1
621
+
622
+ if len(data) == 0:
623
+ _fail("文件有表头但无数据行",
624
+ hint=f"检测到表头列: {headers},但文件中没有数据行,请确认文件内容")
625
+ return 1
626
+
627
+ if not key_field:
628
+ key_field = guess_key_field(headers)
629
+ if not stock_field:
630
+ stock_field = guess_stock_field(headers)
631
+
632
+ guessed = []
633
+ if key_field and getattr(args, "key_field", None) is None:
634
+ guessed.append(f"唯一标识字段 → {key_field}")
635
+ if stock_field and getattr(args, "stock_field", None) is None:
636
+ guessed.append(f"库存数量字段 → {stock_field}")
637
+
638
+ if not key_field:
639
+ _fail("无法自动推断唯一标识字段(如 SKU/编号),请用 --key-field 指定",
640
+ hint="请从以下列名中选择一个作为商品唯一标识",
641
+ valid_values=headers,
642
+ example=f'init --project {project_name} --file {filepath} --key-field {headers[0]} --stock-field <库存列名>')
643
+ return 1
644
+ if not stock_field:
645
+ _fail("无法自动推断库存数量字段(如 库存/数量),请用 --stock-field 指定",
646
+ hint="请从以下列名中选择一个作为库存数量字段(该字段的值须为数字)",
647
+ valid_values=headers,
648
+ example=f'init --project {project_name} --file {filepath} --key-field {key_field} --stock-field {headers[-1]}')
649
+ return 1
650
+ if key_field not in headers:
651
+ _fail(f"指定的唯一标识字段「{key_field}」不在文件表头中",
652
+ hint="--key-field 的值必须是文件表头中的某一列名",
653
+ valid_values=headers)
654
+ return 1
655
+ if stock_field not in headers:
656
+ _fail(f"指定的库存数量字段「{stock_field}」不在文件表头中",
657
+ hint="--stock-field 的值必须是文件表头中的某一列名",
658
+ valid_values=headers)
659
+ return 1
660
+ if key_field == stock_field:
661
+ _fail(f"唯一标识字段和库存数量字段不能相同(都是「{key_field}」)",
662
+ hint="--key-field 和 --stock-field 必须是不同的列",
663
+ valid_values=headers)
664
+ return 1
665
+
666
+ col_values: dict[str, list[str]] = {h: [] for h in headers}
667
+ for row in data:
668
+ for h in headers:
669
+ col_values[h].append(str(row.get(h, "")))
670
+
671
+ field_config = []
672
+ for h in headers:
673
+ ft = infer_field_type(col_values[h])
674
+ entry: dict = {"name": h, "type": ft}
675
+ if h == key_field:
676
+ entry["role"] = "key"
677
+ elif h == stock_field:
678
+ entry["role"] = "stock"
679
+ entry["type"] = "number"
680
+ field_config.append(entry)
681
+
682
+ conn = get_db()
683
+ existing = conn.execute("SELECT id FROM projects WHERE name = ?", (project_name,)).fetchone()
684
+ if existing:
685
+ _fail(f"项目「{project_name}」已存在,不能重复创建",
686
+ hint="若要向该项目追加/更新数据,请使用 import 命令",
687
+ usage=f'import --project {project_name} --file <新的CSV文件路径>',
688
+ valid_values={"existing_projects": _list_project_names(conn)})
689
+ conn.close()
690
+ return 1
691
+
692
+ conn.execute(
693
+ "INSERT INTO projects (name, field_config, key_field, stock_field, low_stock_threshold) VALUES (?,?,?,?,?)",
694
+ (project_name, json.dumps(field_config, ensure_ascii=False), key_field, stock_field, threshold),
695
+ )
696
+ conn.commit()
697
+ proj = conn.execute("SELECT * FROM projects WHERE name = ?", (project_name,)).fetchone()
698
+ pid = proj["id"]
699
+
700
+ inserted = 0
701
+ skipped = []
702
+ for i, row in enumerate(data, start=2):
703
+ kv = str(row.get(key_field, "")).strip()
704
+ if not kv:
705
+ skipped.append(f"第{i}行: 唯一标识为空")
706
+ continue
707
+ sq_raw = str(row.get(stock_field, "0")).strip().replace(",", "")
708
+ try:
709
+ sq = float(sq_raw) if sq_raw else 0
710
+ except ValueError:
711
+ sq = 0
712
+ try:
713
+ conn.execute(
714
+ "INSERT INTO products (project_id, key_value, stock_qty, data) VALUES (?,?,?,?)",
715
+ (pid, kv, sq, json.dumps(row, ensure_ascii=False)),
716
+ )
717
+ inserted += 1
718
+ except sqlite3.IntegrityError:
719
+ skipped.append(f"第{i}行: 标识「{kv}」重复")
720
+ conn.commit()
721
+ conn.close()
722
+
723
+ print(f"项目「{project_name}」创建成功")
724
+ if guessed:
725
+ print("自动推断:" + ",".join(guessed))
726
+ print(f"字段({len(field_config)}个): {', '.join(h for h in headers)}")
727
+ print(f"导入: 成功 {inserted} 条" + (f",跳过 {len(skipped)} 条" if skipped else ""))
728
+ if skipped:
729
+ for s in skipped[:10]:
730
+ print(f" - {s}")
731
+ _ok(project=project_name, imported=inserted, skipped=len(skipped), fields=[f["name"] for f in field_config],
732
+ key_field=key_field, stock_field=stock_field)
733
+ return 0
734
+
735
+
736
+ # ---------- import ----------
737
+
738
+
739
+ def cmd_import(args: argparse.Namespace) -> int:
740
+ if not getattr(args, "file", None) or not args.file.strip():
741
+ _fail("--file 文件路径不能为空",
742
+ usage="import --project <项目名> --file <CSV或XLSX文件路径>")
743
+ return 1
744
+
745
+ conn = get_db()
746
+ try:
747
+ proj = resolve_project(conn, args.project)
748
+ except ValueError as e:
749
+ _fail(str(e), usage="import --project <项目名> --file <文件路径>",
750
+ valid_values={"existing_projects": _list_project_names(conn)})
751
+ conn.close()
752
+ return 1
753
+
754
+ p = Path(args.file)
755
+ if not p.exists():
756
+ _fail(f"文件不存在: {args.file}",
757
+ hint="请检查文件路径是否正确")
758
+ conn.close()
759
+ return 1
760
+
761
+ try:
762
+ headers, data = parse_file(args.file)
763
+ except Exception as e:
764
+ _fail(f"文件解析失败: {e}")
765
+ conn.close()
766
+ return 1
767
+
768
+ pid = proj["id"]
769
+ key_field = proj["key_field"]
770
+ stock_field = proj["stock_field"]
771
+ config_fields = {f["name"] for f in _get_field_config(proj)}
772
+
773
+ unknown = [h for h in headers if h not in config_fields]
774
+ if unknown:
775
+ _fail(f"文件中存在项目「{proj['name']}」未定义的字段: {unknown}",
776
+ hint="导入文件的列名必须与项目已有字段一致(允许缺列,不允许多出新列)。"
777
+ "如需新增字段请先用 field-add 命令",
778
+ valid_values={"project_fields": sorted(config_fields), "file_unknown_fields": unknown},
779
+ usage=f"field-add --project {proj['name']} --name <新字段名> --type text|number|date")
780
+ conn.close()
781
+ return 1
782
+
783
+ if key_field not in headers:
784
+ _fail(f"文件缺少唯一标识字段「{key_field}」",
785
+ hint=f"导入文件的表头中必须包含项目的唯一标识字段「{key_field}」",
786
+ valid_values={"required_field": key_field, "file_headers": headers})
787
+ conn.close()
788
+ return 1
789
+
790
+ inserted = 0
791
+ updated = 0
792
+ skipped = []
793
+ for i, row in enumerate(data, start=2):
794
+ kv = str(row.get(key_field, "")).strip()
795
+ if not kv:
796
+ skipped.append(f"第{i}行: 唯一标识为空")
797
+ continue
798
+ sq_raw = str(row.get(stock_field, "0")).strip().replace(",", "") if stock_field in row else None
799
+ existing = conn.execute(
800
+ "SELECT id, data FROM products WHERE project_id = ? AND key_value = ? AND status = 'active'",
801
+ (pid, kv),
802
+ ).fetchone()
803
+ if existing:
804
+ old_data = json.loads(existing["data"] or "{}")
805
+ old_data.update(row)
806
+ sq = float(sq_raw) if sq_raw else float(str(old_data.get(stock_field, 0)).replace(",", "") or 0)
807
+ conn.execute(
808
+ "UPDATE products SET data = ?, stock_qty = ?, updated_at = datetime('now','localtime') WHERE id = ?",
809
+ (json.dumps(old_data, ensure_ascii=False), sq, existing["id"]),
810
+ )
811
+ updated += 1
812
+ else:
813
+ sq = float(sq_raw) if sq_raw else 0
814
+ conn.execute(
815
+ "INSERT INTO products (project_id, key_value, stock_qty, data) VALUES (?,?,?,?)",
816
+ (pid, kv, sq, json.dumps(row, ensure_ascii=False)),
817
+ )
818
+ inserted += 1
819
+ conn.commit()
820
+ conn.close()
821
+ print(f"导入完成: 新增 {inserted} 条,更新 {updated} 条" + (f",跳过 {len(skipped)} 条" if skipped else ""))
822
+ if skipped:
823
+ for s in skipped[:10]:
824
+ print(f" - {s}")
825
+ _ok(inserted=inserted, updated=updated, skipped=len(skipped))
826
+ return 0
827
+
828
+
829
+ # ---------- project-list ----------
830
+
831
+
832
+ def cmd_project_list(args: argparse.Namespace) -> int:
833
+ conn = get_db()
834
+ projects = conn.execute("SELECT * FROM projects ORDER BY created_at").fetchall()
835
+ if not projects:
836
+ print("暂无项目,请先用 init 命令创建。")
837
+ conn.close()
838
+ return 0
839
+ rows = []
840
+ for p in projects:
841
+ cnt = conn.execute(
842
+ "SELECT COUNT(*) as c FROM products WHERE project_id = ? AND status = 'active'", (p["id"],)
843
+ ).fetchone()["c"]
844
+ fields = _get_field_config(p)
845
+ rows.append([p["name"], str(cnt), str(len(fields)), p["key_field"], p["stock_field"], p["created_at"]])
846
+ conn.close()
847
+ print_table(["项目名称", "商品数", "字段数", "标识字段", "库存字段", "创建时间"], rows)
848
+ return 0
849
+
850
+
851
+ # ---------- project-delete ----------
852
+
853
+
854
+ def cmd_project_delete(args: argparse.Namespace) -> int:
855
+ conn = get_db()
856
+ try:
857
+ proj = resolve_project(conn, args.project)
858
+ except ValueError as e:
859
+ _fail(str(e), usage="project-delete --project <项目名>",
860
+ valid_values={"existing_projects": _list_project_names(conn)})
861
+ conn.close()
862
+ return 1
863
+ pid = proj["id"]
864
+ cnt = conn.execute(
865
+ "SELECT COUNT(*) as c FROM products WHERE project_id = ? AND status = 'active'", (pid,)
866
+ ).fetchone()["c"]
867
+ conn.execute("DELETE FROM products WHERE project_id = ?", (pid,))
868
+ conn.execute("DELETE FROM stock_logs WHERE project_id = ?", (pid,))
869
+ conn.execute("DELETE FROM stocktake_items WHERE stocktake_id IN (SELECT id FROM stocktakes WHERE project_id = ?)", (pid,))
870
+ conn.execute("DELETE FROM stocktakes WHERE project_id = ?", (pid,))
871
+ conn.execute("DELETE FROM projects WHERE id = ?", (pid,))
872
+ conn.commit()
873
+ conn.close()
874
+ print(f"项目「{proj['name']}」已删除(含 {cnt} 条商品及所有变动记录)")
875
+ _ok(project=proj["name"], deleted_products=cnt)
876
+ return 0
877
+
878
+
879
+ # ---------- field-list ----------
880
+
881
+
882
+ def cmd_field_list(args: argparse.Namespace) -> int:
883
+ conn = get_db()
884
+ try:
885
+ proj = resolve_project(conn, args.project)
886
+ except ValueError as e:
887
+ _fail(str(e), usage="field-list --project <项目名>",
888
+ valid_values={"existing_projects": _list_project_names(conn)})
889
+ conn.close()
890
+ return 1
891
+ conn.close()
892
+ fields = _get_field_config(proj)
893
+ rows = []
894
+ for f in fields:
895
+ role = f.get("role", "")
896
+ role_zh = {"key": "唯一标识", "stock": "库存数量"}.get(role, "—")
897
+ rows.append([f["name"], f.get("type", "text"), role_zh])
898
+ print(f"项目「{proj['name']}」字段配置:")
899
+ print_table(["字段名", "类型", "角色"], rows)
900
+ return 0
901
+
902
+
903
+ # ---------- field-add ----------
904
+
905
+
906
+ def cmd_field_add(args: argparse.Namespace) -> int:
907
+ conn = get_db()
908
+ try:
909
+ proj = resolve_project(conn, args.project)
910
+ except ValueError as e:
911
+ _fail(str(e), usage="field-add --project <项目名> --name <字段名> --type text|number|date",
912
+ valid_values={"existing_projects": _list_project_names(conn)})
913
+ conn.close()
914
+ return 1
915
+
916
+ if not getattr(args, "name", None) or not args.name.strip():
917
+ _fail("--name 字段名不能为空",
918
+ usage="field-add --project <项目名> --name <字段名> --type text|number|date",
919
+ example=f"field-add --project {proj['name']} --name 保质期 --type date")
920
+ conn.close()
921
+ return 1
922
+
923
+ fields = _get_field_config(proj)
924
+ existing_names = {f["name"] for f in fields}
925
+ if args.name in existing_names:
926
+ _fail(f"字段「{args.name}」在项目「{proj['name']}」中已存在",
927
+ valid_values={"existing_fields": sorted(existing_names)})
928
+ conn.close()
929
+ return 1
930
+ fields.append({"name": args.name, "type": args.type or "text"})
931
+ conn.execute("UPDATE projects SET field_config = ? WHERE id = ?",
932
+ (json.dumps(fields, ensure_ascii=False), proj["id"]))
933
+ conn.commit()
934
+ conn.close()
935
+ print(f"已为项目「{proj['name']}」新增字段「{args.name}」(类型: {args.type or 'text'})")
936
+ _ok(field=args.name, type=args.type or "text")
937
+ return 0
938
+
939
+
940
+ # ---------- field-delete ----------
941
+
942
+
943
+ def cmd_field_delete(args: argparse.Namespace) -> int:
944
+ conn = get_db()
945
+ try:
946
+ proj = resolve_project(conn, args.project)
947
+ except ValueError as e:
948
+ _fail(str(e), usage="field-delete --project <项目名> --name <字段名>",
949
+ valid_values={"existing_projects": _list_project_names(conn)})
950
+ conn.close()
951
+ return 1
952
+
953
+ if not getattr(args, "name", None) or not args.name.strip():
954
+ fields = _get_field_config(proj)
955
+ _fail("--name 字段名不能为空",
956
+ hint="请指定要删除的字段名",
957
+ valid_values={"deletable_fields": [
958
+ f["name"] for f in fields if f.get("role") not in ("key", "stock")
959
+ ]},
960
+ usage=f"field-delete --project {proj['name']} --name <字段名>")
961
+ conn.close()
962
+ return 1
963
+
964
+ fields = _get_field_config(proj)
965
+ field_names = [f["name"] for f in fields]
966
+ deletable = [f["name"] for f in fields if f.get("role") not in ("key", "stock")]
967
+
968
+ to_delete = [n.strip() for n in args.name.split(",") if n.strip()]
969
+ if not to_delete:
970
+ _fail("--name 解析后为空,请提供要删除的字段名",
971
+ valid_values={"deletable_fields": deletable})
972
+ conn.close()
973
+ return 1
974
+
975
+ blocked = []
976
+ not_found = []
977
+ will_delete = []
978
+ for name in to_delete:
979
+ match = next((f for f in fields if f["name"] == name), None)
980
+ if not match:
981
+ not_found.append(name)
982
+ elif match.get("role") in ("key", "stock"):
983
+ blocked.append(name)
984
+ else:
985
+ will_delete.append(name)
986
+
987
+ if not_found:
988
+ _fail(f"以下字段在项目「{proj['name']}」中不存在: {not_found}",
989
+ hint="请检查字段名是否正确",
990
+ valid_values={"existing_fields": field_names, "deletable_fields": deletable})
991
+ conn.close()
992
+ return 1
993
+ if blocked:
994
+ key_f = proj["key_field"]
995
+ stock_f = proj["stock_field"]
996
+ _fail(f"以下字段是核心字段,不能删除: {blocked}(唯一标识:「{key_f}」,库存数量:「{stock_f}」)",
997
+ hint="唯一标识字段和库存数量字段是项目正常运行的基础,不可删除",
998
+ valid_values={"deletable_fields": deletable})
999
+ conn.close()
1000
+ return 1
1001
+ if not will_delete:
1002
+ _fail("没有可删除的字段",
1003
+ valid_values={"deletable_fields": deletable})
1004
+ conn.close()
1005
+ return 1
1006
+
1007
+ new_fields = [f for f in fields if f["name"] not in will_delete]
1008
+ conn.execute("UPDATE projects SET field_config = ? WHERE id = ?",
1009
+ (json.dumps(new_fields, ensure_ascii=False), proj["id"]))
1010
+
1011
+ pid = proj["id"]
1012
+ products = conn.execute(
1013
+ "SELECT id, data FROM products WHERE project_id = ? AND status = 'active'", (pid,)
1014
+ ).fetchall()
1015
+ for p in products:
1016
+ data = json.loads(p["data"] or "{}")
1017
+ changed = False
1018
+ for name in will_delete:
1019
+ if name in data:
1020
+ del data[name]
1021
+ changed = True
1022
+ if changed:
1023
+ conn.execute("UPDATE products SET data = ?, updated_at = datetime('now','localtime') WHERE id = ?",
1024
+ (json.dumps(data, ensure_ascii=False), p["id"]))
1025
+ conn.commit()
1026
+ conn.close()
1027
+
1028
+ remaining = [f["name"] for f in new_fields]
1029
+ print(f"已从项目「{proj['name']}」删除字段: {will_delete}")
1030
+ print(f"剩余字段({len(remaining)}个): {remaining}")
1031
+ _ok(deleted_fields=will_delete, remaining_fields=remaining, affected_products=len(products))
1032
+ return 0
1033
+
1034
+
1035
+ # ---------- product-add ----------
1036
+
1037
+
1038
+ def cmd_product_add(args: argparse.Namespace) -> int:
1039
+ conn = get_db()
1040
+ try:
1041
+ proj = resolve_project(conn, args.project)
1042
+ except ValueError as e:
1043
+ _fail(str(e), usage="product-add --project <项目名> --data '<JSON>'",
1044
+ valid_values={"existing_projects": _list_project_names(conn)})
1045
+ conn.close()
1046
+ return 1
1047
+
1048
+ if not getattr(args, "data", None) or not args.data.strip():
1049
+ fields = _get_field_config(proj)
1050
+ field_names = [f["name"] for f in fields]
1051
+ example_data = {fn: "<值>" for fn in field_names}
1052
+ _fail("--data 参数不能为空,需传入 JSON 格式的商品数据",
1053
+ hint=f"该项目的字段为: {field_names}",
1054
+ example=f'product-add --project {proj["name"]} --data \'{json.dumps(example_data, ensure_ascii=False)}\'')
1055
+ conn.close()
1056
+ return 1
1057
+
1058
+ try:
1059
+ data = json.loads(args.data)
1060
+ except json.JSONDecodeError as e:
1061
+ fields = _get_field_config(proj)
1062
+ field_names = [f["name"] for f in fields]
1063
+ example_data = {fn: "<值>" for fn in field_names}
1064
+ _fail(f"--data JSON 解析失败: {e}",
1065
+ hint="--data 的值必须是合法的 JSON 字符串,注意使用英文双引号",
1066
+ example=f'--data \'{json.dumps(example_data, ensure_ascii=False)}\'')
1067
+ conn.close()
1068
+ return 1
1069
+
1070
+ if not isinstance(data, dict):
1071
+ _fail("--data 必须是 JSON 对象(字典),不能是数组或其他类型",
1072
+ example='--data \'{"SKU":"BT-003","商品名":"蓝牙音箱","库存":0}\'')
1073
+ conn.close()
1074
+ return 1
1075
+
1076
+ field_err = _validate_json_data(data, proj, purpose="add")
1077
+ if field_err:
1078
+ fields = _get_field_config(proj)
1079
+ field_names = [f["name"] for f in fields]
1080
+ _fail(field_err,
1081
+ hint="--data 中的 key 必须是项目已定义的字段名",
1082
+ valid_values={"project_fields": field_names})
1083
+ conn.close()
1084
+ return 1
1085
+
1086
+ key_field = proj["key_field"]
1087
+ stock_field = proj["stock_field"]
1088
+ kv = str(data.get(key_field, "")).strip()
1089
+ if not kv:
1090
+ fields = _get_field_config(proj)
1091
+ field_names = [f["name"] for f in fields]
1092
+ _fail(f"JSON 数据中缺少唯一标识字段「{key_field}」或其值为空",
1093
+ hint=f"唯一标识字段「{key_field}」为必填,不能为空",
1094
+ example=f'--data \'{{"{ key_field}":"XX-001",...}}\'',
1095
+ valid_values={"key_field": key_field, "all_fields": field_names})
1096
+ conn.close()
1097
+ return 1
1098
+
1099
+ sq_raw = str(data.get(stock_field, "0")).strip().replace(",", "")
1100
+ try:
1101
+ sq = float(sq_raw) if sq_raw else 0
1102
+ except ValueError:
1103
+ _fail(f"库存字段「{stock_field}」的值「{data.get(stock_field)}」不是有效数字",
1104
+ hint=f"「{stock_field}」字段值必须为数字(如 0、100、50.5)")
1105
+ conn.close()
1106
+ return 1
1107
+
1108
+ try:
1109
+ conn.execute(
1110
+ "INSERT INTO products (project_id, key_value, stock_qty, data) VALUES (?,?,?,?)",
1111
+ (proj["id"], kv, sq, json.dumps(data, ensure_ascii=False)),
1112
+ )
1113
+ conn.commit()
1114
+ except sqlite3.IntegrityError:
1115
+ _fail(f"商品「{kv}」在项目「{proj['name']}」中已存在,不能重复添加",
1116
+ hint="若要更新已有商品,请使用 product-edit 命令",
1117
+ usage=f'product-edit --project {proj["name"]} --key {kv} --set \'{{...}}\'')
1118
+ conn.close()
1119
+ return 1
1120
+ conn.close()
1121
+ print(f"商品「{kv}」添加成功,库存: {sq}")
1122
+ _ok(key=kv, stock=sq)
1123
+ return 0
1124
+
1125
+
1126
+ # ---------- product-edit ----------
1127
+
1128
+
1129
+ def cmd_product_edit(args: argparse.Namespace) -> int:
1130
+ conn = get_db()
1131
+ try:
1132
+ proj = resolve_project(conn, args.project)
1133
+ except ValueError as e:
1134
+ _fail(str(e), usage="product-edit --project <项目名> --key <商品标识> --set '<JSON>'",
1135
+ valid_values={"existing_projects": _list_project_names(conn)})
1136
+ conn.close()
1137
+ return 1
1138
+
1139
+ if not getattr(args, "key", None) or not args.key.strip():
1140
+ _fail("--key 参数不能为空",
1141
+ hint=f"请传入商品的唯一标识值(字段名:「{proj['key_field']}」)",
1142
+ usage=f"product-edit --project {proj['name']} --key <标识值> --set '<JSON>'")
1143
+ conn.close()
1144
+ return 1
1145
+
1146
+ if not getattr(args, "set", None) or not args.set.strip():
1147
+ fields = _get_field_config(proj)
1148
+ field_names = [f["name"] for f in fields]
1149
+ _fail("--set 参数不能为空,需传入要更新的字段 JSON",
1150
+ hint=f"该项目可更新的字段: {field_names}",
1151
+ example=f'product-edit --project {proj["name"]} --key {args.key} --set \'{{"售价":299}}\'')
1152
+ conn.close()
1153
+ return 1
1154
+
1155
+ try:
1156
+ updates = json.loads(args.set)
1157
+ except json.JSONDecodeError as e:
1158
+ fields = _get_field_config(proj)
1159
+ field_names = [f["name"] for f in fields]
1160
+ _fail(f"--set JSON 解析失败: {e}",
1161
+ hint="--set 的值必须是合法 JSON 对象,只需包含要修改的字段",
1162
+ example=f'--set \'{{"售价":259,"商品名":"新名称"}}\'',
1163
+ valid_values={"project_fields": field_names})
1164
+ conn.close()
1165
+ return 1
1166
+
1167
+ if not isinstance(updates, dict):
1168
+ _fail("--set 必须是 JSON 对象(字典),不能是数组或其他类型",
1169
+ example='--set \'{"售价":259}\'')
1170
+ conn.close()
1171
+ return 1
1172
+
1173
+ if not updates:
1174
+ _fail("--set JSON 对象为空,没有需要更新的字段",
1175
+ example='--set \'{"售价":259}\'')
1176
+ conn.close()
1177
+ return 1
1178
+
1179
+ field_err = _validate_json_data(updates, proj, purpose="edit")
1180
+ if field_err:
1181
+ fields = _get_field_config(proj)
1182
+ field_names = [f["name"] for f in fields]
1183
+ _fail(field_err,
1184
+ hint="--set 中的 key 必须是项目已定义的字段名",
1185
+ valid_values={"project_fields": field_names})
1186
+ conn.close()
1187
+ return 1
1188
+
1189
+ pid = proj["id"]
1190
+ row = conn.execute(
1191
+ "SELECT * FROM products WHERE project_id = ? AND key_value = ? AND status = 'active'",
1192
+ (pid, args.key),
1193
+ ).fetchone()
1194
+ if not row:
1195
+ _product_not_found(conn, proj, args.key)
1196
+ conn.close()
1197
+ return 1
1198
+
1199
+ old_data = json.loads(row["data"] or "{}")
1200
+ old_data.update(updates)
1201
+
1202
+ stock_field = proj["stock_field"]
1203
+ sq = row["stock_qty"]
1204
+ if stock_field in updates:
1205
+ raw = str(updates[stock_field]).strip().replace(",", "")
1206
+ try:
1207
+ sq = float(raw)
1208
+ except ValueError:
1209
+ _fail(f"库存字段「{stock_field}」的新值「{updates[stock_field]}」不是有效数字",
1210
+ hint=f"通过 --set 修改「{stock_field}」时,值必须为数字")
1211
+ conn.close()
1212
+ return 1
1213
+
1214
+ key_field = proj["key_field"]
1215
+ new_kv = str(old_data.get(key_field, args.key)).strip()
1216
+
1217
+ conn.execute(
1218
+ "UPDATE products SET key_value = ?, stock_qty = ?, data = ?, updated_at = datetime('now','localtime') WHERE id = ?",
1219
+ (new_kv, sq, json.dumps(old_data, ensure_ascii=False), row["id"]),
1220
+ )
1221
+ conn.commit()
1222
+ conn.close()
1223
+ print(f"商品「{args.key}」更新成功")
1224
+ _ok(key=new_kv, updated_fields=list(updates.keys()))
1225
+ return 0
1226
+
1227
+
1228
+ # ---------- product-delete ----------
1229
+
1230
+
1231
+ def cmd_product_delete(args: argparse.Namespace) -> int:
1232
+ conn = get_db()
1233
+ try:
1234
+ proj = resolve_project(conn, args.project)
1235
+ except ValueError as e:
1236
+ _fail(str(e), usage="product-delete --project <项目名> --key <商品标识>",
1237
+ valid_values={"existing_projects": _list_project_names(conn)})
1238
+ conn.close()
1239
+ return 1
1240
+
1241
+ if not getattr(args, "key", None) or not args.key.strip():
1242
+ _fail("--key 参数不能为空",
1243
+ hint=f"请传入要删除商品的唯一标识值(字段名:「{proj['key_field']}」)",
1244
+ valid_values={"existing_keys": _list_product_keys(conn, proj["id"])})
1245
+ conn.close()
1246
+ return 1
1247
+
1248
+ pid = proj["id"]
1249
+ row = conn.execute(
1250
+ "SELECT * FROM products WHERE project_id = ? AND key_value = ? AND status = 'active'",
1251
+ (pid, args.key),
1252
+ ).fetchone()
1253
+ if not row:
1254
+ _product_not_found(conn, proj, args.key)
1255
+ conn.close()
1256
+ return 1
1257
+ conn.execute(
1258
+ "UPDATE products SET status = 'deleted', updated_at = datetime('now','localtime') WHERE id = ?",
1259
+ (row["id"],),
1260
+ )
1261
+ conn.commit()
1262
+ conn.close()
1263
+ print(f"商品「{args.key}」已删除(软删除)")
1264
+ _ok(key=args.key)
1265
+ return 0
1266
+
1267
+
1268
+ # ---------- product-list ----------
1269
+
1270
+
1271
+ def cmd_product_list(args: argparse.Namespace) -> int:
1272
+ conn = get_db()
1273
+ try:
1274
+ proj = resolve_project(conn, args.project)
1275
+ except ValueError as e:
1276
+ _fail(str(e), usage="product-list --project <项目名> [--search <关键词>] [--filter 字段名=值]",
1277
+ valid_values={"existing_projects": _list_project_names(conn)})
1278
+ conn.close()
1279
+ return 1
1280
+ pid = proj["id"]
1281
+ fields = _get_field_config(proj)
1282
+ field_names = [f["name"] for f in fields]
1283
+
1284
+ filter_str = getattr(args, "filter", None)
1285
+ if filter_str:
1286
+ filter_err = _validate_filter(filter_str, proj)
1287
+ if filter_err:
1288
+ _fail(filter_err, valid_values={"project_fields": field_names})
1289
+ conn.close()
1290
+ return 1
1291
+
1292
+ query = "SELECT * FROM products WHERE project_id = ? AND status = 'active'"
1293
+ params: list = [pid]
1294
+
1295
+ rows = conn.execute(query, params).fetchall()
1296
+ conn.close()
1297
+
1298
+ search = getattr(args, "search", None)
1299
+
1300
+ result_rows = []
1301
+ for r in rows:
1302
+ data = json.loads(r["data"] or "{}")
1303
+ if search:
1304
+ matched = any(search.lower() in str(v).lower() for v in data.values())
1305
+ if not matched:
1306
+ continue
1307
+ if filter_str and "=" in filter_str:
1308
+ fk, fv = filter_str.split("=", 1)
1309
+ if str(data.get(fk.strip(), "")).strip() != fv.strip():
1310
+ continue
1311
+ row_cells = [str(data.get(fn, "")) for fn in field_names]
1312
+ result_rows.append(row_cells)
1313
+
1314
+ if not result_rows:
1315
+ print("无匹配商品")
1316
+ return 0
1317
+ print(f"项目「{proj['name']}」商品列表 ({len(result_rows)} 条):")
1318
+ print_table(field_names, result_rows)
1319
+ return 0
1320
+
1321
+
1322
+ # ---------- product-detail ----------
1323
+
1324
+
1325
+ def cmd_product_detail(args: argparse.Namespace) -> int:
1326
+ conn = get_db()
1327
+ try:
1328
+ proj = resolve_project(conn, args.project)
1329
+ except ValueError as e:
1330
+ _fail(str(e), usage="product-detail --project <项目名> --key <商品标识>",
1331
+ valid_values={"existing_projects": _list_project_names(conn)})
1332
+ conn.close()
1333
+ return 1
1334
+
1335
+ if not getattr(args, "key", None) or not args.key.strip():
1336
+ _fail("--key 参数不能为空",
1337
+ hint=f"请传入商品的唯一标识值(字段名:「{proj['key_field']}」)",
1338
+ valid_values={"existing_keys": _list_product_keys(conn, proj["id"])})
1339
+ conn.close()
1340
+ return 1
1341
+
1342
+ pid = proj["id"]
1343
+ row = conn.execute(
1344
+ "SELECT * FROM products WHERE project_id = ? AND key_value = ? AND status = 'active'",
1345
+ (pid, args.key),
1346
+ ).fetchone()
1347
+ if not row:
1348
+ _product_not_found(conn, proj, args.key)
1349
+ conn.close()
1350
+ return 1
1351
+
1352
+ data = json.loads(row["data"] or "{}")
1353
+ fields = _get_field_config(proj)
1354
+ print(f"---------- 商品详情 ----------")
1355
+ for f in fields:
1356
+ fn = f["name"]
1357
+ val = data.get(fn, "")
1358
+ role = f.get("role", "")
1359
+ suffix = " ← 唯一标识" if role == "key" else (" ← 库存数量" if role == "stock" else "")
1360
+ print(f" {fn}: {val}{suffix}")
1361
+ print(f" [系统库存]: {row['stock_qty']}")
1362
+ print(f" [状态]: {row['status']}")
1363
+ print(f" [创建时间]: {row['created_at']}")
1364
+ print(f" [更新时间]: {row['updated_at']}")
1365
+
1366
+ logs = conn.execute(
1367
+ "SELECT * FROM stock_logs WHERE project_id = ? AND key_value = ? ORDER BY created_at DESC LIMIT 10",
1368
+ (pid, args.key),
1369
+ ).fetchall()
1370
+ conn.close()
1371
+ if logs:
1372
+ print(f"\n 最近 {len(logs)} 条变动记录:")
1373
+ for lg in logs:
1374
+ d = "入库" if lg["direction"] == "in" else "出库"
1375
+ print(f" {lg['created_at']} {d}({lg['type']}) 数量:{lg['quantity']} {lg['before_qty']}→{lg['after_qty']} {lg['remark']}")
1376
+ print(f"------------------------------")
1377
+ return 0
1378
+
1379
+
1380
+ # ---------- stock-in ----------
1381
+
1382
+
1383
+ def cmd_stock_in(args: argparse.Namespace) -> int:
1384
+ conn = get_db()
1385
+ try:
1386
+ proj = resolve_project(conn, args.project)
1387
+ except ValueError as e:
1388
+ _fail(str(e), usage="stock-in --project <项目名> --key <商品标识> --quantity <数量>",
1389
+ valid_values={"existing_projects": _list_project_names(conn)})
1390
+ conn.close()
1391
+ return 1
1392
+
1393
+ if not getattr(args, "key", None) or not args.key.strip():
1394
+ _fail("--key 参数不能为空",
1395
+ hint=f"请传入商品的唯一标识值(字段名:「{proj['key_field']}」)",
1396
+ valid_values={"existing_keys": _list_product_keys(conn, proj["id"])},
1397
+ usage=f"stock-in --project {proj['name']} --key <标识值> --quantity <数量>")
1398
+ conn.close()
1399
+ return 1
1400
+
1401
+ pid = proj["id"]
1402
+ row = conn.execute(
1403
+ "SELECT * FROM products WHERE project_id = ? AND key_value = ? AND status = 'active'",
1404
+ (pid, args.key),
1405
+ ).fetchone()
1406
+ if not row:
1407
+ _product_not_found(conn, proj, args.key)
1408
+ conn.close()
1409
+ return 1
1410
+ qty = float(args.quantity)
1411
+ if qty <= 0:
1412
+ _fail("入库数量必须为正数(大于0)",
1413
+ hint=f"当前传入的 --quantity 为 {args.quantity},请传入正数",
1414
+ example=f"stock-in --key {args.key} --quantity 100")
1415
+ conn.close()
1416
+ return 1
1417
+ before = row["stock_qty"]
1418
+ after = before + qty
1419
+
1420
+ stock_field = proj["stock_field"]
1421
+ data = json.loads(row["data"] or "{}")
1422
+ data[stock_field] = after
1423
+
1424
+ conn.execute(
1425
+ "UPDATE products SET stock_qty = ?, data = ?, updated_at = datetime('now','localtime') WHERE id = ?",
1426
+ (after, json.dumps(data, ensure_ascii=False), row["id"]),
1427
+ )
1428
+ conn.execute(
1429
+ "INSERT INTO stock_logs (project_id, key_value, direction, type, quantity, before_qty, after_qty, remark) VALUES (?,?,?,?,?,?,?,?)",
1430
+ (pid, args.key, "in", args.type or "purchase", qty, before, after, args.remark or ""),
1431
+ )
1432
+ conn.commit()
1433
+ conn.close()
1434
+ print(f"入库成功:商品「{args.key}」库存 {before} → {after}(+{qty})")
1435
+ _ok(key=args.key, before=before, after=after, quantity=qty)
1436
+ return 0
1437
+
1438
+
1439
+ # ---------- stock-out ----------
1440
+
1441
+
1442
+ def cmd_stock_out(args: argparse.Namespace) -> int:
1443
+ conn = get_db()
1444
+ try:
1445
+ proj = resolve_project(conn, args.project)
1446
+ except ValueError as e:
1447
+ _fail(str(e), usage="stock-out --project <项目名> --key <商品标识> --quantity <数量>",
1448
+ valid_values={"existing_projects": _list_project_names(conn)})
1449
+ conn.close()
1450
+ return 1
1451
+
1452
+ if not getattr(args, "key", None) or not args.key.strip():
1453
+ _fail("--key 参数不能为空",
1454
+ hint=f"请传入商品的唯一标识值(字段名:「{proj['key_field']}」)",
1455
+ valid_values={"existing_keys": _list_product_keys(conn, proj["id"])},
1456
+ usage=f"stock-out --project {proj['name']} --key <标识值> --quantity <数量>")
1457
+ conn.close()
1458
+ return 1
1459
+
1460
+ pid = proj["id"]
1461
+ row = conn.execute(
1462
+ "SELECT * FROM products WHERE project_id = ? AND key_value = ? AND status = 'active'",
1463
+ (pid, args.key),
1464
+ ).fetchone()
1465
+ if not row:
1466
+ _product_not_found(conn, proj, args.key)
1467
+ conn.close()
1468
+ return 1
1469
+ qty = float(args.quantity)
1470
+ if qty <= 0:
1471
+ _fail("出库数量必须为正数(大于0)",
1472
+ hint=f"当前传入的 --quantity 为 {args.quantity},请传入正数",
1473
+ example=f"stock-out --key {args.key} --quantity 5")
1474
+ conn.close()
1475
+ return 1
1476
+ before = row["stock_qty"]
1477
+ if qty > before:
1478
+ _fail(f"库存不足:商品「{args.key}」当前库存为 {before},请求出库 {qty}",
1479
+ hint=f"出库数量不能超过当前库存 {before},请减少出库数量或先入库补货",
1480
+ current_stock=before, requested=qty)
1481
+ conn.close()
1482
+ return 1
1483
+ after = before - qty
1484
+
1485
+ stock_field = proj["stock_field"]
1486
+ data = json.loads(row["data"] or "{}")
1487
+ data[stock_field] = after
1488
+
1489
+ conn.execute(
1490
+ "UPDATE products SET stock_qty = ?, data = ?, updated_at = datetime('now','localtime') WHERE id = ?",
1491
+ (after, json.dumps(data, ensure_ascii=False), row["id"]),
1492
+ )
1493
+ conn.execute(
1494
+ "INSERT INTO stock_logs (project_id, key_value, direction, type, quantity, before_qty, after_qty, remark) VALUES (?,?,?,?,?,?,?,?)",
1495
+ (pid, args.key, "out", args.type or "sale", qty, before, after, args.remark or ""),
1496
+ )
1497
+ conn.commit()
1498
+ conn.close()
1499
+ print(f"出库成功:商品「{args.key}」库存 {before} → {after}(-{qty})")
1500
+ _ok(key=args.key, before=before, after=after, quantity=qty)
1501
+ return 0
1502
+
1503
+
1504
+ # ---------- stock-query ----------
1505
+
1506
+
1507
+ def cmd_stock_query(args: argparse.Namespace) -> int:
1508
+ conn = get_db()
1509
+ try:
1510
+ proj = resolve_project(conn, args.project)
1511
+ except ValueError as e:
1512
+ _fail(str(e), usage="stock-query --project <项目名> [--key <商品标识>] [--low-stock] [--filter 字段名=值]",
1513
+ valid_values={"existing_projects": _list_project_names(conn)})
1514
+ conn.close()
1515
+ return 1
1516
+ pid = proj["id"]
1517
+ fields = _get_field_config(proj)
1518
+ field_names = [f["name"] for f in fields]
1519
+ threshold = proj["low_stock_threshold"]
1520
+
1521
+ filter_str = getattr(args, "filter", None)
1522
+ if filter_str:
1523
+ filter_err = _validate_filter(filter_str, proj)
1524
+ if filter_err:
1525
+ _fail(filter_err, valid_values={"project_fields": field_names})
1526
+ conn.close()
1527
+ return 1
1528
+
1529
+ if getattr(args, "key", None):
1530
+ row = conn.execute(
1531
+ "SELECT * FROM products WHERE project_id = ? AND key_value = ? AND status = 'active'",
1532
+ (pid, args.key),
1533
+ ).fetchone()
1534
+ if not row:
1535
+ _product_not_found(conn, proj, args.key)
1536
+ conn.close()
1537
+ return 1
1538
+ data = json.loads(row["data"] or "{}")
1539
+ alert = "⚠ 低库存" if row["stock_qty"] <= threshold else ""
1540
+ print(f"商品「{args.key}」库存: {row['stock_qty']} {alert}")
1541
+ for fn in field_names:
1542
+ print(f" {fn}: {data.get(fn, '')}")
1543
+ return 0
1544
+
1545
+ rows = conn.execute(
1546
+ "SELECT * FROM products WHERE project_id = ? AND status = 'active' ORDER BY stock_qty ASC",
1547
+ (pid,),
1548
+ ).fetchall()
1549
+ conn.close()
1550
+
1551
+ low_stock = getattr(args, "low_stock", False)
1552
+ filter_str = getattr(args, "filter", None)
1553
+
1554
+ display_fields = [proj["key_field"], proj["stock_field"]]
1555
+ for fn in field_names:
1556
+ if fn not in display_fields:
1557
+ display_fields.append(fn)
1558
+ display_fields.append("预警")
1559
+
1560
+ result_rows = []
1561
+ for r in rows:
1562
+ data = json.loads(r["data"] or "{}")
1563
+ if low_stock and r["stock_qty"] > threshold:
1564
+ continue
1565
+ if filter_str and "=" in filter_str:
1566
+ fk, fv = filter_str.split("=", 1)
1567
+ if str(data.get(fk.strip(), "")).strip() != fv.strip():
1568
+ continue
1569
+ cells = []
1570
+ for fn in display_fields[:-1]:
1571
+ cells.append(str(data.get(fn, "")))
1572
+ cells.append("⚠" if r["stock_qty"] <= threshold else "")
1573
+ result_rows.append(cells)
1574
+
1575
+ if not result_rows:
1576
+ print("无匹配商品" + ("(低库存预警)" if low_stock else ""))
1577
+ return 0
1578
+ title = "低库存预警" if low_stock else "库存查询"
1579
+ print(f"项目「{proj['name']}」{title} ({len(result_rows)} 条):")
1580
+ print_table(display_fields, result_rows)
1581
+ return 0
1582
+
1583
+
1584
+ # ---------- stock-log ----------
1585
+
1586
+
1587
+ def cmd_stock_log(args: argparse.Namespace) -> int:
1588
+ conn = get_db()
1589
+ try:
1590
+ proj = resolve_project(conn, args.project)
1591
+ except ValueError as e:
1592
+ _fail(str(e), usage="stock-log --project <项目名> [--key <标识>] [--direction in|out] [--from YYYY-MM-DD] [--to YYYY-MM-DD]",
1593
+ valid_values={"existing_projects": _list_project_names(conn)})
1594
+ conn.close()
1595
+ return 1
1596
+ pid = proj["id"]
1597
+
1598
+ if getattr(args, "from_date", None):
1599
+ date_err = _validate_date(args.from_date, "--from")
1600
+ if date_err:
1601
+ _fail(date_err)
1602
+ conn.close()
1603
+ return 1
1604
+ if getattr(args, "to_date", None):
1605
+ date_err = _validate_date(args.to_date, "--to")
1606
+ if date_err:
1607
+ _fail(date_err)
1608
+ conn.close()
1609
+ return 1
1610
+
1611
+ query = "SELECT * FROM stock_logs WHERE project_id = ?"
1612
+ params: list = [pid]
1613
+
1614
+ if getattr(args, "key", None):
1615
+ query += " AND key_value = ?"
1616
+ params.append(args.key)
1617
+ if getattr(args, "direction", None):
1618
+ query += " AND direction = ?"
1619
+ params.append(args.direction)
1620
+ if getattr(args, "from_date", None):
1621
+ query += " AND created_at >= ?"
1622
+ params.append(args.from_date)
1623
+ if getattr(args, "to_date", None):
1624
+ query += " AND created_at <= ?"
1625
+ params.append(args.to_date + " 23:59:59")
1626
+
1627
+ query += " ORDER BY created_at DESC LIMIT 100"
1628
+ logs = conn.execute(query, params).fetchall()
1629
+ conn.close()
1630
+
1631
+ if not logs:
1632
+ print("无变动记录")
1633
+ return 0
1634
+
1635
+ TYPE_ZH = {
1636
+ "purchase": "采购入库", "return": "退货", "other": "其他",
1637
+ "sale": "销售出库", "damage": "报损",
1638
+ }
1639
+ headers = ["时间", "商品标识", "方向", "类型", "数量", "变动前", "变动后", "备注"]
1640
+ rows = []
1641
+ for lg in logs:
1642
+ d = "入库" if lg["direction"] == "in" else "出库"
1643
+ t = TYPE_ZH.get(lg["type"], lg["type"])
1644
+ rows.append([
1645
+ lg["created_at"], lg["key_value"], d, t,
1646
+ str(lg["quantity"]), str(lg["before_qty"]), str(lg["after_qty"]), lg["remark"],
1647
+ ])
1648
+ print(f"变动记录 ({len(rows)} 条):")
1649
+ print_table(headers, rows)
1650
+ return 0
1651
+
1652
+
1653
+ # ---------- export ----------
1654
+
1655
+
1656
+ def cmd_export(args: argparse.Namespace) -> int:
1657
+ conn = get_db()
1658
+ try:
1659
+ proj = resolve_project(conn, args.project)
1660
+ except ValueError as e:
1661
+ _fail(str(e), usage="export --project <项目名> --type products|logs [--output <文件路径>]",
1662
+ valid_values={"existing_projects": _list_project_names(conn)})
1663
+ conn.close()
1664
+ return 1
1665
+ pid = proj["id"]
1666
+ export_type = args.type or "products"
1667
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
1668
+ default_name = f"export_{proj['name']}_{export_type}_{ts}.csv"
1669
+ output = args.output or str(WORKSPACE_DIR / default_name)
1670
+
1671
+ if export_type == "products":
1672
+ fields = _get_field_config(proj)
1673
+ field_names = [f["name"] for f in fields]
1674
+ rows = conn.execute(
1675
+ "SELECT * FROM products WHERE project_id = ? AND status = 'active'", (pid,)
1676
+ ).fetchall()
1677
+ conn.close()
1678
+ with open(output, "w", encoding="utf-8-sig", newline="") as f:
1679
+ writer = csv.DictWriter(f, fieldnames=field_names)
1680
+ writer.writeheader()
1681
+ for r in rows:
1682
+ data = json.loads(r["data"] or "{}")
1683
+ writer.writerow({fn: data.get(fn, "") for fn in field_names})
1684
+ print(f"已导出 {len(rows)} 条商品数据 → {output}")
1685
+ elif export_type == "logs":
1686
+ logs = conn.execute(
1687
+ "SELECT * FROM stock_logs WHERE project_id = ? ORDER BY created_at DESC", (pid,)
1688
+ ).fetchall()
1689
+ conn.close()
1690
+ log_fields = ["created_at", "key_value", "direction", "type", "quantity", "before_qty", "after_qty", "remark"]
1691
+ with open(output, "w", encoding="utf-8-sig", newline="") as f:
1692
+ writer = csv.DictWriter(f, fieldnames=log_fields)
1693
+ writer.writeheader()
1694
+ for lg in logs:
1695
+ writer.writerow({k: lg[k] for k in log_fields})
1696
+ print(f"已导出 {len(logs)} 条变动记录 → {output}")
1697
+ else:
1698
+ _fail(f"不支持的导出类型: {export_type},可选 products / logs")
1699
+ conn.close()
1700
+ return 1
1701
+ _ok(output=output, type=export_type)
1702
+ return 0
1703
+
1704
+
1705
+ # ========== CLI 入口 ==========
1706
+
1707
+
1708
+ def main() -> int:
1709
+ parser = argparse.ArgumentParser(description="库存管理工具(多项目、自定义字段)")
1710
+ sub = parser.add_subparsers(dest="command", required=True)
1711
+
1712
+ # create(自然语言建表)
1713
+ p = sub.add_parser("create", help="通过指定字段名直接创建空项目(无需上传文件,用户用自然语言描述字段时使用)")
1714
+ p.add_argument("--project", required=True, help="项目名称")
1715
+ p.add_argument("--fields", required=True,
1716
+ help='字段定义。简单格式: "SKU,名称,颜色,库存数量,售价" 或带类型: "SKU:text,库存数量:number";'
1717
+ 'JSON 格式: \'[{"name":"SKU","type":"text"},...]\'。类型可选 text/number/date,不指定时自动推断')
1718
+ p.add_argument("--key-field", default=None, help="唯一标识字段名(不传则自动推断)")
1719
+ p.add_argument("--stock-field", default=None, help="库存数量字段名(不传则自动推断)")
1720
+ p.add_argument("--low-stock-threshold", type=int, default=10, help="低库存预警阈值(默认10)")
1721
+ p.set_defaults(func=cmd_create)
1722
+
1723
+ # init(从文件创建)
1724
+ p = sub.add_parser("init", help="从 CSV/XLSX 文件创建项目:解析表头创建项目并导入数据")
1725
+ p.add_argument("--project", required=True, help="项目名称")
1726
+ p.add_argument("--file", required=True, help="CSV 或 XLSX 文件路径")
1727
+ p.add_argument("--key-field", default=None, help="唯一标识字段名(不传则自动推断)")
1728
+ p.add_argument("--stock-field", default=None, help="库存数量字段名(不传则自动推断)")
1729
+ p.add_argument("--low-stock-threshold", type=int, default=10, help="低库存预警阈值(默认10)")
1730
+ p.set_defaults(func=cmd_init)
1731
+
1732
+ # import
1733
+ p = sub.add_parser("import", help="追加/更新导入数据到已有项目")
1734
+ p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1735
+ p.add_argument("--file", required=True, help="CSV 或 XLSX 文件路径")
1736
+ p.set_defaults(func=cmd_import)
1737
+
1738
+ # project-list
1739
+ p = sub.add_parser("project-list", help="查看所有项目")
1740
+ p.set_defaults(func=cmd_project_list)
1741
+
1742
+ # project-delete
1743
+ p = sub.add_parser("project-delete", help="删除项目及其所有数据")
1744
+ p.add_argument("--project", required=True, help="项目名称")
1745
+ p.set_defaults(func=cmd_project_delete)
1746
+
1747
+ # field-list
1748
+ p = sub.add_parser("field-list", help="查看项目字段配置")
1749
+ p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1750
+ p.set_defaults(func=cmd_field_list)
1751
+
1752
+ # field-add
1753
+ p = sub.add_parser("field-add", help="为项目新增自定义字段")
1754
+ p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1755
+ p.add_argument("--name", required=True, help="字段名")
1756
+ p.add_argument("--type", default="text", choices=["text", "number", "date"], help="字段类型")
1757
+ p.set_defaults(func=cmd_field_add)
1758
+
1759
+ # field-delete
1760
+ p = sub.add_parser("field-delete", help="删除字段(支持逗号分隔批量删除,唯一标识和库存字段不可删)")
1761
+ p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1762
+ p.add_argument("--name", required=True, help="要删除的字段名(多个用逗号分隔,如 \"条码,保质期,进价\")")
1763
+ p.set_defaults(func=cmd_field_delete)
1764
+
1765
+ # product-add
1766
+ p = sub.add_parser("product-add", help="添加单个商品")
1767
+ p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1768
+ p.add_argument("--data", required=True, help="商品数据 JSON 字符串")
1769
+ p.set_defaults(func=cmd_product_add)
1770
+
1771
+ # product-edit
1772
+ p = sub.add_parser("product-edit", help="编辑商品字段")
1773
+ p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1774
+ p.add_argument("--key", required=True, help="商品唯一标识值")
1775
+ p.add_argument("--set", required=True, help="要更新的字段 JSON 字符串")
1776
+ p.set_defaults(func=cmd_product_edit)
1777
+
1778
+ # product-delete
1779
+ p = sub.add_parser("product-delete", help="删除商品(软删除)")
1780
+ p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1781
+ p.add_argument("--key", required=True, help="商品唯一标识值")
1782
+ p.set_defaults(func=cmd_product_delete)
1783
+
1784
+ # product-list
1785
+ p = sub.add_parser("product-list", help="查询商品列表")
1786
+ p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1787
+ p.add_argument("--search", default=None, help="模糊搜索关键词")
1788
+ p.add_argument("--filter", default=None, help="精确筛选 字段名=值")
1789
+ p.set_defaults(func=cmd_product_list)
1790
+
1791
+ # product-detail
1792
+ p = sub.add_parser("product-detail", help="查看商品详情")
1793
+ p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1794
+ p.add_argument("--key", required=True, help="商品唯一标识值")
1795
+ p.set_defaults(func=cmd_product_detail)
1796
+
1797
+ # stock-in
1798
+ p = sub.add_parser("stock-in", help="入库")
1799
+ p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1800
+ p.add_argument("--key", required=True, help="商品唯一标识值")
1801
+ p.add_argument("--quantity", required=True, type=float, help="入库数量")
1802
+ p.add_argument("--type", default="purchase", choices=["purchase", "return", "other"], help="入库类型")
1803
+ p.add_argument("--remark", default="", help="备注")
1804
+ p.set_defaults(func=cmd_stock_in)
1805
+
1806
+ # stock-out
1807
+ p = sub.add_parser("stock-out", help="出库")
1808
+ p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1809
+ p.add_argument("--key", required=True, help="商品唯一标识值")
1810
+ p.add_argument("--quantity", required=True, type=float, help="出库数量")
1811
+ p.add_argument("--type", default="sale", choices=["sale", "return", "damage", "other"], help="出库类型")
1812
+ p.add_argument("--remark", default="", help="备注")
1813
+ p.set_defaults(func=cmd_stock_out)
1814
+
1815
+ # stock-query
1816
+ p = sub.add_parser("stock-query", help="库存查询")
1817
+ p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1818
+ p.add_argument("--key", default=None, help="商品唯一标识值(查单个)")
1819
+ p.add_argument("--low-stock", action="store_true", help="仅显示低库存商品")
1820
+ p.add_argument("--filter", default=None, help="精确筛选 字段名=值")
1821
+ p.set_defaults(func=cmd_stock_query)
1822
+
1823
+ # stock-log
1824
+ p = sub.add_parser("stock-log", help="查看库存变动记录")
1825
+ p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1826
+ p.add_argument("--key", default=None, help="商品唯一标识值")
1827
+ p.add_argument("--direction", default=None, choices=["in", "out"], help="方向筛选")
1828
+ p.add_argument("--from", dest="from_date", default=None, help="起始日期 YYYY-MM-DD")
1829
+ p.add_argument("--to", dest="to_date", default=None, help="结束日期 YYYY-MM-DD")
1830
+ p.set_defaults(func=cmd_stock_log)
1831
+
1832
+ # export
1833
+ p = sub.add_parser("export", help="导出数据为 CSV")
1834
+ p.add_argument("--project", default=None, help="项目名称(单项目可省略)")
1835
+ p.add_argument("--type", default="products", choices=["products", "logs"], help="导出类型")
1836
+ p.add_argument("--output", default=None, help="输出文件路径(默认自动生成)")
1837
+ p.set_defaults(func=cmd_export)
1838
+
1839
+ args = parser.parse_args()
1840
+ return args.func(args)
1841
+
1842
+
1843
+ if __name__ == "__main__":
1844
+ sys.exit(main())