cetustek 0.9.0 → 0.10.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +32 -3
- data/lib/cetustek/cancel_invoice.rb +2 -0
- data/lib/cetustek/queries.rb +62 -43
- data/lib/cetustek/query_invoice_by_order_id.rb +6 -0
- data/lib/cetustek/version.rb +1 -1
- data/lib/cetustek/xml.rb +29 -0
- data/lib/cetustek.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 90bc8844eac9b3eb305d59f5550be7bf6e552b0fffafe74b0e71e6afc31c55ef
|
|
4
|
+
data.tar.gz: a9337fd9f37f80a3ec6532a319c4eac87ce1db79a475d7e237e3f2b59567a850
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 57e5c7e15f119c182c8d0f38ef0c98ce40c682bc0262ed7043a09ed8c379a2603ae88d581bef8bcdc9e0f0a95cc5be85e6a6dc502f7c883bb7f6e74c9ebd8fa3
|
|
7
|
+
data.tar.gz: c267be5d85ea54c917d932453a910bfcca464b4e5441ad4f2c98aec3e4b2f567712e65617e6250186ebd6164ec39f4157ad12014cfce6445847afe7c8db1b926
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.10.0] - 2026-08-07
|
|
9
|
+
|
|
10
|
+
延續 0.9.0 的 code review:統一驗證力度與查詢介面,讓四個查詢類別與
|
|
11
|
+
`CancelInvoice`/`CancelAllowance` 的行為一致。
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `QueryInvoice.find`、`QueryInvoiceByOrderId.find`、`QueryInvoiceNumberByOrderId.find`:
|
|
15
|
+
比照既有的 `QueryAllowance.find`,把 Savon 回應解析成 snake_case 的 Hash(`QueryInvoice`
|
|
16
|
+
與 `QueryInvoiceByOrderId` 共用 Table 13 格式,含 `:seller`/`:buyer`/`:details`),
|
|
17
|
+
`nil` 表示空回覆或規格說的 `"nodata"`,非 XML 的代碼字串一律拋 `Cetustek::ResultError`
|
|
18
|
+
- `CancelInvoice` 補上 `invoice_number`/`invoice_year` 必填檢查(Table 9 皆為 Y),
|
|
19
|
+
原本只驗證 `remark`,另兩個欄位空白時要等伺服器回 `M:?` 才會發現
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- 四個查詢類別的 XML 解析共用邏輯(`text_of`/`snake_case`)搬到 `Cetustek::Xml.parse_fields`,
|
|
23
|
+
不再各自留一份幾乎相同的 private method
|
|
24
|
+
- `QueryInvoice`/`QueryInvoiceNumberByOrderId`/`QueryAllowance` 改用 `Soap#soap_call`,
|
|
25
|
+
不再手動組 `source`/`rentid`,與 `QueryInvoiceByOrderId`/`CancelInvoice` 等類別一致
|
|
26
|
+
- `QueryInvoice.parse`/`QueryAllowance.parse` 共用的「空值/nodata 判斷、非 XML 代碼丟
|
|
27
|
+
`ResultError`、解析成 Ox root」邏輯收成 `Cetustek::Xml.parse_response`
|
|
28
|
+
- `QueryInvoice.party` 改名 `parse_party`,跟同一個類別的 `parse` 放在一起看更清楚
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- **Breaking:** `QueryInvoiceNumberByOrderId.find` 原本會把非發票號碼、非 `"nodata"`
|
|
32
|
+
的回覆(如 `Invalid`)直接當發票號碼回傳;現在會比對 Table 9 的字軌+8碼數字格式,
|
|
33
|
+
格式不符者一律丟 `Cetustek::ResultError`,跟其他 `Query*.find` 一致
|
|
34
|
+
|
|
8
35
|
## [0.9.0] - 2026-08-05
|
|
9
36
|
|
|
10
37
|
比對規格 AVM-26-03 做的 code review 修正:0.8.0 有幾條規則寫得比規格寬或比規格嚴,
|
data/README.md
CHANGED
|
@@ -212,6 +212,10 @@ Cetustek::CancelInvoice.new('AB12345678', 2024, remark: '明細錯誤',
|
|
|
212
212
|
return_tax_document_number: '65327645').execute
|
|
213
213
|
```
|
|
214
214
|
|
|
215
|
+
`invoice_number` and `invoice_year` are Table 9's other two `必填` fields, and
|
|
216
|
+
raise `ArgumentError` if left blank — the same guaranteed-rejection guarantee
|
|
217
|
+
`InvoiceData`/`AllowanceData` give for their own required fields.
|
|
218
|
+
|
|
215
219
|
Uploading is not the end of it: the cancellation still has to be confirmed
|
|
216
220
|
manually on the 鯨躍 platform before the invoice counts as void. Any code other
|
|
217
221
|
than `"C0"` raises `Cetustek::ResultError` (`C5 - 該發票已經作廢過`, …), and
|
|
@@ -219,10 +223,35 @@ marking your own record as canceled is the caller's job.
|
|
|
219
223
|
|
|
220
224
|
### Query invoices
|
|
221
225
|
|
|
226
|
+
Each query class has both a raw `.query` (the Savon response, for callers who
|
|
227
|
+
want the untouched SOAP object) and a `.find` that parses the same response
|
|
228
|
+
into a plain Ruby value, mirroring `QueryAllowance` below:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
Cetustek::QueryInvoiceByOrderId.query(order_id) # raw Savon response
|
|
232
|
+
Cetustek::QueryInvoiceByOrderId.find(order_id) # parsed Hash, or nil
|
|
233
|
+
Cetustek::QueryInvoice.query(invoice_number, invoice_year) # raw Savon response
|
|
234
|
+
Cetustek::QueryInvoice.find(invoice_number, invoice_year) # parsed Hash, or nil
|
|
235
|
+
Cetustek::QueryInvoiceNumberByOrderId.query(order_id) # raw Savon response
|
|
236
|
+
Cetustek::QueryInvoiceNumberByOrderId.find(order_id) # bare invoice number String, or nil
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`.find` returns `nil` when the platform answers with nothing at all or with
|
|
240
|
+
the documented `"nodata"`; a non-XML answer (a result code) is raised as
|
|
241
|
+
`Cetustek::ResultError`, same as `QueryAllowance.find`.
|
|
242
|
+
|
|
243
|
+
`QueryInvoice.find`/`QueryInvoiceByOrderId.find` (both share Table 13's XML
|
|
244
|
+
shape) return the fields as snake_case symbols, with `:seller`/`:buyer`
|
|
245
|
+
sub-hashes and the line items under `:details`:
|
|
246
|
+
|
|
222
247
|
```ruby
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
248
|
+
{ order_id: '44556655', invoice_number: 'AA00000027', invoice_date: '2011/05/19',
|
|
249
|
+
invoice_time: '19:10:49', invoice_status: '開立', donate_mark: '0',
|
|
250
|
+
sales_amount: '120', tax_amount: '6', total_amount: '126', ctk_url: nil,
|
|
251
|
+
seller: { identifier: '53118823', name: '鯨躍科技有限公司', ... },
|
|
252
|
+
buyer: { identifier: '55669988', name: '漢客料理店', ... },
|
|
253
|
+
details: [{ product_code: 'AA783457', description: '筆記本(綠色)', quantity: '1',
|
|
254
|
+
unit: nil, unit_price: '60', amount: '60', sequence_number: '1' }] }
|
|
226
255
|
```
|
|
227
256
|
|
|
228
257
|
### Tax-inclusive vs tax-exclusive prices (`hastax`)
|
|
@@ -22,6 +22,8 @@ module Cetustek
|
|
|
22
22
|
|
|
23
23
|
# remark 是 Table 9 的必填作廢原因,沒有預設值可用:理由是業務決定的。
|
|
24
24
|
def initialize(invoice_number, invoice_year, remark:, return_tax_document_number: nil)
|
|
25
|
+
raise ArgumentError, 'invoice_number is required' if invoice_number.to_s.strip.empty?
|
|
26
|
+
raise ArgumentError, 'invoice_year is required' if invoice_year.to_s.strip.empty?
|
|
25
27
|
raise ArgumentError, 'remark (作廢原因) is required' if remark.to_s.strip.empty?
|
|
26
28
|
raise ArgumentError, "remark must not exceed #{MAX_REMARK_LENGTH} characters" if remark.length > MAX_REMARK_LENGTH
|
|
27
29
|
|
data/lib/cetustek/queries.rb
CHANGED
|
@@ -1,41 +1,81 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'ox'
|
|
4
|
-
|
|
5
3
|
module Cetustek
|
|
6
4
|
# @deprecated use Cetustek::Soap. Kept so `extend Queries` keeps working.
|
|
7
5
|
Queries = Soap
|
|
8
6
|
|
|
9
7
|
# 2.4 QueryInvoice 查詢發票資訊 (by invoice number + year)
|
|
10
8
|
class QueryInvoice
|
|
11
|
-
extend
|
|
9
|
+
extend Soap
|
|
10
|
+
|
|
11
|
+
# Spec AVM-26-03 Table 13.
|
|
12
|
+
FIELDS = %w[OrderID InvoiceNumber InvoiceDate InvoiceTime MainRemark CheckNumber
|
|
13
|
+
RandomNumber InvoiceStatus DonateMark SalesAmount FreeTaxSalesAmount
|
|
14
|
+
ZeroTaxSalesAmount TaxAmount TotalAmount CtkUrl].freeze
|
|
15
|
+
PARTY_FIELDS = %w[Identifier Name Address PersonInCharge TelephoneNumber
|
|
16
|
+
FacsimileNumber EmailAddress].freeze
|
|
17
|
+
DETAIL_FIELDS = %w[ProductCode Description Quantity Unit UnitPrice Amount SequenceNumber].freeze
|
|
12
18
|
|
|
13
19
|
def self.query(invoice_number, invoice_year)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
soap_call(:query_invoice, invoicenumber: invoice_number, invoiceyear: invoice_year)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Same query, with the returned XML parsed into a Hash of snake_case keys
|
|
24
|
+
# plus :seller/:buyer sub-hashes and a :details array. nil when the
|
|
25
|
+
# platform answers with nothing at all, or the documented "nodata".
|
|
26
|
+
def self.find(invoice_number, invoice_year)
|
|
27
|
+
parse(soap_return(query(invoice_number, invoice_year), :query_invoice))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# §2.5 QueryInvoicebyOrderid shares this exact XML shape (Table 13), so
|
|
31
|
+
# QueryInvoiceByOrderId#find reuses this instead of duplicating it.
|
|
32
|
+
def self.parse(xml)
|
|
33
|
+
root = Xml.parse_response(xml, nil_values: %w[nodata])
|
|
34
|
+
return nil unless root
|
|
35
|
+
|
|
36
|
+
data = Xml.parse_fields(root, FIELDS)
|
|
37
|
+
data[:seller] = parse_party(root, 'Seller')
|
|
38
|
+
data[:buyer] = parse_party(root, 'Buyer')
|
|
39
|
+
data[:details] = root.locate('Details/ProductItem').map { |item| Xml.parse_fields(item, DETAIL_FIELDS) }
|
|
40
|
+
data
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.parse_party(root, name)
|
|
44
|
+
element = root.locate(name).first
|
|
45
|
+
element && Xml.parse_fields(element, PARTY_FIELDS)
|
|
20
46
|
end
|
|
47
|
+
|
|
48
|
+
private_class_method :parse_party
|
|
21
49
|
end
|
|
22
50
|
|
|
23
51
|
# 2.6 QueryInvoiceNumberbyOrderid 以訂單編號查詢發票號碼
|
|
24
52
|
class QueryInvoiceNumberByOrderId
|
|
25
|
-
extend
|
|
53
|
+
extend Soap
|
|
54
|
+
|
|
55
|
+
# 發票號碼格式,字軌 2 碼大寫字母+8 碼數字 (Table 9). Anything else that
|
|
56
|
+
# isn't the "nodata" sentinel is a bare result code, not a number.
|
|
57
|
+
FORMAT = /\A[A-Z]{2}\d{8}\z/
|
|
26
58
|
|
|
27
59
|
def self.query(order_id)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
60
|
+
soap_call(:query_invoice_number_by_orderid, orderid: order_id)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Same query, returning just the bare invoice number string, or nil for
|
|
64
|
+
# an empty response or the documented "nodata". A response that isn't
|
|
65
|
+
# either shape is a result code, raised as ResultError like the other
|
|
66
|
+
# Query* classes — it used to be returned as if it were the number.
|
|
67
|
+
def self.find(order_id)
|
|
68
|
+
body = soap_return(query(order_id), :query_invoice_number_by_orderid).to_s.strip
|
|
69
|
+
return nil if body.empty? || body == 'nodata'
|
|
70
|
+
return body if body.match?(FORMAT)
|
|
71
|
+
|
|
72
|
+
ResultCode.raise!(body, ResultCode::COMMON)
|
|
33
73
|
end
|
|
34
74
|
end
|
|
35
75
|
|
|
36
76
|
# 2.11 QueryAllowance 查詢折讓資料
|
|
37
77
|
class QueryAllowance
|
|
38
|
-
extend
|
|
78
|
+
extend Soap
|
|
39
79
|
|
|
40
80
|
# Spec AVM-26-03 Table 20. Note the query response uses ProductCode and
|
|
41
81
|
# InvoiceDate, unlike CreateAllowance's ProductionCode and InvoiceYear.
|
|
@@ -46,11 +86,7 @@ module Cetustek
|
|
|
46
86
|
UnitPrice Amount Tax TaxType].freeze
|
|
47
87
|
|
|
48
88
|
def self.query(allowance_number)
|
|
49
|
-
|
|
50
|
-
allowancenumber: allowance_number,
|
|
51
|
-
source: source,
|
|
52
|
-
rentid: rentid
|
|
53
|
-
})
|
|
89
|
+
soap_call(:query_allowance, allowancenumber: allowance_number)
|
|
54
90
|
end
|
|
55
91
|
|
|
56
92
|
# Same query, with the returned XML parsed into a Hash of snake_case keys
|
|
@@ -61,29 +97,12 @@ module Cetustek
|
|
|
61
97
|
end
|
|
62
98
|
|
|
63
99
|
def self.parse(xml)
|
|
64
|
-
|
|
65
|
-
return nil
|
|
66
|
-
# §2.11 只描述成功時的 XML;非 XML 的回覆是代碼字串,原樣拋給呼叫端。
|
|
67
|
-
ResultCode.raise!(body, ResultCode::COMMON) unless body.start_with?('<')
|
|
68
|
-
|
|
69
|
-
root = Ox.parse(body)
|
|
70
|
-
root = root.root if root.is_a?(Ox::Document)
|
|
71
|
-
|
|
72
|
-
data = FIELDS.to_h { |field| [snake_case(field), text_of(root, field)] }
|
|
73
|
-
data[:details] = root.locate('Details/ProductItem').map do |item|
|
|
74
|
-
DETAIL_FIELDS.to_h { |field| [snake_case(field), text_of(item, field)] }
|
|
75
|
-
end
|
|
76
|
-
data
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def self.text_of(element, name)
|
|
80
|
-
element.locate(name).first&.text
|
|
81
|
-
end
|
|
100
|
+
root = Xml.parse_response(xml)
|
|
101
|
+
return nil unless root
|
|
82
102
|
|
|
83
|
-
|
|
84
|
-
|
|
103
|
+
data = Xml.parse_fields(root, FIELDS)
|
|
104
|
+
data[:details] = root.locate('Details/ProductItem').map { |item| Xml.parse_fields(item, DETAIL_FIELDS) }
|
|
105
|
+
data
|
|
85
106
|
end
|
|
86
|
-
|
|
87
|
-
private_class_method :text_of, :snake_case
|
|
88
107
|
end
|
|
89
108
|
end
|
|
@@ -8,5 +8,11 @@ module Cetustek
|
|
|
8
8
|
def self.query(order_id)
|
|
9
9
|
soap_call(:query_invoice_by_orderid, orderid: order_id)
|
|
10
10
|
end
|
|
11
|
+
|
|
12
|
+
# Same query, parsed into a Hash. §2.5 shares Table 13's XML shape with
|
|
13
|
+
# §2.4, so this reuses QueryInvoice.parse instead of duplicating it.
|
|
14
|
+
def self.find(order_id)
|
|
15
|
+
QueryInvoice.parse(soap_return(query(order_id), :query_invoice_by_orderid))
|
|
16
|
+
end
|
|
11
17
|
end
|
|
12
18
|
end
|
data/lib/cetustek/version.rb
CHANGED
data/lib/cetustek/xml.rb
CHANGED
|
@@ -39,5 +39,34 @@ module Cetustek
|
|
|
39
39
|
|
|
40
40
|
element << tag(name, value)
|
|
41
41
|
end
|
|
42
|
+
|
|
43
|
+
# Shared guard every Query* class needs before it can parse a response
|
|
44
|
+
# body: nil for an empty answer or one of the documented sentinel strings
|
|
45
|
+
# (e.g. "nodata"); ResultError for a bare result code; otherwise the
|
|
46
|
+
# parsed root element, ready for parse_fields.
|
|
47
|
+
def parse_response(body, nil_values: [])
|
|
48
|
+
text = body.to_s.strip
|
|
49
|
+
return nil if text.empty? || nil_values.include?(text)
|
|
50
|
+
|
|
51
|
+
ResultCode.raise!(text, ResultCode::COMMON) unless text.start_with?('<')
|
|
52
|
+
|
|
53
|
+
root = Ox.parse(text)
|
|
54
|
+
root.is_a?(Ox::Document) ? root.root : root
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Shared response-parsing side: every Query* class turns a response
|
|
58
|
+
# element into a Hash of snake_case keys, so this lives here once instead
|
|
59
|
+
# of once per query class.
|
|
60
|
+
def parse_fields(element, fields)
|
|
61
|
+
fields.to_h { |field| [snake_case(field), text_of(element, field)] }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def text_of(element, name)
|
|
65
|
+
element&.locate(name)&.first&.text
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def snake_case(name)
|
|
69
|
+
name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
|
|
70
|
+
end
|
|
42
71
|
end
|
|
43
72
|
end
|
data/lib/cetustek.rb
CHANGED
|
@@ -8,8 +8,8 @@ require_relative "cetustek/soap"
|
|
|
8
8
|
require_relative "cetustek/models/allowance_data"
|
|
9
9
|
require_relative "cetustek/create_invoice"
|
|
10
10
|
require_relative "cetustek/cancel_invoice"
|
|
11
|
-
require_relative "cetustek/query_invoice_by_order_id"
|
|
12
11
|
require_relative "cetustek/queries"
|
|
12
|
+
require_relative "cetustek/query_invoice_by_order_id"
|
|
13
13
|
require_relative "cetustek/create_allowance"
|
|
14
14
|
require_relative "cetustek/cancel_allowance"
|
|
15
15
|
require_relative "cetustek/phone_barcode"
|