@robsun/create-keystone-app 0.2.15 → 0.4.0

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 (96) hide show
  1. package/README.md +46 -44
  2. package/dist/create-keystone-app.js +347 -10
  3. package/dist/create-module.js +1217 -1187
  4. package/package.json +1 -1
  5. package/template/.claude/skills/keystone-implement/SKILL.md +113 -0
  6. package/template/.claude/skills/keystone-implement/references/CHECKLIST.md +91 -0
  7. package/template/.claude/skills/keystone-implement/references/PATTERNS.md +1088 -0
  8. package/template/.claude/skills/keystone-implement/references/SCHEMA.md +135 -0
  9. package/template/.claude/skills/keystone-implement/references/TESTING.md +231 -0
  10. package/template/.claude/skills/keystone-requirements/SKILL.md +296 -0
  11. package/template/.claude/skills/keystone-requirements/references/CONFIRM_TEMPLATE.md +170 -0
  12. package/template/.claude/skills/keystone-requirements/references/SCHEMA.md +135 -0
  13. package/template/.eslintrc.js +3 -0
  14. package/template/.github/workflows/ci.yml +30 -0
  15. package/template/.github/workflows/release.yml +32 -0
  16. package/template/.golangci.yml +11 -0
  17. package/template/README.md +82 -81
  18. package/template/apps/server/README.md +8 -0
  19. package/template/apps/server/cmd/server/main.go +27 -185
  20. package/template/apps/server/config.example.yaml +31 -1
  21. package/template/apps/server/config.yaml +31 -1
  22. package/template/apps/server/go.mod +60 -18
  23. package/template/apps/server/go.sum +183 -31
  24. package/template/apps/server/internal/frontend/embed.go +3 -8
  25. package/template/apps/server/internal/modules/example/README.md +18 -0
  26. package/template/apps/server/internal/modules/example/api/handler/handler_test.go +9 -0
  27. package/template/apps/server/internal/modules/example/api/handler/item_handler.go +468 -165
  28. package/template/apps/server/internal/modules/example/bootstrap/seeds/item.go +217 -8
  29. package/template/apps/server/internal/modules/example/domain/models/item.go +40 -7
  30. package/template/apps/server/internal/modules/example/domain/service/approval_callback.go +68 -0
  31. package/template/apps/server/internal/modules/example/domain/service/approval_schema.go +41 -0
  32. package/template/apps/server/internal/modules/example/domain/service/errors.go +20 -22
  33. package/template/apps/server/internal/modules/example/domain/service/item_service.go +267 -7
  34. package/template/apps/server/internal/modules/example/domain/service/item_service_test.go +281 -0
  35. package/template/apps/server/internal/modules/example/i18n/keys.go +32 -20
  36. package/template/apps/server/internal/modules/example/i18n/locales/en-US.json +30 -18
  37. package/template/apps/server/internal/modules/example/i18n/locales/zh-CN.json +30 -18
  38. package/template/apps/server/internal/modules/example/infra/exporter/item_exporter.go +119 -0
  39. package/template/apps/server/internal/modules/example/infra/importer/item_importer.go +77 -0
  40. package/template/apps/server/internal/modules/example/infra/repository/item_repository.go +99 -49
  41. package/template/apps/server/internal/modules/example/module.go +171 -97
  42. package/template/apps/server/internal/modules/example/tests/integration_test.go +7 -0
  43. package/template/apps/server/internal/modules/manifest.go +7 -7
  44. package/template/apps/web/README.md +4 -2
  45. package/template/apps/web/package.json +1 -1
  46. package/template/apps/web/src/app.config.ts +8 -6
  47. package/template/apps/web/src/index.css +7 -3
  48. package/template/apps/web/src/main.tsx +2 -5
  49. package/template/apps/web/src/modules/example/help/en-US/faq.md +27 -0
  50. package/template/apps/web/src/modules/example/help/en-US/items.md +30 -0
  51. package/template/apps/web/src/modules/example/help/en-US/overview.md +31 -0
  52. package/template/apps/web/src/modules/example/help/zh-CN/faq.md +27 -0
  53. package/template/apps/web/src/modules/example/help/zh-CN/items.md +31 -0
  54. package/template/apps/web/src/modules/example/help/zh-CN/overview.md +32 -0
  55. package/template/apps/web/src/modules/example/locales/en-US/example.json +99 -32
  56. package/template/apps/web/src/modules/example/locales/zh-CN/example.json +85 -18
  57. package/template/apps/web/src/modules/example/pages/ExampleItemsPage.tsx +840 -237
  58. package/template/apps/web/src/modules/example/services/exampleItems.ts +79 -8
  59. package/template/apps/web/src/modules/example/types.ts +14 -1
  60. package/template/apps/web/src/modules/index.ts +1 -0
  61. package/template/apps/web/vite.config.ts +9 -3
  62. package/template/docs/CONVENTIONS.md +17 -17
  63. package/template/package.json +4 -5
  64. package/template/pnpm-lock.yaml +76 -5
  65. package/template/scripts/build.bat +15 -3
  66. package/template/scripts/build.sh +9 -3
  67. package/template/scripts/check-help.js +249 -0
  68. package/template/scripts/compress-assets.js +89 -0
  69. package/template/scripts/test.bat +23 -0
  70. package/template/scripts/test.sh +16 -0
  71. package/template/.claude/skills/keystone-dev/SKILL.md +0 -90
  72. package/template/.claude/skills/keystone-dev/references/ADVANCED_PATTERNS.md +0 -716
  73. package/template/.claude/skills/keystone-dev/references/APPROVAL.md +0 -121
  74. package/template/.claude/skills/keystone-dev/references/CAPABILITIES.md +0 -261
  75. package/template/.claude/skills/keystone-dev/references/CHECKLIST.md +0 -285
  76. package/template/.claude/skills/keystone-dev/references/GOTCHAS.md +0 -390
  77. package/template/.claude/skills/keystone-dev/references/PATTERNS.md +0 -605
  78. package/template/.claude/skills/keystone-dev/references/TEMPLATES.md +0 -2710
  79. package/template/.claude/skills/keystone-dev/references/TESTING.md +0 -44
  80. package/template/.codex/skills/keystone-dev/SKILL.md +0 -90
  81. package/template/.codex/skills/keystone-dev/references/ADVANCED_PATTERNS.md +0 -716
  82. package/template/.codex/skills/keystone-dev/references/APPROVAL.md +0 -121
  83. package/template/.codex/skills/keystone-dev/references/CAPABILITIES.md +0 -261
  84. package/template/.codex/skills/keystone-dev/references/CHECKLIST.md +0 -285
  85. package/template/.codex/skills/keystone-dev/references/GOTCHAS.md +0 -390
  86. package/template/.codex/skills/keystone-dev/references/PATTERNS.md +0 -605
  87. package/template/.codex/skills/keystone-dev/references/TEMPLATES.md +0 -2710
  88. package/template/.codex/skills/keystone-dev/references/TESTING.md +0 -44
  89. package/template/apps/server/internal/app/routes/module_routes.go +0 -16
  90. package/template/apps/server/internal/app/routes/routes.go +0 -226
  91. package/template/apps/server/internal/app/startup/startup.go +0 -74
  92. package/template/apps/server/internal/frontend/handler.go +0 -122
  93. package/template/apps/server/internal/modules/registry.go +0 -145
  94. package/template/apps/web/src/modules/example/help/faq.md +0 -23
  95. package/template/apps/web/src/modules/example/help/items.md +0 -26
  96. package/template/apps/web/src/modules/example/help/overview.md +0 -25
