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.
- package/README.md +199 -187
- package/agents/ai-cs-admin/.config.json +51 -51
- package/agents/ai-cs-admin/AGENTS.md +293 -293
- package/agents/ai-cs-admin/HEARTBEAT.md +18 -18
- package/agents/ai-cs-qa/.config.json +47 -47
- package/agents/ai-cs-qa/BOOTSTRAP.md +22 -22
- package/agents/ai-cs-qa/scripts/setup_links.sh +39 -39
- package/agents/beauty/.config.json +17 -17
- package/agents/beauty/AGENTS.md +234 -234
- package/agents/beauty/BOOTSTRAP.md +55 -55
- package/agents/beauty/HEARTBEAT.md +5 -5
- package/agents/beauty/IDENTITY.md +5 -5
- package/agents/beauty/MEMORY.md +44 -44
- package/agents/beauty/SOUL.md +64 -64
- package/agents/beauty/TOOLS.md +160 -160
- package/agents/beauty/USER.md +114 -114
- package/agents/intern-admin/.config.json +60 -60
- package/agents/intern-admin/AGENTS.md +267 -267
- package/agents/intern-admin/BOOTSTRAP.md +21 -21
- package/agents/intern-admin/HEARTBEAT.md +3 -3
- package/agents/intern-admin/IDENTITY.md +6 -6
- package/agents/intern-admin/MEMORY.md +21 -21
- package/agents/intern-admin/SOUL.md +23 -23
- package/agents/intern-admin/TOOLS.md +93 -93
- package/agents/intern-admin/USER.md +16 -16
- package/agents/intern-admin/scripts/init_workspace.sh +27 -27
- package/agents/intern-qa/.config.json +46 -46
- package/agents/intern-qa/AGENTS.md +303 -303
- package/agents/intern-qa/BOOTSTRAP.md +16 -16
- package/agents/intern-qa/HEARTBEAT.md +3 -3
- package/agents/intern-qa/IDENTITY.md +6 -6
- package/agents/intern-qa/MEMORY.md +22 -22
- package/agents/intern-qa/SOUL.md +24 -24
- package/agents/intern-qa/TOOLS.md +24 -24
- package/agents/intern-qa/USER.md +27 -27
- package/agents/intern-qa/scripts/setup_links.sh +54 -54
- package/agents/parent-toddler/.config.json +37 -37
- package/agents/parent-toddler/AGENTS.md +51 -51
- package/agents/parent-toddler/BOOTSTRAP.md +55 -55
- package/agents/parent-toddler/HEARTBEAT.md +5 -5
- package/agents/parent-toddler/IDENTITY.md +5 -5
- package/agents/parent-toddler/MEMORY.md +22 -22
- package/agents/parent-toddler/SOUL.md +35 -35
- package/agents/parent-toddler/TOOLS.md +31 -31
- package/agents/parent-toddler/USER.md +44 -44
- package/agents/vip-admin/.config.json +51 -51
- package/agents/vip-admin/AGENTS.md +314 -314
- package/agents/vip-admin/BOOTSTRAP.md +21 -21
- package/agents/vip-admin/HEARTBEAT.md +19 -19
- package/agents/vip-admin/IDENTITY.md +6 -6
- package/agents/vip-admin/MEMORY.md +30 -30
- package/agents/vip-admin/SOUL.md +25 -25
- package/agents/vip-admin/TOOLS.md +108 -108
- package/agents/vip-admin/USER.md +31 -31
- package/agents/vip-qa/.config.json +58 -58
- package/agents/vip-qa/AGENTS.md +319 -319
- package/agents/vip-qa/BOOTSTRAP.md +73 -73
- package/agents/vip-qa/HEARTBEAT.md +23 -23
- package/agents/vip-qa/IDENTITY.md +7 -7
- package/agents/vip-qa/MEMORY.md +23 -23
- package/agents/vip-qa/SOUL.md +34 -34
- package/agents/vip-qa/TOOLS.md +41 -41
- package/agents/vip-qa/USER.md +16 -16
- package/agents/vip-qa/scripts/setup_links.sh +39 -39
- package/bin/sophhub.js +25 -25
- package/package.json +35 -33
- package/skills/agent-install/skill.json +34 -34
- package/skills/agent-install/src/SKILL.md +240 -240
- package/skills/agent-install/src/pyproject.toml +6 -6
- package/skills/agent-install/src/scripts/backup_agent.py +120 -120
- package/skills/agent-install/src/scripts/check_installed.py +479 -479
- package/skills/agent-install/src/scripts/common.py +568 -568
- package/skills/agent-install/src/scripts/copy_agent_files.py +59 -59
- package/skills/agent-install/src/scripts/list_agents.py +285 -285
- package/skills/agent-install/src/scripts/resolve_install_params.py +90 -90
- package/skills/agent-install/src/scripts/update_agent_md.py +76 -76
- package/skills/agent-install/src/scripts/update_openclaw.py +193 -193
- package/skills/agent-install/src/scripts/verify_download.py +148 -148
- package/skills/aippt/skill.json +20 -20
- package/skills/aippt/src/SKILL.md +235 -235
- package/skills/aippt/src/pyproject.toml +8 -8
- package/skills/aippt/src/scripts/auth.py +122 -122
- package/skills/aippt/src/scripts/ppt.py +361 -361
- package/skills/aippt/src/scripts/provider_docmee.py +299 -299
- package/skills/beauty-salon-inventory/skill.json +16 -16
- package/skills/beauty-salon-inventory/src/SKILL.md +69 -69
- package/skills/beauty-salon-inventory/src/scripts/init_salon_inventory.py +39 -39
- package/skills/beauty-salon-inventory/src/scripts/init_salon_inventory.sh +4 -4
- package/skills/beauty-salon-inventory/src/scripts/salon_inventory_cli.py +244 -244
- package/skills/beauty-salon-marketing/skill.json +10 -10
- package/skills/beauty-salon-marketing/src/SKILL.md +36 -36
- package/skills/beauty-salon-marketing/src/playbooks/beauty-salon-festival.md +19 -19
- package/skills/beauty-salon-marketing/src/playbooks/beauty-salon-segment.md +18 -18
- package/skills/beauty-salon-marketing/src/scripts/beauty_marketing_cli.py +99 -99
- package/skills/beauty-salon-marketing/src/scripts/member_segment.py +114 -114
- package/skills/beauty-salon-member-appointment/skill.json +10 -10
- package/skills/beauty-salon-member-appointment/src/SKILL.md +36 -36
- package/skills/beauty-salon-member-appointment/src/pyproject.toml +9 -9
- package/skills/beauty-salon-member-appointment/src/scripts/run_e2e_smoke.py +160 -160
- package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/__init__.py +1 -1
- package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/__main__.py +4 -4
- package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/cli.py +921 -921
- package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/db.py +30 -30
- package/skills/beauty-salon-membership/skill.json +20 -20
- package/skills/beauty-salon-membership/src/SKILL.md +67 -67
- package/skills/beauty-salon-product-service/skill.json +12 -12
- package/skills/beauty-salon-product-service/src/SKILL.md +42 -42
- package/skills/beauty-salon-product-service/src/pyproject.toml +9 -9
- package/skills/beauty-salon-product-service/src/src/product_service_cli/__init__.py +1 -1
- package/skills/beauty-salon-product-service/src/src/product_service_cli/__main__.py +4 -4
- package/skills/beauty-salon-product-service/src/src/product_service_cli/cli.py +329 -329
- package/skills/beauty-salon-product-service/src/src/product_service_cli/db.py +29 -29
- package/skills/beauty-salon-staff/skill.json +10 -10
- package/skills/beauty-salon-staff/src/SKILL.md +37 -37
- package/skills/beauty-salon-staff/src/pyproject.toml +9 -9
- package/skills/beauty-salon-staff/src/src/staff_cli/__init__.py +1 -1
- package/skills/beauty-salon-staff/src/src/staff_cli/__main__.py +4 -4
- package/skills/beauty-salon-staff/src/src/staff_cli/cli.py +479 -479
- package/skills/beauty-salon-staff/src/src/staff_cli/db.py +28 -28
- package/skills/beauty-salon-suite/skill.json +13 -13
- package/skills/beauty-salon-suite/src/SKILL.md +18 -18
- package/skills/beauty-salon-suite/src/beauty_db/__init__.py +2 -2
- package/skills/beauty-salon-suite/src/beauty_db/db.py +249 -249
- package/skills/beauty-salon-traffic/skill.json +20 -20
- package/skills/beauty-salon-traffic/src/SKILL.md +84 -84
- package/skills/bing-image-search/skill.json +20 -20
- package/skills/bing-image-search/src/SKILL.md +105 -105
- package/skills/bot-api-status/skill.json +44 -44
- package/skills/bot-api-status/src/SKILL.md +99 -99
- package/skills/bot-api-status/src/pyproject.toml +5 -5
- package/skills/bot-api-status/src/scripts/secret.py +496 -496
- package/skills/bot-secret/skill.json +35 -35
- package/skills/bot-secret/src/SKILL.md +51 -51
- package/skills/bot-secret/src/pyproject.toml +5 -5
- package/skills/bot-secret/src/scripts/secret.py +120 -120
- package/skills/cake-flower-holiday-campaign/skill.json +20 -20
- package/skills/cake-flower-holiday-campaign/src/SKILL.md +68 -68
- package/skills/cake-flower-order-sop/skill.json +20 -20
- package/skills/cake-flower-order-sop/src/SKILL.md +65 -65
- package/skills/claw-agent-get-send/skill.json +32 -32
- package/skills/claw-agent-get-send/src/SKILL.md +43 -43
- package/skills/claw-agent-get-send/src/pyproject.toml +5 -5
- package/skills/claw-agent-get-send/src/scripts/appia_claw.py +379 -379
- package/skills/compact-context/skill.json +20 -20
- package/skills/compact-context/src/SKILL.md +133 -133
- package/skills/compact-context/src/scripts/check.sh +381 -381
- package/skills/compact-context/src/scripts/set-keep-recent.mjs +1337 -1337
- package/skills/compact-context/src/scripts/setup.sh +96 -96
- package/skills/consensus/skill.json +20 -20
- package/skills/consensus/src/SKILL.md +93 -93
- package/skills/deepwiki/skill.json +20 -20
- package/skills/deepwiki/src/SKILL.md +45 -45
- package/skills/deepwiki/src/_meta.json +5 -5
- package/skills/deepwiki/src/scripts/deepwiki.js +135 -135
- package/skills/didi-ride/skill.json +20 -20
- package/skills/didi-ride/src/SKILL.md +309 -309
- package/skills/didi-ride/src/_meta.json +5 -5
- package/skills/didi-ride/src/assets/PREFERENCE.md +58 -58
- package/skills/didi-ride/src/package.json +15 -15
- package/skills/didi-ride/src/references/api_references.md +171 -171
- package/skills/didi-ride/src/references/error_handling.md +68 -68
- package/skills/didi-ride/src/references/setup.md +73 -73
- package/skills/didi-ride/src/references/workflow.md +150 -150
- package/skills/feishu-bitable/skill.json +20 -20
- package/skills/feishu-bitable/src/CHECKLIST.md +149 -149
- package/skills/feishu-bitable/src/README.md +177 -177
- package/skills/feishu-bitable/src/SKILL.md +113 -113
- package/skills/feishu-bitable/src/_meta.json +5 -5
- package/skills/feishu-bitable/src/api.js +380 -380
- package/skills/feishu-bitable/src/bin/cli.js +283 -283
- package/skills/feishu-bitable/src/description.md +142 -142
- package/skills/feishu-bitable/src/examples/create-records.json +51 -51
- package/skills/feishu-bitable/src/examples/create-table.json +63 -63
- package/skills/feishu-bitable/src/package-lock.json +324 -324
- package/skills/feishu-bitable/src/package.json +32 -32
- package/skills/feishu-bitable/src/publish-config.json +13 -13
- package/skills/feishu-bitable/src/test-simple.js +60 -60
- package/skills/feishu-bitable/src/utils.js +260 -260
- package/skills/feishu-notes-assistant-universal/skill.json +20 -20
- package/skills/feishu-notes-assistant-universal/src/README.md +55 -55
- package/skills/feishu-notes-assistant-universal/src/SKILL.md +159 -159
- package/skills/feishu-notes-assistant-universal/src/scripts/_resolve_lark_cli.py +58 -58
- package/skills/feishu-notes-assistant-universal/src/scripts/openclaw_meeting_minutes.py +462 -462
- package/skills/feishu-notes-assistant-universal/src/scripts/openclaw_notes_crud.py +547 -547
- package/skills/feishu-notes-assistant-universal/src/scripts/openclaw_notes_crud_test.py +181 -181
- package/skills/feishu-notes-assistant-universal/src/scripts/run_meeting_minutes.py +80 -80
- package/skills/feishu-notes-assistant-universal/src/scripts/run_meeting_minutes.sh +5 -5
- package/skills/feishu-notes-assistant-universal/src/scripts/run_note_crud.py +32 -32
- package/skills/feishu-notes-assistant-universal/src/scripts/run_note_crud.sh +5 -5
- package/skills/flight-booking/skill.json +36 -36
- package/skills/flight-booking/src/SKILL.md +288 -288
- package/skills/flight-booking/src/scripts/flight_booking.py +1237 -1237
- package/skills/flyai/skill.json +20 -20
- package/skills/flyai/src/SKILL.md +119 -119
- package/skills/flyai/src/references/fliggy-fast-search.md +53 -53
- package/skills/flyai/src/references/search-flight.md +89 -89
- package/skills/flyai/src/references/search-hotels.md +57 -57
- package/skills/flyai/src/references/search-poi.md +48 -48
- package/skills/google-maps/skill.json +20 -20
- package/skills/google-maps/src/SKILL.md +237 -237
- package/skills/google-maps/src/_meta.json +5 -5
- package/skills/google-maps/src/lib/map_helper.py +912 -912
- package/skills/image-classify/skill.json +42 -42
- package/skills/image-classify/src/SKILL.md +368 -368
- package/skills/image-classify/src/references/config.json +4 -4
- package/skills/image-classify/src/scripts/face_search.py +1276 -1276
- package/skills/image-description/skill.json +34 -34
- package/skills/image-description/src/SKILL.md +33 -33
- package/skills/image-description/src/pyproject.toml +8 -8
- package/skills/image-description/src/scripts/ana_image.py +112 -112
- package/skills/image-identify-world/skill.json +20 -20
- package/skills/image-identify-world/src/SKILL.md +40 -40
- package/skills/image-identify-world/src/pyproject.toml +8 -8
- package/skills/image-identify-world/src/scripts/identify_world.py +115 -115
- package/skills/insurance-policy-review/skill.json +27 -27
- package/skills/insurance-policy-review/src/SKILL.md +75 -75
- package/skills/insurance-sales-playbook/skill.json +20 -20
- package/skills/insurance-sales-playbook/src/SKILL.md +58 -58
- package/skills/inventory-management/skill.json +20 -20
- package/skills/inventory-management/src/SKILL.md +241 -241
- package/skills/inventory-management/src/scripts/inventory.py +1844 -1844
- package/skills/large-task-router/skill.json +20 -20
- package/skills/large-task-router/src/SKILL.md +79 -79
- package/skills/large-task-router/src/templates/plan.md +74 -74
- package/skills/lawding-contract-review/skill.json +20 -20
- package/skills/lawding-contract-review/src/SKILL.md +284 -284
- package/skills/lawding-contract-review/src/references/legal-language-library.md +1385 -1385
- package/skills/lawding-contract-review/src/scripts/build_reminders.py +471 -471
- package/skills/lawding-contract-review/src/scripts/register_contract_cron.py +457 -457
- package/skills/md2pdf-converter/skill.json +20 -20
- package/skills/md2pdf-converter/src/SKILL.md +244 -244
- package/skills/md2pdf-converter/src/_meta.json +5 -5
- package/skills/md2pdf-converter/src/scripts/generate_emoji_mapping.py +74 -74
- package/skills/md2pdf-converter/src/scripts/md2pdf-local.sh +291 -291
- package/skills/notes-hub-assistant/skill.json +20 -20
- package/skills/notes-hub-assistant/src/SKILL.md +233 -233
- package/skills/notes-hub-assistant/src/scripts/_resolve_lark_cli.py +48 -48
- package/skills/notes-hub-assistant/src/scripts/openclaw_meeting_minutes.py +473 -473
- package/skills/notes-hub-assistant/src/scripts/openclaw_notes_crud.py +596 -596
- package/skills/notes-hub-assistant/src/scripts/openclaw_wolai_notes_crud.py +364 -364
- package/skills/notes-hub-assistant/src/scripts/run_meeting_minutes.py +79 -79
- package/skills/notes-hub-assistant/src/scripts/run_note_crud.py +37 -37
- package/skills/notes-hub-assistant/src/scripts/run_notionbot.py +36 -36
- package/skills/notes-hub-assistant/src/scripts/run_wolai_note_crud.py +27 -27
- package/skills/schedule-reminder/skill.json +20 -20
- package/skills/schedule-reminder/src/SKILL.md +619 -619
- package/skills/schedule-reminder/src/schedule_template.md +68 -68
- package/skills/schedule-reminder/src/scripts/append_event.py +204 -204
- package/skills/schedule-reminder/src/scripts/create_reminders.sh +163 -163
- package/skills/schedule-reminder/src/scripts/daily_activate.sh +175 -175
- package/skills/schedule-reminder/src/scripts/parse_schedule.py +704 -704
- package/skills/schedule-reminder/src/scripts/setup.sh +242 -242
- package/skills/schedule-reminder/src//347/224/250/346/210/267/346/214/207/345/215/227.md +311 -311
- package/skills/sessions-analysis/skill.json +34 -34
- package/skills/sessions-analysis/src/SKILL.md +81 -81
- package/skills/sessions-analysis/src/pyproject.toml +5 -5
- package/skills/sessions-analysis/src/scripts/ana_logs.py +205 -205
- package/skills/share-skill/skill.json +20 -20
- package/skills/share-skill/src/SKILL.md +261 -261
- package/skills/share-skill/src/scripts/share_skill_to_friend.py +1031 -1031
- package/skills/skill-creator/skill.json +20 -20
- package/skills/skill-creator/src/SKILL.md +370 -370
- package/skills/skill-creator/src/license.txt +202 -202
- package/skills/skill-creator/src/scripts/init_skill.py +378 -378
- package/skills/skill-creator/src/scripts/package_skill.py +111 -111
- package/skills/skill-creator/src/scripts/quick_validate.py +101 -101
- package/skills/skillhub/skill.json +27 -27
- package/skills/skillhub/src/SKILL.md +121 -121
- package/skills/sophnet-age-appearance/skill.json +20 -20
- package/skills/sophnet-age-appearance/src/SKILL.md +83 -83
- package/skills/sophnet-age-appearance/src/pyproject.toml +10 -10
- package/skills/sophnet-age-appearance/src/scripts/age_appearance.py +395 -395
- package/skills/sophnet-age-appearance/src/scripts/age_face_crop.py +313 -313
- package/skills/sophnet-bot-client/skill.json +20 -20
- package/skills/sophnet-bot-client/src/SKILL.md +255 -255
- package/skills/sophnet-bot-client/src/pyproject.toml +13 -13
- package/skills/sophnet-bot-client/src/scripts/bot_client_proxy.py +165 -165
- package/skills/sophnet-bot-client/src/scripts/bot_client_safe.sh +29 -29
- package/skills/sophnet-bot-client/src/scripts/bot_client_setup.py +502 -502
- package/skills/sophnet-bot-client/src/tests/test_bot_client_proxy.py +255 -255
- package/skills/sophnet-bot-client/src/tests/test_bot_client_setup.py +679 -679
- package/skills/sophnet-bot-client/src/uv.lock +8 -8
- package/skills/sophnet-customer-management/skill.json +20 -20
- package/skills/sophnet-customer-management/src/SKILL.md +270 -270
- package/skills/sophnet-customer-management/src/pyproject.toml +15 -15
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/__init__.py +2 -2
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/__main__.py +5 -5
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/cli.py +67 -67
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/__init__.py +2 -2
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/customer.py +60 -60
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/export_file.py +18 -18
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/import_file.py +15 -15
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/reminder.py +26 -26
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/schema.py +28 -28
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/config.py +54 -54
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/__init__.py +2 -2
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/exporter.py +85 -85
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/models.py +84 -84
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/normalizer.py +144 -144
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/parser.py +241 -241
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/query.py +109 -109
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/reminder.py +121 -121
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/repository.py +397 -397
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/schema.py +106 -106
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/service.py +565 -565
- package/skills/sophnet-customer-management/src/uv.lock +48 -48
- package/skills/sophnet-customized-marketing/skill.json +28 -28
- package/skills/sophnet-customized-marketing/src/SKILL.md +144 -144
- package/skills/sophnet-customized-marketing/src/playbooks/campaign-planning.md +187 -187
- package/skills/sophnet-customized-marketing/src/playbooks/content-generation.md +124 -124
- package/skills/sophnet-customized-marketing/src/playbooks/marketing-calendar.md +59 -59
- package/skills/sophnet-customized-marketing/src/playbooks/multi-channel-bundle.md +94 -94
- package/skills/sophnet-customized-marketing/src/playbooks/poster-generation.md +182 -182
- package/skills/sophnet-customized-marketing/src/playbooks/style-profile-workflow.md +103 -103
- package/skills/sophnet-customized-marketing/src/pyproject.toml +8 -8
- package/skills/sophnet-customized-marketing/src/references/campaign-mechanics.md +168 -168
- package/skills/sophnet-customized-marketing/src/references/content-safety.md +26 -26
- package/skills/sophnet-customized-marketing/src/references/marketing-date-checklist.md +99 -99
- package/skills/sophnet-customized-marketing/src/references/platform-writing-guidelines.md +88 -88
- package/skills/sophnet-customized-marketing/src/references/quality-checklist.md +44 -44
- package/skills/sophnet-customized-marketing/src/scripts/generate_poster.py +572 -572
- package/skills/sophnet-customized-marketing/src/scripts/style_profile.py +215 -215
- package/skills/sophnet-dailynews/skill.json +20 -20
- package/skills/sophnet-dailynews/src/SKILL.md +179 -179
- package/skills/sophnet-dailynews/src/cache.json +150 -150
- package/skills/sophnet-dailynews/src/sources.json +230 -230
- package/skills/sophnet-docx/skill.json +20 -20
- package/skills/sophnet-docx/src/SKILL.md +463 -463
- package/skills/sophnet-docx/src/package-lock.json +208 -208
- package/skills/sophnet-docx/src/package.json +16 -16
- package/skills/sophnet-docx/src/pyproject.toml +11 -11
- package/skills/sophnet-docx/src/scripts/__init__.py +1 -1
- package/skills/sophnet-docx/src/scripts/accept_changes.py +135 -135
- package/skills/sophnet-docx/src/scripts/comment.py +318 -318
- package/skills/sophnet-docx/src/scripts/ensure_uv_env.sh +68 -68
- package/skills/sophnet-docx/src/scripts/office/helpers/merge_runs.py +199 -199
- package/skills/sophnet-docx/src/scripts/office/helpers/simplify_redlines.py +197 -197
- package/skills/sophnet-docx/src/scripts/office/pack.py +159 -159
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -1499
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -146
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -1085
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -11
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -3081
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -23
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -185
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -287
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -1676
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -28
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -144
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -174
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -25
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -18
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -59
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -56
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -195
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -582
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -25
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -4439
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -570
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -509
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -12
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -108
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -96
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -3646
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -116
- package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -42
- package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -50
- package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -49
- package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -33
- package/skills/sophnet-docx/src/scripts/office/schemas/mce/mc.xsd +75 -75
- package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-2010.xsd +560 -560
- package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-2012.xsd +67 -67
- package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-2018.xsd +14 -14
- package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -20
- package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -13
- package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -4
- package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -8
- package/skills/sophnet-docx/src/scripts/office/soffice.py +183 -183
- package/skills/sophnet-docx/src/scripts/office/unpack.py +132 -132
- package/skills/sophnet-docx/src/scripts/office/validate.py +111 -111
- package/skills/sophnet-docx/src/scripts/office/validators/__init__.py +15 -15
- package/skills/sophnet-docx/src/scripts/office/validators/base.py +847 -847
- package/skills/sophnet-docx/src/scripts/office/validators/docx.py +446 -446
- package/skills/sophnet-docx/src/scripts/office/validators/pptx.py +275 -275
- package/skills/sophnet-docx/src/scripts/office/validators/redlining.py +247 -247
- package/skills/sophnet-docx/src/scripts/templates/comments.xml +3 -3
- package/skills/sophnet-docx/src/scripts/templates/commentsExtended.xml +3 -3
- package/skills/sophnet-docx/src/scripts/templates/commentsExtensible.xml +3 -3
- package/skills/sophnet-docx/src/scripts/templates/commentsIds.xml +3 -3
- package/skills/sophnet-docx/src/scripts/templates/people.xml +3 -3
- package/skills/sophnet-docx/src/scripts/upload_file.sh +96 -96
- package/skills/sophnet-docx/src/uv.lock +320 -320
- package/skills/sophnet-face-search/skill.json +20 -20
- package/skills/sophnet-face-search/src/SKILL.md +115 -115
- package/skills/sophnet-face-search/src/pyproject.toml +11 -11
- package/skills/sophnet-face-search/src/scripts/face_search.py +335 -335
- package/skills/sophnet-face-search/src/uv.lock +508 -508
- package/skills/sophnet-id-photo/skill.json +20 -20
- package/skills/sophnet-id-photo/src/SKILL.md +107 -107
- package/skills/sophnet-id-photo/src/pyproject.toml +10 -10
- package/skills/sophnet-id-photo/src/scripts/id_photo.py +540 -540
- package/skills/sophnet-id-photo/src/scripts/id_photo_compliance.py +215 -215
- package/skills/sophnet-id-photo/src/scripts/id_photo_face_crop.py +313 -313
- package/skills/sophnet-image-edit/skill.json +20 -20
- package/skills/sophnet-image-edit/src/SKILL.md +140 -140
- package/skills/sophnet-image-edit/src/pyproject.toml +9 -9
- package/skills/sophnet-image-edit/src/scripts/edit_and_preview.sh +68 -68
- package/skills/sophnet-image-edit/src/scripts/edit_image.py +279 -279
- package/skills/sophnet-image-edit/src/uv.lock +234 -234
- package/skills/sophnet-image-generate/skill.json +20 -20
- package/skills/sophnet-image-generate/src/SKILL.md +62 -62
- package/skills/sophnet-image-generate/src/pyproject.toml +9 -9
- package/skills/sophnet-image-generate/src/scripts/generate_image.py +156 -156
- package/skills/sophnet-image-generate/src/uv.lock +234 -234
- package/skills/sophnet-image-ocr/skill.json +20 -20
- package/skills/sophnet-image-ocr/src/SKILL.md +167 -167
- package/skills/sophnet-image-ocr/src/pyproject.toml +13 -13
- package/skills/sophnet-image-ocr/src/scripts/ocr.py +225 -225
- package/skills/sophnet-image-ocr/src/uv.lock +234 -234
- package/skills/sophnet-infinite-talk/skill.json +20 -20
- package/skills/sophnet-infinite-talk/src/SKILL.md +140 -140
- package/skills/sophnet-infinite-talk/src/pyproject.toml +9 -9
- package/skills/sophnet-infinite-talk/src/scripts/gen.py +172 -172
- package/skills/sophnet-oss/skill.json +27 -27
- package/skills/sophnet-oss/src/SKILL.md +118 -118
- package/skills/sophnet-oss/src/pyproject.toml +8 -8
- package/skills/sophnet-oss/src/scripts/upload_file.py +43 -43
- package/skills/sophnet-pdf/skill.json +20 -20
- package/skills/sophnet-pdf/src/SKILL.md +413 -413
- package/skills/sophnet-pdf/src/forms.md +297 -297
- package/skills/sophnet-pdf/src/pyproject.toml +14 -14
- package/skills/sophnet-pdf/src/reference.md +611 -611
- package/skills/sophnet-pdf/src/scripts/check_bounding_boxes.py +65 -65
- package/skills/sophnet-pdf/src/scripts/check_fillable_fields.py +11 -11
- package/skills/sophnet-pdf/src/scripts/convert_pdf_to_images.py +33 -33
- package/skills/sophnet-pdf/src/scripts/create_validation_image.py +37 -37
- package/skills/sophnet-pdf/src/scripts/enhance_tutorial.py +557 -557
- package/skills/sophnet-pdf/src/scripts/ensure_uv_env.sh +68 -68
- package/skills/sophnet-pdf/src/scripts/extract_form_field_info.py +122 -122
- package/skills/sophnet-pdf/src/scripts/extract_form_structure.py +115 -115
- package/skills/sophnet-pdf/src/scripts/extract_pdf_content.py +34 -34
- package/skills/sophnet-pdf/src/scripts/fill_fillable_fields.py +98 -98
- package/skills/sophnet-pdf/src/scripts/fill_pdf_form_with_annotations.py +107 -107
- package/skills/sophnet-pdf/src/scripts/upload_file.sh +88 -88
- package/skills/sophnet-pdf/src/uv.lock +537 -537
- package/skills/sophnet-qa-install/skill.json +27 -27
- package/skills/sophnet-qa-install/src/SKILL.md +210 -210
- package/skills/sophnet-qa-install/src/pyproject.toml +6 -6
- package/skills/sophnet-qa-install/src/scripts/backup_md.py +35 -35
- package/skills/sophnet-qa-install/src/scripts/check_installed.py +143 -143
- package/skills/sophnet-qa-install/src/scripts/update_config.py +142 -142
- package/skills/sophnet-qa-install/src/scripts/update_md.py +73 -73
- package/skills/sophnet-schedule/skill.json +20 -20
- package/skills/sophnet-schedule/src/ARCHITECTURE.md +321 -321
- package/skills/sophnet-schedule/src/IMPROVEMENTS.md +145 -145
- package/skills/sophnet-schedule/src/SKILL.md +1050 -1050
- package/skills/sophnet-schedule/src/_meta.json +6 -6
- package/skills/sophnet-schedule/src/api/models.py +245 -245
- package/skills/sophnet-schedule/src/apps/add_event.py +237 -237
- package/skills/sophnet-schedule/src/apps/check_reminders.py +112 -112
- package/skills/sophnet-schedule/src/apps/check_roc.py +246 -246
- package/skills/sophnet-schedule/src/apps/generate_daily_plan.py +342 -342
- package/skills/sophnet-schedule/src/apps/import_events.py +216 -216
- package/skills/sophnet-schedule/src/apps/monitor_calendar_changes.py +140 -140
- package/skills/sophnet-schedule/src/apps/register_tasks.py +169 -169
- package/skills/sophnet-schedule/src/apps/sync_roc_to_gcal.py +174 -174
- package/skills/sophnet-schedule/src/compat.py +66 -66
- package/skills/sophnet-schedule/src/config/reminder_rules.yaml +96 -96
- package/skills/sophnet-schedule/src/config/roc_events.yaml +44 -44
- package/skills/sophnet-schedule/src/config/settings.py +133 -133
- package/skills/sophnet-schedule/src/config/task_registry.yaml +92 -92
- package/skills/sophnet-schedule/src/docs/FRONTEND_INTEGRATION_GUIDE.md +437 -437
- package/skills/sophnet-schedule/src/gcal/client.py +374 -374
- package/skills/sophnet-schedule/src/gcal/models.py +91 -91
- package/skills/sophnet-schedule/src/requirements.txt +6 -6
- package/skills/sophnet-schedule/src/scripts/setup_gcal_token.py +85 -85
- package/skills/sophnet-schedule/src/server.py +669 -669
- package/skills/sophnet-schedule/src/services/calendar_backend.py +139 -139
- package/skills/sophnet-schedule/src/services/conflict_detector.py +96 -96
- package/skills/sophnet-schedule/src/services/datetime_utils.py +117 -117
- package/skills/sophnet-schedule/src/services/event_classifier.py +100 -100
- package/skills/sophnet-schedule/src/services/event_diff.py +160 -160
- package/skills/sophnet-schedule/src/services/google_integration.py +500 -500
- package/skills/sophnet-schedule/src/services/job_store.py +100 -100
- package/skills/sophnet-schedule/src/services/local_event_store.py +266 -266
- package/skills/sophnet-schedule/src/services/reminder_planner.py +116 -116
- package/skills/sophnet-schedule/src/services/runtime_utils.py +31 -31
- package/skills/sophnet-schedule/src/services/table_parser.py +286 -286
- package/skills/sophnet-schedule/src/services/task_builder.py +167 -167
- package/skills/sophnet-schedule/src/services/time_window.py +72 -72
- package/skills/sophnet-sticker-edit/skill.json +27 -27
- package/skills/sophnet-sticker-edit/src/SKILL.md +80 -80
- package/skills/sophnet-sticker-edit/src/pyproject.toml +9 -9
- package/skills/sophnet-sticker-edit/src/scripts/edit_sticker_image.py +403 -403
- package/skills/sophnet-stock/skill.json +20 -20
- package/skills/sophnet-stock/src/App-Plan.md +442 -442
- package/skills/sophnet-stock/src/README.md +214 -214
- package/skills/sophnet-stock/src/SKILL.md +236 -236
- package/skills/sophnet-stock/src/TODO.md +394 -394
- package/skills/sophnet-stock/src/_meta.json +5 -5
- package/skills/sophnet-stock/src/docs/ARCHITECTURE.md +408 -408
- package/skills/sophnet-stock/src/docs/CONCEPT.md +233 -233
- package/skills/sophnet-stock/src/docs/HOT_SCANNER.md +288 -288
- package/skills/sophnet-stock/src/docs/README.md +95 -95
- package/skills/sophnet-stock/src/docs/USAGE.md +465 -465
- package/skills/sophnet-stock/src/scripts/analyze_stock.py +2565 -2565
- package/skills/sophnet-stock/src/scripts/dividends.py +365 -365
- package/skills/sophnet-stock/src/scripts/hot_scanner.py +582 -582
- package/skills/sophnet-stock/src/scripts/portfolio.py +548 -548
- package/skills/sophnet-stock/src/scripts/rumor_scanner.py +342 -342
- package/skills/sophnet-stock/src/scripts/test_stock_analysis.py +409 -409
- package/skills/sophnet-stock/src/scripts/watchlist.py +336 -336
- package/skills/sophnet-training-install/skill.json +27 -27
- package/skills/sophnet-training-install/src/SKILL.md +211 -211
- package/skills/sophnet-training-install/src/pyproject.toml +6 -6
- package/skills/sophnet-training-install/src/scripts/backup_md.py +35 -35
- package/skills/sophnet-training-install/src/scripts/check_installed.py +144 -144
- package/skills/sophnet-training-install/src/scripts/update_config.py +142 -142
- package/skills/sophnet-training-install/src/scripts/update_md.py +73 -73
- package/skills/sophnet-tts/skill.json +20 -20
- package/skills/sophnet-tts/src/SKILL.md +79 -79
- package/skills/sophnet-tts/src/pyproject.toml +9 -9
- package/skills/sophnet-tts/src/scripts/gen_tts.py +130 -130
- package/skills/sophnet-video-generate/skill.json +37 -37
- package/skills/sophnet-video-generate/src/SKILL.md +117 -117
- package/skills/sophnet-video-generate/src/scripts/gen_video.py +321 -321
- package/skills/sophnet-xlsx/skill.json +20 -20
- package/skills/sophnet-xlsx/src/SKILL.md +399 -399
- package/skills/sophnet-xlsx/src/pyproject.toml +11 -11
- package/skills/sophnet-xlsx/src/scripts/ensure_uv_env.sh +68 -68
- package/skills/sophnet-xlsx/src/scripts/office/helpers/merge_runs.py +199 -199
- package/skills/sophnet-xlsx/src/scripts/office/helpers/simplify_redlines.py +197 -197
- package/skills/sophnet-xlsx/src/scripts/office/pack.py +159 -159
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -1499
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -146
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -1085
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -11
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -3081
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -23
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -185
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -287
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -1676
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -28
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -144
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -174
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -25
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -18
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -59
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -56
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -195
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -582
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -25
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -4439
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -570
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -509
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -12
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -108
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -96
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -3646
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -116
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -42
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -50
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -49
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -33
- package/skills/sophnet-xlsx/src/scripts/office/schemas/mce/mc.xsd +75 -75
- package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-2010.xsd +560 -560
- package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-2012.xsd +67 -67
- package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-2018.xsd +14 -14
- package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -20
- package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -13
- package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -4
- package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -8
- package/skills/sophnet-xlsx/src/scripts/office/soffice.py +183 -183
- package/skills/sophnet-xlsx/src/scripts/office/unpack.py +132 -132
- package/skills/sophnet-xlsx/src/scripts/office/validate.py +111 -111
- package/skills/sophnet-xlsx/src/scripts/office/validators/__init__.py +15 -15
- package/skills/sophnet-xlsx/src/scripts/office/validators/base.py +847 -847
- package/skills/sophnet-xlsx/src/scripts/office/validators/docx.py +446 -446
- package/skills/sophnet-xlsx/src/scripts/office/validators/pptx.py +275 -275
- package/skills/sophnet-xlsx/src/scripts/office/validators/redlining.py +247 -247
- package/skills/sophnet-xlsx/src/scripts/recalc.py +184 -184
- package/skills/sophnet-xlsx/src/scripts/upload_file.sh +96 -96
- package/skills/sophnet-xlsx/src/uv.lock +319 -319
- package/skills/ui-ux-pro-max/skill.json +20 -20
- package/skills/ui-ux-pro-max/src/SKILL.md +377 -377
- package/skills/ui-ux-pro-max/src/data/icons.csv +101 -101
- package/skills/ui-ux-pro-max/src/data/react-performance.csv +45 -45
- package/skills/ui-ux-pro-max/src/data/stacks/astro.csv +54 -54
- package/skills/ui-ux-pro-max/src/data/stacks/jetpack-compose.csv +53 -53
- package/skills/ui-ux-pro-max/src/data/stacks/nuxt-ui.csv +51 -51
- package/skills/ui-ux-pro-max/src/data/stacks/nuxtjs.csv +59 -59
- package/skills/ui-ux-pro-max/src/data/stacks/shadcn.csv +61 -61
- package/skills/ui-ux-pro-max/src/data/typography.csv +57 -57
- package/skills/ui-ux-pro-max/src/data/ui-reasoning.csv +101 -101
- package/skills/ui-ux-pro-max/src/data/web-interface.csv +31 -31
- package/skills/ui-ux-pro-max/src/scripts/core.py +253 -253
- package/skills/ui-ux-pro-max/src/scripts/design_system.py +1067 -1067
- package/skills/video-understand/skill.json +20 -20
- package/skills/video-understand/src/SKILL.md +79 -79
- package/skills/video-understand/src/scripts/video_understand.py +204 -204
- package/skills/weather/skill.json +19 -19
- package/skills/weather/src/SKILL.md +112 -112
- package/skills/web-scraper/skill.json +20 -20
- package/skills/web-scraper/src/SKILL.md +101 -101
- package/skills/web-scraper/src/scripts/scrape.py +270 -270
- package/skills/website-builder/skill.json +20 -20
- package/skills/website-builder/src/SKILL.md +266 -266
- package/skills/website-builder/src/scripts/deploy_site.sh +46 -46
- package/skills/wechat-article-publisher/skill.json +20 -20
- package/skills/wechat-article-publisher/src/SKILL.md +60 -60
- package/skills/wechat-article-publisher/src/config.json +6 -6
- package/skills/wechat-article-publisher/src/pyproject.toml +12 -12
- package/skills/wechat-article-publisher/src/scripts/publish_wechat.py +825 -825
- package/skills/xiaohongshu/skill.json +20 -20
- package/skills/xiaohongshu/src/SKILL.md +91 -91
- package/skills/xiaohongshu/src/_meta.json +5 -5
- package/skills/xiaohongshu/src/assets/card.html +216 -216
- package/skills/xiaohongshu/src/assets/cover.html +82 -82
- package/skills/xiaohongshu/src/assets/example.md +84 -84
- package/skills/xiaohongshu/src/assets/styles.css +318 -318
- package/skills/xiaohongshu/src/scripts/render_xhs_v2.py +737 -737
- package/skills/xiaohongshu/src/scripts/sign_server.py +158 -158
- package/skills/xiaohongshu/src/scripts/stealth.min.js +6 -6
- package/skills/xiaohongshu/src/scripts/xhs_tool.py +186 -186
- package/skills/xiaohongshu/src/workflow.py +185 -185
- package/src/commands/agent.js +112 -112
- package/src/commands/download.js +101 -101
- package/src/commands/info.js +58 -58
- package/src/commands/list.js +71 -71
- package/src/utils/agents.js +36 -36
- package/src/utils/config.js +22 -22
- package/src/utils/paths.js +31 -31
- 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())
|