质量模块和库存扫码

This commit is contained in:
qihao.gong@jikimo.com
2023-07-24 11:42:15 +08:00
parent 8d024ad625
commit 3c89404543
228 changed files with 142596 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="98.162%" x2="0%" y1="1.838%" y2="100%"><stop offset="0%" stop-color="#797DA5"/><stop offset="50.799%" stop-color="#6D7194"/><stop offset="100%" stop-color="#626584"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/><path fill="#393939" d="M32.008 69H4c-2 0-4-1-4-4V35.936l11.093-11.422L15.61 19 33 13h4l18 6 3.928 5.93-4.884 5.147-.077 12.337L32.008 69z" opacity=".324"/><path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/><path fill="#000" d="M40 39h1v5h-2v-5h1zm-7 0h1v5h-2v-5h1zm-3 0h1v5h-2v-5h1zm4 16h-.38a.293.293 0 0 1-.145-.04l-18.24-10.322c-.142-.08-.235-.27-.235-.481V30.589c0-.168.059-.325.159-.423.1-.098.227-.123.343-.07 0 0 12.918 6.018 13.27 6.185.297.139.488-.212.488-.212l4.065-6.876C33.426 29.021 34 29 34 29h1s.574.021.675.193l4.065 6.876s.191.35.487.211c.353-.166 13.27-6.184 13.27-6.184.117-.054.245-.028.344.07.1.098.159.255.159.423v13.568c0 .21-.093.4-.235.48L35.525 54.96a.292.292 0 0 1-.145.04H34zM22 38v7h25v-7H22zm2 6v-5h1v5h-1zm3 0v-5h1v5h-1zm8 0v-5h1v5h-1zm2 0v-5h1v5h-1zm5 0v-5h1v5h-1zm2 0v-5h1v5h-1zm14.925-17.582a.518.518 0 0 1 .058.402.423.423 0 0 1-.24.292L41.708 33.97a.325.325 0 0 1-.121.024.359.359 0 0 1-.294-.166l-4.769-6.972-1.413.461-.021.005v.001a.32.32 0 0 1-.2 0l-1.414-.461-4.769 6.972a.36.36 0 0 1-.294.166.325.325 0 0 1-.121-.024l-17.035-6.858a.423.423 0 0 1-.24-.292.518.518 0 0 1 .058-.402l3.466-5.494a.384.384 0 0 1 .2-.163l20.146-6.747a.39.39 0 0 1 .226-.006l20.146 6.747c.08.027.15.084.2.163l3.466 5.494zM35.01 25.413l14.426-4.688L35.01 15.92l-14.447 4.81 14.447 4.682z" opacity=".3"/><path fill="#FFF" d="M40 37h1v5h-2v-5h1zm-7 0h1v5h-2v-5h1zm-3 0h1v5h-2v-5h1zm6 6h11v-7H36v-9h.9s.516.021.608.193l3.658 6.876s.172.35.438.211c.318-.166 11.944-6.184 11.944-6.184.104-.054.22-.028.31.07.089.098.142.255.142.423v13.568c0 .21-.083.4-.211.48L37.373 52.96a.243.243 0 0 1-.131.04H36V43zm-2-7H22v7h12v10h-.38a.293.293 0 0 1-.145-.04l-18.24-10.322c-.142-.08-.235-.27-.235-.481V28.589c0-.168.059-.325.159-.423.1-.098.227-.123.343-.07 0 0 12.918 6.018 13.27 6.185.297.139.488-.212.488-.212l4.065-6.876C33.426 27.021 34 27 34 27v9zm-10 6v-5h1v5h-1zm3 0v-5h1v5h-1zm8 0v-5h1v5h-1zm2 0v-5h1v5h-1zm5 0v-5h1v5h-1zm2 0v-5h1v5h-1zm14.925-17.582a.518.518 0 0 1 .058.402.423.423 0 0 1-.24.292L41.708 31.97a.325.325 0 0 1-.121.024.359.359 0 0 1-.294-.166l-4.769-6.972-1.413.461-.021.005v.001a.32.32 0 0 1-.2 0l-1.414-.461-4.769 6.972a.36.36 0 0 1-.294.166.325.325 0 0 1-.121-.024l-17.035-6.858a.423.423 0 0 1-.24-.292.518.518 0 0 1 .058-.402l3.466-5.494a.384.384 0 0 1 .2-.163l20.146-6.747a.39.39 0 0 1 .226-.006l20.146 6.747c.08.027.15.084.2.163l3.466 5.494zM35.01 23.413l14.426-4.688L35.01 13.92l-14.447 4.81 14.447 4.682z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="400" height="300" preserveAspectRatio="xMinYMin meet" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
<metadata id="metadata27">image/svg+xml</metadata>
<g class="layer">
<title>Barcode Icon</title>
<g id="g20">
<g id="g12" fill="#8f8f8f">
<rect height="186" id="rect2" width="44" x="61" y="57"/>
<rect height="186" id="rect4" width="44" x="236.5" y="57"/>
<rect height="186" id="rect6" width="44" x="295" y="57"/>
<rect height="186" id="rect8" width="24" x="129.5" y="57"/>
<rect height="186" id="rect10" width="14" x="193" y="57"/>
</g>
<g id="g18" fill="#8f8f8f">
<polygon id="polygon14" points="59.5,269.5 25.5,269.5 25.5,30.5 59.5,30.5 59.5,19.5 14.5,19.5 14.5,280.5 59.5,280.5"/>
<polygon id="polygon16" points="339.4999694824219,19.5 339.4999694824219,30.5 374.4999694824219,30.5 374.4999694824219,269.5 339.4999694824219,269.5 339.4999694824219,280.5 385.4999694824219,280.5 385.4999694824219,19.5"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,117 @@
# Note to run this script (specifically for datamatrix generation) you will need the following installed:
# - dmtx-utils (available through apt-get)
# - pylibdmtx (available on pip3)
# - reportlab version 3.5.52 or higher (available on pip3)
from io import BytesIO
from PyPDF2 import PdfFileReader, PdfFileMerger
from reportlab.graphics import renderPDF
from reportlab.graphics.barcode import dmtx
from reportlab.graphics.shapes import Drawing
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
OTHER_DEMO_FILENAME = "barcodes_demo.pdf"
FONT = "Helvetica"
HEADER_FONT_SIZE = 16
LABEL_FONT_SIZE = 14
TITLE_FONT_SIZE = 11
CODE_FONT_SIZE = 8
FOOTER_FONT_SIZE = 8
PAGE_SIZE = A4
def create_page(barcodes, font_size_and_texts):
packet = BytesIO()
can = canvas.Canvas(packet, pagesize=PAGE_SIZE)
for barcode in barcodes:
d = Drawing(PAGE_SIZE[0], PAGE_SIZE[1])
d.add(dmtx.DataMatrixWidget(barcode[2]))
renderPDF.draw(d, can, barcode[0], barcode[1])
# add text
for font_size, texts in font_size_and_texts:
can.setFont(FONT, font_size)
for text in texts:
can.drawString(text[0], text[1], text[2])
can.save()
packet.seek(0)
return PdfFileReader(packet)
# same on all GS1 pages
footer = [(45, 45, "Don't have any barcode scanner? Right click on your screen > Inspect > Console and type the following command:"),
(45, 35, ' odoo.__DEBUG__.services["web.core"].bus.trigger("barcode_scanned", "setyourbarcodehere", $(".o_web_client")[0])'),
(45, 25, 'and replace "setyourbarcodehere" by the barcode you would like to scan OR use our mobile app.'),
(45, 15, 'For GS1 barcodes, remove all "("s and ")"s')]
# page 1
# format is (x, y, "barcode")
# barcodes to generate
barcodes = [(65, 635, "WH-RECEIPTS"), (250, 623, "0106016478556677300000000510Lot0001"), (435, 623, "0106016478556677300000000510Lot0002"),
(65, 490, "0106016478556677300000000510Lot0003"), (250, 500, "O-BTN.validate"),
(65, 345, "WH-RECEIPTS"), (250, 345, "01060164785599993650000010"), (435, 345, "01060164785599823160000002"),
(65, 215, "O-BTN.validate")]
header = [(45, 785, "GS1 Barcodes (set Barcode Nomenclature to Default GS1 Nomenclature)")]
# text to describe barcode flow
flow_labels = [(45, 752, "Receive products tracked by lot (activate Lots & Serial Numbers) "),
(45, 460, "Receive products with different unit of measures (activate Units of Measure)")]
# text to describe (above) each barcode
barcode_titles = [(55, 733, "YourCompany Receipts"), (215, 733, "Cable Management Box x5 Lot0001"), (400, 733, "Cable Management Box x5 Lot0002"),
(45, 600, "Cable Management Box x5 Lot0003"), (280, 600, "Validate"),
(55, 444, "YourCompany Receipts"), (230, 444, "Customized Cabinet (USA) 10 ft³"), (415, 444, "Customized Cabinet (Metric) 2 m³"),
(93, 315, "Validate")]
# text of barcode code (below), but made to look pretty (not literal barcode)
barcode_code = [(85, 631, "WH-RECEIPTS"), (220, 619, "(01)06016478556677(30)00000005(10)Lot0001"), (405, 619, "(01)06016478556677(30)00000005(10)Lot0002"),
(45, 485, "(01)06016478556677(30)00000005(10)Lot0003"), (275, 495, "O-BTN.validate"),
(85, 340, "WH-RECEIPTS"), (238, 340, "(01)06016478559999(3650)000010"), (425, 340, "(01)06016478559982(3160)000002"),
(85, 212, "O-BTN.validate")]
font_size_and_texts = [(HEADER_FONT_SIZE, header),
(LABEL_FONT_SIZE, flow_labels),
(TITLE_FONT_SIZE, barcode_titles),
(CODE_FONT_SIZE, barcode_code),
(FOOTER_FONT_SIZE, footer)]
page1 = create_page(barcodes, font_size_and_texts)
# page 2
# format is (x, y, "barcode")
# barcodes to generate
barcodes = [(65, 635, "WH-RECEIPTS"), (250, 635, "0106016478556332305"), (435, 635, "0006016471234567890591PAL"),
(65, 500, "O-BTN.validate")]
header = []
# text to describe barcode flow
flow_labels = [(45, 752, "Put in Pack (activate Packages)")]
# text to describe (above) each barcode
barcode_titles = [(55, 733, "YourCompany Receipts"), (233, 733, "Individual Workplace x10"), (410, 733, "Put in Pack: Pallet with SSCC"),
(93, 600, "Validate")]
# text of barcode code (below), but made to look pretty (not literal barcode)
barcode_code = [(85, 631, "WH-RECEIPTS"), (245, 631, "(01)06016478556332(30)10"), (425, 631, "(00)060164712345678905(91)PAL"),
(85, 495, "O-BTN.validate")]
font_size_and_texts = [(HEADER_FONT_SIZE, header),
(LABEL_FONT_SIZE, flow_labels),
(TITLE_FONT_SIZE, barcode_titles),
(CODE_FONT_SIZE, barcode_code),
(FOOTER_FONT_SIZE, footer)]
page2 = create_page(barcodes, font_size_and_texts)
# merge with other demo barcodes
merger = PdfFileMerger()
merger.append(PdfFileReader(open(OTHER_DEMO_FILENAME, "rb")))
merger.append(page1)
merger.append(page2)
merger.write(OTHER_DEMO_FILENAME)

View File