@@ -2,41 +2,99 @@ package service
2
2
 
3
3
  import (
4
4
  "context"
5
+ "encoding/json"
6
+ "fmt"
5
7
  "strings"
8
+ "time"
9
+
10
+ "github.com/google/uuid"
11
+ "gorm.io/datatypes"
12
+
13
+ approvalmodels "github.com/robsuncn/keystone/domain/approval/models"
14
+ approvalsvc "github.com/robsuncn/keystone/domain/approval/service"
15
+ coremodels "github.com/robsuncn/keystone/domain/models"
16
+ coreservice "github.com/robsuncn/keystone/domain/service"
6
17
 
7
18
  "__APP_NAME__/apps/server/internal/modules/example/domain/models"
8
19
  )
9
20
 
21
+ const ApprovalBusinessType approvalmodels.BusinessType = "example_item"
22
+
10
23
  type ItemRepository interface {
11
- List(ctx context.Context, tenantID uint) ([]models.ExampleItem, error)
24
+ List(ctx context.Context, tenantID uint, filter ItemListFilter) ([]models.ExampleItem, int64, error)
12
25
  FindByID(tenantID, id uint) (*models.ExampleItem, error)
13
26
  Create(ctx context.Context, item *models.ExampleItem) error
14
27
  Update(ctx context.Context, item *models.ExampleItem) error
15
28
  Delete(ctx context.Context, item *models.ExampleItem) error
16
29
  }
17
30
 
