labull-framework 1.4.0__tar.gz → 1.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {labull_framework-1.4.0/labull_framework.egg-info → labull_framework-1.4.1}/PKG-INFO +1 -1
  2. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/flow.py +96 -25
  3. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/devkit.py +9 -5
  4. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/types/workflow.py +18 -8
  5. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/validate.py +1 -1
  6. {labull_framework-1.4.0 → labull_framework-1.4.1/labull_framework.egg-info}/PKG-INFO +1 -1
  7. {labull_framework-1.4.0 → labull_framework-1.4.1}/pyproject.toml +1 -1
  8. {labull_framework-1.4.0 → labull_framework-1.4.1}/LICENSE +0 -0
  9. {labull_framework-1.4.0 → labull_framework-1.4.1}/MANIFEST.in +0 -0
  10. {labull_framework-1.4.0 → labull_framework-1.4.1}/README-pypi.md +0 -0
  11. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/__init__.py +0 -0
  12. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/admin.py +0 -0
  13. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/apps.py +0 -0
  14. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/auth/__init__.py +0 -0
  15. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/auth/backend.py +0 -0
  16. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/auth/bootstrap.py +0 -0
  17. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/auth/sso.py +0 -0
  18. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/auth/views.py +0 -0
  19. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/canvas/__init__.py +0 -0
  20. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/canvas/data.py +0 -0
  21. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/canvas/gitinfo.py +0 -0
  22. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/canvas/shape_ast.py +0 -0
  23. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/canvas/static/canvas.css +0 -0
  24. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/canvas/static/canvas.js +0 -0
  25. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/canvas/static/page.html +0 -0
  26. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/canvas/views.py +0 -0
  27. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/env.py +0 -0
  28. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/jsonio.py +0 -0
  29. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/__init__.py +0 -0
  30. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/blockcheck.py +0 -0
  31. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/blockpackage.py +0 -0
  32. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/blocktypes.py +0 -0
  33. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/deepcheck.py +0 -0
  34. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/dispatch.py +0 -0
  35. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/export.py +0 -0
  36. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/health.py +0 -0
  37. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/manifest.py +0 -0
  38. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/parallel.py +0 -0
  39. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/registry.py +0 -0
  40. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/requires.py +0 -0
  41. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/sdk.py +0 -0
  42. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/service.py +0 -0
  43. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/subproc.py +0 -0
  44. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/types/__init__.py +0 -0
  45. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/types/plain.py +0 -0
  46. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/magic_block/views.py +0 -0
  47. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/management/__init__.py +0 -0
  48. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/management/commands/__init__.py +0 -0
  49. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/management/commands/labull_blocktype.py +0 -0
  50. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/management/commands/labull_contract.py +0 -0
  51. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/management/commands/labull_requires.py +0 -0
  52. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/migrations/0001_initial.py +0 -0
  53. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/migrations/0002_remove_magicblock_prev_files_and_more.py +0 -0
  54. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/migrations/0003_magicblock_tags.py +0 -0
  55. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/migrations/0004_magicblock_detail.py +0 -0
  56. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/migrations/__init__.py +0 -0
  57. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/models.py +0 -0
  58. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/settings.py +0 -0
  59. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/urls.py +0 -0
  60. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework/virtual.py +0 -0
  61. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework.egg-info/SOURCES.txt +0 -0
  62. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework.egg-info/dependency_links.txt +0 -0
  63. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework.egg-info/requires.txt +0 -0
  64. {labull_framework-1.4.0 → labull_framework-1.4.1}/labull_framework.egg-info/top_level.txt +0 -0
  65. {labull_framework-1.4.0 → labull_framework-1.4.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 1.4.0
3
+ Version: 1.4.1
4
4
  Summary: Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks
5
5
  Author: Labull Framework
6
6
  License-Expression: MIT
@@ -11,7 +11,7 @@
11
11
  待办仍靠「当前处理人」一列一条 JOIN —— 不建任务表、不做通用引擎。这是刻意的取舍:
12
12
  通用引擎 + 任务表 = 老系统三次生产事故的根源(并发往指派表插行 ⇒ duplicate key ⇒ 单据永久卡住)。
13
13
  🔴 本模块**不 import 任何业务模型**:`entity` 只当字符串进报文;业务模型的 import 全部留在块的钩子里。
14
- 🔴 定义期判据(D1–D13 / D18 / D19 / D21–D23)在**构造时**就 `raise`:写错一条流程,保存那一刻就报,
14
+ 🔴 定义期判据(D1–D13 / D18 / D19 / D21–D25)在**构造时**就 `raise`:写错一条流程,保存那一刻就报,
15
15
  而不是某次点按钮才 500。话术照附录 C:中文、说清"哪个字段、现在是什么、应该是什么、怎么改"。
16
16
  """
17
17
 
@@ -88,7 +88,7 @@ DEFAULT_PEOPLE: dict[str, str] = {
88
88
 
89
89
 
90
90
  class FlowError(Exception):
91
- """定义期判据不通过(D1–D13 / D18 / D19 / D21–D23)或流程声明用错("这条流程里没有那个动作")。
91
+ """定义期判据不通过(D1–D13 / D18 / D19 / D21–D25)或流程声明用错("这条流程里没有那个动作")。
92
92
 
93
93
  🔴 它是**给作者看的**:报文就是答案(中文、说清哪个字段、怎么改),不要包一层"参数错误"。
94
94
  """
@@ -162,9 +162,16 @@ class Step:
162
162
  #: 没命中 ⇒ 全部动作(与 `ui_by` 的兜底一致);命中 ⇒ **按 `moves` 的声明顺序过滤,不重排**。
163
163
  #: 用途:同一个"待审批",加急单只该出现「通过 / 退回」,标准单还该出现「转交」。
164
164
  moves_by: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
165
+ #: **待办挂给谁**(规则串,与 `candidates` 同一份白名单,D10)。不写 = 跟 `candidates` 一样。
166
+ #: 🔴 它与 `candidates` 是**两件事**:`candidates` 管**闸门**(这个人能不能点),这一格管
167
+ #: 「当前处理人」那一列(待办按它查)。真实流程里它们经常故意不同(见 `docs/magic-block/流程.md` 5.2)。
168
+ assignees: str = ''
169
+ #: **历史终态**:只存在于历史数据里、没有任何动作能写到它(不参与 D7 的可达性,D25)。
170
+ #: 必须是终态、且 `candidates` / `assignees` 都为空的 —— 存量行打开时能解析出这个状态,而不是 500。
171
+ legacy: bool = False
165
172
 
166
173
  def __post_init__(self) -> None:
167
- """归一化:`moves` / `ui_by` / `moves_by` / `editable` 一律成元组与 dict(可比较、可 dump、顺序稳定)。"""
174
+ """归一化:`moves` / `ui_by` / `moves_by` / `editable` / `assignees` 一律成元组、dict 或 str。"""
168
175
  object.__setattr__(self, 'moves', tuple(self.moves or ()))
169
176
  object.__setattr__(self, 'ui_by', {str(key): str(value) for key, value in dict(self.ui_by or {}).items()})
170
177
  object.__setattr__(
@@ -176,13 +183,14 @@ class Step:
176
183
  },
177
184
  )
178
185
  object.__setattr__(self, 'editable', tuple(str(item) for item in (self.editable or ())))
186
+ object.__setattr__(self, 'assignees', str(self.assignees or ''))
179
187
 
180
188
 
181
189
  @dataclass(frozen=True)
182
190
  class Flow:
183
191
  """一条流程:一个 `type`(跨块比对的主键)+ 一张状态表。
184
192
 
185
- 🔴 构造时就判(D1–D13 / D18 / D19 / D21–D23),不合法当场 `raise FlowError`:错误在这一刻最便宜 ——
193
+ 🔴 构造时就判(D1–D13 / D18 / D19 / D21–D25),不合法当场 `raise FlowError`:错误在这一刻最便宜 ——
186
194
  保存时被拦,而不是上线后由一张卡住的单据告诉你。
187
195
  """
188
196
 
@@ -213,7 +221,9 @@ class Flow:
213
221
  #: 🔴 `m2m` 是"**所有候选人**都写进去"(待办按这个集合查);`fk` 仍只写"恰好一个候选人"——
214
222
  #: 两者不是同一个东西,不许互相兜底。
215
223
  handler_kind: str = 'fk'
216
- #: 自定义日志条目:`f(row, *, action, actor, note, move, from_step, to_step, **data)`。
224
+ #: 自定义日志条目:`f(row, *, action, actor, actor_name, note, move, from_step, to_step, **data)`。
225
+ #: `actor` 是**登录名**(`username`)、`actor_name` 是**显示名**(`name`)—— 两个值分开递,
226
+ #: 重名时不会记错人;`to_step=None` 表示"只留痕、没推进"。
217
227
  #: 给了就替掉平台默认那一形状(**只在内存里追一条**,落库仍由调用方那次 save 一起做)。
218
228
  log_entry: Callable[..., Any] | None = None
219
229
  #: 候选人求值器:`f(step, row) -> Candidates`。给了就替掉内置的 `candidates_of()`。
@@ -224,10 +234,10 @@ class Flow:
224
234
  #: ⚠️ 它只旁路**候选人闸门**,不旁路 `confirm_roles`("谁的点击才算数"仍按岗位判)。
225
235
  staff_bypass: bool = False
226
236
 
227
- # ── 构造时判(D1–D13 / D18 / D19 / D21–D23) ──
237
+ # ── 构造时判(D1–D13 / D18 / D19 / D21–D25) ──
228
238
 
229
239
  def __post_init__(self) -> None:
230
- """把声明归一化并**立刻**判 D1–D13 / D18 / D19 / D21–D23(`frozen` 也能改 —— 用 `object.__setattr__`)。"""
240
+ """把声明归一化并**立刻**判 D1–D13 / D18 / D19 / D21–D25(`frozen` 也能改 —— 用 `object.__setattr__`)。"""
231
241
  steps = tuple(self.steps or ())
232
242
  terminal = tuple(self.terminal or ())
233
243
  object.__setattr__(self, 'steps', steps)
@@ -235,7 +245,7 @@ class Flow:
235
245
  self._validate()
236
246
 
237
247
  def _validate(self) -> None:
238
- """D1–D13 / D18 / D19 / D21–D23 全部在这里判;**顺序就是报文顺序**(第一条永远先说的是最靠前的那个错)。"""
248
+ """D1–D13 / D18 / D19 / D21–D25 全部在这里判;**顺序就是报文顺序**(第一条永远先说的是最靠前的那个错)。"""
239
249
  where = f'流程 {self.type or "(没写 type)"}'
240
250
  # D1
241
251
  if not str(self.type or '').strip():
@@ -357,7 +367,8 @@ class Flow:
357
367
  if move.to not in reached:
358
368
  reached.add(move.to)
359
369
  frontier.append(move.to)
360
- unreachable = [code for code in codes if code not in reached]
370
+ # 🔴 历史终态(`legacy=True`)不参与可达性:它只存在于历史数据里,没有任何动作能写到它。
371
+ unreachable = [code for code in codes if code not in reached and not by_code[code].legacy]
361
372
  if unreachable:
362
373
  raise FlowError(
363
374
  f'{where}:状态 {" / ".join(unreachable)} 没有任何动作能到达它 —— '
@@ -375,22 +386,51 @@ class Flow:
375
386
  f'{where}:状态 {step.code} 不是终态,却一个动作都没有 —— '
376
387
  f'单据走到这里就永远停住了(要么给它动作,要么把它标成终态)'
377
388
  )
378
- # D10 / D11
389
+ # D25 / D11 扩展 / D24:legacy 与 assignees 的语义边界
379
390
  for step in self.steps:
380
- rule = _rule_of(step.candidates)
391
+ if not isinstance(step.legacy, bool):
392
+ raise FlowError(
393
+ f'{where}:状态 {step.code} 的 legacy 必须是 True / False —— '
394
+ f'它决定这个状态参不参与可达性(D7)'
395
+ )
396
+ if step.legacy and not step.terminal:
397
+ raise FlowError(
398
+ f'{where}:状态 {step.code} 写了 legacy=True,但没标 terminal —— '
399
+ f'历史状态只能是终态(没有任何动作能写到它)'
400
+ )
401
+ if step.assignees:
402
+ if step.terminal:
403
+ raise FlowError(
404
+ f'{where}:终态 {step.code} 写了 assignees({step.assignees!r})—— '
405
+ f'终态没人要办,待办不该挂在谁头上'
406
+ )
407
+ if not str(step.candidates or '').strip():
408
+ raise FlowError(
409
+ f'{where}:状态 {step.code} 写了 assignees,但 candidates 是空串 —— '
410
+ f'"该谁办"必须有个闸门(不能只有待办、没有人能点)'
411
+ )
412
+ # D10 / D11:规则串的语法与岗位白名单(candidates 与 assignees 用**同一份**判据)
413
+ def check_rule(step: Step, name: str) -> None:
414
+ text = str(getattr(step, name) or '')
415
+ rule = _rule_of(text)
381
416
  if rule is None:
382
417
  raise FlowError(
383
- f'{where}:状态 {step.code} 的 candidates({step.candidates!r})不是认得的写法 —— '
418
+ f'{where}:状态 {step.code} 的 {name}({text!r})不是认得的写法 —— '
384
419
  f'只认 {" / ".join(CANDIDATE_RULES)}'
385
420
  )
386
421
  if rule['kind'] in ('role', 'roles', 'applicant_or_roles'):
387
422
  unknown = [code for code in rule['codes'] if code not in positions]
388
423
  if unknown:
389
424
  raise FlowError(
390
- f'{where}:状态 {step.code} 的 candidates 里有不认识的岗位 '
425
+ f'{where}:状态 {step.code} 的 {name} 里有不认识的岗位 '
391
426
  f'{" / ".join(unknown)} —— 业务岗位白名单是 {" / ".join(sorted(positions))}'
392
427
  f'(内置 ∪ Flow.positions)'
393
428
  )
429
+
430
+ for step in self.steps:
431
+ check_rule(step, 'candidates')
432
+ if step.assignees:
433
+ check_rule(step, 'assignees')
394
434
  if (step.candidates == '') != bool(step.terminal):
395
435
  if step.terminal:
396
436
  raise FlowError(
@@ -559,6 +599,8 @@ class Flow:
559
599
  'ui': step.ui,
560
600
  'ui_by': {str(key): str(value) for key, value in step.ui_by.items()},
561
601
  'moves_by': {str(key): list(codes) for key, codes in step.moves_by.items()},
602
+ 'assignees': step.assignees,
603
+ 'legacy': bool(step.legacy),
562
604
  'form': str(step.form.__name__) if step.form is not None else '',
563
605
  'editable': list(step.editable),
564
606
  'moves': [
@@ -692,12 +734,12 @@ class Candidates:
692
734
  return bool(_group_names(user) & set(self.groups))
693
735
 
694
736
 
695
- def candidates_of(flow: Flow, step: Step, row: Any) -> Candidates:
696
- """这一步"该谁办"(规则串 → 能办的人)。内置规则的那一半;`Flow.resolve` 可以替掉它。"""
697
- rule = _rule_of(step.candidates)
737
+ def _candidates_of_rule(flow: Flow, step: Step, rule_text: str, row: Any) -> Candidates:
738
+ """把一条规则串求值成候选人(`candidates` 与 `assignees` **共用这一份实现**)。"""
739
+ rule = _rule_of(rule_text)
698
740
  if rule is None:
699
741
  # 定义期已经拦过;走到这里只可能是"运行期拿到的 Flow 不是这个构造出来的"。
700
- raise FlowError(f'流程 {flow.type}:状态 {step.code} 的 candidates({step.candidates!r})认不出来')
742
+ raise FlowError(f'流程 {flow.type}:状态 {step.code} 的规则串({rule_text!r})认不出来')
701
743
  positions = _positions_of(flow)
702
744
  kind = rule['kind']
703
745
  ids: set[str] = set()
@@ -729,6 +771,24 @@ def candidates_of(flow: Flow, step: Step, row: Any) -> Candidates:
729
771
  return Candidates(ids=frozenset(ids), groups=frozenset(groups), staff_only=staff_only)
730
772
 
731
773
 
774
+ def candidates_of(flow: Flow, step: Step, row: Any) -> Candidates:
775
+ """这一步"该谁办"(`step.candidates` → 能办的人)。内置规则的那一半;`Flow.resolve` 可以替掉它。"""
776
+ return _candidates_of_rule(flow, step, step.candidates, row)
777
+
778
+
779
+ def assignees_of(flow: Flow, step: Step, row: Any) -> Candidates:
780
+ """这一步的**待办挂给谁**(`step.assignees`;不写 = 跟 `candidates` 一样)。
781
+
782
+ 🔴 `assignees` 永远按**内置规则串**求值(不经过 `Flow.resolve` —— resolve 是闸门的书面出口);
783
+ 没写它时走 `_candidates()`(= 1.4.0 的行为,含 resolve)—— 老流程一个字不用改。
784
+ 🔴 它只影响「当前处理人」那一列(`_refresh_handler` / 报文 `handler`),
785
+ **不影响** `actions[].allowed` / `capabilities.actions`(那一半只看 `candidates`)。
786
+ """
787
+ if str(step.assignees or '').strip():
788
+ return _candidates_of_rule(flow, step, step.assignees, row)
789
+ return _candidates(flow, step, row)
790
+
791
+
732
792
  def _candidates(flow: Flow, step: Step, row: Any) -> Candidates:
733
793
  """候选人求值的唯一入口:有 `Flow.resolve` 就用它,否则走内置的 `candidates_of()`。
734
794
 
@@ -910,18 +970,23 @@ def _append_log(
910
970
  move: Move | None = None,
911
971
  from_step: Step | None = None,
912
972
  to_step: Step | None = None,
973
+ actor_name: str = '',
974
+ login: str = '',
913
975
  **data: Any,
914
976
  ) -> None:
915
977
  """往业务行的 `log` 追一条(**只在内存里**,落库由调用方那一次 `save` 一起做)。
916
978
 
917
- 🔴 有 `Flow.log_entry` 就交给它(项目已有的日志形状靠它写;`to_step=None` = 只留痕、没推进);
918
- 否则走 `WorkflowModel.append_log()` 那一处(它是默认形状的唯一真源);行上没有那个方法
919
- (业务表没继承虚拟基类)就按同一形状自己追一条 —— 形状逐字一致。
979
+ 🔴 有 `Flow.log_entry` 就交给它(项目已有的日志形状靠它写;`to_step=None` = 只留痕、没推进):
980
+ 钩子拿到的是**分开的两个值** —— `actor`=登录名(`username`)、`actor_name`=显示名,
981
+ 重名时不会记错人(要反查一次库才能拆开的那种写法从这一版起不必了)。
982
+ 否则走 `WorkflowModel.append_log()` 那一处(它是默认形状的唯一真源),`actor` 仍是
983
+ 1.4.0 的那个值(显示名优先、没有才用登录名)—— **默认形状一个字不改**。
984
+ 行上没有那个方法(业务表没继承虚拟基类)就按同一形状自己追一条 —— 形状逐字一致。
920
985
  """
921
986
  if flow.log_entry is not None:
922
987
  flow.log_entry(
923
- row, action=action, actor=actor, note=note,
924
- move=move, from_step=from_step, to_step=to_step, **data,
988
+ row, action=action, actor=login or actor, actor_name=actor_name or actor,
989
+ note=note, move=move, from_step=from_step, to_step=to_step, **data,
925
990
  )
926
991
  return
927
992
  from .models import WorkflowModel
@@ -1014,12 +1079,12 @@ def _refresh_handler(flow: Flow, row: Any, step: Step, *, candidates: Candidates
1014
1079
  if step.terminal:
1015
1080
  manager.clear()
1016
1081
  return ''
1017
- found = candidates if candidates is not None else _candidates(flow, step, row)
1082
+ found = candidates if candidates is not None else assignees_of(flow, step, row)
1018
1083
  manager.set(_ids_of(found))
1019
1084
  return '、'.join(str(user) for user in manager.all())
1020
1085
  value = ''
1021
1086
  if not step.terminal:
1022
- found = candidates if candidates is not None else _candidates(flow, step, row)
1087
+ found = candidates if candidates is not None else assignees_of(flow, step, row)
1023
1088
  if not found.staff_only and len(found.ids) == 1 and not found.groups:
1024
1089
  value = next(iter(found.ids))
1025
1090
  if hasattr(row, f'{column}_id'):
@@ -1087,7 +1152,11 @@ def advance(flow: Flow, row: Any, action: str, params: Mapping[str, Any] | None
1087
1152
 
1088
1153
  values: dict[str, Any] = dict(params or {})
1089
1154
  user = getattr(ctx, 'user', None)
1090
- actor = str(getattr(user, 'name', '') or getattr(user, 'username', '') or '')
1155
+ # 🔴 日志要的是两个值:`actor`(默认形状 / 1.4.0 的取值:显示名优先)与钩子那两个
1156
+ # (登录名 `login` / 显示名 `actor_name`)。默认形状一个字不改,钩子才拿得到分开的两列。
1157
+ display = str(getattr(user, 'name', '') or '')
1158
+ login = str(getattr(user, 'username', '') or '')
1159
+ actor = display or login
1091
1160
  if not str(action or '').strip():
1092
1161
  raise errors.BadRequest('要告诉我要走哪个动作(报文里的 actions[].action)')
1093
1162
  if getattr(row, 'pk', None) is None:
@@ -1130,6 +1199,7 @@ def advance(flow: Flow, row: Any, action: str, params: Mapping[str, Any] | None
1130
1199
  flow, row, action=move.code, actor=actor,
1131
1200
  note=f'点了「{move.label}」,但岗位不对:只留痕',
1132
1201
  move=move, from_step=from_step, to_step=None,
1202
+ actor_name=display, login=login,
1133
1203
  stage='confirm-roles', roles=list(move.confirm_roles),
1134
1204
  )
1135
1205
  _save_row(flow, row, include_log=True)
@@ -1150,6 +1220,7 @@ def advance(flow: Flow, row: Any, action: str, params: Mapping[str, Any] | None
1150
1220
  _append_log(
1151
1221
  flow, row, action=move.code, actor=actor, note=comment or move.label,
1152
1222
  move=move, from_step=from_step, to_step=to_step,
1223
+ actor_name=display, login=login,
1153
1224
  kind=move.kind, to=move.to,
1154
1225
  )
1155
1226
  _refresh_handler(flow, row, to_step)
@@ -739,13 +739,13 @@ EXAMPLE_FILES: tuple[tuple[str, str], ...] = (
739
739
  #: ⚠️ **每种类型都必须有一个样例**(缺样例算导出失败项,见 `example_files()`):它们是块作者
740
740
  #: 照着抄的唯一一份 —— 抄到一份过期的就等于教人写错。
741
741
  EXAMPLE_FLOW_FILES: tuple[tuple[str, str], ...] = (
742
- ('README.md', '# 流程块(`type: workflow`)—— 这一类块怎么写\n\n这一份是**类型级**的说明,同一份正文铺在两个地方:\n`workspace/workflow/README.md`(你放流程块的那一层)与 `example/workflow/README.md`(样例那一层)。\n\n`example/workflow/workspace/报销流程-<uuid>/` 里那一份是**完整样例**:整个文件夹搬进\n`workspace/workflow/` 就是一个块(搬之前把文件夹名末尾的 uuid 与清单里的 `id` 换成你自己的)。\n\n它比普通块多两个文件:\n\n| 文件 | 是什么 |\n|---|---|\n| `flow.py` | **这条流程的唯一权威**:状态表 / 动作 / 谁能办 / 每个节点的内容 key / 业务钩子 |\n| `flow.js` | 前端**内容渲染器登记表**(`MagicSteps.register(\'<ui>\', fn)`),与 `flow.py` 双向对齐 |\n\n`flow.open.yaml` 是导出时铺的**说明性草稿**(从 `flow.py` 生成,给人对照用)—— 它不进清单、不影响保存。\n\n## 三处"读一眼就懂"的地方\n\n1. **状态表**:`flow.py` 里那一个 `Flow(...)`,加节点就是加一个 `Step(...)`;\n2. **推进**:块后端一行 `advance(flow_of(\'reimbursement\'), row, params[\'action\'], params, ctx)` ——\n 状态校验 / 权限闸门 / 必填意见 / 日志 / 刷处理人 / 报文全在平台;\n3. **内容**:`index.open.html` 的壳不变,只有 `#slot` 随报文里的 `step.ui` 换。\n\n## 这一版多给几种表达力(样例 `flow.py` 里能看到前两条)\n\n| 写法 | 解决什么 |\n|---|---|\n| `Flow(positions={\'CASHIER\': \'出纳\'})` | 业务岗位码由**这条流程**声明(与内置码合并),换项目不必改平台 |\n| `Step(..., moves_by={\'accounting\': (\'approve\', \'reject\')})` | 同一个状态、**不同单据类型给不同动作集**(列由 `view_field` 给) |\n| `candidates=\'biz:owner,approver\'` | 一步的候选人是**单据上指名的几个人**(多字段取并集) |\n| `Move(..., after=...)` | 推进**之后**的副作用(写生效时间 / 解绑关联单据),同一次事务里 |\n| `Flow(handler_kind=\'m2m\')` | 处理人是一组人时(多对多),推进后写入**全部**候选人 |\n| `Flow(log_entry=...)` | 项目已有自己的日志形状时,替换平台默认的那一条 |\n| `Flow(staff_bypass=True)` | 管理员旁路:候选人闸门对 `is_staff` 放行(默认不放行) |\n| `Flow(resolve=...)` | 候选人求值的书面出口(⚠️ 跨块比对不比它:两份副本各写一个不会被报出来) |\n\n⚠️ 这是示例:业务表是 `canvasdemo.Reimbursement`(示例项目里那张),换项目就换 `flow.py` / `backend_open.py` 里那两处 import。\n'),
743
- ('README.open.md', "# 报销流程\n\n一张报销单从**草稿**到**付款**:申请人填、提交,财务 / 出纳(或申请人自己)审批,通过则付款,退回要写意见。\n\n- **谁能用**:填单人(申请人)与财务 / 出纳岗(Django 组 `ACCOUNTANT` / `财务` / `CASHIER` / `出纳`)。\n- **怎么用**:左边「新建一张草稿单」→ 改事由与金额 →「保存」→「提交」→「通过 / 退回 / 撤回重填」。\n- **边界**:这是**示例**,业务表是 `canvasdemo.Reimbursement`(换项目就换 `flow.py` 与 `backend_open.py` 里那两处 import);\n 钱的真实动作(付款流水、发票)不在这里。\n\n## 这个例子示范了什么(流程块的要点)\n\n| 要点 | 在哪一行 |\n|---|---|\n| 流程表只有一处 | `flow.py` 的 `FLOW = register(Flow(...))` |\n| 一个 `ui` 被多个节点复用 | `draft` 与 `rejected` 都用 `ui='bill'` |\n| 一个 `ui_by` 分叉 | `pending` 的 `ui_by={'accounting': 'approve-zero'}`(列是 `view_field='bill_type'`) |\n| 一个 `moves_by` 动作集分叉 | `pending` 的 `moves_by={'accounting': ('approve', 'reject')}` —— 记账报销没有「撤回重填」 |\n| 本流程自己的岗位码 | `positions={'CASHIER': '出纳'}`,被 `candidates='applicant_or_roles:ACCOUNTANT,CASHIER'` 用到 |\n| 必填意见的退回 + 两个终态 | `Move('reject', …, requires_comment=True)`、`paid` / `rejected` |\n| 「保存不推进」 | `draft` 的 `form=keep_title` + `editable=('title','amount')` |\n| 前端按 key 渲染 | `flow.js` 里四行 `MagicSteps.register(...)`,`app.js` 里那四个渲染器 |\n\n🔴 前端登记的 `ui` 与 `flow.py` 用到的 `ui` 是**双向**判据(D16/D17):少一个会在保存时被拦(白屏之前先被拦),\n多一个也会被拦(死代码)。\n"),
742
+ ('README.md', '# 流程块(`type: workflow`)—— 这一类块怎么写\n\n这一份是**类型级**的说明,同一份正文铺在两个地方:\n`workspace/workflow/README.md`(你放流程块的那一层)与 `example/workflow/README.md`(样例那一层)。\n\n`example/workflow/workspace/报销流程-<uuid>/` 里那一份是**完整样例**:整个文件夹搬进\n`workspace/workflow/` 就是一个块(搬之前把文件夹名末尾的 uuid 与清单里的 `id` 换成你自己的)。\n\n它比普通块多两个文件:\n\n| 文件 | 是什么 |\n|---|---|\n| `flow.py` | **这条流程的唯一权威**:状态表 / 动作 / 谁能办 / 每个节点的内容 key / 业务钩子 |\n| `flow.js` | 前端**内容渲染器登记表**(`MagicSteps.register(\'<ui>\', fn)`),与 `flow.py` 双向对齐 |\n\n`flow.open.yaml` 是导出时铺的**说明性草稿**(从 `flow.py` 生成,给人对照用)—— 它不进清单、不影响保存。\n\n## 三处"读一眼就懂"的地方\n\n1. **状态表**:`flow.py` 里那一个 `Flow(...)`,加节点就是加一个 `Step(...)`;\n2. **推进**:块后端一行 `advance(flow_of(\'reimbursement\'), row, params[\'action\'], params, ctx)` ——\n 状态校验 / 权限闸门 / 必填意见 / 日志 / 刷处理人 / 报文全在平台;\n3. **内容**:`index.open.html` 的壳不变,只有 `#slot` 随报文里的 `step.ui` 换。\n\n## 这一版多给几种表达力(样例 `flow.py` 里能看到前两条)\n\n| 写法 | 解决什么 |\n|---|---|\n| `Flow(positions={\'CASHIER\': \'出纳\'})` | 业务岗位码由**这条流程**声明(与内置码合并),换项目不必改平台 |\n| `Step(..., moves_by={\'accounting\': (\'approve\', \'reject\')})` | 同一个状态、**不同单据类型给不同动作集**(列由 `view_field` 给) |\n| `candidates=\'biz:owner,approver\'` | 一步的候选人是**单据上指名的几个人**(多字段取并集) |\n| `Step(..., assignees=\'biz:applicant\')` | **谁能点**(`candidates`)与**待办挂给谁**(`assignees`)分开;不写 = 跟 `candidates` 一样 |\n| `Step(..., legacy=True)` | 登记只存在于**历史数据**里的终态(不参与 D7;存量行打开不 500) |\n| `Move(..., after=...)` | 推进**之后**的副作用(写生效时间 / 解绑关联单据),同一次事务里 |\n| `Flow(handler_kind=\'m2m\')` | 处理人是一组人时(多对多),推进后写入**全部**候选人 |\n| `Flow(log_entry=...)` | 项目已有自己的日志形状时,替换平台默认的那一条 |\n| `Flow(staff_bypass=True)` | 管理员旁路:候选人闸门对 `is_staff` 放行(默认不放行) |\n| `Flow(resolve=...)` | 候选人求值的书面出口(⚠️ 跨块比对不比它:两份副本各写一个不会被报出来) |\n\n⚠️ 这是示例:业务表是 `canvasdemo.Reimbursement`(示例项目里那张),换项目就换 `flow.py` / `backend_open.py` 里那两处 import。\n'),
743
+ ('README.open.md', "# 报销流程\n\n一张报销单从**草稿**到**付款**:申请人填、提交,财务 / 出纳(或申请人自己)审批,通过则付款,退回要写意见。\n\n- **谁能用**:填单人(申请人)与财务 / 出纳岗(Django 组 `ACCOUNTANT` / `财务` / `CASHIER` / `出纳`)。\n- **怎么用**:左边「新建一张草稿单」→ 改事由与金额 →「保存」→「提交」→「通过 / 退回 / 撤回重填」。\n- **边界**:这是**示例**,业务表是 `canvasdemo.Reimbursement`(换项目就换 `flow.py` 与 `backend_open.py` 里那两处 import);\n 钱的真实动作(付款流水、发票)不在这里。\n\n## 这个例子示范了什么(流程块的要点)\n\n| 要点 | 在哪一行 |\n|---|---|\n| 流程表只有一处 | `flow.py` 的 `FLOW = register(Flow(...))` |\n| 一个 `ui` 被多个节点复用 | `draft` 与 `rejected` 都用 `ui='bill'` |\n| 一个 `ui_by` 分叉 | `pending` 的 `ui_by={'accounting': 'approve-zero'}`(列是 `view_field='bill_type'`) |\n| 一个 `moves_by` 动作集分叉 | `pending` 的 `moves_by={'accounting': ('approve', 'reject')}` —— 记账报销没有「撤回重填」 |\n| 「谁能点」与「待办挂给谁」分开 | `pending` 的 `candidates='applicant_or_roles:ACCOUNTANT,CASHIER'` + `assignees='biz:applicant'` |\n| 本流程自己的岗位码 | `positions={'CASHIER': '出纳'}`,被 `candidates='applicant_or_roles:ACCOUNTANT,CASHIER'` 用到 |\n| 必填意见的退回 + 两个终态 | `Move('reject', …, requires_comment=True)`、`paid` / `rejected` |\n| 「保存不推进」 | `draft` 的 `form=keep_title` + `editable=('title','amount')` |\n| 前端按 key 渲染 | `flow.js` 里四行 `MagicSteps.register(...)`,`app.js` 里那四个渲染器 |\n\n🔴 前端登记的 `ui` 与 `flow.py` 用到的 `ui` 是**双向**判据(D16/D17):少一个会在保存时被拦(白屏之前先被拦),\n多一个也会被拦(死代码)。\n"),
744
744
  ('app.css', '/* 报销流程示例块的样式(宿主会把这份内联进 iframe)。\n *\n * 设计口径(与仓库里那些真实业务块一致):\n * · 只用主题给的类与 token:`.labull-card` / `.labull-subject-title` / `.labull-steps` / `--sp-*`;\n * · 底色用 `--sp-color-surface`(那一层浅灰蓝),内容是白卡片 —— 与宿主那一页同一个观感;\n * · 步骤条 `.labull-steps` 一行样式都不写:桌面横向、窄屏自动纵向、≤480px 节点自动缩到 28px(主题里)。\n *\n * 🔴 手机端这一份是**真的**媒体查询,不是"缩一缩":块 iframe 的宽度就是手机宽度,\n * `@media (max-width: 768px)` 在这里照常生效(与 `app.css` 那一档同一个断点)。\n */\nbody {\n margin: 0;\n padding: 16px 20px 24px;\n font: var(--sp-font-md, 14px) / var(--sp-line-height-body, 1.5) var(--sp-font-family, system-ui, sans-serif);\n color: var(--sp-color-text, #2e3440);\n background: var(--sp-color-surface, #eceff4);\n}\n\n.wf { display: flex; flex-direction: column; gap: var(--sp-space-3, 12px); }\n\n/* ── 抬头 ─────────────────────────────────────────────────────────────── */\n.wf-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 10px; min-height: 36px; }\n.wf-head .labull-subject-title { display: inline-flex; align-items: center; gap: 6px; margin: 0; font-size: var(--sp-font-lg, 18px); }\n.wf-no { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: var(--sp-font-md, 14px); color: var(--sp-color-text, #2e3440); }\n.wf-muted { color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n\n/* 状态药丸:颜色按状态走(不是按文字猜),与表单里那几处一致 */\n.wf-pill {\n display: inline-flex;\n align-items: center;\n height: 22px;\n padding: 0 8px;\n border-radius: var(--sp-radius-pill, 999px);\n font-size: var(--sp-font-xs, 12px);\n font-weight: var(--sp-font-weight-strong, 600);\n background: var(--sp-color-active, #d8e1eb);\n color: var(--sp-color-primary, #5e81ac);\n}\n.wf-pill[data-status=\'pending\'] { background: #fdf1dc; color: var(--sp-color-warn, #d98b00); }\n.wf-pill[data-status=\'paid\'] { background: #dff3e6; color: var(--sp-color-success, #2e9e5b); }\n.wf-pill[data-status=\'rejected\'] { background: #fbe5e6; color: var(--sp-color-danger, #bf616a); }\n\n/* ── 步骤条那一张卡片 ─────────────────────────────────────────────────── */\n.wf-steps { padding: 6px 12px; }\n.wf-steps #steps { width: 100%; }\n\n/* ── 内容槽 + 卡片里的排版 ───────────────────────────────────────────── */\n.wf-slot { display: flex; flex-direction: column; gap: var(--sp-space-3, 12px); }\n.wf-card-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }\n.wf-card-head .pi { color: var(--sp-color-primary, #5e81ac); }\n.wf-card-title { margin: 0; font-size: var(--sp-font-md, 14px); font-weight: var(--sp-font-weight-strong, 600); }\n\n/* 一行「标签 + 值/控件」:只读的标签贴着内容(`max-content`),输入框那几行标签列定宽、控件那列最多 220px\n (与示例抬头那种"一眼扫完"的密度一致;长文本仍然在值那一列折行,不把整行撑开)。 */\n.wf-kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); align-items: center; gap: 4px 12px; }\n.wf-kv.wf-field { grid-template-columns: 64px minmax(0, 220px); gap: 8px 12px; }\n.wf-kv-label { color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); white-space: nowrap; }\n.wf-kv-value { min-width: 0; overflow-wrap: anywhere; }\n.wf-kv-value .p-inputtext { width: 100%; box-sizing: border-box; }\n.wf-kv-value .wf-num { font-variant-numeric: tabular-nums; }\n\n.wf-hint { margin: 0; color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n.wf-note {\n margin: 0;\n padding: 8px 10px;\n border-left: 3px solid var(--sp-color-primary, #5e81ac);\n border-radius: var(--sp-radius-sm, 4px);\n background: var(--sp-color-active, #d8e1eb);\n font-size: var(--sp-font-sm, 13px);\n}\n.wf-empty { color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n\n/* ── 动作条 + 提示 ───────────────────────────────────────────────────── */\n.wf-actions { display: flex; flex-direction: column; gap: 6px; }\n.wf-actions #actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; min-height: 36px; }\n.wf-actions #actions:empty::before { content: \'这一步没有可点的动作\'; color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n.wf-tip { margin: 0; min-height: 18px; color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n.wf-tip.is-bad { color: var(--sp-color-danger, #bf616a); }\n\n/* ── 切换单子 ────────────────────────────────────────────────────────── */\n.wf-bills { color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n.wf-bills > summary { cursor: pointer; width: fit-content; padding: 2px 0; }\n.wf-billlist { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0 0; }\n.wf-billlist button {\n padding: 4px 10px;\n border: var(--sp-border-width, 1px) solid var(--sp-color-border, #e5e9f0);\n border-radius: var(--sp-radius-sm, 4px);\n background: var(--sp-color-surface-raised, #fff);\n color: var(--sp-color-text, #2e3440);\n font-size: var(--sp-font-sm, 13px);\n cursor: pointer;\n}\n.wf-billlist button:hover { border-color: var(--sp-color-primary, #5e81ac); color: var(--sp-color-primary, #5e81ac); }\n\n/* ── 手机:抬头收成两行、卡片贴边、动作条铺满(手指点得到) ───────────── */\n@media (max-width: 768px) {\n body { padding: 10px 12px 16px; }\n .wf { gap: 10px; }\n .wf-head { gap: 6px 8px; }\n .wf-head .labull-subject-title { font-size: var(--sp-font-md, 14px); }\n .wf-head .wf-muted { width: 100%; order: 4; }\n .wf-head #create { margin-left: auto; }\n .wf-steps { padding: 4px 8px; }\n /* 只有"输入框那几行"要定宽标签;只读的键值仍然贴着内容走 */\n .wf-kv.wf-field { grid-template-columns: 56px minmax(0, 1fr); }\n .wf-actions #actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }\n .wf-actions #actions .p-button { width: 100%; min-height: 40px; justify-content: center; }\n .wf-billlist { flex-direction: column; }\n .wf-billlist button { width: 100%; min-height: 40px; text-align: left; }\n}\n\n/* 更窄(≤480):节点那一圈交给主题缩,这里只把内边距再收一点 */\n@media (max-width: 480px) {\n body { padding: 8px 10px 14px; }\n .wf-kv.wf-field { grid-template-columns: 48px minmax(0, 1fr); gap: 6px 10px; }\n}\n'),
745
745
  ('app.js', '/* 报销流程的驱动 + 四个内容渲染器(**壳不变,只有中间那个内容槽随节点换**)。\n *\n * 🔴 壳(抬头 / 步骤条 / 动作条)永远是同一个:变的只有 `#slot` 里的内容,\n * 而且"换哪一块"由报文里的 `step.ui` 决定 —— 前端**不许**自己映射 status→UI。\n * 🔴 渲染器签名是 `(slot, payload)`:`payload` 就是后端那份报文\n * (`step` / `steps` / `capabilities` / `actions` / `timeline`)。\n * 🔴 这里**不画弹窗**:要人填意见时用 `magicBlock.dialog(...)`,由宿主在真视口里画\n * (块 iframe 的 `position: fixed` 基准是"这一块有多高",自己画的浮层会被摆到看不见的地方)。\n */\n;(function () {\n var api = magicBackendApi.ReimbursementBlock\n var current = null\n\n function esc(value) {\n return String(value == null ? \'\' : value).replace(/[&<>"]/g, function (ch) {\n return { \'&\': \'&amp;\', \'<\': \'&lt;\', \'>\': \'&gt;\', \'"\': \'&quot;\' }[ch]\n })\n }\n\n /** 一张卡片:`head` 是抬头(可带 `?` 右边的东西),`body` 是正文 HTML。 */\n function card(title, icon, body, extra) {\n return (\n \'<section class="labull-card wf-card">\' +\n \'<div class="labull-card__head wf-card-head">\' +\n \'<i class="pi \' + icon + \'"></i><h3 class="labull-card__title wf-card-title">\' + esc(title) + \'</h3>\' +\n (extra || \'\') +\n \'</div>\' +\n \'<div class="labull-card__body">\' + body + \'</div>\' +\n \'</section>\'\n )\n }\n\n /** 一行「标签 + 值」(只读)。 */\n function kv(label, value, extraClass) {\n return (\n \'<div class="wf-kv">\' +\n \'<span class="wf-kv-label">\' + esc(label) + \'</span>\' +\n \'<span class="wf-kv-value \' + (extraClass || \'\') + \'">\' + (value == null || value === \'\' ? \'—\' : esc(value)) + \'</span>\' +\n \'</div>\'\n )\n }\n\n /** 一行「标签 + 输入框」(可编辑的那几列,key 要进 `step.editable` 白名单)。 */\n function field(label, key, value) {\n return (\n \'<div class="wf-kv wf-field">\' +\n \'<span class="wf-kv-label"><label for="wf-\' + esc(key) + \'">\' + esc(label) + \'</label></span>\' +\n \'<span class="wf-kv-value">\' +\n \'<input class="p-inputtext" id="wf-\' + esc(key) + \'" data-field="\' + esc(key) + \'" value="\' + esc(value) + \'">\' +\n \'</span></div>\'\n )\n }\n\n /** 内容 key `bill`:草稿(可编辑 + 保存)与已退回(只读)**共用**这一块。 */\n function bill(slot, payload) {\n var editable = payload.step.editable.length > 0\n var body = editable\n ? field(\'事由\', \'title\', payload.bill.title) + field(\'金额\', \'amount\', payload.bill.amount)\n : kv(\'事由\', payload.bill.title) + kv(\'金额\', payload.bill.amount, \'wf-num\')\n body += \'<p class="wf-hint">\' + (editable\n ? \'改完点下面的「保存」:只保存、不推进(状态还是草稿)。\'\n : \'这张单已经结束,不能再改。\') + \'</p>\'\n slot.innerHTML = card(\'基本信息\', \'pi-info-circle\', body)\n }\n\n /** 内容 key `approve`:待审批(采购报销走这一块)。 */\n function approve(slot, payload) {\n var body =\n kv(\'申请人\', payload.bill.applicant) +\n kv(\'单据类型\', payload.bill.billType === \'accounting\' ? \'记账报销\' : \'采购报销\') +\n kv(\'金额\', payload.bill.amount, \'wf-num\') +\n \'<p class="wf-hint">核对发票与金额后点「通过」;退回要写一句意见(那一步声明了必填意见)。</p>\'\n slot.innerHTML = card(\'审批\', \'pi-check-circle\', body)\n }\n\n /** 内容 key `approve-zero`:同一个节点的另一个内容(`ui_by` 按单据类型分叉,记账报销零输入)。 */\n function approveZero(slot, payload) {\n var body =\n kv(\'单据类型\', \'记账报销\') +\n kv(\'金额\', payload.bill.amount, \'wf-num\') +\n \'<p class="wf-note">记账报销:金额由财务复核,这里<strong>不需要填任何东西</strong>。</p>\' +\n \'<p class="wf-hint">同一个「待审批」节点、两种单据类型是两块不同的内容 —— 这就是 ui + ui_by 的用处。</p>\'\n slot.innerHTML = card(\'审批(记账)\', \'pi-calculator\', body)\n }\n\n /** 内容 key `done`:已付款(终态)。 */\n function done(slot, payload) {\n slot.innerHTML = card(\'结果\', \'pi-check\', kv(\'状态\', payload.step.label) + kv(\'金额\', payload.bill.amount, \'wf-num\') +\n \'<p class="wf-hint">这张单已经处理完,不会再出现在任何人的待办里。</p>\')\n }\n\n window.Renderers = { bill: bill, approve: approve, approveZero: approveZero, done: done }\n\n // ──────────────────────────── 壳(数据 / 渲染 / 动作) ────────────────────────────\n\n function tip(text, bad) {\n var node = document.getElementById(\'tip\')\n node.textContent = text || \'\'\n node.className = \'wf-tip\' + (bad ? \' is-bad\' : \'\')\n }\n\n function paint(data) {\n current = data\n document.getElementById(\'no\').textContent = \'#\' + data.bill.no\n var status = document.getElementById(\'status\')\n status.hidden = false\n status.dataset.status = data.step.code\n status.textContent = data.step.label\n document.getElementById(\'handler\').textContent = data.bill.handler ? \'处理人:\' + data.bill.handler : \'\'\n MagicSteps.renderBar(document.getElementById(\'steps\'), data)\n var slot = document.getElementById(\'slot\')\n slot.innerHTML = \'\'\n MagicSteps.mount(slot, data)\n paintActions(data)\n }\n\n function paintActions(data) {\n var box = document.getElementById(\'actions\')\n box.innerHTML = \'\'\n data.actions.forEach(function (item) {\n if (!item.allowed) return\n var button = document.createElement(\'button\')\n button.type = \'button\'\n button.className = \'p-button p-button-sm\' + (item.kind === \'reject\' ? \' p-button-danger\' : \'\')\n button.textContent = item.label\n button.onclick = function () {\n act(item).catch(function (error) { tip(error.message, true) })\n }\n box.appendChild(button)\n })\n if (data.capabilities.hasForm) {\n var saveButton = document.createElement(\'button\')\n saveButton.type = \'button\'\n saveButton.className = \'p-button p-button-sm p-button-text\'\n saveButton.textContent = \'保存\'\n saveButton.onclick = function () {\n save().catch(function (error) { tip(error.message, true) })\n }\n box.appendChild(saveButton)\n }\n }\n\n /** 推进:要意见的先弹宿主那个窗(`magicBlock.dialog` 由**宿主页面**渲染,位置一定对)。 */\n async function act(item) {\n var params = { id: current.bill.id, action: item.action }\n if (item.needsComment) {\n var answer = await magicBlock.dialog({\n title: \'退回这张单\',\n body: \'退回必须写一句意见(流程图里那一步声明了 requires_comment)。\',\n fields: [{ name: \'comment\', label: \'意见\', type: \'textarea\', required: true }],\n buttons: [{ id: \'ok\', label: \'退回\', primary: true, danger: true }, { id: \'cancel\', label: \'再想想\' }]\n })\n if (answer.button !== \'ok\') return\n params.comment = answer.values.comment\n }\n var data = await api.act(params)\n if (data.applied === false) tip(\'这一下不算数(岗位不对):只留了一条痕,状态没有推进。\')\n else tip(\'\')\n await open(current.bill.id)\n }\n\n /** 「保存不推进」:只把 `step.editable` 那几列递上去(白名单在平台那一侧再判一次)。 */\n async function save() {\n var params = { id: current.bill.id }\n current.step.editable.forEach(function (name) {\n var input = document.querySelector(\'#slot [data-field="\' + name + \'"]\')\n if (input) params[name] = input.value\n })\n await api.save(params)\n tip(\'已保存(状态没变)\')\n await open(current.bill.id)\n }\n\n async function open(id) {\n paint(await api.detail({ id: id }))\n tip(\'\')\n }\n\n async function list() {\n var data = await api.list({})\n var box = document.getElementById(\'bills\')\n box.innerHTML = \'\'\n data.bills.forEach(function (row) {\n var button = document.createElement(\'button\')\n button.type = \'button\'\n button.textContent = row.no + \' · \' + row.title + \' · \' + row.status\n button.onclick = function () { open(row.id).catch(function (error) { tip(error.message, true) }) }\n box.appendChild(button)\n })\n if (data.bills.length) await open(data.bills[0].id)\n else {\n document.getElementById(\'steps\').innerHTML = \'\'\n document.getElementById(\'slot\').innerHTML =\n \'<section class="labull-card wf-card"><p class="wf-empty">还没有单子:点右上角「新建一张草稿单」,\' +\n \'就能看到「草稿 → 待审批 → 已付款 / 已退回」这条流程。</p></section>\'\n document.getElementById(\'actions\').innerHTML = \'\'\n document.getElementById(\'status\').hidden = true\n tip(\'\')\n }\n }\n\n document.getElementById(\'create\').onclick = function () {\n tip(\'\')\n api.create({}).then(function () { return list() }).catch(function (error) { tip(error.message, true) })\n }\n\n list().catch(function (error) { tip(\'读不出来:\' + error.message, true) })\n})()\n'),
746
746
  ('backend_open.py', '"""报销流程 —— 块后端(**薄薄一层**:每个路由一行转发,杂活全在平台)。\n\n🔴 它只是一份普通 Python(能 import 什么就 import 什么):`from canvasdemo.models import Reimbursement`\n 就是 Django 的写法。业务模型**不经过 `ctx`**。\n🔴 模块级只许 import / class / docstring(这一份是**入口**):所以流程表不在这里 ——\n 它住在兄弟文件 `flow.py` 里(那份不受入口这几条约束)。\n🔴 **块内文件之间不许互相引用**:所以这里不能 `from flow import FLOW`。要那条流程就\n `flow_of(\'reimbursement\')` —— 平台注册表按 `flow_type` 给,声明仍然只有 `flow.py` 那一份。\n🔴 **类里的每个方法都必须是路由**(带 `@MagicBackendAPI`、签名恰好 `(self, ctx, params)`):\n 所以"取那一行"这种小工具**不写成方法**,就地铺开(四行重复,比多一条判据便宜)。\n🔴 每个 handler 第一句就校验参数:保存与体检会拿空参数真跑一遍。\n"""\n\nfrom labull_framework.magic_block.sdk import MagicBackendAPI, errors\n\n\nclass ReimbursementBlock:\n """报销单的五个接口:列表 / 新建 / 详情 / 推进 / 保存。"""\n\n @MagicBackendAPI()\n def list(self, ctx, params):\n """最近 20 张单(谁申请的、现在到谁手上、什么状态)。"""\n from canvasdemo.models import Reimbursement\n\n rows = Reimbursement.objects.order_by(\'-id\')[:20]\n return {\n \'me\': ctx.user.name or ctx.user.id,\n \'bills\': [\n {\n \'id\': row.id,\n \'no\': row.no,\n \'title\': row.title,\n \'amount\': str(row.amount),\n \'status\': row.status,\n \'billType\': row.bill_type,\n \'handler\': str(row.current_handler or \'\'),\n }\n for row in rows\n ],\n }\n\n @MagicBackendAPI()\n def create(self, ctx, params):\n """新建一张草稿单(申请人 = 当前用户;**示例**才有的入口,真实业务多半在别的页面建单)。"""\n from canvasdemo.models import Reimbursement\n\n latest = Reimbursement.objects.order_by(\'-id\').first()\n number = (latest.id if latest else 0) + 1\n # 🔴 认人只认 `ctx.user`(它来自会话,不是请求参数);外键直接写 `_id` 就不必去取一次 User。\n row = Reimbursement.objects.create(\n no=f\'BX-{number:04d}\',\n title=str(params.get(\'title\') or \'差旅报销\'),\n amount=params.get(\'amount\') or 100,\n bill_type=str(params.get(\'billType\') or \'purchase\'),\n applicant_id=int(ctx.user.id),\n current_handler_id=int(ctx.user.id),\n )\n return {\'id\': row.id}\n\n @MagicBackendAPI()\n def detail(self, ctx, params):\n """详情 = 平台那份只读报文(`step` / 能力位 / 动作 / 时间轴 / 步骤条)+ 这一行的业务字段。"""\n from canvasdemo.models import Reimbursement\n from labull_framework.flow import flow_of, snapshot\n\n bill_id = params.get(\'id\')\n if not bill_id:\n raise errors.BadRequest(\'要给我一个 id\')\n row = Reimbursement.objects.filter(id=bill_id).first()\n if row is None:\n raise errors.NotFound(f\'没有这张报销单:{bill_id}\')\n return dict(\n snapshot(flow_of(\'reimbursement\'), row, ctx),\n bill={\n \'id\': row.id,\n \'no\': row.no,\n \'title\': row.title,\n \'amount\': str(row.amount),\n \'billType\': row.bill_type,\n \'applicant\': str(row.applicant or \'\'),\n \'handler\': str(row.current_handler or \'\'),\n },\n )\n\n @MagicBackendAPI()\n def act(self, ctx, params):\n """**推进状态的唯一口子**:一行转发(状态校验 / 权限 / 必填意见 / 日志 / 刷处理人全在平台)。"""\n from canvasdemo.models import Reimbursement\n from labull_framework.flow import advance, flow_of\n\n bill_id = params.get(\'id\')\n if not bill_id:\n raise errors.BadRequest(\'要给我一个 id\')\n action = str(params.get(\'action\') or \'\').strip()\n if not action:\n raise errors.BadRequest(\'要告诉我要走哪个动作(报文里的 actions[].action)\')\n row = Reimbursement.objects.filter(id=bill_id).first()\n if row is None:\n raise errors.NotFound(f\'没有这张报销单:{bill_id}\')\n return advance(flow_of(\'reimbursement\'), row, action, params, ctx)\n\n @MagicBackendAPI()\n def save(self, ctx, params):\n """「保存不推进」:只写 `step.editable` 那几列(白名单由平台判),状态一个字都不动。"""\n from canvasdemo.models import Reimbursement\n from labull_framework.flow import flow_of, save\n\n bill_id = params.get(\'id\')\n if not bill_id:\n raise errors.BadRequest(\'要给我一个 id\')\n row = Reimbursement.objects.filter(id=bill_id).first()\n if row is None:\n raise errors.NotFound(f\'没有这张报销单:{bill_id}\')\n return save(flow_of(\'reimbursement\'), row, params, ctx)\n'),
747
747
  ('flow.js', '/* 节点内容登记表(**薄薄一层**:只放映射与挂载,渲染器实现在 app.js 里)。\n *\n * 🔴 `MagicSteps` 是**平台注进这个 iframe** 的(与 `magicBlock` / `magicBackendApi` 同一座桥):\n * 块里没有模块系统(不能 import / export),所以能力都挂在全局上。\n * 🔴 判据是**双向**的(保存时就会被拦):\n * · `flow.py` 里声明的每个 `ui`,这里不登记 ⇒ 那个节点打开就是**白屏**(D16);\n * · 这里登记的每个 `ui`,`flow.py` 里没有节点用到 ⇒ **死代码**(D17)。\n * 两边靠寄存器里那个 **ui 字符串字面量**对起来(所以 key 不要拼出来)。\n * 🔴 `ui` 是"内容型"不是"节点型":`bill` 同时给「草稿」与「已退回」两个节点用(它们长一样)。\n */\nMagicSteps.register(\'bill\', function (slot, payload) {\n Renderers.bill(slot, payload)\n})\nMagicSteps.register(\'approve\', function (slot, payload) {\n Renderers.approve(slot, payload)\n})\n/* `ui_by` 分叉出来的那一块内容:同一个「待审批」节点,记账报销走这一个。 */\nMagicSteps.register(\'approve-zero\', function (slot, payload) {\n Renderers.approveZero(slot, payload)\n})\nMagicSteps.register(\'done\', function (slot, payload) {\n Renderers.done(slot, payload)\n})\n'),
748
- ('flow.py', '"""报销流程 —— **这条流程的唯一权威**:流程表 + 业务钩子 + 推进路由都在这里。\n\n🔴 `flow.py` 是**兄弟文件,不是入口**:\n · 模块级赋值(那张流程表)与模块级函数(业务钩子)都是它的正常写法 ——\n `backend_open.py` 那套"模块级只许 import / class"的约束**不适用于它**;\n · 它可以挂 `@MagicBackendAPI` 路由(路由本来就是整包合扫);\n · 平台**只认 `flow.py` 一个名字、每块最多一个**,而且每块**恰好一条**流程。\n🔴 这个文件的写法:`FLOW = register(Flow(...))`。`Flow(...)` 构造时就判 D1–D13 / D18 / D19 / D21–D23,\n 不合当场抛(中文原话),保存那一刻就拦下来 —— 不用等到某次点按钮才 500。\n🔴 本块的后端**不 import 这个文件**(块内文件之间不许互相引用):它用\n `flow_of(\'reimbursement\')` 从平台注册表按 `flow_type` 取 —— 声明仍然只有这一份。\n"""\n\nfrom labull_framework.flow import Flow, Move, Reject, Step, register\n\n\n# ──────────────────────────── 业务钩子(`f(row, params, ctx)`) ────────────────────────────\n#\n# 🔴 要拒绝就 `raise Reject(\'中文原话\')`:平台把它转成 **400 + 这句原话**(不是 500、不是"操作失败")。\n# 钩子是**业务规则**(金额超限、库存不够),人话就是它唯一的结论。\n# 🔴 钩子里该 import 什么就 import 什么(业务模型的 import 全部留在块里,平台不认识任何业务模型)。\n\n\ndef check_amount(row, params, ctx):\n """提交前校验金额(演示"钩子拒绝 ⇒ 400 中文原话")。"""\n if row.amount is None or row.amount <= 0:\n raise Reject(\'金额必须大于 0 才能提交\')\n\n\ndef keep_title(row, params, ctx):\n """草稿的「保存不推进」钩子:只做业务校验,**状态一个字都不动**。"""\n if not str(row.title or \'\').strip():\n raise Reject(\'事由不能为空\')\n\n\n# ──────────────────────────── 流程表(**唯一一行事实**) ────────────────────────────\nFLOW = register(\n Flow(\n # `type` 是**全局键**:同一套流程在别的块里复制一份时,两份都用它 —— 跨块比对就是按它配对的。\n # 注意它**不是**清单里那个 `type: workflow`(那一格回答"哪一类块")。\n type=\'reimbursement\',\n entity=\'reimbursement\',\n # 状态列:只认 status / finance_status / workflow_status(各表不同,写错会被 D4 拦下)。\n status_field=\'status\',\n initial=\'draft\',\n terminal=(\'paid\', \'rejected\'),\n # 内容分叉的列:`Step.ui_by` 的键就是这一列的**真实取值**。\n view_field=\'bill_type\',\n # 当前处理人那一列(待办靠它一条 JOIN;终态由平台清空)。\n handler_field=\'current_handler\',\n # 本流程自己的业务岗位:码 → 认人用的 Django 组名(与内置 POSITIONS 合并,内置码不必重写)。\n # 换一套岗位码的项目就靠这一格,不去改平台。\n positions={\'CASHIER\': \'出纳\'},\n doc=\'报销单:草稿 → 待审批 → 已付款 / 已退回\',\n steps=(\n # ① 草稿:申请人自己办;有「保存不推进」(form + editable 必须一起写)。\n Step(\n \'draft\',\n \'草稿\',\n candidates=\'applicant\',\n ui=\'bill\',\n form=keep_title,\n editable=(\'title\', \'amount\'),\n moves=(\n Move(\'submit\', \'提交\', to=\'pending\', kind=\'submit\', hook=check_amount),\n ),\n ),\n # ② 待审批:申请人 / 财务 / 出纳都能办;内容与**动作集**都按单据类型分叉。\n Step(\n \'pending\',\n \'待审批\',\n candidates=\'applicant_or_roles:ACCOUNTANT,CASHIER\',\n ui=\'approve\',\n ui_by={\'accounting\': \'approve-zero\'},\n # 记账报销只有「通过 / 退回」;标准报销多一个「撤回重填」。\n # 列就是 view_field(bill_type),**没命中 ⇒ 全部动作**(与 ui_by 的兜底一致)。\n moves_by={\'accounting\': (\'approve\', \'reject\')},\n moves=(\n Move(\'approve\', \'通过\', to=\'paid\', kind=\'approve\'),\n Move(\'reject\', \'退回\', to=\'rejected\', kind=\'reject\', requires_comment=True),\n Move(\'withdraw\', \'撤回重填\', to=\'draft\', kind=\'revoke\'),\n ),\n ),\n # ③ 终态:不挂动作、candidates 必须是空串(D8 / D11)。\n Step(\'paid\', \'已付款\', terminal=True, ui=\'done\'),\n # ④ 又一个终态,**复用 `bill` 那个内容 key**:多个节点共用一个 ui 是正解\n # ("内容不同"才是分叉的理由,不是"节点不同")。\n Step(\'rejected\', \'已退回\', terminal=True, ui=\'bill\'),\n ),\n )\n)\n'),
748
+ ('flow.py', '"""报销流程 —— **这条流程的唯一权威**:流程表 + 业务钩子 + 推进路由都在这里。\n\n🔴 `flow.py` 是**兄弟文件,不是入口**:\n · 模块级赋值(那张流程表)与模块级函数(业务钩子)都是它的正常写法 ——\n `backend_open.py` 那套"模块级只许 import / class"的约束**不适用于它**;\n · 它可以挂 `@MagicBackendAPI` 路由(路由本来就是整包合扫);\n · 平台**只认 `flow.py` 一个名字、每块最多一个**,而且每块**恰好一条**流程。\n🔴 这个文件的写法:`FLOW = register(Flow(...))`。`Flow(...)` 构造时就判 D1–D13 / D18 / D19 / D21–D25,\n 不合当场抛(中文原话),保存那一刻就拦下来 —— 不用等到某次点按钮才 500。\n🔴 本块的后端**不 import 这个文件**(块内文件之间不许互相引用):它用\n `flow_of(\'reimbursement\')` 从平台注册表按 `flow_type` 取 —— 声明仍然只有这一份。\n"""\n\nfrom labull_framework.flow import Flow, Move, Reject, Step, register\n\n\n# ──────────────────────────── 业务钩子(`f(row, params, ctx)`) ────────────────────────────\n#\n# 🔴 要拒绝就 `raise Reject(\'中文原话\')`:平台把它转成 **400 + 这句原话**(不是 500、不是"操作失败")。\n# 钩子是**业务规则**(金额超限、库存不够),人话就是它唯一的结论。\n# 🔴 钩子里该 import 什么就 import 什么(业务模型的 import 全部留在块里,平台不认识任何业务模型)。\n\n\ndef check_amount(row, params, ctx):\n """提交前校验金额(演示"钩子拒绝 ⇒ 400 中文原话")。"""\n if row.amount is None or row.amount <= 0:\n raise Reject(\'金额必须大于 0 才能提交\')\n\n\ndef keep_title(row, params, ctx):\n """草稿的「保存不推进」钩子:只做业务校验,**状态一个字都不动**。"""\n if not str(row.title or \'\').strip():\n raise Reject(\'事由不能为空\')\n\n\n# ──────────────────────────── 流程表(**唯一一行事实**) ────────────────────────────\nFLOW = register(\n Flow(\n # `type` 是**全局键**:同一套流程在别的块里复制一份时,两份都用它 —— 跨块比对就是按它配对的。\n # 注意它**不是**清单里那个 `type: workflow`(那一格回答"哪一类块")。\n type=\'reimbursement\',\n entity=\'reimbursement\',\n # 状态列:只认 status / finance_status / workflow_status(各表不同,写错会被 D4 拦下)。\n status_field=\'status\',\n initial=\'draft\',\n terminal=(\'paid\', \'rejected\'),\n # 内容分叉的列:`Step.ui_by` 的键就是这一列的**真实取值**。\n view_field=\'bill_type\',\n # 当前处理人那一列(待办靠它一条 JOIN;终态由平台清空)。\n handler_field=\'current_handler\',\n # 本流程自己的业务岗位:码 → 认人用的 Django 组名(与内置 POSITIONS 合并,内置码不必重写)。\n # 换一套岗位码的项目就靠这一格,不去改平台。\n positions={\'CASHIER\': \'出纳\'},\n doc=\'报销单:草稿 → 待审批 → 已付款 / 已退回\',\n steps=(\n # ① 草稿:申请人自己办;有「保存不推进」(form + editable 必须一起写)。\n Step(\n \'draft\',\n \'草稿\',\n candidates=\'applicant\',\n ui=\'bill\',\n form=keep_title,\n editable=(\'title\', \'amount\'),\n moves=(\n Move(\'submit\', \'提交\', to=\'pending\', kind=\'submit\', hook=check_amount),\n ),\n ),\n # ② 待审批:申请人 / 财务 / 出纳都能办;内容与**动作集**都按单据类型分叉。\n Step(\n \'pending\',\n \'待审批\',\n candidates=\'applicant_or_roles:ACCOUNTANT,CASHIER\',\n # 「谁能点」与「待办挂给谁」是两件事:财务 / 出纳 / 申请人**都能点**,\n # 但「当前处理人」只挂给单据上的申请人(`biz:applicant` 是单据那一列)。\n assignees=\'biz:applicant\',\n ui=\'approve\',\n ui_by={\'accounting\': \'approve-zero\'},\n # 记账报销只有「通过 / 退回」;标准报销多一个「撤回重填」。\n # 列就是 view_field(bill_type),**没命中 ⇒ 全部动作**(与 ui_by 的兜底一致)。\n moves_by={\'accounting\': (\'approve\', \'reject\')},\n moves=(\n Move(\'approve\', \'通过\', to=\'paid\', kind=\'approve\'),\n Move(\'reject\', \'退回\', to=\'rejected\', kind=\'reject\', requires_comment=True),\n Move(\'withdraw\', \'撤回重填\', to=\'draft\', kind=\'revoke\'),\n ),\n ),\n # ③ 终态:不挂动作、candidates 必须是空串(D8 / D11)。\n Step(\'paid\', \'已付款\', terminal=True, ui=\'done\'),\n # ④ 又一个终态,**复用 `bill` 那个内容 key**:多个节点共用一个 ui 是正解\n # ("内容不同"才是分叉的理由,不是"节点不同")。\n Step(\'rejected\', \'已退回\', terminal=True, ui=\'bill\'),\n ),\n )\n)\n'),
749
749
  ('index.open.html', '<!doctype html>\n<!-- 报销流程(块类型 workflow 的样例)。\n 🔴 壳**永远不变**:抬头(单号 / 状态 / 处理人 / 新建)+ 流程步骤条 + 内容槽 + 动作条;\n 变的只有中间那个内容槽 `#slot`,换哪一块由报文里的 `step.ui` 决定(前端不做 status→UI 映射)。\n 🔴 块跑在**不透明源**的沙箱 iframe 里:当前用户 / 弹窗 / 调自己的后端一律走宿主注入的桥\n (`magicBlock` / `magicBackendApi` / `MagicSteps`)。\n 🔴 样式只用主题给的东西:`.labull-card` / `.labull-subject-title` / `.labull-steps` 与 `--sp-*` token;\n 块自己那份 `app.css` 只写"这一块的排版",不发明色值。 -->\n<html lang="zh">\n<head>\n <meta charset="utf-8">\n <meta name="viewport" content="width=device-width, initial-scale=1">\n <title>报销流程</title>\n <link rel="stylesheet" href="app.css">\n</head>\n<body>\n <div class="wf">\n <!-- 抬头:单号 / 状态 / 处理人 / 新建 -->\n <header class="wf-head">\n <h2 class="labull-subject-title"><i class="pi pi-file-edit"></i> 报销单 <span class="wf-no" id="no">—</span></h2>\n <span class="wf-pill" id="status" hidden></span>\n <span class="wf-muted" id="handler"></span>\n <button class="p-button p-button-sm p-button-outlined" id="create" type="button">\n <i class="pi pi-plus"></i> 新建一张草稿单\n </button>\n </header>\n\n <!-- 步骤条:由 MagicSteps.renderBar 按报文的 steps 画(桌面横向 / 手机自动纵向) -->\n <section class="labull-card labull-card--tight wf-steps">\n <div id="steps"></div>\n </section>\n\n <!-- 内容槽:渲染器按 payload.step.ui 往这里画一张卡片 -->\n <div id="slot" class="wf-slot"></div>\n\n <!-- 动作条:按钮由报文的能力位算(allowed / needsComment) -->\n <footer class="wf-actions">\n <div id="actions"></div>\n <p class="wf-tip" id="tip"></p>\n </footer>\n\n <details class="wf-bills">\n <summary>切换单子</summary>\n <div id="bills" class="wf-billlist"></div>\n </details>\n </div>\n\n <script src="app.js"></script>\n <script src="flow.js"></script>\n</body>\n</html>\n'),
750
750
  ('magic-block.open.yaml', '# 报销流程 —— 流程块(`type: workflow`)的样例\n#\n# 🔴 这个文件夹是一个**完整的块**(与你开发目录里 `workspace/<可读名>-<块 uuid>/` 同构):\n# 想拿它当起点就把这一整个文件夹搬进 `workspace/`(再把 uuid 换成你自己的)。\n#\n# 🔴 前四个键必填:id / module / name / brief\n# 🔴 `type: workflow` 是**块类型**(枚举,缺省 plain 一行都不写):\n# 声明了它就必须有一份 `flow.py`(平台**不会**替你造一个 —— 注入会让"缺文件"这个错误永远不出现)。\n# 它只回答"哪一类块",**不回答"哪条流程"**:`flow_type` 住在 `flow.py` 里。\n# 🔴 `requires` 同样可选:这个块用到了 `canvasdemo.models` 里的业务表,所以声明 models 那一行。\nid: 2f7c1a44-6b1e-4f3d-9a10-5c8e7d2b9043\nmodule: canvasdemo\nname: 报销流程\nbrief: 一张报销单从草稿到付款:流程表写在 flow.py 里,平台负责推进、留痕与报文。\ntype: workflow\nrequires:\n models:\n - canvasdemo.models\n # surface: 模型的**行为表面**:方法 / 属性(键是完整类路径,值是一串名字)\n # core.models.WorkflowModel: [log_transition, timeline]\n # packages: pip 包:写**分发名**(`Pillow` 而不是 `PIL`),只写项目依赖里没有的那些\n # - wechatpy\n # settings: 项目 settings 里读的键:**只写键名,不写值**(值因环境而异,不是判据)\n # - MEDIA_URL\n # config: 环境变量键:带 `BLOCK_` 前缀的那些,**剥掉前缀**写\n # - OCR_KEY\n # routes: 跨块跳转的目标地址(契约,不是"随便跳"):那个页面改名时,这一步会报出来\n # - /finance/prices\n # opaque: 豁免:这些模块**只声明、不检查它内部的字段与属性**(越界的书面出口,不是许可)\n # - labull_framework.models.User\n'),
751
751
  ('requires.open.yaml', '# requires.open.yaml —— 这个块向外依赖什么(**草稿**:正文进清单,注释只是建议)\n#\n# · 下面 `requires:` 那一块的**正文**就是清单里现在那份声明 —— 改它、读回,它就生效\n# (只合并清单的 `requires` 键,`id` / `module` / `name` / `brief` 一个字都不动)。\n# · `#` 开头的那些行里,**除了正文那一块**都是给你抄的:\n# ① 那一项你还没声明 ⇒ 那一项连同它的写法示例(与清单模板逐字同一份)都是注释,\n# 整项要用就把它的 `#` 一起去掉;\n# ② 标着"扫到的"的行 = **扫你的代码**得出来的建议:要用就把那一行与它上面那行的 `#` 去掉。\n# **没扫到的不代表不需要** —— 拼出来的路径、`getattr` 取的东西扫不出来,拿不准的照样要自己补。\n# · 七个键都摆在这儿了(默认留空):一眼看得见还能声明什么,别漏了 `opaque`。\n# · 依赖声明(**可选**):这个块**向外**依赖什么。不写 = 不依赖任何外部东西(只在包内写页面的块就该这样)。\n# · 🔴 判据只认两件事:**声明的东西真实存在**、**代码里用了的必须声明**;声明**不授予任何能力**(缺了它不是"用不了",而是"用了没报备")。\n# · 🔴 **不用声明**的:块包内的文件、Python 标准库、平台 SDK、主题契约(`p-*` / `--sp-*`)、桥与 `ctx`(`magicBlock.*` / `ctx.user` / `ctx.block`)。\n#\n# 现在的默认档是 `LABULL_REQUIRES=error`:判据照跑,**用了却没声明就拦住保存**(临时放宽成 `LABULL_REQUIRES=warn` 只提示、或者 `off` 整段不跑 —— 那两档都要显式写)。\n#\nrequires:\n models:\n - canvasdemo.models\n # surface: 模型的**行为表面**:方法 / 属性(键是完整类路径,值是一串名字)\n # core.models.WorkflowModel: [log_transition, timeline]\n # packages: pip 包:写**分发名**(`Pillow` 而不是 `PIL`),只写项目依赖里没有的那些\n # - wechatpy\n # settings: 项目 settings 里读的键:**只写键名,不写值**(值因环境而异,不是判据)\n # - MEDIA_URL\n # config: 环境变量键:带 `BLOCK_` 前缀的那些,**剥掉前缀**写\n # - OCR_KEY\n # routes: 跨块跳转的目标地址(契约,不是"随便跳"):那个页面改名时,这一步会报出来\n # - /finance/prices\n # opaque: 豁免:这些模块**只声明、不检查它内部的字段与属性**(越界的书面出口,不是许可)\n # - labull_framework.models.User\n # 注意:平台**不猜**这一项 —— 扫到平台自己的模块只提示在这里,\n # 要不要写成豁免、怎么写,都得你自己判断(写了就是"我知道我在越界")。\n # - labull_framework.flow ← 扫到的(要用就把这一行与上面那行的 # 去掉)\n'),
@@ -1512,7 +1512,7 @@ def dev_doc(blocks: int) -> str:
1512
1512
  | 值 | 含义 |
1513
1513
  |---|---|
1514
1514
  | (不写) | 普通块 —— 只有通用校验 |
1515
- | `workflow` | **流程块**:必须有一份合法的 `flow.py`,另外还要过 D14–D23 那一段针对性判据 |
1515
+ | `workflow` | **流程块**:必须有一份合法的 `flow.py`,另外还要过 D14–D25 那一段针对性判据 |
1516
1516
 
1517
1517
  🔴 **不要写成布尔**(`workflow: true`):再加一种类型就得多一个布尔,然后 `if workflow and report` 满天飞。
1518
1518
  🔴 清单这一格只回答"哪一类块",**不回答"哪条流程"** —— 流程声明住在 `flow.py` 里(两处写同一个事实就是新造漂移)。
@@ -1543,6 +1543,10 @@ return advance(flow_of('reimbursement'), row, params['action'], params, ctx)
1543
1543
  (岗位白名单 = **内置 ∪ `Flow.positions`**:换一套岗位码就在 `flow.py` 里声明,不必改平台)。
1544
1544
  - 同一个状态可以按单据类型给**不同动作集**(`Step.moves_by`,列是 `view_field`);推进**之后**的副作用
1545
1545
  用 `Move.after`;处理人是一组人时 `Flow(handler_kind='m2m')`;项目已有自己的日志形状时用 `Flow.log_entry`。
1546
+ - 🔴 **「谁能点」与「待办挂给谁」是两件事**:`candidates` 是闸门,`Step.assignees` 是「当前处理人」那一列
1547
+ (不写 = 跟 `candidates` 一样)。别为了"让某个队列看到待办"去放宽闸门。
1548
+ - 只存在于**历史数据**里的终态(没有任何动作能写到它)用 `Step(..., terminal=True, legacy=True)` 登记,
1549
+ 它不参与 D7 的可达性,存量行打开时能解析出这个状态。
1546
1550
  - 前端只认报文里的 `step.ui`(**不许**自己把 status 映射成界面):
1547
1551
 
1548
1552
  ```js
@@ -1552,7 +1556,7 @@ MagicSteps.renderBar(document.getElementById('steps'), payload) // 步骤条
1552
1556
  ```
1553
1557
 
1554
1558
  🔴 一个 `ui` 被**多个节点共用**是正解(内容真的不同才该分叉:用 `ui_by` 按列值分叉)。
1555
- 写流程的完整契约(D1–D23 逐条、白名单、跨块一致性、三档开关 `LABULL_BLOCK_TYPE`)见平台文档与
1559
+ 写流程的完整契约(D1–D25 逐条、白名单、跨块一致性、三档开关 `LABULL_BLOCK_TYPE`)见平台文档与
1556
1560
  `example/workflow/` 里那个「报销流程」样例;`workspace/workflow/README.md` 是同一份写法说明。
1557
1561
 
1558
1562
  ### 3. "权限"这个词在这个项目里指哪两类
@@ -10,15 +10,17 @@
10
10
  | D16 | `flow.py` 用到的每个 `ui`,前端必须登记过 | 本文件(扫块包里的 `flow.js`) |
11
11
  | D17 | 前端登记的每个 `ui`,`flow.py` 必须用到 | 同上(**双向**是重点:缺了白屏、多了死代码) |
12
12
  | D18 / D19 | `form` ⇔ `editable`;`ui_by` 的键是列值、值是合法 `ui`;用了 `ui_by` / `moves_by` 必须写 `view_field` | `flow.py`(构造时判,与 D1–D13 一起) |
13
- | D20 | 同一 `flow_type` 的副本之间 `ui` / `form` / `positions` / `handler_kind` / `staff_bypass` / `moves_by` 一致 | 本文件的 `cross()`(跨块,见 `blocktypes.cross_check`) |
13
+ | D20 | 同一 `flow_type` 的副本之间 `ui` / `form` / `positions` / `handler_kind` / `staff_bypass` / `moves_by` / `assignees` / `legacy` 一致 | 本文件的 `cross()`(跨块,见 `blocktypes.cross_check`) |
14
14
  | D21 | `moves_by` 的动作码必须是本节点声明过的;`view_field` 不许出现在该步的 `editable` 里 | `flow.py` |
15
15
  | D22 | `handler_kind` 只认 `fk` / `m2m`(m2m 必须有 `handler_field`) | `flow.py` |
16
16
  | D23 | `Flow.positions` 的码合法、组名非空 | `flow.py` |
17
+ | D24 | `assignees` 非空时 `candidates` 也必须非空(不能只有待办、没有闸门) | `flow.py` |
18
+ | D25 | `legacy=True` 必须是终态(历史状态没有动作能写到它) | `flow.py` |
17
19
 
18
- 🔴 **为什么 D1–D13 / D18 / D19 / D21–D23 不在这个文件里重写一遍**:判据只有一处 —— 定义期判据在 `flow.py` 的构造器里,
20
+ 🔴 **为什么 D1–D13 / D18 / D19 / D21–D25 不在这个文件里重写一遍**:判据只有一处 —— 定义期判据在 `flow.py` 的构造器里,
19
21
  本文件只是"把它跑一遍、把报错原文递上来"。两处各写一份的下场是"模块能导入、保存说合法"。
20
22
  🔴 **为什么 D14/D16 要起子进程**:`flow.py` 的写法是**模块级赋值**(`FLOW = register(Flow(...))`),
21
- 不执行它就读不出"注册了几条"。而 `Flow(...)` 本身会 raise(D1–D13 / D18 / D19 / D21–D23),所以"跑一遍"同时就是
23
+ 不执行它就读不出"注册了几条"。而 `Flow(...)` 本身会 raise(D1–D13 / D18 / D19 / D21–D25),所以"跑一遍"同时就是
22
24
  "定义期判据真的跑过"。子进程与试加载同一套递数据的规矩(`subproc.py`)。
23
25
  🔴 **D16/D17 是双向的**(这是本需求的要点):`flow.py` 声明了一个前端没实现的内容 ⇒ 打开就白屏;
24
26
  前端实现了一个没有节点用的内容 ⇒ 死代码。两边都在保存时被拦 ⇒ 两处不可能悄悄分叉。
@@ -174,7 +176,7 @@ def _check(files: dict[str, str], _manifest: dict[str, Any]) -> list[tuple[str,
174
176
  ]
175
177
  specs, error = _run(files)
176
178
  if error:
177
- return [('workflow', f'{prefix}{FLOW_FILE} 跑不起来:{error}(定义期判据 D1–D13 / D18 / D19 / D21–D23 只要不合就当场拒绝)')]
179
+ return [('workflow', f'{prefix}{FLOW_FILE} 跑不起来:{error}(定义期判据 D1–D13 / D18 / D19 / D21–D25 只要不合就当场拒绝)')]
178
180
  # D14:恰好一条
179
181
  if len(specs) == 0:
180
182
  return [
@@ -252,7 +254,7 @@ def cross(items: list[tuple[str, dict[str, Any]]]) -> list[tuple[str, str]]:
252
254
 
253
255
  🔴 第一个是**权威**(按块标签排序,所以"谁是权威"与加载顺序无关)。
254
256
  🔴 比的是流程骨架:状态码 / 动作码 / `to` / `kind` / `candidates` / 必填意见 / 解绑 /
255
- `confirm_roles` / 中文名 / `ui` / `ui_by` / `moves_by` / `form` / `editable`,以及
257
+ `confirm_roles` / 中文名 / `ui` / `ui_by` / `moves_by` / `assignees` / `legacy` / `form` / `editable`,以及
256
258
  `positions`(**合并后的**白名单)/ `handler_kind` / `staff_bypass`。**不比**注释、docstring、钩子对象。
257
259
  🔴 报错要逐条列出差异(哪个块、哪个字段、各自的值)并给两种改法:改一致,或者**统一成一份**
258
260
  (派生机制刻意不做:那一份差异一旦允许"显式声明",就又多了一处要说两遍的事实)。
@@ -314,6 +316,7 @@ def _diff(here: dict[str, Any], authority: dict[str, Any]) -> list[str]:
314
316
  ('ui', 'ui'),
315
317
  ('ui_by', 'ui_by'),
316
318
  ('moves_by', 'moves_by'),
319
+ ('legacy', 'legacy'),
317
320
  ('form', 'form'),
318
321
  ('editable', 'editable'),
319
322
  ):
@@ -321,6 +324,12 @@ def _diff(here: dict[str, Any], authority: dict[str, Any]) -> list[str]:
321
324
  diffs.append(
322
325
  f'状态 {code} 的{label}这里是 {mine.get(key)!r},权威副本里是 {base.get(key)!r}'
323
326
  )
327
+ # `assignees` 不写 = 跟 `candidates` 一样 ⇒ 比的是**生效的那条规则**:一份副本显式写成
328
+ # 与 candidates 相同的值、另一份不写,行为完全一样,不算不一致(与 positions 同一个道理)。
329
+ here_todo = mine.get('assignees') or mine.get('candidates')
330
+ base_todo = base.get('assignees') or base.get('candidates')
331
+ if here_todo != base_todo:
332
+ diffs.append(f'状态 {code} 的 assignees 这里是 {here_todo!r},权威副本里是 {base_todo!r}')
324
333
  diffs.extend(_diff_moves(code, mine, base))
325
334
  return diffs
326
335
 
@@ -364,7 +373,7 @@ _SEED = (
364
373
  '# 🔴 流程表的唯一权威是本目录的 flow.py(`FLOW = register(Flow(...))`)。\n'
365
374
  '# 这一份不进清单、不影响保存、也**不会被导出覆盖**:它只是给你(和同事)对照用的。\n'
366
375
  '# 🔴 想改流程:改 flow.py 里的 Flow / Step / Move,然后回管理台保存 ——\n'
367
- '# 定义期判据(D1–D13 / D18 / D19 / D21–D23)会在保存那一刻把写错的地方当场说出来。\n'
376
+ '# 定义期判据(D1–D13 / D18 / D19 / D21–D25)会在保存那一刻把写错的地方当场说出来。\n'
368
377
  '#\n'
369
378
  '# 一条流程在这几处各有各的一份话,别混:\n'
370
379
  '# · flow.py 状态表 / 动作 / 谁能办 / 每个节点的内容 key(**唯一权威**)\n'
@@ -373,8 +382,9 @@ _SEED = (
373
382
  '#\n'
374
383
  '# 每个节点要在 flow.py 里说清四件事:ui(这一屏长什么样)、ui_by(按列值分叉)、\n'
375
384
  '# form(「保存不推进」的钩子)、editable(这一步能改哪几列)。\n'
376
- '# 还能按列值给不同动作集(Step.moves_by)、在推进之后做事(Move.after)、\n'
377
- '# 声明本流程自己的岗位码(Flow.positions)、换处理人列的类型(Flow.handler_kind)。\n'
385
+ '# 还能按列值给不同动作集(Step.moves_by)、把"谁能点"与"待办挂给谁"分开(Step.assignees)、\n'
386
+ '# 在推进之后做事(Move.after)、声明本流程自己的岗位码(Flow.positions)、\n'
387
+ '# 换处理人列的类型(Flow.handler_kind)、登记只存在于历史数据里的终态(Step(legacy=True))。\n'
378
388
  '# 报文里的 step 就是 {code, label, ui, editable, hasForm} —— 前端按 ui 查表渲染,\n'
379
389
  '# **不许**自己把 status 映射成界面。\n'
380
390
  )
@@ -226,7 +226,7 @@ def check_block(files: dict[str, str]) -> list[tuple[str, str]]:
226
226
  f'(那一半要一个真的 Django 环境)。'
227
227
  )
228
228
  # 🔴 块类型那一层(**第四段判据**,与上面三段并列):清单里 `type:` 声明了哪一种块,
229
- # 就把那一种多出来的判据跑一遍(流程块就是 D14–D20 那一段)。
229
+ # 就把那一种多出来的判据跑一遍(流程块就是 D14–D25 那一段)。
230
230
  # · `issue.kind` 用**类型名**(注册表里那一格),管理台按它分组显示(见 `health._KINDS`);
231
231
  # · 通用那几段**一行都不许掺类型名**(V18 扫源码盯着这一条)—— 加一种类型只加一个模块;
232
232
  # · `LABULL_BLOCK_TYPE=off` 只关这一段,上面三段照跑(V7)。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 1.4.0
3
+ Version: 1.4.1
4
4
  Summary: Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks
5
5
  Author: Labull Framework
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "labull-framework"
7
- version = "1.4.0"
7
+ version = "1.4.1"
8
8
  description = "Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks"
9
9
  readme = "README-pypi.md"
10
10
  requires-python = ">=3.10"