@robsun/create-keystone-app 0.2.13 → 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 (88) hide show
  1. package/README.md +46 -43
  2. package/dist/create-keystone-app.js +347 -10
  3. package/dist/create-module.js +1219 -607
  4. package/package.json +22 -23
  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 +81 -73
  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 +10 -7
  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 -103
  72. package/template/.claude/skills/keystone-dev/references/APPROVAL.md +0 -121
  73. package/template/.claude/skills/keystone-dev/references/CAPABILITIES.md +0 -261
  74. package/template/.claude/skills/keystone-dev/references/TEMPLATES.md +0 -532
  75. package/template/.claude/skills/keystone-dev/references/TESTING.md +0 -44
  76. package/template/.codex/skills/keystone-dev/SKILL.md +0 -103
  77. package/template/.codex/skills/keystone-dev/references/APPROVAL.md +0 -121
  78. package/template/.codex/skills/keystone-dev/references/CAPABILITIES.md +0 -261
  79. package/template/.codex/skills/keystone-dev/references/TEMPLATES.md +0 -532
  80. package/template/.codex/skills/keystone-dev/references/TESTING.md +0 -44
  81. package/template/apps/server/internal/app/routes/module_routes.go +0 -16
  82. package/template/apps/server/internal/app/routes/routes.go +0 -226
  83. package/template/apps/server/internal/app/startup/startup.go +0 -74
  84. package/template/apps/server/internal/frontend/handler.go +0 -122
  85. package/template/apps/server/internal/modules/registry.go +0 -145
  86. package/template/apps/web/src/modules/example/help/faq.md +0 -23
  87. package/template/apps/web/src/modules/example/help/items.md +0 -26
  88. package/template/apps/web/src/modules/example/help/overview.md +0 -25