31
+ type ApprovalService interface {
32
+ CreateInstance(ctx context.Context, req *approvalsvc.CreateInstanceRequest) (*approvalmodels.ApprovalInstance, error)
33
+ Cancel(ctx context.Context, tenantID, instanceID, actorID uint, req approvalsvc.CancelRequest) (*approvalmodels.ApprovalInstance, error)
34
+ }
35
+
36
+ type NotificationService interface {
37
+ Create(ctx context.Context, notification *coremodels.Notification) error
38
+ }
39
+
18
40
  type ItemService struct {
19
- items ItemRepository
41
+ items ItemRepository
42
+ approvals ApprovalService
43
+ flowMatcher approvalsvc.FlowMatcherInterface
44
+ notifier NotificationService
45
+ clock func() time.Time
46
+ }
47
+
48
+ type ItemListFilter struct {
49
+ Page int
50
+ PageSize int
51
+ Keyword string
52
+ Status *models.ItemStatus
53
+ ApprovalStatus *approvalmodels.InstanceStatus
54
+ Category *models.ItemCategory
55
+ StartDate *time.Time
56
+ EndDate *time.Time
20
57
  }
21
58
 
22
59
  type ItemInput struct {
23
60
  Title string
24
61
  Description string
62
+ Category models.ItemCategory
63
+ Amount float64
25
64
  Status models.ItemStatus
65
+ Attachment datatypes.JSON
26
66
  }
27
67
 
28
68
  type ItemUpdateInput struct {
29
69
  Title *string
30
70
  Description *string
71
+ Category *models.ItemCategory
72
+ Amount *float64
31
73
  Status *models.ItemStatus
74
+ Attachment *datatypes.JSON
75
+ }
76
+
77
+ func NewItemService(
78
+ items ItemRepository,
79
+ approvals ApprovalService,
80
+ flowMatcher approvalsvc.FlowMatcherInterface,
81
+ notifier NotificationService,
82
+ ) *ItemService {
83
+ return &ItemService{
84
+ items: items,
85
+ approvals: approvals,
86
+ flowMatcher: flowMatcher,
87
+ notifier: notifier,
88
+ clock: time.Now,
89
+ }
32
90
  }
33
91
 
