OpenCraft Beverage Line Format
ภาษากลางสำหรับรายการเครื่องดื่ม — คนอ่านเข้าใจ เครื่องแยกข้อมูลได้ และใช้ได้กับเครื่องดื่มทุกประเภท
เผยแพร่โดย OpenCraft · 3 พฤศจิกายน 2026
วางหรือพิมพ์ BLF รูปแบบย่อหนึ่งเรกคอร์ดต่อบรรทัด การ์ดจะอัปเดตขณะพิมพ์
ทำไมต้องมี BLF
รายการเครื่องดื่มเดินทางระหว่างผู้ผลิต ผู้จัดจำหน่าย บาร์ และลูกค้าทุกวัน — ผ่านแชท เมนูพิมพ์ สเปรดชีต และใบสั่งซื้อ แต่ละฝ่ายจัดรูปแบบต่างกัน คนรับจึงต้องพิมพ์ข้อมูลซ้ำใหม่ทุกครั้ง
BLF (Beverage Line Format) คือมาตรฐานขนาดเล็กที่ OpenCraft เสนอ หนึ่งบรรทัดบอกหนึ่งเครื่องดื่ม: ใครผลิต ชื่ออะไร สไตล์ไหน แรงแค่ไหน บรรจุอย่างไร ต้นทุนเท่าไหร่ เหลือกี่ชิ้น และขายเท่าไหร่ คนอ่านเข้าใจได้ทันที และเครื่องแยกข้อมูลได้ด้วยกฎที่ไม่ต้องเดา
คนต้องอ่านได้ก่อน
บรรทัดหนึ่งต้องเข้าใจได้โดยบาร์เทนเดอร์หรือผู้ซื้อ โดยไม่ต้องมีเครื่องมือ ไม่มี XML ไม่มีออบเจ็กต์ซ้อนกัน — แค่ข้อความธรรมดา
เครื่องแยกได้ตามกฎ
ทุกโทเคนมีคำนำ คำต่อท้าย หรือหน่วยที่ชัดเจน เครื่องแยกข้อมูลจึงไม่ต้องเดาขอบเขตของฟิลด์
ทุกฟิลด์ไม่บังคับ
บังคับเฉพาะชื่อผู้ผลิต รายการสามารถเริ่มจากข้อมูลน้อยแล้วค่อยเพิ่มความสมบูรณ์ภายหลังได้โดยไม่พัง
คำศัพท์ขยายได้
ประเภทบรรจุภัณฑ์และสไตล์เป็นรายการควบคุมที่เพิ่มได้ BLF เป็นรูปแบบของเครื่องดื่ม ไม่ใช่ของเบียร์เท่านั้น
โครงสร้างของหนึ่งบรรทัด
อ่านจากซ้ายไปขวา ลำดับและชื่อผู้ผลิตคือส่วนหัวของบรรทัด หลังจากนั้นคือพารามิเตอร์ที่ระบุด้วยสัญลักษณ์ หน่วย หรือรหัสสกุลเงินของตัวเอง ไม่ได้อ้างอิงตำแหน่ง
<order>. <producer>: <beverage>, <style> <abv> <size> <container> <currency> @<cost> [V<vat>] [@<cost-incl>v] x<stock> / <price> [<price-incl>v] 2. Sample Brewing: Sea Breeze, Pale Ale 5% 440ml Can THB @180 V7.0 @192.6v x8 /320 342.4v | โทเคน | ฟิลด์ | ความหมาย |
|---|---|---|
2. | ลำดับ (Order Number) | ดัชนีในรายการ ไม่ใช่รหัสสินค้า |
Sample Brewing | ชื่อผู้ผลิต (Producer Name) | ฟิลด์เดียวที่บังคับ สิ้นสุดที่เครื่องหมาย : |
Sea Breeze | ชื่อเครื่องดื่ม (Beverage Name) | อยู่หลังเครื่องหมาย : และก่อนจุลภาคตัวสุดท้าย |
Pale Ale | สไตล์ (Style) | อยู่หลังจุลภาคตัวสุดท้ายในส่วนหัว |
5% | ABV | แอลกอฮอล์โดยปริมาตร รองรับทศนิยม |
440ml | ขนาดบรรจุ (Container Size) | หน่วย ml, cl, L หรือ oz |
Can | ประเภทบรรจุ (Container Type) | Can, Bottle, Keg, Cask, Growler และเพิ่มเติมได้ในอนาคต |
THB | สกุลเงิน (Currency) | รหัส ISO 4217 มีผลกับทุกจำนวนในเรกคอร์ด |
@180 | ต้นทุน (ไม่รวม VAT) | ต่อหนึ่งหน่วยบรรจุ ในสกุลเงินที่ระบุหรือตกลงกัน |
V7.0 | อัตรา VAT | เปอร์เซ็นต์ มีผลกับทั้งเรกคอร์ด |
@192.6v | ต้นทุน (รวม VAT) | คำต่อท้าย v หมายถึงจำนวนที่รวม VAT แล้ว |
x8 | จำนวนคงเหลือ (Stock Amount) | จำนวนเต็มของหน่วยบรรจุ |
/320 | ราคาขายปลีก (ไม่รวม VAT) | ต่อหนึ่งหน่วยบรรจุ |
342.4v | ราคาขายปลีก (รวม VAT) | คำต่อท้าย v หมายถึงจำนวนที่รวม VAT แล้ว |
พารามิเตอร์สลับลำดับได้: 5% 440ml Can กับ 440ml 5% Can ให้ความหมายเดียวกัน ลำดับด้านบนคือลำดับมาตรฐานที่แนะนำ ลำดับก็ไม่บังคับเช่นกัน — ถ้าไม่ระบุ บรรทัดจะเริ่มที่ชื่อผู้ผลิต
ตารางฟิลด์
ทุกฟิลด์เป็นตัวเลือก ยกเว้นชื่อผู้ผลิต ลำดับด้านล่างคือลำดับมาตรฐานที่แนะนำสำหรับทั้งรูปแบบย่อและรูปแบบขยาย
| ฟิลด์ | บังคับ | ไวยากรณ์ | หมายเหตุ |
|---|---|---|---|
| ลำดับ (Order Number) | ไม่บังคับ | 1. | ดัชนีของบรรทัด ต้องไม่ซ้ำกันภายในรายการที่เผยแพร่ ใช้เพื่อการแสดงผลเท่านั้น ไม่ใช่รหัสสินค้าถาวร |
| ชื่อผู้ผลิต (Producer Name) | บังคับ | Sample Brewing: | แบรนด์ โรงเบียร์ หรือผู้ผลิต อาจมีจุลภาคได้ ฟิลด์สิ้นสุดที่เครื่องหมาย : |
| ชื่อเครื่องดื่ม (Beverage Name) | ไม่บังคับ | Sea Breeze, | ชื่อสินค้า แยกจากสไตล์ที่จุลภาคตัวสุดท้ายในส่วนหัว |
| สไตล์ (Style) | ไม่บังคับ | Pale Ale | ข้อความอิสระใน v1.0 กำลังวางแผนทะเบียนสไตล์ร่วมกัน |
| ABV | ไม่บังคับ | 5% | แอลกอฮอล์โดยปริมาตรเป็นเปอร์เซ็นต์ รองรับทศนิยม และ 0% ถือว่าถูกต้อง |
| ขนาดบรรจุ (Container Size) | ไม่บังคับ | 440ml | ตัวเลขพร้อมหน่วย ml, cl, L หรือ oz โดย oz คือ US fluid ounce (29.5735 ml) แปลงเป็นมิลลิลิตรเมื่อต้องเปรียบเทียบ |
| ประเภทบรรจุ (Container Type) | ไม่บังคับ | Can | Can, Bottle, Keg, Cask, Growler และเพิ่มเติมได้ |
| สกุลเงิน (Currency) | ไม่บังคับ | THB | รหัส ISO 4217 มีผลกับทุกจำนวนในเรกคอร์ด ถ้าไม่ระบุจะใช้สกุลเงินที่ตกลงกันของรายการ |
| ต้นทุน (ไม่รวม VAT) | ไม่บังคับ | @180 | ต้นทุนต่อหนึ่งหน่วยบรรจุ ในสกุลเงินที่ระบุหรือตกลงกัน คำว่า Cost เป็นคำย่อของฟิลด์นี้ |
| อัตรา VAT | ไม่บังคับ | V7.0 | เปอร์เซ็นต์ มีผลกับทุกจำนวนในเรกคอร์ด ในประเทศไทยคือ 7.0 |
| ต้นทุน (รวม VAT) | ไม่บังคับ | @192.6v | ระบุตรงหรือคำนวณก็ได้ ต้องมีคำต่อท้าย v |
| จำนวนคงเหลือ (Stock Amount) | ไม่บังคับ | x8 | จำนวนเต็มของหน่วยบรรจุ — กระป๋อง ขวด หรือถัง ไม่ใช่จำนวนที่เสิร์ฟ |
| ราคาขายปลีก (ไม่รวม VAT) | ไม่บังคับ | /320 | ราคาต่อหนึ่งหน่วยบรรจุ คำว่า Retail Price เป็นคำย่อของฟิลด์นี้ |
| ราคาขายปลีก (รวม VAT) | ไม่บังคับ | 342.4v | ระบุตรงหรือคำนวณก็ได้ ต้องมีคำต่อท้าย v |
จำนวนที่ไม่มีคำต่อท้าย v ถือว่าไม่รวม VAT ถ้าไม่ระบุสกุลเงิน จำนวนจะใช้สกุลเงินที่ตกลงกันของรายการ หนึ่งเรกคอร์ดไม่ควรมีหลายสกุลเงิน
สองรูปแบบที่เทียบเท่ากัน
รูปแบบย่อเหมาะกับข้อความแชทและป้ายชั้นวาง รูปแบบขยายเหมาะกับการตรวจทาน แก้ไข และควบคุมเวอร์ชัน ทั้งสองรูปแบบบรรจุข้อมูลชุดเดียวกันและแปลงกลับไปมาได้โดยไม่สูญเสียข้อมูล
รูปแบบย่อ — หนึ่งบรรทัดต่อสินค้า
ลำดับและชื่อผู้ผลิตนำหน้าบรรทัด ตามด้วยพารามิเตอร์ที่ระบุด้วยสัญลักษณ์ หน่วย หรือรหัสสกุลเงิน ISO ตำแหน่งไม่มีความหมาย ลำดับมาตรฐานจึงเป็นเพียงธรรมเนียมเพื่อให้อ่านง่าย ไม่ใช่ข้อบังคับในการแยกข้อมูล
1. Mock Brewery: Night Owl, Chocolate Milk Stout 5.6% 12oz Bottle THB @120 x5 / 260
2. Sample Brewing: Sea Breeze, Pale Ale 5% 440ml Can THB @180 V7.0 @192.6v x8 / 320 342.4v
3. Demo Lagerhaus, Munich Helles 5.5% - พารามิเตอร์ถูกตรวจจับด้วย @, V, x, / และหน่วยอย่าง %, ml และ L
- จำนวนเงินใช้จุดทศนิยม ห้ามใช้จุลภาคคั่นหลักพันภายในจำนวน
- หนึ่งเรกคอร์ดต่อหนึ่งบรรทัด บรรทัดว่างคั่นระหว่างเรกคอร์ดได้
- คำสำคัญและหน่วยไม่แยกตัวพิมพ์ใหญ่เล็ก การเขียนตัวพิมพ์ตามตัวอย่างคือรูปแบบมาตรฐาน
- สกุลเงินใช้รหัส ISO 4217 เช่น THB วางไว้ก่อนจำนวนแรก
- เว้นวรรคหลัง @ หรือ / ได้ จึงเขียน @180 หรือ @ 180 ก็ให้ผลเดียวกัน
- หนึ่งบรรทัดต่อหนึ่งรูปแบบบรรจุที่ขายจริง — เบียร์เดียวกันขนาด 330ml กับ 500ml คือสองบรรทัด
- ถ้าชื่อมีข้อความที่หน้าตาเหมือนโทเคน หรือสไตล์มีจุลภาค ให้ใช้รูปแบบขยายแทน
รูปแบบขยาย — หนึ่งฟิลด์ต่อบรรทัด
เขียน ชื่อฟิลด์: ค่า แล้วคั่นเรกคอร์ดด้วยบรรทัดว่าง รูปแบบนี้ตรวจทาน diff และกรอกด้วยมือได้ง่ายกว่า และปลอดภัยที่สุดเมื่อชื่อมีจุลภาค ตัวเลข หรือหน่วย
Order Number: 2
Producer Name: Sample Brewing
Beverage Name: Sea Breeze
Style: Pale Ale
ABV: 5%
Container Size: 440ml
Container Type: Can
Currency: THB
Cost (VAT Excluded): 180
VAT: 7.0%
Cost (VAT Included): 192.6
Stock Amount: 8
Retail Price (VAT Excluded): 320
Retail Price (VAT Included): 342.4 - ชื่อฟิลด์ไม่แยกตัวพิมพ์ใหญ่เล็ก แต่แนะนำให้ใช้รูปแบบมาตรฐานเมื่อเผยแพร่
- Cost และ Retail Price เป็นคำยอมรับได้ของฟิลด์ไม่รวม VAT
- เครื่องอ่านควรข้ามฟิลด์ที่ไม่รู้จัก ไม่ใช่ปฏิเสธทั้งเรกคอร์ด
- บรรทัดว่างเป็นตัวจบเรกคอร์ด ฟิลด์ที่ไม่ระบุถือว่าไม่มีข้อมูล
กฎ VAT
จำนวนเงินเป็นแบบไม่รวม VAT โดยค่าเริ่มต้น เพราะเป็นวิธีที่รายการซื้อขายส่วนใหญ่ใช้กัน คำต่อท้าย v หมายถึงจำนวนนั้นรวม VAT แล้ว ทั้งสองแบบอยู่ร่วมกันในเรกคอร์ดเดียวได้
ค่าเริ่มต้นคือไม่รวม VAT
จำนวนใดที่ไม่มีคำต่อท้าย v ถือว่าไม่รวม VAT ใช้กับทั้งต้นทุนและราคาขายปลีก
v หมายถึงรวม VAT
ต่อท้ายจำนวนโดยตรง: @192.6v หรือ /342.4v และยอมรับจำนวนเปล่าที่ลงท้ายด้วย v ต่อจากจำนวนไม่รวม VAT ที่เกี่ยวข้องด้วย แต่รูปแบบนั้นอ้างอิงตำแหน่ง สำหรับรายการใหม่แนะนำให้ต่อ v กับสัญลักษณ์ @ หรือ / โดยตรง
V<rate> ประกาศอัตรา
อัตรามีผลกับทุกจำนวนในเรกคอร์ด ในประเทศไทยอัตรามาตรฐานคือ V7.0
คำนวณจำนวนรวม VAT
เมื่อรู้จำนวนไม่รวม VAT และอัตรา ให้คำนวณ จำนวน × (1 + อัตรา / 100) แล้วปัดครึ่งขึ้นที่ทศนิยมสองตำแหน่ง
ตรวจความสอดคล้อง
ถ้าเรกคอร์ดระบุทั้งจำนวนไม่รวมและรวม VAT ค่าทั้งคู่ควรต่างกันไม่เกิน 0.01 หากต่างมากกว่านั้นถือเป็นข้อมูลผิด
ไม่จำเป็นต้องมีอัตราเสมอ
จำนวนที่ลงท้ายด้วย v โดยไม่ระบุอัตราได้ หมายความว่าจำนวนนั้นรวม VAT แต่ไม่ทราบอัตรา — ห้ามเดา
ตัวอย่างการคำนวณ: เรกคอร์ดที่ 2
Currency: THB
Cost (VAT Excluded): 180
VAT: 7.0%
Cost (VAT Included): 192.6
Retail Price (VAT Excluded): 320
Retail Price (VAT Included): 342.4 180 × 1.07 = 192.60 และ 320 × 1.07 = 342.40 ทั้งคู่ลงตัวที่ทศนิยมสองตำแหน่ง
ตัวอย่างสมมติทั้งหก
เรกคอร์ดทั้งหกด้านล่างเป็นข้อมูลสมมติ ใช้ทดสอบทุกส่วนของรูปแบบ แต่ละการ์ดแสดงรูปแบบย่อและฟิลด์ในรูปแบบขยาย
Mock Brewery — Night Owl
1. Mock Brewery: Night Owl, Chocolate Milk Stout 5.6% 12oz Bottle THB @120 x5 / 260 ฟิลด์แบบขยาย
- ลำดับ
- 1
- ชื่อผู้ผลิต
- Mock Brewery
- ชื่อเครื่องดื่ม
- Night Owl
- สไตล์
- Chocolate Milk Stout
- ABV
- 5.6%
- ขนาดบรรจุ
- 12oz
- ประเภทบรรจุ
- Bottle
- สกุลเงิน
- THB
- ต้นทุน (ไม่รวม VAT)
- 120
- จำนวนคงเหลือ
- 5
- ราคาขายปลีก (ไม่รวม VAT)
- 260
ไม่มีการระบุ VAT จำนวนเงินทั้งสองจึงเป็นแบบไม่รวม VAT ตามค่าเริ่มต้น ขนาด 12oz แสดงการรองรับหน่วย oz
Sample Brewing — Sea Breeze
2. Sample Brewing: Sea Breeze, Pale Ale 5% 440ml Can THB @180 V7.0 @192.6v x8 / 320 342.4v ฟิลด์แบบขยาย
- ลำดับ
- 2
- ชื่อผู้ผลิต
- Sample Brewing
- ชื่อเครื่องดื่ม
- Sea Breeze
- สไตล์
- Pale Ale
- ABV
- 5%
- ขนาดบรรจุ
- 440ml
- ประเภทบรรจุ
- Can
- สกุลเงิน
- THB
- ต้นทุน (ไม่รวม VAT)
- 180
- VAT
- 7.0%
- ต้นทุน (รวม VAT)
- 192.6
- จำนวนคงเหลือ
- 8
- ราคาขายปลีก (ไม่รวม VAT)
- 320
- ราคาขายปลีก (รวม VAT)
- 342.4
เรกคอร์ดสมมติที่สมบูรณ์ที่สุด: มีทั้งอัตรา จำนวนคงเหลือ และต้นทุนกับราคาทั้งแบบไม่รวมและรวม VAT
Sample Brewing — Sunbeam
3. Sample Brewing: Sunbeam, Fruity Pale Ale 4.8% 440ml Can THB @150v x10 / 280v ฟิลด์แบบขยาย
- ลำดับ
- 3
- ชื่อผู้ผลิต
- Sample Brewing
- ชื่อเครื่องดื่ม
- Sunbeam
- สไตล์
- Fruity Pale Ale
- ABV
- 4.8%
- ขนาดบรรจุ
- 440ml
- ประเภทบรรจุ
- Can
- สกุลเงิน
- THB
- ต้นทุน (รวม VAT)
- 150
- จำนวนคงเหลือ
- 10
- ราคาขายปลีก (รวม VAT)
- 280
จำนวนแบบรวม VAT โดยไม่ระบุอัตราถือว่าถูกต้อง อัตราจะคงเป็นไม่ทราบ ไม่ใช่เดา
Demo Lagerhaus — Munich Helles
4. Demo Lagerhaus, Munich Helles 5.5% ฟิลด์แบบขยาย
- ลำดับ
- 4
- ชื่อผู้ผลิต
- Demo Lagerhaus
- สไตล์
- Munich Helles
- ABV
- 5.5%
ไม่มีเครื่องหมาย : — จุลภาคแยกผู้ผลิตออกจากสไตล์ และไม่มีชื่อเครื่องดื่ม
Mock Cocktail Co. — Citrus Club
5. Mock Cocktail Co.: Citrus Club, Pre-made Cocktail 330ml 5% ฟิลด์แบบขยาย
- ลำดับ
- 5
- ชื่อผู้ผลิต
- Mock Cocktail Co.
- ชื่อเครื่องดื่ม
- Citrus Club
- สไตล์
- Pre-made Cocktail
- ขนาดบรรจุ
- 330ml
- ABV
- 5%
ขนาดมาก่อน ABV — พารามิเตอร์สลับลำดับได้ และนี่คือเครื่องดื่มที่ไม่ใช่เบียร์ในรูปแบบเดียวกัน
Demo Brewing — Love, Laugh, and Sorrow
6. Demo Brewing: Love, Laugh, and Sorrow, QDH Hazy IPA 7.5% 20L Keg ฟิลด์แบบขยาย
- ลำดับ
- 6
- ชื่อผู้ผลิต
- Demo Brewing
- ชื่อเครื่องดื่ม
- Love, Laugh, and Sorrow
- สไตล์
- QDH Hazy IPA
- ABV
- 7.5%
- ขนาดบรรจุ
- 20L
- ประเภทบรรจุ
- Keg
จุลภาคตัวสุดท้ายแยกชื่อเครื่องดื่มออกจากสไตล์ จุลภาคภายในชื่อเครื่องดื่มจึงปลอดภัย
สำหรับนักพัฒนา
BLF ถูกออกแบบให้แยกโทเคนด้วย regular expression ไม่กี่ตัวและกฎสั้น ๆ โดยไม่ต้องมีไลบรารีเฉพาะ
| โทเคน | แพตเทิร์น |
|---|---|
| ลำดับ | ^\d+\. |
| ABV | \d+(?:\.\d+)?% |
| ขนาดบรรจุ | \d+(?:\.\d+)?(?:ml|cl|L|oz)\b |
| ประเภทบรรจุ | \b(?:Bottle|Can|Keg|Cask|Growler)\b |
| สกุลเงิน (ISO 4217) | \b[A-Z]{3}\b |
| ต้นทุน (ไม่รวม VAT) | @\d+(?:\.\d+)? |
| อัตรา VAT | V\d+(?:\.\d+)? |
| ต้นทุน (รวม VAT) | @?\d+(?:\.\d+)?v |
| จำนวนคงเหลือ | x\d+ |
| ราคาขายปลีก (ไม่รวม VAT) | /\d+(?:\.\d+)? |
| ราคาขายปลีก (รวม VAT) | /?\d+(?:\.\d+)?v |
การแยกรูปแบบย่อ
- ตัดช่องว่างหัวท้ายและข้ามบรรทัดว่าง หนึ่งบรรทัดที่ไม่ว่างคือหนึ่งเรกคอร์ด
- อ่านลำดับที่ต้นบรรทัดถ้ามี ด้วยแพตเทิร์น ^\d+\.
- ถ้ามีรหัส ISO 4217 เดี่ยว ๆ (เช่น THB) ในส่วนพารามิเตอร์ ให้บันทึกเป็นสกุลเงินของทุกจำนวนในเรกคอร์ด
- แยกส่วนหัวที่เครื่องหมาย : ตัวแรก ถ้าไม่มี : ให้แยกที่จุลภาคตัวแรก: ก่อนหน้าคือผู้ผลิต หลังคือสไตล์
- ในส่วนหัวหลัง : ให้แยกที่จุลภาคตัวสุดท้าย: ก่อนหน้าคือชื่อเครื่องดื่ม หลังคือสไตล์ ถ้าไม่มีจุลภาค ทั้งส่วนคือชื่อเครื่องดื่ม
- สแกนข้อความที่เหลือเพื่อหาพารามิเตอร์ด้วยแพตเทิร์นของแต่ละโทเคน ลำดับของพารามิเตอร์ไม่สำคัญ
- แปลงขนาดบรรจุเป็นมิลลิลิตรเมื่อต้องเปรียบเทียบ (1 oz = 29.5735 ml) แต่เก็บหน่วยเดิมไว้แสดงผล
- ใช้กฎ VAT และปฏิเสธเรกคอร์ดที่จำนวนไม่รวมและรวม VAT ต่างกันเกิน 0.01
Reference parser (JavaScript)
parser ตัวเดียวกับที่ขับเคลื่อนตัวตรวจสอบสดด้านบน ไม่มี dependency และคัดลอกไปใช้ต่อในโปรเจกต์ได้
/**
* BLF v1.0 reference parser — compact form.
*
* Usage:
* import { parseBlf } from './blf-parser.js';
* const { results, itemCount, warningCount, errorCount } = parseBlf(text);
*
* Each non-empty line is one record. The order number is optional; the
* Producer Name is the only required field. Parameters are recognised by
* their sigil or unit, so they may appear in any order. An optional ISO 4217
* currency code (for example THB) applies to every amount in the record.
*/
const CONTAINER_TYPES = ['bottle', 'can', 'keg', 'cask', 'growler'];
// Use the runtime's ISO 4217 list when available; otherwise accept any
// uppercase three-letter token and let the caller validate it.
const CURRENCY_CODES =
typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function'
? new Set(Intl.supportedValuesOf('currency'))
: null;
const isCurrencyCode = (token) =>
/^[A-Z]{3}$/.test(token) && (CURRENCY_CODES === null || CURRENCY_CODES.has(token));
const round2 = (value) => Math.round((value + Number.EPSILON) * 100) / 100;
const normalizeContainer = (token) => {
const match = CONTAINER_TYPES.find((type) => type === token.toLowerCase());
return match ? match.charAt(0).toUpperCase() + match.slice(1) : null;
};
/**
* Classify a whitespace-separated parameter token, or return null when the
* token is not a parameter.
*/
const classifyToken = (token) => {
if (/^@\d+(?:\.\d+)?v?$/i.test(token)) return 'cost';
if (/^\/\d+(?:\.\d+)?v?$/i.test(token)) return 'retail';
if (/^V\d+(?:\.\d+)?%?$/i.test(token)) return 'vat';
if (/^x\d+$/i.test(token)) return 'stock';
if (/^\d+(?:\.\d+)?%$/.test(token)) return 'abv';
if (/^\d+(?:\.\d+)?(?:ml|cl|L|oz)$/i.test(token)) return 'size';
if (normalizeContainer(token)) return 'container';
if (isCurrencyCode(token)) return 'currency';
if (/^\d+(?:\.\d+)?v$/i.test(token)) return 'inc';
return null;
};
/**
* Split the text after the order number into a name head and parameter
* tokens. A token only starts the parameter section if it is a strong
* parameter (not a bare container word), so names containing "Can" or
* "Cask" survive better.
*/
const splitHeadAndParams = (text) => {
const tokens = text.split(/\s+/).filter(Boolean);
let firstParam = tokens.length;
for (let i = 0; i < tokens.length; i += 1) {
const kind = classifyToken(tokens[i]);
if (kind && kind !== 'container') {
firstParam = i;
break;
}
}
return {
head: tokens.slice(0, firstParam).join(' '),
params: tokens.slice(firstParam),
};
};
/**
* Parse the head into producer, beverage, and style.
* With a colon: producer : beverage , style
* Without colon: producer , style
*/
const parseHead = (head) => {
const colon = head.indexOf(':');
if (colon !== -1) {
const producer = head.slice(0, colon).trim();
const tail = head.slice(colon + 1).trim();
const lastComma = tail.lastIndexOf(',');
return {
producer,
beverage: lastComma === -1 ? tail || null : tail.slice(0, lastComma).trim() || null,
style: lastComma === -1 ? null : tail.slice(lastComma + 1).trim() || null,
};
}
const firstComma = head.indexOf(',');
if (firstComma !== -1) {
return {
producer: head.slice(0, firstComma).trim(),
beverage: null,
style: head.slice(firstComma + 1).trim() || null,
};
}
return { producer: head.trim(), beverage: null, style: null };
};
/**
* Parse one compact BLF line.
* Returns { lineNumber, raw, ok, item, warnings, error }.
*/
export const parseBlfLine = (raw, lineNumber = 1) => {
const line = String(raw ?? '').trim();
const result = {
lineNumber,
raw: line,
ok: false,
item: null,
warnings: [],
error: null,
};
if (!line) return result;
// 1. Optional order number.
let rest = line;
let orderNumber = null;
const orderMatch = rest.match(/^(\d+)\s*\.\s*/);
if (orderMatch) {
orderNumber = Number(orderMatch[1]);
rest = rest.slice(orderMatch[0].length).trim();
}
// The examples write prices as "/ 320" with a space. Treat the space after
// a price or cost sigil as optional so both "/320" and "/ 320" work.
rest = rest.replace(/\/\s+(?=\d)/g, '/').replace(/@\s+(?=\d)/g, '@');
// 2. Locate the first parameter token.
const { head, params } = splitHeadAndParams(rest);
const parsedHead = parseHead(head);
if (!parsedHead.producer) {
result.error = { code: 'missing-producer' };
return result;
}
const item = {
orderNumber,
producerName: parsedHead.producer,
beverageName: parsedHead.beverage,
style: parsedHead.style,
abv: null,
containerSize: null,
containerType: null,
currency: null,
costExVat: null,
vatRate: null,
costIncVat: null,
stockAmount: null,
retailPriceExVat: null,
retailPriceIncVat: null,
};
// 3. Parse parameter tokens in order.
let previousKind = null;
for (const token of params) {
const kind = classifyToken(token);
if (kind === 'cost') {
const match = token.match(/^@(\d+(?:\.\d+)?)(v)?$/i);
if (match) {
if (match[2]) item.costIncVat = Number(match[1]);
else item.costExVat = Number(match[1]);
}
} else if (kind === 'retail') {
const match = token.match(/^\/(\d+(?:\.\d+)?)(v)?$/i);
if (match) {
if (match[2]) item.retailPriceIncVat = Number(match[1]);
else item.retailPriceExVat = Number(match[1]);
}
} else if (kind === 'vat') {
item.vatRate = Number(token.slice(1).replace('%', ''));
} else if (kind === 'stock') {
item.stockAmount = Number(token.slice(1));
} else if (kind === 'abv') {
item.abv = Number(token.slice(0, -1));
} else if (kind === 'size') {
// oz is a US fluid ounce (29.5735 ml); the original unit is preserved.
const match = token.match(/^(\d+(?:\.\d+)?)(ml|cl|L|oz)$/i);
if (match) {
item.containerSize = {
value: Number(match[1]),
unit: match[2].toLowerCase() === 'l' ? 'L' : match[2].toLowerCase(),
};
}
} else if (kind === 'container') {
item.containerType = normalizeContainer(token);
} else if (kind === 'currency') {
item.currency = token.toUpperCase();
} else if (kind === 'inc') {
const value = Number(token.slice(0, -1));
if (previousKind === 'retail') item.retailPriceIncVat = value;
else if (previousKind === 'cost' || previousKind === 'vat') item.costIncVat = value;
else if (item.retailPriceExVat !== null && item.retailPriceIncVat === null) {
item.retailPriceIncVat = value;
} else if (item.costExVat !== null && item.costIncVat === null) {
item.costIncVat = value;
} else {
result.warnings.push({ code: 'unknown-token', token });
}
} else {
result.warnings.push({ code: 'unknown-token', token });
}
if (kind) previousKind = kind;
}
// 4. Validate stated VAT pairs when a rate is present.
if (item.vatRate !== null) {
const factor = 1 + item.vatRate / 100;
const checks = [
['cost', item.costExVat, item.costIncVat],
['retail', item.retailPriceExVat, item.retailPriceIncVat],
];
for (const [field, exclusive, inclusive] of checks) {
if (exclusive === null || inclusive === null) continue;
const expected = round2(exclusive * factor);
if (Math.abs(expected - inclusive) > 0.01) {
result.warnings.push({
code: 'vat-mismatch',
field,
expected,
actual: inclusive,
});
}
}
}
result.item = item;
result.ok = true;
return result;
};
/**
* Parse a multi-line BLF document.
* Blank lines are skipped; every other line becomes a result in input order.
*/
export const parseBlf = (text) => {
const results = [];
const lines = String(text ?? '').split(/\r?\n/);
lines.forEach((line, index) => {
if (line.trim() === '') return;
results.push(parseBlfLine(line, index + 1));
});
return {
results,
itemCount: results.filter((entry) => entry.ok).length,
warningCount: results.reduce((total, entry) => total + entry.warnings.length, 0),
errorCount: results.filter((entry) => !entry.ok).length,
};
};
การแมปเป็น JSON มาตรฐาน
{
"orderNumber": 2,
"producerName": "Sample Brewing",
"beverageName": "Sea Breeze",
"style": "Pale Ale",
"abv": 5,
"containerSize": { "value": 440, "unit": "ml" },
"containerType": "Can",
"currency": "THB",
"costExVat": 180,
"vatRate": 7.0,
"costIncVat": 192.6,
"stockAmount": 8,
"retailPriceExVat": 320,
"retailPriceIncVat": 342.4
} ฟิลด์ที่ไม่มีให้ละไว้ ไม่ต้องใส่ null จำนวนเงินเป็น JSON number สกุลเงินใช้รหัส ISO 4217 และเก็บหน่วยบรรจุเดิมไว้ ถ้าไม่ระบุสกุลเงิน จะใช้สกุลเงินที่ตกลงกันของรายการ
ไม่จำกัดแค่เบียร์
BLF ย่อมาจาก Beverage Line Format และทุกฟิลด์ใช้ได้กับเครื่องดื่มบรรจุทุกประเภท เบียร์เป็นเพียงหนึ่งในหลายหมวดหมู่
ค็อกเทลพร้อมดื่ม
ฟิลด์เดียวกันอธิบายค็อกเทลกระป๋องได้ สไตล์บอกหมวดสินค้า และ ABV ยังคงใช้ได้
5. Mock Cocktail Co.: Citrus Club, Pre-made Cocktail 330ml 5% มี้ด ไวน์ และสาเก
ผู้ผลิต ชื่อ สไตล์ ABV บรรจุภัณฑ์ และราคา ไม่มีอะไรผูกกับเบียร์ ใช้คำศัพท์สไตล์ที่เหมาะสม
7. Mock Meadery: Wildflower, Semi-Sweet Mead 12% 375ml Bottle THB @320 x12 / 650 ไม่มีแอลกอฮอล์
0% เป็น ABV ที่ถูกต้อง ใช้สไตล์เช่น Non-Alcoholic, Hop Water หรือ Soft Drink
8. Mock Brewery: Hop Water, Non-Alcoholic Sparkling 0% 330ml Can THB @28 x24 / 65 ข้อจำกัดและเส้นทางสู่ v1.1
มาตรฐานที่ดีต้องบอกข้อจำกัดของตัวเอง นี่คือช่องว่างของ v1.0 ที่ OpenCraft กำลังพิจารณาสำหรับเวอร์ชันถัดไป
รหัสสินค้าถาวร
ลำดับเป็นเพียงดัชนีแสดงผล โทเคน SKU ที่ไม่บังคับจะช่วยให้ระบบจับคู่สินค้าเดียวกันข้ามการอัปเดตได้
ส่วนหัวของรายการและข้อมูลเมตา
ส่วนหัวระดับรายการสามารถประกาศเวอร์ชัน BLF สกุลเงินเริ่มต้น อัตรา VAT เริ่มต้น และวันที่มีผลครั้งเดียว แทนการเขียนซ้ำในทุกเรกคอร์ด
ทะเบียนสไตล์
สไตล์เป็นข้อความอิสระใน v1.0 ทะเบียนชื่อมาตรฐานร่วมกันจะทำให้ค้นหาและกรองได้แม่นยำ โดยยังอนุญาตข้อความอิสระ
การ escape และ quoting
ชื่อที่มีข้อความคล้ายโทเคน เช่นเครื่องดื่มที่ชื่อ “440ml” ต้องมีทางหนี รูปแบบขยายคือทางออกชั่วคราว
ชุดทดสอบอ้างอิง
ชุดทดสอบสาธารณะจะทำให้ parser สองตัวตีความตรงกันทุกกรณี
ลองใช้ แล้วบอกเราว่าพังตรงไหน
BLF เป็นข้อเสนอเปิด ยังไม่ใช่มาตรฐานอุตสาหกรรมที่เสร็จสมบูรณ์ ลองนำไปใช้กับรายการจริงของคุณ ทดสอบ และส่งข้อแก้ไขหรือกรณีขอบมาให้เรา ตัวอย่างจากโลกจริงคือทางที่เร็วที่สุดที่จะทำให้ v1.1 ดีขึ้น
📧 ส่งความคิดเห็นBLF v1.0 · เสนอโดย OpenCraft · 3 พฤศจิกายน 2026