bisac 0.8
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.
- data/COPYING +340 -0
- data/LICENSE +12 -0
- data/README +24 -0
- data/Rakefile +90 -0
- data/lib/bisac.rb +17 -0
- data/lib/bisac/message.rb +110 -0
- data/lib/bisac/po.rb +252 -0
- data/lib/bisac/po_line_item.rb +115 -0
- data/lib/bisac/poa.rb +212 -0
- data/lib/bisac/poa_line_item.rb +81 -0
- data/lib/bisac/product.rb +174 -0
- data/specs/data/bisac_multi_po.txt +41 -0
- data/specs/data/bisac_po.txt +112 -0
- data/specs/data/bisac_po_no_footer.txt +111 -0
- data/specs/data/bisac_po_no_header.txt +111 -0
- data/specs/data/single_product.xml +74 -0
- data/specs/data/valid_bisac.txt +213 -0
- data/specs/data/valid_poa.txt +83 -0
- data/specs/new_bisac_spec.rb +67 -0
- data/specs/new_po_line_item_spec.rb +52 -0
- data/specs/new_po_spec.rb +85 -0
- data/specs/poa_line_item_spec.rb +36 -0
- data/specs/poa_spec.rb +70 -0
- metadata +96 -0
data/lib/bisac/poa.rb
ADDED
@@ -0,0 +1,212 @@
|
|
1
|
+
module Bisac
|
2
|
+
|
3
|
+
# Represents a single BISAC purchase order acknowledgement
|
4
|
+
#
|
5
|
+
# = Generating
|
6
|
+
#
|
7
|
+
# It's currently not possible to generate a POA with this class. All it needs
|
8
|
+
# is a to_s method though, much like Bisac::PO. Patches welcome.
|
9
|
+
#
|
10
|
+
# = Reading
|
11
|
+
#
|
12
|
+
# Each POA file can contain multiple POA's, so use pasrse_file() to iterate
|
13
|
+
# over them all.
|
14
|
+
#
|
15
|
+
# Bisac::POA.parse_file("filename.bsc") do |msg|
|
16
|
+
# puts msg.source_san
|
17
|
+
# puts msg.source_name
|
18
|
+
# puts msg.items.size
|
19
|
+
# ...
|
20
|
+
# end
|
21
|
+
class POA
|
22
|
+
|
23
|
+
# file header attributes
|
24
|
+
attr_accessor :source_san, :source_suffix, :source_name
|
25
|
+
attr_accessor :date, :filename, :format_version
|
26
|
+
attr_accessor :destination_san, :destination_suffix
|
27
|
+
attr_accessor :ack_type
|
28
|
+
|
29
|
+
# poa header attributes
|
30
|
+
attr_accessor :supplier_poa_number, :po_number
|
31
|
+
attr_accessor :customer_san, :customer_suffix
|
32
|
+
attr_accessor :supplier_san, :supplier_suffix
|
33
|
+
attr_accessor :poa_date, :currency, :po_date
|
34
|
+
attr_accessor :po_cancel_date, :po_type
|
35
|
+
attr_accessor :items
|
36
|
+
|
37
|
+
# creates a new Bisac::POA object
|
38
|
+
def initialize
|
39
|
+
@items = []
|
40
|
+
|
41
|
+
# default values
|
42
|
+
@cancellation_date = "000000"
|
43
|
+
@do_not_ship_before = "000000"
|
44
|
+
@backorder = true
|
45
|
+
end
|
46
|
+
|
47
|
+
# return all POs from a BISAC file
|
48
|
+
def self.parse_file(input, &block)
|
49
|
+
raise ArgumentError, 'no file provided' if input.nil?
|
50
|
+
raise ArgumentError, 'Invalid file' unless File.file?(input)
|
51
|
+
self.parse_string(File.read(input)) do |msg|
|
52
|
+
yield msg
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# return all POs from a BISAC string
|
57
|
+
def self.parse_string(input, &block)
|
58
|
+
raise ArgumentError, 'no data provided' if input.nil?
|
59
|
+
data = []
|
60
|
+
input.split("\n").each do |l|
|
61
|
+
data << l
|
62
|
+
|
63
|
+
# yield each message found in the string. A line starting with
|
64
|
+
# 90 is the footer to a PO
|
65
|
+
if data.last[0,2] == "90"
|
66
|
+
msg = Bisac::POA.new
|
67
|
+
msg.build_message(data)
|
68
|
+
yield msg
|
69
|
+
data = []
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# if we've got to the end of the file, and haven't hit a footer line yet, the file
|
74
|
+
# is probably malformed. Call build_message anyway, and let it detect any errors
|
75
|
+
if data.size > 0
|
76
|
+
msg = Bisac::POA.new
|
77
|
+
msg.build_message(data)
|
78
|
+
yield msg
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def total_qty
|
83
|
+
@items.collect { |i| i.qty }.inject { |sum, x| sum ? sum+x : x}
|
84
|
+
end
|
85
|
+
|
86
|
+
def to_s
|
87
|
+
lines = []
|
88
|
+
|
89
|
+
# file header
|
90
|
+
line = " " * 80
|
91
|
+
line[0,2] = "00" # line type
|
92
|
+
line[2,5] = "00001" # line counter
|
93
|
+
line[7,7] = pad_trunc(@source_san, 7)
|
94
|
+
line[14,5] = pad_trunc(@source_suffix, 5)
|
95
|
+
line[19,13] = pad_trunc(@source_name, 13)
|
96
|
+
line[32,6] = pad_trunc(@date, 6)
|
97
|
+
line[38,22] = pad_trunc(@filename, 22)
|
98
|
+
line[60,3] = pad_trunc(@format_version, 3)
|
99
|
+
line[63,7] = pad_trunc(@destination_san, 7)
|
100
|
+
line[70,5] = pad_trunc(@destination_suffix, 5)
|
101
|
+
lines << line
|
102
|
+
|
103
|
+
# po header
|
104
|
+
lines << ""
|
105
|
+
lines.last << "10"
|
106
|
+
lines.last << "00002" # line counter
|
107
|
+
lines.last << " "
|
108
|
+
lines.last << @po_number.to_s.ljust(11, " ")
|
109
|
+
lines.last << " " # TODO
|
110
|
+
lines.last << pad_trunc(@source_san, 7)
|
111
|
+
lines.last << pad_trunc("",5) # TODO
|
112
|
+
lines.last << pad_trunc(@destination_san, 7)
|
113
|
+
lines.last << pad_trunc("",5) # TODO
|
114
|
+
lines.last << pad_trunc(@date, 6)
|
115
|
+
lines.last << pad_trunc(@cancellation_date,6)
|
116
|
+
lines.last << yes_no(@backorder)
|
117
|
+
lines.last << pad_trunc(@do_not_exceed_action,1)
|
118
|
+
lines.last << pad_trunc(@do_not_exceed_amount,7)
|
119
|
+
lines.last << pad_trunc(@invoice_copies,2)
|
120
|
+
lines.last << yes_no(@special_instructions)
|
121
|
+
lines.last << pad_trunc("",5) # TODO
|
122
|
+
lines.last << pad_trunc(@do_not_ship_before,6)
|
123
|
+
|
124
|
+
sequence = 3
|
125
|
+
@items.each_with_index do |item, idx|
|
126
|
+
item.line_item_number = idx + 1
|
127
|
+
item.sequence_number = sequence
|
128
|
+
lines += item.to_s.split("\n")
|
129
|
+
sequence += 3
|
130
|
+
end
|
131
|
+
|
132
|
+
# PO control
|
133
|
+
line = " " * 80
|
134
|
+
line[0,2] = "50"
|
135
|
+
line[2,5] = (lines.size + 1).to_s.rjust(5,"0") # line counter
|
136
|
+
line[8,12] = @po_number.to_s.ljust(13, " ")
|
137
|
+
line[20,5] = "00001" # number of POs in file
|
138
|
+
line[25,10] = @items.size.to_s.rjust(10,"0")
|
139
|
+
line[35,10] = total_qty.to_s.rjust(10,"0")
|
140
|
+
lines << line
|
141
|
+
|
142
|
+
# file trailer
|
143
|
+
line = " " * 80
|
144
|
+
line[0,2] = "90"
|
145
|
+
line[2,5] = (lines.size+1).to_s.rjust(5,"0") # line counter
|
146
|
+
line[7,20] = @items.size.to_s.rjust(13,"0")
|
147
|
+
line[20,5] = "00001" # total '10' (PO) records
|
148
|
+
line[25,10] = total_qty.to_s.rjust(10,"0")
|
149
|
+
line[35,5] = "00001" # number of '00'-'09' records
|
150
|
+
line[40,5] = "00001" # number of '10'-'19' records
|
151
|
+
line[55,5] = (@items.size * 3).to_s.rjust(5,"0") # number of '40'-'49' records
|
152
|
+
line[60,5] = "00000" # number of '50'-'59' records
|
153
|
+
line[45,5] = "00000" # number of '60'-'69' records
|
154
|
+
lines << line
|
155
|
+
|
156
|
+
lines.join("\n")
|
157
|
+
end
|
158
|
+
|
159
|
+
# Populate the current object with the message contained in data
|
160
|
+
#
|
161
|
+
# An array of lines making a complete, single POA message
|
162
|
+
#
|
163
|
+
def build_message(data)
|
164
|
+
raise Bisac::InvalidFileError, 'File appears to be too short' unless data.size >= 3
|
165
|
+
raise Bisac::InvalidFileError, 'Missing header information' unless data[0][0,2].eql?("02")
|
166
|
+
raise Bisac::InvalidFileError, 'Missing footer information' unless data[-1][0,2].eql?("91")
|
167
|
+
|
168
|
+
data.each do |line|
|
169
|
+
# ensure each line is at least 80 chars long
|
170
|
+
line = line.ljust(80)
|
171
|
+
|
172
|
+
case line[0,2]
|
173
|
+
when "02" # file header
|
174
|
+
self.source_san = line[7,7].strip
|
175
|
+
self.source_suffix = line[14,5].strip
|
176
|
+
self.source_name = line[19,13].strip
|
177
|
+
self.date = line[32,6].strip
|
178
|
+
self.filename = line[38,22].strip
|
179
|
+
self.format_version = line[60,3].strip
|
180
|
+
self.destination_san = line[63,7].strip
|
181
|
+
self.destination_suffix = line[70,5].strip
|
182
|
+
self.ack_type = line[75,1].strip
|
183
|
+
when "11" # poa header
|
184
|
+
self.supplier_poa_number = line[7,13].strip
|
185
|
+
self.po_number = line[20,13].strip
|
186
|
+
self.customer_san = line[33,7].strip
|
187
|
+
self.customer_suffix = line[40,5].strip
|
188
|
+
self.supplier_san = line[45,7].strip
|
189
|
+
self.supplier_suffix = line[52,5].strip
|
190
|
+
self.poa_date = line[57,6].strip
|
191
|
+
self.currency = line[63,3].strip
|
192
|
+
self.po_date = line[66,6].strip
|
193
|
+
self.po_cancel_date = line[72,6].strip
|
194
|
+
self.po_type = line[78,2].strip
|
195
|
+
when "40" # line item
|
196
|
+
item = Bisac::POALineItem.load_from_string(line)
|
197
|
+
self.items << item
|
198
|
+
when "41"
|
199
|
+
when "42"
|
200
|
+
when "59" # poa footer
|
201
|
+
# check the built objects match the file
|
202
|
+
when "91" # file footer
|
203
|
+
# check the built objects match the file
|
204
|
+
end
|
205
|
+
|
206
|
+
end
|
207
|
+
|
208
|
+
self
|
209
|
+
end
|
210
|
+
|
211
|
+
end
|
212
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'bigdecimal'
|
2
|
+
|
3
|
+
module Bisac
|
4
|
+
|
5
|
+
# represents a single line on the purchase order ack
|
6
|
+
class POALineItem
|
7
|
+
|
8
|
+
attr_accessor :sequence_number, :supplier_poa_number
|
9
|
+
attr_accessor :line_item_number, :order_qty, :unit_price, :nett_price
|
10
|
+
attr_accessor :list_nett_indicator, :special_price, :discount
|
11
|
+
attr_accessor :shippable_qty, :status, :warehouse_status
|
12
|
+
attr_reader :isbn
|
13
|
+
|
14
|
+
# returns a new Bisac::POLineItem object using the data passed in as a string
|
15
|
+
# refer to the bisac spec for the expected format of the string
|
16
|
+
def self.load_from_string(data)
|
17
|
+
data = data.to_s.ljust(93)
|
18
|
+
raise ArgumentError, "POA Line Items must start with '40'" unless data[0,2] == "40"
|
19
|
+
|
20
|
+
item = POALineItem.new
|
21
|
+
|
22
|
+
item.sequence_number = data[2,5].to_i
|
23
|
+
item.supplier_poa_number = data[7,13].strip
|
24
|
+
item.line_item_number = data[20,10].strip
|
25
|
+
|
26
|
+
# prefer the 13 digit ISBN if it exists (it's a non-standard, Pacstream extension)
|
27
|
+
# fallback to the ISBN10
|
28
|
+
item.isbn = data[80,13].strip
|
29
|
+
item.isbn = data[30,10].strip if item.isbn.nil?
|
30
|
+
item.order_qty = data[40,5].to_i
|
31
|
+
item.unit_price = BigDecimal.new(data[45,8]) / 100
|
32
|
+
item.nett_price = BigDecimal.new(data[53,9]) / 100
|
33
|
+
item.list_nett_indicator = data[62,1]
|
34
|
+
item.special_price = data[63,1]
|
35
|
+
item.discount = BigDecimal.new(data[63,1]) / 100
|
36
|
+
item.shippable_qty = data[69,5].to_i
|
37
|
+
item.status = data[74,2].to_i
|
38
|
+
item.warehouse_status = data[76,2].to_i
|
39
|
+
|
40
|
+
return item
|
41
|
+
end
|
42
|
+
|
43
|
+
def isbn=(val)
|
44
|
+
if val == ""
|
45
|
+
@isbn = nil
|
46
|
+
elsif RBook::ISBN.valid_isbn10?(val)
|
47
|
+
@isbn = RBook::ISBN.convert_to_isbn13(val)
|
48
|
+
else
|
49
|
+
@isbn = val
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# is the isbn for this product valid?
|
54
|
+
def isbn?
|
55
|
+
RBook::ISBN.valid_isbn13?(@isbn || "")
|
56
|
+
end
|
57
|
+
|
58
|
+
def isbn10
|
59
|
+
if isbn?
|
60
|
+
RBook::ISBN.convert_to_isbn10(@isbn)
|
61
|
+
else
|
62
|
+
@isbn
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def status_text
|
67
|
+
case self.status
|
68
|
+
when 1 then "Title Shipped As Ordered"
|
69
|
+
when 3 then "Cancelled: Future Publication"
|
70
|
+
when 6 then "Cancelled: Out of Stock"
|
71
|
+
when 7 then "Backordered: Out of Stock"
|
72
|
+
when 9 then "Partial Ship: Cancel Rest"
|
73
|
+
when 10 then "Partial Ship: Backorder Rest"
|
74
|
+
when 20 then "Cancelled: Not Carried"
|
75
|
+
when 28 then "Cancelled: Out of Print"
|
76
|
+
else
|
77
|
+
"UNKNOWN"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,174 @@
|
|
1
|
+
module Bisac
|
2
|
+
|
3
|
+
# Class to represent a single product line in a Bisac File. See
|
4
|
+
# Bisac::Message for basic usage instructions.
|
5
|
+
class Product
|
6
|
+
|
7
|
+
attr_reader :isbn, :title, :author, :price, :pubdate, :publisher
|
8
|
+
attr_reader :imprint, :volumes, :edition, :binding, :volume, :status
|
9
|
+
|
10
|
+
# Creates a new product object with the requested ISBN. Must be a 10 digit ISBN.
|
11
|
+
def initialize(isbn)
|
12
|
+
raise ArgumentError, 'isbn must 10 chars or less' if isbn.to_s.length > 10
|
13
|
+
|
14
|
+
@isbn = isbn.to_s
|
15
|
+
@title = ""
|
16
|
+
@author = ""
|
17
|
+
@price = ""
|
18
|
+
@pubdate = ""
|
19
|
+
@publisher = ""
|
20
|
+
@imprint = ""
|
21
|
+
@volumes = ""
|
22
|
+
@edition = ""
|
23
|
+
@binding = ""
|
24
|
+
@volume = ""
|
25
|
+
@status = ""
|
26
|
+
end
|
27
|
+
|
28
|
+
# sets the products author. Maximum of 30 chars.
|
29
|
+
def author=(author)
|
30
|
+
@author = author.to_s
|
31
|
+
end
|
32
|
+
|
33
|
+
# sets the products binding. Maximum of 2 chars.
|
34
|
+
def binding=(binding)
|
35
|
+
@binding = binding.to_s
|
36
|
+
end
|
37
|
+
|
38
|
+
# sets the products edition number. Maximum of 3 chars.
|
39
|
+
def edition=(edition)
|
40
|
+
@edition = edition.to_s
|
41
|
+
end
|
42
|
+
|
43
|
+
# takes a single line from a BISAC file and attempts to convert
|
44
|
+
# it to a Product object
|
45
|
+
def self.from_string(s)
|
46
|
+
s = s.to_s
|
47
|
+
return nil if s.length < 259
|
48
|
+
product = self.new(s[0,10])
|
49
|
+
product.title = s[15,30].strip
|
50
|
+
product.author = s[46,30].strip
|
51
|
+
product.price = s[79,7].strip if s[79,7].strip.match(/\A\d{0,7}\Z/)
|
52
|
+
product.pubdate = s[87,6].strip if s[87,6].strip.match(/\A\d{6}\Z/)
|
53
|
+
product.publisher = s[94,10].strip
|
54
|
+
product.imprint = s[105,14].strip
|
55
|
+
product.volumes = s[120,3].strip
|
56
|
+
product.edition = s[124,2].strip
|
57
|
+
product.binding = s[127,2].strip
|
58
|
+
product.volume = s[130,3].strip
|
59
|
+
product.status = s[153,3].strip
|
60
|
+
return product
|
61
|
+
end
|
62
|
+
|
63
|
+
# Sets the products imprint. Maximum of 14 chars.
|
64
|
+
def imprint=(imprint)
|
65
|
+
@imprint = imprint.to_s
|
66
|
+
end
|
67
|
+
|
68
|
+
# Sets the price imprint. Maximum of 7 chars. Must by a whole
|
69
|
+
# number (represent price in cents).
|
70
|
+
def price=(price)
|
71
|
+
unless price.to_s.match(/\A\d{0,7}\Z/)
|
72
|
+
raise ArgumentError, 'price should be a whole number with no more than 7 digits. (price in cents)'
|
73
|
+
end
|
74
|
+
@price = price.to_s
|
75
|
+
end
|
76
|
+
|
77
|
+
# Sets the products pubdate. Must be in the form YYMMDD
|
78
|
+
def pubdate=(pubdate)
|
79
|
+
unless pubdate.to_s.match(/\A\d{6}\Z/)
|
80
|
+
raise ArgumentError, 'pubdate should be a date in the form YYMMDD.'
|
81
|
+
end
|
82
|
+
@pubdate = pubdate.to_s
|
83
|
+
end
|
84
|
+
|
85
|
+
# sets the products publisher. Maximum of 10 chars.
|
86
|
+
def publisher=(publisher)
|
87
|
+
@publisher = publisher.to_s
|
88
|
+
end
|
89
|
+
|
90
|
+
# sets the products status code. Maximum of 3 chars.
|
91
|
+
def status=(status)
|
92
|
+
@status = status.to_s
|
93
|
+
end
|
94
|
+
|
95
|
+
# sets the products title. Maximum of 30 chars.
|
96
|
+
def title=(title)
|
97
|
+
@title = title.to_s
|
98
|
+
end
|
99
|
+
|
100
|
+
# Returns the product as a single line ready for inserting into a BISAC file.
|
101
|
+
# Doesn't have a \n on the end
|
102
|
+
def to_s
|
103
|
+
content = ""
|
104
|
+
content << @isbn[0,10].ljust(10) # 10 digit isbn
|
105
|
+
content << "1"
|
106
|
+
content << "N"
|
107
|
+
content << "N"
|
108
|
+
content << "B"
|
109
|
+
content << "N"
|
110
|
+
content << @title[0,30].ljust(30)
|
111
|
+
content << "N"
|
112
|
+
content << @author[0,30].ljust(30)
|
113
|
+
content << "N"
|
114
|
+
content << "A" # author role
|
115
|
+
content << "N"
|
116
|
+
content << @price[0,7].rjust(7,"0") # current price
|
117
|
+
content << "N"
|
118
|
+
content << @pubdate[0,6].ljust(6) # published date
|
119
|
+
content << "N"
|
120
|
+
content << @publisher[0,10].ljust(10) # publisher
|
121
|
+
content << "N"
|
122
|
+
content << @imprint[0,14].ljust(14) #imprint
|
123
|
+
content << "N"
|
124
|
+
content << @volumes[0,3].rjust(3,"0") # volumes included in this isbn
|
125
|
+
content << "N"
|
126
|
+
content << @edition[0,2].rjust(2,"0") # edition
|
127
|
+
content << "N"
|
128
|
+
content << @binding[0,2].rjust(2,"0") # binding
|
129
|
+
content << "N"
|
130
|
+
content << @volume[0,3].rjust(3,"0") # volume number
|
131
|
+
content << "N"
|
132
|
+
content << "0000000" # new price
|
133
|
+
content << "N"
|
134
|
+
content << "000000" # new price effective date
|
135
|
+
content << "N"
|
136
|
+
content << " " # audience type
|
137
|
+
content << "N"
|
138
|
+
content << @status[0,3].rjust(3) # status
|
139
|
+
content << "N"
|
140
|
+
content << " " # available date. only use for status' like NYP
|
141
|
+
content << "N"
|
142
|
+
content << " " # alternate isbn
|
143
|
+
content << "N"
|
144
|
+
content << "999999" # out of print date. only use for status == OP
|
145
|
+
content << "N"
|
146
|
+
content << " " # geographic restrictions
|
147
|
+
content << "N"
|
148
|
+
content << " " # library of congress catalogue number
|
149
|
+
content << "N"
|
150
|
+
content << "".ljust(40) # series title
|
151
|
+
content << "N"
|
152
|
+
content << "0" # price code for current price
|
153
|
+
content << "N"
|
154
|
+
content << "0" # price code for new price
|
155
|
+
content << "N"
|
156
|
+
content << "0000000" # freight pass through price
|
157
|
+
content << "N"
|
158
|
+
content << "000000" # new freight pass through price
|
159
|
+
content << "00000" # last changed date
|
160
|
+
|
161
|
+
return content
|
162
|
+
end
|
163
|
+
|
164
|
+
# sets the products volume number. Maximum of 3 chars.
|
165
|
+
def volume=(volume)
|
166
|
+
@volume = volume.to_s
|
167
|
+
end
|
168
|
+
|
169
|
+
# sets the number of volumes in the set this product belongs to. Maximum of 3 chars.
|
170
|
+
def volumes=(volumes)
|
171
|
+
@volumes = volumes.to_s
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|