34
- func NewItemService(items ItemRepository) *ItemService {
35
- return &ItemService{items: items}
92
+ func (s *ItemService) List(ctx context.Context, tenantID uint, filter ItemListFilter) ([]models.ExampleItem, int64, error) {
93
+ return s.items.List(ctx, tenantID, filter)
36
94
  }
37
95
 
38
- func (s *ItemService) List(ctx context.Context, tenantID uint) ([]models.ExampleItem, error) {
39
- return s.items.List(ctx, tenantID)
96
+ func (s *ItemService) Get(ctx context.Context, tenantID, id uint) (*models.ExampleItem, error) {
97
+ return s.items.FindByID(tenantID, id)
40
98
  }
41
99
 
42
100
  func (s *ItemService) Create(ctx context.Context, tenantID uint, input ItemInput) (*models.ExampleItem, error) {
@@ -45,24 +103,41 @@ func (s *ItemService) Create(ctx context.Context, tenantID uint, input ItemInput
45
103
  return nil, ErrTitleRequired
46
104
  }
47
105
 
106
+ category := input.Category
107
+ if category == "" {
108
+ category = models.CategoryGeneral
109
+ }
110
+ if !category.IsValid() {
111
+ return nil, ErrCategoryInvalid
112
+ }
113
+
48
114
  status := input.Status
49
115
  if status == "" {
50
- status = models.StatusActive
116
+ status = models.StatusDraft
51
117
  }
52
118
  if !status.IsValid() {
53
119
  return nil, ErrStatusInvalid
54
120
  }
55
121
 
122
+ if input.Amount < 0 {
123
+ return nil, ErrAmountInvalid
124
+ }
125
+
56
126
  item := &models.ExampleItem{
127
+ Number: s.buildNumber(),
57
128
  Title: title,
58
129
  Description: strings.TrimSpace(input.Description),
130
+ Category: category,
131
+ Amount: input.Amount,
59
132
  Status: status,
133
+ Attachment: input.Attachment,
60
134
  }
61
135
  item.TenantID = tenantID
62
136
 
63
137
  if err := s.items.Create(ctx, item); err != nil {
64
138
  return nil, err
65
139
  }
140
+ s.notify(ctx, tenantID, item, "created", coremodels.NotificationTypeSuccess)
66
141
  return item, nil
67
142
  }
68
143
 
@@ -75,6 +150,9 @@ func (s *ItemService) Update(
75
150
  if err != nil {
76
151
  return nil, err
77
152
  }
153
+ if !isEditableStatus(item.Status) {
154
+ return nil, ErrItemLocked
155
+ }
78
156
 
79
157
  if input.Title != nil {
80
158
  title := strings.TrimSpace(*input.Title)
@@ -88,6 +166,20 @@ func (s *ItemService) Update(
88
166
  item.Description = strings.TrimSpace(*input.Description)
89
167
  }
90
168
 
169
+ if input.Category != nil {
170
+ if !input.Category.IsValid() {
171
+ return nil, ErrCategoryInvalid
172
+ }
173
+ item.Category = *input.Category
174
+ }
175
+
176
+ if input.Amount != nil {
177
+ if *input.Amount < 0 {
178
+ return nil, ErrAmountInvalid
179
+ }
180
+ item.Amount = *input.Amount
181
+ }
182
+
91
183
  if input.Status != nil {
92
184
  if !input.Status.IsValid() {
93
185
  return nil, ErrStatusInvalid
@@ -95,9 +187,14 @@ func (s *ItemService) Update(
95
187
  item.Status = *input.Status
96
188
  }
97
189
 
190
+ if input.Attachment != nil {
191
+ item.Attachment = normalizeAttachment(*input.Attachment)
192
+ }
193
+
98
194
  if err := s.items.Update(ctx, item); err != nil {
99
195
  return nil, err
100
196
  }
197
+ s.notify(ctx, tenantID, item, "updated", coremodels.NotificationTypeInfo)
101
198
  return item, nil
102
199
  }
103
200
 
@@ -106,5 +203,168 @@ func (s *ItemService) Delete(ctx context.Context, tenantID, id uint) error {
106
203
  if err != nil {
107
204
  return err
108
205
  }
206
+ if item.Status == models.StatusPending {
207
+ return ErrItemLocked
208
+ }
109
209
  return s.items.Delete(ctx, item)
110
210
  }
211
+
212
+ func (s *ItemService) Submit(ctx context.Context, tenantID, id uint) (*models.ExampleItem, error) {
213
+ item, err := s.items.FindByID(tenantID, id)
214
+ if err != nil {
215
+ return nil, err
216
+ }
217
+ if !isSubmittableStatus(item.Status) {
218
+ return nil, ErrApprovalNotReady
219
+ }
220
+ actor, ok := coreservice.ActorFromContext(ctx)
221
+ if !ok || actor.UserID == 0 {
222
+ return nil, ErrSubmitterRequired
223
+ }
224
+ if s.approvals == nil || s.flowMatcher == nil {
225
+ return nil, ErrApprovalNotReady
226
+ }
227
+
228
+ contextMap := map[string]interface{}{
229
+ "number": item.Number,
230
+ "title": item.Title,
231
+ "amount": item.Amount,
232
+ "category": item.Category,
233
+ }
234
+ rawContext, _ := json.Marshal(contextMap)
235
+
236
+ match, err := s.flowMatcher.Match(ctx, tenantID, ApprovalBusinessType, contextMap)
237
+ if err != nil {
238
+ return nil, err
239
+ }
240
+ if match.Blocked {
241
+ return nil, ErrApprovalNotReady
242
+ }
243
+ if !match.RequiresApproval || match.Flow == nil {
244
+ item.Status = models.StatusApproved
245
+ item.ApprovalStatus = approvalmodels.InstanceStatusApproved
246
+ item.ApprovalInstanceID = nil
247
+ item.RejectReason = ""
248
+ if err := s.items.Update(ctx, item); err != nil {
249
+ return nil, err
250
+ }
251
+ s.notify(ctx, tenantID, item, "auto-approved", coremodels.NotificationTypeSuccess)
252
+ return item, nil
253
+ }
254
+
255
+ instance, err := s.approvals.CreateInstance(ctx, &approvalsvc.CreateInstanceRequest{
256
+ TenantID: tenantID,
257
+ BusinessType: ApprovalBusinessType,
258
+ BusinessID: item.ID,
259
+ BusinessNumber: item.Number,
260
+ FlowID: match.Flow.ID,
261
+ FlowCode: match.Flow.Code,
262
+ Context: rawContext,
263
+ TriggerReason: match.TriggerReason,
264
+ BlockReason: match.BlockReason,
265
+ SubmitterID: actor.UserID,
266
+ })
267
+ if err != nil {
268
+ return nil, err
269
+ }
270
+
271
+ item.ApprovalInstanceID = &instance.ID
272
+ item.ApprovalStatus = instance.Status
273
+ item.RejectReason = ""
274
+ if instance.Status == approvalmodels.InstanceStatusApproved {
275
+ item.Status = models.StatusApproved
276
+ } else {
277
+ item.Status = models.StatusPending
278
+ }
279
+ if err := s.items.Update(ctx, item); err != nil {
280
+ return nil, err
281
+ }
282
+ s.notify(ctx, tenantID, item, "submitted", coremodels.NotificationTypeInfo)
283
+ return item, nil
284
+ }
285
+
286
+ func (s *ItemService) Cancel(ctx context.Context, tenantID, id uint, reason string) (*models.ExampleItem, error) {
287
+ item, err := s.items.FindByID(tenantID, id)
288
+ if err != nil {
289
+ return nil, err
290
+ }
291
+ if item.Status != models.StatusPending {
292
+ return nil, ErrApprovalNotPending
293
+ }
294
+ if item.ApprovalInstanceID == nil {
295
+ return nil, ErrApprovalInstanceNil
296
+ }
297
+ actor, ok := coreservice.ActorFromContext(ctx)
298
+ if !ok || actor.UserID == 0 {
299
+ return nil, ErrSubmitterRequired
300
+ }
301
+ if s.approvals == nil {
302
+ return nil, ErrApprovalNotReady
303
+ }
304
+
305
+ instance, err := s.approvals.Cancel(ctx, tenantID, *item.ApprovalInstanceID, actor.UserID, approvalsvc.CancelRequest{
306
+ Reason: reason,
307
+ })
308
+ if err != nil {
309
+ return nil, err
310
+ }
311
+
312
+ item.Status = models.StatusCancelled
313
+ item.ApprovalStatus = instance.Status
314
+ item.RejectReason = strings.TrimSpace(reason)
315
+ if err := s.items.Update(ctx, item); err != nil {
316
+ return nil, err
317
+ }
318
+ s.notify(ctx, tenantID, item, "cancelled", coremodels.NotificationTypeInfo)
319
+ return item, nil
320
+ }
321
+
322
+ func (s *ItemService) buildNumber() string {
323
+ now := s.clock()
324
+ return fmt.Sprintf("EX-%s-%s", now.Format("20060102"), strings.ToUpper(uuid.NewString()[:8]))
325
+ }
326
+
327
+ func (s *ItemService) notify(
328
+ ctx context.Context,
329
+ tenantID uint,
330
+ item *models.ExampleItem,
331
+ action string,
332
+ noticeType coremodels.NotificationType,
333
+ ) {
334
+ if s == nil || s.notifier == nil || item == nil {
335
+ return
336
+ }
337
+ actor, ok := coreservice.ActorFromContext(ctx)
338
+ if !ok || actor.UserID == 0 {
339
+ return
340
+ }
341
+ title := fmt.Sprintf("Example item %s", action)
342
+ content := fmt.Sprintf("%s (%s)", item.Title, item.Number)
343
+ notification := &coremodels.Notification{
344
+ BaseModel: coremodels.BaseModel{TenantID: tenantID},
345
+ UserID: actor.UserID,
346
+ Title: title,
347
+ Content: content,
348
+ Type: noticeType,
349
+ Read: false,
350
+ }
351
+ _ = s.notifier.Create(ctx, notification)
352
+ }
353
+
354
+ func isEditableStatus(status models.ItemStatus) bool {
355
+ return status == models.StatusDraft || status == models.StatusRejected || status == models.StatusCancelled
356
+ }
357
+
358
+ func isSubmittableStatus(status models.ItemStatus) bool {
359
+ return status == models.StatusDraft || status == models.StatusRejected || status == models.StatusCancelled
360
+ }
361
+
362
+ func normalizeAttachment(raw datatypes.JSON) datatypes.JSON {
363
+ if len(raw) == 0 {
364
+ return nil
365
+ }
366
+ if string(raw) == "null" {
367
+ return nil
368
+ }
369
+ return raw
370
+ }
@@ -0,0 +1,281 @@
1
+ package service
2
+
3
+ import (
4
+ "context"
5
+ "strings"
6
+ "testing"
7
+ "time"
8
+
9
+ approvalmodels "github.com/robsuncn/keystone/domain/approval/models"
10
+ approvalsvc "github.com/robsuncn/keystone/domain/approval/service"
11
+ coremodels "github.com/robsuncn/keystone/domain/models"
12
+ coreservice "github.com/robsuncn/keystone/domain/service"
13
+ "gorm.io/datatypes"
14
+
15
+ "__APP_NAME__/apps/server/internal/modules/example/domain/models"
16
+ )
17
+
18
+ type itemRepoStub struct {
19
+ items map[uint]*models.ExampleItem
20
+ nextID uint
21
+ }
22
+
23
+ func newItemRepoStub() *itemRepoStub {
24
+ return &itemRepoStub{
25
+ items: make(map[uint]*models.ExampleItem),
26
+ nextID: 1,
27
+ }
28
+ }
29
+
30
+ func (r *itemRepoStub) List(_ context.Context, _ uint, _ ItemListFilter) ([]models.ExampleItem, int64, error) {
31
+ items := make([]models.ExampleItem, 0, len(r.items))
32
+ for _, item := range r.items {
33
+ items = append(items, *item)
34
+ }
35
+ return items, int64(len(items)), nil
36
+ }
37
+
38
+ func (r *itemRepoStub) FindByID(tenantID, id uint) (*models.ExampleItem, error) {
39
+ item, ok := r.items[id]
40
+ if !ok || item.TenantID != tenantID {
41
+ return nil, ErrItemNotFound
42
+ }
43
+ return item, nil
44
+ }
45
+
46
+ func (r *itemRepoStub) Create(_ context.Context, item *models.ExampleItem) error {
47
+ if item.ID == 0 {
48
+ item.ID = r.nextID
49
+ r.nextID++
50
+ }
51
+ r.items[item.ID] = item
52
+ return nil
53
+ }
54
+
55
+ func (r *itemRepoStub) Update(_ context.Context, item *models.ExampleItem) error {
56
+ if _, ok := r.items[item.ID]; !ok {
57
+ return ErrItemNotFound
58
+ }
59
+ r.items[item.ID] = item
60
+ return nil
61
+ }
62
+
63
+ func (r *itemRepoStub) Delete(_ context.Context, item *models.ExampleItem) error {
64
+ delete(r.items, item.ID)
65
+ return nil
66
+ }
67
+
68
+ type approvalServiceStub struct {
69
+ createCalled bool
70
+ cancelCalled bool
71
+ createReq *approvalsvc.CreateInstanceRequest
72
+ cancelReq *approvalsvc.CancelRequest
73
+ createFn func(context.Context, *approvalsvc.CreateInstanceRequest) (*approvalmodels.ApprovalInstance, error)
74
+ cancelFn func(context.Context, uint, uint, uint, approvalsvc.CancelRequest) (*approvalmodels.ApprovalInstance, error)
75
+ }
76
+
77
+ func (s *approvalServiceStub) CreateInstance(ctx context.Context, req *approvalsvc.CreateInstanceRequest) (*approvalmodels.ApprovalInstance, error) {
78
+ s.createCalled = true
79
+ s.createReq = req
80
+ if s.createFn != nil {
81
+ return s.createFn(ctx, req)
82
+ }
83
+ return &approvalmodels.ApprovalInstance{
84
+ BaseModel: coremodels.BaseModel{ID: 100},
85
+ Status: approvalmodels.InstanceStatusPending,
86
+ }, nil
87
+ }
88
+
89
+ func (s *approvalServiceStub) Cancel(ctx context.Context, tenantID, instanceID, actorID uint, req approvalsvc.CancelRequest) (*approvalmodels.ApprovalInstance, error) {
90
+ s.cancelCalled = true
91
+ s.cancelReq = &req
92
+ if s.cancelFn != nil {
93
+ return s.cancelFn(ctx, tenantID, instanceID, actorID, req)
94
+ }
95
+ return &approvalmodels.ApprovalInstance{
96
+ BaseModel: coremodels.BaseModel{ID: instanceID},
97
+ Status: approvalmodels.InstanceStatusCancelled,
98
+ }, nil
99
+ }
100
+
101
+ type flowMatcherStub struct {
102
+ match *approvalsvc.MatchResult
103
+ err error
104
+ }
105
+
106
+ func (f *flowMatcherStub) Match(_ context.Context, _ uint, _ approvalmodels.BusinessType, _ map[string]interface{}) (*approvalsvc.MatchResult, error) {
107
+ return f.match, f.err
108
+ }
109
+
110
+ func (f *flowMatcherStub) DefaultFlow(_ context.Context, _ uint, _ approvalmodels.BusinessType) (*approvalmodels.ApprovalFlow, error) {
111
+ return nil, nil
112
+ }
113
+
114
+ type notificationStub struct {
115
+ created []*coremodels.Notification
116
+ }
117
+
118
+ func (n *notificationStub) Create(_ context.Context, notification *coremodels.Notification) error {
119
+ n.created = append(n.created, notification)
120
+ return nil
121
+ }
122
+
123
+ func TestItemService_Create_Defaults(t *testing.T) {
124
+ repo := newItemRepoStub()
125
+ svc := NewItemService(repo, nil, nil, &notificationStub{})
126
+ svc.clock = func() time.Time {
127
+ return time.Date(2024, time.January, 2, 12, 0, 0, 0, time.UTC)
128
+ }
129
+
130
+ item, err := svc.Create(context.Background(), 1, ItemInput{
131
+ Title: " Demo ",
132
+ Description: " Example ",
133
+ Amount: 42,
134
+ Attachment: datatypes.JSON(`{"name":"demo.txt"}`),
135
+ })
136
+ if err != nil {
137
+ t.Fatalf("create failed: %v", err)
138
+ }
139
+ if item.Status != models.StatusDraft {
140
+ t.Fatalf("expected status draft, got %s", item.Status)
141
+ }
142
+ if item.Category != models.CategoryGeneral {
143
+ t.Fatalf("expected category general, got %s", item.Category)
144
+ }
145
+ if item.TenantID != 1 {
146
+ t.Fatalf("expected tenant 1, got %d", item.TenantID)
147
+ }
148
+ if !strings.HasPrefix(item.Number, "EX-20240102-") {
149
+ t.Fatalf("unexpected number format: %s", item.Number)
150
+ }
151
+ }
152
+
153
+ func TestItemService_Submit_WithApproval(t *testing.T) {
154
+ repo := newItemRepoStub()
155
+ item := &models.ExampleItem{
156
+ BaseModel: coremodels.BaseModel{ID: 1, TenantID: 1},
157
+ Number: "EX-0001",
158
+ Title: "Approval Item",
159
+ Category: models.CategoryFinance,
160
+ Status: models.StatusDraft,
161
+ }
162
+ repo.items[item.ID] = item
163
+
164
+ approvals := &approvalServiceStub{
165
+ createFn: func(_ context.Context, req *approvalsvc.CreateInstanceRequest) (*approvalmodels.ApprovalInstance, error) {
166
+ if req.BusinessID != item.ID {
167
+ t.Fatalf("unexpected business id: %d", req.BusinessID)
168
+ }
169
+ return &approvalmodels.ApprovalInstance{
170
+ BaseModel: coremodels.BaseModel{ID: 77},
171
+ Status: approvalmodels.InstanceStatusPending,
172
+ }, nil
173
+ },
174
+ }
175
+ matcher := &flowMatcherStub{
176
+ match: &approvalsvc.MatchResult{
177
+ RequiresApproval: true,
178
+ Flow: &approvalmodels.ApprovalFlow{
179
+ BaseModel: coremodels.BaseModel{ID: 10},
180
+ Code: "EXAMPLE_ITEM",
181
+ },
182
+ },
183
+ }
184
+
185
+ svc := NewItemService(repo, approvals, matcher, &notificationStub{})
186
+ ctx := coreservice.WithActor(context.Background(), coreservice.ActorSnapshot{UserID: 9})
187
+
188
+ updated, err := svc.Submit(ctx, 1, item.ID)
189
+ if err != nil {
190
+ t.Fatalf("submit failed: %v", err)
191
+ }
192
+ if updated.Status != models.StatusPending {
193
+ t.Fatalf("expected pending status, got %s", updated.Status)
194
+ }
195
+ if updated.ApprovalInstanceID == nil || *updated.ApprovalInstanceID != 77 {
196
+ t.Fatalf("expected approval instance 77, got %v", updated.ApprovalInstanceID)
197
+ }
198
+ if updated.ApprovalStatus != approvalmodels.InstanceStatusPending {
199
+ t.Fatalf("expected approval status pending, got %s", updated.ApprovalStatus)
200
+ }
201
+ }
202
+
203
+ func TestItemService_Submit_AutoApprove(t *testing.T) {
204
+ repo := newItemRepoStub()
205
+ item := &models.ExampleItem{
206
+ BaseModel: coremodels.BaseModel{ID: 2, TenantID: 1},
207
+ Number: "EX-0002",
208
+ Title: "Auto Approval",
209
+ Category: models.CategoryGeneral,
210
+ Status: models.StatusDraft,
211
+ }
212
+ repo.items[item.ID] = item
213
+
214
+ approvals := &approvalServiceStub{}
215
+ matcher := &flowMatcherStub{
216
+ match: &approvalsvc.MatchResult{RequiresApproval: false},
217
+ }
218
+
219
+ svc := NewItemService(repo, approvals, matcher, &notificationStub{})
220
+ ctx := coreservice.WithActor(context.Background(), coreservice.ActorSnapshot{UserID: 5})
221
+
222
+ updated, err := svc.Submit(ctx, 1, item.ID)
223
+ if err != nil {
224
+ t.Fatalf("submit failed: %v", err)
225
+ }
226
+ if updated.Status != models.StatusApproved {
227
+ t.Fatalf("expected approved status, got %s", updated.Status)
228
+ }
229
+ if updated.ApprovalStatus != approvalmodels.InstanceStatusApproved {
230
+ t.Fatalf("expected approval status approved, got %s", updated.ApprovalStatus)
231
+ }
232
+ if approvals.createCalled {
233
+ t.Fatalf("expected no approval instance creation")
234
+ }
235
+ }
236
+
237
+ func TestItemService_Cancel(t *testing.T) {
238
+ repo := newItemRepoStub()
239
+ instanceID := uint(55)
240
+ item := &models.ExampleItem{
241
+ BaseModel: coremodels.BaseModel{ID: 3, TenantID: 1},
242
+ Number: "EX-0003",
243
+ Title: "Cancel Item",
244
+ Category: models.CategoryOperations,
245
+ Status: models.StatusPending,
246
+ ApprovalInstanceID: &instanceID,
247
+ ApprovalStatus: approvalmodels.InstanceStatusPending,
248
+ RejectReason: "",
249
+ Attachment: datatypes.JSON(`{"name":"proof.pdf"}`),
250
+ }
251
+ repo.items[item.ID] = item
252
+
253
+ approvals := &approvalServiceStub{
254
+ cancelFn: func(_ context.Context, tenantID, instanceID, actorID uint, req approvalsvc.CancelRequest) (*approvalmodels.ApprovalInstance, error) {
255
+ if req.Reason != "need change" {
256
+ t.Fatalf("unexpected cancel reason: %s", req.Reason)
257
+ }
258
+ return &approvalmodels.ApprovalInstance{
259
+ BaseModel: coremodels.BaseModel{ID: instanceID},
260
+ Status: approvalmodels.InstanceStatusCancelled,
261
+ }, nil
262
+ },
263
+ }
264
+
265
+ svc := NewItemService(repo, approvals, nil, &notificationStub{})
266
+ ctx := coreservice.WithActor(context.Background(), coreservice.ActorSnapshot{UserID: 5})
267
+
268
+ updated, err := svc.Cancel(ctx, 1, item.ID, "need change")
269
+ if err != nil {
270
+ t.Fatalf("cancel failed: %v", err)
271
+ }
272
+ if updated.Status != models.StatusCancelled {
273
+ t.Fatalf("expected cancelled status, got %s", updated.Status)
274
+ }
275
+ if updated.ApprovalStatus != approvalmodels.InstanceStatusCancelled {
276
+ t.Fatalf("expected approval status cancelled, got %s", updated.ApprovalStatus)
277
+ }
278
+ if updated.RejectReason != "need change" {
279
+ t.Fatalf("expected reject reason to be set")
280
+ }
281
+ }
@@ -1,23 +1,35 @@
1
1
  package examplei18n
2
2
 
3
3
  // Example module i18n message keys
4
- const (
5
- // Item messages
6
- MsgItemCreated = "example.item.created"
7
- MsgItemUpdated = "example.item.updated"
8
- MsgItemDeleted = "example.item.deleted"
9
- MsgItemNotFound = "example.item.notFound"
10
- MsgItemLoadFailed = "example.item.loadFailed"
11
- MsgItemCreateFailed = "example.item.createFailed"
12
- MsgItemUpdateFailed = "example.item.updateFailed"
13
- MsgItemDeleteFailed = "example.item.deleteFailed"
14
-
15
- // Validation messages
16
- MsgTitleRequired = "example.validation.titleRequired"
17
- MsgStatusInvalid = "example.validation.statusInvalid"
18
- MsgInvalidID = "example.validation.invalidId"
19
- MsgInvalidPayload = "example.validation.invalidPayload"
20
-
21
- // Service messages
22
- MsgServiceUnavailable = "example.service.unavailable"
23
- )
4
+ const (
5
+ // Item messages
6
+ MsgItemCreated = "example.item.created"
7
+ MsgItemUpdated = "example.item.updated"
8
+ MsgItemDeleted = "example.item.deleted"
9
+ MsgItemSubmitted = "example.item.submitted"
10
+ MsgItemCancelled = "example.item.cancelled"
11
+ MsgItemNotFound = "example.item.notFound"
12
+ MsgItemLoadFailed = "example.item.loadFailed"
13
+ MsgItemCreateFailed = "example.item.createFailed"
14
+ MsgItemUpdateFailed = "example.item.updateFailed"
15
+ MsgItemDeleteFailed = "example.item.deleteFailed"
16
+ MsgItemSubmitFailed = "example.item.submitFailed"
17
+ MsgItemCancelFailed = "example.item.cancelFailed"
18
+ MsgItemExportFailed = "example.item.exportFailed"
19
+
20
+ // Validation messages
21
+ MsgTitleRequired = "example.validation.titleRequired"
22
+ MsgStatusInvalid = "example.validation.statusInvalid"
23
+ MsgCategoryInvalid = "example.validation.categoryInvalid"
24
+ MsgAmountInvalid = "example.validation.amountInvalid"
25
+ MsgItemLocked = "example.validation.itemLocked"
26
+ MsgSubmitterRequired = "example.validation.submitterRequired"
27
+ MsgApprovalNotReady = "example.validation.approvalNotReady"
28
+ MsgApprovalNotPending = "example.validation.approvalNotPending"
29
+ MsgApprovalInstanceMissing = "example.validation.approvalInstanceMissing"
30
+ MsgInvalidID = "example.validation.invalidId"
31
+ MsgInvalidPayload = "example.validation.invalidPayload"
32
+
33
+ // Service messages
34
+ MsgServiceUnavailable = "example.service.unavailable"
35
+ )