@@ -0,0 +1,192 @@
#!/bin/sh
barcode -t 2x7+40+40 -m 50x30 -p "210x297mm" -e code128b -n > barcodes_actions_barcode.ps << BARCODES
O-CMD.MAIN-MENU
O-CMD.DISCARD
O-BTN.validate
O-CMD.cancel
O-BTN.print-op
O-BTN.print-slip
O-BTN.pack
O-BTN.scrap
O-BTN.record-components
O-CMD.PREV
O-CMD.NEXT
O-CMD.PAGER-FIRST
O-CMD.PAGER-LAST
BARCODES
cat > barcodes_actions_header.ps << HEADER
/showTitle { /Helvetica findfont 12 scalefont setfont moveto show } def
(MAIN MENU) 89 768 showTitle
(DISCARD) 348 768 showTitle
(VALIDATE) 89 660 showTitle
(CANCEL) 348 660 showTitle
(PRINT PICKING OPERATION) 89 551 showTitle
(PRINT DELIVERY SLIP) 348 551 showTitle
(PUT IN PACK) 89 444 showTitle
(SCRAP) 348 444 showTitle
(RECORD COMPONENTS) 89 337 showTitle
(PREVIOUS PAGE) 348 337 showTitle
(NEXT PAGE) 89 230 showTitle
(FIRST PAGE) 348 230 showTitle
(LAST PAGE) 89 123 showTitle
HEADER
cat barcodes_actions_header.ps barcodes_actions_barcode.ps | ps2pdf - - > barcodes_actions.pdf
rm barcodes_actions_header.ps barcodes_actions_barcode.ps
# pg 1 of demo barcodes due to ps headers being restricted to 1 page. Some blanks may exist due to flows having a rows with less than 3 barcodes.
barcode -t 3x7+20+35 -m 25x30 -p "210x297mm" -e code128b -n > barcodes_demo_barcode_pg_1.ps << BARCODES
WH-RECEIPTS
601647855638
O-BTN.validate
WH/OUT/00005
601647855644
O-BTN.validate
WH-RECEIPTS
601647855640
601647855631
LOT-000002
LOT-000003
O-BTN.validate
WH-STOCK
601647855649
2601892
O-BTN.validate
WH-RECEIPTS
601647855650
O-BTN.pack
BARCODES
# blank lines included for easier visual matching to barcode spacing
cat > barcodes_demo_header_pg_1.ps << HEADER
/showLabel { /Helvetica findfont 14 scalefont setfont moveto show } def
/showTitle { /Helvetica findfont 11 scalefont setfont moveto show } def
/showCode { /Helvetica findfont 8 scalefont setfont moveto show } def
/showFooter { /Helvetica findfont 8 scalefont setfont moveto show } def
(Receive products in stock) 45 797 showLabel
(YourCompany Receipts) 45 777 showTitle
(WH-RECEIPTS) 85 718 showCode
(Desk Stand with Screen) 230 777 showTitle
(601647855638) 271 718 showCode
(Validate) 415 777 showTitle
(O-BTN.validate) 456 718 showCode
(Deliver products to your customers) 45 687 showLabel
(WH/OUT/00005) 45 667 showTitle
(WH/OUT/00005) 85 608 showCode
(Desk Combination) 230 667 showTitle
(601647855644) 271 608 showCode
(Validate) 415 667 showTitle
(O-BTN.validate) 456 608 showCode
(Receive products tracked by lot number (activate Lots & Serial Numbers)) 45 577 showLabel
(YourCompany Receipts) 45 557 showTitle
(WH-RECEIPTS) 85 498 showCode
(Corner Desk Black) 230 557 showTitle
(601647855640) 271 498 showCode
(Cable Management Box) 415 557 showTitle
(601647855631) 456 498 showCode
(LOT-000002) 45 447 showTitle
(LOT-000002) 85 388 showCode
(LOT-000003) 230 447 showTitle
(LOT-000003) 271 388 showCode
(Validate) 415 447 showTitle
(O-BTN.validate) 456 388 showCode
(Internal transfer (activate Storage Locations)) 45 357 showLabel
(WH/Stock) 45 337 showTitle
(WH-STOCK) 85 278 showCode
(Pedal Bin) 230 337 showTitle
(601647855649) 271 278 showCode
(WH/Stock/Shelf1) 415 337 showTitle
(2601892) 456 278 showCode
(Validate) 45 227 showTitle
(O-BTN.validate) 85 168 showCode
(Put in Pack (activate Packages)) 45 137 showLabel
(YourCompany Receipts) 45 117 showTitle
(WH-RECEIPTS) 85 58 showCode
(Large Cabinet) 230 117 showTitle
(601647855650) 271 58 showCode
(Put in Pack) 415 117 showTitle
(O-BTN.pack) 456 58 showCode
(Don't have any barcode scanner? Right click on your screen > Inspect > Console and type the following command:) 45 35 showFooter
( odoo.__DEBUG__.services["web.core"].bus.trigger("barcode_scanned", "setyourbarcodehere", \$(".o_web_client")[0])) 45 25 showFooter
(and replace "setyourbarcodehere" by the barcode you would like to scan OR use our mobile app.) 45 15 showFooter
HEADER
# pg 2 of demo barcodes. Some blanks may exist due to flows having a rows with less than 3 barcodes.
barcode -t 3x7+20+35 -m 25x30 -p "210x297mm" -e code128b -n > barcodes_demo_barcode_pg_2.ps << BARCODES
O-BTN.validate
BATCH/00002
601647855637
601647855651
601647855635
O-BTN.validate
BATCH/00001
601647855652
CLUSTER-PACK-1
601647855653
CLUSTER-PACK-1
601647855651
CLUSTER-PACK-2
O-BTN.validate
BARCODES
cat > barcodes_demo_header_pg_2.ps << HEADER
/showLabel { /Helvetica findfont 14 scalefont setfont moveto show } def
/showTitle { /Helvetica findfont 11 scalefont setfont moveto show } def
/showCode { /Helvetica findfont 8 scalefont setfont moveto show } def
/showFooter { /Helvetica findfont 8 scalefont setfont moveto show } def
(Validate) 45 777 showTitle
(O-BTN.validate) 85 718 showCode
(Batch picking (activate Batch Pickings)) 45 687 showLabel
(BATCH/00002) 45 667 showTitle
(BATCH/00002) 85 608 showCode
(Large Meeting Table) 230 667 showTitle
(601647855637) 271 608 showCode
(Four Person Desk) 415 667 showTitle
(601647855651) 456 608 showCode
(Three-Seat Sofa) 45 557 showTitle
(601647855635) 85 498 showCode
(Validate) 230 557 showTitle
(O-BTN.validate) 271 498 showCode
(Batch picking with cluster pickings (activate Batch Pickings and Packages)) 45 467 showLabel
(BATCH/00001) 45 447 showTitle
(BATCH/00001) 85 388 showCode
(Cabinet with Doors) 230 447 showTitle
(601647855652) 271 388 showCode
(CLUSTER-PACK-1) 415 447 showTitle
(CLUSTER-PACK-1) 456 388 showCode
(Acoustic Bloc Screens) 45 337 showTitle
(601647855653) 85 278 showCode
(CLUSTER-PACK-1) 230 337 showTitle
(CLUSTER-PACK-1) 271 278 showCode
(Four Person Desk) 415 337 showTitle
(601647855651) 456 278 showCode
(CLUSTER-PACK-2) 45 227 showTitle
(CLUSTER-PACK-2) 85 168 showCode
(Validate) 230 227 showTitle
(O-BTN.validate) 271 168 showCode
(Don't have any barcode scanner? Right click on your screen > Inspect > Console and type the following command:) 45 35 showFooter
( odoo.__DEBUG__.services["web.core"].bus.trigger("barcode_scanned", "setyourbarcodehere", \$(".o_web_client")[0])) 45 25 showFooter
(and replace "setyourbarcodehere" by the barcode you would like to scan OR use our mobile app.) 45 15 showFooter
HEADER
cat barcodes_demo_header_pg_1.ps barcodes_demo_barcode_pg_1.ps barcodes_demo_header_pg_2.ps barcodes_demo_barcode_pg_2.ps | ps2pdf - - > barcodes_demo.pdf
rm barcodes_demo_header_pg_1.ps barcodes_demo_barcode_pg_1.ps
rm barcodes_demo_header_pg_2.ps barcodes_demo_barcode_pg_2.ps
python3 make_barcodes.py

View File

@@ -0,0 +1,21 @@
/** @odoo-module **/
import LineComponent from "@stock_barcode/components/line";
export default class GroupedLineComponent extends LineComponent {
get isSelected() {
return this.line.virtual_ids.indexOf(this.env.model.selectedLineVirtualId) !== -1;
}
get opened() {
return this.env.model.groupKey(this.line) === this.env.model.unfoldLineKey;
}
toggleSublines(ev) {
ev.stopPropagation();
this.env.model.toggleSublines(this.line);
}
}
GroupedLineComponent.components = { LineComponent };
GroupedLineComponent.template = 'stock_barcode.GroupedLineComponent';

View File

@@ -0,0 +1,28 @@
.o_barcode_client_action .o_barcode_lines {
.o_barcode_line_summary {
position: sticky;
top: 0;
z-index: 1;
flex-basis: 100%;
&.o_unfolded {
border-bottom-width: 2px;
border-bottom-style: solid;
border-bottom-color: inherit;
}
}
.o_sublines .o_barcode_line {
border-left-width: 12px;
&:last-child {
margin-bottom: 0;
box-shadow: none;
border-bottom: 0;
&.o_selected {
box-shadow: inset 0px 0px 0px 3px $o-enterprise-primary-color;
}
}
}
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="stock_barcode.GroupedLineComponent" owl="1">
<div t-on-click="select"
class="o_barcode_line list-group-item d-flex flex-row flex-wrap pt-0"
t-att-data-barcode="line.product_id.barcode" t-attf-class="{{componentClasses}}">
<div class="o_barcode_line_summary d-flex flex-grow-1 py-2 mt-2" t-att-class="opened ? 'o_unfolded': ''">
<div class="o_barcode_line_details flex-grow-1">
<t t-call="stock_barcode.LineSourceLocation"/>
<t t-call="stock_barcode.LineTitle"/>
<t t-call="stock_barcode.LineQuantity"/>
<t t-call="stock_barcode.LineDestinationLocation"/>
</div>
<button t-on-click="toggleSublines" class="o_line_button o_toggle_sublines btn btn-primary ms-2 ms-sm-4">
<i t-att-class="'fa fa-2x fa-caret-' + (opened ? 'up' : 'down')"/>
</button>
</div>
<div class="o_sublines mb-2 flex-grow-1" t-if="opened">
<t t-foreach="line.lines" t-as="subline" t-key="subline.virtual_id">
<LineComponent line="subline" displayUOM="props.displayUOM" subline="true"/>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,125 @@
/** @odoo-module **/
import { bus } from 'web.core';
const { Component } = owl;
export default class LineComponent extends Component {
get destinationLocationPath () {
return this._getLocationPath(this.env.model._defaultDestLocation(), this.line.location_dest_id);
}
get displayDestinationLocation() {
return !this.props.subline && this.env.model.displayDestinationLocation;
}
get displayResultPackage() {
return this.env.model.displayResultPackage;
}
get displaySourceLocation() {
return !this.props.subline && this.env.model.displaySourceLocation;
}
get highlightLocation() {
return this.env.model.lastScanned.sourceLocation &&
this.env.model.lastScanned.sourceLocation.id == this.line.location_id.id;
}
get isComplete() {
if (!this.qtyDemand || this.qtyDemand != this.qtyDone) {
return false;
} else if (this.isTracked && !this.lotName) {
return false;
}
return true;
}
get isSelected() {
return this.line.virtual_id === this.env.model.selectedLineVirtualId ||
(this.line.package_id && this.line.package_id.id === this.env.model.lastScanned.packageId);
}
get isTracked() {
return this.line.product_id.tracking !== 'none';
}
get lotName() {
return (this.line.lot_id && this.line.lot_id.name) || this.line.lot_name || '';
}
get nextExpected() {
if (!this.isSelected) {
return false;
} else if (this.isTracked && !this.lotName) {
return 'lot';
} else if (this.qtyDemand && this.qtyDone < this.qtyDemand) {
return 'quantity';
}
}
get qtyDemand() {
return this.env.model.getQtyDemand(this.line);
}
get qtyDone() {
return this.env.model.getQtyDone(this.line);
}
get quantityIsSet() {
return this.line.inventory_quantity_set;
}
get incrementQty() {
return this.env.model.getIncrementQuantity(this.line);
}
get line() {
return this.props.line;
}
get requireLotNumber() {
return true;
}
get sourceLocationPath() {
return this._getLocationPath(this.env.model._defaultLocation(), this.line.location_id);
}
get componentClasses() {
return [
this.isComplete ? 'o_line_completed' : 'o_line_not_completed',
this.env.model.lineIsFaulty(this) ? 'o_faulty' : '',
this.isSelected ? 'o_selected o_highlight' : ''
].join(' ');
}
_getLocationPath(rootLocation, currentLocation) {
let locationName = currentLocation.display_name;
if (this.env.model.shouldShortenLocationName) {
if (rootLocation && rootLocation.id != currentLocation.id) {
const name = rootLocation.display_name;
locationName = locationName.replace(name, '...');
}
}
return locationName.replace(new RegExp(currentLocation.name + '$'), '');
}
edit() {
bus.trigger('edit-line', { line: this.line });
}
addQuantity(quantity, ev) {
this.env.model.updateLineQty(this.line.virtual_id, quantity);
}
select(ev) {
ev.stopPropagation();
this.env.model.selectLine(this.line);
this.env.model.trigger('update');
}
setOnHandQuantity(ev) {
this.env.model.setOnHandQuantity(this.line);
}
}
LineComponent.template = 'stock_barcode.LineComponent';

View File

@@ -0,0 +1,128 @@
$o-barcode-completed-color: #befdcc;
.o_barcode_client_action .o_barcode_lines .o_barcode_line {
flex: 0 0 auto;
border-width: 1px 0;
&:first-child {
border-top-width: 0;
}
&:last-child {
box-shadow: 0 3px 10px $gray-300;
margin-bottom: 60vh;
}
&_details {
.fa:first-child {
opacity: 0.5;
margin-right: 5px;
}
}
&.o_barcode_line_package {
.o_barcode_line_details > * {
flex: 1 0 auto;
}
.o_barcode_line_details > .o_barcode_package_name {
flex: 0 1 auto;
overflow: hidden;
> span {
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
}
}
}
&.o_faulty {
background-color: rgba(map-get($theme-colors, 'danger'), 0.25);
&.o_selected {
box-shadow: inset 0px 0px 0px 3px map-get($theme-colors, 'danger');
}
}
&.o_line_completed {
background: var(--barcode__line--completed, #befdcc);
}
&.o_line_not_completed {
background: var(--barcode__line--notCompleted, #fcf9f2);
}
&.o_selected {
box-shadow: inset 0px 0px 0px 3px $o-enterprise-primary-color;
}
.o_barcode_scanner_qty {
font-size: 1em;
border-color: transparent; // Overwrite default badge color
margin-left: -$badge-padding-x; // Compensate badge padding
&[class*="badge-"] {
margin-left: 0; // If a style class is applied, reset compensation margin
}
.qty-done, .inventory_quantity {
min-width: 20px;
&.o_flash {
animation-name: highlighting-flash-primary;
animation-duration: 0.5s;
}
}
}
.o_line_buttons {
min-width: 132px;
text-align: right;
}
.o_line_button {
min-width: 60px;
height: 60px;
padding: 0 8px;
border-radius: 8px;
line-height: 16px;
font-size: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-transform: none;
&.o_shortcut_displayed {
padding-top: 14px;
}
&.btn-secondary {
@include o-hover-opacity();
}
&.o_set {
border: 4px solid $o-brand-primary;
color: $o-brand-primary;
&.o_difference {
color: orange;
border-color: orange;
}
}
&[disabled] {
background-color: gray;
border-color: gray;
}
}
.o_next_expected {
color: #00A09D;
opacity: 1 !important;
}
[name=source_location].o_highlight {
background-color: $o-barcode-completed-color;
& .fa { opacity: 1; }
}
}

View File

@@ -0,0 +1,136 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- Line's sub-elements -->
<t t-name="stock_barcode.LineTitle" owl="1">
<t t-if="props.line.product_id.default_code or props.line.product_id.code">
<div class="o_barcode_line_title">
<i class="fa fa-fw fa-tags"/>
<span t-if="props.line.product_id.default_code"
class="o_barcode_product_ref h5 fw-bold"
t-esc="props.line.product_id.default_code"/>
<span t-if="props.line.product_id.code != props.line.product_id.default_code"
class="o_barcode_partner_code ms-1 h5 text-muted"
t-esc="props.line.product_id.code"/>
</div>
<div>
<i class="fa fa-fw"/>
<span class="product-label" t-esc="props.line.product_id.display_name"/>
</div>
</t>
<div t-else="" class="o_barcode_line_title pb-1">
<i class="fa fa-fw fa-tags"/>
<span class="product-label" t-esc="props.line.product_id.display_name"/>
</div>
</t>
<t t-name="stock_barcode.LineQuantity" owl="1">
<div name="quantity">
<i class="fa fa-fw fa-cube" t-attf-class="{{nextExpected === 'quantity' ? 'o_next_expected' : ''}}"/>
<span t-attf-class="o_barcode_scanner_qty font-monospace badge #{' '}">
<span class="qty-done d-inline-block text-start"
t-attf-class="
{{nextExpected === 'quantity' &amp;&amp; qtyDone ? 'o_flash' : ''}}
{{isSelected &amp;&amp; qtyDemand &amp;&amp; qtyDone &amp;&amp; qtyDone &lt; qtyDemand ? 'fw-bolder' : ''}}"
t-esc="env.model.IsNotSet(line) ? '?' : qtyDone"/>
<span t-if="qtyDemand" t-esc="'/ ' + qtyDemand"/>
</span>
<span t-if="props.displayUOM" t-esc="line.product_uom_id.name"/>
</div>
</t>
<t t-name="stock_barcode.LineOwner" owl="1">
<div t-if="line.owner_id">
<i class="fa fa-fw fa-user-o"/>
<span class="o_line_owner" t-esc="line.owner_id.display_name"/>
</div>
</t>
<t t-name="stock_barcode.LineSourceLocation" owl="1">
<div name="source_location" t-if="displaySourceLocation" title="Source Location"
t-attf-class="{{line.location_id.usage != 'internal' ? 'text-danger' : ''}} {{highlightLocation ? 'o_highlight' : ''}}">
<i class="fa fa-fw fa-sign-out"/>
<span class="o_line_source_location fst-italic text-muted">
<t t-esc="sourceLocationPath"/>
<span t-esc="line.location_id.name"
t-attf-class="
{{highlightLocation ? 'fw-bold' : ''}}
{{line.location_id.usage != 'internal' ? 'text-danger' : 'text-black'}}"/>
</span>
</div>
</t>
<t t-name="stock_barcode.LineDestinationLocation" owl="1">
<div name="destination_location" t-if="displayDestinationLocation" title="Destination Location"
t-att-class="line.location_dest_id.usage != 'internal' ? 'text-danger' : ''">
<i class="fa fa-fw fa-sign-in"/>
<span class="o_line_destination_location fst-italic text-muted">
<t t-esc="destinationLocationPath"/>
<span t-esc="line.location_dest_id.name"
t-attf-class="
{{env.model.lastScanned.destLocation &amp;&amp; env.model.lastScanned.destLocation.id == line.location_dest_id.id ? 'fw-bold' : ''}}
{{line.location_dest_id.usage != 'internal' ? 'text-danger' : 'text-black'}}"/>
</span>
</div>
</t>
<!-- Line's template -->
<t t-name="stock_barcode.LineComponent" owl="1">
<div t-on-click="select"
class="o_barcode_line list-group-item d-flex flex-row flex-nowrap"
t-att-data-virtual-id="line.virtual_id" t-attf-class="{{componentClasses}}"
t-att-data-barcode="line.product_id.barcode">
<div class="o_barcode_line_details flex-grow-1 flex-column flex-nowrap">
<t t-call="stock_barcode.LineSourceLocation"/>
<!-- Hides product's name if it's a subline, as it is already on the parent line. -->
<t t-if="!props.subline" t-call="stock_barcode.LineTitle"/>
<div t-if="isTracked and requireLotNumber" name="lot">
<i class="fa fa-fw fa-barcode" t-attf-class="{{nextExpected === 'lot' ? 'o_next_expected' : ''}}"/>
<span class="o_line_lot_name" t-esc="lotName"/>
</div>
<t t-call="stock_barcode.LineQuantity"/>
<div t-if="line.package_id || line.result_package_id" name="package">
<i class="fa fa-fw fa-archive"/>
<span t-if="line.package_id" class="package" t-esc="line.package_id.name"/>
<i t-if="displayResultPackage" class="fa fa-long-arrow-right mx-1"/>
<span t-if="line.result_package_id" class="result-package" t-esc="line.result_package_id.name"/>
<span t-if="line.result_package_id &amp;&amp; line.result_package_id.package_type_id"
class="fst-italic text-muted">
(<t t-esc="line.result_package_id.package_type_id.name"/>)
</span>
</div>
<t t-call="stock_barcode.LineOwner"/>
<t t-call="stock_barcode.LineDestinationLocation"/>
</div>
<div class="o_line_buttons">
<button t-on-click="edit" class="o_line_button o_edit btn"
t-att-class="this.env.model.lineCanBeEdited(line) ? 'btn-secondary' : ''"
t-att-disabled="!this.env.model.lineCanBeEdited(line)">
<i class="fa fa-2x fa-pencil"/>
</button>
<button t-if="env.model.displaySetButton" t-on-click="setOnHandQuantity"
class="o_line_button o_set btn ms-2 ms-sm-4"
t-attf-class="{{quantityIsSet &amp;&amp; qtyDone != qtyDemand ? 'o_difference' : ''}}">
<i t-if="quantityIsSet" class="fa fa-2x"
t-attf-class="{{qtyDone == qtyDemand ? 'fa-check' : 'fa-times'}}"/>
</button>
<span t-attf-class="{{env.model.incrementButtonsDisplayStyle}}">
<button t-if="env.model.getDisplayDecrementBtn(line)" name="decrementButton" t-on-click="(ev) => this.addQuantity(-1, ev)"
class="o_line_button o_remove_unit btn btn-primary ms-2 ms-sm-4"
t-attf-disabled="{{qtyDone &lt;= 0 || qtyDone == '?'}}">-1</button>
<button t-if="env.model.getDisplayIncrementBtn(line)" name="incrementButton"
t-on-click="(ev) => this.addQuantity(incrementQty, ev)" t-esc="'+' + incrementQty"
t-att-disabled="!this.env.model.lineCanBeEdited(line)"
class="o_line_button o_add_quantity btn btn-primary ms-2 ms-sm-4"/>
</span>
<button t-if="isSelected and env.model.getDisplayIncrementPackagingBtn(line)" name="incrementPackagingButton"
t-on-click="(ev) => this.addQuantity(line.product_packaging_uom_qty, ev)"
class="o_line_button w-100 btn btn-primary my-3 d-block">
<div class="text-capitalize">
+ <t t-esc="line.product_packaging_id.name"/>
</div>
</button>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,391 @@
/** @odoo-module **/
import { ChatterContainer } from '@mail/components/chatter_container/chatter_container';
import BarcodePickingModel from '@stock_barcode/models/barcode_picking_model';
import BarcodeQuantModel from '@stock_barcode/models/barcode_quant_model';
import { bus } from 'web.core';
import config from 'web.config';
import GroupedLineComponent from '@stock_barcode/components/grouped_line';
import LineComponent from '@stock_barcode/components/line';
import PackageLineComponent from '@stock_barcode/components/package_line';
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import * as BarcodeScanner from '@web/webclient/barcode/barcode_scanner';
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { View } from "@web/views/view";
const { Component, onMounted, onPatched, onWillStart, onWillUnmount, useSubEnv } = owl;
/**
* Main Component
* Gather the line information.
* Manage the scan and save process.
*/
class MainComponent extends Component {
//--------------------------------------------------------------------------
// Lifecycle
//--------------------------------------------------------------------------
setup() {
this.rpc = useService('rpc');
this.orm = useService('orm');
this.notification = useService('notification');
this.props.model = this.props.action.res_model;
this.props.id = this.props.action.context.active_id;
const model = this._getModel(this.props);
useSubEnv({model});
this._scrollBehavior = 'smooth';
this.isMobile = config.device.isMobile;
onWillStart(async () => {
const barcodeData = await this.rpc(
'/stock_barcode/get_barcode_data',
{
model: this.props.model,
res_id: this.props.id || false,
}
);
this.groups = barcodeData.groups;
this.env.model.setData(barcodeData);
this.env.model.on('process-action', this, this._onDoAction);
this.env.model.on('notification', this, this._onNotification);
this.env.model.on('refresh', this, this._onRefreshState);
this.env.model.on('update', this, () => this.render(true));
this.env.model.on('do-action', this, args => bus.trigger('do-action', args));
this.env.model.on('history-back', this, () => this.env.config.historyBack());
});
onMounted(() => {
bus.on('barcode_scanned', this, this._onBarcodeScanned);
bus.on('edit-line', this, this._onEditLine);
bus.on('exit', this, this.exit);
bus.on('open-package', this, this._onOpenPackage);
bus.on('refresh', this, this._onRefreshState);
bus.on('warning', this, this._onWarning);
});
onWillUnmount(() => {
bus.off('barcode_scanned', this, this._onBarcodeScanned);
bus.off('edit-line', this, this._onEditLine);
bus.off('exit', this, this.exit);
bus.off('open-package', this, this._onOpenPackage);
bus.off('refresh', this, this._onRefreshState);
bus.off('warning', this, this._onWarning);
});
onPatched(() => {
this._scrollToSelectedLine();
});
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
get displayHeaderInfoAsColumn() {
return this.env.model.isDone || this.env.model.isCancelled;
}
get displayBarcodeApplication() {
return this.env.model.view === 'barcodeLines';
}
get displayBarcodeActions() {
return this.env.model.view === 'actionsView';
}
get displayBarcodeLines() {
return this.displayBarcodeApplication && this.env.model.canBeProcessed;
}
get displayInformation() {
return this.env.model.view === 'infoFormView';
}
get displayNote() {
return !this._hideNote && this.env.model.record.note;
}
get displayPackageContent() {
return this.env.model.view === 'packagePage';
}
get displayProductPage() {
return this.env.model.view === 'productPage';
}
get lineFormViewData() {
const data = this.env.model.viewsWidgetData;
data.context = data.additionalContext;
data.resId = this._editedLineParams && this._editedLineParams.currentId;
return data;
}
get highlightValidateButton() {
return this.env.model.highlightValidateButton;
}
get info() {
return this.env.model.barcodeInfo;
}
get isTransfer() {
return this.currentSourceLocation && this.currentDestinationLocation;
}
get lines() {
return this.env.model.groupedLines;
}
get mobileScanner() {
return BarcodeScanner.isBarcodeScannerSupported();
}
get packageLines() {
return this.env.model.packageLines;
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
_getModel(params) {
const { rpc, orm, notification } = this;
if (params.model === 'stock.picking') {
return new BarcodePickingModel(params, { rpc, orm, notification });
} else if (params.model === 'stock.quant') {
return new BarcodeQuantModel(params, { rpc, orm, notification });
} else {
throw new Error('No JS model define');
}
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
async cancel() {
await this.env.model.save();
const action = await this.orm.call(
this.props.model,
'action_cancel_from_barcode',
[[this.props.id]]
);
const onClose = res => {
if (res && res.cancelled) {
this.env.model._cancelNotification();
this.env.config.historyBack();
}
};
bus.trigger('do-action', {
action,
options: {
on_close: onClose.bind(this),
},
});
}
async openMobileScanner() {
const barcode = await BarcodeScanner.scanBarcode();
if (barcode) {
this.env.model.processBarcode(barcode);
if ('vibrate' in window.navigator) {
window.navigator.vibrate(100);
}
} else {
this.env.services.notification.notify({
type: 'warning',
message: this.env._t("Please, Scan again !"),
});
}
}
async exit(ev) {
if (this.displayBarcodeApplication) {
await this.env.model.save();
this.env.config.historyBack();
} else {
this.toggleBarcodeLines();
}
}
hideNote(ev) {
this._hideNote = true;
this.render();
}
async openProductPage() {
if (!this._editedLineParams) {
await this.env.model.save();
}
this.env.model.displayProductPage();
}
async print(action, method) {
await this.env.model.save();
const options = this.env.model._getPrintOptions();
if (options.warning) {
return this.env.model.notification.add(options.warning, { type: 'warning' });
}
if (!action && method) {
action = await this.orm.call(
this.props.model,
method,
[[this.props.id]]
);
}
bus.trigger('do-action', { action, options });
}
putInPack(ev) {
ev.stopPropagation();
this.env.model._putInPack();
}
saveFormView(lineRecord) {
const lineId = (lineRecord && lineRecord.data.id) || (this._editedLineParams && this._editedLineParams.currentId);
const recordId = (lineRecord.resModel === this.props.model) ? lineId : undefined
this._onRefreshState({ recordId, lineId });
}
toggleBarcodeActions(ev) {
ev.stopPropagation();
this.env.model.displayBarcodeActions();
}
async toggleBarcodeLines(lineId) {
this._editedLineParams = undefined;
await this.env.model.displayBarcodeLines(lineId);
}
async toggleInformation() {
await this.env.model.save();
this.env.model.displayInformation();
}
/**
* Calls `validate` on the model and then triggers up the action because OWL
* components don't seem able to manage wizard without doing custom things.
*
* @param {OdooEvent} ev
*/
async validate(ev) {
ev.stopPropagation();
await this.env.model.validate();
}
/**
* Handler called when a barcode is scanned.
*
* @private
* @param {string} barcode
*/
_onBarcodeScanned(barcode) {
if (this.displayBarcodeApplication) {
this.env.model.processBarcode(barcode);
}
}
_scrollToSelectedLine() {
if (!this.displayBarcodeLines) {
this._scrollBehavior = 'auto';
return;
}
let selectedLine = document.querySelector('.o_sublines .o_barcode_line.o_highlight');
const isSubline = Boolean(selectedLine);
if (!selectedLine) {
selectedLine = document.querySelector('.o_barcode_line.o_highlight');
}
if (!selectedLine) {
const matchingLine = this.env.model.findLineForCurrentLocation();
if (matchingLine) {
selectedLine = document.querySelector(`.o_barcode_line[data-virtual-id="${matchingLine.virtual_id}"]`);
}
}
if (selectedLine) {
// If a line is selected, checks if this line is on the top of the
// page, and if it's not, scrolls until the line is on top.
const header = document.querySelector('.o_barcode_header');
const lineRect = selectedLine.getBoundingClientRect();
const navbar = document.querySelector('.o_main_navbar');
const page = document.querySelector('.o_barcode_lines');
// Computes the real header's height (the navbar is present if the page was refreshed).
const headerHeight = navbar ? navbar.offsetHeight + header.offsetHeight : header.offsetHeight;
if (lineRect.top < headerHeight || lineRect.bottom > (headerHeight + lineRect.height)) {
let top = lineRect.top - headerHeight + page.scrollTop;
if (isSubline) {
const parentLine = selectedLine.closest('.o_barcode_lines > .o_barcode_line');
const parentSummary = parentLine.querySelector('.o_barcode_line_summary');
top -= parentSummary.getBoundingClientRect().height;
}
page.scroll({ left: 0, top, behavior: this._scrollBehavior });
this._scrollBehavior = 'smooth';
}
}
}
async _onDoAction(ev) {
bus.trigger('do-action', {
action: ev,
options: {
on_close: this._onRefreshState.bind(this),
},
});
}
async _onEditLine(ev) {
let { line } = ev;
const virtualId = line.virtual_id;
await this.env.model.save();
// Updates the line id if it's missing, in order to open the line form view.
if (!line.id && virtualId) {
line = this.env.model.pageLines.find(l => Number(l.dummy_id) === virtualId);
}
this._editedLineParams = this.env.model.getEditedLineParams(line);
await this.openProductPage();
}
_onNotification(notifParams) {
const { message } = notifParams;
delete notifParams.message;
this.env.services.notification.add(message, notifParams);
}
_onOpenPackage(packageId) {
this._inspectedPackageId = packageId;
this.env.model.displayPackagePage();
}
async _onRefreshState(paramsRefresh) {
const { recordId, lineId } = paramsRefresh || {}
const { route, params } = this.env.model.getActionRefresh(recordId);
const result = await this.rpc(route, params);
await this.env.model.refreshCache(result.data.records);
await this.toggleBarcodeLines(lineId);
}
/**
* Handles triggered warnings. It can happen from an onchange for example.
*
* @param {CustomEvent} ev
*/
_onWarning(ev) {
const { title, message } = ev.detail;
this.env.services.dialog.add(ConfirmationDialog, { title, body: message });
}
}
MainComponent.template = 'stock_barcode.MainComponent';
MainComponent.components = {
View,
GroupedLineComponent,
LineComponent,
PackageLineComponent,
ChatterContainer,
};
registry.category("actions").add("stock_barcode_client_action", MainComponent);
export default MainComponent;

View File

@@ -0,0 +1,270 @@
.o_barcode_client_action {
display: flex;
flex-direction: column;
height: 100%;
background-color: $o-view-background-color;
overflow: auto;
.o_strong {
font-weight: bold;
}
// Top navbar
// =====================================
.o_barcode_header {
flex: 0 0 46px;
color: white;
background-color: var(--barcode__header-bg, #{$o-brand-odoo});
.nav-link {
cursor: pointer;
}
.nav-link, .navbar-text {
font-size: 16px;
color: #FFFFFF;
&:hover {
color: rgba($color: #FFFFFF, $alpha: 0.75)
}
}
}
// Top Block
// =====================================
.o_barcode_message {
box-shadow: inset 0 0 20px $gray-900;
.o_barcode_pic {
display: flex;
align-items: center;
flex: 1 1 60%;
max-width: 200px;
.fa-exclamation-triangle {
opacity: 0.8;
}
}
}
// Summary
// =====================================
.o_barcode_lines_header {
font-size: 16px;
color: white;
background-color: var(--barcode__linesHeader-bg, #{$o-gray-800});
@include media-breakpoint-down(md) {
font-size: 14px;
}
&:empty {
display: none;
}
}
// Lines Block
// =====================================
.o_barcode_lines {
clear: both;
flex: auto;
overflow: auto;
color: $gray-800;
margin-bottom: 60px;
@media (orientation: portrait) {
margin-bottom: 30px;
}
&.o_js_has_highlight .o_barcode_line.o_highlight {
&.o_highlight_green {
box-shadow: inset 0px 0px 0px 3px $o-brand-secondary;
}
.product-label, .o_barcode_scanner_qty {
color: $headings-color;
}
.qty-done, .inventory_quantity {
font-weight: bold;
&.o_js_qty_animate {
animation: o_barcode_scanner_qty_update .2s alternate;
}
}
}
}
// Embedded views
// =====================================
.o_barcode_generic_view {
flex: 1;
overflow: auto;
margin-bottom: 30px;
.o_view_controller, .o_view_controller .o_form_view.o_form_nosheet {
height: 100%;
flex-grow: 1;
padding-top: 0;
}
.o_field_one2many.o_field_widget .o_kanban_record {
font-size: 0.6em;
}
.o_form_view {
&.o_xxs_form_view {
.o_td_label > .o_form_label {
color: $gray-900;
font-weight: bold;
padding-top: 5px;
}
.o_field_widget {
font-size: 1em;
.btn.fa {
font-size: 1em;
}
}
.o_list_view {
th, .o_field_widget {
font-size: $font-size-base;
}
}
}
&.o_form_nosheet {
padding-bottom: 80px;
}
.o_kanban_record {
font-size: 1em;
}
}
}
// Settings menu
// =====================================
.o_barcode_settings {
display: flex;
flex: auto;
> button {
flex: 1 0 auto;
border-bottom: 1px solid $border-color;
&:last-child {
border-bottom: 0;
}
}
}
// Control buttons (validate, previous,
// next, put in pack, ...)
// =====================================
.o_barcode_control {
flex: 0 0 60px;
margin: 0 -1px;
width: 100%;
> .btn {
flex: 1;
width: 50%;
@media (orientation: portrait) {
font-size: 0.8em;
}
@media (orientation: landscape) {
height: 60px;
}
border-width: 1px 0 0 0;
border-style: solid;
&.btn-secondary {
color: $gray-800;
border-color: $gray-400;
}
&.btn-primary {
border-color: $primary;
}
&.btn-success {
border-color: $success;
}
&[disabled] {
opacity: 1;
background-color: $gray-200;
color: $btn-link-disabled-color;
}
+ .btn {
border-left-width: 1px;
border-left-color: $gray-400;
}
}
.fa-angle-left, .fa-angle-right {
font-size: 1.5em;
}
&:first-of-type {
box-shadow: 0 -3px 10px $gray-300;
}
}
// Line form
// =====================================
.o_barcode_line_form {
margin-left: 24px;
margin-bottom: 36px;
font-size: 1.4em;
@include media-breakpoint-down(md) {
margin-left: 0;
}
.row {
width: 700px;
@include media-breakpoint-down(md) {
width: 100vw;
}
&.row-long {
width: 100%;
}
a.o_field_widget {
display: inline-block;
padding-top: 8px;
}
// Avoids to make the UoM field as long as the quantity done field.
.o_field_widget[name="product_uom_id"] input {
@include media-breakpoint-up(sm) {
min-width: 0;
}
}
.o_qty_done_field_completed input {
background: var(--barcode__input--completed, #f6fdf6);
}
.o_qty_done_field_not_completed input {
background: var(--barcode__input--notCompleted, #fcf9f2);
}
& > div {
.o_field_float {
width: 100%;
}
.o_input {
padding: 8px;
border: 1px solid $border-color;
}
.o_required_modifier .o_input {
border-bottom: 2px solid $border-color
}
.o_dropdown_button {
display: none;
}
i {
min-width: 24px;
max-width: 24px;
color: $o-main-color-muted;
}
}
}
}
}

View File

@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<div t-name="stock_barcode.MainComponent" class="o_content o_barcode_client_action" owl="1">
<div class="o_barcode_header">
<div class="navbar navbar-expand navbar-dark">
<nav class="navbar-nav me-auto">
<button t-on-click="exit" class="o_exit btn nav-link me-4">
<i class="fa fa-chevron-left"/>
</button>
<span class="o_title navbar-text" t-esc="env.model.name"/>
</nav>
<nav class="navbar-nav">
<t t-if="displayBarcodeApplication">
<button t-if="env.model.formViewId" t-on-click="toggleInformation" class="o_show_information btn nav-link">
<i class="fa fa-info-circle"/>
</button>
<button t-if="mobileScanner" class="o_stock_mobile_barcode btn nav-link" t-on-click="openMobileScanner">
<i class="fa fa-barcode"/>
</button>
<button t-on-click="toggleBarcodeActions" class="o_barcode_actions btn nav-link">
<i class="fa fa-cog"/>
</button>
</t>
<button t-else="" t-on-click="() => this.toggleBarcodeLines()" class="o_close btn nav-link">
<i class="fa fa-times"/>
</button>
</nav>
</div>
<t t-if="displayBarcodeApplication">
<div t-if="displayNote" class="alert alert-warning text-start mb-0">
<button type="button" class="close" title="Close" aria-label="Close" t-on-click="hideNote">&#215;</button>
<t t-esc="env.model.record.note"/>
</div>
<div class="o_barcode_lines_header row alert m-0 px-1 "
t-attf-class="{{displayHeaderInfoAsColumn ? 'flex-column justify-content-center align-items-center' : 'justify-content-between'}}">
<div t-if="info.warning" class="o_barcode_pic position-relative text-center mt-2 mb-1">
<i class="fa fa-5x fa-exclamation-triangle"/>
</div>
<div name="barcode_messages" class="d-flex align-items-center justify-content-center w-100">
<span t-if="info.icon" class="fa fa-3x me-3" t-attf-class="fa-{{info.icon}}"/>
<span class="o_scan_message" t-attf-class="o_{{info.class}}">
<span t-if="info.warning" name="warning" class="fa fa-exclamation-triangle me-1"/>
<span t-out="info.message"/>
</span>
</div>
</div>
</t>
</div>
<div t-if="displayBarcodeLines &amp;&amp; (lines.length || packageLines.length)" class="o_barcode_lines"> <!-- Lines -->
<t t-foreach="lines" t-as="line" t-key="line.virtual_id">
<GroupedLineComponent t-if="line.lines" line="line" displayUOM="groups.group_uom"/>
<LineComponent t-else="" line="line" displayUOM="groups.group_uom"/>
</t>
<t t-foreach="packageLines" t-as="line" t-key="line.virtual_id">
<PackageLineComponent line="line" displayUOM="false"/>
</t>
</div>
<div t-if="displayProductPage"> <!-- Barcode Line Edit Form View -->
<View type="'form'" mode="'edit'"
viewId="env.model.lineFormViewId"
resModel="lineFormViewData.resModel"
resId="lineFormViewData.resId"
display="{ controlPanel: false }"
context="lineFormViewData.context"
onSave="(record) => this.saveFormView(record)"
onDiscard="() => this.toggleBarcodeLines()"/>
</div>
<div t-if="displayPackageContent"> <!-- Quants (in package) Kanban View -->
<View type="'kanban'"
viewId="packageKanbanViewId"
display="{ controlPanel: false }"
resModel="'stock.quant'"
domain="[['package_id', '=', _inspectedPackageId]]"/>
</div>
<div t-if="displayInformation"> <!-- Res Model Form View -->
<View type="'form'" mode="'edit'"
viewId="env.model.formViewId"
display="{ controlPanel: false }"
resModel="props.model"
resId="props.id"
onSave="() => this._onRefreshState({ lineId: this._editedLineParams &amp;&amp; this._editedLineParams.currentId })"
onDiscard="() => this.toggleBarcodeLines()"/>
<ChatterContainer threadModel="props.model" threadId="props.id"/>
</div>
<div t-if="displayBarcodeActions" class="o_barcode_settings flex-column h100">
<t t-foreach="env.model.printButtons" t-as="button" t-key="button.class">
<button class="btn-lg btn btn-light text-uppercase"
t-attf-class="{{button.class}}" t-esc="button.name"
t-on-click="() => this.print(button.action, button.method)"/>
</t>
<button t-if="env.model.displayCancelButton"
t-on-click="cancel"
class="o_cancel_operation btn-lg btn btn-light text-uppercase">
Cancel
</button>
</div>
<div t-if="displayBarcodeLines" class="fixed-bottom"> <!-- Footer -->
<div class="o_barcode_control o_action_buttons d-flex">
<button class="o_add_line btn btn-secondary text-uppercase" t-on-click="openProductPage">
<i class="fa fa-plus me-1"/> Add Product
</button>
<button t-if="env.model.displayPutInPackButton" t-on-click="putInPack"
t-att-disabled="!env.model.canPutInPack"
class="o_put_in_pack btn btn-secondary text-uppercase">
<i class="fa fa-cube me-1"/> Put In Pack
</button>
<button t-if="env.model.displayValidateButton" t-on-click="validate"
class="btn text-uppercase o_validate_page"
t-att-disabled="!env.model.canBeValidate"
t-attf-class="{{highlightValidateButton ? 'btn-success' : 'btn-secondary'}}">
<i class="fa fa-check me-1"/> Validate
</button>
<button t-if="env.model.displayApplyButton" t-on-click="() => this.env.model.apply()"
class="btn text-uppercase o_apply_page"
t-att-disabled="env.model.applyOn === 0"
t-attf-class="{{highlightValidateButton ? 'btn-success' : 'btn-secondary'}}">
<i class="fa fa-check me-1"/> Apply
<span t-attf-class="{{highlightValidateButton ? '' : 'text-muted'}}">
(<t t-esc="env.model.applyOn"/>)
</span>
</button>
</div>
</div>
</div>
</templates>

View File

@@ -0,0 +1,41 @@
/** @odoo-module **/
import { bus } from 'web.core';
import LineComponent from './line';
export default class PackageLineComponent extends LineComponent {
get componentClasses() {
return [
this.qtyDone == 1 ? 'o_line_completed' : 'o_line_not_completed',
this.isSelected ? 'o_selected o_highlight' : ''
].join(' ');
}
get isSelected() {
return this.line.package_id.id === this.env.model.lastScanned.packageId;
}
get qtyDemand() {
return this.props.line.reservedPackage ? 1 : false;
}
get qtyDone() {
const reservedQuantity = this.line.lines.reduce((r, l) => r + l.reserved_uom_qty, 0);
const doneQuantity = this.line.lines.reduce((r, l) => r + l.qty_done, 0);
if (reservedQuantity > 0) {
return doneQuantity / reservedQuantity;
}
return doneQuantity >= 0 ? 1 : 0;
}
openPackage() {
bus.trigger('open-package', this.line.package_id.id);
}
select(ev) {
ev.stopPropagation();
this.env.model.selectPackageLine(this.line);
this.env.model.trigger('update');
}
}
PackageLineComponent.template = 'stock_barcode.PackageLineComponent';

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<div t-name="stock_barcode.PackageLineComponent" owl="1" t-on-click="select"
class="o_barcode_line list-group-item d-flex flex-row flex-nowrap py-3"
t-attf-class="{{componentClasses}}" t-att-data-package="line.package_id.name">
<div class="o_barcode_line_details flex-grow-1 flex-column flex-nowrap">
<t t-call="stock_barcode.LineSourceLocation"/>
<div>
<i class="fa fa-fw fa-archive"/>
<t t-esc="line.package_id.name"/>
<i class="fa fa-long-arrow-right mx-1"/>
<t t-esc="line.result_package_id.name"/>
</div>
<t t-call="stock_barcode.LineQuantity"/>
<t t-call="stock_barcode.LineOwner"/>
<t t-call="stock_barcode.LineDestinationLocation"/>
</div>
<button t-on-click="openPackage" class="o_line_button o_package_content btn btn-secondary ms-2 ms-sm-4">
<i class="fa fa-2x fa-dropbox"/>
</button>
</div>
</templates>

View File

@@ -0,0 +1,60 @@
/** @odoo-module */
import { KanbanController } from '@web/views/kanban/kanban_controller';
import { bus } from 'web.core';
const { onMounted, onWillUnmount } = owl;
export class StockBarcodeKanbanController extends KanbanController {
setup() {
super.setup(...arguments);
onMounted(() => {
bus.on('barcode_scanned', this, this._onBarcodeScannedHandler);
document.activeElement.blur();
});
onWillUnmount(() => {
bus.off('barcode_scanned', this, this._onBarcodeScannedHandler);
});
}
openRecord(record) {
this.actionService.doAction('stock_barcode.stock_barcode_picking_client_action', {
additionalContext: { active_id: record.resId },
});
}
async createRecord() {
const action = await this.model.orm.call(
'stock.picking',
'action_open_new_picking',
[], { context: this.props.context }
);
if (action) {
return this.actionService.doAction(action);
}
return super.createRecord(...arguments);
}
// --------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Called when the user scans a barcode.
*
* @param {String} barcode
*/
async _onBarcodeScannedHandler(barcode) {
if (this.props.resModel != 'stock.picking') {
return;
}
const kwargs = { barcode, context: this.props.context };
const res = await this.model.orm.call(this.props.resModel, 'filter_on_barcode', [], kwargs);
if (res.action) {
this.actionService.doAction(res.action);
} else if (res.warning) {
const params = { title: res.warning.title, type: 'danger' };
this.model.notificationService.add(res.warning.message, params);
}
}
}

View File

@@ -0,0 +1,18 @@
/** @odoo-module */
import { KanbanRenderer } from '@web/views/kanban/kanban_renderer';
import { useService } from '@web/core/utils/hooks';
const { onWillStart } = owl;
export class StockBarcodeKanbanRenderer extends KanbanRenderer {
setup() {
super.setup(...arguments);
const user = useService('user');
this.display_protip = this.props.list.resModel === 'stock.picking';
onWillStart(async () => {
this.packageEnabled = await user.hasGroup('stock.group_tracking_lot');
});
}
}
StockBarcodeKanbanRenderer.template = 'stock_barcode.KanbanRenderer';

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="stock_barcode.KanbanRenderer" t-inherit="web.KanbanRenderer" owl="1">
<xpath expr="//div[hasclass('o_kanban_renderer')]" position="before">
<div t-if="display_protip" class="o_kanban_tip_filter text-center h5 w-100 mt-4 mb-2">
<p t-if="packageEnabled">Scan a transfer, a product or a package to filter your records</p>
<p t-else="">Scan a transfer or a product to filter your records</p>
</div>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,12 @@
/** @odoo-module */
import { kanbanView } from '@web/views/kanban/kanban_view';
import { registry } from "@web/core/registry";
import { StockBarcodeKanbanController } from './stock_barcode_kanban_controller';
import { StockBarcodeKanbanRenderer } from './stock_barcode_kanban_renderer';
export const stockBarcodeKanbanView = Object.assign({}, kanbanView, {
Controller: StockBarcodeKanbanController,
Renderer: StockBarcodeKanbanRenderer,
});
registry.category("views").add("stock_barcode_list_kanban", stockBarcodeKanbanView);

View File

@@ -0,0 +1,214 @@
/** @odoo-module **/
export default class LazyBarcodeCache {
constructor(cacheData, params) {
this.rpc = params.rpc;
this.dbIdCache = {}; // Cache by model + id
this.dbBarcodeCache = {}; // Cache by model + barcode
this.missingBarcode = new Set(); // Used as a cache by `_getMissingRecord`
this.barcodeFieldByModel = {
'stock.location': 'barcode',
'product.product': 'barcode',
'product.packaging': 'barcode',
'stock.package.type': 'barcode',
'stock.picking': 'name',
'stock.quant.package': 'name',
'stock.lot': 'name', // Also ref, should take in account multiple fields ?
};
this.gs1LengthsByModel = {
'product.product': 14,
'product.packaging': 14,
'stock.location': 13,
'stock.quant.package': 18,
};
// If there is only one active barcode nomenclature, set the cache to be compliant with it.
if (cacheData['barcode.nomenclature'].length === 1) {
this.nomenclature = cacheData['barcode.nomenclature'][0];
}
this.setCache(cacheData);
}
/**
* Adds records to the barcode application's cache.
*
* @param {Object} cacheData each key is a model's name and contains an array of records.
*/
setCache(cacheData) {
for (const model in cacheData) {
const records = cacheData[model];
// Adds the model's key in the cache's DB.
if (!this.dbIdCache.hasOwnProperty(model)) {
this.dbIdCache[model] = {};
}
if (!this.dbBarcodeCache.hasOwnProperty(model)) {
this.dbBarcodeCache[model] = {};
}
// Adds the record in the cache.
const barcodeField = this._getBarcodeField(model);
for (const record of records) {
this.dbIdCache[model][record.id] = record;
if (barcodeField) {
const barcode = record[barcodeField];
if (!this.dbBarcodeCache[model][barcode]) {
this.dbBarcodeCache[model][barcode] = [];
}
if (!this.dbBarcodeCache[model][barcode].includes(record.id)) {
this.dbBarcodeCache[model][barcode].push(record.id);
if (this.nomenclature && this.nomenclature.is_gs1_nomenclature && this.gs1LengthsByModel[model]) {
this._setBarcodeInCacheForGS1(barcode, model, record);
}
}
}
}
}
}
/**
* Get record from the cache, throw a error if we don't find in the cache
* (the server should have return this information).
*
* @param {int} id id of the record
* @param {string} model model_name of the record
* @param {boolean} [copy=true] if true, returns a deep copy (to avoid to write the cache)
* @returns copy of the record send by the server (fields limited to _get_fields_stock_barcode)
*/
getRecord(model, id) {
if (!this.dbIdCache.hasOwnProperty(model)) {
throw new Error(`Model ${model} doesn't exist in the cache`);
}
if (!this.dbIdCache[model].hasOwnProperty(id)) {
throw new Error(`Record ${model} with id=${id} doesn't exist in the cache, it should return by the server`);
}
const record = this.dbIdCache[model][id];
return JSON.parse(JSON.stringify(record));
}
/**
* @param {string} barcode barcode to match with a record
* @param {string} [model] model name of the record to match (if empty search on all models)
* @param {boolean} [onlyInCache] search only in the cache
* @param {Object} [filters]
* @returns copy of the record send by the server (fields limited to _get_fields_stock_barcode)
*/
async getRecordByBarcode(barcode, model = false, onlyInCache = false, filters = {}) {
if (model) {
if (!this.dbBarcodeCache.hasOwnProperty(model)) {
throw new Error(`Model ${model} doesn't exist in the cache`);
}
if (!this.dbBarcodeCache[model].hasOwnProperty(barcode)) {
if (onlyInCache) {
return null;
}
await this._getMissingRecord(barcode, model);
return await this.getRecordByBarcode(barcode, model, true);
}
const id = this.dbBarcodeCache[model][barcode][0];
return this.getRecord(model, id);
} else {
const result = new Map();
// Returns object {model: record} of possible record.
const models = Object.keys(this.dbBarcodeCache);
for (const model of models) {
if (this.dbBarcodeCache[model].hasOwnProperty(barcode)) {
const ids = this.dbBarcodeCache[model][barcode];
for (const id of ids) {
const record = this.dbIdCache[model][id];
let pass = true;
if (filters[model]) {
const fields = Object.keys(filters[model]);
for (const field of fields) {
if (record[field] != filters[model][field]) {
pass = false;
break;
}
}
}
if (pass) {
result.set(model, JSON.parse(JSON.stringify(record)));
break;
}
}
}
}
if (result.size < 1) {
if (onlyInCache) {
return result;
}
await this._getMissingRecord(barcode, model, filters);
return await this.getRecordByBarcode(barcode, model, true, filters);
}
return result;
}
}
_getBarcodeField(model) {
if (!this.barcodeFieldByModel.hasOwnProperty(model)) {
return null;
}
return this.barcodeFieldByModel[model];
}
async _getMissingRecord(barcode, model, filters) {
const missCache = this.missingBarcode;
const params = { barcode, model_name: model };
// Check if we already try to fetch this missing record.
if (missCache.has(barcode) || missCache.has(`${barcode}_${model}`)) {
return false;
}
// Creates and passes a domain if some filters are provided.
if (filters) {
const domainsByModel = {};
for (const filter of Object.entries(filters)) {
const modelName = filter[0];
const filtersByField = filter[1];
domainsByModel[modelName] = [];
for (const filterByField of Object.entries(filtersByField)) {
domainsByModel[modelName].push([filterByField[0], '=', filterByField[1]]);
}
}
params.domains_by_model = domainsByModel;
}
const result = await this.rpc('/stock_barcode/get_specific_barcode_data', params);
this.setCache(result);
// Set the missing cache if no filters (the barcode's result can vary if there is filter)
if (!filters) {
const keyCache = (model && `${barcode}_${model}`) || barcode;
missCache.add(keyCache);
}
}
/**
* Sets in the cache an entry for the given record with its formatted barcode as key.
* The barcode will be formatted (if needed) at the length corresponding to its data part in a
* GS1 barcode (e.g.: 14 digits for a product's barcode) by padding with 0 the original barcode.
* That makes it easier to find when a GS1 barcode is scanned.
* If the formatted barcode is similar to an another barcode for the same model, it will show a
* warning in the console (as a clue to find where issue could come from, not to alert the user)
*
* @param {string} barcode
* @param {string} model
* @param {Object} record
*/
_setBarcodeInCacheForGS1(barcode, model, record) {
const length = this.gs1LengthsByModel[model];
if (!barcode || barcode.length >= length || isNaN(Number(barcode))) {
// Barcode already has the good length, or is too long or isn't
// fully numerical (and so, it doesn't make sense to adapt it).
return;
}
const paddedBarcode = barcode.padStart(length, '0');
// Avoids to override or mix records if there is already a key for this
// barcode (which means there is a conflict somewhere).
if (!this.dbBarcodeCache[model][paddedBarcode]) {
this.dbBarcodeCache[model][paddedBarcode] = [record.id];
} else if (!this.dbBarcodeCache[model][paddedBarcode].includes(record.id)) {
const previousRecordId = this.dbBarcodeCache[model][paddedBarcode][0];
const previousRecord = this.getRecord(model, previousRecordId);
console.log(
`Conflict for barcode %c${paddedBarcode}%c:`, 'font-weight: bold', '',
`it could refer for both ${record.display_name} and ${previousRecord.display_name}.`,
`\nThe last one will be used but consider to edit those products barcode to avoid error due to ambiguities.`
);
}
}
}

View File

@@ -0,0 +1,75 @@
/** @odoo-module **/
import * as BarcodeScanner from '@web/webclient/barcode/barcode_scanner';
import { bus } from 'web.core';
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
const { Component, onMounted, onWillUnmount, onWillStart, useState } = owl;
export class MainMenu extends Component {
setup() {
const displayDemoMessage = this.props.action.params.message_demo_barcodes;
const user = useService('user');
this.actionService = useService('action');
this.dialogService = useService('dialog');
this.home = useService("home_menu");
this.notificationService = useService("notification");
this.rpc = useService('rpc');
this.state = useState({ displayDemoMessage });
this.mobileScanner = BarcodeScanner.isBarcodeScannerSupported();
onWillStart(async () => {
this.locationsEnabled = await user.hasGroup('stock.group_stock_multi_locations');
this.packagesEnabled = await user.hasGroup('stock.group_tracking_lot');
});
onMounted(() => {
bus.on('barcode_scanned', this, this._onBarcodeScanned);
});
onWillUnmount(() => {
bus.off('barcode_scanned', this, this._onBarcodeScanned);
});
}
async openMobileScanner() {
const barcode = await BarcodeScanner.scanBarcode();
if (barcode){
this._onBarcodeScanned(barcode);
if ('vibrate' in window.navigator) {
window.navigator.vibrate(100);
}
} else {
this.notificationService.add(this.env._t("Please, Scan again !"), { type: 'warning' });
}
}
removeDemoMessage() {
this.state.displayDemoMessage = false;
const params = {
title: this.env._t("Don't show this message again"),
body: this.env._t("Do you want to permanently remove this message ?\
It won't appear anymore, so make sure you don't need the barcodes sheet or you have a copy."),
confirm: () => {
this.rpc('/stock_barcode/rid_of_message_demo_barcodes');
location.reload();
},
cancel: () => {},
confirmLabel: this.env._t("Remove it"),
cancelLabel: this.env._t("Leave it"),
};
this.dialogService.add(ConfirmationDialog, params);
}
async _onBarcodeScanned(barcode) {
const res = await this.rpc('/stock_barcode/scan_from_main_menu', { barcode });
if (res.action) {
return this.actionService.doAction(res.action);
}
this.notificationService.add(res.warning, { type: 'danger' });
}
}
MainMenu.template = 'stock_barcode.MainMenu';
registry.category('actions').add('stock_barcode_main_menu', MainMenu);

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<div t-name="stock_barcode.MainMenu" class="o_stock_barcode_main_menu_container o_home_menu_background" owl="1">
<div class="o_stock_barcode_main_menu position-relative bg-view">
<a href="#" class="o_stock_barcode_menu d-block float-start" t-on-click="() => this.home.toggle(true)">
<i class="fa fa-chevron-left"/>
</a>
<h1 class="mb-4">Barcode Scanning</h1>
<div t-if="state.displayDemoMessage" class="message_demo_barcodes alert alert-info alert-dismissible text-start" role="status">
<button t-on-click="removeDemoMessage" type="button" class="btn-close" title="Close"/>
We have created a few demo data with barcodes for you to explore the features. Print the
<a href="/stock_barcode/static/img/barcodes_demo.pdf" target="_blank">stock barcodes sheet</a>
to check out what this module can do! You can also print the barcode
<a class="o_stock_inventory_commands_download" href="/stock_barcode/print_inventory_commands" target="_blank" aria-label="Download" title="Download">commands for Inventory</a>.
</div>
<div class="o_stock_barcode_container position-relative d-inline-block mt-4 mb-5">
<div t-if='mobileScanner' class="o_stock_mobile_barcode_container">
<button class="btn btn-primary o_stock_mobile_barcode" t-on-click="openMobileScanner">
<i class="fa fa-camera fa-2x o_mobile_barcode_camera"/> Tap to scan
</button>
<img src="/barcodes/static/img/barcode.png" alt="Barcode" class="img-fluid mb-1 mt-1"/>
</div>
<img t-else="" src="/barcodes/static/img/barcode.png" alt="Barcode" class="img-fluid mb-1 mt-1"/>
<span class="o_stock_barcode_laser"/>
</div>
<ul class="text-start mb-sm-5 ps-4">
<li>Scan an <b>operation type</b> to create a new transfer.</li>
<li t-if="locationsEnabled">Scan a <b>location</b> to create a new transfer from this location.</li>
<li>Scan a <b>document</b> to open it.</li>
<li>Scan a <b>product</b> to show its location and quantity.</li>
<li t-if="packagesEnabled">Scan a <b>package</b> to know its content.</li>
</ul>
<hr class="mb-4 d-none d-sm-block"/>
<div class="o_main_menu_buttons row">
<div class="col">
<button class="button_operations btn btn-block btn-primary mb-4 w-100"
t-on-click="() => this.actionService.doAction('stock_barcode.stock_picking_type_action_kanban')">
Operations
</button>
</div>
<div class="col">
<button class="button_inventory btn btn-block btn-primary mb-4 w-100"
t-on-click="() => this.actionService.doAction('stock_barcode.stock_barcode_inventory_client_action')">
Inventory Adjustments
</button>
</div>
</div>
</div>
</div>
</templates>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,597 @@
/** @odoo-module **/
import BarcodeModel from '@stock_barcode/models/barcode_model';
import {_t} from "web.core";
import { sprintf } from '@web/core/utils/strings';
export default class BarcodeQuantModel extends BarcodeModel {
constructor(params) {
super(...arguments);
this.lineModel = params.model;
this.validateMessage = _t("The inventory adjustment has been validated");
this.validateMethod = 'action_validate';
}
/**
* Validates only the quants of the current inventory page and don't close it.
*
* @returns {Promise}
*/
async apply() {
await this.save();
const linesToApply = this.pageLines.filter(line => line.inventory_quantity_set);
if (linesToApply.length === 0) {
const message = _t("There is nothing to apply in this page.");
return this.notification.add(message, { type: 'warning' });
}
const action = await this.orm.call('stock.quant', 'action_validate',
[linesToApply.map(quant => quant.id)]
);
const notifyAndGoAhead = res => {
if (res && res.special) { // Do nothing if come from a discarded wizard.
return this.trigger('refresh');
}
this.notification.add(_t("The inventory adjustment has been validated"), { type: 'success' });
this.trigger('history-back');
};
if (action && action.res_model) {
const options = { on_close: notifyAndGoAhead };
return this.trigger('do-action', { action, options });
}
notifyAndGoAhead();
}
get applyOn() {
return this.pageLines.filter(line => line.inventory_quantity_set).length;
}
get barcodeInfo() {
// Takes the parent line if the current line is part of a group.
let line = this._getParentLine(this.selectedLine) || this.selectedLine;
if (!line && this.lastScanned.packageId) {
const lines = this._moveEntirePackage() ? this.packageLines : this.pageLines;
line = lines.find(l => l.package_id && l.package_id.id === this.lastScanned.packageId);
}
if (line) { // Message depends of the selected line's state.
const { tracking } = line.product_id;
const trackingNumber = (line.lot_id && line.lot_id.name) || line.lot_name;
if (this._lineIsNotComplete(line)) {
if (tracking === 'none') {
this.messageType = 'scan_product';
} else {
this.messageType = tracking === 'lot' ? 'scan_lot' : 'scan_serial';
}
} else if (tracking !== 'none' && !trackingNumber) {
// Line's quantity is fulfilled but still waiting a tracking number.
this.messageType = tracking === 'lot' ? 'scan_lot' : 'scan_serial';
} else { // Line's quantity is fulfilled.
this.messageType = this.groups.group_stock_multi_locations && line.location_id.id === this.location.id ?
"scan_product_or_src" :
"scan_product";
}
} else { // Message depends if multilocation is enabled.
this.messageType = this.groups.group_stock_multi_locations && !this.lastScanned.sourceLocation ?
'scan_src' :
'scan_product';
}
const barcodeInformations = { class: this.messageType, warning: false, icon: 'barcode' };
switch (this.messageType) {
case 'scan_product':
barcodeInformations.message = this.groups.group_stock_multi_locations ?
sprintf(_t("Scan a product in %s or scan another location"), this.location.display_name) :
_t("Scan a product");
break;
case 'scan_src':
barcodeInformations.message = _t("Scan a location");
barcodeInformations.icon = 'sign-out';
break;
case 'scan_product_or_src':
barcodeInformations.message = sprintf(
_t("Scan more products in %s or scan another location"),
this.location.display_name);
break;
case 'scan_product_or_dest':
barcodeInformations.message = _t("Scan more products, or scan the destination location");
barcodeInformations.icon = 'sign-in';
break;
case 'scan_lot':
barcodeInformations.message = sprintf(
_t("Scan lot numbers for product %s to change their quantity"),
line.product_id.display_name
);
break;
case 'scan_serial':
barcodeInformations.message = sprintf(
_t("Scan serial numbers for product %s to change their quantity"),
line.product_id.display_name
);
break;
}
return barcodeInformations;
}
get displayByUnitButton () {
return true;
}
get displaySetButton() {
return true;
}
setData(data) {
this.userId = data.data.user_id;
super.setData(...arguments);
const companies = data.data.records['res.company'];
this.companyIds = companies.map(company => company.id);
this.lineFormViewId = data.data.line_view_id;
}
get displayApplyButton() {
return true;
}
getDisplayIncrementBtn(line) {
return line.product_id.tracking !== 'serial' && this.selectedLine &&
line.virtual_id === this.selectedLine.virtual_id;
}
getDisplayDecrementBtn(line) {
return this.getDisplayIncrementBtn(line);
}
getQtyDone(line) {
return line.inventory_quantity;
}
getQtyDemand(line) {
return line.quantity;
}
getActionRefresh(newId) {
const action = super.getActionRefresh(newId);
action.params.res_id = this.currentState.lines.map(l => l.id);
if (newId) {
action.params.res_id.push(newId);
}
return action;
}
get highlightValidateButton() {
return this.applyOn > 0 && this.applyOn === this.pageLines.length;
}
get incrementButtonsDisplayStyle() {
return "d-block my-3";
}
IsNotSet(line) {
return !line.inventory_quantity_set;
}
lineIsFaulty(line) {
return line.inventory_quantity_set && line.inventory_quantity !== line.quantity;
}
get printButtons() {
return [{
name: _t("Print Inventory"),
class: 'o_print_inventory',
action: 'stock.action_report_inventory',
}];
}
get recordIds() {
return this.currentState.lines.map(l => l.id);
}
/**
* Marks the line as set and set its inventory quantity if it was unset, or
* unset it if the line was already set.
*
* @param {Object} line
*/
setOnHandQuantity(line) {
if (line.product_id.tracking === 'serial') { // Special case for product tracked by SN.
const quantity = !(line.lot_name || line.lot_id) && line.quantity || 1;
if (line.inventory_quantity_set) {
line.inventory_quantity = line.inventory_quantity ? 0 : quantity;
line.inventory_quantity_set = line.inventory_quantity != quantity;
} else {
line.inventory_quantity = quantity;
line.inventory_quantity_set = true;
}
this._markLineAsDirty(line);
} else {
if (line.inventory_quantity_set) {
line.inventory_quantity = 0;
line.inventory_quantity_set = false;
this._markLineAsDirty(line);
} else {
const inventory_quantity = line.quantity - line.inventory_quantity;
this.updateLine(line, { inventory_quantity });
line.inventory_quantity_set = true;
}
}
this.trigger('update');
}
updateLineQty(virtualId, qty = 1) {
this.actionMutex.exec(() => {
const line = this.pageLines.find(l => l.virtual_id === virtualId);
this.updateLine(line, {inventory_quantity: qty});
this.trigger('update');
});
}
// --------------------------------------------------------------------------
// Private
// --------------------------------------------------------------------------
_getCommands() {
return Object.assign(super._getCommands(), {
'O-BTN.apply': this.apply.bind(this),
});
}
_getNewLineDefaultContext() {
return {
default_company_id: this.companyIds[0],
default_location_id: this._defaultLocation().id,
default_inventory_quantity: 1,
default_user_id: this.userId,
inventory_mode: true,
};
}
_createCommandVals(line) {
const values = {
dummy_id: line.virtual_id,
inventory_date: line.inventory_date,
inventory_quantity: line.inventory_quantity,
inventory_quantity_set: line.inventory_quantity_set,
location_id: line.location_id,
lot_id: line.lot_id,
lot_name: line.lot_name,
package_id: line.package_id,
product_id: line.product_id,
owner_id: line.owner_id,
user_id: this.userId,
};
for (const [key, value] of Object.entries(values)) {
values[key] = this._fieldToValue(value);
}
return values;
}
async _createNewLine(params) {
// When creating a new line, we need to know if a quant already exists
// for this line, and in this case, update the new line fields.
const product = params.fieldsParams.product_id;
if (product.detailed_type != 'product') {
const productName = (product.default_code ? `[${product.default_code}] ` : '') + product.display_name;
const message = sprintf(
_t("%s can't be inventoried. Only storable products can be inventoried."), productName);
this.notification.add(message, { type: 'warning' });
return false;
}
const domain = [
['location_id', '=', this.location.id],
['product_id', '=', product.id],
];
if (product.tracking !== 'none') {
if (params.fieldsParams.lot_name) { // Search for a quant with the exact same lot.
domain.push(['lot_id.name', '=', params.fieldsParams.lot_name]);
} else { // Search for a quant with no lot.
domain.push(['lot_id', '=', false]);
}
}
if (params.fieldsParams.package_id) {
domain.push(['package_id', '=', params.fieldsParams.package_id]);
}
const quant = await this.orm.searchRead(
'stock.quant',
domain,
['id', 'inventory_date', 'inventory_quantity', 'inventory_quantity_set', 'quantity', 'user_id'],
{ limit: 1 }
);
if (quant.length) {
Object.assign(params.fieldsParams, quant[0], { inventory_quantity: 1 });
}
const newLine = await super._createNewLine(params);
if (quant.length) {
// If the quant already exits, we add it into the `initialState` to
// avoid comparison issue with the `currentState` when the save occurs.
this.initialState.lines.push(Object.assign({}, newLine, quant[0]));
}
return newLine;
}
_convertDataToFieldsParams(args) {
const params = {
inventory_quantity: args.quantity,
lot_id: args.lot,
lot_name: args.lotName,
owner_id: args.owner,
package_id: args.package || args.resultPackage,
product_id: args.product,
product_uom_id: args.product && args.product.uom_id,
};
return params;
}
_getNewLineDefaultValues(fieldsParams) {
const defaultValues = super._getNewLineDefaultValues(...arguments);
return Object.assign(defaultValues, {
inventory_date: new Date().toISOString().slice(0, 10),
inventory_quantity: 0,
inventory_quantity_set: true,
quantity: (fieldsParams && fieldsParams.quantity) || 0,
user_id: this.userId,
});
}
_getFieldToWrite() {
return [
'inventory_date',
'inventory_quantity',
'inventory_quantity_set',
'user_id',
'location_id',
'lot_name',
'lot_id',
'package_id',
'owner_id',
];
}
_getSaveCommand() {
const commands = this._getSaveLineCommand();
if (commands.length) {
return {
route: '/stock_barcode/save_barcode_data',
params: {
model: this.params.model,
res_id: false,
write_field: false,
write_vals: commands,
},
};
}
return {};
}
_groupSublines(sublines, ids, virtual_ids, qtyDemand, qtyDone) {
return Object.assign(super._groupSublines(...arguments), {
inventory_quantity: qtyDone,
quantity: qtyDemand,
});
}
_lineIsNotComplete(line) {
return line.inventory_quantity === 0;
}
async _processPackage(barcodeData) {
const { packageType, packageName } = barcodeData;
let recPackage = barcodeData.package;
this.lastScanned.packageId = false;
if (!recPackage && !packageType && !packageName) {
return; // No Package data to process.
}
// Scan a new package and/or a package type -> Create a new package with those parameters.
const currentLine = this.selectedLine || this.lastScannedLine;
if (currentLine.package_id && packageType &&
!recPackage && ! packageName &&
currentLine.package_id.id !== packageType) {
// Changes the package type for the scanned one.
await this.orm.write('stock.quant.package', [currentLine.package_id.id], {
package_type_id: packageType.id,
});
const message = sprintf(
_t("Package type %s was correctly applied to the package %s"),
packageType.name, currentLine.package_id.name
);
barcodeData.stopped = true;
return this.notification.add(message, { type: 'success' });
}
if (!recPackage) {
if (currentLine && !currentLine.package_id) {
const valueList = {};
if (packageName) {
valueList.name = packageName;
}
if (packageType) {
valueList.package_type_id = packageType.id;
}
const newPackageData = await this.orm.call(
'stock.quant.package',
'action_create_from_barcode',
[valueList]
);
this.cache.setCache(newPackageData);
recPackage = newPackageData['stock.quant.package'][0];
}
}
if (!recPackage && packageName) {
const currentLine = this.selectedLine || this.lastScannedLine;
if (currentLine && !currentLine.package_id) {
const newPackageData = await this.orm.call(
'stock.quant.package',
'action_create_from_barcode',
[{ name: packageName }]
);
this.cache.setCache(newPackageData);
recPackage = newPackageData['stock.quant.package'][0];
}
}
if (!recPackage || (
recPackage.location_id && recPackage.location_id != this.location.id
)) {
return;
}
// TODO: can check if quants already in cache to avoid to make a RPC if
// there is all in it (or make the RPC only on missing quants).
const res = await this.orm.call(
'stock.quant',
'get_stock_barcode_data_records',
[recPackage.quant_ids]
);
const quants = res.records['stock.quant'];
if (!quants.length) { // Empty package => Assigns it to the last scanned line.
const currentLine = this.selectedLine || this.lastScannedLine;
if (currentLine && !currentLine.package_id && !currentLine.result_package_id) {
const fieldsParams = this._convertDataToFieldsParams({
resultPackage: recPackage,
});
await this.updateLine(currentLine, fieldsParams);
barcodeData.stopped = true;
this.selectedLineVirtualId = false;
this.lastScanned.packageId = recPackage.id;
this.trigger('update');
}
return;
}
this.cache.setCache(res.records);
// Checks if the package is already scanned.
let alreadyExisting = 0;
for (const line of this.pageLines) {
if (line.package_id && line.package_id.id === recPackage.id &&
this.getQtyDone(line) > 0) {
alreadyExisting++;
}
}
if (alreadyExisting === quants.length) {
barcodeData.error = _t("This package is already scanned.");
return;
}
// For each quants, creates or increments a barcode line.
for (const quant of quants) {
const product = this.cache.getRecord('product.product', quant.product_id);
const searchLineParams = Object.assign({}, barcodeData, { product });
const currentLine = this._findLine(searchLineParams);
if (currentLine) { // Updates an existing line.
const fieldsParams = this._convertDataToFieldsParams({
quantity: quant.quantity,
lotName: barcodeData.lotName,
lot: barcodeData.lot,
package: recPackage,
owner: barcodeData.owner,
});
await this.updateLine(currentLine, fieldsParams);
} else { // Creates a new line.
const fieldsParams = this._convertDataToFieldsParams({
product,
quantity: quant.quantity,
lot: quant.lot_id,
package: quant.package_id,
resultPackage: quant.package_id,
owner: quant.owner_id,
});
const newLine = await this._createNewLine({ fieldsParams });
newLine.inventory_quantity = quant.quantity;
}
}
barcodeData.stopped = true;
this.selectedLineVirtualId = false;
this.lastScanned.packageId = recPackage.id;
this.trigger('update');
}
_updateLineQty(line, args) {
if (args.quantity) { // Set stock quantity.
line.quantity = args.quantity;
}
if (args.inventory_quantity) { // Increments inventory quantity.
if (args.uom) {
// An UoM was passed alongside the quantity, needs to check it's
// compatible with the product's UoM.
const productUOM = this.cache.getRecord('uom.uom', line.product_id.uom_id);
if (args.uom.category_id !== productUOM.category_id) {
// Not the same UoM's category -> Can't be converted.
const message = sprintf(
_t("Scanned quantity uses %s as Unit of Measure, but this UoM is not compatible with the product's one (%s)."),
args.uom.name, productUOM.name
);
return this.notification.add(message, { title: _t("Wrong Unit of Measure"), type: 'warning' });
} else if (args.uom.id !== productUOM.id) {
// Compatible but not the same UoM => Need a conversion.
args.inventory_quantity = (args.inventory_quantity / args.uom.factor) * productUOM.factor;
}
}
line.inventory_quantity += args.inventory_quantity;
line.inventory_quantity_set = true;
if (line.product_id.tracking === 'serial' && (line.lot_name || line.lot_id)) {
line.inventory_quantity = Math.max(0, Math.min(1, line.inventory_quantity));
}
}
}
async _updateLotName(line, lotName) {
if (line.lot_name === lotName) {
// No need to update the line's tracking number if it's already set.
return Promise.resolve();
}
line.lot_name = lotName;
// Checks if a quant exists for this line and updates the line in this case.
const domain = [
['location_id', '=', line.location_id.id],
['product_id', '=', line.product_id.id],
['lot_id.name', '=', lotName],
['owner_id', '=', line.owner_id && line.owner_id.id],
['package_id', '=', line.package_id && line.package_id.id],
];
const existingQuant = await this.orm.searchRead(
'stock.quant',
domain,
['id', 'quantity'],
{ limit: 1, load: false }
);
if (existingQuant.length) {
Object.assign(line, existingQuant[0]);
if (line.lot_id) {
line.lot_id = await this.cache.getRecordByBarcode(lotName, 'stock.lot');
}
}
}
_createLinesState() {
const today = new Date().toISOString().slice(0, 10);
const lines = [];
for (const id of Object.keys(this.cache.dbIdCache['stock.quant']).map(id => Number(id))) {
const quant = this.cache.getRecord('stock.quant', id);
if (quant.user_id !== this.userId || quant.inventory_date > today) {
// Doesn't take quants who must be counted by another user or in the future.
continue;
}
// Checks if this line is already in the quant state to get back
// its `virtual_id` (and so, avoid to set a new `virtual_id`).
const prevLine = this.currentState && this.currentState.lines.find(l => l.id === id);
const previousVirtualId = prevLine && prevLine.virtual_id;
quant.virtual_id = quant.dummy_id || previousVirtualId || this._uniqueVirtualId;
quant.product_id = this.cache.getRecord('product.product', quant.product_id);
quant.location_id = this.cache.getRecord('stock.location', quant.location_id);
quant.lot_id = quant.lot_id && this.cache.getRecord('stock.lot', quant.lot_id);
quant.package_id = quant.package_id && this.cache.getRecord('stock.quant.package', quant.package_id);
quant.owner_id = quant.owner_id && this.cache.getRecord('res.partner', quant.owner_id);
lines.push(Object.assign({}, quant));
}
return lines;
}
_getName() {
return _t("Inventory Adjustment");
}
_getPrintOptions() {
const options = super._getPrintOptions();
const quantsToPrint = this.pageLines.filter(quant => quant.inventory_quantity_set);
if (quantsToPrint.length === 0) {
return { warning: _t("There is nothing to print in this page.") };
}
options.additional_context = { active_ids: quantsToPrint.map(quant => quant.id) };
return options;
}
}

View File

@@ -0,0 +1,15 @@
// = Stock Barcode
// ============================================================================
// No CSS hacks, variables overrides only
.o_barcode_client_action {
--barcode__header-bg: #{$o-gray-100};
--barcode__linesHeader-bg: #{$o-gray-300};
--barcode__line--completed: #1f5a2d;
--barcode__line--notCompleted: #303030;
--barcode__input--completed: #262c26;
--barcode__input--notCompleted: #303030;
}

View File

@@ -0,0 +1,138 @@
.o_stock_barcode_main_menu_container {
display: flex;
flex-flow: column nowrap;
justify-content: center;
align-items: center;
min-height: 100%;
@include media-breakpoint-down(md) {
@include o-position-absolute(0, 0, 0, 0);
overflow-y: auto;
min-height: 0;
justify-content: flex-start;
}
}
.o_stock_barcode_main_menu {
text-align: center;
padding: 24px 16px;
font-size: 1.2em;
overflow: auto;
width: 100%;
height: 100%;
.o_stock_mobile_barcode_container{
position: relative;
display: inline-block;
.o_stock_mobile_barcode{
width: 100%;
bottom: 0px;
position: absolute;
opacity: 0.75;
font-size: 12px;
.o_mobile_barcode_camera{
margin: 5px;
font-size: 2.2em;
}
}
}
.o_stock_barcode_menu {
line-height: 1.9;
}
.message_demo_barcodes {
font-size: 0.9em;
}
>ul {
display: inline-block;
}
.o_stock_barcode_container {
span.o_stock_barcode_laser {
@include o-position-absolute(50%, -15px, auto, -15px);
height: 5px;
background: rgba(red, 0.6);
box-shadow: 0 1px 10px 1px rgba(red, 0.8);
animation: o_barcode_scanner_intro 1s cubic-bezier(0.6, -0.28, 0.735, 0.045) 0.4s;
}
img {
width: 200px;
@include media-breakpoint-down(sm) {
width: 140px;
height: 94px;
}
}
.o_stock_mobile_barcode {
height: 94px;
}
}
@include media-breakpoint-up(md) {
flex: 0 0 auto;
width: 550px;
border-radius: 4px;
margin-top: -24px;
.o_stock_barcode_menu {
line-height: 2.6;
}
button.btn-primary {
font-size: 1.25rem;
}
}
@include media-breakpoint-up(sm) {
.row .col .btn {
height: 100%;
}
}
@include media-breakpoint-down(sm) {
.row .col {
min-width: 100%;
}
}
}
// Defines animation for highlighting flash.
$highlighting-colors: (
"primary": theme-color("primary"),
"white": white
);
@each $c-name, $c-value in $highlighting-colors {
@keyframes highlighting-flash-#{$c-name} {
0% {
background-color: #{$c-value};
}
20% {
background-color: transparent;
}
21% {
background-color: #{$c-value};
}
100% {
background-color: transparent;
}
}
}
@keyframes o_barcode_scanner_intro {
25% {
top: 75%;
}
50% {
top: 0;
}
75% {
top: 100%;
}
100% {
top: 50%;
}
}

View File

@@ -0,0 +1,111 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
const { Component, onWillStart } = owl;
export class Digipad extends Component {
setup() {
this.orm = useService('orm');
const user = useService('user');
this.buttons = [7, 8, 9, 4, 5, 6, 1, 2, 3, '.', '0', 'erase'].map((value, index) => {
return { index, value };
});
this.value = String(this.props.record.data[this.props.quantityField]);
onWillStart(async () => {
this.displayUOM = await user.hasGroup('uom.group_uom');
await this._fetchPackagingButtons();
});
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Copies the input value if digipad value is not set yet, or overrides it if there is a
* difference between the two values (in case user has manualy edited the input value).
* @private
*/
_checkInputValue() {
const input = document.querySelector(`div[name="${this.props.quantityField}"] input`);
const inputValue = input.value;
if (Number(this.value) != Number(inputValue)) {
console.warn(`-- Change widget value: ${this.value} -> ${inputValue}`);
this.value = inputValue;
}
}
/**
* Increments the field value by the interval amount (1 by default).
* @private
* @param {integer} [interval=1]
*/
async _increment(interval=1) {
this._checkInputValue();
const numberValue = Number(this.value || 0);
this.value = String(numberValue + interval);
await this._notifyChanges();
}
/**
* Notifies changes on the field to mark the record as dirty.
* @private
*/
async _notifyChanges() {
const changes = { [this.props.quantityField]: Number(this.value) };
await this.props.record.update(changes);
}
/**
* Search for the product's packaging buttons.
* @private
* @returns {Promise}
*/
async _fetchPackagingButtons() {
const record = this.props.record.data;
const demandQty = record.reserved_uom_qty;
const domain = [['product_id', '=', record.product_id[0]]];
if (demandQty) { // Doesn't fetch packaging with a too high quantity.
domain.push(['qty', '<=', demandQty]);
}
this.packageButtons = await this.orm.searchRead(
'product.packaging',
domain,
['name', 'product_uom_id', 'qty'],
{ limit: 3 },
);
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Handles the click on one of the digipad's button and updates the value..
* @private
* @param {String} button
*/
_onCLickButton(button) {
this._checkInputValue();
if (button === 'erase') {
this.value = this.value.substr(0, this.value.length - 1);
} else {
if (button === '.' && this.value.indexOf('.') != -1) {
// Avoids to add a decimal separator multiple time.
return;
}
this.value += button;
}
this._notifyChanges();
}
}
Digipad.template = 'stock_barcode.DigipadTemplate';
Digipad.extractProps = ({ attrs }) => {
return {
quantityField: attrs.quantity_field,
};
};
registry.category('view_widgets').add('digipad', Digipad);

View File

@@ -0,0 +1,46 @@
.o_digipad_widget {
display: flex;
.btn {
padding: 0;
border-radius: 4px;
font-size: 2em;
.o_web_client.o_touch_device & {
border-radius: 4px;
font-size: 1em;
padding: 4px;
}
}
.o_digipad_digit_buttons, .o_digipad_special_buttons {
display: grid;
grid-template-rows: repeat(4, 1fr);
gap: 4px;
}
.o_digipad_digit_buttons {
width: 75%;
grid-template-columns: repeat(3, 1fr);
}
.o_digipad_special_buttons {
width: 25%;
grid-template-columns: repeat(1, 1fr);
}
.o_packaging_button {
font-size: 1em;
overflow: hidden;
div[name=packaging_name] {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.small-text {
font-size: 0.66em;
}
}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<div t-name="stock_barcode.DigipadTemplate" class="o_digipad_widget" owl="1">
<t t-set="commonCssClasses">o_digipad_button btn d-flex justify-content-center align-items-center border w-100 py-2</t>
<!-- Digits, erase and dot buttons -->
<div class="o_digipad_digit_buttons me-2">
<div t-foreach="buttons" t-as="button" t-key="button.index" t-att-data-button="button.value"
class="btn-primary" t-att-class="commonCssClasses"
t-on-click="() => this._onCLickButton(button.value)">
<div t-if="button.value == 'erase'" class="fa fa-lg fa-long-arrow-left"/>
<div t-else="" t-out="button.value"/>
</div>
</div>
<div class="o_digipad_special_buttons">
<!-- +1 / -1 buttons -->
<div class="btn-secondary o_increase" t-att-class="commonCssClasses"
t-on-click="() => this._increment()"
t-att-data-button="increase">+1</div>
<div class="btn-secondary o_decrease" t-att-class="commonCssClasses"
t-on-click="() => this._increment(-1)"
t-att-data-button="decrease">-1</div>
<!-- Product packagings buttons -->
<div t-foreach="packageButtons" t-as="button" t-key="button.id" t-att-data-qty="button.qty"
t-on-click="() => this._increment(button.qty)"
class="o_packaging_button btn btn-secondary border w-100 py-2">
<div class="text-capitalize">
<span t-out="'+' + button.qty"/>
<span t-if="displayUOM" class="small-text ms-1" t-out="button.product_uom_id[1]"/>
</div>
<div name="packaging_name" class="small-text" t-out="button.name"/>
</div>
</div>
</div>
</templates>

View File

@@ -0,0 +1,34 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
const { Component, onWillStart } = owl;
export class SetReservedQuantityButton extends Component {
setup() {
const user = useService('user');
onWillStart(async () => {
this.displayUOM = await user.hasGroup('uom.group_uom');
});
}
get uom() {
const [id, name] = this.props.record.data.product_uom_id || [];
return { id, name };
}
_setQuantity (ev) {
ev.stopPropagation();
this.props.record.update({ [this.props.fieldToSet]: this.props.value });
}
}
SetReservedQuantityButton.extractProps = ({ attrs }) => {
if (attrs.field_to_set) {
return { fieldToSet: attrs.field_to_set };
}
};
SetReservedQuantityButton.template = 'stock_barcode.SetReservedQuantityButtonTemplate';
registry.category('fields').add('set_reserved_qty_button', SetReservedQuantityButton);

View File

@@ -0,0 +1,5 @@
.o_web_client.o_touch_device .o_button_qty_done, .o_button_qty_done {
padding-top: 8px;
padding-bottom: 8px;
font-size: 1em;
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<button t-name="stock_barcode.SetReservedQuantityButtonTemplate" owl="1"
class="o_button_qty_done d-flex btn"
t-attf-class="{{props.value ? 'btn-primary' : 'btn-secondary'}}"
t-on-click="_setQuantity" t-att-disabled="!props.value">
<t t-if="props.value">
/ <span name="product_uom_qty" class="ms-1" t-out="props.value"/>
</t>
<span t-if="displayUOM" name="product_uom_id" class="text-capitalize ms-1" t-out="uom.name"/>
</button>
</templates>

View File

@@ -0,0 +1,52 @@
odoo.define('stock_barcode.RunningTourActionHelper', function(require) {
"use strict";
var RunningTourActionHelper = require('web_tour.RunningTourActionHelper');
RunningTourActionHelper.include({
_scan: function (element, barcode) {
odoo.__DEBUG__.services['web.core'].bus.trigger('barcode_scanned', barcode, element);
},
scan: function(barcode, element) {
this._scan(this._get_action_values(element), barcode);
},
});
const StepUtils = require('web_tour.TourStepUtils');
StepUtils.include({
closeModal() {
return {
trigger: '.btn.btn-primary',
in_modal: true,
};
},
confirmAddingUnreservedProduct() {
return {
trigger: '.btn-primary',
extra_trigger: '.modal-title:contains("Add extra product?")',
in_modal: true,
};
},
validateBarcodeForm() {
return [{
trigger: '.o_barcode_client_action',
run: 'scan O-BTN.validate'
}, {
trigger: '.o_notification.border-success',
}];
},
discardBarcodeForm() {
return [{
content: "discard barcode form",
trigger: '.o_discard',
auto: true,
}, {
content: "wait to be back on the barcode lines",
trigger: '.o_add_line',
auto: true,
run() {},
}];
},
});
});

View File

@@ -0,0 +1,290 @@
odoo.define('stock_barcode.tourHelper', function (require) {
'use strict';
var tour = require('web_tour.tour');
function fail (errorMessage) {
tour._consume_tour(tour.running_tour, errorMessage);
}
function getLine (description) {
var $res;
$('.o_barcode_lines > .o_barcode_line').each(function () {
var $line = $(this);
const barcode = $line[0].dataset.barcode.trim();
if (description.barcode === barcode) {
if ($res) {
$res = $res.add($line);
} else {
$res = $line;
}
}
});
if (! $res) {
fail('cannot get the line with the barcode ' + description.barcode);
}
return $res;
}
function getSubline(selector) {
const $subline = $('.o_sublines .o_barcode_line' + selector);
if ($subline.length === 0) {
fail(`No subline was found for the selector "${selector}"`);
} else if($subline.length > 1) {
fail(`Multiple sublines were found for the selector "${selector}"`);
}
return $subline;
}
function triggerKeydown(eventKey, shiftkey=false) {
document.querySelector('.o_barcode_client_action')
.dispatchEvent(new window.KeyboardEvent('keydown', { bubbles: true, key: eventKey, shiftKey: shiftkey}));
}
function assert (current, expected, info) {
if (current !== expected) {
fail(info + ': "' + current + '" instead of "' + expected + '".');
}
}
/**
* Checks if a button on the given line is visible.
*
* @param {jQuerryElement} $line the line where we test the button visibility.
* @param {string} buttonName could be 'add_quantity' or 'remove_unit'.
* @param {boolean} [isVisible=true]
*/
function assertButtonIsVisible($line, buttonName, isVisible=true) {
const $button = $line.find(`.o_${buttonName}`);
assert($button.length, isVisible ? 1 : 0,
isVisible ? `Buttons should be in the DOM` : "Button shouldn't be in the DOM");
}
/**
* Checks if a button on the given line is invisible.
*
* @param {jQuerryElement} $line the line where we test the button visibility.
* @param {string} buttonName could be 'add_quantity' or 'remove_unit'.
*/
function assertButtonIsNotVisible ($line, buttonName) {
assertButtonIsVisible($line, buttonName, false);
}
/**
* Checks if both "Add unit" and "Add reserved remaining quantity" buttons are
* displayed or not on the given line.
*
* @param {integer} lineIndex
* @param {boolean} isVisible
*/
function assertLineButtonsAreVisible(lineIndex, isVisible, cssSelector='.o_line_button') {
const $buttonAddQty = $(`.o_barcode_line:eq(${lineIndex}) ${cssSelector}`);
const message = `Buttons must be ${(isVisible ? 'visible' : 'hidden')}`;
assert($buttonAddQty.length > 0, isVisible, message);
}
function assertValidateVisible (expected) {
const validateButton = document.querySelector('.o_validate_page,.o_apply_page');
assert(Boolean(validateButton), expected, 'Validate visible');
}
function assertValidateEnabled (expected) {
const validateButton = document.querySelector('.o_validate_page,.o_apply_page') || false;
assert(validateButton && !validateButton.hasAttribute('disabled'), expected, 'Validate enabled');
}
function assertValidateIsHighlighted (expected) {
const validateButton = document.querySelector('.o_validate_page,.o_apply_page') || false;
const isHighlighted = validateButton && validateButton.classList.contains('btn-success');
assert(isHighlighted, expected, 'Validate button is highlighted');
}
function assertLinesCount(expected) {
const current = document.querySelectorAll('.o_barcode_lines > .o_barcode_line').length;
assert(current, expected, `Should have ${expected} line(s)`);
}
function assertScanMessage (expected) {
const instruction = document.querySelector(`.o_scan_message`);
const cssClass = instruction.classList[1];
assert(cssClass, `o_${expected}`, "Not the right message displayed");
}
function assertSublinesCount(expected) {
const current = document.querySelectorAll('.o_sublines > .o_barcode_line').length;
assert(current, expected, `Should have ${expected} subline(s), found ${current}`);
}
function assertLineDestinationIsNotVisible(line) {
const destinationElement = line.querySelector('.o_line_destination_location');
if (destinationElement) {
const product = line.querySelector('.product-label').innerText;
fail(`The destination for line of the product ${product} should not be visible, "${destinationElement.innerText}" instead`);
}
}
/**
* Checks if the given line is going in the given location. Implies the destination is visible.
* @param {Element} line
* @param {string} location
*/
function assertLineDestinationLocation(line, location) {
const destinationElement = line.querySelector('.o_line_destination_location');
const product = line.querySelector('.product-label').innerText;
if (!destinationElement) {
fail(`The destination (${location}) for line of the product ${product} is not visible`);
}
assert(
destinationElement.innerText, location,
`The destination for line of product ${product} isn't in the right location`);
}
function assertLineIsHighlighted ($line, expected) {
assert($line.hasClass('o_highlight'), expected, 'line should be highlighted');
}
function assertLineQty($line, expectedQuantity) {
const lineNode = $line[0];
if (!lineNode) {
fail("Can't check the quantity: no line was given.");
} else if (!lineNode.classList.contains('o_barcode_line')) {
fail("Can't check the quantity: given element isn't a barcode line.");
}
const lineQuantity = lineNode.querySelector('.qty-done,.inventory_quantity').innerText;
expectedQuantity = String(expectedQuantity);
assert(lineQuantity, expectedQuantity, `Line's quantity is wrong`);
}
function assertLineLocations(line, source=false, destination=false) {
if (source) {
assertLineSourceLocation(line, source);
} else {
assertLineSourceIsNotVisible(line);
}
if (destination) {
assertLineDestinationLocation(line, destination);
} else {
assertLineDestinationIsNotVisible(line);
}
}
function assertLineProduct(line, productName) {
const lineProduct = line.querySelector('.product-label').innerText;
assert(lineProduct, productName, "No the expected product");
}
/**
* Checks the done quantity on the reserved quantity is what is expected.
*
* @param {integer} lineIndex
* @param {string} textQty quantity on the line, formatted as "n / N"
*/
function assertLineQuantityOnReservedQty (lineIndex, textQty) {
const $line = $('.o_barcode_line').eq(lineIndex);
const qty = $line.find('.qty-done').text();
const reserved = $line.find('.qty-done').next().text();
const qtyText = reserved ? qty + ' ' + reserved : qty;
assert(qtyText, textQty, 'Something wrong with the quantities');
}
function assertLineSourceIsNotVisible(line) {
const sourceElement = line.querySelector('.o_line_source_location');
if (sourceElement) {
const product = line.querySelector('.product-label').innerText;
fail(`The location for line of the product ${product} should not be visible, "${sourceElement.innerText}" instead`);
}
}
/**
* Checks if the given line is in the given location. Implies the location is visible.
* @param {Element} line
* @param {string} location
*/
function assertLineSourceLocation(line, location) {
const sourceElement = line.querySelector('.o_line_source_location');
const product = line.querySelector('.product-label').innerText;
if (!sourceElement) {
fail(`The source (${location}) for line of the product ${product} is not visible`);
}
assert(
sourceElement.innerText, location,
`The source for line of product ${product} isn't in the right location`);
}
function assertFormLocationSrc(expected) {
var $location = $('.o_field_widget[name="location_id"] input');
assert($location.val(), expected, 'Wrong source location');
}
function assertFormLocationDest(expected) {
var $location = $('.o_field_widget[name="location_dest_id"] input');
assert($location.val(), expected, 'Wrong destination location');
}
function assertFormQuantity(expected) {
const quantityField = document.querySelector(
'.o_field_widget[name="inventory_quantity"] input, .o_field_widget[name="qty_done"] input');
assert(quantityField.value, expected, 'Wrong quantity');
}
function assertErrorMessage(expected) {
var $errorMessage = $('.o_notification_content').eq(-1);
assert($errorMessage[0].innerText, expected, 'wrong or absent error message');
}
function assertKanbanRecordsCount(expected) {
const kanbanRecords = document.querySelectorAll(
'.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)');
assert(kanbanRecords.length, expected, 'Wrong number of cards');
}
function pressShift() {
document.querySelector('.o_barcode_client_action').dispatchEvent(
new window.KeyboardEvent(
'keydown', { bubbles: true, key: 'Shift' },
)
);
}
function releaseShift() {
document.querySelector('.o_barcode_client_action').dispatchEvent(
new window.KeyboardEvent(
'keyup', { bubbles: true, key: 'Shift' },
)
);
}
return {
assert: assert,
assertButtonIsVisible: assertButtonIsVisible,
assertButtonIsNotVisible: assertButtonIsNotVisible,
assertLineButtonsAreVisible: assertLineButtonsAreVisible,
assertLineDestinationIsNotVisible,
assertLineDestinationLocation,
assertLineLocations,
assertLineSourceIsNotVisible,
assertLineSourceLocation,
assertErrorMessage: assertErrorMessage,
assertFormLocationDest: assertFormLocationDest,
assertFormLocationSrc: assertFormLocationSrc,
assertFormQuantity,
assertLinesCount: assertLinesCount,
assertLineIsHighlighted: assertLineIsHighlighted,
assertLineProduct,
assertLineQty: assertLineQty,
assertLineQuantityOnReservedQty: assertLineQuantityOnReservedQty,
assertKanbanRecordsCount,
assertScanMessage: assertScanMessage,
assertSublinesCount,
assertValidateEnabled: assertValidateEnabled,
assertValidateIsHighlighted: assertValidateIsHighlighted,
assertValidateVisible: assertValidateVisible,
fail: fail,
getLine: getLine,
getSubline,
pressShift: pressShift,
releaseShift: releaseShift,
triggerKeydown: triggerKeydown,
};
});

View File

@@ -0,0 +1,651 @@
/** @odoo-module */
import helper from 'stock_barcode.tourHelper';
import tour from 'web_tour.tour';
tour.register('test_inventory_adjustment', {test: true}, [
{
trigger: '.button_inventory',
},
{
trigger: '.o_scan_message.o_scan_product',
run: function () {
helper.assertScanMessage('scan_product');
helper.assertValidateVisible(true);
helper.assertValidateIsHighlighted(false);
helper.assertValidateEnabled(false);
}
},
{
trigger: '.o_barcode_client_action',
run: 'scan product1',
},
{
trigger: '.o_barcode_line',
run: function () {
// Checks the product code and name are on separate lines.
const $line = helper.getLine({barcode: 'product1'});
helper.assert($line.find('.o_barcode_line_details > .o_barcode_line_title > .o_barcode_product_ref').length, 1);
helper.assert($line.find('.o_barcode_line_details .product-label').length, 1);
}
},
{
trigger: '.o_barcode_client_action',
run: 'scan product1',
},
{
trigger: '.o_edit',
},
{
trigger: '.o_field_widget[name="inventory_quantity"]',
run: function () {
helper.assertFormQuantity('2');
}
},
{
trigger: '.o_save',
},
{
trigger: '.o_barcode_line',
run: function () {
// Checks the product code and name are on separate lines.
const $line = helper.getLine({barcode: 'product1'});
helper.assert($line.find('.o_barcode_line_details > .o_barcode_line_title > .o_barcode_product_ref').length, 1);
helper.assert($line.find('.o_barcode_line_details .product-label').length, 1);
}
},
{
trigger: '.o_add_line',
},
{
trigger: ".o_field_widget[name=product_id] input",
run: 'text product2',
},
{
trigger: ".ui-menu-item > a:contains('product2')",
},
{
trigger: ".o_field_widget[name=inventory_quantity] input",
run: 'text 2',
},
{
trigger: '.o_save',
},
{
extra_trigger: '.o_scan_message.o_scan_product',
trigger: '.o_barcode_line',
run: 'scan O-BTN.validate',
},
{
trigger: '.o_stock_barcode_main_menu',
},
{
trigger: '.o_notification.border-success',
run: function () {
helper.assertErrorMessage('The inventory adjustment has been validated');
},
},
]);
tour.register('test_inventory_adjustment_multi_location', {test: true}, [
{
trigger: '.button_inventory',
},
{
trigger: '.o_barcode_client_action',
run: 'scan LOC-01-00-00'
},
{
trigger: '.o_scan_message:contains("WH/Stock")',
},
{
trigger: '.o_barcode_client_action',
run: 'scan product1',
},
{
trigger: '.o_barcode_client_action',
run: 'scan product1',
},
{
trigger: '.o_barcode_client_action',
run: 'scan product2',
},
{
trigger: '.o_barcode_client_action',
run: 'scan LOC-01-01-00'
},
{
trigger: '.o_scan_message:contains("WH/Stock/Section 1")',
},
{
trigger: '.o_barcode_client_action',
run: 'scan product2',
},
{
trigger: '.o_barcode_client_action',
run: 'scan LOC-01-02-00'
},
{
trigger: '.o_scan_message:contains("WH/Stock/Section 2")',
},
{
trigger: '.o_barcode_client_action',
run: 'scan product1',
},
{
trigger: '.o_barcode_client_action',
run: 'scan O-BTN.validate',
},
{
trigger: '.o_stock_barcode_main_menu',
run: function () {
helper.assertErrorMessage('The inventory adjustment has been validated');
},
},
]);
tour.register('test_inventory_adjustment_tracked_product', {test: true}, [
{
trigger: '.button_inventory',
},
{
trigger: '.o_barcode_client_action',
run: 'scan productlot1',
},
{
trigger: '.o_barcode_line:contains("productlot1")',
run: 'scan lot1',
},
{
trigger: '.o_barcode_client_action',
run: 'scan lot1',
},
{
trigger: '.o_barcode_line.o_selected .qty-done:contains(2)',
run: 'scan productserial1',
},
{
trigger: '.o_barcode_line:contains("productserial1")',
run: 'scan serial1',
},
{
trigger: '.o_barcode_client_action',
run: 'scan serial1',
},
{
trigger: '.o_notification.border-danger',
run: function () {
// Check that other lines is correct
let $line = helper.getLine({barcode: 'productserial1'});
helper.assertLineQty($line, "1");
helper.assert($line.find('.o_line_lot_name').text().trim(), 'serial1');
$line = helper.getLine({barcode: 'productlot1'});
helper.assertLineQty($line, "2");
helper.assert($line.find('.o_line_lot_name').text().trim(), 'lot1');
helper.assertErrorMessage('The scanned serial number is already used.');
},
},
{
trigger: '.o_barcode_client_action',
run: 'scan serial2',
},
{ trigger: '.o_barcode_line.o_selected .btn.o_toggle_sublines .fa-caret-down' },
{
trigger: '.o_barcode_line:contains("serial2")',
run: 'scan productlot1',
},
{
trigger: '.o_barcode_line:contains("productlot1")',
run: 'scan lot1',
},
{
trigger: '.o_barcode_line .qty-done:contains(3)',
run: 'scan productserial1',
},
{
trigger: '.o_barcode_line:contains("productserial1")',
run: 'scan serial3',
},
{
trigger: ':contains("productserial1") .o_sublines .o_barcode_line:contains("serial3")',
run: function () {
helper.assertLinesCount(2);
helper.assertSublinesCount(3);
},
},
// Edit a line to trigger a save.
{
trigger: '.o_add_line',
},
{
trigger: '.o_field_widget[name="product_id"]',
},
{
trigger: '.o_discard',
},
// Scan tracked by lots product, then scan new lots.
{
trigger: '.o_sublines .o_barcode_line:nth-child(3)',
run: function () {
helper.assertLinesCount(2);
helper.assertSublinesCount(3);
},
},
{
trigger: '.o_barcode_client_action',
run: 'scan productlot1',
},
{
trigger: '.o_barcode_line.o_selected:contains("productlot1")',
run: 'scan lot2',
},
{ trigger: '.o_barcode_line.o_selected .btn.o_toggle_sublines .fa-caret-down' },
{
trigger: '.o_barcode_line .o_barcode_line:contains("lot2")',
run: 'scan lot3',
},
// Must have 6 lines in two groups: lot1, lot2, lot3 and serial1, serial2, serial3.
// Grouped lines for `productlot1` should be unfolded.
{
trigger: '.o_barcode_line:contains("productlot1") .o_sublines>.o_barcode_line.o_selected:contains("lot3")',
run: function () {
helper.assertLinesCount(2);
helper.assertSublinesCount(3);
}
},
...tour.stepUtils.validateBarcodeForm(),
{
trigger: '.o_stock_barcode_main_menu',
run: function () {
helper.assertErrorMessage('The inventory adjustment has been validated');
},
},
]);
tour.register('test_inventory_nomenclature', {test: true}, [
{
trigger: '.button_inventory',
},
{
trigger: '.o_barcode_client_action',
run: function() {
helper.assertScanMessage('scan_product');
},
},
{
trigger: '.o_barcode_client_action',
run: 'scan 2145631123457', // 12.345 kg
},
{
trigger: '.product-label:contains("product_weight")'
},
...tour.stepUtils.validateBarcodeForm(),
{
trigger: '.o_stock_barcode_main_menu',
run: function () {
helper.assertErrorMessage('The inventory adjustment has been validated');
},
},
]);
tour.register('test_inventory_package', {test: true}, [
{
trigger: '.button_inventory',
},
{
trigger: '.o_barcode_client_action',
run: 'scan PACK001',
},
{
trigger: '.o_barcode_line:contains("product2") .o_edit',
},
{
trigger: '[name="inventory_quantity"] input',
run: 'text 21'
},
{
trigger: '.o_save',
},
{
trigger: '.o_apply_page',
},
{
trigger: '.o_notification.border-success',
run: function () {
helper.assertErrorMessage('The inventory adjustment has been validated');
},
},
{
trigger: '.o_stock_barcode_main_menu',
},
]);
tour.register('test_inventory_owner_scan_package', {test: true}, [
{
trigger: '.button_inventory',
},
{
trigger: '.o_barcode_client_action',
run: 'scan P00001',
},
{
trigger: '.o_barcode_client_action:contains("P00001")',
},
{
trigger: '.o_barcode_client_action:contains("Azure Interior")',
},
...tour.stepUtils.validateBarcodeForm(),
]);
tour.register('test_inventory_using_buttons', {test: true}, [
{ trigger: '.button_inventory' },
// Scans product 1: must have 1 quantity and buttons +1/-1 must be visible.
{ trigger: '.o_barcode_client_action', run: 'scan product1' },
{
trigger: '.o_barcode_client_action .o_barcode_line',
run: function () {
helper.assertLinesCount(1);
const $line = helper.getLine({barcode: 'product1'});
helper.assertLineIsHighlighted($line, true);
helper.assertLineQty($line, '1');
helper.assertButtonIsVisible($line, 'add_quantity');
helper.assertButtonIsVisible($line, 'remove_unit');
}
},
// Clicks on -1 button: must have 0 quantity, -1 still visible but disabled.
{ trigger: '.o_remove_unit' },
{
trigger: '.o_barcode_line:contains("0")',
run: function () {
helper.assertLinesCount(1);
const $line = helper.getLine({barcode: 'product1'});
helper.assertLineIsHighlighted($line, true);
helper.assertLineQty($line, '0');
helper.assertButtonIsVisible($line, 'add_quantity');
helper.assertButtonIsVisible($line, 'remove_unit');
const decrementButton = document.querySelector('.o_line_button.o_remove_unit');
helper.assert(decrementButton.hasAttribute('disabled'), true);
}
},
// Clicks on +1 button: must have 1 quantity, -1 must be enabled now.
{ trigger: '.o_add_quantity' },
{
trigger: '.o_barcode_line .qty-done:contains("1")',
run: function () {
helper.assertLinesCount(1);
const $line = helper.getLine({barcode: 'product1'});
helper.assertLineIsHighlighted($line, true);
helper.assertLineQty($line, '1');
helper.assertButtonIsVisible($line, 'add_quantity');
helper.assertButtonIsVisible($line, 'remove_unit');
const decrementButton = document.querySelector('.o_line_button.o_remove_unit');
helper.assert(decrementButton.hasAttribute('disabled'), false);
}
},
// Scans productserial1: must have 0 quantity, buttons must be hidden (a
// line for a product tracked by SN doesn't have -1/+1 buttons).
{ trigger: '.o_barcode_client_action', run: 'scan productserial1' },
{
trigger: '.o_barcode_client_action .o_barcode_line:nth-child(2)',
run: function () {
helper.assertLinesCount(2);
const $line = helper.getLine({barcode: 'productserial1'});
helper.assertLineIsHighlighted($line, true);
helper.assertLineQty($line, '0');
helper.assertButtonIsNotVisible($line, 'add_quantity');
helper.assertButtonIsNotVisible($line, 'remove_unit');
const setButton = document.querySelector('.o_selected .o_line_button.o_set > .fa-check');
helper.assert(Boolean(setButton), true);
}
},
// Scans a serial number: must have 1 quantity, check button must display a "X".
{ trigger: '.o_barcode_client_action', run: 'scan BNG-118' },
{
trigger: '.o_barcode_line:contains("BNG-118")',
run: function () {
helper.assertLinesCount(2);
const $line = helper.getLine({barcode: 'productserial1'});
helper.assertLineIsHighlighted($line, true);
helper.assertLineQty($line, '1');
helper.assertButtonIsNotVisible($line, 'add_quantity');
helper.assertButtonIsNotVisible($line, 'remove_unit');
const setButton = document.querySelector('.o_selected .o_line_button.o_set.o_difference');
helper.assert(Boolean(setButton), true);
}
},
// Clicks on set button: must set the inventory quantity equals to the quantity .
{ trigger: '.o_barcode_line:contains("productserial1") .o_line_button.o_set' },
{
trigger: '.o_barcode_line.o_selected .fa-check',
run: function () {
helper.assertLinesCount(2);
const $line = helper.getLine({barcode: 'productserial1'});
helper.assertLineIsHighlighted($line, true);
helper.assertLineQty($line, '0');
helper.assertButtonIsNotVisible($line, 'add_quantity');
helper.assertButtonIsNotVisible($line, 'remove_unit');
const goodQuantitySetButton = document.querySelector('.o_selected .o_line_button.o_set > .fa-check');
helper.assert(Boolean(goodQuantitySetButton), true);
const differenceSetButton = document.querySelector('.o_selected .o_line_button.o_set.o_difference');
helper.assert(Boolean(differenceSetButton), false);
}
},
// Clicks again on set button: must unset the quantity.
{ trigger: '.o_barcode_line:contains("productserial1") .o_line_button.o_set' },
{
trigger: '.o_barcode_line:contains("productserial1"):contains("?")',
run: function () {
helper.assertLinesCount(2);
const $line = helper.getLine({barcode: 'productserial1'});
helper.assertLineIsHighlighted($line, true);
helper.assertLineQty($line, '?');
helper.assertButtonIsNotVisible($line, 'add_quantity');
helper.assertButtonIsNotVisible($line, 'remove_unit');
const goodQuantitySetButton = document.querySelector('.o_selected .o_line_button.o_set > .fa-check');
helper.assert(Boolean(goodQuantitySetButton), false);
const differenceSetButton = document.querySelector('.o_selected .o_line_button.o_set.o_difference');
helper.assert(Boolean(differenceSetButton), false);
const emptySetButton = document.querySelector('.o_selected .o_line_button.o_set');
helper.assert(Boolean(emptySetButton), true);
}
},
// Scans productlot1: must have 0 quantity, buttons should be visible.
{ trigger: '.o_barcode_client_action', run: 'scan productlot1' },
{
trigger: '.o_barcode_client_action .o_barcode_line:nth-child(3)',
run: function () {
helper.assertLinesCount(3);
const $line = helper.getLine({barcode: 'productlot1'});
helper.assertLineIsHighlighted($line, true);
helper.assertLineQty($line, '0');
helper.assertButtonIsVisible($line, 'add_quantity');
helper.assertButtonIsVisible($line, 'remove_unit');
const decrementButton = document.querySelector('.o_line_button.o_remove_unit');
helper.assert(decrementButton.hasAttribute('disabled'), true);
}
},
// Scans a lot number: must have 1 quantity, buttons should still be visible.
{ trigger: '.o_barcode_client_action', run: 'scan toto-42' },
{
trigger: '.o_barcode_line:contains("toto-42")',
run: function () {
helper.assertLinesCount(3);
const $line = helper.getLine({barcode: 'productlot1'});
helper.assertLineIsHighlighted($line, true);
helper.assertLineQty($line, '1');
helper.assertButtonIsVisible($line, 'add_quantity');
helper.assertButtonIsVisible($line, 'remove_unit');
const decrementButton = document.querySelector('.o_line_button.o_remove_unit');
helper.assert(decrementButton.hasAttribute('disabled'), false);
}
},
// Clicks on -1 button: must have 0 quantity, button -1 must be disabled again.
{ trigger: '.o_barcode_line:contains("productlot1") .o_remove_unit' },
{
trigger: '.o_barcode_line:contains("productlot1") .qty-done:contains("0")',
run: function () {
helper.assertLinesCount(3);
const $line = helper.getLine({barcode: 'productlot1'});
helper.assertLineIsHighlighted($line, true);
helper.assertLineQty($line, '0');
helper.assertButtonIsVisible($line, 'add_quantity');
helper.assertButtonIsVisible($line, 'remove_unit');
const decrementButton = document.querySelector('.o_line_button.o_remove_unit');
helper.assert(decrementButton.hasAttribute('disabled'), true);
}
},
// Clicks on +1 button: must have 1 quantity, buttons must be visible.
{ trigger: '.o_barcode_line:contains("productlot1") .o_add_quantity' },
{
trigger: '.o_barcode_line:contains("productlot1") .qty-done:contains(1)',
run: function () {
helper.assertLinesCount(3);
const $line = helper.getLine({barcode: 'productlot1'});
helper.assertLineIsHighlighted($line, true);
helper.assertLineQty($line, '1');
helper.assertButtonIsVisible($line, 'add_quantity');
helper.assertButtonIsVisible($line, 'remove_unit');
const decrementButton = document.querySelector('.o_line_button.o_remove_unit');
helper.assert(decrementButton.hasAttribute('disabled'), false);
}
},
// Scans product2 => Should retrieve the quantity on hand and display 1/10.
{ trigger: '.o_barcode_client_action', run: 'scan product2' },
{
trigger: '.o_barcode_line:contains("product2")',
run: function () {
helper.assertLinesCount(4);
const $line = helper.getLine({barcode: 'product2'});
helper.assertLineIsHighlighted($line, true);
helper.assertLineQuantityOnReservedQty(3, '1 / 10');
helper.assertButtonIsVisible($line, 'add_quantity');
helper.assertButtonIsVisible($line, 'remove_unit');
const setButton = document.querySelector('.o_selected .o_line_button.o_set.o_difference');
helper.assert(Boolean(setButton), true);
}
},
// Clicks multiple time on the set quantity button and checks the save is rightly done.
{ trigger: '.o_selected .o_line_button.o_set.o_difference' },
{
trigger: '.o_barcode_line:contains("product2"):contains("?")',
run: function () {
const line = document.querySelector('.o_barcode_line[data-barcode=product2]');
const qty = line.querySelector('.o_barcode_scanner_qty').textContent;
helper.assert(qty, '?/ 10');
}
},
// Goes to the quant form view to trigger a save then go back.
{ trigger: '.o_selected .o_line_button.o_edit' },
{ trigger: '.o_discard' },
{
trigger: '.o_barcode_line:contains("product2"):contains("?")',
run: function () {
const line = document.querySelector('.o_barcode_line[data-barcode=product2]');
const qty = line.querySelector('.o_barcode_scanner_qty').textContent;
helper.assert(qty, '?/ 10');
}
},
// Clicks again, should pass from "? / 10" to "10 / 10"
{ trigger: '.o_barcode_line:contains("product2") .o_line_button.o_set' },
{
trigger: '.o_barcode_line:contains("product2") .qty-done:contains("10")',
run: function () {
const line = document.querySelector('.o_barcode_line[data-barcode=product2]');
const qty = line.querySelector('.o_barcode_scanner_qty').textContent;
helper.assert(qty, '10/ 10');
}
},
// Goes to the quant form view to trigger a save then go back.
{ trigger: '.o_barcode_line:contains("product2") .o_line_button.o_edit' },
{ trigger: '.o_discard' },
{
trigger: '.o_barcode_line:contains("product2") .qty-done:contains("10")',
run: function () {
const line = document.querySelector('.o_barcode_line[data-barcode=product2]');
const qty = line.querySelector('.o_barcode_scanner_qty').textContent;
helper.assert(qty, '10/ 10');
}
},
// Clicks again, should pass from "10 / 10" to "? / 10"
{ trigger: '.o_barcode_line:contains("product2") .o_line_button.o_set .fa-check' },
{
trigger: '.o_barcode_line:contains("product2"):contains("?")',
run: function () {
const line = document.querySelector('.o_barcode_line[data-barcode=product2]');
const qty = line.querySelector('.o_barcode_scanner_qty').textContent;
helper.assert(qty, '?/ 10');
}
},
// Validates the inventory.
{ trigger: '.o_apply_page' },
{ trigger: '.o_notification.border-success' }
]);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
odoo.define('stock_mobile_barcode.stock_picking_barcode_tests', function (require) {
"use strict";
const { mock } = require('web.test_utils');
const { createWebClient, doAction } = require('@web/../tests/webclient/helpers');
const BarcodeScanner = require('@web/webclient/barcode/barcode_scanner');
const { destroy, getFixture } = require("@web/../tests/helpers/utils");
QUnit.module('stock_mobile_barcode', {}, function () {
QUnit.module('Barcode', {
beforeEach: function () {
var self = this;
this.clientData = {
action: {
tag: 'stock_barcode_client_action',
type: 'ir.actions.client',
res_model: "stock.picking",
context: {},
},
currentState: {
actions: {},
data: {
records: {
'barcode.nomenclature': [{
id: 1,
rule_ids: [],
}],
'stock.location': [],
'stock.move.line': [],
'stock.picking': [],
},
nomenclature_id: 1,
},
groups: {},
},
};
this.mockRPC = function (route, args) {
if (route === '/stock_barcode/get_barcode_data') {
return Promise.resolve(self.clientData.currentState);
} else if (route === '/stock_barcode/static/img/barcode.svg') {
return Promise.resolve();
}
};
}
});
QUnit.test('scan barcode button in mobile device', async function (assert) {
assert.expect(1);
const pickingRecord = {
id: 2,
state: 'done',
move_line_ids: [],
};
this.clientData.action.context.active_id = pickingRecord.id;
this.clientData.currentState.data.records['stock.picking'].push(pickingRecord);
this.clientData.currentState.groups.group_stock_multi_locations = false;
mock.patch(BarcodeScanner, {
isBarcodeScannerSupported: () => true,
scanBarcode: async () => {},
});
const target = getFixture();
const webClient = await createWebClient({
mockRPC: this.mockRPC,
});
await doAction(webClient, this.clientData.action);
assert.containsOnce(target, '.o_stock_mobile_barcode');
destroy(webClient);
mock.unpatch(BarcodeScanner);
});
});
});

View File

@@ -0,0 +1,65 @@
odoo.define('stock_barcode.stock_picking_barcode_tests', function (require) {
"use strict";
const { createWebClient, doAction } = require('@web/../tests/webclient/helpers');
const { getFixture } = require("@web/../tests/helpers/utils");
QUnit.module('stock_barcode', {}, function () {
QUnit.module('Barcode', {
beforeEach: function () {
var self = this;
this.clientData = {
action: {
tag: 'stock_barcode_client_action',
type: 'ir.actions.client',
res_model: "stock.picking",
context: {},
},
currentState: {
actions: {},
data: {
records: {
'barcode.nomenclature': [{
id: 1,
rule_ids: [],
}],
'stock.location': [],
'stock.move.line': [],
'stock.picking': [],
},
nomenclature_id: 1,
},
groups: {},
},
};
this.mockRPC = function (route, args) {
if (route === '/stock_barcode/get_barcode_data') {
return Promise.resolve(self.clientData.currentState);
} else if (route === '/stock_barcode/static/img/barcode.svg') {
return Promise.resolve();
}
};
}
});
QUnit.test('exclamation-triangle when picking is done', async function (assert) {
assert.expect(1);
const pickingRecord = {
id: 2,
state: 'done',
move_line_ids: [],
};
this.clientData.action.context.active_id = pickingRecord.id;
this.clientData.currentState.data.records['stock.picking'].push(pickingRecord);
const target = getFixture();
const webClient = await createWebClient({
mockRPC: this.mockRPC,
});
await doAction(webClient, this.clientData.action);
assert.containsOnce(target, '.fa-5x.fa-exclamation-triangle:not(.d-none)', "Should have warning icon");
});
});
});