@crabcloud/cli 0.5.0 → 0.5.3

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/dist/crab.js CHANGED
@@ -6091,16 +6091,18 @@ var SHOP_PERMISSIONS = ["products", "orders", "aftercare", "customers", "marketi
6091
6091
  var shopCategoryMoveInput = object({
6092
6092
  direction: _enum(["up", "down"])
6093
6093
  });
6094
- var shopSpecGroupCreateInput = object({
6095
- name: string2().trim().min(1).max(30),
6096
- values: array(string2().trim().min(1).max(60)).min(1).max(20)
6097
- });
6098
- var shopSpecGroupUpdateInput = shopSpecGroupCreateInput.partial();
6099
6094
  var comboValues = array(string2().min(1).max(60)).min(1).max(24);
6100
6095
  var shopStoreCreateInput = object({
6101
6096
  name: string2().min(1).max(60),
6102
6097
  tagline: string2().max(200).default("")
6103
6098
  });
6099
+ var shopStoreUpdateInput = object({
6100
+ name: string2().min(1).max(60),
6101
+ tagline: string2().max(200),
6102
+ description: string2().max(2000),
6103
+ logoObjectId: string2().min(1).nullable(),
6104
+ skuPrefix: string2().regex(/^[A-Za-z0-9]{0,8}$/)
6105
+ }).partial();
6104
6106
  var shopCategoryCreateInput = object({
6105
6107
  name: string2().min(1).max(60),
6106
6108
  parentId: string2().min(1).nullish()
@@ -6111,31 +6113,61 @@ var shopCategoryUpdateInput = object({
6111
6113
  }).partial();
6112
6114
  var shopVariantInput = object({
6113
6115
  combo: comboValues,
6114
- priceCents: number2().int().min(0).max(1e8),
6116
+ priceCents: number2().int().min(0).max(1e8).nullish(),
6117
+ compareAtPriceCents: number2().int().min(0).max(1e8).nullish(),
6115
6118
  stock: number2().int().min(0).max(1e7).nullish(),
6116
- enabled: boolean2().default(true)
6119
+ enabled: boolean2().default(true),
6120
+ code: string2().max(64).nullish()
6117
6121
  });
6118
6122
  var shopProductCreateInput = object({
6119
6123
  kind: _enum(["digital", "physical"]),
6124
+ variantMode: _enum(["single", "multi"]),
6120
6125
  name: string2().min(1).max(140),
6121
6126
  description: string2().max(2000).default(""),
6127
+ content: string2().max(30000).default(""),
6122
6128
  categoryId: string2().min(1).nullish(),
6123
6129
  status: _enum(["draft", "active", "archived"]).default("draft"),
6124
6130
  priceCents: number2().int().min(0).max(1e8).nullish(),
6131
+ compareAtPriceCents: number2().int().min(0).max(1e8).nullish(),
6125
6132
  stock: number2().int().min(0).max(1e7).nullish(),
6133
+ refundable: boolean2().nullish(),
6126
6134
  deliveryType: _enum(["file", "license"]).nullish(),
6127
6135
  deliveryFileId: string2().min(1).nullish(),
6136
+ imageObjectId: string2().min(1).nullish(),
6137
+ gallery: array(string2().min(1)).max(8).nullish(),
6128
6138
  specAxes: array(object({ name: string2().min(1).max(30), values: comboValues })).max(3).nullish(),
6129
- specGroupIds: array(string2().min(1)).max(3).nullish(),
6130
- variants: array(shopVariantInput).max(24).nullish()
6139
+ variants: array(shopVariantInput).max(24).nullish(),
6140
+ code: string2().max(64).nullish(),
6141
+ maxPerOrder: number2().int().min(1).max(1e4).nullish(),
6142
+ maxPerBuyer: number2().int().min(1).max(1e4).nullish()
6143
+ });
6144
+ var shopProductUpdateInput = shopProductCreateInput.extend({
6145
+ status: _enum(["draft", "active", "archived"]).optional(),
6146
+ description: string2().max(2000).optional(),
6147
+ content: string2().max(30000).optional()
6148
+ }).partial();
6149
+ var shopStockAdjustTarget = object({
6150
+ variantId: string2().min(1).nullish(),
6151
+ amount: number2().int().min(0).max(1e7)
6131
6152
  });
6132
- var shopProductUpdateInput = shopProductCreateInput.partial();
6133
6153
  var shopStockAdjustInput = object({
6134
6154
  mode: _enum(["in", "out", "set"]),
6135
- amount: number2().int().min(0).max(1e7),
6136
- variantId: string2().min(1).nullish(),
6137
6155
  reason: _enum(["purchase", "manual"]).nullish(),
6138
- note: string2().max(200).nullish()
6156
+ note: string2().max(200).nullish(),
6157
+ targets: array(shopStockAdjustTarget).min(1).max(50)
6158
+ }).superRefine((val, ctx) => {
6159
+ if (val.reason === "purchase" && val.mode !== "in") {
6160
+ ctx.addIssue({ code: "custom", path: ["reason"], message: "reason 'purchase' only applies to inbound" });
6161
+ }
6162
+ if (val.mode !== "set" && val.targets.some((t) => t.amount === 0)) {
6163
+ ctx.addIssue({ code: "custom", path: ["targets"], message: "amount must be positive for in/out" });
6164
+ }
6165
+ });
6166
+ var shopListedStockInput = object({
6167
+ targets: array(object({
6168
+ variantId: string2().min(1).nullish(),
6169
+ stock: number2().int().min(0).max(1e7)
6170
+ })).min(1).max(50)
6139
6171
  });
6140
6172
  var shopStripeConnectInput = object({
6141
6173
  secretKey: string2().min(1).max(255).regex(/^(sk|rk)_(test|live)_/, "must be a Stripe secret key (sk_…/rk_…)"),
@@ -6219,15 +6251,20 @@ var hrEventTypes = [
6219
6251
  var hrTaskStatuses = ["alert", "confirm", "queued", "done"];
6220
6252
  var hrTaskDecisions = ["approved", "rejected"];
6221
6253
  var optionalDay = number2().int().positive().nullable().optional();
6222
- var hrOrgCreateInput = object({
6223
- name: string2().trim().min(1).max(80)
6224
- });
6225
6254
  var hrOrgUpdateInput = object({
6226
6255
  name: string2().trim().min(1).max(80)
6227
6256
  });
6228
6257
  var hrMemberAddInput = object({
6229
6258
  username: string2().trim().toLowerCase().min(3).max(32),
6230
- title: string2().trim().max(40).optional()
6259
+ title: string2().trim().max(40).optional(),
6260
+ permissions: record(_enum(hrPermissionFlags), boolean2()).optional()
6261
+ });
6262
+ var hrInviteUpdateInput = object({
6263
+ permissions: record(_enum(hrPermissionFlags), boolean2())
6264
+ });
6265
+ var hrOrgApplicationCreateInput = object({
6266
+ name: string2().trim().min(1).max(80),
6267
+ note: string2().trim().max(200).default("")
6231
6268
  });
6232
6269
  var hrMemberUpdateInput = object({
6233
6270
  role: _enum(["admin", "member"]).optional(),
@@ -6398,6 +6435,10 @@ var scopes = [
6398
6435
  "storage.delete",
6399
6436
  "contacts.read",
6400
6437
  "contacts.write",
6438
+ "addresses.read",
6439
+ "addresses.write",
6440
+ "favorites.read",
6441
+ "favorites.write",
6401
6442
  "collab.read",
6402
6443
  "collab.write",
6403
6444
  "hr.read",
@@ -6411,6 +6452,8 @@ var scopeGroups = [
6411
6452
  { id: "storage", scopes: ["storage.read", "storage.write", "storage.delete"] },
6412
6453
  { id: "collab", scopes: ["collab.read", "collab.write"] },
6413
6454
  { id: "contacts", scopes: ["contacts.read", "contacts.write"] },
6455
+ { id: "addresses", scopes: ["addresses.read", "addresses.write"] },
6456
+ { id: "favorites", scopes: ["favorites.read", "favorites.write"] },
6414
6457
  { id: "hr", scopes: ["hr.read", "hr.write"] },
6415
6458
  { id: "agent", scopes: ["agent.read", "agent.write"] }
6416
6459
  ];
@@ -6509,7 +6552,7 @@ var agentTokenSummarySchema = object({
6509
6552
  scopes: array(_enum(scopes)),
6510
6553
  collabSpace: string2().nullable().optional(),
6511
6554
  hrOrg: string2().nullable().optional(),
6512
- kind: _enum(["agent", "api"]).optional(),
6555
+ kind: _enum(["agent", "api", "mobile"]).optional(),
6513
6556
  keyHint: string2().optional(),
6514
6557
  createdAt: number2(),
6515
6558
  lastUsedAt: number2().nullable(),
@@ -6520,6 +6563,21 @@ var apiKeyCreateInput = object({
6520
6563
  name: string2().trim().min(1).max(64),
6521
6564
  scopes: array(string2().trim().min(1).max(40)).min(1).max(32)
6522
6565
  });
6566
+ var mobileTokenCreateInput = object({
6567
+ name: string2().trim().min(1).max(64)
6568
+ });
6569
+ var agentPushPlatforms = ["android", "ios", "web"];
6570
+ var agentPushPrefsSchema = object({
6571
+ approvals: boolean2().optional(),
6572
+ tasks: boolean2().optional(),
6573
+ cron: boolean2().optional()
6574
+ });
6575
+ var agentPushRegisterInput = object({
6576
+ platform: _enum(agentPushPlatforms),
6577
+ token: string2().min(10).max(2000),
6578
+ name: string2().trim().max(64).optional(),
6579
+ prefs: agentPushPrefsSchema.optional()
6580
+ });
6523
6581
  var oauthProviders = ["google", "reai", "github"];
6524
6582
  var oauthUnbindInput = object({ provider: _enum(oauthProviders) });
6525
6583
  var oauthOnboardingConfirmInput = object({
@@ -6572,6 +6630,9 @@ var adminShopStoreStatusInput = object({
6572
6630
  var adminShopApplicationRejectInput = object({
6573
6631
  note: string2().trim().min(1).max(500)
6574
6632
  });
6633
+ var adminHrApplicationRejectInput = object({
6634
+ note: string2().trim().min(1).max(500)
6635
+ });
6575
6636
  var subscriptionChangeInput = object({
6576
6637
  tierId: string2().trim().min(1).max(50)
6577
6638
  });
@@ -6686,6 +6747,27 @@ var contactQueryInput = object({
6686
6747
  limit: number2().int().min(1).max(50).default(20),
6687
6748
  offset: number2().int().min(0).default(0)
6688
6749
  });
6750
+ var addressCreateInput = object({
6751
+ name: string2().trim().min(1).max(64),
6752
+ phone: string2().trim().min(1).max(32),
6753
+ region: string2().trim().max(128).optional().default(""),
6754
+ detail: string2().trim().min(1).max(256),
6755
+ isDefault: boolean2().optional().default(false)
6756
+ });
6757
+ var addressUpdateInput = object({
6758
+ name: string2().trim().min(1).max(64).optional(),
6759
+ phone: string2().trim().min(1).max(32).optional(),
6760
+ region: string2().trim().max(128).optional(),
6761
+ detail: string2().trim().min(1).max(256).optional(),
6762
+ isDefault: boolean2().optional()
6763
+ });
6764
+ var FAVORITE_KINDS = ["shop.product"];
6765
+ var favoriteKindSchema = _enum(FAVORITE_KINDS);
6766
+ var favoritePutInput = object({
6767
+ kind: favoriteKindSchema,
6768
+ refId: string2().trim().min(1).max(64),
6769
+ meta: record(string2(), unknown()).optional().default({})
6770
+ });
6689
6771
  var collabTaskStatuses = ["open", "done"];
6690
6772
  var collabBoostKinds = ["tasks", "members"];
6691
6773
  var COLLAB_RECORD_BODY_MAX = 20000;
@@ -7258,6 +7340,22 @@ var usage = `crab — Crab Cloud CLI(Agent-first 入口)
7258
7340
  contacts invite [--reset] [--json] 我的邀请码、链接与统计(--reset 重置,旧链接失效)
7259
7341
  contacts invitees [--offset N] [--json]
7260
7342
  我邀请的人(直接下级 + 间接计数)
7343
+ addresses list [--json] 收货地址簿(默认地址在前,上限 20 条)
7344
+ addresses add --name 收件人 --phone 电话 --detail 详址 [--region 省/市/区] [--default] [--json]
7345
+ 添加地址(--default 同时清掉旧默认)
7346
+ addresses update <id|名称> [--name] [--phone] [--region] [--detail] [--default|--no-default] [--json]
7347
+ 更新地址(只改给出的字段)
7348
+ addresses set-default <id|名称> 设为默认地址(服务端保证默认唯一,旧默认自动让位)
7349
+ addresses rm <id|名称> 删除地址(应用侧已落库的快照不受影响)
7350
+ favorites list [--kind shop.product] [--offset N] [--json]
7351
+ 收藏列表(kind 点分式「模块.资源」,v1 仅 shop.product)
7352
+ favorites add <refId> [--kind shop.product] [--meta productName=名称,storeId=店铺id] [--json]
7353
+ 收藏(幂等,重复执行返回现有条目;meta 是收藏时快照,
7354
+ shop.product 建议 productName + storeId 供展示兜底)
7355
+ favorites check <refId> [--kind shop.product] [--json]
7356
+ 查询单条是否已收藏
7357
+ favorites rm <refId> [--kind shop.product]
7358
+ 取消收藏(幂等,不存在也成功)
7261
7359
  storage ls [--q 关键字] [--source files|media|mail|collab|agent] [--category <id|none>]
7262
7360
  [--type doc|image|video|archive|code] [--tag a,b] [--trash]
7263
7361
  [--shared] [--recent] [--encrypted only|plain] [--message-id <id>]
@@ -7320,16 +7418,23 @@ var usage = `crab — Crab Cloud CLI(Agent-first 入口)
7320
7418
  post <taskId> (--body "..." | --body-file <path>) [--attach <fileId,...>] [--link <url,...>] [--json]
7321
7419
  推送任务记录(糖命令:等价 collab task records 的写入)
7322
7420
  hr orgs [--json] 我的组织列表(hr 命令需令牌绑定组织:login --scopes hr --org org:<名称>)
7323
- hr org create <名称> [--json] 创建公司 / 组织(创建者 owner)
7421
+ hr org apply <名称> [--note 说明] [--json]
7422
+ 申请创建公司(审核-计费制:通过时扣 1 积分自动建司)
7423
+ hr org application [--json] 查我的建司申请进度(pending / approved / rejected)
7424
+ hr org rename <新名称> [--org <arg>] [--json]
7425
+ 组织改名(org.manage)
7324
7426
  hr members [--org <arg>] [--json] 成员列表(角色与权限 flags)
7325
- hr member add <用户名> [--title 头衔] [--org <arg>]
7326
- 邀请平台账号入组织(member,只读)
7427
+ hr member add <用户名> [--title 头衔] [--permissions org.manage=1,...] [--org <arg>]
7428
+ 邀请平台账号入组织(默认只读,--permissions 定生效权限)
7327
7429
  hr member set <memberId> [--role admin|member] [--permissions org.manage=1,...] [--title]
7328
7430
  角色与权限逐项配置(owner 恒全权不可改)
7329
7431
  hr member rm <memberId> 移除成员
7330
- hr invite [--reset] [--org <arg>] 常驻邀请链接(未注册者注册即入组织;--reset 作废旧链)
7432
+ hr invite [--permissions org.manage=1,...] [--reset] [--org <arg>]
7433
+ 常驻邀请链接(/join 加入页;--permissions 改加入后默认权限,
7434
+ 未指定项视为关;--reset 作废旧链,配置保留)
7331
7435
  hr people [--q 关键字] [--status active|probation|resigned] [--dept] [--limit N] [--offset N] [--json]
7332
7436
  花名册(身份证/薪资对令牌永不外泄,输出如实按无权限转述)
7437
+ hr stats [--org <arg>] [--json] 花名册统计带(在职/试用/离职 + 合同与试用期风险计数)
7333
7438
  hr person <id> [--json] 人员详情
7334
7439
  hr person create --name "姓名" --hired-at YYYY-MM-DD [--dept] [--role] [--probation-end] [--contract-type] [--contract-end] [--mobile] [--email] [--notes] [--json]
7335
7440
  建档(自动落入职事件)
@@ -7337,19 +7442,36 @@ var usage = `crab — Crab Cloud CLI(Agent-first 入口)
7337
7442
  编辑(转正/离职状态流转自动落痕)
7338
7443
  hr person offboard <id> [--date YYYY-MM-DD] [--json]
7339
7444
  办理离职(档案封存,记录依法保留)
7445
+ hr person rm <id> 删除人员(仅零记录档案可删;有事件时间线一律 409)
7340
7446
  hr event add <personId> --type <onboard|contract|change|attendance|payroll|award|asset|offboard|note> --note "..." [--at YYYY-MM-DD] [--json]
7341
7447
  事件落痕(时间线即证据链)
7342
7448
  hr events [--person <id>] [--type] [--limit N] [--offset N] [--json]
7343
7449
  事件时间线
7344
7450
  hr import <文件.csv|.tsv|.json> [--org <arg>] [--json]
7345
7451
  批量建档(≤200 行;CSV/TSV 表头自动识别,姓名/入职列必填)
7452
+ hr dicts [--org <arg>] [--json] 字典全量(部门 / 岗位)
7453
+ hr dict add <dept|position> <名称> [--org <arg>] [--json]
7454
+ 新增字典项(在用项禁删,服务端 409)
7455
+ hr dict rm <entryId> [--org <arg>] 删除字典项
7346
7456
  hr tasks [--status alert|confirm|queued|done] [--kind] [--limit N] [--offset N] [--json]
7347
7457
  任务列表
7348
7458
  hr task create --kind <attendance|payroll|contract|...> --title "..." [--node] [--detail] [--due YYYY-MM-DD] [--json]
7349
7459
  新建任务
7350
7460
  hr task update <id> [--status ...] [--node] [--detail] [--due YYYY-MM-DD] [--json]
7351
7461
  任务流转
7352
- hr desk [--org <arg>] [--json] 工作台聚合(Agent 首入口:任务三段 + 大事提醒 + 最近动态)
7462
+ hr task link <taskId> [--reset] [--org <arg>] [--json]
7463
+ 申请单免登录填单链接(入职/转正/离职单发候选人;--reset 作废旧链)
7464
+ hr apply-link [--reset] [--org <arg>] [--json]
7465
+ 组织常驻入职收集链接(候选人提交即自动生成入职申请单)
7466
+ hr announce list [--all] [--limit N] [--offset N] [--org <arg>] [--json]
7467
+ 公告列表(默认只看生效;--all 含已撤回)
7468
+ hr announce post <标题> --body "..." [--org <arg>] [--json]
7469
+ 发布公告(org.manage;对象 = 组织全体成员)
7470
+ hr announce withdraw <公告id> [--org <arg>] [--json]
7471
+ 撤回公告(发布者本人或 org.manage;留档不删)
7472
+ hr inbox [--seen] [--org <arg>] [--json]
7473
+ 收件箱聚合(未读公告 + 待拍板任务 + 提醒;--seen 标记已读)
7474
+ hr desk [--org <arg>] [--json] 工作台聚合(Agent 首入口:统计 + 提醒 + 任务三段 + 公告 + 动态,一次取全)
7353
7475
  shop status [--json] 店铺列表 + 开通申请进度(多店全列,★ = 当前店)
7354
7476
  shop use <slug> 切换当前管理的店铺(持久保存;店级命令缺省作用于它,
7355
7477
  --store 可单条临时覆盖;不带参数显示当前店)
@@ -7374,12 +7496,37 @@ var usage = `crab — Crab Cloud CLI(Agent-first 入口)
7374
7496
  同级内上移/下移一位(边界静默不动)
7375
7497
  shop category delete <id或名称> --store <slug> [--json]
7376
7498
  删除分类(有子级拒绝;叶子删除后商品迁入未分类)
7377
- shop specs [--store <slug>] [--json]
7378
- 规格库模板(名称/选项值/引用商品数)
7379
- shop spec add <名称> --values <值1,值2,…> --store <slug> [--json]
7380
- shop spec update <id或名称> [--name <新名>] [--values <a,b,c>] --store <slug> [--json]
7381
- shop spec delete <id或名称> --store <slug> [--json]
7382
- 规格模板管理(引用 = 快照拷贝,改/删不影响已建商品)
7499
+ shop products [--status draft|active|archived] [--category <id|名称>] [--q 文本]
7500
+ [--limit N --offset N] [--json]
7501
+ 商品列表(多规格行库存 = 变体合计;q 按名称/描述匹配)
7502
+ shop product <id|SKU码> [--json] 商品详情(先 id 直查,404 再按店内 SKU 码反查)
7503
+ shop product create --kind physical|digital --name 名称 [--variant-mode single|multi]
7504
+ [--spec-axis "颜色=红,蓝"]… [--price-cents N] [--compare-at-cents N]
7505
+ [--category <id|名称>] [--description …] [--code <SKU码>] [--stock N]
7506
+ [--refundable on|off] [--max-per-order N] [--max-per-buyer N]
7507
+ [--delivery file|license --delivery-file <对象id>] [--status draft|active] [--json]
7508
+ 新建商品(类型与规格模式创建即锁定;多规格组合价
7509
+ 后续 product spec 配置;金额一律整数分)
7510
+ shop product update <id|SKU码> [--name …] [--description …] [--category <id|名称|none>]
7511
+ [--price-cents N] [--compare-at-cents N|clear] [--status draft|active|archived]
7512
+ [--refundable on|off] [--max-per-order N|clear] [--max-per-buyer N|clear]
7513
+ [--code <SKU码>] [--json]
7514
+ 更新商品(类型与规格模式不可变;改组合价用 product
7515
+ spec,改库存用 stock / listed)
7516
+ shop product spec <id|SKU码> --set "<值1,值2>=价格分[,划线价分][,on|off]" … [--json]
7517
+ 多规格组合整组配置(值按轴顺序;未提及组合保持
7518
+ 原值;不改库存;被台账引用的组合只能关不能删)
7519
+ shop product archive <id|SKU码> [--json]
7520
+ 下架归档(软删;订单快照不受影响)
7521
+ shop stock in|out|set <id|SKU码> --amount N [--variant <组合码|变体id|"值1/值2">]
7522
+ [--reason purchase] --note "说明" [--json]
7523
+ 库存动账(实物动仓库账;数字限量同命令走上架账;
7524
+ in/out ≥1;set 或未给 --reason 必须带 --note;
7525
+ 退货入库/发货出库由售后与发货端点自动落账)
7526
+ shop listed <id|SKU码> --stock N [--variant <组合码|变体id|"值1/值2">] [--json]
7527
+ 实物销售库存(上架额度)直设,0 ≤ N ≤ 实际库存
7528
+ shop movements [--product <id|SKU码>] [--limit N --offset N] [--json]
7529
+ 出入库台账(新→旧;--product 按商品过滤)
7383
7530
  credits [--json] 积分余额(月送/充值/合计;需 credits.read scope)
7384
7531
  credits history [--limit N] [--offset N] [--json]
7385
7532
  积分流水(变动 ±:消耗为负;需 credits.read)
@@ -8130,6 +8277,322 @@ async function contactsInvitees() {
8130
8277
  console.log(`(还有更多:contacts invitees --offset ${page.nextOffset})`);
8131
8278
  return 0;
8132
8279
  }
8280
+ function resolveAddressArg(list, arg) {
8281
+ const byId = list.find((item) => item.id === arg);
8282
+ if (byId)
8283
+ return { ok: true, id: byId.id };
8284
+ const byName = list.filter((item) => item.name === arg);
8285
+ if (byName.length === 1)
8286
+ return { ok: true, id: byName[0].id };
8287
+ if (byName.length > 1) {
8288
+ return {
8289
+ ok: false,
8290
+ failure: "ambiguous",
8291
+ error: `有 ${byName.length} 条同名地址,请改用 id:
8292
+ ` + byName.map((item) => ` ${item.id} ${item.name} ${item.phone} ${item.detail}`).join(`
8293
+ `)
8294
+ };
8295
+ }
8296
+ return { ok: false, failure: "missing", error: "地址不存在或已删除(crab addresses list 查看现有条目)。" };
8297
+ }
8298
+ async function requireAddressList() {
8299
+ const credentials = requireCredentials();
8300
+ if (typeof credentials === "number")
8301
+ return credentials;
8302
+ const res = await api2(credentials.apiBase, "/v1/addresses", { headers: authHeaders(credentials) });
8303
+ if (res.status !== 200) {
8304
+ console.error(errorMessage(res.body, "获取地址簿失败。"));
8305
+ return exitCodeForApiError(res.body);
8306
+ }
8307
+ return { credentials, list: res.body.data };
8308
+ }
8309
+ function addressLine(address) {
8310
+ const region = address.region ? `${address.region} · ` : "";
8311
+ return `${address.id} ${address.name} ${address.phone} ${region}${address.detail}${address.isDefault ? " [默认]" : ""}`;
8312
+ }
8313
+ async function addressesList() {
8314
+ const loaded = await requireAddressList();
8315
+ if (typeof loaded === "number")
8316
+ return loaded;
8317
+ if (hasFlag("--json")) {
8318
+ console.log(JSON.stringify(loaded.list, null, 2));
8319
+ return 0;
8320
+ }
8321
+ if (loaded.list.length === 0) {
8322
+ console.log("地址簿还是空的。");
8323
+ return 0;
8324
+ }
8325
+ for (const address of loaded.list)
8326
+ console.log(addressLine(address));
8327
+ console.log(`共 ${loaded.list.length} 条`);
8328
+ return 0;
8329
+ }
8330
+ function addressFieldFlags() {
8331
+ const body = {};
8332
+ let hasAny = false;
8333
+ for (const [flag, key] of [
8334
+ ["--name", "name"],
8335
+ ["--phone", "phone"],
8336
+ ["--region", "region"],
8337
+ ["--detail", "detail"]
8338
+ ]) {
8339
+ const value = readFlag(flag);
8340
+ if (value !== undefined) {
8341
+ body[key] = value;
8342
+ hasAny = true;
8343
+ }
8344
+ }
8345
+ if (hasFlag("--default")) {
8346
+ body.isDefault = true;
8347
+ hasAny = true;
8348
+ } else if (hasFlag("--no-default")) {
8349
+ body.isDefault = false;
8350
+ hasAny = true;
8351
+ }
8352
+ return { body, hasAny };
8353
+ }
8354
+ async function addressesAdd() {
8355
+ const name = readFlag("--name");
8356
+ const phone = readFlag("--phone");
8357
+ const detail = readFlag("--detail");
8358
+ if (!name || !phone || !detail) {
8359
+ console.error("用法:crab addresses add --name 收件人 --phone 电话 --detail 详址 [--region 省/市/区] [--default] [--json]");
8360
+ return EXIT_USAGE;
8361
+ }
8362
+ const credentials = requireCredentials();
8363
+ if (typeof credentials === "number")
8364
+ return credentials;
8365
+ const { body } = addressFieldFlags();
8366
+ const res = await api2(credentials.apiBase, "/v1/addresses", {
8367
+ method: "POST",
8368
+ headers: authHeaders(credentials),
8369
+ body: JSON.stringify({ name, phone, detail, region: readFlag("--region") ?? "", ...body })
8370
+ });
8371
+ if (res.status !== 200 && res.status !== 201) {
8372
+ console.error(errorMessage(res.body, "添加失败。"));
8373
+ return exitCodeForApiError(res.body);
8374
+ }
8375
+ const address = res.body.data;
8376
+ if (hasFlag("--json")) {
8377
+ console.log(JSON.stringify(address, null, 2));
8378
+ return 0;
8379
+ }
8380
+ console.log(`✓ 已添加:${address.name}(id ${address.id})${address.isDefault ? ",已设为默认" : ""}`);
8381
+ return 0;
8382
+ }
8383
+ async function addressesUpdate(idOrName) {
8384
+ if (!idOrName) {
8385
+ console.error("用法:crab addresses update <id|名称> [--name 名] [--phone 电话] [--region 省/市/区] [--detail 详址] [--default|--no-default] [--json]");
8386
+ return EXIT_USAGE;
8387
+ }
8388
+ const { body, hasAny } = addressFieldFlags();
8389
+ if (!hasAny) {
8390
+ console.error("没有给出要更新的字段(--name/--phone/--region/--detail/--default)。");
8391
+ return EXIT_USAGE;
8392
+ }
8393
+ const loaded = await requireAddressList();
8394
+ if (typeof loaded === "number")
8395
+ return loaded;
8396
+ const resolved = resolveAddressArg(loaded.list, idOrName);
8397
+ if (!resolved.ok) {
8398
+ console.error(resolved.error);
8399
+ return resolved.failure === "ambiguous" ? EXIT_USAGE : EXIT_NOT_FOUND;
8400
+ }
8401
+ const res = await api2(loaded.credentials.apiBase, `/v1/addresses/${encodeURIComponent(resolved.id)}`, {
8402
+ method: "PATCH",
8403
+ headers: authHeaders(loaded.credentials),
8404
+ body: JSON.stringify(body)
8405
+ });
8406
+ if (res.status !== 200) {
8407
+ console.error(errorMessage(res.body, "更新失败。"));
8408
+ return exitCodeForApiError(res.body);
8409
+ }
8410
+ const address = res.body.data;
8411
+ if (hasFlag("--json")) {
8412
+ console.log(JSON.stringify(address, null, 2));
8413
+ return 0;
8414
+ }
8415
+ console.log(`✓ 已更新:${addressLine(address)}`);
8416
+ return 0;
8417
+ }
8418
+ async function addressesSetDefault(idOrName) {
8419
+ if (!idOrName) {
8420
+ console.error("用法:crab addresses set-default <id|名称>");
8421
+ return EXIT_USAGE;
8422
+ }
8423
+ const loaded = await requireAddressList();
8424
+ if (typeof loaded === "number")
8425
+ return loaded;
8426
+ const resolved = resolveAddressArg(loaded.list, idOrName);
8427
+ if (!resolved.ok) {
8428
+ console.error(resolved.error);
8429
+ return resolved.failure === "ambiguous" ? EXIT_USAGE : EXIT_NOT_FOUND;
8430
+ }
8431
+ const res = await api2(loaded.credentials.apiBase, `/v1/addresses/${encodeURIComponent(resolved.id)}`, {
8432
+ method: "PATCH",
8433
+ headers: authHeaders(loaded.credentials),
8434
+ body: JSON.stringify({ isDefault: true })
8435
+ });
8436
+ if (res.status !== 200) {
8437
+ console.error(errorMessage(res.body, "设置默认地址失败。"));
8438
+ return exitCodeForApiError(res.body);
8439
+ }
8440
+ const address = res.body.data;
8441
+ if (hasFlag("--json")) {
8442
+ console.log(JSON.stringify(address, null, 2));
8443
+ return 0;
8444
+ }
8445
+ console.log(`✓ 默认地址已切换:${address.name}(${address.region ? `${address.region} · ` : ""}${address.detail})`);
8446
+ return 0;
8447
+ }
8448
+ async function addressesRm(idOrName) {
8449
+ if (!idOrName) {
8450
+ console.error("用法:crab addresses rm <id|名称>");
8451
+ return EXIT_USAGE;
8452
+ }
8453
+ const loaded = await requireAddressList();
8454
+ if (typeof loaded === "number")
8455
+ return loaded;
8456
+ const resolved = resolveAddressArg(loaded.list, idOrName);
8457
+ if (!resolved.ok) {
8458
+ console.error(resolved.error);
8459
+ return resolved.failure === "ambiguous" ? EXIT_USAGE : EXIT_NOT_FOUND;
8460
+ }
8461
+ const res = await api2(loaded.credentials.apiBase, `/v1/addresses/${encodeURIComponent(resolved.id)}`, {
8462
+ method: "DELETE",
8463
+ headers: authHeaders(loaded.credentials)
8464
+ });
8465
+ if (res.status !== 200) {
8466
+ console.error(errorMessage(res.body, "删除失败。"));
8467
+ return exitCodeForApiError(res.body);
8468
+ }
8469
+ console.log(`✓ 已删除(${resolved.id});已下单订单不受影响(结账用的是下单时的地址快照)。`);
8470
+ return 0;
8471
+ }
8472
+ var FAVORITE_KINDS_CLI = ["shop.product"];
8473
+ function favoriteKindFlag() {
8474
+ return readFlag("--kind") ?? FAVORITE_KINDS_CLI[0];
8475
+ }
8476
+ function parseMetaFlag() {
8477
+ const raw = readFlag("--meta");
8478
+ if (raw === undefined)
8479
+ return {};
8480
+ const meta = {};
8481
+ for (const pair of raw.split(",")) {
8482
+ const eq = pair.indexOf("=");
8483
+ if (eq <= 0)
8484
+ return null;
8485
+ meta[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
8486
+ }
8487
+ return meta;
8488
+ }
8489
+ function favoriteLine(favorite) {
8490
+ const productName = typeof favorite.meta.productName === "string" ? favorite.meta.productName : "";
8491
+ const storeId = typeof favorite.meta.storeId === "string" ? ` 店 ${favorite.meta.storeId}` : "";
8492
+ const name = productName ? `${productName}(${favorite.refId})` : favorite.refId;
8493
+ return `${favorite.id} ${name}${storeId} ${favorite.kind} ${new Date(favorite.createdAt).toLocaleDateString("zh-CN")}`;
8494
+ }
8495
+ async function favoritesList() {
8496
+ const credentials = requireCredentials();
8497
+ if (typeof credentials === "number")
8498
+ return credentials;
8499
+ const kind = favoriteKindFlag();
8500
+ const params = new URLSearchParams({ kind });
8501
+ const offset = readFlag("--offset");
8502
+ if (offset)
8503
+ params.set("offset", offset);
8504
+ params.set("limit", readFlag("--limit") ?? "30");
8505
+ const res = await api2(credentials.apiBase, `/v1/favorites?${params}`, { headers: authHeaders(credentials) });
8506
+ if (res.status !== 200) {
8507
+ console.error(errorMessage(res.body, "获取收藏失败。"));
8508
+ return exitCodeForApiError(res.body);
8509
+ }
8510
+ const page = res.body.data;
8511
+ if (hasFlag("--json")) {
8512
+ console.log(JSON.stringify(page, null, 2));
8513
+ return 0;
8514
+ }
8515
+ if (page.items.length === 0) {
8516
+ console.log(`还没有 ${kind} 收藏。`);
8517
+ return 0;
8518
+ }
8519
+ for (const favorite of page.items)
8520
+ console.log(favoriteLine(favorite));
8521
+ console.log(`共 ${page.total} 条`);
8522
+ if (page.nextOffset !== null)
8523
+ console.log(`(还有更多:favorites list --offset ${page.nextOffset})`);
8524
+ return 0;
8525
+ }
8526
+ async function favoritesAdd(refId) {
8527
+ if (!refId) {
8528
+ console.error("用法:crab favorites add <refId> [--kind shop.product] [--meta productName=名称,storeId=店铺id] [--json]");
8529
+ return EXIT_USAGE;
8530
+ }
8531
+ const meta = parseMetaFlag();
8532
+ if (meta === null) {
8533
+ console.error("--meta 格式不正确:应为 k=v,k=v(逗号分隔、每个键值对首个 = 分隔)。");
8534
+ return EXIT_USAGE;
8535
+ }
8536
+ const credentials = requireCredentials();
8537
+ if (typeof credentials === "number")
8538
+ return credentials;
8539
+ const kind = favoriteKindFlag();
8540
+ const res = await api2(credentials.apiBase, "/v1/favorites", {
8541
+ method: "PUT",
8542
+ headers: authHeaders(credentials),
8543
+ body: JSON.stringify({ kind, refId, meta })
8544
+ });
8545
+ if (res.status !== 200) {
8546
+ console.error(errorMessage(res.body, "收藏失败。"));
8547
+ return exitCodeForApiError(res.body);
8548
+ }
8549
+ const favorite = res.body.data;
8550
+ if (hasFlag("--json")) {
8551
+ console.log(JSON.stringify(favorite, null, 2));
8552
+ return 0;
8553
+ }
8554
+ console.log(`✓ 已收藏:${favoriteLine(favorite)}`);
8555
+ return 0;
8556
+ }
8557
+ async function favoritesCheck(refId) {
8558
+ if (!refId) {
8559
+ console.error("用法:crab favorites check <refId> [--kind shop.product] [--json]");
8560
+ return EXIT_USAGE;
8561
+ }
8562
+ const credentials = requireCredentials();
8563
+ if (typeof credentials === "number")
8564
+ return credentials;
8565
+ const kind = favoriteKindFlag();
8566
+ const res = await api2(credentials.apiBase, `/v1/favorites/${encodeURIComponent(kind)}/${encodeURIComponent(refId)}`, { headers: authHeaders(credentials) });
8567
+ if (res.status !== 200) {
8568
+ console.error(errorMessage(res.body, "查询收藏状态失败。"));
8569
+ return exitCodeForApiError(res.body);
8570
+ }
8571
+ const { favorited } = res.body.data;
8572
+ if (hasFlag("--json")) {
8573
+ console.log(JSON.stringify({ favorited }, null, 2));
8574
+ return 0;
8575
+ }
8576
+ console.log(favorited ? `✓ 已收藏(${kind} · ${refId})` : `未收藏(${kind} · ${refId})`);
8577
+ return 0;
8578
+ }
8579
+ async function favoritesRm(refId) {
8580
+ if (!refId) {
8581
+ console.error("用法:crab favorites rm <refId> [--kind shop.product]");
8582
+ return EXIT_USAGE;
8583
+ }
8584
+ const credentials = requireCredentials();
8585
+ if (typeof credentials === "number")
8586
+ return credentials;
8587
+ const kind = favoriteKindFlag();
8588
+ const res = await api2(credentials.apiBase, `/v1/favorites/${encodeURIComponent(kind)}/${encodeURIComponent(refId)}`, { method: "DELETE", headers: authHeaders(credentials) });
8589
+ if (res.status !== 200) {
8590
+ console.error(errorMessage(res.body, "取消收藏失败。"));
8591
+ return exitCodeForApiError(res.body);
8592
+ }
8593
+ console.log(`✓ 已取消收藏(${kind} · ${refId})`);
8594
+ return 0;
8595
+ }
8133
8596
  var STORAGE_SOURCES = ["files", "media", "mail", "collab", "agent"];
8134
8597
  var STORAGE_TYPES = ["doc", "image", "video", "archive", "code"];
8135
8598
  var MIME_BY_EXT = {
@@ -9634,14 +10097,42 @@ var HR_VALUED_FLAGS = [
9634
10097
  "--due",
9635
10098
  "--limit",
9636
10099
  "--offset",
9637
- "--q"
10100
+ "--q",
10101
+ "--body",
10102
+ "--meta"
9638
10103
  ];
9639
10104
  var HR_PERSON_STATUSES = ["active", "probation", "resigned"];
9640
10105
  var HR_TASK_STATUSES = ["alert", "confirm", "queued", "done"];
9641
10106
  var HR_EVENT_TYPES = ["onboard", "contract", "change", "attendance", "payroll", "award", "asset", "offboard", "note"];
10107
+ var HR_PERMISSION_FLAGS = ["org.manage", "people.write", "tasks.write", "sensitive.read"];
10108
+ var HR_DICT_TYPES = ["dept", "position"];
9642
10109
  function hrOrgToken(org) {
9643
10110
  return `org:${org.name}`;
9644
10111
  }
10112
+ function hrWebBase(apiBase) {
10113
+ if (/127\.0\.0\.1:14321|localhost:14321/.test(apiBase))
10114
+ return "http://127.0.0.1:14342";
10115
+ try {
10116
+ return `https://hr.${new URL(apiBase).host.replace(/^api\./, "")}`;
10117
+ } catch {
10118
+ return apiBase;
10119
+ }
10120
+ }
10121
+ function parseHrPermissions(raw) {
10122
+ if (raw === undefined)
10123
+ return;
10124
+ const out = { "org.manage": false, "people.write": false, "tasks.write": false, "sensitive.read": false };
10125
+ for (const pair of raw.split(",").map((x) => x.trim()).filter(Boolean)) {
10126
+ const [key, value] = pair.split("=");
10127
+ if (!key || !HR_PERMISSION_FLAGS.includes(key.trim())) {
10128
+ console.error(`未知权限项:${pair}
10129
+ 可用:${HR_PERMISSION_FLAGS.join(", ")}`);
10130
+ return null;
10131
+ }
10132
+ out[key.trim()] = value !== "0" && value?.toLowerCase() !== "false";
10133
+ }
10134
+ return out;
10135
+ }
9645
10136
  async function resolveHrOrg(credentials, arg) {
9646
10137
  const res = await api2(credentials.apiBase, "/v1/hr/orgs", { headers: authHeaders(credentials) });
9647
10138
  if (res.status !== 200) {
@@ -9654,7 +10145,7 @@ async function resolveHrOrg(credentials, arg) {
9654
10145
  }
9655
10146
  const orgs = res.body.data.orgs;
9656
10147
  if (orgs.length === 0) {
9657
- console.error("你还没有加入任何公司 / 组织(在 hr-web 创建,或 crab hr org create <名称>)。");
10148
+ console.error("你还没有加入任何公司 / 组织(在 hr-web 申请创建,或 crab hr org apply <名称> 提交申请)。");
9658
10149
  return EXIT_NOT_FOUND;
9659
10150
  }
9660
10151
  const raw = (arg ?? "").trim();
@@ -10193,166 +10684,919 @@ async function shopCategoryDelete() {
10193
10684
  console.log(`✓ 已删除分类:${target.name}(旗下商品迁入「未分类」)`);
10194
10685
  return 0;
10195
10686
  }
10196
- async function fetchSpecGroups(credentials, storeId) {
10197
- const res = await api2(credentials.apiBase, `/v1/shop/stores/${storeId}/spec-groups`, { headers: authHeaders(credentials) });
10198
- if (res.status !== 200) {
10199
- console.error(errorMessage(res.body, "获取规格库失败。"));
10200
- return exitCodeForApiError(res.body);
10687
+ var SHOP_PRODUCT_VALUED_FLAGS = [
10688
+ "--store",
10689
+ "--status",
10690
+ "--category",
10691
+ "--q",
10692
+ "--limit",
10693
+ "--offset",
10694
+ "--product",
10695
+ "--kind",
10696
+ "--variant-mode",
10697
+ "--name",
10698
+ "--price-cents",
10699
+ "--compare-at-cents",
10700
+ "--description",
10701
+ "--code",
10702
+ "--stock",
10703
+ "--refundable",
10704
+ "--max-per-order",
10705
+ "--max-per-buyer",
10706
+ "--delivery",
10707
+ "--delivery-file",
10708
+ "--variant",
10709
+ "--amount",
10710
+ "--reason",
10711
+ "--note",
10712
+ "--set",
10713
+ "--spec-axis"
10714
+ ];
10715
+ function readFlagAll(flag) {
10716
+ const out = [];
10717
+ for (let i = 0;i < args.length; i++) {
10718
+ if (args[i] === flag && i + 1 < args.length)
10719
+ out.push(args[i + 1]);
10201
10720
  }
10202
- return res.body.data.groups;
10721
+ return out;
10203
10722
  }
10204
- function resolveSpecGroupRef(list, ref) {
10205
- const byId = list.find((g) => g.id === ref);
10206
- if (byId)
10207
- return byId;
10208
- const byName = list.filter((g) => g.name === ref);
10209
- if (byName.length === 1)
10210
- return byName[0];
10211
- if (byName.length > 1)
10212
- return `名称「${ref}」有 ${byName.length} 个同名模板,请用 id 指定(crab shop specs 查看)`;
10213
- return `规格模板不存在:${ref}(crab shop specs 查看)`;
10723
+ function fmtMoney(cents) {
10724
+ if (cents === null || cents === undefined)
10725
+ return "—";
10726
+ return `$${(cents / 100).toFixed(2)}`;
10214
10727
  }
10215
- function parseSpecValues(flagName) {
10216
- const raw = readFlag(flagName);
10217
- if (raw === undefined)
10728
+ function parseCentsFlag(raw, flagName) {
10729
+ if (raw === undefined || raw.trim() === "") {
10730
+ console.error(`缺少 ${flagName}(金额一律整数分,如 1990 = $19.90)`);
10218
10731
  return null;
10219
- const values = raw.split(/[,,、]+/).map((x) => x.trim()).filter(Boolean);
10220
- if (values.length === 0 || values.length > 20) {
10221
- console.error("选项值需 1-20 个(逗号分隔;重复值自动去重)。");
10732
+ }
10733
+ const n = Number(raw.trim());
10734
+ if (!Number.isSafeInteger(n) || n < 0 || n > 1e8) {
10735
+ console.error(`${flagName} 必须是非负整数分(禁止元/浮点),如 1990 = $19.90`);
10222
10736
  return null;
10223
10737
  }
10224
- return values;
10738
+ return n;
10225
10739
  }
10226
- async function shopSpecs() {
10227
- const credentials = requireCredentials();
10228
- if (typeof credentials === "number")
10229
- return credentials;
10230
- const store = await resolveStoreArg(credentials);
10231
- if (typeof store === "number")
10232
- return store;
10233
- const groups = await fetchSpecGroups(credentials, store.id);
10234
- if (typeof groups === "number")
10235
- return groups;
10236
- if (hasFlag("--json")) {
10237
- console.log(JSON.stringify(groups, null, 2));
10238
- return 0;
10740
+ function parseIntFlag(raw, flagName, minN) {
10741
+ if (raw === undefined || raw.trim() === "") {
10742
+ console.error(`缺少 ${flagName}`);
10743
+ return null;
10239
10744
  }
10240
- if (groups.length === 0) {
10241
- console.log(`「${store.name}」规格库还是空的。crab shop spec add <名称> --values <a,b,c> 创建模板。`);
10242
- return 0;
10745
+ const n = Number(raw.trim());
10746
+ if (!Number.isSafeInteger(n) || n < minN) {
10747
+ console.error(`${flagName} 必须是 ≥${minN} 的整数`);
10748
+ return null;
10243
10749
  }
10244
- console.log(`「${store.name}」规格库(引用 = 快照拷贝进商品,改模板不影响已建商品):`);
10245
- for (const g of groups) {
10246
- console.log(` ${g.name} [${g.values.join(" / ")}] 引用商品 ${g.productCount} ${g.id}`);
10750
+ return n;
10751
+ }
10752
+ function parseOnOffFlag(raw, flagName) {
10753
+ if (raw !== "on" && raw !== "off") {
10754
+ console.error(`${flagName} 只接受 on | off`);
10755
+ return null;
10247
10756
  }
10248
- return 0;
10757
+ return raw === "on";
10249
10758
  }
10250
- async function shopSpecAdd() {
10251
- const credentials = requireCredentials();
10252
- if (typeof credentials === "number")
10253
- return credentials;
10254
- const positional = shopPositionals(3, ["--values"]);
10255
- const name = positional.join(" ").trim();
10256
- const values = parseSpecValues("--values");
10257
- if (!name || values === null) {
10258
- console.error("用法:crab shop spec add <名称> --values <值1,值2,…> --store <slug>");
10259
- return EXIT_USAGE;
10759
+ function shopStatusLabel(status) {
10760
+ return status === "active" ? "在售" : status === "draft" ? "草稿" : "已归档";
10761
+ }
10762
+ function shopKindLabel(kind) {
10763
+ return kind === "physical" ? "实物" : "数字";
10764
+ }
10765
+ function fmtListedStock(kind, stock) {
10766
+ if (stock === null)
10767
+ return kind === "physical" ? "待补(挂出不可买)" : "不限";
10768
+ return String(stock);
10769
+ }
10770
+ async function fetchShopProduct(credentials, ref) {
10771
+ const direct = await api2(credentials.apiBase, `/v1/shop/products/${encodeURIComponent(ref)}`, { headers: authHeaders(credentials) });
10772
+ if (direct.status === 200)
10773
+ return direct.body.data.product;
10774
+ if (direct.status !== 404) {
10775
+ console.error(errorMessage(direct.body, "获取商品失败。"));
10776
+ return exitCodeForApiError(direct.body);
10260
10777
  }
10261
10778
  const store = await resolveStoreArg(credentials);
10262
10779
  if (typeof store === "number")
10263
10780
  return store;
10264
- const res = await api2(credentials.apiBase, `/v1/shop/stores/${store.id}/spec-groups`, {
10265
- method: "POST",
10266
- headers: authHeaders(credentials),
10267
- body: JSON.stringify({ name, values })
10268
- });
10269
- if (res.status !== 201) {
10270
- console.error(errorMessage(res.body, "创建规格模板失败。"));
10271
- return exitCodeForApiError(res.body);
10781
+ const lookup = await api2(credentials.apiBase, `/v1/shop/stores/${store.id}/products/lookup?code=${encodeURIComponent(ref)}`, { headers: authHeaders(credentials) });
10782
+ if (lookup.status !== 200) {
10783
+ console.error(`找不到商品:${ref}(不是商品 id,也不是店内 SKU 码;crab shop products 查看)`);
10784
+ return EXIT_NOT_FOUND;
10272
10785
  }
10273
- const group = res.body.data.group;
10274
- if (hasFlag("--json")) {
10275
- console.log(JSON.stringify(group, null, 2));
10276
- return 0;
10786
+ const productId = lookup.body.data.productId;
10787
+ const detail = await api2(credentials.apiBase, `/v1/shop/products/${productId}`, { headers: authHeaders(credentials) });
10788
+ if (detail.status !== 200) {
10789
+ console.error(errorMessage(detail.body, "获取商品失败。"));
10790
+ return exitCodeForApiError(detail.body);
10277
10791
  }
10278
- console.log(`✓ 模板已创建:${group.name} [${group.values.join(" / ")}](${group.id})`);
10279
- return 0;
10792
+ return detail.body.data.product;
10793
+ }
10794
+ async function resolveCategoryFlag(credentials, storeId, ref, allowNone) {
10795
+ if (allowNone && ref === "none")
10796
+ return { id: null };
10797
+ const categories = await fetchStoreCategories(credentials, storeId);
10798
+ if (typeof categories === "number")
10799
+ return categories;
10800
+ const hit = resolveCategoryRef(categories, ref);
10801
+ if (typeof hit === "string") {
10802
+ console.error(hit);
10803
+ return EXIT_NOT_FOUND;
10804
+ }
10805
+ return { id: hit.id };
10806
+ }
10807
+ function resolveVariantRef(product, ref) {
10808
+ const normalized = ref.replace(/\s*\/\s*/g, "/");
10809
+ return product.variants.find((x) => x.id === ref) ?? product.variants.find((x) => x.code === ref) ?? product.variants.find((x) => x.combo.join("/") === normalized) ?? null;
10280
10810
  }
10281
- async function shopSpecUpdate() {
10811
+ function cartesianCombos(axes) {
10812
+ let acc = [[]];
10813
+ for (const axis of axes)
10814
+ acc = acc.flatMap((prefix) => axis.values.map((value) => [...prefix, value]));
10815
+ return acc;
10816
+ }
10817
+ function variantChoiceList(product) {
10818
+ return product.variants.map((v) => ` ${v.code} ${v.combo.join(" / ")}(id ${v.id})`).join(`
10819
+ `);
10820
+ }
10821
+ async function shopProductsList() {
10282
10822
  const credentials = requireCredentials();
10283
10823
  if (typeof credentials === "number")
10284
10824
  return credentials;
10285
- const positional = shopPositionals(3, ["--name", "--values"]);
10286
- const ref = positional[0];
10287
- const name = readFlag("--name");
10288
- const values = readFlag("--values") !== undefined ? parseSpecValues("--values") : undefined;
10289
- if (!ref || name === undefined && values === undefined) {
10290
- console.error("用法:crab shop spec update <模板 id 或名称> [--name <新名>] [--values <a,b,c>] --store <slug>");
10825
+ const store = await resolveStoreArg(credentials);
10826
+ if (typeof store === "number")
10827
+ return store;
10828
+ const status = readFlag("--status");
10829
+ if (status && !["draft", "active", "archived"].includes(status)) {
10830
+ console.error("--status 只接受 draft | active | archived");
10831
+ return EXIT_USAGE;
10832
+ }
10833
+ const params = new URLSearchParams;
10834
+ if (status)
10835
+ params.set("status", status);
10836
+ const categoryRef = readFlag("--category");
10837
+ if (categoryRef) {
10838
+ const cat = await resolveCategoryFlag(credentials, store.id, categoryRef, true);
10839
+ if (typeof cat === "number")
10840
+ return cat;
10841
+ params.set("categoryId", cat.id ?? "none");
10842
+ }
10843
+ const q = readFlag("--q");
10844
+ if (q)
10845
+ params.set("q", q);
10846
+ const limit = readFlag("--limit");
10847
+ if (limit)
10848
+ params.set("limit", limit);
10849
+ const offset = readFlag("--offset");
10850
+ if (offset)
10851
+ params.set("offset", offset);
10852
+ const res = await api2(credentials.apiBase, `/v1/shop/stores/${store.id}/products?${params.toString()}`, { headers: authHeaders(credentials) });
10853
+ if (res.status !== 200) {
10854
+ console.error(errorMessage(res.body, "获取商品列表失败。"));
10855
+ return exitCodeForApiError(res.body);
10856
+ }
10857
+ const data = res.body.data;
10858
+ if (hasFlag("--json")) {
10859
+ console.log(JSON.stringify(data, null, 2));
10860
+ return 0;
10861
+ }
10862
+ if (data.products.length === 0) {
10863
+ console.log(`「${store.name}」还没有符合条件的商品。新建:crab shop product create --kind physical --name 名称 --variant-mode single`);
10864
+ return 0;
10865
+ }
10866
+ for (const p of data.products) {
10867
+ const shape = p.hasVariants ? `多规格·${p.variants.length} 组合` : "单规格";
10868
+ let price;
10869
+ if (p.hasVariants && p.variants.length > 0) {
10870
+ const prices = p.variants.map((v) => v.priceCents);
10871
+ const min = Math.min(...prices);
10872
+ const max = Math.max(...prices);
10873
+ price = min === max ? fmtMoney(min) : `${fmtMoney(min)} ~ ${fmtMoney(max)}`;
10874
+ } else {
10875
+ price = fmtMoney(p.priceCents);
10876
+ }
10877
+ const stock = p.hasVariants ? p.variants.some((v) => v.stock === null) ? "上架 不限" : `上架 ${p.variants.reduce((a, v) => a + (v.stock ?? 0), 0)}` : `上架 ${fmtListedStock(p.kind, p.stock)}`;
10878
+ const warehouse = p.kind === "physical" ? ` · 仓库 ${p.hasVariants ? p.variants.every((v) => v.warehouseStock === null) ? "—" : p.variants.reduce((a, v) => a + (v.warehouseStock ?? 0), 0) : p.warehouseStock ?? 0}` : "";
10879
+ console.log(`${p.id} [${shopStatusLabel(p.status)}] ${p.name} ${shopKindLabel(p.kind)}·${shape} ${price} ${stock}${warehouse}${p.code ? ` ${p.code}` : ""}`);
10880
+ }
10881
+ console.log(`共 ${data.total} 件商品(本页 ${data.products.length} 件;--offset 翻页)`);
10882
+ return 0;
10883
+ }
10884
+ async function shopProductDetail(ref) {
10885
+ const credentials = requireCredentials();
10886
+ if (typeof credentials === "number")
10887
+ return credentials;
10888
+ if (!ref) {
10889
+ console.error("用法:crab shop product <id|SKU码> [--json]");
10890
+ return EXIT_USAGE;
10891
+ }
10892
+ const product = await fetchShopProduct(credentials, ref);
10893
+ if (typeof product === "number")
10894
+ return product;
10895
+ if (hasFlag("--json")) {
10896
+ console.log(JSON.stringify(product, null, 2));
10897
+ return 0;
10898
+ }
10899
+ let categoryName = product.categoryId ?? "未分类";
10900
+ if (product.categoryId) {
10901
+ const categories = await fetchStoreCategories(credentials, product.storeId);
10902
+ if (typeof categories !== "number")
10903
+ categoryName = categories.find((c) => c.id === product.categoryId)?.name ?? product.categoryId;
10904
+ }
10905
+ console.log(`${product.name} [${shopStatusLabel(product.status)}]`);
10906
+ console.log(` ${shopKindLabel(product.kind)} · ${product.hasVariants ? "多规格" : "单规格"} 分类:${categoryName} id:${product.id}`);
10907
+ if (product.code)
10908
+ console.log(` SKU 码:${product.code}`);
10909
+ const priceParts = [`基础价:${fmtMoney(product.priceCents)}`];
10910
+ if (product.compareAtPriceCents !== null)
10911
+ priceParts.push(`划线价:${fmtMoney(product.compareAtPriceCents)}`);
10912
+ priceParts.push(`退款:${product.refundable ? "开" : "关"}`);
10913
+ if (product.maxPerOrder !== null)
10914
+ priceParts.push(`单笔限购 ${product.maxPerOrder}`);
10915
+ if (product.maxPerBuyer !== null)
10916
+ priceParts.push(`每买家限购 ${product.maxPerBuyer}`);
10917
+ console.log(` ${priceParts.join(" ")}`);
10918
+ if (product.kind === "digital") {
10919
+ console.log(` 交付:${product.deliveryType === "license" ? "license 密钥" : "文件下载"}${product.deliveryFileId ? `(对象 ${product.deliveryFileId})` : ""}`);
10920
+ } else if (product.hasVariants) {
10921
+ const listedStr = product.variants.some((v) => v.stock === null) ? "不限" : `合计 ${product.variants.reduce((a, v) => a + (v.stock ?? 0), 0)}`;
10922
+ const whStr = product.variants.every((v) => v.warehouseStock === null) ? "—" : `合计 ${product.variants.reduce((a, v) => a + (v.warehouseStock ?? 0), 0)}`;
10923
+ console.log(` 库存:上架 ${listedStr} · 仓库 ${whStr}`);
10924
+ } else {
10925
+ console.log(` 库存:上架 ${fmtListedStock(product.kind, product.stock)}${product.kind === "physical" ? ` · 仓库 ${product.warehouseStock ?? 0}` : ""}`);
10926
+ }
10927
+ if (product.specAxes.length > 0) {
10928
+ console.log(` 规格轴:${product.specAxes.map((a) => `${a.name}(${a.values.join("、")})`).join(" ")}`);
10929
+ }
10930
+ if (product.hasVariants) {
10931
+ if (product.variants.length === 0) {
10932
+ console.log(' 组合:待配置——crab shop product spec <id> --set "值1,值2=价格分" 配置组合价格后才能上架');
10933
+ } else {
10934
+ console.log(` 组合(${product.variants.length}):`);
10935
+ for (const v of product.variants) {
10936
+ const price = `${fmtMoney(v.priceCents)}${v.compareAtPriceCents !== null ? `(划线 ${fmtMoney(v.compareAtPriceCents)})` : ""}`;
10937
+ const stock = `上架 ${v.stock === null ? "不限" : v.stock}${v.warehouseStock !== null ? ` · 仓库 ${v.warehouseStock}` : ""}`;
10938
+ console.log(` ${v.code} ${v.combo.join(" / ")} ${price} ${stock} ${v.enabled ? "在售" : "停售"}`);
10939
+ }
10940
+ }
10941
+ }
10942
+ if (product.gallery.length > 0)
10943
+ console.log(` 图册:${product.gallery.length} 张(第一张为主图)`);
10944
+ if (product.description)
10945
+ console.log(` 描述:${product.description.length > 100 ? `${product.description.slice(0, 100)}…` : product.description}`);
10946
+ console.log(` 更新:${formatTime(product.updatedAt)}${product.status === "archived" ? "(恢复上架:product update <id> --status draft)" : ""}`);
10947
+ return 0;
10948
+ }
10949
+ async function shopProductCreate() {
10950
+ const credentials = requireCredentials();
10951
+ if (typeof credentials === "number")
10952
+ return credentials;
10953
+ const kind = readFlag("--kind");
10954
+ if (kind !== "physical" && kind !== "digital") {
10955
+ console.error(`用法:crab shop product create --kind physical|digital --name 名称 [--variant-mode single|multi] [--spec-axis "颜色=红,蓝"]… [--price-cents N] [--status draft|active] …
10956
+ 必填:--kind(类型,创建后不可变)与 --name;金额一律整数分`);
10291
10957
  return EXIT_USAGE;
10292
10958
  }
10293
- if (values === null)
10959
+ const name = readFlag("--name")?.trim();
10960
+ if (!name) {
10961
+ console.error("缺少 --name 名称");
10962
+ return EXIT_USAGE;
10963
+ }
10964
+ const variantMode = readFlag("--variant-mode") ?? "single";
10965
+ if (variantMode !== "single" && variantMode !== "multi") {
10966
+ console.error("--variant-mode 只接受 single | multi(创建即锁定,之后不可改)");
10294
10967
  return EXIT_USAGE;
10968
+ }
10969
+ const axisArgs = readFlagAll("--spec-axis");
10970
+ if (variantMode === "single" && axisArgs.length > 0) {
10971
+ console.error("单规格商品没有规格轴(规格模式创建即锁定);要规格就 --variant-mode multi");
10972
+ return EXIT_USAGE;
10973
+ }
10974
+ const specAxes = [];
10975
+ for (const raw of axisArgs) {
10976
+ const eq = raw.indexOf("=");
10977
+ if (eq <= 0) {
10978
+ console.error(`--spec-axis 格式:轴名=值1,值2(如 "颜色=红,蓝"),收到「${raw}」`);
10979
+ return EXIT_USAGE;
10980
+ }
10981
+ const axisName = raw.slice(0, eq).trim();
10982
+ const values = raw.slice(eq + 1).split(/[,,、]+/).map((x) => x.trim()).filter(Boolean);
10983
+ if (!axisName || values.length === 0) {
10984
+ console.error(`--spec-axis 轴名与选项值都不能为空:「${raw}」`);
10985
+ return EXIT_USAGE;
10986
+ }
10987
+ specAxes.push({ name: axisName, values });
10988
+ }
10989
+ if (specAxes.length > 3) {
10990
+ console.error("规格轴最多 3 条(--spec-axis 可重复出现)");
10991
+ return EXIT_USAGE;
10992
+ }
10993
+ if (variantMode === "multi") {
10994
+ const comboCount = specAxes.reduce((acc, a) => acc * a.values.length, 1);
10995
+ if (comboCount > 24) {
10996
+ console.error(`规格组合 ${comboCount} 个超出上限 24——减少轴或选项值`);
10997
+ return EXIT_USAGE;
10998
+ }
10999
+ }
11000
+ const body = { kind, variantMode, name };
11001
+ if (variantMode === "multi" && specAxes.length > 0)
11002
+ body.specAxes = specAxes;
11003
+ const status = readFlag("--status");
11004
+ if (status) {
11005
+ if (status !== "draft" && status !== "active") {
11006
+ console.error("--status 创建时只接受 draft | active");
11007
+ return EXIT_USAGE;
11008
+ }
11009
+ body.status = status;
11010
+ }
11011
+ const categoryRef = readFlag("--category");
11012
+ if (categoryRef) {
11013
+ const store = await resolveStoreArg(credentials);
11014
+ if (typeof store === "number")
11015
+ return store;
11016
+ const cat = await resolveCategoryFlag(credentials, store.id, categoryRef, false);
11017
+ if (typeof cat === "number")
11018
+ return cat;
11019
+ body.categoryId = cat.id;
11020
+ }
11021
+ const description = readFlag("--description");
11022
+ if (description !== undefined)
11023
+ body.description = description;
11024
+ const priceCents = readFlag("--price-cents");
11025
+ if (priceCents !== undefined) {
11026
+ const parsed = parseCentsFlag(priceCents, "--price-cents");
11027
+ if (parsed === null)
11028
+ return EXIT_USAGE;
11029
+ body.priceCents = parsed;
11030
+ }
11031
+ const compareAt = readFlag("--compare-at-cents");
11032
+ if (compareAt !== undefined) {
11033
+ const parsed = parseCentsFlag(compareAt, "--compare-at-cents");
11034
+ if (parsed === null)
11035
+ return EXIT_USAGE;
11036
+ body.compareAtPriceCents = parsed;
11037
+ }
11038
+ const code = readFlag("--code");
11039
+ if (code !== undefined) {
11040
+ if (variantMode === "multi") {
11041
+ console.error("多规格商品的码在每个组合上(组合配置时自动/指定分配),商品级 --code 不适用");
11042
+ return EXIT_USAGE;
11043
+ }
11044
+ body.code = code.trim();
11045
+ }
11046
+ const stockRaw = readFlag("--stock");
11047
+ if (stockRaw !== undefined) {
11048
+ if (variantMode === "multi") {
11049
+ console.error("多规格商品的库存挂在组合上:创建后用 crab shop stock(仓库账)/ crab shop listed(上架额度)按组合调整");
11050
+ return EXIT_USAGE;
11051
+ }
11052
+ const parsed = parseIntFlag(stockRaw, "--stock", 0);
11053
+ if (parsed === null)
11054
+ return EXIT_USAGE;
11055
+ body.stock = parsed;
11056
+ }
11057
+ const refundable = readFlag("--refundable");
11058
+ if (refundable !== undefined) {
11059
+ const parsed = parseOnOffFlag(refundable, "--refundable");
11060
+ if (parsed === null)
11061
+ return EXIT_USAGE;
11062
+ body.refundable = parsed;
11063
+ }
11064
+ for (const [flag, key] of [["--max-per-order", "maxPerOrder"], ["--max-per-buyer", "maxPerBuyer"]]) {
11065
+ const raw = readFlag(flag);
11066
+ if (raw !== undefined) {
11067
+ const parsed = parseIntFlag(raw, flag, 1);
11068
+ if (parsed === null)
11069
+ return EXIT_USAGE;
11070
+ body[key] = parsed;
11071
+ }
11072
+ }
11073
+ const deliveryType = readFlag("--delivery");
11074
+ const deliveryFile = readFlag("--delivery-file");
11075
+ if (deliveryType !== undefined || deliveryFile !== undefined) {
11076
+ if (kind !== "digital") {
11077
+ console.error("--delivery / --delivery-file 只适用于数字商品(实物无交付形态)");
11078
+ return EXIT_USAGE;
11079
+ }
11080
+ if (deliveryType !== undefined) {
11081
+ if (deliveryType !== "file" && deliveryType !== "license") {
11082
+ console.error("--delivery 只接受 file | license");
11083
+ return EXIT_USAGE;
11084
+ }
11085
+ body.deliveryType = deliveryType;
11086
+ }
11087
+ if (deliveryFile !== undefined)
11088
+ body.deliveryFileId = deliveryFile;
11089
+ }
10295
11090
  const store = await resolveStoreArg(credentials);
10296
11091
  if (typeof store === "number")
10297
11092
  return store;
10298
- const groups = await fetchSpecGroups(credentials, store.id);
10299
- if (typeof groups === "number")
10300
- return groups;
10301
- const target = resolveSpecGroupRef(groups, ref);
10302
- if (typeof target === "string") {
10303
- console.error(target);
10304
- return EXIT_NOT_FOUND;
11093
+ const res = await api2(credentials.apiBase, `/v1/shop/stores/${store.id}/products`, {
11094
+ method: "POST",
11095
+ headers: authHeaders(credentials),
11096
+ body: JSON.stringify(body)
11097
+ });
11098
+ if (res.status !== 201) {
11099
+ console.error(errorMessage(res.body, "创建商品失败。"));
11100
+ if (res.body?.error?.code === "specs_required") {
11101
+ console.error('提示:多规格商品上架前必须配置完整组合——crab shop product spec <id> --set "值1,值2=价格分"');
11102
+ }
11103
+ return exitCodeForApiError(res.body);
11104
+ }
11105
+ const product = res.body.data.product;
11106
+ if (hasFlag("--json")) {
11107
+ console.log(JSON.stringify(product, null, 2));
11108
+ return 0;
11109
+ }
11110
+ console.log(`✓ 已创建商品(${shopStatusLabel(product.status)}):${product.name} ${product.id}`);
11111
+ if (product.hasVariants) {
11112
+ console.log(` 规格轴:${product.specAxes.map((a) => `${a.name}(${a.values.join("、")})`).join(" ")}`);
11113
+ console.log(` 下一步:crab shop product spec ${product.id} --set "值1,值2=价格分" 配置组合价格(配置完整后才能上架)`);
11114
+ } else {
11115
+ console.log(` SKU 码:${product.code}`);
11116
+ console.log(` 下一步:crab shop product update ${product.id} --price-cents 1990 --status active 上架(金额整数分)`);
11117
+ }
11118
+ return 0;
11119
+ }
11120
+ async function shopProductUpdate() {
11121
+ const credentials = requireCredentials();
11122
+ if (typeof credentials === "number")
11123
+ return credentials;
11124
+ const target = shopPositionals(3, SHOP_PRODUCT_VALUED_FLAGS)[0];
11125
+ if (!target) {
11126
+ console.error(`用法:crab shop product update <id|SKU码> [--name …] [--description …] [--category <id|名称|none>] [--price-cents N] [--compare-at-cents N|clear] [--status draft|active|archived] [--refundable on|off] [--max-per-order N|clear] [--max-per-buyer N|clear] [--code <SKU码>] [--json]
11127
+ 只提交给出的字段;改多规格组合用 product spec,改库存用 stock / listed`);
11128
+ return EXIT_USAGE;
11129
+ }
11130
+ const product = await fetchShopProduct(credentials, target);
11131
+ if (typeof product === "number")
11132
+ return product;
11133
+ const body = {};
11134
+ const name = readFlag("--name");
11135
+ if (name !== undefined) {
11136
+ if (!name.trim()) {
11137
+ console.error("--name 不能为空");
11138
+ return EXIT_USAGE;
11139
+ }
11140
+ body.name = name.trim();
11141
+ }
11142
+ const description = readFlag("--description");
11143
+ if (description !== undefined)
11144
+ body.description = description;
11145
+ const categoryRef = readFlag("--category");
11146
+ if (categoryRef !== undefined) {
11147
+ const cat = await resolveCategoryFlag(credentials, product.storeId, categoryRef, true);
11148
+ if (typeof cat === "number")
11149
+ return cat;
11150
+ body.categoryId = cat.id;
11151
+ }
11152
+ const status = readFlag("--status");
11153
+ if (status !== undefined) {
11154
+ if (!["draft", "active", "archived"].includes(status)) {
11155
+ console.error("--status 只接受 draft | active | archived");
11156
+ return EXIT_USAGE;
11157
+ }
11158
+ body.status = status;
11159
+ }
11160
+ const priceRaw = readFlag("--price-cents");
11161
+ if (priceRaw !== undefined) {
11162
+ const parsed = parseCentsFlag(priceRaw, "--price-cents");
11163
+ if (parsed === null)
11164
+ return EXIT_USAGE;
11165
+ body.priceCents = parsed;
11166
+ }
11167
+ const compareAtRaw = readFlag("--compare-at-cents");
11168
+ if (compareAtRaw !== undefined) {
11169
+ if (compareAtRaw === "clear")
11170
+ body.compareAtPriceCents = null;
11171
+ else {
11172
+ const parsed = parseCentsFlag(compareAtRaw, "--compare-at-cents");
11173
+ if (parsed === null)
11174
+ return EXIT_USAGE;
11175
+ body.compareAtPriceCents = parsed;
11176
+ }
11177
+ }
11178
+ const refundable = readFlag("--refundable");
11179
+ if (refundable !== undefined) {
11180
+ const parsed = parseOnOffFlag(refundable, "--refundable");
11181
+ if (parsed === null)
11182
+ return EXIT_USAGE;
11183
+ body.refundable = parsed;
11184
+ }
11185
+ for (const [flag, key] of [["--max-per-order", "maxPerOrder"], ["--max-per-buyer", "maxPerBuyer"]]) {
11186
+ const raw = readFlag(flag);
11187
+ if (raw !== undefined) {
11188
+ if (raw === "clear")
11189
+ body[key] = null;
11190
+ else {
11191
+ const parsed = parseIntFlag(raw, flag, 1);
11192
+ if (parsed === null)
11193
+ return EXIT_USAGE;
11194
+ body[key] = parsed;
11195
+ }
11196
+ }
11197
+ }
11198
+ const code = readFlag("--code");
11199
+ if (code !== undefined) {
11200
+ if (product.hasVariants) {
11201
+ console.error("多规格商品的码在每个组合上(商品级 --code 不适用,服务端会忽略)");
11202
+ return EXIT_USAGE;
11203
+ }
11204
+ body.code = code.trim();
10305
11205
  }
10306
- const res = await api2(credentials.apiBase, `/v1/shop/spec-groups/${target.id}`, {
11206
+ if (Object.keys(body).length === 0) {
11207
+ console.error("没有任何要更新的字段——至少给一个 --name / --price-cents / --status …");
11208
+ return EXIT_USAGE;
11209
+ }
11210
+ const res = await api2(credentials.apiBase, `/v1/shop/products/${product.id}`, {
10307
11211
  method: "PATCH",
10308
11212
  headers: authHeaders(credentials),
10309
- body: JSON.stringify({ ...name !== undefined ? { name: name.trim() } : {}, ...values !== undefined ? { values } : {} })
11213
+ body: JSON.stringify(body)
10310
11214
  });
10311
11215
  if (res.status !== 200) {
10312
- console.error(errorMessage(res.body, "更新规格模板失败。"));
11216
+ console.error(errorMessage(res.body, "更新商品失败。"));
11217
+ return exitCodeForApiError(res.body);
11218
+ }
11219
+ const updated = res.body.data.product;
11220
+ if (hasFlag("--json")) {
11221
+ console.log(JSON.stringify(updated, null, 2));
11222
+ return 0;
11223
+ }
11224
+ console.log(`✓ 已更新:${updated.name} [${shopStatusLabel(updated.status)}] ${updated.id}`);
11225
+ console.log(` 基础价:${fmtMoney(updated.priceCents)}${updated.compareAtPriceCents !== null ? `(划线 ${fmtMoney(updated.compareAtPriceCents)})` : ""}`);
11226
+ return 0;
11227
+ }
11228
+ function parseSpecSet(raw, axes) {
11229
+ const eq = raw.indexOf("=");
11230
+ if (eq <= 0)
11231
+ return `--set 格式:"<值1,值2>=价格分[,划线价分|clear][,on|off]"(如 "红,M=1990,2500,on"),收到「${raw}」`;
11232
+ const combo = raw.slice(0, eq).split(/[,,、]+/).map((x) => x.trim());
11233
+ if (combo.length !== axes.length) {
11234
+ return `组合需要 ${axes.length} 个值(轴顺序:${axes.map((a) => a.name).join(" / ")}),收到「${raw.slice(0, eq)}」`;
11235
+ }
11236
+ for (let i = 0;i < combo.length; i++) {
11237
+ if (!axes[i].values.includes(combo[i])) {
11238
+ return `值「${combo[i]}」不在规格轴「${axes[i].name}」的选项里(可选:${axes[i].values.join("、")})`;
11239
+ }
11240
+ }
11241
+ const right = raw.slice(eq + 1).split(/[,,、]+/).map((x) => x.trim());
11242
+ const price = Number(right[0]);
11243
+ if (!Number.isSafeInteger(price) || price < 0) {
11244
+ return `--set 价格分必须是非负整数(如 1990 = $19.90),收到「${right[0]}」`;
11245
+ }
11246
+ const entry = { combo, price };
11247
+ for (const extra of right.slice(1)) {
11248
+ if (extra === "on" || extra === "off")
11249
+ entry.enabled = extra === "on";
11250
+ else if (extra === "clear")
11251
+ entry.compareAt = null;
11252
+ else {
11253
+ const n = Number(extra);
11254
+ if (!Number.isSafeInteger(n) || n < 0)
11255
+ return `--set 第 2 段起应为划线价分、clear 或 on|off,收到「${extra}」`;
11256
+ entry.compareAt = n;
11257
+ }
11258
+ }
11259
+ return entry;
11260
+ }
11261
+ async function shopProductSpec() {
11262
+ const credentials = requireCredentials();
11263
+ if (typeof credentials === "number")
11264
+ return credentials;
11265
+ const target = shopPositionals(3, SHOP_PRODUCT_VALUED_FLAGS)[0];
11266
+ const sets = readFlagAll("--set");
11267
+ if (!target || sets.length === 0) {
11268
+ console.error(`用法:crab shop product spec <id|SKU码> --set "<值1,值2>=价格分[,划线价分|clear][,on|off]" … [--json]
11269
+ 值按规格轴顺序给(值1=第一轴);未提及的组合保持原值;本命令不改库存(动账 stock / 上架额度 listed);规格轴在创建时锁定,轴数量首次保存即锁`);
11270
+ return EXIT_USAGE;
11271
+ }
11272
+ const product = await fetchShopProduct(credentials, target);
11273
+ if (typeof product === "number")
11274
+ return product;
11275
+ if (!product.hasVariants) {
11276
+ console.error("单规格商品没有规格组合——改价格用 crab shop product update <id> --price-cents N");
11277
+ return EXIT_USAGE;
11278
+ }
11279
+ if (product.specAxes.length === 0) {
11280
+ console.error("该商品还没有规格轴(多规格商品创建时用 --spec-axis 配置;存量无轴商品暂不支持 CLI 加轴,到商家控制台操作)");
11281
+ return EXIT_USAGE;
11282
+ }
11283
+ const overrides = new Map;
11284
+ for (const raw of sets) {
11285
+ const parsed = parseSpecSet(raw, product.specAxes);
11286
+ if (typeof parsed === "string") {
11287
+ console.error(parsed);
11288
+ return EXIT_USAGE;
11289
+ }
11290
+ overrides.set(JSON.stringify(parsed.combo), parsed);
11291
+ }
11292
+ const prevByKey = new Map(product.variants.map((v) => [v.comboKey, v]));
11293
+ const combos = cartesianCombos(product.specAxes);
11294
+ if (combos.length > 24) {
11295
+ console.error(`规格组合 ${combos.length} 个超出上限 24`);
11296
+ return EXIT_USAGE;
11297
+ }
11298
+ const unresolved = combos.filter((combo) => {
11299
+ const key = JSON.stringify(combo);
11300
+ return !overrides.has(key) && !prevByKey.has(key) && product.priceCents === null;
11301
+ });
11302
+ if (unresolved.length > 0) {
11303
+ console.error(`以下新组合未给价格且商品无基础价可沿用:${unresolved.map((c) => c.join("/")).join("、")}
11304
+ 用 --set 补价格,或先 crab shop product update <id> --price-cents N 设基础价`);
11305
+ return EXIT_USAGE;
11306
+ }
11307
+ const variants = combos.map((combo) => {
11308
+ const key = JSON.stringify(combo);
11309
+ const prev = prevByKey.get(key);
11310
+ const o = overrides.get(key);
11311
+ const row = { combo, enabled: o?.enabled ?? prev?.enabled ?? true };
11312
+ const price = o ? o.price : prev?.priceCents;
11313
+ if (price !== undefined)
11314
+ row.priceCents = price;
11315
+ row.compareAtPriceCents = o ? o.compareAt !== undefined ? o.compareAt : prev?.compareAtPriceCents ?? null : prev?.compareAtPriceCents ?? null;
11316
+ if (prev) {
11317
+ row.stock = prev.stock;
11318
+ row.code = prev.code;
11319
+ }
11320
+ return row;
11321
+ });
11322
+ const res = await api2(credentials.apiBase, `/v1/shop/products/${product.id}`, {
11323
+ method: "PATCH",
11324
+ headers: authHeaders(credentials),
11325
+ body: JSON.stringify({ specAxes: product.specAxes, variants })
11326
+ });
11327
+ if (res.status !== 200) {
11328
+ console.error(errorMessage(res.body, "配置组合失败。"));
11329
+ const code = res.body?.error?.code;
11330
+ if (code === "spec_combo_in_ledger")
11331
+ console.error('提示:被出入库台账引用的组合不能删除——用 --set "…,off" 关闭在售代替');
11332
+ if (code === "exceeds_warehouse")
11333
+ console.error("提示:组合销售库存不能超过实际库存(存量组合原值回写不该触发;检查商品是否有超账警示态)");
11334
+ return exitCodeForApiError(res.body);
11335
+ }
11336
+ const updated = res.body.data.product;
11337
+ if (hasFlag("--json")) {
11338
+ console.log(JSON.stringify(updated, null, 2));
11339
+ return 0;
11340
+ }
11341
+ console.log(`✓ 已配置 ${updated.variants.length} 个组合(${updated.variants.filter((v) => v.enabled).length} 个在售):`);
11342
+ for (const v of updated.variants) {
11343
+ const price = `${fmtMoney(v.priceCents)}${v.compareAtPriceCents !== null ? `(划线 ${fmtMoney(v.compareAtPriceCents)})` : ""}`;
11344
+ const stock = `上架 ${v.stock === null ? "不限" : v.stock}${v.warehouseStock !== null ? ` · 仓库 ${v.warehouseStock}` : ""}`;
11345
+ console.log(` ${v.code} ${v.combo.join(" / ")} ${price} ${stock} ${v.enabled ? "在售" : "停售"}`);
11346
+ }
11347
+ console.log("改库存:crab shop stock in|out|set <id>(仓库账)· crab shop listed <id>(上架额度,实物)");
11348
+ return 0;
11349
+ }
11350
+ async function shopProductArchive() {
11351
+ const credentials = requireCredentials();
11352
+ if (typeof credentials === "number")
11353
+ return credentials;
11354
+ const target = shopPositionals(3, SHOP_PRODUCT_VALUED_FLAGS)[0];
11355
+ if (!target) {
11356
+ console.error(`用法:crab shop product archive <id|SKU码> [--json]
11357
+ 下架归档(软删):买家不可见,历史订单快照不受影响;恢复用 product update <id> --status draft`);
11358
+ return EXIT_USAGE;
11359
+ }
11360
+ const product = await fetchShopProduct(credentials, target);
11361
+ if (typeof product === "number")
11362
+ return product;
11363
+ const res = await api2(credentials.apiBase, `/v1/shop/products/${product.id}`, { method: "DELETE", headers: authHeaders(credentials) });
11364
+ if (res.status !== 200) {
11365
+ console.error(errorMessage(res.body, "归档商品失败。"));
10313
11366
  return exitCodeForApiError(res.body);
10314
11367
  }
10315
- const group = res.body.data.group;
10316
11368
  if (hasFlag("--json"))
10317
- console.log(JSON.stringify(group, null, 2));
11369
+ console.log(JSON.stringify({ ok: true, id: product.id }, null, 2));
10318
11370
  else
10319
- console.log(`✓ 模板已更新:${group.name} [${group.values.join(" / ")}](已建商品不受影响)`);
11371
+ console.log(`✓ 已归档:${product.name} ${product.id}(恢复:crab shop product update ${product.id} --status draft)`);
10320
11372
  return 0;
10321
11373
  }
10322
- async function shopSpecDelete() {
11374
+ async function shopStockAdjust(mode) {
10323
11375
  const credentials = requireCredentials();
10324
11376
  if (typeof credentials === "number")
10325
11377
  return credentials;
10326
- const positional = shopPositionals(3, []);
10327
- const ref = positional[0];
10328
- if (!ref) {
10329
- console.error(`用法:crab shop spec delete <模板 id 或名称> --store <slug>
10330
- (引用 = 快照拷贝:删除不影响已建商品)`);
11378
+ if (mode !== "in" && mode !== "out" && mode !== "set") {
11379
+ console.error(`用法:crab shop stock in|out|set <id|SKU码> --amount N [--variant <组合码|变体id|"值1/值2">] [--reason purchase] --note "说明" [--json]
11380
+ in 入库 / out 出库(变动量 ≥1)/ set 盘点设定(仓库账 = N,可为 0)
11381
+ --reason purchase 仅入库有效;set 或未给 --reason(按 manual 落账)必须带 --note
11382
+ 退货入库与发货出库由售后/发货端点自动落账,勿用本命令手工登记`);
11383
+ return EXIT_USAGE;
11384
+ }
11385
+ const target = shopPositionals(3, SHOP_PRODUCT_VALUED_FLAGS)[0];
11386
+ if (!target) {
11387
+ console.error("缺少商品 <id|SKU码>");
11388
+ return EXIT_USAGE;
11389
+ }
11390
+ const amountRaw = readFlag("--amount");
11391
+ if (amountRaw === undefined) {
11392
+ console.error("缺少 --amount N(in/out 为变动量 ≥1;set 为盘点后的实际库存数 ≥0)");
11393
+ return EXIT_USAGE;
11394
+ }
11395
+ const amount = Number(amountRaw);
11396
+ if (!Number.isSafeInteger(amount) || amount < 0 || mode !== "set" && amount === 0) {
11397
+ console.error(mode === "set" ? "--amount 必须是 ≥0 的整数(盘点后的实际库存数)" : "--amount 必须是 ≥1 的整数(in/out 为变动量)");
11398
+ return EXIT_USAGE;
11399
+ }
11400
+ const reason = readFlag("--reason");
11401
+ if (reason !== undefined && reason !== "purchase" && reason !== "manual") {
11402
+ console.error("--reason 只接受 purchase(采购入库)| manual");
11403
+ return EXIT_USAGE;
11404
+ }
11405
+ if (reason === "purchase" && mode !== "in") {
11406
+ console.error("--reason purchase 仅入库(in)有效;出库/盘点一律落 manual");
11407
+ return EXIT_USAGE;
11408
+ }
11409
+ const note = readFlag("--note");
11410
+ if ((mode === "set" || reason === undefined) && !note?.trim()) {
11411
+ console.error('盘点(set)或未指定 --reason(按 manual 落账)必须带 --note "原因说明"(台账留痕)');
11412
+ return EXIT_USAGE;
11413
+ }
11414
+ const product = await fetchShopProduct(credentials, target);
11415
+ if (typeof product === "number")
11416
+ return product;
11417
+ const targets = [];
11418
+ const variantRef = readFlag("--variant");
11419
+ if (product.hasVariants) {
11420
+ if (!variantRef) {
11421
+ console.error(`多规格商品必须用 --variant 指定组合(组合码 / 变体 id / "值1/值2"):
11422
+ ${variantChoiceList(product)}`);
11423
+ return EXIT_USAGE;
11424
+ }
11425
+ const variant = resolveVariantRef(product, variantRef);
11426
+ if (!variant) {
11427
+ console.error(`找不到组合:${variantRef}(可用组合:
11428
+ ${variantChoiceList(product)})`);
11429
+ return EXIT_USAGE;
11430
+ }
11431
+ targets.push({ variantId: variant.id, amount });
11432
+ } else {
11433
+ if (variantRef !== undefined) {
11434
+ console.error("单规格商品没有组合,去掉 --variant");
11435
+ return EXIT_USAGE;
11436
+ }
11437
+ targets.push({ amount });
11438
+ }
11439
+ const body = { mode, targets };
11440
+ if (reason)
11441
+ body.reason = reason;
11442
+ if (note?.trim())
11443
+ body.note = note.trim();
11444
+ const res = await api2(credentials.apiBase, `/v1/shop/products/${product.id}/stock`, {
11445
+ method: "POST",
11446
+ headers: authHeaders(credentials),
11447
+ body: JSON.stringify(body)
11448
+ });
11449
+ if (res.status !== 200) {
11450
+ console.error(errorMessage(res.body, "库存动账失败。"));
11451
+ const code = res.body?.error?.code;
11452
+ if (code === "insufficient_stock")
11453
+ console.error("提示:库存不足——先入库:crab shop stock in …");
11454
+ if (code === "unlimited_stock")
11455
+ console.error("提示:不限量库存不能出库——先用 stock set 转限量(盘点语义)再操作");
11456
+ return exitCodeForApiError(res.body);
11457
+ }
11458
+ const items = res.body.data.items;
11459
+ if (hasFlag("--json")) {
11460
+ console.log(JSON.stringify(res.body.data, null, 2));
11461
+ return 0;
11462
+ }
11463
+ const after = items[0];
11464
+ const label = mode === "in" ? "入库" : mode === "out" ? "出库" : "盘点";
11465
+ console.log(`✓ ${label} ${amount}:${product.name}`);
11466
+ if (after) {
11467
+ const parts = [];
11468
+ if (after.warehouseStock !== null)
11469
+ parts.push(`仓库 ${after.warehouseStock}`);
11470
+ parts.push(`上架 ${after.stock === null ? "不限" : after.stock}`);
11471
+ console.log(` 现值:${parts.join(" · ")}`);
11472
+ }
11473
+ if (product.kind === "physical") {
11474
+ console.log(" 提示:动的是仓库账;上架(销售)额度另用 crab shop listed 设定(≤ 仓库账)");
11475
+ }
11476
+ return 0;
11477
+ }
11478
+ async function shopListedStock() {
11479
+ const credentials = requireCredentials();
11480
+ if (typeof credentials === "number")
11481
+ return credentials;
11482
+ const target = shopPositionals(2, SHOP_PRODUCT_VALUED_FLAGS)[0];
11483
+ if (!target) {
11484
+ console.error(`用法:crab shop listed <id|SKU码> --stock N [--variant <组合码|变体id|"值1/值2">] [--json]
11485
+ 实物销售库存(上架额度)直设:0 ≤ N ≤ 实际库存,不够先 crab shop stock in 入库;数字商品无仓库层(限售额度用 stock in/set 起账)`);
11486
+ return EXIT_USAGE;
11487
+ }
11488
+ const stockRaw = readFlag("--stock");
11489
+ if (stockRaw === undefined) {
11490
+ console.error("缺少 --stock N(上架额度,0 ~ 实际库存)");
11491
+ return EXIT_USAGE;
11492
+ }
11493
+ const stock = Number(stockRaw);
11494
+ if (!Number.isSafeInteger(stock) || stock < 0) {
11495
+ console.error("--stock 必须是 ≥0 的整数");
10331
11496
  return EXIT_USAGE;
10332
11497
  }
11498
+ const product = await fetchShopProduct(credentials, target);
11499
+ if (typeof product === "number")
11500
+ return product;
11501
+ if (product.kind !== "physical") {
11502
+ console.error("数字商品没有仓库层,销售库存用 crab shop stock in/set 起账");
11503
+ return EXIT_USAGE;
11504
+ }
11505
+ const targets = [];
11506
+ const variantRef = readFlag("--variant");
11507
+ if (product.hasVariants) {
11508
+ if (!variantRef) {
11509
+ console.error(`多规格商品必须用 --variant 指定组合:
11510
+ ${variantChoiceList(product)}`);
11511
+ return EXIT_USAGE;
11512
+ }
11513
+ const variant = resolveVariantRef(product, variantRef);
11514
+ if (!variant) {
11515
+ console.error(`找不到组合:${variantRef}(可用组合:
11516
+ ${variantChoiceList(product)})`);
11517
+ return EXIT_USAGE;
11518
+ }
11519
+ targets.push({ variantId: variant.id, stock });
11520
+ } else {
11521
+ if (variantRef !== undefined) {
11522
+ console.error("单规格商品没有组合,去掉 --variant");
11523
+ return EXIT_USAGE;
11524
+ }
11525
+ targets.push({ stock });
11526
+ }
11527
+ const res = await api2(credentials.apiBase, `/v1/shop/products/${product.id}/listed-stock`, {
11528
+ method: "POST",
11529
+ headers: authHeaders(credentials),
11530
+ body: JSON.stringify({ targets })
11531
+ });
11532
+ if (res.status !== 200) {
11533
+ console.error(errorMessage(res.body, "设定销售库存失败。"));
11534
+ const code = res.body?.error?.code;
11535
+ if (code === "exceeds_warehouse")
11536
+ console.error('提示:上架额度不能超过实际库存——先入库再上调:crab shop stock in <id> --amount N --note "…"');
11537
+ return exitCodeForApiError(res.body);
11538
+ }
11539
+ const items = res.body.data.items;
11540
+ if (hasFlag("--json")) {
11541
+ console.log(JSON.stringify(res.body.data, null, 2));
11542
+ return 0;
11543
+ }
11544
+ const after = items[0];
11545
+ console.log(`✓ 已设上架额度 ${stock}:${product.name}${after?.warehouseStock !== null && after?.warehouseStock !== undefined ? `(仓库 ${after.warehouseStock})` : ""}`);
11546
+ return 0;
11547
+ }
11548
+ var MOVEMENT_REASON_LABELS = {
11549
+ purchase: "采购入库",
11550
+ sale: "销售",
11551
+ manual: "手工调整",
11552
+ restore: "退款回补",
11553
+ shipment: "发货出库"
11554
+ };
11555
+ async function shopMovements() {
11556
+ const credentials = requireCredentials();
11557
+ if (typeof credentials === "number")
11558
+ return credentials;
10333
11559
  const store = await resolveStoreArg(credentials);
10334
11560
  if (typeof store === "number")
10335
11561
  return store;
10336
- const groups = await fetchSpecGroups(credentials, store.id);
10337
- if (typeof groups === "number")
10338
- return groups;
10339
- const target = resolveSpecGroupRef(groups, ref);
10340
- if (typeof target === "string") {
10341
- console.error(target);
10342
- return EXIT_NOT_FOUND;
11562
+ const params = new URLSearchParams;
11563
+ const productRef = readFlag("--product");
11564
+ if (productRef) {
11565
+ const product = await fetchShopProduct(credentials, productRef);
11566
+ if (typeof product === "number")
11567
+ return product;
11568
+ params.set("productId", product.id);
10343
11569
  }
10344
- const res = await api2(credentials.apiBase, `/v1/shop/spec-groups/${target.id}`, {
10345
- method: "DELETE",
10346
- headers: authHeaders(credentials)
10347
- });
11570
+ const limit = readFlag("--limit");
11571
+ if (limit)
11572
+ params.set("limit", limit);
11573
+ const offset = readFlag("--offset");
11574
+ if (offset)
11575
+ params.set("offset", offset);
11576
+ const res = await api2(credentials.apiBase, `/v1/shop/stores/${store.id}/movements?${params.toString()}`, { headers: authHeaders(credentials) });
10348
11577
  if (res.status !== 200) {
10349
- console.error(errorMessage(res.body, "删除规格模板失败。"));
11578
+ console.error(errorMessage(res.body, "获取出入库台账失败。"));
10350
11579
  return exitCodeForApiError(res.body);
10351
11580
  }
10352
- if (hasFlag("--json"))
10353
- console.log(JSON.stringify({ ok: true }, null, 2));
10354
- else
10355
- console.log(`✓ 已删除模板:${target.name}(已引用商品不受影响)`);
11581
+ const data = res.body.data;
11582
+ if (hasFlag("--json")) {
11583
+ console.log(JSON.stringify(data, null, 2));
11584
+ return 0;
11585
+ }
11586
+ if (data.movements.length === 0) {
11587
+ console.log("还没有出入库流水(入库 crab shop stock in / 发货与退款自动落账)。");
11588
+ return 0;
11589
+ }
11590
+ for (const m of data.movements) {
11591
+ const arrow = m.direction === "in" ? "入库 +" : "出库 -";
11592
+ const tier = m.tier === "warehouse" ? "仓库" : "上架";
11593
+ const combo = m.variantCombo ? `(${m.variantCombo.join("/")})` : "";
11594
+ const reason = MOVEMENT_REASON_LABELS[m.reason] ?? m.reason;
11595
+ const order = m.orderId ? ` 订单 ${m.orderId}` : "";
11596
+ console.log(`${formatTime(m.createdAt)} ${arrow}${m.quantity} [${tier}] ${m.productName}${combo} ${reason}${m.note ? ` ${m.note}` : ""}${order}`);
11597
+ }
11598
+ const offsetN = Number.parseInt(offset ?? "0", 10) || 0;
11599
+ console.log(`共 ${data.total} 条流水(本页 ${data.movements.length} 条${offsetN > 0 ? `,自第 ${offsetN + 1} 条` : ""};--offset 翻页)`);
10356
11600
  return 0;
10357
11601
  }
10358
11602
  async function hrOrgsList() {
@@ -10378,29 +11622,96 @@ async function hrOrgsList() {
10378
11622
  }
10379
11623
  return 0;
10380
11624
  }
10381
- async function hrOrgCreate(name) {
11625
+ async function hrOrgApply(name) {
10382
11626
  if (!name) {
10383
- console.error("用法:crab hr org create <名称> [--json]");
11627
+ console.error("用法:crab hr org apply <名称> [--note 说明] [--json](审核通过扣 1 积分自动建司)");
10384
11628
  return EXIT_USAGE;
10385
11629
  }
11630
+ const note = readFlag("--note") ?? "";
10386
11631
  const credentials = requireCredentials();
10387
11632
  if (typeof credentials === "number")
10388
11633
  return credentials;
10389
- const res = await api2(credentials.apiBase, "/v1/hr/orgs", {
11634
+ const res = await api2(credentials.apiBase, "/v1/hr/orgs/applications", {
10390
11635
  method: "POST",
10391
11636
  headers: authHeaders(credentials),
10392
- body: JSON.stringify({ name })
11637
+ body: JSON.stringify({ name, note })
10393
11638
  });
10394
11639
  if (res.status !== 200 && res.status !== 201) {
10395
- console.error(errorMessage(res.body, "创建组织失败。"));
11640
+ const code = res.body?.error?.code;
11641
+ if (res.status === 409 || code === "application_exists" || code === "conflict") {
11642
+ console.error("你已有一条待审核的建司申请(crab hr org application 查进度)。");
11643
+ return exitCodeForApiError(res.body);
11644
+ }
11645
+ console.error(errorMessage(res.body, "提交申请失败。"));
11646
+ return exitCodeForApiError(res.body);
11647
+ }
11648
+ const application = res.body.data.application;
11649
+ if (hasFlag("--json"))
11650
+ console.log(JSON.stringify(application, null, 2));
11651
+ else {
11652
+ console.log(`✓ 已提交建司申请「${application.name}」· id: ${application.id}`);
11653
+ console.log("审核通过后公司自动出现(通过时扣 1 积分);进度:crab hr org application");
11654
+ }
11655
+ return 0;
11656
+ }
11657
+ async function hrOrgApplication() {
11658
+ const credentials = requireCredentials();
11659
+ if (typeof credentials === "number")
11660
+ return credentials;
11661
+ const res = await api2(credentials.apiBase, "/v1/hr/orgs/applications/mine", { headers: authHeaders(credentials) });
11662
+ if (res.status !== 200) {
11663
+ console.error(errorMessage(res.body, "查询申请进度失败。"));
10396
11664
  return exitCodeForApiError(res.body);
10397
11665
  }
10398
- const org = res.body.data.org;
11666
+ const application = res.body.data.application;
10399
11667
  if (hasFlag("--json")) {
10400
- console.log(JSON.stringify(org, null, 2));
11668
+ console.log(JSON.stringify(application, null, 2));
11669
+ return 0;
11670
+ }
11671
+ if (!application) {
11672
+ console.log("还没有建司申请(crab hr org apply <名称> 提交)。");
10401
11673
  return 0;
10402
11674
  }
10403
- console.log(`✓ 已创建 ${org.name}(你是 owner)。提示:重新授权绑定该组织:crab login --scopes hr --org ${hrOrgToken(org)}`);
11675
+ const day = new Date(application.createdAt).toISOString().slice(0, 10);
11676
+ if (application.status === "pending") {
11677
+ console.log(`审核中:${application.name}(${day} 提交)· id: ${application.id}`);
11678
+ console.log("通过时扣 1 积分并自动建司,无需其他操作。");
11679
+ } else if (application.status === "approved") {
11680
+ console.log(`✓ 已通过:${application.name}(${day} 提交)· 公司已建立`);
11681
+ console.log("绑定新组织:crab login --scopes hr --org org:<公司名>");
11682
+ } else {
11683
+ console.log(`✗ 已驳回:${application.name}(${day} 提交)`);
11684
+ if (application.reviewNote)
11685
+ console.log(` 驳回意见:${application.reviewNote}`);
11686
+ console.log("可修改后重新提交:crab hr org apply <名称> [--note 说明]");
11687
+ }
11688
+ return 0;
11689
+ }
11690
+ async function hrOrgRename() {
11691
+ const name = positionalArgs(args.slice(3), HR_VALUED_FLAGS)[0];
11692
+ if (!name) {
11693
+ console.error("用法:crab hr org rename <新名称> [--org <arg>] [--json]");
11694
+ return EXIT_USAGE;
11695
+ }
11696
+ const credentials = requireCredentials();
11697
+ if (typeof credentials === "number")
11698
+ return credentials;
11699
+ const org = await resolveHrOrg(credentials, readFlag("--org"));
11700
+ if (typeof org === "number")
11701
+ return org;
11702
+ const res = await api2(credentials.apiBase, `/v1/hr/orgs/${encodeURIComponent(org.id)}`, {
11703
+ method: "PATCH",
11704
+ headers: authHeaders(credentials),
11705
+ body: JSON.stringify({ name })
11706
+ });
11707
+ if (res.status !== 200) {
11708
+ console.error(errorMessage(res.body, "组织改名失败。"));
11709
+ return exitCodeForApiError(res.body);
11710
+ }
11711
+ if (hasFlag("--json"))
11712
+ console.log(JSON.stringify({ ok: true }, null, 2));
11713
+ else
11714
+ console.log(`✓ 已改名:${org.name} → ${name}(org: 令牌绑定标识随新名变化,注意重新授权)。`);
10404
11715
  return 0;
10405
11716
  }
10406
11717
  async function hrMembersList() {
@@ -10441,10 +11752,13 @@ async function hrMemberAdd() {
10441
11752
  return EXIT_USAGE;
10442
11753
  }
10443
11754
  const title = readFlag("--title");
11755
+ const permissions = parseHrPermissions(readFlag("--permissions"));
11756
+ if (permissions === null)
11757
+ return EXIT_USAGE;
10444
11758
  const res = await api2(credentials.apiBase, `/v1/hr/orgs/${encodeURIComponent(org.id)}/members`, {
10445
11759
  method: "POST",
10446
11760
  headers: authHeaders(credentials),
10447
- body: JSON.stringify({ username: target, ...title ? { title } : {} })
11761
+ body: JSON.stringify({ username: target, ...title ? { title } : {}, ...permissions ? { permissions } : {} })
10448
11762
  });
10449
11763
  if (res.status !== 200 && res.status !== 201) {
10450
11764
  console.error(errorMessage(res.body, "邀请成员失败。"));
@@ -10452,21 +11766,11 @@ async function hrMemberAdd() {
10452
11766
  }
10453
11767
  if (hasFlag("--json"))
10454
11768
  console.log(JSON.stringify(res.body.data, null, 2));
10455
- else
10456
- console.log(`✓ 已把 ${target} 加入 ${org.name}(member,只读;用 hr member set 提权)。`);
10457
- return 0;
10458
- }
10459
- function parsePermissionFlags(text) {
10460
- if (!text)
10461
- return;
10462
- const out = {};
10463
- for (const pair of text.split(",").map((x) => x.trim()).filter(Boolean)) {
10464
- const [key, value] = pair.split("=");
10465
- if (!key)
10466
- continue;
10467
- out[key.trim()] = value !== "0" && value?.toLowerCase() !== "false";
11769
+ else {
11770
+ const on = permissions ? Object.entries(permissions).filter(([, v]) => v).map(([k]) => k).join(",") : "";
11771
+ console.log(`✓ 已把 ${target} 加入 ${org.name}(member${on ? `,权限:${on}` : ",只读"};用 hr member set 调整)。`);
10468
11772
  }
10469
- return out;
11773
+ return 0;
10470
11774
  }
10471
11775
  async function hrMemberSet() {
10472
11776
  const credentials = requireCredentials();
@@ -10477,7 +11781,9 @@ async function hrMemberSet() {
10477
11781
  return org;
10478
11782
  const target = positionalArgs(args.slice(3), HR_VALUED_FLAGS)[0];
10479
11783
  const role = readFlag("--role");
10480
- const permissions = parsePermissionFlags(readFlag("--permissions"));
11784
+ const permissions = parseHrPermissions(readFlag("--permissions"));
11785
+ if (permissions === null)
11786
+ return EXIT_USAGE;
10481
11787
  const title = readFlag("--title");
10482
11788
  if (!target || !role && !permissions && title === undefined) {
10483
11789
  console.error("用法:crab hr member set <memberId> [--role admin|member] [--permissions org.manage=1,people.write=1,tasks.write=1,sensitive.read=0] [--title 头衔] [--org <arg>]");
@@ -10536,6 +11842,28 @@ async function hrInvite() {
10536
11842
  const org = await resolveHrOrg(credentials, readFlag("--org"));
10537
11843
  if (typeof org === "number")
10538
11844
  return org;
11845
+ const permissionsInput = parseHrPermissions(readFlag("--permissions"));
11846
+ if (permissionsInput === null)
11847
+ return EXIT_USAGE;
11848
+ if (permissionsInput) {
11849
+ const patch = await api2(credentials.apiBase, `/v1/hr/orgs/${encodeURIComponent(org.id)}/invite`, {
11850
+ method: "PATCH",
11851
+ headers: authHeaders(credentials),
11852
+ body: JSON.stringify({ permissions: permissionsInput })
11853
+ });
11854
+ if (patch.status !== 200) {
11855
+ console.error(errorMessage(patch.body, "更新邀请默认权限失败。"));
11856
+ return exitCodeForApiError(patch.body);
11857
+ }
11858
+ const updated = patch.body.data.invite;
11859
+ if (hasFlag("--json")) {
11860
+ console.log(JSON.stringify(updated, null, 2));
11861
+ return 0;
11862
+ }
11863
+ const on = Object.entries(updated.permissions).filter(([, v]) => v).map(([k]) => k);
11864
+ console.log(`✓ 已更新加入后默认权限:${on.length ? on.join(", ") : "全只读"}(链接不变)。`);
11865
+ return 0;
11866
+ }
10539
11867
  const path = hasFlag("--reset") ? "/invite/reset" : "/invite";
10540
11868
  const res = await api2(credentials.apiBase, `/v1/hr/orgs/${encodeURIComponent(org.id)}${path}`, { method: hasFlag("--reset") ? "POST" : "GET", headers: authHeaders(credentials) });
10541
11869
  if (res.status !== 200) {
@@ -10543,14 +11871,15 @@ async function hrInvite() {
10543
11871
  return exitCodeForApiError(res.body);
10544
11872
  }
10545
11873
  const invite = res.body.data.invite;
10546
- const main = credentials.apiBase.includes("127.0.0.1") ? "http://127.0.0.1:14331" : "https://crabcloud.cc";
10547
- const link = `${main}/register?invite=${invite.token}`;
11874
+ const link = `${hrWebBase(credentials.apiBase)}/join/${invite.token}`;
10548
11875
  if (hasFlag("--json"))
10549
11876
  console.log(JSON.stringify({ ...invite, link }, null, 2));
10550
11877
  else {
10551
11878
  if (hasFlag("--reset"))
10552
- console.log("✓ 已重置(旧链接立即失效)。");
11879
+ console.log("✓ 已重置(旧链接立即失效,默认权限配置保留)。");
11880
+ const on = Object.entries(invite.permissions).filter(([, v]) => v).map(([k]) => k);
10553
11881
  console.log(`邀请链接:${link}`);
11882
+ console.log(`加入后默认权限:${on.length ? on.join(", ") : "全只读"}(修改:--permissions org.manage=1,...)`);
10554
11883
  console.log("未注册的老板 / 同事打开此链接,注册后自动加入组织。");
10555
11884
  }
10556
11885
  return 0;
@@ -10619,13 +11948,20 @@ async function hrPersonDetail(id) {
10619
11948
  const org = await resolveHrOrg(credentials, readFlag("--org"));
10620
11949
  if (typeof org === "number")
10621
11950
  return org;
10622
- const res = await api2(credentials.apiBase, `/v1/hr/orgs/${encodeURIComponent(org.id)}/people?q=${encodeURIComponent(id)}&limit=50`, { headers: authHeaders(credentials) });
10623
- if (res.status !== 200) {
10624
- console.error(errorMessage(res.body, "获取人员失败。"));
10625
- return exitCodeForApiError(res.body);
11951
+ let person;
11952
+ let offset = 0;
11953
+ while (person === undefined) {
11954
+ const res = await api2(credentials.apiBase, `/v1/hr/orgs/${encodeURIComponent(org.id)}/people?limit=200&offset=${offset}`, { headers: authHeaders(credentials) });
11955
+ if (res.status !== 200) {
11956
+ console.error(errorMessage(res.body, "获取人员失败。"));
11957
+ return exitCodeForApiError(res.body);
11958
+ }
11959
+ const page = res.body.data;
11960
+ person = page.people.find((p) => p.id === id);
11961
+ offset += page.people.length;
11962
+ if (person === undefined && (page.people.length === 0 || offset >= page.total))
11963
+ break;
10626
11964
  }
10627
- const people = res.body.data.people;
10628
- const person = people.find((p) => p.id === id);
10629
11965
  if (!person) {
10630
11966
  console.error("人员不存在或不属于当前组织。");
10631
11967
  return EXIT_NOT_FOUND;
@@ -10814,6 +12150,73 @@ async function hrPersonOffboard() {
10814
12150
  console.log(`✓ ${person?.name ?? target} 已离职(档案封存,离职事件已落时间线)。`);
10815
12151
  return 0;
10816
12152
  }
12153
+ async function hrPersonRm() {
12154
+ const target = positionalArgs(args.slice(3), HR_VALUED_FLAGS)[0];
12155
+ if (!target) {
12156
+ console.error("用法:crab hr person rm <id> [--org <arg>] [--json](仅零记录档案可删;有事件时间线走离职封存)");
12157
+ return EXIT_USAGE;
12158
+ }
12159
+ const credentials = requireCredentials();
12160
+ if (typeof credentials === "number")
12161
+ return credentials;
12162
+ const org = await resolveHrOrg(credentials, readFlag("--org"));
12163
+ if (typeof org === "number")
12164
+ return org;
12165
+ const res = await api2(credentials.apiBase, `/v1/hr/orgs/${encodeURIComponent(org.id)}/people/${encodeURIComponent(target)}`, {
12166
+ method: "DELETE",
12167
+ headers: authHeaders(credentials)
12168
+ });
12169
+ if (res.status !== 200) {
12170
+ const code = res.body?.error?.code;
12171
+ if (res.status === 409 || code === "conflict") {
12172
+ console.error("该档案已有事件记录(时间线即证据链),不能删除——请走离职封存:crab hr person offboard。");
12173
+ return exitCodeForApiError(res.body);
12174
+ }
12175
+ console.error(errorMessage(res.body, "删除人员失败。"));
12176
+ return exitCodeForApiError(res.body);
12177
+ }
12178
+ if (hasFlag("--json"))
12179
+ console.log(JSON.stringify({ ok: true }, null, 2));
12180
+ else
12181
+ console.log("✓ 已删除(零记录档案)。");
12182
+ return 0;
12183
+ }
12184
+ function hrStatsLines(s) {
12185
+ const lines = [`在职 ${s.active} · 试用 ${s.probation} · 离职 ${s.resigned} · 共 ${s.total} 人`];
12186
+ const risks = [];
12187
+ if (s.contractDueSoon)
12188
+ risks.push(`合同 60 天内到期 ${s.contractDueSoon} 人`);
12189
+ if (s.contractOverdue)
12190
+ risks.push(`合同已到期未续签 ${s.contractOverdue} 人`);
12191
+ if (s.probationDueSoon)
12192
+ risks.push(`试用期 30 天内届满 ${s.probationDueSoon} 人`);
12193
+ if (s.probationOverdue)
12194
+ risks.push(`试用期已过未转正 ${s.probationOverdue} 人`);
12195
+ lines.push(risks.length ? `风险:${risks.join(" · ")}` : "风险:无");
12196
+ return lines;
12197
+ }
12198
+ async function hrStats() {
12199
+ const credentials = requireCredentials();
12200
+ if (typeof credentials === "number")
12201
+ return credentials;
12202
+ const org = await resolveHrOrg(credentials, readFlag("--org"));
12203
+ if (typeof org === "number")
12204
+ return org;
12205
+ const res = await api2(credentials.apiBase, `/v1/hr/orgs/${encodeURIComponent(org.id)}/people/stats`, { headers: authHeaders(credentials) });
12206
+ if (res.status !== 200) {
12207
+ console.error(errorMessage(res.body, "获取统计失败。"));
12208
+ return exitCodeForApiError(res.body);
12209
+ }
12210
+ const stats = res.body.data;
12211
+ if (hasFlag("--json")) {
12212
+ console.log(JSON.stringify({ ...stats, org: { id: org.id, name: org.name } }, null, 2));
12213
+ return 0;
12214
+ }
12215
+ console.log(`组织:${hrOrgToken(org)} · 花名册统计`);
12216
+ for (const line of hrStatsLines(stats.stats))
12217
+ console.log(line);
12218
+ return 0;
12219
+ }
10817
12220
  async function hrEventAdd() {
10818
12221
  const target = positionalArgs(args.slice(3), HR_VALUED_FLAGS)[0];
10819
12222
  const type = readFlag("--type");
@@ -11057,7 +12460,7 @@ async function hrTaskCreate() {
11057
12460
  const kind = readFlag("--kind");
11058
12461
  const title = readFlag("--title");
11059
12462
  if (!kind || !title) {
11060
- console.error('用法:crab hr task create --kind <attendance|payroll|contract|recruit|meeting|asset|admin|...> --title "..." [--node 节点说明] [--detail 详情] [--due YYYY-MM-DD] [--status alert|confirm|queued|done] [--json]');
12463
+ console.error('用法:crab hr task create --kind <attendance|payroll|contract|recruit|meeting|asset|admin|person/onboard|...> --title "..." [--node 节点说明] [--detail 详情] [--due YYYY-MM-DD] [--status alert|confirm|queued|done] [--meta JSON] [--json]');
11061
12464
  return EXIT_USAGE;
11062
12465
  }
11063
12466
  const status = readFlag("--status");
@@ -11077,10 +12480,20 @@ async function hrTaskCreate() {
11077
12480
  const org = await resolveHrOrg(credentials, readFlag("--org"));
11078
12481
  if (typeof org === "number")
11079
12482
  return org;
12483
+ let meta;
12484
+ const metaFlag = readFlag("--meta");
12485
+ if (metaFlag !== undefined) {
12486
+ try {
12487
+ meta = JSON.parse(metaFlag);
12488
+ } catch {
12489
+ console.error("--meta 需要合法 JSON(申请单任务须含 meta.form,服务端按 kind schema 校验)。");
12490
+ return EXIT_USAGE;
12491
+ }
12492
+ }
11080
12493
  const res = await api2(credentials.apiBase, `/v1/hr/orgs/${encodeURIComponent(org.id)}/tasks`, {
11081
12494
  method: "POST",
11082
12495
  headers: authHeaders(credentials),
11083
- body: JSON.stringify({ kind, title, node: readFlag("--node") ?? "", detail: readFlag("--detail") ?? "", ...status ? { status } : {}, ...due != null ? { dueAt: due } : {} })
12496
+ body: JSON.stringify({ kind, title, node: readFlag("--node") ?? "", detail: readFlag("--detail") ?? "", ...status ? { status } : {}, ...due != null ? { dueAt: due } : {}, ...meta ? { meta } : {} })
11084
12497
  });
11085
12498
  if (res.status !== 200 && res.status !== 201) {
11086
12499
  console.error(errorMessage(res.body, "创建任务失败。"));
@@ -11132,6 +12545,42 @@ async function hrTaskUpdate() {
11132
12545
  console.log(`✓ 已更新 ${task.title}(${task.status})。`);
11133
12546
  return 0;
11134
12547
  }
12548
+ async function hrTaskLink() {
12549
+ const target = positionalArgs(args.slice(3), HR_VALUED_FLAGS)[0];
12550
+ if (!target) {
12551
+ console.error("用法:crab hr task link <taskId> [--reset] [--org <arg>] [--json](仅入职/转正/离职申请单有候选人表单)");
12552
+ return EXIT_USAGE;
12553
+ }
12554
+ const credentials = requireCredentials();
12555
+ if (typeof credentials === "number")
12556
+ return credentials;
12557
+ const org = await resolveHrOrg(credentials, readFlag("--org"));
12558
+ if (typeof org === "number")
12559
+ return org;
12560
+ const reset = hasFlag("--reset");
12561
+ const path = `/orgs/${encodeURIComponent(org.id)}/tasks/${encodeURIComponent(target)}${reset ? "/form-link/reset" : "/form-link"}`;
12562
+ const res = await api2(credentials.apiBase, `/v1/hr${path}`, { method: reset ? "POST" : "GET", headers: authHeaders(credentials) });
12563
+ if (res.status !== 200) {
12564
+ const code = res.body?.error?.code;
12565
+ if (res.status === 400 && code === "bad_request") {
12566
+ console.error("该任务没有候选人填单表单(仅 person/onboard · person/promotion · person/offboard 申请单支持)。");
12567
+ return exitCodeForApiError(res.body);
12568
+ }
12569
+ console.error(errorMessage(res.body, "获取填单链接失败。"));
12570
+ return exitCodeForApiError(res.body);
12571
+ }
12572
+ const token = res.body.data.token;
12573
+ const link = `${hrWebBase(credentials.apiBase)}/form/${token}`;
12574
+ if (hasFlag("--json"))
12575
+ console.log(JSON.stringify({ token, link }, null, 2));
12576
+ else {
12577
+ if (reset)
12578
+ console.log("✓ 已重置(旧链接立即失效)。");
12579
+ console.log(`填单链接:${link}`);
12580
+ console.log("候选人免登录打开即可填写本人区字段;提交后申请单进待拍板。");
12581
+ }
12582
+ return 0;
12583
+ }
11135
12584
  async function hrDesk() {
11136
12585
  const credentials = requireCredentials();
11137
12586
  if (typeof credentials === "number")
@@ -11150,29 +12599,41 @@ async function hrDesk() {
11150
12599
  return 0;
11151
12600
  }
11152
12601
  console.log(`组织:${hrOrgToken(org)} · 工作台`);
12602
+ for (const line of hrStatsLines(desk.desk.stats))
12603
+ console.log(line);
12604
+ console.log(`
12605
+ 替你盯着:`);
12606
+ for (const reminder of desk.desk.reminders) {
12607
+ const chip = reminder.kind === "onboard_pending" ? `已等 ${reminder.days} 天` : reminder.days < 0 ? `过期 ${-reminder.days} 天` : `剩 ${reminder.days} 天`;
12608
+ console.log(` ⚠ ${reminder.text}(${chip})`);
12609
+ }
12610
+ if (!desk.desk.reminders.length)
12611
+ console.log(" (无)");
11153
12612
  console.log(`
11154
12613
  等你拍板:`);
11155
12614
  for (const task of desk.desk.pending)
11156
- console.log(` [${task.status}] ${task.title} ${task.node || ""} id: ${task.id}`);
12615
+ console.log(` [${task.status}] ${task.title}${task.refLabel ? ` · ${task.refLabel}` : ""} ${task.node || ""} id: ${task.id}`);
11157
12616
  if (!desk.desk.pending.length)
11158
12617
  console.log(" (空)");
11159
12618
  console.log(`
11160
12619
  助理在办:`);
11161
12620
  for (const task of desk.desk.running)
11162
- console.log(` ${task.title} ${task.node || ""} id: ${task.id}`);
12621
+ console.log(` ${task.title}${task.refLabel ? ` · ${task.refLabel}` : ""} ${task.node || ""} id: ${task.id}`);
11163
12622
  if (!desk.desk.running.length)
11164
12623
  console.log(" (空)");
11165
12624
  console.log(`
11166
12625
  近期完成:`);
11167
12626
  for (const task of desk.desk.done)
11168
- console.log(` ${task.title} ${task.node || ""}`);
12627
+ console.log(` ${task.title}${task.refLabel ? ` · ${task.refLabel}` : ""} ${task.node || ""}`);
11169
12628
  if (!desk.desk.done.length)
11170
12629
  console.log(" (空)");
11171
12630
  console.log(`
11172
- 替你盯着:`);
11173
- for (const reminder of desk.desk.reminders)
11174
- console.log(` ⚠ ${reminder.text}(${reminder.days < 0 ? `过期 ${-reminder.days} 天` : `剩 ${reminder.days} 天`})`);
11175
- if (!desk.desk.reminders.length)
12631
+ 最近公告:`);
12632
+ for (const item of desk.desk.announcements.slice(0, 3)) {
12633
+ const day = new Date(item.createdAt).toISOString().slice(0, 10);
12634
+ console.log(` ${day} ${item.title}(${item.createdByName})`);
12635
+ }
12636
+ if (!desk.desk.announcements.length)
11176
12637
  console.log(" (无)");
11177
12638
  console.log(`
11178
12639
  最近动态:`);
@@ -11184,6 +12645,259 @@ async function hrDesk() {
11184
12645
  console.log(" (无)");
11185
12646
  return 0;
11186
12647
  }
12648
+ function hrAnnouncementStatus(s) {
12649
+ return s === "active" ? "[生效]" : "[已撤回]";
12650
+ }
12651
+ async function hrAnnounce() {
12652
+ const op = args[2];
12653
+ const credentials = requireCredentials();
12654
+ if (typeof credentials === "number")
12655
+ return credentials;
12656
+ const org = await resolveHrOrg(credentials, readFlag("--org"));
12657
+ if (typeof org === "number")
12658
+ return org;
12659
+ const base = `/v1/hr/orgs/${encodeURIComponent(org.id)}/announcements`;
12660
+ if (op === "list") {
12661
+ const params = new URLSearchParams;
12662
+ if (hasFlag("--all"))
12663
+ params.set("includeWithdrawn", "1");
12664
+ const limit = readFlag("--limit");
12665
+ if (limit)
12666
+ params.set("limit", limit);
12667
+ const offset = readFlag("--offset");
12668
+ if (offset)
12669
+ params.set("offset", offset);
12670
+ const res = await api2(credentials.apiBase, `${base}?${params}`, { headers: authHeaders(credentials) });
12671
+ if (res.status !== 200) {
12672
+ console.error(errorMessage(res.body, "获取公告失败。"));
12673
+ return exitCodeForApiError(res.body);
12674
+ }
12675
+ const page = res.body.data;
12676
+ if (hasFlag("--json")) {
12677
+ console.log(JSON.stringify({ ...page, org: { id: org.id, name: org.name } }, null, 2));
12678
+ return 0;
12679
+ }
12680
+ console.log(`组织:${hrOrgToken(org)} · 公告 ${page.total} 条`);
12681
+ for (const a of page.announcements) {
12682
+ const day = new Date(a.createdAt).toISOString().slice(0, 10);
12683
+ console.log(`${day} ${hrAnnouncementStatus(a.status)} ${a.title}(${a.createdByName}) id: ${a.id}`);
12684
+ console.log(` ${a.body.length > 120 ? `${a.body.slice(0, 120)}…` : a.body}`);
12685
+ }
12686
+ if (page.announcements.length < page.total)
12687
+ console.log(`(还有更多:--offset ${(Number(offset ?? 0) || 0) + page.announcements.length})`);
12688
+ return 0;
12689
+ }
12690
+ if (op === "post") {
12691
+ const title = positionalArgs(args.slice(3), HR_VALUED_FLAGS)[0];
12692
+ const body = readFlag("--body");
12693
+ if (!title || !body) {
12694
+ console.error('用法:crab hr announce post <标题> --body "正文" [--org <arg>] [--json]');
12695
+ return EXIT_USAGE;
12696
+ }
12697
+ const res = await api2(credentials.apiBase, base, {
12698
+ method: "POST",
12699
+ headers: authHeaders(credentials),
12700
+ body: JSON.stringify({ title, body })
12701
+ });
12702
+ if (res.status !== 200 && res.status !== 201) {
12703
+ console.error(errorMessage(res.body, "发布公告失败。"));
12704
+ return exitCodeForApiError(res.body);
12705
+ }
12706
+ const a = res.body.data.announcement;
12707
+ if (hasFlag("--json"))
12708
+ console.log(JSON.stringify(a, null, 2));
12709
+ else
12710
+ console.log(`✓ 已发布「${a.title}」· id: ${a.id}(成员收件箱可见)。`);
12711
+ return 0;
12712
+ }
12713
+ if (op === "withdraw") {
12714
+ const target = positionalArgs(args.slice(3), HR_VALUED_FLAGS)[0];
12715
+ if (!target) {
12716
+ console.error("用法:crab hr announce withdraw <公告id> [--org <arg>] [--json](撤回留档不删)");
12717
+ return EXIT_USAGE;
12718
+ }
12719
+ const res = await api2(credentials.apiBase, `${base}/${encodeURIComponent(target)}/withdraw`, { method: "POST", headers: authHeaders(credentials) });
12720
+ if (res.status !== 200) {
12721
+ console.error(errorMessage(res.body, "撤回失败。"));
12722
+ return exitCodeForApiError(res.body);
12723
+ }
12724
+ const a = res.body.data.announcement;
12725
+ if (hasFlag("--json"))
12726
+ console.log(JSON.stringify({ ok: true }, null, 2));
12727
+ else
12728
+ console.log(`✓ 已撤回「${a?.title ?? target}」(成员不再可见,记录保留)。`);
12729
+ return 0;
12730
+ }
12731
+ console.error(`用法:crab hr announce list | post <标题> --body "..." | withdraw <id>
12732
+ `);
12733
+ return EXIT_USAGE;
12734
+ }
12735
+ async function hrInbox() {
12736
+ const credentials = requireCredentials();
12737
+ if (typeof credentials === "number")
12738
+ return credentials;
12739
+ const org = await resolveHrOrg(credentials, readFlag("--org"));
12740
+ if (typeof org === "number")
12741
+ return org;
12742
+ const base = `/v1/hr/orgs/${encodeURIComponent(org.id)}/inbox`;
12743
+ if (hasFlag("--seen")) {
12744
+ const res = await api2(credentials.apiBase, `${base}/seen`, { method: "POST", headers: authHeaders(credentials) });
12745
+ if (res.status !== 200) {
12746
+ console.error(errorMessage(res.body, "标记已读失败。"));
12747
+ return exitCodeForApiError(res.body);
12748
+ }
12749
+ if (hasFlag("--json"))
12750
+ console.log(JSON.stringify(res.body.data, null, 2));
12751
+ else
12752
+ console.log("✓ 公告已全部标记已读。");
12753
+ return 0;
12754
+ }
12755
+ const res = await api2(credentials.apiBase, base, { headers: authHeaders(credentials) });
12756
+ if (res.status !== 200) {
12757
+ console.error(errorMessage(res.body, "获取收件箱失败。"));
12758
+ return exitCodeForApiError(res.body);
12759
+ }
12760
+ const inbox = res.body.data.inbox;
12761
+ if (hasFlag("--json")) {
12762
+ console.log(JSON.stringify({ ...res.body.data, org: { id: org.id, name: org.name } }, null, 2));
12763
+ return 0;
12764
+ }
12765
+ console.log(`组织:${hrOrgToken(org)} · 收件箱(上次打开 ${inbox.lastSeenAt ? new Date(inbox.lastSeenAt).toLocaleDateString("zh-CN") : "从未"})`);
12766
+ console.log(`
12767
+ 未读公告 ${inbox.unreadAnnouncementCount} 条:`);
12768
+ for (const a of inbox.announcements)
12769
+ console.log(` ${new Date(a.createdAt).toISOString().slice(0, 10)} ${a.title}(${a.createdByName}) id: ${a.id}`);
12770
+ if (!inbox.announcements.length)
12771
+ console.log(" (无)");
12772
+ console.log(`
12773
+ 待拍板 ${inbox.pendingTaskCount} 条:`);
12774
+ for (const t of inbox.pendingTasks)
12775
+ console.log(` [${t.status}] ${t.title} ${t.node || ""} id: ${t.id}`);
12776
+ if (!inbox.pendingTasks.length)
12777
+ console.log(" (无)");
12778
+ console.log(`
12779
+ 替你盯着:`);
12780
+ for (const r of inbox.reminders)
12781
+ console.log(` ⚠ ${r.text}(${r.days < 0 ? `过期 ${-r.days} 天` : `剩 ${r.days} 天`})`);
12782
+ if (!inbox.reminders.length)
12783
+ console.log(" (无)");
12784
+ console.log(`
12785
+ 打开看完后标记已读:crab hr inbox --seen`);
12786
+ return 0;
12787
+ }
12788
+ async function hrDicts() {
12789
+ const credentials = requireCredentials();
12790
+ if (typeof credentials === "number")
12791
+ return credentials;
12792
+ const org = await resolveHrOrg(credentials, readFlag("--org"));
12793
+ if (typeof org === "number")
12794
+ return org;
12795
+ const res = await api2(credentials.apiBase, `/v1/hr/orgs/${encodeURIComponent(org.id)}/dicts`, { headers: authHeaders(credentials) });
12796
+ if (res.status !== 200) {
12797
+ console.error(errorMessage(res.body, "获取字典失败。"));
12798
+ return exitCodeForApiError(res.body);
12799
+ }
12800
+ const entries = res.body.data.entries;
12801
+ if (hasFlag("--json")) {
12802
+ console.log(JSON.stringify({ ...res.body.data, org: { id: org.id, name: org.name } }, null, 2));
12803
+ return 0;
12804
+ }
12805
+ for (const type of HR_DICT_TYPES) {
12806
+ const list = entries.filter((e) => e.type === type);
12807
+ console.log(`${type === "dept" ? "部门" : "岗位"}(${list.length}):`);
12808
+ for (const e of list)
12809
+ console.log(` ${e.name} id: ${e.id}`);
12810
+ if (!list.length)
12811
+ console.log(" (空)");
12812
+ }
12813
+ return 0;
12814
+ }
12815
+ async function hrDictAdd() {
12816
+ const type = args[3];
12817
+ const name = args[4];
12818
+ if (!type || !name || !HR_DICT_TYPES.includes(type)) {
12819
+ console.error("用法:crab hr dict add <dept|position> <名称> [--org <arg>] [--json]");
12820
+ return EXIT_USAGE;
12821
+ }
12822
+ const credentials = requireCredentials();
12823
+ if (typeof credentials === "number")
12824
+ return credentials;
12825
+ const org = await resolveHrOrg(credentials, readFlag("--org"));
12826
+ if (typeof org === "number")
12827
+ return org;
12828
+ const res = await api2(credentials.apiBase, `/v1/hr/orgs/${encodeURIComponent(org.id)}/dicts`, {
12829
+ method: "POST",
12830
+ headers: authHeaders(credentials),
12831
+ body: JSON.stringify({ type, name })
12832
+ });
12833
+ if (res.status !== 200 && res.status !== 201) {
12834
+ console.error(errorMessage(res.body, "新增字典项失败。"));
12835
+ return exitCodeForApiError(res.body);
12836
+ }
12837
+ const entry = res.body.data.entry;
12838
+ if (hasFlag("--json"))
12839
+ console.log(JSON.stringify(entry, null, 2));
12840
+ else
12841
+ console.log(`✓ 已新增${type === "dept" ? "部门" : "岗位"}「${entry.name}」· id: ${entry.id}(同名幂等返回已有项)。`);
12842
+ return 0;
12843
+ }
12844
+ async function hrDictRm() {
12845
+ const target = positionalArgs(args.slice(3), HR_VALUED_FLAGS)[0];
12846
+ if (!target) {
12847
+ console.error("用法:crab hr dict rm <entryId> [--org <arg>] [--json](被花名册在用的项服务端 409 拒删)");
12848
+ return EXIT_USAGE;
12849
+ }
12850
+ const credentials = requireCredentials();
12851
+ if (typeof credentials === "number")
12852
+ return credentials;
12853
+ const org = await resolveHrOrg(credentials, readFlag("--org"));
12854
+ if (typeof org === "number")
12855
+ return org;
12856
+ const res = await api2(credentials.apiBase, `/v1/hr/orgs/${encodeURIComponent(org.id)}/dicts/${encodeURIComponent(target)}`, {
12857
+ method: "DELETE",
12858
+ headers: authHeaders(credentials)
12859
+ });
12860
+ if (res.status !== 200) {
12861
+ const code = res.body?.error?.code;
12862
+ if (res.status === 409 || code === "conflict") {
12863
+ console.error("该字典项正被花名册人员使用,不能删除(先改人员的部门/岗位)。");
12864
+ return exitCodeForApiError(res.body);
12865
+ }
12866
+ console.error(errorMessage(res.body, "删除字典项失败。"));
12867
+ return exitCodeForApiError(res.body);
12868
+ }
12869
+ if (hasFlag("--json"))
12870
+ console.log(JSON.stringify({ ok: true }, null, 2));
12871
+ else
12872
+ console.log("✓ 已删除字典项。");
12873
+ return 0;
12874
+ }
12875
+ async function hrApplyLink() {
12876
+ const credentials = requireCredentials();
12877
+ if (typeof credentials === "number")
12878
+ return credentials;
12879
+ const org = await resolveHrOrg(credentials, readFlag("--org"));
12880
+ if (typeof org === "number")
12881
+ return org;
12882
+ const reset = hasFlag("--reset");
12883
+ const path = `/orgs/${encodeURIComponent(org.id)}${reset ? "/form-link/reset" : "/form-link"}`;
12884
+ const res = await api2(credentials.apiBase, `/v1/hr${path}`, { method: reset ? "POST" : "GET", headers: authHeaders(credentials) });
12885
+ if (res.status !== 200) {
12886
+ console.error(errorMessage(res.body, "获取入职收集链接失败。"));
12887
+ return exitCodeForApiError(res.body);
12888
+ }
12889
+ const token = res.body.data.token;
12890
+ const link = `${hrWebBase(credentials.apiBase)}/apply/${token}`;
12891
+ if (hasFlag("--json"))
12892
+ console.log(JSON.stringify({ token, link }, null, 2));
12893
+ else {
12894
+ if (reset)
12895
+ console.log("✓ 已重置(散出去的旧二维码全部作废)。");
12896
+ console.log(`入职收集链接:${link}`);
12897
+ console.log("候选人扫码 / 打开提交即自动生成入职申请单(进待拍板),HR 无需先建单。");
12898
+ }
12899
+ return 0;
12900
+ }
11187
12901
  switch (command) {
11188
12902
  case "init":
11189
12903
  process.exit(await init());
@@ -11253,6 +12967,40 @@ switch (command) {
11253
12967
  process.exit(await contactsInvitees());
11254
12968
  console.error(sub ? `未知子命令:contacts ${sub}
11255
12969
  ` : `缺少子命令:contacts list | add | rm | invite | invitees
12970
+ `);
12971
+ console.log(usage);
12972
+ process.exit(EXIT_USAGE);
12973
+ }
12974
+ case "addresses": {
12975
+ const sub = args[1];
12976
+ if (sub === "list")
12977
+ process.exit(await addressesList());
12978
+ if (sub === "add")
12979
+ process.exit(await addressesAdd());
12980
+ if (sub === "update")
12981
+ process.exit(await addressesUpdate(args[2]));
12982
+ if (sub === "set-default")
12983
+ process.exit(await addressesSetDefault(args[2]));
12984
+ if (sub === "rm")
12985
+ process.exit(await addressesRm(args[2]));
12986
+ console.error(sub ? `未知子命令:addresses ${sub}
12987
+ ` : `缺少子命令:addresses list | add | update | set-default | rm
12988
+ `);
12989
+ console.log(usage);
12990
+ process.exit(EXIT_USAGE);
12991
+ }
12992
+ case "favorites": {
12993
+ const sub = args[1];
12994
+ if (sub === "list")
12995
+ process.exit(await favoritesList());
12996
+ if (sub === "add")
12997
+ process.exit(await favoritesAdd(args[2]));
12998
+ if (sub === "check")
12999
+ process.exit(await favoritesCheck(args[2]));
13000
+ if (sub === "rm")
13001
+ process.exit(await favoritesRm(args[2]));
13002
+ console.error(sub ? `未知子命令:favorites ${sub}
13003
+ ` : `缺少子命令:favorites list | add | check | rm
11256
13004
  `);
11257
13005
  console.log(usage);
11258
13006
  process.exit(EXIT_USAGE);
@@ -11334,6 +13082,26 @@ switch (command) {
11334
13082
  `);
11335
13083
  process.exit(EXIT_USAGE);
11336
13084
  }
13085
+ if (sub === "products")
13086
+ process.exit(await shopProductsList());
13087
+ if (sub === "product") {
13088
+ const op = args[2];
13089
+ if (op === "create")
13090
+ process.exit(await shopProductCreate());
13091
+ if (op === "update")
13092
+ process.exit(await shopProductUpdate());
13093
+ if (op === "spec")
13094
+ process.exit(await shopProductSpec());
13095
+ if (op === "archive")
13096
+ process.exit(await shopProductArchive());
13097
+ process.exit(await shopProductDetail(op));
13098
+ }
13099
+ if (sub === "stock")
13100
+ process.exit(await shopStockAdjust(args[2]));
13101
+ if (sub === "listed")
13102
+ process.exit(await shopListedStock());
13103
+ if (sub === "movements")
13104
+ process.exit(await shopMovements());
11337
13105
  if (sub === "categories")
11338
13106
  process.exit(await shopCategories());
11339
13107
  if (sub === "category") {
@@ -11346,19 +13114,6 @@ switch (command) {
11346
13114
  if (args[2] === "delete")
11347
13115
  process.exit(await shopCategoryDelete());
11348
13116
  console.error(`用法:crab shop category add|rename|move|delete …(crab --help 看完整用法)
11349
- `);
11350
- process.exit(EXIT_USAGE);
11351
- }
11352
- if (sub === "specs")
11353
- process.exit(await shopSpecs());
11354
- if (sub === "spec") {
11355
- if (args[2] === "add")
11356
- process.exit(await shopSpecAdd());
11357
- if (args[2] === "update")
11358
- process.exit(await shopSpecUpdate());
11359
- if (args[2] === "delete")
11360
- process.exit(await shopSpecDelete());
11361
- console.error(`用法:crab shop spec add|update|delete …(crab --help 看完整用法)
11362
13117
  `);
11363
13118
  process.exit(EXIT_USAGE);
11364
13119
  }
@@ -11389,7 +13144,7 @@ switch (command) {
11389
13144
  process.exit(await shopApply(positional.join(" "), taglineArg));
11390
13145
  }
11391
13146
  console.error(sub ? `未知子命令:shop ${sub}
11392
- ` : `缺少子命令:shop status | shop apply <店铺名>
13147
+ ` : `缺少子命令:shop status | apply | products | product | stock | listed | movements | categories | category | members | member | invite | use | roles
11393
13148
  `);
11394
13149
  console.log(usage);
11395
13150
  process.exit(EXIT_USAGE);
@@ -11451,9 +13206,13 @@ switch (command) {
11451
13206
  if (sub === "orgs")
11452
13207
  process.exit(await hrOrgsList());
11453
13208
  if (sub === "org") {
11454
- if (args[2] === "create")
11455
- process.exit(await hrOrgCreate(args.slice(3).find((x) => !x.startsWith("--"))));
11456
- console.error(`用法:crab hr org create <名称> [--json]
13209
+ if (args[2] === "apply")
13210
+ process.exit(await hrOrgApply(positionalArgs(args.slice(3), HR_VALUED_FLAGS).join(" ") || undefined));
13211
+ if (args[2] === "application")
13212
+ process.exit(await hrOrgApplication());
13213
+ if (args[2] === "rename")
13214
+ process.exit(await hrOrgRename());
13215
+ console.error(`用法:crab hr org apply <名称> [--note 说明] | org application | org rename <新名称>
11457
13216
  `);
11458
13217
  process.exit(EXIT_USAGE);
11459
13218
  }
@@ -11475,6 +13234,8 @@ switch (command) {
11475
13234
  process.exit(await hrInvite());
11476
13235
  if (sub === "people")
11477
13236
  process.exit(await hrPeopleList());
13237
+ if (sub === "stats")
13238
+ process.exit(await hrStats());
11478
13239
  if (sub === "person") {
11479
13240
  const op = args[2];
11480
13241
  if (op === "create")
@@ -11483,6 +13244,8 @@ switch (command) {
11483
13244
  process.exit(await hrPersonEdit());
11484
13245
  if (op === "offboard")
11485
13246
  process.exit(await hrPersonOffboard());
13247
+ if (op === "rm")
13248
+ process.exit(await hrPersonRm());
11486
13249
  process.exit(await hrPersonDetail(op));
11487
13250
  }
11488
13251
  if (sub === "event") {
@@ -11496,6 +13259,17 @@ switch (command) {
11496
13259
  process.exit(await hrEventsList());
11497
13260
  if (sub === "import")
11498
13261
  process.exit(await hrImport());
13262
+ if (sub === "dicts")
13263
+ process.exit(await hrDicts());
13264
+ if (sub === "dict") {
13265
+ if (args[2] === "add")
13266
+ process.exit(await hrDictAdd());
13267
+ if (args[2] === "rm")
13268
+ process.exit(await hrDictRm());
13269
+ console.error(`用法:crab hr dict add <dept|position> <名称> | dict rm <entryId>
13270
+ `);
13271
+ process.exit(EXIT_USAGE);
13272
+ }
11499
13273
  if (sub === "tasks")
11500
13274
  process.exit(await hrTasksList());
11501
13275
  if (sub === "task") {
@@ -11504,14 +13278,22 @@ switch (command) {
11504
13278
  process.exit(await hrTaskCreate());
11505
13279
  if (op === "update")
11506
13280
  process.exit(await hrTaskUpdate());
11507
- console.error(`用法:crab hr task create|update
13281
+ if (op === "link")
13282
+ process.exit(await hrTaskLink());
13283
+ console.error(`用法:crab hr task create|update|link
11508
13284
  `);
11509
13285
  process.exit(EXIT_USAGE);
11510
13286
  }
13287
+ if (sub === "apply-link")
13288
+ process.exit(await hrApplyLink());
13289
+ if (sub === "announce")
13290
+ process.exit(await hrAnnounce());
13291
+ if (sub === "inbox")
13292
+ process.exit(await hrInbox());
11511
13293
  if (sub === "desk")
11512
13294
  process.exit(await hrDesk());
11513
13295
  console.error(sub ? `未知子命令:hr ${sub}
11514
- ` : `缺少子命令:hr orgs | org create | members | member | invite | people | person | event add | events | import | tasks | task | desk
13296
+ ` : `缺少子命令:hr orgs | org apply | members | member | invite | people | stats | person | event add | events | import | dicts | dict | tasks | task | apply-link | announce | inbox | desk
11515
13297
  `);
11516
13298
  console.log(usage);
11517
13299
  process.exit(EXIT_USAGE);