digiwin_dsp 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dfd6bd54cc74a0a92198baf85f66c940882a9ef23e9f8c957fcf1ce10e137725
4
+ data.tar.gz: 07f3727ce554845d88f989a89206ec814ce83e8ddbf3c3f9587f4ca60c3b6131
5
+ SHA512:
6
+ metadata.gz: 21846a4c401f66c10df4f2fee23af8291c8c9819b64393285333762ddee19541fecc799dcc446f7e7d6b30d7529046ddbe221979dc043e3455aef141878d539e
7
+ data.tar.gz: dcd9694dd636c648a5e973980542a99eb476f421bb18d082934370749ca7d61a2473146183a54eda64d3d37d2e587f7f4147371eeccecb39e839c0125ddc6543
data/CHANGELOG.md ADDED
@@ -0,0 +1,51 @@
1
+ # Changelog
2
+
3
+ All notable changes to `digiwin_dsp` are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Added
10
+
11
+ - Release workflow (`.github/workflows/release.yml`): tag push (`v*`) triggers RubyGems publish via OIDC Trusted Publishing, runs the full spec suite as a guard, and updates the GitHub Release with the built `.gem` artifact.
12
+
13
+ ## [0.1.0] - 2026-05-21
14
+
15
+ Initial release. Covers the four 自有官網模組 endpoints under `/v1/SalesOrder/*`.
16
+
17
+ ### Added
18
+
19
+ - `DigiwinDsp.configure` block + per-setting ENV fallback (`DIGIWIN_DSP_API_KEY`, `DIGIWIN_DSP_API_SECRET`, `DIGIWIN_DSP_PLATFORM_ID`, `DIGIWIN_DSP_ENV`, `DIGIWIN_DSP_BASE_URL`)
20
+ - `DigiwinDsp::Configuration` — UAT/production base URL resolver, timeout knobs, injectable logger, `#validate!` guard
21
+ - `DigiwinDsp::Authenticator` — static `DSP-api-key` header
22
+ - `DigiwinDsp::Client` — Faraday 2 connection with:
23
+ - `faraday-retry` for HTTP 429 / 5xx (3 attempts, exponential backoff)
24
+ - Path-prefix-safe URL joining (`/DSP_UAT/api/DSP` + `/v1/SalesOrder/add` → correct full URL)
25
+ - JSON serialization / parsing
26
+ - Status-code → exception mapping
27
+ - **Body-envelope `Status`/`Message` inspection** — DSP returns HTTP 200 even on failure, so the gem classifies `Duplicated:`, `Processing:`, `WrongStatus:`, `系統異常:` patterns into typed exceptions
28
+ - Resources, all exposing `#create(records, idempotency_key:, digi_header:)`:
29
+ - `Resources::Order` → `POST /v1/SalesOrder/add`
30
+ - `Resources::Cancellation` → `POST /v1/SalesOrder/cancel`
31
+ - `Resources::Invoice` → `POST /v1/SalesOrder/invoice`
32
+ - `Resources::Return` → `POST /v1/SalesOrder/return`
33
+ - Serializers with per-endpoint `REQUIRED_FIELDS` (22 / 8 / 11 / 19) sharing a `Serializers::Base` module that wraps records into the `digi_body.std_data.parameter.request.request_detail[]` envelope
34
+ - Exception hierarchy: `Error`, `ConfigurationError`, `AuthenticationError`, `ValidationError`, `RateLimitError`, `ServerError`, `NetworkError`, `DuplicateRequestError` — each carrying `#code`, `#dsp_message`, `#http_status`, `#request_id`, `#response_body`
35
+ - `docs/dsp-api-spec.md` summary + `docs/dsp-specs/DSPOOFFICIAL00{1,2,4,5}.yaml` (Digiwin's OpenAPI 3.1 source)
36
+ - 134 RSpec examples, WebMock-driven, 100% line + 100% branch coverage, RuboCop clean
37
+
38
+ ### Tooling
39
+
40
+ - SimpleCov + simplecov-lcov coverage reporting (line + branch); minimum thresholds 80% line / 70% branch
41
+ - GitHub Actions CI workflow (`.github/workflows/ci.yml`): Ruby 3.2 / 3.3 / 3.4 matrix, runs RuboCop + RSpec, uploads `coverage/lcov.info` to Codecov on the 3.3 row
42
+ - CI status + Codecov coverage badges in README
43
+
44
+ ### Design notes
45
+
46
+ - `digi_header` is **omitted by default**; only passed through when caller provides a custom hash. Standard DSP traffic doesn't need it.
47
+ - The gem is **synchronous on purpose**. Callers wrap requests in their own background job runner (e.g. ActiveJob) when needed.
48
+ - Idempotency: clients can send `X-Idempotency-Key` via the `idempotency_key:` kwarg. DSP also dedupes server-side by `form_no + platform_id`.
49
+
50
+ [Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.1.0...HEAD
51
+ [0.1.0]: https://github.com/7a6163/digiwin_dsp/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Zac
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,216 @@
1
+ # digiwin_dsp
2
+
3
+ [![CI](https://github.com/7a6163/digiwin_dsp/actions/workflows/ci.yml/badge.svg)](https://github.com/7a6163/digiwin_dsp/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/7a6163/digiwin_dsp/branch/main/graph/badge.svg)](https://codecov.io/gh/7a6163/digiwin_dsp)
5
+ [![Ruby](https://img.shields.io/badge/ruby-%E2%89%A53.2-CC342D)](https://www.ruby-lang.org/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE.txt)
7
+
8
+ Ruby client for the Digiwin DSP **自有官網模組** (Official Website Module) API. Lets a Rails 自有官網 push orders, cancellations, invoice updates, and returns into the Digiwin ERP through the DSP gateway.
9
+
10
+ | Operation | Resource | Endpoint |
11
+ |---|---|---|
12
+ | 新增訂單 | `DigiwinDsp::Resources::Order` | `POST /v1/SalesOrder/add` |
13
+ | 取消訂單 | `DigiwinDsp::Resources::Cancellation` | `POST /v1/SalesOrder/cancel` |
14
+ | 發票更新 | `DigiwinDsp::Resources::Invoice` | `POST /v1/SalesOrder/invoice` |
15
+ | 退貨 | `DigiwinDsp::Resources::Return` | `POST /v1/SalesOrder/return` |
16
+
17
+ See [`docs/dsp-api-spec.md`](./docs/dsp-api-spec.md) (plus `docs/dsp-specs/*.yaml`) for the wire spec.
18
+
19
+ ## Installation
20
+
21
+ ```ruby
22
+ # Gemfile
23
+ gem "digiwin_dsp"
24
+ ```
25
+
26
+ ```bash
27
+ bundle install
28
+ ```
29
+
30
+ Ruby ≥ 3.2 required.
31
+
32
+ ## Configuration
33
+
34
+ Configure once at boot (e.g. `config/initializers/digiwin_dsp.rb` in Rails):
35
+
36
+ ```ruby
37
+ DigiwinDsp.configure do |c|
38
+ c.api_key = ENV.fetch("DIGIWIN_DSP_API_KEY")
39
+ c.platform_id = ENV.fetch("DIGIWIN_DSP_PLATFORM_ID")
40
+ c.environment = :sandbox # :sandbox (UAT) | :production
41
+ c.logger = Rails.logger # any Logger-like object
42
+ c.timeout = 10 # request timeout (seconds)
43
+ c.open_timeout = 5
44
+ end
45
+ ```
46
+
47
+ Every setting also falls back to an ENV var:
48
+
49
+ | Setting | ENV var | Default |
50
+ |---|---|---|
51
+ | `api_key` | `DIGIWIN_DSP_API_KEY` | _(required)_ |
52
+ | `api_secret` | `DIGIWIN_DSP_API_SECRET` | `nil` |
53
+ | `platform_id` | `DIGIWIN_DSP_PLATFORM_ID` | `nil` |
54
+ | `environment` | `DIGIWIN_DSP_ENV` | `:sandbox` |
55
+ | `base_url` | `DIGIWIN_DSP_BASE_URL` | resolved from `environment` |
56
+ | `timeout` | — | `10` |
57
+ | `open_timeout` | — | `5` |
58
+ | `logger` | — | `Logger.new(IO::NULL)` |
59
+
60
+ Base URLs (resolved from `environment`):
61
+
62
+ - `:sandbox` → `https://digiwindsp.digiwin.com/DSP_UAT/api/DSP`
63
+ - `:production` → `https://digiwindsp.digiwin.com/DSP/api/DSP`
64
+
65
+ See [`.env.local.example`](./.env.local.example) for a starter env file.
66
+
67
+ ## Usage
68
+
69
+ Each resource exposes a single `#create(records, idempotency_key:, digi_header:)` method that returns the parsed `response_detail` array on success or raises a typed exception on failure.
70
+
71
+ ### Create an order
72
+
73
+ ```ruby
74
+ record = {
75
+ "platform_id" => "acme_storefront_test",
76
+ "create_datetime" => Time.now.strftime("%Y-%m-%d %H:%M:%S"),
77
+ "site_no" => "acme_storefront_test",
78
+ "form_no" => "WEB202605200001", # 官網訂單編號
79
+ "order_date" => "20260520",
80
+ "buyer_name" => "王小明",
81
+ "receiver_name" => "王小明",
82
+ "pay_type" => "9104",
83
+ "shipping_type" => "9102",
84
+ "tax_type" => "1",
85
+ "sno" => "1", # line index
86
+ "form_subno" => "1",
87
+ "product_no" => "P-001",
88
+ "product_name" => "測試商品",
89
+ "unit" => "EA",
90
+ "qty" => "1",
91
+ "free_qty" => "0",
92
+ "price" => "100",
93
+ "subtotal" => "100",
94
+ "payment" => "100",
95
+ "order_status" => "3", # 3 = 新增
96
+ "last_record" => "Y" # "Y" on the final line
97
+ }
98
+
99
+ response_detail = DigiwinDsp::Resources::Order.create(record)
100
+ # => [{ "form_no" => "WEB202605200001", ... }]
101
+ ```
102
+
103
+ Multi-line orders: pass an array. Each element must carry the order-level fields plus its own line fields. Set `"last_record" => "Y"` on the final element and `"N"` on the rest:
104
+
105
+ ```ruby
106
+ records = [
107
+ base_fields.merge("sno" => "1", "product_no" => "P-001", "qty" => "1", "last_record" => "N"),
108
+ base_fields.merge("sno" => "2", "product_no" => "P-002", "qty" => "3", "last_record" => "Y")
109
+ ]
110
+
111
+ DigiwinDsp::Resources::Order.create(records)
112
+ ```
113
+
114
+ ### Cancel, invoice update, return
115
+
116
+ ```ruby
117
+ DigiwinDsp::Resources::Cancellation.create(cancel_record)
118
+ DigiwinDsp::Resources::Invoice.create(invoice_record)
119
+ DigiwinDsp::Resources::Return.create(return_record)
120
+ ```
121
+
122
+ Each has its own required-field set (8 / 11 / 19 fields respectively). Inspect `REQUIRED_FIELDS` for the exact list, e.g.:
123
+
124
+ ```ruby
125
+ DigiwinDsp::Serializers::CancellationSerializer::REQUIRED_FIELDS
126
+ ```
127
+
128
+ ### Idempotency
129
+
130
+ Pass `idempotency_key:` to attach an `X-Idempotency-Key` request header. DSP also dedupes server-side by `form_no + platform_id` and returns `Duplicated:訂單不可重複` on a re-send (mapped to `DuplicateRequestError`).
131
+
132
+ ```ruby
133
+ DigiwinDsp::Resources::Order.create(record, idempotency_key: "order-#{record['form_no']}")
134
+ ```
135
+
136
+ ### Background jobs
137
+
138
+ The gem is synchronous on purpose. Wrap calls in your own job runner:
139
+
140
+ ```ruby
141
+ class SyncOrderToDigiwinJob < ApplicationJob
142
+ retry_on DigiwinDsp::RateLimitError, wait: :polynomially_longer, attempts: 5
143
+ discard_on DigiwinDsp::DuplicateRequestError
144
+
145
+ def perform(order_id)
146
+ order = Order.find(order_id)
147
+ DigiwinDsp::Resources::Order.create(order.to_dsp_payload, idempotency_key: "order-#{order.id}")
148
+ end
149
+ end
150
+ ```
151
+
152
+ ## Error handling
153
+
154
+ All exceptions inherit from `DigiwinDsp::Error` and carry rich attributes (`#code`, `#dsp_message`, `#http_status`, `#request_id`, `#response_body`).
155
+
156
+ | Exception | Raised when |
157
+ |---|---|
158
+ | `DigiwinDsp::ConfigurationError` | `api_key` missing at request time |
159
+ | `DigiwinDsp::ValidationError` | Required field missing locally, or DSP returns `WrongStatus:` / `Processing:取消訂單處理中` / HTTP 400 |
160
+ | `DigiwinDsp::AuthenticationError` | HTTP 401 / 403 |
161
+ | `DigiwinDsp::DuplicateRequestError` | DSP returns `Duplicated:` or HTTP 409 |
162
+ | `DigiwinDsp::RateLimitError` | DSP returns `Processing:資料處理中` (retryable) or persistent HTTP 429 |
163
+ | `DigiwinDsp::ServerError` | DSP returns `系統異常:` or HTTP 5xx that exhausts retries |
164
+ | `DigiwinDsp::NetworkError` | TCP connect failure or timeout |
165
+ | `DigiwinDsp::Error` | Catch-all (unmapped failure message or unexpected status) |
166
+
167
+ > ⚠️ Digiwin DSP returns **HTTP 200** even on application-level failure. The gem parses the response body's `Status` / `Message` fields and raises the appropriate typed exception so callers can rescue normally.
168
+
169
+ ```ruby
170
+ begin
171
+ DigiwinDsp::Resources::Order.create(record)
172
+ rescue DigiwinDsp::DuplicateRequestError
173
+ Rails.logger.info("Order already pushed, skipping")
174
+ rescue DigiwinDsp::ValidationError => e
175
+ Rails.logger.error("Payload rejected: #{e.dsp_message}")
176
+ raise
177
+ rescue DigiwinDsp::RateLimitError, DigiwinDsp::ServerError
178
+ raise # let the job retry
179
+ end
180
+ ```
181
+
182
+ ## Custom `digi_header`
183
+
184
+ By default the gem **omits `digi_header`** from the request body (it's only required for certain custom Digiwin integrations). If your DSP setup expects one, pass it through:
185
+
186
+ ```ruby
187
+ DigiwinDsp::Resources::Order.create(
188
+ record,
189
+ digi_header: {
190
+ "digi_host" => { "prod" => "EC-SHOP", "ip" => "10.0.0.42", "timestamp" => "20260520123456789" },
191
+ "digi_service" => { "prod" => "ECP", "name" => "salesorder.add" }
192
+ }
193
+ )
194
+ ```
195
+
196
+ ## Development
197
+
198
+ ```bash
199
+ bin/setup # bundle install
200
+ bundle exec rspec # run the full test suite (134 examples, 100% coverage)
201
+ bundle exec rubocop # lint
202
+ bin/console # IRB with the gem loaded
203
+ ```
204
+
205
+ The full DSP OpenAPI 3.1 specs live under `docs/dsp-specs/`. If Digiwin updates them, replace the YAML files and re-run the test suite — the `REQUIRED_FIELDS` constants in each serializer pin the contract.
206
+
207
+ ## Contributing
208
+
209
+ 1. Fork & branch
210
+ 2. `bin/setup`
211
+ 3. Write tests first (TDD); ensure `bundle exec rspec` and `bundle exec rubocop` are green
212
+ 4. Open a PR
213
+
214
+ ## License
215
+
216
+ MIT. See [`LICENSE.txt`](./LICENSE.txt).
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ class Authenticator
5
+ HEADER_NAME = "DSP-api-key"
6
+
7
+ def initialize(configuration = DigiwinDsp.configuration)
8
+ @configuration = configuration
9
+ end
10
+
11
+ def auth_headers
12
+ @configuration.validate!
13
+ { HEADER_NAME => @configuration.api_key }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+
6
+ module DigiwinDsp
7
+ class Client
8
+ RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
9
+ USER_AGENT = "digiwin_dsp/#{VERSION} (Faraday/#{Faraday::VERSION})".freeze
10
+
11
+ STATUS_ERROR_MAP = {
12
+ 400 => ValidationError,
13
+ 401 => AuthenticationError,
14
+ 403 => AuthenticationError,
15
+ 409 => DuplicateRequestError,
16
+ 429 => RateLimitError
17
+ }.freeze
18
+
19
+ # Order matters — more-specific patterns first.
20
+ ENVELOPE_FAILURE_MAP = [
21
+ [/\ADuplicated:/, DuplicateRequestError],
22
+ [/\AProcessing:資料處理中/, RateLimitError],
23
+ [/\AProcessing:取消訂單處理中/, ValidationError],
24
+ [/\AWrongStatus:/, ValidationError],
25
+ [/\A系統異常:/, ServerError]
26
+ ].freeze
27
+
28
+ def initialize(configuration: DigiwinDsp.configuration, authenticator: nil)
29
+ @configuration = configuration
30
+ @authenticator = authenticator || Authenticator.new(configuration)
31
+ end
32
+
33
+ def post(path, body, idempotency_key: nil, headers: {})
34
+ @configuration.validate!
35
+ response = connection.post(normalize_path(path)) do |req|
36
+ req.headers["X-Idempotency-Key"] = idempotency_key if idempotency_key
37
+ headers.each { |k, v| req.headers[k] = v }
38
+ req.body = body
39
+ end
40
+ handle_response(response)
41
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
42
+ raise NetworkError, e.message
43
+ end
44
+
45
+ private
46
+
47
+ def connection
48
+ @connection ||= Faraday.new(url: connection_base_url, headers: default_headers) do |f|
49
+ f.request :json
50
+ f.request :retry,
51
+ max: 3,
52
+ interval: 0.0,
53
+ backoff_factor: 1,
54
+ retry_statuses: RETRY_STATUSES,
55
+ methods: %i[get post put patch delete]
56
+ f.response :json, content_type: /\bjson\z/
57
+ f.response :logger, @configuration.logger, headers: false, bodies: false, log_level: :debug
58
+ f.adapter Faraday.default_adapter
59
+ f.options.timeout = @configuration.timeout
60
+ f.options.open_timeout = @configuration.open_timeout
61
+ end
62
+ end
63
+
64
+ def connection_base_url
65
+ base = @configuration.base_url
66
+ base.end_with?("/") ? base : "#{base}/"
67
+ end
68
+
69
+ def normalize_path(path)
70
+ path.sub(%r{\A/+}, "")
71
+ end
72
+
73
+ def default_headers
74
+ {
75
+ "User-Agent" => USER_AGENT,
76
+ "Accept" => "application/json"
77
+ }.merge(@authenticator.auth_headers)
78
+ end
79
+
80
+ def handle_response(response)
81
+ status = response.status
82
+ body = response.body
83
+ return inspect_envelope(body) if status.between?(200, 299)
84
+
85
+ raise classify_http_error(status, body)
86
+ end
87
+
88
+ def classify_http_error(status, body)
89
+ klass = STATUS_ERROR_MAP[status] || (status.between?(500, 599) ? ServerError : Error)
90
+ message = klass == Error ? "unexpected HTTP status #{status}" : http_message(status, body)
91
+ klass.new(message, **error_attrs(status, body))
92
+ end
93
+
94
+ def inspect_envelope(body)
95
+ return body unless body.is_a?(Hash) && body["Status"].to_s.casecmp("failure").zero?
96
+
97
+ message = body["Message"].to_s
98
+ klass = ENVELOPE_FAILURE_MAP.find { |regex, _| regex.match?(message) }&.last || Error
99
+ raise klass.new(message, **envelope_error_attrs(body))
100
+ end
101
+
102
+ def http_message(status, body)
103
+ dsp_msg = body.is_a?(Hash) ? body["error_message"] || body["message"] : nil
104
+ dsp_msg ? "HTTP #{status}: #{dsp_msg}" : "HTTP #{status}"
105
+ end
106
+
107
+ def error_attrs(status, body)
108
+ hash = body.is_a?(Hash) ? body : {}
109
+ {
110
+ code: hash["error_code"] || hash["code"],
111
+ dsp_message: hash["error_message"] || hash["message"],
112
+ request_id: hash["request_id"],
113
+ http_status: status,
114
+ response_body: body
115
+ }
116
+ end
117
+
118
+ def envelope_error_attrs(body)
119
+ {
120
+ code: body["Status"],
121
+ dsp_message: body["Message"],
122
+ request_id: body["request_id"],
123
+ http_status: 200,
124
+ response_body: body
125
+ }
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module DigiwinDsp
6
+ class Configuration
7
+ DEFAULT_TIMEOUT = 10
8
+ DEFAULT_OPEN_TIMEOUT = 5
9
+
10
+ BASE_URLS = {
11
+ sandbox: "https://digiwindsp.digiwin.com/DSP_UAT/api/DSP",
12
+ production: "https://digiwindsp.digiwin.com/DSP/api/DSP"
13
+ }.freeze
14
+
15
+ attr_accessor :api_key, :api_secret, :platform_id, :environment, :logger, :timeout, :open_timeout
16
+ attr_writer :base_url
17
+
18
+ def initialize
19
+ @api_key = ENV.fetch("DIGIWIN_DSP_API_KEY", nil)
20
+ @api_secret = ENV.fetch("DIGIWIN_DSP_API_SECRET", nil)
21
+ @platform_id = ENV.fetch("DIGIWIN_DSP_PLATFORM_ID", nil)
22
+ @environment = ENV.fetch("DIGIWIN_DSP_ENV", "sandbox").to_sym
23
+ @base_url = ENV.fetch("DIGIWIN_DSP_BASE_URL", nil)
24
+ @timeout = DEFAULT_TIMEOUT
25
+ @open_timeout = DEFAULT_OPEN_TIMEOUT
26
+ @logger = Logger.new(IO::NULL)
27
+ end
28
+
29
+ def base_url
30
+ return @base_url if @base_url
31
+
32
+ BASE_URLS.fetch(environment) do
33
+ raise ConfigurationError,
34
+ "unknown environment #{environment.inspect}; expected one of #{BASE_URLS.keys.inspect}"
35
+ end
36
+ end
37
+
38
+ def validate!
39
+ return unless api_key.nil? || api_key.to_s.empty?
40
+
41
+ raise ConfigurationError,
42
+ "api_key is required (set via DigiwinDsp.configure or DIGIWIN_DSP_API_KEY)"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ module Resources
5
+ class Cancellation
6
+ PATH = "/v1/SalesOrder/cancel"
7
+
8
+ def self.create(records, **)
9
+ new.create(records, **)
10
+ end
11
+
12
+ def initialize(client = Client.new)
13
+ @client = client
14
+ end
15
+
16
+ def create(records, idempotency_key: nil, digi_header: nil)
17
+ body = Serializers::CancellationSerializer.serialize(records, digi_header: digi_header)
18
+ response = @client.post(PATH, body, idempotency_key: idempotency_key)
19
+ response["response_detail"]
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ module Resources
5
+ class Invoice
6
+ PATH = "/v1/SalesOrder/invoice"
7
+
8
+ def self.create(records, **)
9
+ new.create(records, **)
10
+ end
11
+
12
+ def initialize(client = Client.new)
13
+ @client = client
14
+ end
15
+
16
+ def create(records, idempotency_key: nil, digi_header: nil)
17
+ body = Serializers::InvoiceSerializer.serialize(records, digi_header: digi_header)
18
+ response = @client.post(PATH, body, idempotency_key: idempotency_key)
19
+ response["response_detail"]
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ module Resources
5
+ class Order
6
+ PATH = "/v1/SalesOrder/add"
7
+
8
+ def self.create(records, **)
9
+ new.create(records, **)
10
+ end
11
+
12
+ def initialize(client = Client.new)
13
+ @client = client
14
+ end
15
+
16
+ def create(records, idempotency_key: nil, digi_header: nil)
17
+ body = Serializers::SalesOrderSerializer.serialize(records, digi_header: digi_header)
18
+ response = @client.post(PATH, body, idempotency_key: idempotency_key)
19
+ response["response_detail"]
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ module Resources
5
+ class Return
6
+ PATH = "/v1/SalesOrder/return"
7
+
8
+ def self.create(records, **)
9
+ new.create(records, **)
10
+ end
11
+
12
+ def initialize(client = Client.new)
13
+ @client = client
14
+ end
15
+
16
+ def create(records, idempotency_key: nil, digi_header: nil)
17
+ body = Serializers::ReturnSerializer.serialize(records, digi_header: digi_header)
18
+ response = @client.post(PATH, body, idempotency_key: idempotency_key)
19
+ response["response_detail"]
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ module Serializers
5
+ # Mix into a serializer module via `extend Base`. The hosting module must
6
+ # define a REQUIRED_FIELDS constant (array of string field names).
7
+ module Base
8
+ def serialize(records, digi_header: nil)
9
+ normalized = normalize(records)
10
+ validate!(normalized)
11
+ wrap(normalized, digi_header)
12
+ end
13
+
14
+ private
15
+
16
+ def normalize(records)
17
+ list = records.is_a?(Array) ? records : [records]
18
+ list.map { |r| r.transform_keys(&:to_s) }
19
+ end
20
+
21
+ def validate!(records)
22
+ problems = records.each_with_index.flat_map { |record, index| missing_fields(record, index, records.size) }
23
+ return if problems.empty?
24
+
25
+ raise ValidationError, "missing or empty required fields: #{problems.join(", ")}"
26
+ end
27
+
28
+ def missing_fields(record, index, total)
29
+ missing = self::REQUIRED_FIELDS.reject { |f| present?(record[f]) }
30
+ return [] if missing.empty?
31
+
32
+ prefix = total > 1 ? "[#{index}]." : ""
33
+ missing.map { |f| "#{prefix}#{f}" }
34
+ end
35
+
36
+ def present?(value)
37
+ !value.nil? && !value.to_s.strip.empty?
38
+ end
39
+
40
+ def wrap(records, digi_header)
41
+ body = {
42
+ "digi_body" => {
43
+ "std_data" => {
44
+ "parameter" => {
45
+ "request" => {
46
+ "request_detail" => records
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ digi_header ? { "digi_header" => digi_header }.merge(body) : body
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ module Serializers
5
+ module CancellationSerializer
6
+ extend Base
7
+
8
+ REQUIRED_FIELDS = %w[
9
+ platform_id create_datetime site_no form_no order_date
10
+ sno product_no order_status
11
+ ].freeze
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ module Serializers
5
+ module InvoiceSerializer
6
+ extend Base
7
+
8
+ REQUIRED_FIELDS = %w[
9
+ platform_id create_datetime site_no form_no
10
+ invoice_no invoice_date invoice_time invoice_status invoice_type random_code
11
+ order_status
12
+ ].freeze
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ module Serializers
5
+ module ReturnSerializer
6
+ extend Base
7
+
8
+ REQUIRED_FIELDS = %w[
9
+ platform_id create_datetime site_no form_no form_subno
10
+ original_form_no tax_type sno product_no
11
+ qty free_qty price subtotal payment order_status
12
+ returner_name returner_address returner_phone returner_zip_code
13
+ ].freeze
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ module Serializers
5
+ module SalesOrderSerializer
6
+ extend Base
7
+
8
+ REQUIRED_FIELDS = %w[
9
+ platform_id create_datetime site_no form_no order_date
10
+ buyer_name receiver_name pay_type shipping_type tax_type
11
+ sno form_subno product_no product_name unit
12
+ qty free_qty price subtotal payment
13
+ order_status last_record
14
+ ].freeze
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+
5
+ module DigiwinDsp
6
+ class Error < StandardError
7
+ attr_reader :code, :dsp_message, :request_id, :http_status, :response_body
8
+
9
+ def initialize(message = nil, code: nil, dsp_message: nil, request_id: nil, http_status: nil, response_body: nil)
10
+ super(message)
11
+ @code = code
12
+ @dsp_message = dsp_message
13
+ @request_id = request_id
14
+ @http_status = http_status
15
+ @response_body = response_body
16
+ end
17
+ end
18
+
19
+ class ConfigurationError < Error; end
20
+ class AuthenticationError < Error; end
21
+ class ValidationError < Error; end
22
+ class RateLimitError < Error; end
23
+ class ServerError < Error; end
24
+ class NetworkError < Error; end
25
+ class DuplicateRequestError < Error; end
26
+
27
+ class << self
28
+ attr_writer :configuration
29
+
30
+ def configuration
31
+ @configuration ||= Configuration.new
32
+ end
33
+
34
+ def configure
35
+ yield(configuration)
36
+ end
37
+
38
+ def reset_configuration!
39
+ @configuration = Configuration.new
40
+ end
41
+ end
42
+ end
43
+
44
+ loader = Zeitwerk::Loader.for_gem
45
+ loader.setup
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: digiwin_dsp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Zac
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-05-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.9'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.9'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday-retry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: zeitwerk
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.6'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.6'
55
+ description: Synchronous Ruby gem wrapping the Digiwin DSP 自有官網模組 endpoints (create
56
+ order, cancel order, invoice update, return) for use from a Rails site.
57
+ email:
58
+ - 579103+7a6163@users.noreply.github.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - CHANGELOG.md
64
+ - LICENSE.txt
65
+ - README.md
66
+ - lib/digiwin_dsp.rb
67
+ - lib/digiwin_dsp/authenticator.rb
68
+ - lib/digiwin_dsp/client.rb
69
+ - lib/digiwin_dsp/configuration.rb
70
+ - lib/digiwin_dsp/resources/cancellation.rb
71
+ - lib/digiwin_dsp/resources/invoice.rb
72
+ - lib/digiwin_dsp/resources/order.rb
73
+ - lib/digiwin_dsp/resources/return.rb
74
+ - lib/digiwin_dsp/serializers/base.rb
75
+ - lib/digiwin_dsp/serializers/cancellation_serializer.rb
76
+ - lib/digiwin_dsp/serializers/invoice_serializer.rb
77
+ - lib/digiwin_dsp/serializers/return_serializer.rb
78
+ - lib/digiwin_dsp/serializers/sales_order_serializer.rb
79
+ - lib/digiwin_dsp/version.rb
80
+ homepage: https://github.com/7a6163/digiwin_dsp
81
+ licenses:
82
+ - MIT
83
+ metadata:
84
+ homepage_uri: https://github.com/7a6163/digiwin_dsp
85
+ source_code_uri: https://github.com/7a6163/digiwin_dsp
86
+ rubygems_mfa_required: 'true'
87
+ post_install_message:
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 3.2.0
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 3.5.22
103
+ signing_key:
104
+ specification_version: 4
105
+ summary: Ruby client for the Digiwin DSP 自有官網模組 API.
106
+ test_files: []