service.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. package service
  2. import (
  3. "errors"
  4. "fmt"
  5. "regexp"
  6. "strings"
  7. "time"
  8. "wails-app/internal/dao"
  9. "wails-app/internal/global"
  10. "wails-app/internal/modules/payment/repository"
  11. "gorm.io/gorm"
  12. )
  13. // Internal payment constants and configuration values.
  14. const (
  15. PaymentCash = "cash"
  16. PaymentFree = "free"
  17. PaymentPOS = "pos"
  18. PaymentTicketQR = "ticket_qr"
  19. InputModeManualAmount = "manual_amount"
  20. InputModeConfirm = "confirm"
  21. InputModeExternal = "external"
  22. EntryCounter = "counter"
  23. EntryTicket = "ticket"
  24. // Scene* are kept as source-compatible aliases for older callers. New code
  25. // should pass an Entry* value and treat it as the payment entry, not a
  26. // property of the payment method.
  27. SceneCounter = EntryCounter
  28. SceneTicket = EntryTicket
  29. )
  30. var paymentCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{1,31}$`)
  31. type PaymentService struct {
  32. repo *repository.PaymentRepository
  33. }
  34. func NewPaymentService() *PaymentService {
  35. return &PaymentService{repo: &repository.PaymentRepository{}}
  36. }
  37. func (s *PaymentService) ListMethods() ([]dao.PaymentMethodConfig, error) {
  38. return s.repo.MethodList()
  39. }
  40. func (s *PaymentService) ListEntries() ([]dao.PaymentEntryConfig, error) {
  41. return s.repo.EntryList()
  42. }
  43. func (s *PaymentService) ListEnabledEntries() ([]dao.PaymentEntryConfig, error) {
  44. entries, err := s.repo.EntryList()
  45. if err != nil {
  46. return nil, err
  47. }
  48. result := make([]dao.PaymentEntryConfig, 0, len(entries))
  49. for _, entry := range entries {
  50. if entry.Enabled {
  51. result = append(result, entry)
  52. }
  53. }
  54. return result, nil
  55. }
  56. func (s *PaymentService) ListEnabledMethods(entryCode string) ([]dao.PaymentMethodConfig, error) {
  57. if entryCode != "" && entryCode != EntryCounter && entryCode != EntryTicket {
  58. // Custom entries are valid too; only reject an empty value at the API
  59. // boundary where an entry is required.
  60. if _, err := s.repo.GetEntryByCode(nil, entryCode); err != nil {
  61. return nil, errors.New("无效的支付入口")
  62. }
  63. }
  64. methods, err := s.repo.EnabledMethodList(global.GVA_DB, entryCode)
  65. if err == nil {
  66. return methods, nil
  67. }
  68. // Existing installations created before the split may not have the
  69. // relation tables yet. Keep their old scene data usable until startup
  70. // migration has completed.
  71. if strings.Contains(strings.ToLower(err.Error()), "no such table") {
  72. return s.listLegacyEnabledMethods(entryCode), nil
  73. }
  74. return nil, err
  75. }
  76. func (s *PaymentService) listLegacyEnabledMethods(entryCode string) []dao.PaymentMethodConfig {
  77. methods, err := s.repo.MethodList()
  78. if err != nil {
  79. return nil
  80. }
  81. result := make([]dao.PaymentMethodConfig, 0, len(methods))
  82. for _, method := range methods {
  83. if method.Enabled && containsScene(method.Scenes, entryCode) {
  84. result = append(result, method)
  85. }
  86. }
  87. return result
  88. }
  89. func normalizeCodes(codes []string) []string {
  90. seen := make(map[string]bool, len(codes))
  91. result := make([]string, 0, len(codes))
  92. for _, code := range codes {
  93. code = strings.ToLower(strings.TrimSpace(code))
  94. if code != "" && !seen[code] {
  95. seen[code] = true
  96. result = append(result, code)
  97. }
  98. }
  99. return result
  100. }
  101. func normalizeMethod(method *dao.PaymentMethodConfig) error {
  102. method.Code = strings.ToLower(strings.TrimSpace(method.Code))
  103. method.Name = strings.TrimSpace(method.Name)
  104. method.InputMode = strings.TrimSpace(method.InputMode)
  105. method.Description = strings.TrimSpace(method.Description)
  106. if !paymentCodePattern.MatchString(method.Code) {
  107. return errors.New("支付编码必须以小写字母开头,只能包含小写字母、数字和下划线,长度为2-32位")
  108. }
  109. if method.Name == "" || len([]rune(method.Name)) > 50 {
  110. return errors.New("支付方式名称不能为空且不能超过50个字符")
  111. }
  112. validModes := map[string]bool{InputModeManualAmount: true, InputModeConfirm: true, InputModeExternal: true}
  113. if !validModes[method.InputMode] {
  114. return errors.New("无效的金额处理方式")
  115. }
  116. method.EntryCodes = normalizeCodes(method.EntryCodes)
  117. // Scenes is only read during migration. Do not use it as a runtime
  118. // authorization source after payment entries have been configured.
  119. method.Scenes = normalizeCodes(method.Scenes)
  120. return nil
  121. }
  122. func (s *PaymentService) CreateMethod(method *dao.PaymentMethodConfig) error {
  123. if err := normalizeMethod(method); err != nil {
  124. return err
  125. }
  126. method.IsSystem = false
  127. err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  128. if err := s.repo.CreateMethodTx(tx, method); err != nil {
  129. if strings.Contains(strings.ToLower(err.Error()), "unique") {
  130. return errors.New("支付编码已存在")
  131. }
  132. return fmt.Errorf("新增支付方式失败: %w", err)
  133. }
  134. if len(method.EntryCodes) > 0 {
  135. if err := s.validateMethodEntryCodes(tx, method.EntryCodes); err != nil {
  136. return err
  137. }
  138. return s.repo.ReplaceMethodEntries(tx, method.Code, method.EntryCodes)
  139. }
  140. return nil
  141. })
  142. return err
  143. }
  144. func (s *PaymentService) UpdateMethod(id uint, updates *dao.PaymentMethodConfig) error {
  145. current, err := s.repo.GetMethodByID(id)
  146. if err != nil {
  147. return errors.New("支付方式不存在")
  148. }
  149. updates.Code = current.Code
  150. updates.IsSystem = current.IsSystem
  151. updates.EntryCodes = nil
  152. if current.Code == PaymentCash {
  153. updates.InputMode = InputModeManualAmount
  154. updates.Enabled = true
  155. }
  156. if err := normalizeMethod(updates); err != nil {
  157. return err
  158. }
  159. updates.ID = current.ID
  160. return s.repo.UpdateMethod(updates)
  161. }
  162. func (s *PaymentService) SetMethodEnabled(id uint, enabled bool) error {
  163. method, err := s.repo.GetMethodByID(id)
  164. if err != nil {
  165. return errors.New("支付方式不存在")
  166. }
  167. if method.Code == PaymentCash && !enabled {
  168. return errors.New("人工支付是基础功能,不能停用")
  169. }
  170. return s.repo.SetMethodEnabled(id, enabled)
  171. }
  172. func (s *PaymentService) DeleteMethod(id uint) error {
  173. method, err := s.repo.GetMethodByID(id)
  174. if err != nil {
  175. return errors.New("支付方式不存在")
  176. }
  177. if method.IsSystem {
  178. return errors.New("系统内置支付方式不能删除")
  179. }
  180. return s.repo.DeleteMethod(method)
  181. }
  182. func normalizeEntry(entry *dao.PaymentEntryConfig) error {
  183. entry.Code = strings.ToLower(strings.TrimSpace(entry.Code))
  184. entry.Name = strings.TrimSpace(entry.Name)
  185. entry.Description = strings.TrimSpace(entry.Description)
  186. if !paymentCodePattern.MatchString(entry.Code) {
  187. return errors.New("支付入口编码必须以小写字母开头,只能包含小写字母、数字和下划线,长度为2-32位")
  188. }
  189. if entry.Name == "" || len([]rune(entry.Name)) > 50 {
  190. return errors.New("支付入口名称不能为空且不能超过50个字符")
  191. }
  192. entry.MethodCodes = normalizeCodes(entry.MethodCodes)
  193. if !containsScene(entry.MethodCodes, PaymentCash) {
  194. entry.MethodCodes = append([]string{PaymentCash}, entry.MethodCodes...)
  195. }
  196. return nil
  197. }
  198. func (s *PaymentService) validateEntryCodes(db *gorm.DB, codes []string) error {
  199. for _, code := range codes {
  200. method, err := s.repo.GetMethodByCode(db, code)
  201. if err != nil || method.IsLegacy {
  202. return fmt.Errorf("支付方式“%s”不存在或不可用", code)
  203. }
  204. }
  205. return nil
  206. }
  207. func (s *PaymentService) validateMethodEntryCodes(db *gorm.DB, codes []string) error {
  208. for _, code := range codes {
  209. if _, err := s.repo.GetEntryByCode(db, code); err != nil {
  210. return fmt.Errorf("支付入口“%s”不存在", code)
  211. }
  212. }
  213. return nil
  214. }
  215. func (s *PaymentService) CreateEntry(entry *dao.PaymentEntryConfig) error {
  216. if err := normalizeEntry(entry); err != nil {
  217. return err
  218. }
  219. if err := s.validateEntryCodes(global.GVA_DB, entry.MethodCodes); err != nil {
  220. return err
  221. }
  222. entry.IsSystem = false
  223. return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  224. if err := s.repo.CreateEntryTx(tx, entry); err != nil {
  225. if strings.Contains(strings.ToLower(err.Error()), "unique") {
  226. return errors.New("支付入口编码已存在")
  227. }
  228. return fmt.Errorf("新增支付入口失败: %w", err)
  229. }
  230. return s.repo.ReplaceEntryMethods(tx, entry.Code, entry.MethodCodes)
  231. })
  232. }
  233. func (s *PaymentService) UpdateEntry(id uint, updates *dao.PaymentEntryConfig) error {
  234. current, err := s.repo.GetEntryByID(id)
  235. if err != nil {
  236. return errors.New("支付入口不存在")
  237. }
  238. updates.Code = current.Code
  239. updates.IsSystem = current.IsSystem
  240. if err := normalizeEntry(updates); err != nil {
  241. return err
  242. }
  243. if err := s.validateEntryCodes(global.GVA_DB, updates.MethodCodes); err != nil {
  244. return err
  245. }
  246. updates.ID = current.ID
  247. return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  248. if err := s.repo.UpdateEntryTx(tx, updates); err != nil {
  249. return err
  250. }
  251. return s.repo.ReplaceEntryMethods(tx, current.Code, updates.MethodCodes)
  252. })
  253. }
  254. func (s *PaymentService) SetEntryEnabled(id uint, enabled bool) error {
  255. if _, err := s.repo.GetEntryByID(id); err != nil {
  256. return errors.New("支付入口不存在")
  257. }
  258. return s.repo.SetEntryEnabled(id, enabled)
  259. }
  260. func (s *PaymentService) DeleteEntry(id uint) error {
  261. entry, err := s.repo.GetEntryByID(id)
  262. if err != nil {
  263. return errors.New("支付入口不存在")
  264. }
  265. if entry.IsSystem {
  266. return errors.New("系统内置支付入口不能删除")
  267. }
  268. return s.repo.DeleteEntry(entry)
  269. }
  270. func containsScene(scenes []string, scene string) bool {
  271. for _, item := range scenes {
  272. if item == scene {
  273. return true
  274. }
  275. }
  276. return false
  277. }
  278. func (s *PaymentService) ValidateMethod(db *gorm.DB, code, entryCode string) (*dao.PaymentMethodConfig, error) {
  279. if code == PaymentFree {
  280. if entryCode != EntryCounter {
  281. return nil, errors.New("免密放行不能用于当前支付场景")
  282. }
  283. entry, err := s.repo.GetEntryByCode(db, entryCode)
  284. if err == nil && !entry.Enabled {
  285. return nil, fmt.Errorf("支付入口“%s”已停用", entry.Name)
  286. }
  287. if err != nil && !strings.Contains(strings.ToLower(err.Error()), "no such table") {
  288. return nil, errors.New("支付入口不存在")
  289. }
  290. return &dao.PaymentMethodConfig{Code: PaymentFree, Name: "免密放行", InputMode: InputModeConfirm, Enabled: true}, nil
  291. }
  292. entry, entryErr := s.repo.GetEntryByCode(db, entryCode)
  293. method, methodErr := s.repo.GetMethodByCode(db, code)
  294. if methodErr != nil {
  295. return nil, errors.New("支付方式不存在")
  296. }
  297. if !method.Enabled {
  298. return nil, fmt.Errorf("支付方式“%s”已停用", method.Name)
  299. }
  300. if entryErr != nil {
  301. if strings.Contains(strings.ToLower(entryErr.Error()), "no such table") && methodErr == nil {
  302. if containsScene(method.Scenes, entryCode) {
  303. return method, nil
  304. }
  305. return nil, fmt.Errorf("支付方式“%s”不适用于当前场景", method.Name)
  306. }
  307. return nil, errors.New("支付入口不存在")
  308. }
  309. if !entry.Enabled {
  310. return nil, fmt.Errorf("支付入口“%s”已停用", entry.Name)
  311. }
  312. allowed, err := s.repo.IsMethodAllowed(db, entryCode, code)
  313. if err != nil {
  314. if strings.Contains(strings.ToLower(err.Error()), "no such table") && containsScene(method.Scenes, entryCode) {
  315. return method, nil
  316. }
  317. return nil, err
  318. }
  319. if !allowed {
  320. return nil, fmt.Errorf("支付方式“%s”未配置到当前支付入口", method.Name)
  321. }
  322. return method, nil
  323. }
  324. func (s *PaymentService) ValidatePayment(db *gorm.DB, code, entryCode string, amount, paidAmount float64) (*dao.PaymentMethodConfig, float64, error) {
  325. method, err := s.ValidateMethod(db, code, entryCode)
  326. if err != nil {
  327. return nil, 0, err
  328. }
  329. if amount < 0 || paidAmount < 0 {
  330. return nil, 0, errors.New("支付金额不能为负数")
  331. }
  332. if code == PaymentFree {
  333. return method, 0, nil
  334. }
  335. if method.InputMode == InputModeManualAmount {
  336. if paidAmount < amount {
  337. return nil, 0, errors.New("实收金额不足")
  338. }
  339. return method, paidAmount, nil
  340. }
  341. return method, amount, nil
  342. }
  343. // ProcessPayment creates a payment_record and updates vehicle_record payment status
  344. func (s *PaymentService) ProcessPayment(recordID uint, paymentMethod string, amount float64, paidAmount float64, operatorID uint) (*dao.PaymentRecord, error) {
  345. return s.ProcessPaymentTx(global.GVA_DB, recordID, paymentMethod, amount, paidAmount, operatorID, EntryCounter)
  346. }
  347. // ProcessPaymentTx keeps the pre-split argument order for callers compiled
  348. // against the old service. The final argument is now interpreted as the
  349. // payment entry.
  350. func (s *PaymentService) ProcessPaymentTx(db *gorm.DB, recordID uint, paymentMethod string, amount float64, paidAmount float64, operatorID uint, paymentEntry string) (*dao.PaymentRecord, error) {
  351. return s.ProcessPaymentTxAtEntry(db, recordID, paymentEntry, paymentMethod, amount, paidAmount, operatorID)
  352. }
  353. func (s *PaymentService) ProcessPaymentTxAtEntry(db *gorm.DB, recordID uint, paymentEntry string, paymentMethod string, amount float64, paidAmount float64, operatorID uint) (*dao.PaymentRecord, error) {
  354. method, paidAmount, err := s.ValidatePayment(db, paymentMethod, paymentEntry, amount, paidAmount)
  355. if err != nil {
  356. return nil, err
  357. }
  358. changeAmount := 0.0
  359. if method.InputMode == InputModeManualAmount && paidAmount > amount {
  360. changeAmount = paidAmount - amount
  361. }
  362. now := time.Now()
  363. pr := &dao.PaymentRecord{
  364. RecordID: recordID,
  365. PaymentEntry: paymentEntry,
  366. PaymentMethod: paymentMethod,
  367. Amount: amount,
  368. PaidAmount: paidAmount,
  369. ChangeAmount: changeAmount,
  370. OperatorID: operatorID,
  371. PaidAt: now,
  372. }
  373. if err := db.Create(pr).Error; err != nil {
  374. return nil, fmt.Errorf("创建支付记录失败: %w", err)
  375. }
  376. // Update vehicle_record payment status
  377. if err := db.Model(&dao.VehicleRecord{}).Where("id = ?", recordID).Updates(map[string]interface{}{
  378. "payment_status": "paid",
  379. "payment_entry": paymentEntry,
  380. "payment_method": paymentMethod,
  381. }).Error; err != nil {
  382. return nil, fmt.Errorf("更新支付状态失败: %w", err)
  383. }
  384. return pr, nil
  385. }
  386. // QueryPayments 查询收费记录列表
  387. func (s *PaymentService) QueryPayments(q repository.PaymentListQuery) ([]repository.PaymentListResult, int64, error) {
  388. return s.repo.List(q)
  389. }
  390. // GetTodaySummary 获取当日收费汇总
  391. func (s *PaymentService) GetTodaySummary() (repository.TodaySummary, error) {
  392. return s.repo.GetTodaySummary()
  393. }