质量模块和库存扫码

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

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>