@@ -1,165 +1,468 @@
1
- package handler
2
-
3
- import (
4
- "errors"
5
-
6
- "github.com/gin-gonic/gin"
7
- hcommon "github.com/robsuncn/keystone/api/handler/common"
8
- "github.com/robsuncn/keystone/api/response"
9
- "github.com/robsuncn/keystone/infra/i18n"
10
-
11
- examplei18n "__APP_NAME__/apps/server/internal/modules/example/i18n"
12
- "__APP_NAME__/apps/server/internal/modules/example/domain/models"
13
- "__APP_NAME__/apps/server/internal/modules/example/domain/service"
14
- )
15
-
16
- type ItemHandler struct {
17
- items *service.ItemService
18
- }
19
-
20
- func NewItemHandler(items *service.ItemService) *ItemHandler {
21
- if items == nil {
22
- return nil
23
- }
24
- return &ItemHandler{items: items}
25
- }
26
-
27
- type itemInput struct {
28
- Title string `json:"title"`
29
- Description string `json:"description"`
30
- Status models.ItemStatus `json:"status"`
31
- }
32
-
33
- type itemUpdateInput struct {
34
- Title *string `json:"title"`
35
- Description *string `json:"description"`
36
- Status *models.ItemStatus `json:"status"`
37
- }
38
-
39
- const defaultTenantID uint = 1
40
-
41
- func (h *ItemHandler) List(c *gin.Context) {
42
- if h == nil || h.items == nil {
43
- response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
44
- return
45
- }
46
- tenantID := resolveTenantID(c)
47
-
48
- items, err := h.items.List(c.Request.Context(), tenantID)
49
- if err != nil {
50
- response.InternalErrorI18n(c, examplei18n.MsgItemLoadFailed)
51
- return
52
- }
53
-
54
- response.Success(c, gin.H{"items": items})
55
- }
56
-
57
- func (h *ItemHandler) Create(c *gin.Context) {
58
- if h == nil || h.items == nil {
59
- response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
60
- return
61
- }
62
- tenantID := resolveTenantID(c)
63
-
64
- var input itemInput
65
- if err := c.ShouldBindJSON(&input); err != nil {
66
- response.BadRequestI18n(c, examplei18n.MsgInvalidPayload)
67
- return
68
- }
69
-
70
- item, err := h.items.Create(c.Request.Context(), tenantID, service.ItemInput{
71
- Title: input.Title,
72
- Description: input.Description,
73
- Status: input.Status,
74
- })
75
- if err != nil {
76
- var i18nErr *i18n.I18nError
77
- if errors.As(err, &i18nErr) {
78
- response.BadRequestI18n(c, i18nErr.Key)
79
- return
80
- }
81
- response.InternalErrorI18n(c, examplei18n.MsgItemCreateFailed)
82
- return
83
- }
84
-
85
- response.CreatedI18n(c, examplei18n.MsgItemCreated, item)
86
- }
87
-
88
- func (h *ItemHandler) Update(c *gin.Context) {
89
- if h == nil || h.items == nil {
90
- response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
91
- return
92
- }
93
- tenantID := resolveTenantID(c)
94
-
95
- id, err := hcommon.ParseUintParam(c, "id")
96
- if err != nil || id == 0 {
97
- response.BadRequestI18n(c, examplei18n.MsgInvalidID)
98
- return
99
- }
100
-
101
- var input itemUpdateInput
102
- if err := c.ShouldBindJSON(&input); err != nil {
103
- response.BadRequestI18n(c, examplei18n.MsgInvalidPayload)
104
- return
105
- }
106
-
107
- item, err := h.items.Update(c.Request.Context(), tenantID, id, service.ItemUpdateInput{
108
- Title: input.Title,
109
- Description: input.Description,
110
- Status: input.Status,
111
- })
112
- if err != nil {
113
- var i18nErr *i18n.I18nError
114
- if errors.As(err, &i18nErr) {
115
- if i18nErr.Key == examplei18n.MsgItemNotFound {
116
- response.NotFoundI18n(c, i18nErr.Key)
117
- } else {
118
- response.BadRequestI18n(c, i18nErr.Key)
119
- }
120
- return
121
- }
122
- response.InternalErrorI18n(c, examplei18n.MsgItemUpdateFailed)
123
- return
124
- }
125
-
126
- response.SuccessI18n(c, examplei18n.MsgItemUpdated, item)
127
- }
128
-
129
- func (h *ItemHandler) Delete(c *gin.Context) {
130
- if h == nil || h.items == nil {
131
- response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
132
- return
133
- }
134
- tenantID := resolveTenantID(c)
135
-
136
- id, err := hcommon.ParseUintParam(c, "id")
137
- if err != nil || id == 0 {
138
- response.BadRequestI18n(c, examplei18n.MsgInvalidID)
139
- return
140
- }
141
-
142
- if err := h.items.Delete(c.Request.Context(), tenantID, id); err != nil {
143
- var i18nErr *i18n.I18nError
144
- if errors.As(err, &i18nErr) {
145
- if i18nErr.Key == examplei18n.MsgItemNotFound {
146
- response.NotFoundI18n(c, i18nErr.Key)
147
- return
148
- }
149
- }
150
- response.InternalErrorI18n(c, examplei18n.MsgItemDeleteFailed)
151
- return
152
- }
153
-
154
- response.SuccessI18n(c, examplei18n.MsgItemDeleted, gin.H{"id": id})
155
- }
156
-
157
- func resolveTenantID(c *gin.Context) uint {
158
- if c == nil {
159
- return defaultTenantID
160
- }
161
- if tenantID, ok := hcommon.GetTenantID(c); ok && tenantID > 0 {
162
- return tenantID
163
- }
164
- return defaultTenantID
165
- }
1
+ package handler
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "time"
9
+
10
+ "github.com/gin-gonic/gin"
11
+ "github.com/xuri/excelize/v2"
12
+ "gorm.io/datatypes"
13
+
14
+ hcommon "github.com/robsuncn/keystone/api/handler/common"
15
+ "github.com/robsuncn/keystone/api/response"
16
+ approvalmodels "github.com/robsuncn/keystone/domain/approval/models"
17
+ "github.com/robsuncn/keystone/infra/i18n"
18
+ "github.com/robsuncn/keystone/infra/pdf"
19
+
20
+ examplei18n "__APP_NAME__/apps/server/internal/modules/example/i18n"
21
+ "__APP_NAME__/apps/server/internal/modules/example/domain/models"
22
+ "__APP_NAME__/apps/server/internal/modules/example/domain/service"
23
+ )
24
+
25
+ type ItemHandler struct {
26
+ items *service.ItemService
27
+ }
28
+
29
+ func NewItemHandler(items *service.ItemService) *ItemHandler {
30
+ if items == nil {
31
+ return nil
32
+ }
33
+ return &ItemHandler{items: items}
34
+ }
35
+
36
+ type itemInput struct {
37
+ Title string `json:"title"`
38
+ Description string `json:"description"`
39
+ Category models.ItemCategory `json:"category"`
40
+ Amount float64 `json:"amount"`
41
+ Status models.ItemStatus `json:"status"`
42
+ Attachment json.RawMessage `json:"attachment"`
43
+ }
44
+
45
+ type itemUpdateInput struct {
46
+ Title *string `json:"title"`
47
+ Description *string `json:"description"`
48
+ Category *models.ItemCategory `json:"category"`
49
+ Amount *float64 `json:"amount"`
50
+ Status *models.ItemStatus `json:"status"`
51
+ Attachment *json.RawMessage `json:"attachment"`
52
+ }
53
+
54
+ type cancelInput struct {
55
+ Reason string `json:"reason"`
56
+ }
57
+
58
+ const defaultTenantID uint = 1
59
+
60
+ func (h *ItemHandler) List(c *gin.Context) {
61
+ if h == nil || h.items == nil {
62
+ response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
63
+ return
64
+ }
65
+ tenantID := resolveTenantID(c)
66
+ page, pageSize := hcommon.ParsePagination(c)
67
+
68
+ filter, err := buildItemListFilter(c)
69
+ if err != nil {
70
+ response.BadRequestI18n(c, examplei18n.MsgInvalidPayload)
71
+ return
72
+ }
73
+ filter.Page = page
74
+ filter.PageSize = pageSize
75
+
76
+ items, total, err := h.items.List(c.Request.Context(), tenantID, filter)
77
+ if err != nil {
78
+ response.InternalErrorI18n(c, examplei18n.MsgItemLoadFailed)
79
+ return
80
+ }
81
+ response.Paginated(c, items, total, page, pageSize)
82
+ }
83
+
84
+ func (h *ItemHandler) ExportPDF(c *gin.Context) {
85
+ if h == nil || h.items == nil {
86
+ response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
87
+ return
88
+ }
89
+ tenantID := resolveTenantID(c)
90
+ filter, err := buildItemListFilter(c)
91
+ if err != nil {
92
+ response.BadRequestI18n(c, examplei18n.MsgInvalidPayload)
93
+ return
94
+ }
95
+ filter.Page = 1
96
+ filter.PageSize = 0
97
+
98
+ items, _, err := h.items.List(c.Request.Context(), tenantID, filter)
99
+ if err != nil {
100
+ response.InternalErrorI18n(c, examplei18n.MsgItemExportFailed)
101
+ return
102
+ }
103
+
104
+ data, err := buildItemsPDF(items)
105
+ if err != nil {
106
+ response.InternalErrorI18n(c, examplei18n.MsgItemExportFailed)
107
+ return
108
+ }
109
+ filename := fmt.Sprintf("example-items-%s.pdf", time.Now().Format("20060102-150405"))
110
+ response.DownloadPDF(c, filename, data)
111
+ }
112
+
113
+ func (h *ItemHandler) DownloadImportTemplate(c *gin.Context) {
114
+ if h == nil || h.items == nil {
115
+ response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
116
+ return
117
+ }
118
+ file := excelize.NewFile()
119
+ defer func() { _ = file.Close() }()
120
+
121
+ sheet := file.GetSheetName(0)
122
+ if sheet == "" {
123
+ sheet = "Sheet1"
124
+ file.NewSheet(sheet)
125
+ }
126
+ headers := []string{"title", "description", "category", "amount", "status"}
127
+ if err := file.SetSheetRow(sheet, "A1", &headers); err != nil {
128
+ response.InternalErrorI18n(c, examplei18n.MsgItemExportFailed)
129
+ return
130
+ }
131
+ _ = file.SetColWidth(sheet, "A", "E", 18)
132
+
133
+ if _, err := file.NewSheet("README"); err == nil {
134
+ _ = file.SetSheetRow("README", "A1", &[]string{"Columns: title, description, category, amount, status"})
135
+ _ = file.SetSheetRow("README", "A3", &[]string{"category: general | operations | finance | growth"})
136
+ _ = file.SetSheetRow("README", "A4", &[]string{"status: draft | pending | approved | rejected | cancelled"})
137
+ }
138
+
139
+ buf, err := file.WriteToBuffer()
140
+ if err != nil {
141
+ response.InternalErrorI18n(c, examplei18n.MsgItemExportFailed)
142
+ return
143
+ }
144
+ response.DownloadFile(
145
+ c,
146
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
147
+ "example_items_import_template.xlsx",
148
+ buf.Bytes(),
149
+ )
150
+ }
151
+
152
+ func (h *ItemHandler) Get(c *gin.Context) {
153
+ if h == nil || h.items == nil {
154
+ response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
155
+ return
156
+ }
157
+ tenantID := resolveTenantID(c)
158
+ id, err := hcommon.ParseUintParam(c, "id")
159
+ if err != nil || id == 0 {
160
+ response.BadRequestI18n(c, examplei18n.MsgInvalidID)
161
+ return
162
+ }
163
+ item, err := h.items.Get(c.Request.Context(), tenantID, id)
164
+ if err != nil {
165
+ var i18nErr *i18n.I18nError
166
+ if errors.As(err, &i18nErr) {
167
+ response.NotFoundI18n(c, i18nErr.Key)
168
+ return
169
+ }
170
+ response.InternalErrorI18n(c, examplei18n.MsgItemLoadFailed)
171
+ return
172
+ }
173
+ response.Success(c, item)
174
+ }
175
+
176
+ func (h *ItemHandler) Create(c *gin.Context) {
177
+ if h == nil || h.items == nil {
178
+ response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
179
+ return
180
+ }
181
+ tenantID := resolveTenantID(c)
182
+
183
+ var input itemInput
184
+ if err := c.ShouldBindJSON(&input); err != nil {
185
+ response.BadRequestI18n(c, examplei18n.MsgInvalidPayload)
186
+ return
187
+ }
188
+
189
+ item, err := h.items.Create(c.Request.Context(), tenantID, service.ItemInput{
190
+ Title: input.Title,
191
+ Description: input.Description,
192
+ Category: input.Category,
193
+ Amount: input.Amount,
194
+ Status: input.Status,
195
+ Attachment: normalizeAttachment(input.Attachment),
196
+ })
197
+ if err != nil {
198
+ var i18nErr *i18n.I18nError
199
+ if errors.As(err, &i18nErr) {
200
+ response.BadRequestI18n(c, i18nErr.Key)
201
+ return
202
+ }
203
+ response.InternalErrorI18n(c, examplei18n.MsgItemCreateFailed)
204
+ return
205
+ }
206
+
207
+ response.CreatedI18n(c, examplei18n.MsgItemCreated, item)
208
+ }
209
+
210
+ func (h *ItemHandler) Update(c *gin.Context) {
211
+ if h == nil || h.items == nil {
212
+ response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
213
+ return
214
+ }
215
+ tenantID := resolveTenantID(c)
216
+
217
+ id, err := hcommon.ParseUintParam(c, "id")
218
+ if err != nil || id == 0 {
219
+ response.BadRequestI18n(c, examplei18n.MsgInvalidID)
220
+ return
221
+ }
222
+
223
+ var input itemUpdateInput
224
+ if err := c.ShouldBindJSON(&input); err != nil {
225
+ response.BadRequestI18n(c, examplei18n.MsgInvalidPayload)
226
+ return
227
+ }
228
+
229
+ item, err := h.items.Update(c.Request.Context(), tenantID, id, service.ItemUpdateInput{
230
+ Title: input.Title,
231
+ Description: input.Description,
232
+ Category: input.Category,
233
+ Amount: input.Amount,
234
+ Status: input.Status,
235
+ Attachment: normalizeAttachmentPtr(input.Attachment),
236
+ })
237
+ if err != nil {
238
+ var i18nErr *i18n.I18nError
239
+ if errors.As(err, &i18nErr) {
240
+ if i18nErr.Key == examplei18n.MsgItemNotFound {
241
+ response.NotFoundI18n(c, i18nErr.Key)
242
+ } else {
243
+ response.BadRequestI18n(c, i18nErr.Key)
244
+ }
245
+ return
246
+ }
247
+ response.InternalErrorI18n(c, examplei18n.MsgItemUpdateFailed)
248
+ return
249
+ }
250
+
251
+ response.SuccessI18n(c, examplei18n.MsgItemUpdated, item)
252
+ }
253
+
254
+ func (h *ItemHandler) Delete(c *gin.Context) {
255
+ if h == nil || h.items == nil {
256
+ response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
257
+ return
258
+ }
259
+ tenantID := resolveTenantID(c)
260
+
261
+ id, err := hcommon.ParseUintParam(c, "id")
262
+ if err != nil || id == 0 {
263
+ response.BadRequestI18n(c, examplei18n.MsgInvalidID)
264
+ return
265
+ }
266
+
267
+ if err := h.items.Delete(c.Request.Context(), tenantID, id); err != nil {
268
+ var i18nErr *i18n.I18nError
269
+ if errors.As(err, &i18nErr) {
270
+ if i18nErr.Key == examplei18n.MsgItemNotFound {
271
+ response.NotFoundI18n(c, i18nErr.Key)
272
+ return
273
+ }
274
+ response.BadRequestI18n(c, i18nErr.Key)
275
+ return
276
+ }
277
+ response.InternalErrorI18n(c, examplei18n.MsgItemDeleteFailed)
278
+ return
279
+ }
280
+
281
+ response.SuccessI18n(c, examplei18n.MsgItemDeleted, gin.H{"id": id})
282
+ }
283
+
284
+ func (h *ItemHandler) Submit(c *gin.Context) {
285
+ if h == nil || h.items == nil {
286
+ response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
287
+ return
288
+ }
289
+ tenantID := resolveTenantID(c)
290
+
291
+ id, err := hcommon.ParseUintParam(c, "id")
292
+ if err != nil || id == 0 {
293
+ response.BadRequestI18n(c, examplei18n.MsgInvalidID)
294
+ return
295
+ }
296
+
297
+ item, err := h.items.Submit(c.Request.Context(), tenantID, id)
298
+ if err != nil {
299
+ var i18nErr *i18n.I18nError
300
+ if errors.As(err, &i18nErr) {
301
+ response.BadRequestI18n(c, i18nErr.Key)
302
+ return
303
+ }
304
+ response.InternalErrorI18n(c, examplei18n.MsgItemSubmitFailed)
305
+ return
306
+ }
307
+
308
+ response.SuccessI18n(c, examplei18n.MsgItemSubmitted, item)
309
+ }
310
+
311
+ func (h *ItemHandler) Cancel(c *gin.Context) {
312
+ if h == nil || h.items == nil {
313
+ response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
314
+ return
315
+ }
316
+ tenantID := resolveTenantID(c)
317
+
318
+ id, err := hcommon.ParseUintParam(c, "id")
319
+ if err != nil || id == 0 {
320
+ response.BadRequestI18n(c, examplei18n.MsgInvalidID)
321
+ return
322
+ }
323
+
324
+ var input cancelInput
325
+ _ = c.ShouldBindJSON(&input)
326
+
327
+ item, err := h.items.Cancel(c.Request.Context(), tenantID, id, input.Reason)
328
+ if err != nil {
329
+ var i18nErr *i18n.I18nError
330
+ if errors.As(err, &i18nErr) {
331
+ response.BadRequestI18n(c, i18nErr.Key)
332
+ return
333
+ }
334
+ response.InternalErrorI18n(c, examplei18n.MsgItemCancelFailed)
335
+ return
336
+ }
337
+
338
+ response.SuccessI18n(c, examplei18n.MsgItemCancelled, item)
339
+ }
340
+
341
+ func resolveTenantID(c *gin.Context) uint {
342
+ if c == nil {
343
+ return defaultTenantID
344
+ }
345
+ if tenantID, ok := hcommon.GetTenantID(c); ok && tenantID > 0 {
346
+ return tenantID
347
+ }
348
+ return defaultTenantID
349
+ }
350
+
351
+ func buildItemListFilter(c *gin.Context) (service.ItemListFilter, error) {
352
+ filter := service.ItemListFilter{
353
+ Keyword: c.Query("keyword"),
354
+ }
355
+ if raw := c.Query("status"); raw != "" {
356
+ status := models.ItemStatus(raw)
357
+ filter.Status = &status
358
+ }
359
+ if raw := c.Query("approval_status"); raw != "" {
360
+ status := approvalmodels.InstanceStatus(raw)
361
+ filter.ApprovalStatus = &status
362
+ }
363
+ if raw := c.Query("category"); raw != "" {
364
+ category := models.ItemCategory(raw)
365
+ filter.Category = &category
366
+ }
367
+ if raw := c.Query("start_date"); raw != "" {
368
+ parsed, err := parseDate(raw, false)
369
+ if err != nil {
370
+ return filter, err
371
+ }
372
+ filter.StartDate = parsed
373
+ }
374
+ if raw := c.Query("end_date"); raw != "" {
375
+ parsed, err := parseDate(raw, true)
376
+ if err != nil {
377
+ return filter, err
378
+ }
379
+ filter.EndDate = parsed
380
+ }
381
+ return filter, nil
382
+ }
383
+
384
+ func buildItemsPDF(items []models.ExampleItem) ([]byte, error) {
385
+ doc, err := pdf.NewDocument(pdf.Config{})
386
+ if err != nil {
387
+ return nil, err
388
+ }
389
+ doc.AddPage()
390
+ p := doc.PDF()
391
+ p.SetFont(doc.FontFamily(), "", 16)
392
+ p.CellFormat(0, 10, "Example Items", "", 1, "L", false, 0, "")
393
+ p.SetFont(doc.FontFamily(), "", 11)
394
+ p.CellFormat(0, 7, fmt.Sprintf("Generated at: %s", time.Now().Format("2006-01-02 15:04")), "", 1, "L", false, 0, "")
395
+ p.CellFormat(0, 7, fmt.Sprintf("Total: %d", len(items)), "", 1, "L", false, 0, "")
396
+ p.Ln(2)
397
+
398
+ columns := []pdf.TableColumn{
399
+ {Header: "Number", Width: 28, Align: "C"},
400
+ {Header: "Title", Width: 62, Align: "L"},
401
+ {Header: "Category", Width: 22, Align: "C"},
402
+ {Header: "Amount", Width: 20, Align: "R"},
403
+ {Header: "Status", Width: 20, Align: "C"},
404
+ {Header: "Approval", Width: 28, Align: "C"},
405
+ }
406
+ table := pdf.NewTable(doc, columns)
407
+ table.DrawHeader()
408
+ for _, item := range items {
409
+ status := string(item.Status)
410
+ if status == "" {
411
+ status = "-"
412
+ }
413
+ approvalStatus := string(item.ApprovalStatus)
414
+ if approvalStatus == "" {
415
+ approvalStatus = "-"
416
+ }
417
+ category := string(item.Category)
418
+ if category == "" {
419
+ category = "-"
420
+ }
421
+ if err := table.DrawRow(
422
+ []string{
423
+ item.Number,
424
+ item.Title,
425
+ category,
426
+ fmt.Sprintf("%.2f", item.Amount),
427
+ status,
428
+ approvalStatus,
429
+ },
430
+ 12,
431
+ table.DrawHeader,
432
+ ); err != nil {
433
+ return nil, err
434
+ }
435
+ }
436
+ return doc.OutputBytes()
437
+ }
438
+
439
+ func normalizeAttachment(raw json.RawMessage) datatypes.JSON {
440
+ clean := bytes.TrimSpace(raw)
441
+ if len(clean) == 0 || string(clean) == "null" {
442
+ return nil
443
+ }
444
+ return datatypes.JSON(clean)
445
+ }
446
+
447
+ func normalizeAttachmentPtr(raw *json.RawMessage) *datatypes.JSON {
448
+ if raw == nil {
449
+ return nil
450
+ }
451
+ normalized := normalizeAttachment(*raw)
452
+ return &normalized
453
+ }
454
+
455
+ func parseDate(value string, endOfDay bool) (*time.Time, error) {
456
+ layouts := []string{time.RFC3339, "2006-01-02"}
457
+ for _, layout := range layouts {
458
+ parsed, err := time.Parse(layout, value)
459
+ if err != nil {
460
+ continue
461
+ }
462
+ if endOfDay && layout == "2006-01-02" {
463
+ parsed = parsed.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
464
+ }
465
+ return &parsed, nil
466
+ }
467
+ return nil, errors.New("invalid date")
468
+ }