diff --git a/yuthon_purchase/views/inherit_product_product_views.xml b/yuthon_purchase/views/inherit_product_product_views.xml index 64319e615..f68eb4417 100644 --- a/yuthon_purchase/views/inherit_product_product_views.xml +++ b/yuthon_purchase/views/inherit_product_product_views.xml @@ -12,12 +12,16 @@ - + product.template.tree.inherit.yuthon.purchase product.template + + + bottom + 1 @@ -34,6 +38,10 @@ 详细类别 + + + + diff --git a/yuthon_stock/__manifest__.py b/yuthon_stock/__manifest__.py index f44f479b1..7e0119241 100644 --- a/yuthon_stock/__manifest__.py +++ b/yuthon_stock/__manifest__.py @@ -9,6 +9,8 @@ 'depends': ['base', 'stock', 'purchase_stock', 'documents', 'maintenance', 'funenc_json_widget'], 'data': [ 'data/sequence.xml', + 'data/inventory_sequence.xml', + 'data/inventory_cron.xml', 'security/ir.model.access.csv', 'security/company_rule.xml', 'wizard/stock_picking_transfer_wizard_views.xml', @@ -19,6 +21,7 @@ 'views/inherit_stock_picking.xml', 'views/Inherit_stock_picking_type_views.xml', 'views/inherit_product_template_views.xml', + 'views/yuthon_inventory_views.xml', ], 'installable': True, 'application': True, diff --git a/yuthon_stock/data/inventory_cron.xml b/yuthon_stock/data/inventory_cron.xml new file mode 100644 index 000000000..0c059794f --- /dev/null +++ b/yuthon_stock/data/inventory_cron.xml @@ -0,0 +1,15 @@ + + + + + 盘点任务提醒 + + code + model.action_send_reminder() + 1 + days + -1 + False + + + diff --git a/yuthon_stock/data/inventory_sequence.xml b/yuthon_stock/data/inventory_sequence.xml new file mode 100644 index 000000000..9d54dc72c --- /dev/null +++ b/yuthon_stock/data/inventory_sequence.xml @@ -0,0 +1,13 @@ + + + + + 盘点任务编号 + yuthon.inventory.task + INV%(y)s%(month)s + 3 + 1 + 1 + + + diff --git a/yuthon_stock/models/__init__.py b/yuthon_stock/models/__init__.py index 28f787203..9823225b7 100644 --- a/yuthon_stock/models/__init__.py +++ b/yuthon_stock/models/__init__.py @@ -3,4 +3,10 @@ from . import inherit_stock_picking from . import Inherit_stock_picking_type from . import inherit_stock_location from . import inherit_stock_move +from . import inherit_product_template +from . import inherit_product_product from . import ir_model_patch +from . import yuthon_inventory_task +from . import yuthon_inventory_sheet +from . import yuthon_inventory_sheet_line +from . import yuthon_inventory_adjustment diff --git a/yuthon_stock/models/inherit_product_product.py b/yuthon_stock/models/inherit_product_product.py new file mode 100644 index 000000000..784151a11 --- /dev/null +++ b/yuthon_stock/models/inherit_product_product.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +"""继承 product.product,让"在手"数量统计所有内部库位 + +Odoo 默认只在仓库层级内的库位统计 qty_available,而本项目中存在大量 +像"办公室"这样不属于仓库层级的内部库位,导致产品树"在手"列显示为 0。 +这里覆盖 _get_domain_locations,使默认情况下统计所有内部库位。 +""" +from odoo import models + + +class ProductProduct(models.Model): + """继承 product.product,修改数量计算的默认库位范围""" + _inherit = 'product.product' + + def _get_domain_locations(self): + """覆盖默认逻辑:未指定仓库/位置时,统计所有内部库位 + + 当 context 中没有显式的 location 或 warehouse 时,不再只取仓库的 + view_location_id,而是取所有当前公司下的内部库位,保证 办公室、 + 保密室 等非仓库层级库位也能正确计入 qty_available。 + """ + # 如果 context 中有显式 location 或 warehouse,保持原逻辑不变 + if self.env.context.get('location') or self.env.context.get('warehouse'): + return super(ProductProduct, self)._get_domain_locations() + + # 默认:使用所有内部库位(usage='internal)且属于当前可用公司的 + Location = self.env['stock.location'] + location_ids = set(Location.search([ + ('usage', '=', 'internal'), + ('company_id', 'in', self.env.companies.ids), + ]).ids) + return self._get_domain_locations_new(location_ids) diff --git a/yuthon_stock/models/inherit_product_template.py b/yuthon_stock/models/inherit_product_template.py index e03ecd9a8..9de98a324 100644 --- a/yuthon_stock/models/inherit_product_template.py +++ b/yuthon_stock/models/inherit_product_template.py @@ -1,2 +1,80 @@ # -*- coding: utf-8 -*- -from odoo import models +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + """继承 product.template,添加库存位置相关字段""" + _inherit = 'product.template' + + # 库存位置名称(所有内部库位,顿号拼接)—— 只读展示 + stock_location_names = fields.Char( + string='库存位置', + compute='_compute_stock_location_names', + help='产品在内部库位的所有位置,多个位置用顿号拼接', + ) + + # 可编辑的库存位置下拉 —— 用户可通过下拉修改位置,移动库存 + stock_location_id = fields.Many2one( + 'stock.location', + string='库存位置', + compute='_compute_stock_location_id', + inverse='_inverse_stock_location_id', + store=True, + domain="[('usage', '=', 'internal')]", + help='选择目标位置,产品在当前内部库位的库存将被移动到该位置', + ) + + @api.depends('product_variant_ids', 'product_variant_ids.stock_quant_ids', + 'product_variant_ids.stock_quant_ids.location_id') + def _compute_stock_location_names(self): + """计算产品所有内部库位名称,去重后用顿号拼接""" + for template in self: + if not template.product_variant_ids: + template.stock_location_names = '' + continue + # 搜索该产品所有变体在内部库位的库存记录 + quants = self.env['stock.quant'].search([ + ('product_id', 'in', template.product_variant_ids.ids), + ('location_id.usage', '=', 'internal'), + ]) + # 去重:用 dict.fromkeys 保持插入顺序 + location_names = list(dict.fromkeys( + quants.mapped('location_id.display_name') + )) + template.stock_location_names = '、'.join(location_names) if location_names else '' + + @api.depends('product_variant_ids', 'product_variant_ids.stock_quant_ids', + 'product_variant_ids.stock_quant_ids.location_id', + 'product_variant_ids.stock_quant_ids.quantity') + def _compute_stock_location_id(self): + """计算当前主库存位置 —— + 取库存数量最大的内部库位作为默认值,没有库存则留空 + """ + for template in self: + if not template.product_variant_ids: + template.stock_location_id = False + continue + # 取库存数量最大的那个内部库位 + quants = self.env['stock.quant'].search([ + ('product_id', 'in', template.product_variant_ids.ids), + ('location_id.usage', '=', 'internal'), + ], order='quantity desc', limit=1) + template.stock_location_id = quants.location_id.id if quants else False + + def _inverse_stock_location_id(self): + """用户选择新位置时,将产品所有内部库位的库存合并移动到新位置 + - Odoo 自动把同产品同位置的 quant 合并为一条,无需手动处理数量 + """ + for template in self: + if not template.stock_location_id: + continue + new_location = template.stock_location_id + # 找到该产品所有不在目标位置的 quant → 直接搬家 + quants = self.env['stock.quant'].sudo().search([ + ('product_id', 'in', template.product_variant_ids.ids), + ('location_id.usage', '=', 'internal'), + ('location_id', '!=', new_location.id), + ]) + for q in quants: + # 直接改位置,Odoo 自动合并同产品同库位的 quant + q.location_id = new_location.id diff --git a/yuthon_stock/models/inherit_stock_location.py b/yuthon_stock/models/inherit_stock_location.py index 0b80ffe10..3af86033e 100644 --- a/yuthon_stock/models/inherit_stock_location.py +++ b/yuthon_stock/models/inherit_stock_location.py @@ -6,6 +6,23 @@ import json class InheritStockLocation(models.Model): _inherit = 'stock.location' + def write(self, values): + """覆盖 write 方法,允许修改已使用库位的 company_id""" + if 'company_id' in values: + # Odoo 原生禁止修改已使用库位的公司,我们通过 SQL 绕过此限制 + company_value = values.pop('company_id') + # 先写其他字段(走父类完整校验) + result = super(InheritStockLocation, self).write(values) + # 直接用 SQL 写入 company_id,绕过 Python 层的限制检查 + self._cr.execute( + 'UPDATE stock_location SET company_id = %s WHERE id IN %s', + [company_value, tuple(self.ids)] + ) + # 清除缓存,确保后续读取到最新值 + self.invalidate_recordset() + return result + return super(InheritStockLocation, self).write(values) + def update_stock(self): department_obj = self.env['hr.department'] root_departments = department_obj.search([('child_ids', '=', False)]) diff --git a/yuthon_stock/models/inherit_stock_picking.py b/yuthon_stock/models/inherit_stock_picking.py index 57a49abdd..e2107315f 100644 --- a/yuthon_stock/models/inherit_stock_picking.py +++ b/yuthon_stock/models/inherit_stock_picking.py @@ -694,19 +694,21 @@ class StockPicking(models.Model): return True def action_acceptance(self): - """验收按鈕:检查是否指定验收人,自动填充验收人和验收时间""" + """验收按钮:采购人员直接执行验收,自动填充验收人和验收时间,并记录验收人身份以便后续转交证明""" self.ensure_one() - # 如果已指定验收人,检查当前用户是否是指定验收人 - if self.acceptance_user_id and self.acceptance_user_id.id != self.env.user.id: - raise UserError('只有指定的验收人才能执行验收!') - # 用指定验收人填充,没有指定则取当前用户 - acceptor = self.acceptance_user_id or self.env.user + # 检查当前用户是否为采购人员(user_id),只有采购人员才能执行验收 + if self.user_id and self.user_id.id != self.env.user.id: + raise UserError('只有采购人员才能执行验收!') + acceptor = self.env.user current_employee = acceptor.employee_ids[:1] if acceptor.employee_ids else False self.acceptance_date = dt.utcnow() if current_employee: self.employee_id = current_employee.id - self.receipt_status = 'certification' # 状态变为证明 - # 将验收人的待办 log 更新为已办 + self.write({ + 'receipt_status': 'certification', # 状态变为证明 + 'acceptance_user_id': self.env.user.id, # 记录验收人身份,供后续转交证明按钮权限判断使用 + }) + # 将验收人的待办 log 更新为已办(若存在由转交验收生成的待办 log) pending_logs = self.env['soong.workflow.log'].search([ ('model', '=', 'stock.picking'), ('res_id', '=', self.id), @@ -730,21 +732,6 @@ class StockPicking(models.Model): 'acceptance_date': False, }) - def action_transfer_acceptance(self): - """转交验收:弹出选人 wizard""" - self.ensure_one() - return { - 'type': 'ir.actions.act_window', - 'name': '转交验收', - 'res_model': 'stock.picking.transfer.wizard', - 'view_mode': 'form', - 'target': 'new', - 'context': { - 'default_picking_id': self.id, - 'default_transfer_type': 'acceptance', - }, - } - def action_transfer_certifier(self): """转交证明:弹出选人 wizard""" self.ensure_one() diff --git a/yuthon_stock/models/yuthon_inventory_adjustment.py b/yuthon_stock/models/yuthon_inventory_adjustment.py new file mode 100644 index 000000000..bb3aad2c7 --- /dev/null +++ b/yuthon_stock/models/yuthon_inventory_adjustment.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class YuthonInventoryAdjustment(models.Model): + """库存调整记录:盘点提交后自动生成,记录每次库存修正""" + _name = 'yuthon.inventory.adjustment' + _description = '库存调整记录' + _order = 'adjust_date desc, id desc' + + task_id = fields.Many2one( + 'yuthon.inventory.task', + string='所属盘点任务', + readonly=True, + ) + line_id = fields.Many2one( + 'yuthon.inventory.sheet.line', + string='所属盘点明细行', + readonly=True, + ) + # 办公用品关联产品 + product_id = fields.Many2one( + 'product.product', + string='调整产品', + readonly=True, + ) + # 固定资产关联设备 + equipment_id = fields.Many2one( + 'maintenance.equipment', + string='调整设备', + readonly=True, + ) + adjust_type = fields.Selection( + [('increase', '盘盈(增加)'), ('decrease', '盘亏(减少)')], + string='调整类型', + required=True, + readonly=True, + ) + qty_before = fields.Float( + string='调整前数量', + readonly=True, + digits='Product Unit of Measure', + ) + qty_after = fields.Float( + string='调整后数量', + readonly=True, + digits='Product Unit of Measure', + ) + adjust_qty = fields.Float( + string='调整数量', + readonly=True, + digits='Product Unit of Measure', + ) + reason = fields.Char(string='调整原因', readonly=True) + adjust_date = fields.Datetime( + string='调整时间', + readonly=True, + default=fields.Datetime.now, + ) + company_id = fields.Many2one( + 'res.company', + string='所属公司', + readonly=True, + ) diff --git a/yuthon_stock/models/yuthon_inventory_sheet.py b/yuthon_stock/models/yuthon_inventory_sheet.py new file mode 100644 index 000000000..74f4badc0 --- /dev/null +++ b/yuthon_stock/models/yuthon_inventory_sheet.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class YuthonInventorySheet(models.Model): + """盘点表头:每次点击「确认生成盘点表」时创建一条,内含明细行""" + _name = 'yuthon.inventory.sheet' + _description = '盘点表' + _order = 'create_date desc, id desc' + + # ========== 关联任务 ========== + task_id = fields.Many2one( + 'yuthon.inventory.task', + string='所属盘点任务', + required=True, + ondelete='cascade', + ) + + # ========== 头记录信息 ========== + name = fields.Char( + string='盘点表名称', + compute='_compute_name', + store=True, + ) + + # ========== 明细行 ========== + line_ids = fields.One2many( + 'yuthon.inventory.sheet.line', + 'sheet_id', + string='盘点明细', + ) + # 办公用品明细行(按 sheet_type 过滤) + office_line_ids = fields.One2many( + 'yuthon.inventory.sheet.line', + 'sheet_id', + string='办公用品明细', + domain=[('sheet_type', '=', 'office')], + ) + # 固定资产明细行(按 sheet_type 过滤) + fixed_asset_line_ids = fields.One2many( + 'yuthon.inventory.sheet.line', + 'sheet_id', + string='固定资产明细', + domain=[('sheet_type', '=', 'fixed_asset')], + ) + line_count = fields.Integer( + string='明细行数', + compute='_compute_line_count', + ) + + # ========== 状态 ========== + state = fields.Selection( + [('pending', '待盘点'), ('done', '已完成')], + string='状态', + default='pending', + readonly=True, + index=True, + ) + + # ========== 计算方法 ========== + @api.depends('task_id.code', 'task_id.name') + def _compute_name(self): + """盘点表名称 = 任务编号 + 任务名""" + for sheet in self: + if sheet.task_id: + sheet.name = '%s - %s' % (sheet.task_id.code or '', sheet.task_id.name or '') + else: + sheet.name = '' + + @api.depends('line_ids') + def _compute_line_count(self): + """计算明细行数""" + for sheet in self: + sheet.line_count = len(sheet.line_ids) diff --git a/yuthon_stock/models/yuthon_inventory_sheet_line.py b/yuthon_stock/models/yuthon_inventory_sheet_line.py new file mode 100644 index 000000000..5475abab6 --- /dev/null +++ b/yuthon_stock/models/yuthon_inventory_sheet_line.py @@ -0,0 +1,289 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class YuthonInventorySheetLine(models.Model): + """盘点表明细行:每条记录对应一个需要盘点的产品或固定资产设备""" + _name = 'yuthon.inventory.sheet.line' + _description = '盘点表明细行' + _order = 'sheet_type, product_id, equipment_id' + + # ========== 关联头记录 ========== + sheet_id = fields.Many2one( + 'yuthon.inventory.sheet', + string='所属盘点表', + required=True, + ondelete='cascade', + ) + + # ========== 盘点类型 ========== + sheet_type = fields.Selection( + [('office', '办公用品'), ('fixed_asset', '固定资产')], + string='盘点类型', + required=True, + readonly=True, + index=True, + ) + + # ========== 办公用品字段 ========== + product_id = fields.Many2one( + 'product.product', + string='产品', + readonly=True, + ) + + # ========== 固定资产字段 ========== + equipment_id = fields.Many2one( + 'maintenance.equipment', + string='固定资产设备', + readonly=True, + ) + + # ========== 显示信息 ========== + product_name = fields.Char( + string='产品/设备名称', + compute='_compute_display_info', + readonly=True, + store=True, + ) + equipment_name = fields.Char( + string='设备名称', + related='equipment_id.display_name', + readonly=True, + store=False, + ) + specification = fields.Char( + string='规格型号', + compute='_compute_display_info', + store=True, + ) + # ========== 库存位置(办公用品可编辑,固定资产不显示) ========== + stock_location_id = fields.Many2one( + 'stock.location', + string='库存位置', + compute='_compute_stock_location_id', + inverse='_inverse_stock_location_id', + store=True, + domain="[('usage', '=', 'internal')]", + help='选择目标位置,产品在当前内部库位的库存将被移动到该位置', + ) + user_name = fields.Char(string='使用人') + department_name = fields.Char(string='使用部门') + equipment_unit_name = fields.Char( + string='单位', + compute='_compute_display_info', + store=True, + ) + + # ========== 数量字段 ========== + on_hand_qty = fields.Float( + string='在手数量(系统数)', + readonly=True, + digits='Product Unit of Measure', + ) + actual_qty = fields.Float( + string='实际盘点数量', + digits='Product Unit of Measure', + ) + difference_qty = fields.Float( + string='差额', + compute='_compute_difference', + store=True, + digits='Product Unit of Measure', + ) + has_difference = fields.Boolean( + string='有差额', + compute='_compute_difference', + store=True, + ) + difference_reason = fields.Char(string='差额原因') + + # ========== 状态 ========== + company_id = fields.Many2one( + 'res.company', + string='所属公司', + readonly=True, + ) + state = fields.Selection( + [('pending', '待盘点'), ('counted', '已盘点')], + string='盘点状态', + default='pending', + readonly=True, + ) + + # ========== 计算字段 ========== + @api.depends('product_id', 'equipment_id', 'sheet_type') + def _compute_display_info(self): + """根据盘点类型,从产品或设备带出名称、规格型号、单位""" + for line in self: + if line.sheet_type == 'fixed_asset' and line.equipment_id: + eq = line.equipment_id + line.product_name = eq.name or '' + line.specification = eq.model or '' + line.equipment_unit_name = eq.equipment_unit.name if eq.equipment_unit else '' + elif line.sheet_type == 'office' and line.product_id: + tmpl = line.product_id.product_tmpl_id + line.product_name = line.product_id.display_name or '' + line.specification = tmpl.description or tmpl.name or '' + line.equipment_unit_name = '' + else: + line.product_name = '' + line.specification = '' + line.equipment_unit_name = '' + + @api.depends('on_hand_qty', 'actual_qty') + def _compute_difference(self): + """计算差额""" + for line in self: + line.difference_qty = line.actual_qty - line.on_hand_qty + line.has_difference = line.difference_qty != 0.0 + + @api.depends('product_id', 'sheet_type') + def _compute_stock_location_id(self): + """计算库存位置 —— + 办公用品:从 stock.quant 取数量最大的内部库位 + 固定资产:无库存位置概念,返回 False(视图用 invisible 隐藏) + """ + for line in self: + if line.sheet_type != 'office' or not line.product_id: + line.stock_location_id = False + continue + # 从产品变体取库存量最大的内部库位 + quants = self.env['stock.quant'].sudo().search([ + ('product_id', '=', line.product_id.id), + ('location_id.usage', '=', 'internal'), + ], order='quantity desc', limit=1) + line.stock_location_id = quants.location_id.id if quants else False + + def _inverse_stock_location_id(self): + """用户修改库存位置时,将产品所有 internal quant 搬到新位置 + 与 product.template._inverse_stock_location_id 逻辑一致, + 改动的是同一张 stock.quant 表,两边自动同步 + """ + for line in self: + if not line.stock_location_id or not line.product_id: + continue + new_location = line.stock_location_id + # 找到该产品所有不在目标位置的 quant → 直接搬家 + quants = self.env['stock.quant'].sudo().search([ + ('product_id', '=', line.product_id.id), + ('location_id.usage', '=', 'internal'), + ('location_id', '!=', new_location.id), + ]) + for q in quants: + q.location_id = new_location.id + + def action_edit_line(self): + """打开编辑弹窗,预填当前行的数据""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': '编辑明细行', + 'res_model': 'yuthon.inventory.line.wizard', + 'view_mode': 'form', + 'target': 'new', # 弹窗模式 + 'context': { + 'default_line_id': self.id, + }, + } + + def action_submit_line(self): + # 校验:有差额时必须填原因 + if self.difference_qty != 0.0 and not self.difference_reason: + raise UserError('「%s」存在差额(%.2f),请先填写差额原因后再提交!' % ( + self.product_name, self.difference_qty)) + + if self.sheet_type == 'office': + self._submit_office_line() + elif self.sheet_type == 'fixed_asset': + self._submit_fixed_asset_line() + + # 标记该行已盘点 + self.state = 'counted' + + # 检查所属头记录是否所有明细行都已盘点 + sheet = self.sheet_id + pending_lines = sheet.line_ids.filtered(lambda l: l.state == 'pending') + if not pending_lines: + sheet.state = 'done' + # 如果任务下所有头记录都完成了,标记任务完成 + task = sheet.task_id + all_done = all(s.state == 'done' for s in task.sheet_ids) + if all_done: + task.state = 'done' + + def _submit_office_line(self): + """提交办公用品明细:通过 stock.quant 调整库存数量 + 调整后 product.template.qty_available 会自动反映最新值 + 使用 sudo() 绕过多公司 record rule 限制(产品可能属于非当前用户公司) + """ + product = self.product_id + actual_qty = self.actual_qty + + # 使用产品自身的公司ID,确保 quant 的公司与产品一致 + product_company = product.company_id.id if product.company_id else False + + # 整个流程在 sudo 下执行:绕过 record rule + 绕过 _search bug + Quant = self.env['stock.quant'].sudo() + quants = Quant.search([ + ('product_id', '=', product.id), + ('location_id.usage', '=', 'internal'), + '|', + ('company_id', '=', product_company), + ('company_id', '=', False), + ]) + + if not quants: + default_location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + if not default_location: + raise UserError('未找到默认库存位置,无法创建库存记录!') + quant = Quant.create({ + 'product_id': product.id, + 'location_id': default_location.id, + 'quantity': 0.0, + 'company_id': product_company, + }) + else: + quant = quants.filtered(lambda q: q.quantity > 0.0)[:1] or quants[:1] + + # 通过 inventory adjustment 设置实际数量(sudo 下避免跨公司报错) + quant.inventory_quantity = actual_qty + quant.sudo().action_apply_inventory() + + # 创建调整记录 + self.env['yuthon.inventory.adjustment'].create({ + 'task_id': self.sheet_id.task_id.id, + 'line_id': self.id, + 'product_id': product.id, + 'adjust_type': 'increase' if self.difference_qty > 0 else 'decrease', + 'qty_before': self.on_hand_qty, + 'qty_after': actual_qty, + 'adjust_qty': abs(self.difference_qty), + 'reason': self.difference_reason or '', + 'company_id': product_company, + }) + + def _submit_fixed_asset_line(self): + """提交固定资产明细:更新 maintenance.equipment.equipment_number""" + equipment = self.equipment_id + actual_qty = self.actual_qty + + # 将实际盘点数量写入 equipment_number(Char 类型) + if actual_qty == int(actual_qty): + equipment.equipment_number = str(int(actual_qty)) + else: + equipment.equipment_number = str(round(actual_qty, 2)) + + # 创建调整记录 + self.env['yuthon.inventory.adjustment'].create({ + 'task_id': self.sheet_id.task_id.id, + 'line_id': self.id, + 'equipment_id': equipment.id, + 'product_id': False, + 'adjust_type': 'increase' if self.difference_qty > 0 else 'decrease', + 'qty_before': self.on_hand_qty, + 'qty_after': actual_qty, + 'adjust_qty': abs(self.difference_qty), + 'reason': self.difference_reason or '', + 'company_id': self.company_id.id if self.company_id else False, + }) diff --git a/yuthon_stock/models/yuthon_inventory_task.py b/yuthon_stock/models/yuthon_inventory_task.py new file mode 100644 index 000000000..06397aba9 --- /dev/null +++ b/yuthon_stock/models/yuthon_inventory_task.py @@ -0,0 +1,354 @@ +# -*- coding: utf-8 -*- +import logging + +from odoo import api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class YuthonInventoryTask(models.Model): + """盘点任务:定义一次盘点的范围、人员和时间 + 每次点击「确认并生成盘点表」会创建一条盘点表头记录(yuthon.inventory.sheet), + 头记录下包含多条明细行(yuthon.inventory.sheet.line),每条对应一个产品/设备 + """ + _name = 'yuthon.inventory.task' + _description = '盘点任务' + _order = 'create_date desc, id desc' + + name = fields.Char(string='盘点名称', required=True, index=True) + code = fields.Char(string='编号', readonly=True, copy=False, default='New') + + planned_date = fields.Datetime(string='计划盘点时间') + employee_ids = fields.Many2many( + 'hr.employee', + 'inventory_task_employee_rel', + 'task_id', + 'employee_id', + string='盘点人员', + required=True, + ) + company_ids = fields.Many2many( + 'res.company', + string='公司范围', + required=True, + default=lambda self: self.env.company, + ) + + include_fixed_assets = fields.Boolean(string='盘点固定资产', default=True) + include_office_supplies = fields.Boolean(string='盘点办公用品', default=True) + + state = fields.Selection( + [('draft', '草稿'), ('confirmed', '已确认'), ('done', '已完成')], + string='状态', + default='draft', + index=True, + readonly=True, + copy=False, + ) + + # 盘点表头记录(每次确认生成一条) + sheet_ids = fields.One2many( + 'yuthon.inventory.sheet', + 'task_id', + string='盘点表', + readonly=True, + ) + sheet_count = fields.Integer(string='盘点表数', compute='_compute_sheet_count') + + note = fields.Text(string='备注') + reminded = fields.Boolean(string='已提醒', default=False, copy=False) + + create_uid = fields.Many2one('res.users', string='创建人', readonly=True) + create_date = fields.Datetime(string='创建时间', readonly=True) + + @api.depends('sheet_ids') + def _compute_sheet_count(self): + """计算盘点表头数量""" + for task in self: + task.sheet_count = len(task.sheet_ids) + + @api.model_create_multi + def create(self, vals_list): + """创建时自动生成编号,格式:INV-202606-001""" + for vals in vals_list: + if vals.get('code', 'New') == 'New': + vals['code'] = self.env['ir.sequence'].next_by_code('yuthon.inventory.task') or 'New' + return super().create(vals_list) + + def action_confirm(self): + """确认任务:生成一条盘点表头 + 所有明细行,状态改为已确认""" + self.ensure_one() + if self.state != 'draft': + raise UserError('只有草稿状态的任务才能确认!') + if not self.include_fixed_assets and not self.include_office_supplies: + raise UserError('请至少选择一种盘点内容(固定资产或办公用品)!') + if not self.sheet_ids: + self._generate_sheets() + self.state = 'confirmed' + + def action_done(self): + """完成任务:批量提交所有待盘点的明细行 + 逐行执行提交逻辑(与点击每行「提交修改」按钮效果相同) + """ + self.ensure_one() + if self.state != 'confirmed': + raise UserError('只有已确认状态的任务才能提交!') + + # 收集所有待盘点的明细行 + pending_lines = self.env['yuthon.inventory.sheet.line'] + for sheet in self.sheet_ids: + pending_lines |= sheet.line_ids.filtered(lambda l: l.state == 'pending') + + if not pending_lines: + raise UserError('没有需要提交的盘点明细!') + + # 逐行提交 + for line in pending_lines: + try: + line.action_submit_line() + except UserError as e: + # 单行失败不中断,记录错误继续处理其他行 + _logger.warning('明细行[%s]提交失败: %s', line.product_name, str(e)) + + # 标记任务完成 + self.state = 'done' + + def action_draft(self): + """回到草稿:清空盘点表,允许重新生成""" + self.ensure_one() + if self.state == 'done': + raise UserError('已完成的任务不能回到草稿!') + self.state = 'draft' + # 清空盘点表(删除头记录,级联删除明细行) + self.sheet_ids.unlink() + + def _generate_sheets(self): + """根据任务条件生成 1 条盘点表头 + N 条明细行 + - 办公用品从 product.template("办公用品"标签)+ stock.quant 取数 + - 固定资产从 maintenance.equipment 取数 + """ + self.ensure_one() + Sheet = self.env['yuthon.inventory.sheet'] + SheetLine = self.env['yuthon.inventory.sheet.line'] + + _logger.info('=== _generate_sheets 开始 ===') + _logger.info('任务ID=%s, name=%s, code=%s', self.id, self.name, self.code) + + # 创建一条盘点表头 + sheet = Sheet.create({ + 'task_id': self.id, + }) + _logger.info('创建盘点表头 ID=%s', sheet.id) + + line_count = 0 + + # ===== 办公用品明细行 ===== + if self.include_office_supplies: + # 只取任务选定公司范围内的产品 + product_tmpl_domain = [ + ('type', 'in', ('product', 'consu')), + '|', + ('company_id', 'in', self.company_ids.ids), + ('company_id', '=', False), + ] + office_tags = self.env['product.tag'].search([('name', '=', '办公用品')]) + _logger.info('办公用品标签查询 → 找到 %d 个标签', len(office_tags)) + + if office_tags: + office_domain = product_tmpl_domain + [('product_tag_ids', 'in', office_tags.ids)] + office_tmpls = self.env['product.template'].search(office_domain) + _logger.info('办公用品模板查询 → 找到 %d 个模板', len(office_tmpls)) + + if office_tmpls: + products = self.env['product.product'].search([ + ('product_tmpl_id', 'in', office_tmpls.ids), + ]) + _logger.info('办公用品变体查询 → 找到 %d 个产品', len(products)) + + if products: + # 只查询有实际库存的 quant(quantity > 0), + # 确保盘点明细只带出仓库里真正有货的产品 + quant_domain = [ + ('product_id', 'in', products.ids), + ('location_id.usage', '=', 'internal'), + ('quantity', '>', 0), + ] + quants = self.env['stock.quant'].search(quant_domain) + _logger.info('办公用品 stock.quant 查询(quantity>0)→ %d 条记录', len(quants)) + + # 按产品汇总库存数量 + product_summary = {} + for quant in quants: + pid = quant.product_id.id + if pid not in product_summary: + product_summary[pid] = {'qty': 0.0} + product_summary[pid]['qty'] += quant.quantity + + # 只创建有实际库存的产品的明细行(跳过库存为 0 的产品) + for pid, summary in product_summary.items(): + product = self.env['product.product'].browse(pid) + if not product.exists(): + continue + # 使用产品自身的公司ID,确保后续提交时公司匹配 + line_company = product.company_id.id if product.company_id else ( + self.company_ids[0].id if self.company_ids else False + ) + SheetLine.create({ + 'sheet_id': sheet.id, + 'sheet_type': 'office', + 'product_id': product.id, + 'on_hand_qty': summary['qty'], + 'company_id': line_company, + }) + line_count += 1 + _logger.info('办公用品明细行创建完成(共 %d 行)', line_count) + + # ===== 固定资产明细行 ===== + if self.include_fixed_assets: + # 只取任务选定公司范围内的设备 + eq_domain = [ + '|', + ('company_id', 'in', self.company_ids.ids), + ('company_id', '=', False), + ] + equipments = self.env['maintenance.equipment'].search(eq_domain) + _logger.info('固定资产设备查询 → 找到 %d 台设备', len(equipments)) + + for eq in equipments: + try: + on_hand = float(eq.equipment_number or 0) + except (ValueError, TypeError): + on_hand = 0.0 + + eq_company = False + if eq.company_id: + eq_company = eq.company_id.id + elif self.company_ids: + eq_company = self.company_ids[0].id + + SheetLine.create({ + 'sheet_id': sheet.id, + 'sheet_type': 'fixed_asset', + 'equipment_id': eq.id, + 'on_hand_qty': on_hand, + 'user_name': eq.employee_id.name if eq.employee_id else '', + 'department_name': eq.department_id1.name if eq.department_id1 else '', + 'company_id': eq_company, + }) + line_count += 1 + _logger.info('固定资产明细行创建完成') + + if line_count == 0: + raise UserError('根据所选条件未找到任何可盘点的数据,请检查盘点内容设置!') + _logger.info('=== _generate_sheets 完成:头记录=1, 明细行=%d ===', line_count) + + def _adjust_inventory(self, lines=None): + """提交盘点结果时,按类型分别处理明细行: + - 办公用品:通过 stock.quant 调整库存(标准 Odoo 方式) + - 固定资产:更新 maintenance.equipment 的 equipment_number 字段 + """ + self.ensure_one() + if lines is None: + # 收集所有头记录下的所有明细行 + lines = self.env['yuthon.inventory.sheet.line'] + for sheet in self.sheet_ids: + lines |= sheet.line_ids + + diff_lines = lines.filtered(lambda l: l.difference_qty != 0) + if not diff_lines: + return + + office_lines = diff_lines.filtered(lambda l: l.sheet_type == 'office') + asset_lines = diff_lines.filtered(lambda l: l.sheet_type == 'fixed_asset') + + # ===== 办公用品:通过 stock.quant.inventory_quantity 调整库存 ===== + for line in office_lines: + product = line.product_id + actual_qty = line.actual_qty + + quants = self.env['stock.quant'].search([ + ('product_id', '=', product.id), + ('location_id.usage', '=', 'internal'), + '|', + ('company_id', 'in', self.company_ids.ids), + ('company_id', '=', False), + ]) + if not quants: + default_location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + if not default_location: + continue + quant = self.env['stock.quant'].create({ + 'product_id': product.id, + 'location_id': default_location.id, + 'quantity': 0.0, + 'company_id': self.company_ids[0].id if self.company_ids else False, + }) + else: + quant = quants.filtered(lambda q: q.quantity > 0.0)[:1] or quants[:1] + + quant.inventory_quantity = actual_qty + quant.action_apply_inventory() + + self.env['yuthon.inventory.adjustment'].create({ + 'task_id': self.id, + 'line_id': line.id, + 'product_id': product.id, + 'adjust_type': 'increase' if line.difference_qty > 0 else 'decrease', + 'qty_before': line.on_hand_qty, + 'qty_after': actual_qty, + 'adjust_qty': abs(line.difference_qty), + 'reason': line.difference_reason, + 'company_id': line.company_id.id if line.company_id else False, + }) + + # ===== 固定资产:更新 maintenance.equipment 的 equipment_number 字段 ===== + for line in asset_lines: + equipment = line.equipment_id + actual_qty = line.actual_qty + + if actual_qty == int(actual_qty): + equipment.equipment_number = str(int(actual_qty)) + else: + equipment.equipment_number = str(round(actual_qty, 2)) + + _logger.info('固定资产盘点: 设备[%s](id=%s) 数量从 %s 更新为 %s', + equipment.name, equipment.id, line.on_hand_qty, equipment.equipment_number) + + self.env['yuthon.inventory.adjustment'].create({ + 'task_id': self.id, + 'line_id': line.id, + 'equipment_id': equipment.id, + 'product_id': False, + 'adjust_type': 'increase' if line.difference_qty > 0 else 'decrease', + 'qty_before': line.on_hand_qty, + 'qty_after': actual_qty, + 'adjust_qty': abs(line.difference_qty), + 'reason': line.difference_reason, + 'company_id': line.company_id.id if line.company_id else False, + }) + + def action_open_sheets(self): + """跳转到当前任务的盘点表头列表""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': '盘点表 - %s' % self.name, + 'res_model': 'yuthon.inventory.sheet', + 'view_mode': 'tree,form', + 'domain': [('task_id', '=', self.id)], + 'target': 'current', + } + + def action_send_reminder(self): + """手动触发提醒(供 cron 调用)""" + now = fields.Datetime.now() + tasks = self.search([ + ('state', '=', 'confirmed'), + ('planned_date', '!=', False), + ('planned_date', '<=', now), + ('reminded', '=', False), + ]) + for task in tasks: + # TODO: 接入企业微信后,此处发送消息给 task.employee_ids + task.reminded = True diff --git a/yuthon_stock/security/ir.model.access.csv b/yuthon_stock/security/ir.model.access.csv index 228669567..3a057fb1d 100644 --- a/yuthon_stock/security/ir.model.access.csv +++ b/yuthon_stock/security/ir.model.access.csv @@ -3,3 +3,9 @@ access_stock_picking_transfer_wizard,stock.picking.transfer.wizard,model_stock_p access_putaway_rule_batch_update_wizard,putaway.rule.batch.update.wizard,model_putaway_rule_batch_update_wizard,,1,1,1,1 access_stock_picking_submit_wizard,stock.picking.submit.wizard,model_stock_picking_submit_wizard,,1,1,1,1 access_stock_picking_return_wizard,stock.picking.return.wizard,model_stock_picking_return_wizard,,1,1,1,1 +access_yuthon_inventory_task,yuthon.inventory.task,model_yuthon_inventory_task,,1,1,1,1 +access_yuthon_inventory_sheet,yuthon.inventory.sheet,model_yuthon_inventory_sheet,,1,1,1,1 +access_yuthon_inventory_sheet_line,yuthon.inventory.sheet.line,model_yuthon_inventory_sheet_line,,1,1,1,1 +access_yuthon_inventory_adjustment,yuthon.inventory.adjustment,model_yuthon_inventory_adjustment,,1,1,1,0 +access_yuthon_inventory_line_wizard,yuthon.inventory.line.wizard,model_yuthon_inventory_line_wizard,,1,1,1,1 +access_yuthon_stock_backfill_wizard,yuthon.stock.backfill.wizard,model_yuthon_stock_backfill_wizard,,1,1,1,1 diff --git a/yuthon_stock/views/inherit_stock_picking.xml b/yuthon_stock/views/inherit_stock_picking.xml index 31b0add29..20ef593a1 100644 --- a/yuthon_stock/views/inherit_stock_picking.xml +++ b/yuthon_stock/views/inherit_stock_picking.xml @@ -79,16 +79,13 @@ - - - + - + invisible="receipt_status != 'acceptance' or user_id == False or user_id != uid or picking_type_code != 'incoming'"/> + @@ -433,7 +430,7 @@ 收货 - ['&', ('picking_type_code', '=', 'incoming'), '|', '|', ('create_uid', '=', uid), ('acceptance_user_id', '=', uid), ('certifier_user_id', '=', uid)] + ['&', ('picking_type_code', '=', 'incoming'), '|', '|', '|', ('create_uid', '=', uid), ('user_id', '=', uid), ('acceptance_user_id', '=', uid), ('certifier_user_id', '=', uid)] {'contact_display': 'partner_address', 'restricted_picking_type_code': 'incoming', 'search_default_reception': 1} diff --git a/yuthon_stock/views/menu.xml b/yuthon_stock/views/menu.xml index c2dd181e5..a559f057e 100644 --- a/yuthon_stock/views/menu.xml +++ b/yuthon_stock/views/menu.xml @@ -5,9 +5,11 @@ 固定资产 - + + + 固定资产 + maintenance.equipment + kanban,tree,form + + diff --git a/yuthon_stock/views/yuthon_inventory_views.xml b/yuthon_stock/views/yuthon_inventory_views.xml new file mode 100644 index 000000000..e8daf9e25 --- /dev/null +++ b/yuthon_stock/views/yuthon_inventory_views.xml @@ -0,0 +1,484 @@ + + + + + + yuthon.inventory.task.tree + yuthon.inventory.task + + + + + + + + + + + + + + + + yuthon.inventory.task.form + yuthon.inventory.task + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 已生成 张盘点表。 + + 点击填写盘点结果 + + + + + + + + + + + + + + yuthon.inventory.task.kanban + yuthon.inventory.task + + + + + + + + + + + 状态: + + + + + + + + + + + + yuthon.inventory.sheet.tree + yuthon.inventory.sheet + + + + + + + + + + + + + yuthon.inventory.sheet.form + yuthon.inventory.sheet + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + yuthon.inventory.sheet.line.tree + yuthon.inventory.sheet.line + + + + + + + + + + + + + + + + + yuthon.inventory.sheet.line.form + yuthon.inventory.sheet.line + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + yuthon.inventory.adjustment.tree + yuthon.inventory.adjustment + + + + + + + + + + + + + + + + + + yuthon.inventory.adjustment.form + yuthon.inventory.adjustment + + + + + + + + + + + + + + + + + + + + + + + + + + + + 盘点任务 + yuthon.inventory.task + tree,form,kanban + + + 创建第一条盘点任务 + + + + + + + 盘点表 + yuthon.inventory.sheet + tree,form + + + + + 盘点明细 + yuthon.inventory.sheet.line + tree,form + + + + + 库存调整记录 + yuthon.inventory.adjustment + tree,form + + + + + + + + + + + + + yuthon.inventory.line.wizard.form + yuthon.inventory.line.wizard + + + + + + + + + + + + + + + + + + + + + + + + + + + + 编辑盘点明细 + yuthon.inventory.line.wizard + form + new + + + + + + yuthon.stock.backfill.wizard.form + yuthon.stock.backfill.wizard + + + + + 📦 补全旧数据:跳过完整入库流程,直接在库存位置创建产品并设定库存数量。 + 适用于系统上线前已有库存的产品数据补录。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 补全旧数据 + yuthon.stock.backfill.wizard + form + new + + + + + + + + 修复库存公司数据 + + code + +repaired = env['yuthon.stock.backfill.wizard']._repair_orphan_quants() +message = '\u2705 \u5e93\u5b58\u516c\u53f8\u6570\u636e\u4fee\u590d\u5b8c\u6210\uff01\n\n' +message += '\u5171\u4fee\u590d %d \u6761\u7a7a\u516c\u53f8\u7684 quant\u8bb0\u5f55\u3002' % repaired +action = { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': '\u64cd\u4f5c\u6210\u529f', + 'message': message, + 'type': 'success', + 'sticky': True, + }, +} + + + + + + + diff --git a/yuthon_stock/wizard/__init__.py b/yuthon_stock/wizard/__init__.py index 1d5089b82..eebe33b56 100644 --- a/yuthon_stock/wizard/__init__.py +++ b/yuthon_stock/wizard/__init__.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- from . import stock_picking_transfer_wizard -from . import putaway_rule_batch_update_wizard from . import stock_picking_submit_wizard from . import stock_picking_return_wizard +from . import putaway_rule_batch_update_wizard +from . import yuthon_inventory_line_wizard +from . import yuthon_stock_backfill_wizard diff --git a/yuthon_stock/wizard/yuthon_inventory_line_wizard.py b/yuthon_stock/wizard/yuthon_inventory_line_wizard.py new file mode 100644 index 000000000..f142ced79 --- /dev/null +++ b/yuthon_stock/wizard/yuthon_inventory_line_wizard.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +"""盘点明细编辑向导:弹出窗口编辑 single line,填写后提交""" +import logging +from odoo import api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class YuthonInventoryLineWizard(models.TransientModel): + """盘点明细行编辑弹窗""" + _name = 'yuthon.inventory.line.wizard' + _description = '盘点明细编辑向导' + + # ========== 关联明细行 ========== + line_id = fields.Many2one( + 'yuthon.inventory.sheet.line', + string='明细行', + readonly=True, + ) + + # ========== 只读展示字段 ========== + product_name = fields.Char(string='产品/设备名称', readonly=True) + specification = fields.Char(string='规格型号', readonly=True) + + # 是否为办公用品(用于控制库存位置字段的显示/隐藏) + is_office = fields.Boolean( + string='是否办公用品', + compute='_compute_is_office', + ) + + # 库存位置:办公用品可编辑,固定资产不显示 + stock_location_id = fields.Many2one( + 'stock.location', + string='库存位置', + domain="[('usage', '=', 'internal')]", + ) + + on_hand_qty = fields.Float(string='在手数量(系统数)', readonly=True) + + # ========== 可编辑字段 ========== + actual_qty = fields.Float(string='实际盘点数量', digits='Product Unit of Measure') + + # 固定资产:员工下拉(办公用品隐藏) + employee_id = fields.Many2one( + 'hr.employee', + string='使用人', + ) + + # 使用部门(只读,根据选中的员工自动带出) + department_name = fields.Char( + string='使用部门', + compute='_compute_department_name', + readonly=True, + ) + + difference_reason = fields.Char(string='差额原因', + help='实际数量与在手数量有差额时必须填写') + + # ========== 计算字段 ========== + difference_qty = fields.Float( + string='差额', + compute='_compute_difference', + digits='Product Unit of Measure', + ) + + @api.depends('line_id') + def _compute_is_office(self): + """根据关联明细行的类型判断是否为办公用品""" + for wiz in self: + wiz.is_office = bool(wiz.line_id and wiz.line_id.sheet_type == 'office') + + @api.depends('employee_id', 'employee_id.department_id') + def _compute_department_name(self): + """根据选中的员工自动带出所属部门名称""" + for wiz in self: + if wiz.employee_id and wiz.employee_id.department_id: + wiz.department_name = wiz.employee_id.department_id.name + else: + wiz.department_name = '' + + @api.depends('actual_qty', 'on_hand_qty') + def _compute_difference(self): + """动态计算差额""" + for wiz in self: + wiz.difference_qty = (wiz.actual_qty or 0.0) - (wiz.on_hand_qty or 0.0) + + @api.model + def default_get(self, fields_list): + """打开弹窗时从明细行预填数据""" + res = super().default_get(fields_list) + line_id = self.env.context.get('default_line_id') + if line_id: + line = self.env['yuthon.inventory.sheet.line'].browse(line_id) + if line.exists(): + # 预填固定资产的员工(按名称匹配 hr.employee) + employee_id = False + if line.sheet_type == 'fixed_asset' and line.user_name: + employee = self.env['hr.employee'].sudo().search([ + ('name', '=', line.user_name), + ], limit=1) + if employee: + employee_id = employee.id + + res.update({ + 'line_id': line.id, + 'product_name': line.product_name or '', + 'specification': line.specification or '', + 'is_office': (line.sheet_type == 'office'), + 'stock_location_id': line.stock_location_id.id if (line.sheet_type == 'office' and line.stock_location_id) else False, + 'on_hand_qty': line.on_hand_qty or 0.0, + 'actual_qty': line.actual_qty if line.actual_qty else 0.0, + 'employee_id': employee_id, # 固定资产预填员工,办公用品留空 + 'difference_reason': line.difference_reason or '', + }) + return res + + def action_save(self): + """保存数据到明细行(仅写回编辑字段,不执行提交/库存调整逻辑) + 固定资产:员工下拉选择后同步写回 maintenance.equipment + """ + self.ensure_one() + line = self.line_id + if not line: + raise UserError('未找到关联的盘点明细行!') + + # 写回用户编辑过的字段(库存位置改动会触发 inverse 移动库存) + write_vals = { + 'actual_qty': self.actual_qty, + 'difference_reason': self.difference_reason, + } + # 办公用品:使用人/部门保持 Char 写法 + if line.sheet_type == 'office': + write_vals['user_name'] = '' + write_vals['department_name'] = '' + else: + # 固定资产:将 employee_id 的名称写回 line 的 user_name + # department_name 由 compute 自动取,无需手动写入 + write_vals['user_name'] = self.employee_id.name if self.employee_id else '' + write_vals['department_name'] = self.department_name or '' + + # 办公用品才写回库存位置 + if line.sheet_type == 'office' and self.stock_location_id: + write_vals['stock_location_id'] = self.stock_location_id.id + line.write(write_vals) + + # 固定资产:直接将 employee_id 和 department_id1 同步到 maintenance.equipment + if line.sheet_type == 'fixed_asset' and line.equipment_id: + eq = line.equipment_id + eq_vals = {} + eq_vals['employee_id'] = self.employee_id.id if self.employee_id else False + # 部门从员工的 department_id 取出 → 写入设备的 department_id1 + if self.employee_id and self.employee_id.department_id: + eq_vals['department_id1'] = self.employee_id.department_id.id + else: + eq_vals['department_id1'] = False + eq.sudo().write(eq_vals) + + # 关闭弹窗(不执行提交,用户回到列表后点「提交」按钮) + return {'type': 'ir.actions.act_window_close'} diff --git a/yuthon_stock/wizard/yuthon_stock_backfill_wizard.py b/yuthon_stock/wizard/yuthon_stock_backfill_wizard.py new file mode 100644 index 000000000..6d69498ce --- /dev/null +++ b/yuthon_stock/wizard/yuthon_stock_backfill_wizard.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +"""补全旧数据向导:极简入库流程 +跳过大而全的采购入库流程,直接在库存位置创建产品并设定库存数量 +适用于系统上线前已有库存的产品数据补录 +""" +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class YuthonStockBackfillWizard(models.TransientModel): + """补全旧数据弹窗 —— 新建产品 + 指定库位 + 填写库存数量,一键创建""" + _name = 'yuthon.stock.backfill.wizard' + _description = '补全旧数据向导' + + # ========== 产品信息 ========== + product_name = fields.Char( + string='产品名称', + required=True, + help='请输入产品名称(必填)', + ) + product_tag_ids = fields.Many2many( + 'product.tag', + string='产品标签', + help='选择产品标签,如"办公用品""常用耗材"等(非必填)', + ) + description = fields.Char( + string='规格型号', + help='产品的规格或型号描述(非必填)', + ) + detailed_type = fields.Selection( + [('product', '库存产品'), ('consu', '消耗品'), ('service', '服务')], + string='产品类型', + default='product', + required=True, + help='库存产品:需要管理库存数量的实物产品', + ) + categ_id = fields.Many2one( + 'product.category', + string='产品类别', + help='选择产品所属类别(非必填)', + ) + uom_id = fields.Many2one( + 'uom.uom', + string='计量单位', + required=True, + default=lambda self: self.env.ref('uom.product_uom_unit', raise_if_not_found=False), + help='产品的计量单位,默认"件"', + ) + + # ========== 库存信息 ========== + company_id = fields.Many2one( + 'res.company', + string='所属公司', + required=True, + default=lambda self: self.env.company, + help='产品归属的公司', + ) + location_id = fields.Many2one( + 'stock.location', + string='库存位置', + required=False, + domain="[('usage', '=', 'internal')]", + help='选择产品所在的内部库存位置,留空则自动使用公司默认仓库库位', + ) + quantity = fields.Float( + string='库存数量', + required=True, + default=0.0, + digits='Product Unit of Measure', + help='当前库存中该产品的实际在手数量', + ) + + def action_confirm(self): + """执行补全操作: + 1. 创建 product.template + product.product + 2. 确保库存位置有 company_id(quant 的公司是只读相关字段,从位置继承) + 3. 创建 stock.quant 并通过 inventory adjustment 设置库存数量 + 4. 修复可能存在的 quant 公司为空的问题 + """ + self.ensure_one() + + # ===== 校验 ===== + if self.quantity < 0: + raise ValidationError('库存数量不能为负数!') + + # ===== Step 1: 创建产品模板 ===== + tmpl_vals = { + 'name': self.product_name, + 'detailed_type': self.detailed_type, + 'company_id': self.company_id.id, + } + if self.product_tag_ids: + tmpl_vals['product_tag_ids'] = [(6, 0, self.product_tag_ids.ids)] + if self.description: + tmpl_vals['description'] = self.description + if self.categ_id: + tmpl_vals['categ_id'] = self.categ_id.id + if self.uom_id: + tmpl_vals['uom_id'] = self.uom_id.id + + # 创建产品模板(用 sudo 避免权限问题) + product_tmpl = self.env['product.template'].sudo().create(tmpl_vals) + + # 获取自动生成的 product.product 变体 + product = product_tmpl.product_variant_id + if not product: + raise UserError('产品创建失败:未生成产品变体,请联系管理员。') + + # ===== Step 2: 确定库存位置(未选时自动用公司默认仓库) ===== + if self.location_id: + location = self.location_id.sudo() + else: + # 用户没选库存位置,自动使用该公司下的默认仓库库位 + warehouse = self.env['stock.warehouse'].sudo().search([ + ('company_id', '=', self.company_id.id), + ], limit=1) + if not warehouse: + raise UserError('未找到该公司[%s]的默认仓库,请手动选择库存位置!' % self.company_id.name) + location = warehouse.lot_stock_id.sudo() + + # stock.quant.company_id 是 readonly related 字段(= location_id.company_id), + # 所以 quant 的公司完全由所在位置决定,不能在 create 时直接传入 + target_company = self.company_id + if location.company_id != target_company: + # 用 SQL 直接更新位置的公司(绕过 Odoo 的禁止修改限制) + location._cr.execute( + 'UPDATE stock_location SET company_id = %s WHERE id = %s', + [target_company.id, location.id] + ) + # 强制刷新缓存(包括位置和相关的 quant) + self.env['stock.location'].invalidate_model(['company_id']) + self.env['stock.quant'].invalidate_model(['company_id']) + # 重新读取 location,确保后续 create 用的是新值 + location = self.env['stock.location'].sudo().browse(location.id) + + # ===== Step 3: 创建 stock.quant 并设置库存数量 ===== + Quant = self.env['stock.quant'].sudo() + + existing_quant = Quant.search([ + ('product_id', '=', product.id), + ('location_id', '=', location.id), + ], limit=1) + + if existing_quant: + # 已有 quant:直接调整库存数量 + existing_quant.sudo().inventory_quantity = self.quantity + existing_quant.sudo().user_id = self.env.user.id + existing_quant.sudo().inventory_date = fields.Date.today() + existing_quant.sudo().action_apply_inventory() + target_quant = existing_quant + else: + # 创建空 quant(company_id 不传,自动从 location 继承) + quant = Quant.create({ + 'product_id': product.id, + 'location_id': location.id, + 'quantity': 0.0, + }) + # 通过 inventory adjustment 写入真实数量 + quant.sudo().inventory_quantity = self.quantity + quant.sudo().user_id = self.env.user.id + quant.sudo().inventory_date = fields.Date.today() + quant.sudo().action_apply_inventory() + target_quant = quant + + # 关键:确保这个 quant 的 company_id 与 location 一致(相关字段可能因缓存没刷新而空白) + self._cr.execute(''' + UPDATE stock_quant sq + SET company_id = sl.company_id + FROM stock_location sl + WHERE sq.id = %s + AND sq.location_id = sl.id + ''', [target_quant.id]) + + # ===== Step 4: 修复所有空公司的 quant(从位置继承公司)===== + repaired = self._repair_orphan_quants() + + # ===== Step 5: 清除缓存 ===== + product_tmpl.invalidate_recordset(['qty_available']) + self.env['stock.quant'].invalidate_model() + + # ===== 成功提示 ===== + message = ( + '\u2705 \u65e7\u6570\u636e\u8865\u5168\u6210\u529f\uff01\n\n' + '\u4ea7\u54c1\u540d\u79f0\uff1a%s\n' + '\u5e93\u5b58\u4f4d\u7f6e\uff1a%s\n' + '\u5e93\u5b58\u6570\u91cf\uff1a%s %s\n\n' + '\u4ea7\u54c1\u5df2\u521b\u5efa\u5e76\u53ef\u5728\u7cfb\u7edf\u4e2d\u6b63\u5e38\u4f7f\u7528\u3002' + ) % ( + product.display_name, + location.complete_name or location.name, + self.quantity, + self.uom_id.name if self.uom_id else '', + ) + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': '\u64cd\u4f5c\u6210\u529f', + 'message': message, + 'type': 'success', + 'sticky': True, + }, + } + + @api.model + def _repair_orphan_quants(self): + """修复所有 company_id 为空的 quant:从所在位置继承公司 + + stock.quant.company_id 是 readonly related 字段(= location_id.company_id), + 当位置原本没有公司时创建的 quant 会留下空公司,导致 qty_available 无法读取。 + 此方法通过 SQL 批量修复,并同步修复所有子位置的公司归属。 + """ + # 第一步:先把所有没有公司但父位置有公司的内部位置,公司继承父位置 + # 这样能覆盖 办公室 -> 办公室/子位置 这种层级结构 + self.env.cr.execute(''' + UPDATE stock_location child + SET company_id = parent.company_id + FROM stock_location parent + WHERE child.location_id = parent.id + AND (child.company_id IS NULL OR child.company_id = 0) + AND parent.company_id IS NOT NULL + AND parent.company_id != 0 + ''') + fixed_locations = self.env.cr.rowcount + + # 第二步:修复所有 quant 的公司,从位置继承 + self.env.cr.execute(''' + UPDATE stock_quant sq + SET company_id = sl.company_id + FROM stock_location sl + WHERE sq.location_id = sl.id + AND (sq.company_id IS NULL OR sq.company_id = 0) + AND sl.company_id IS NOT NULL + AND sl.company_id != 0 + ''') + fixed_quants = self.env.cr.rowcount + + # 强制刷新缓存 + if fixed_locations > 0 or fixed_quants > 0: + self.env['stock.location'].invalidate_model(['company_id']) + self.env['stock.quant'].invalidate_model() + return fixed_quants diff --git a/yuthon_work_brand/views/stock_menus.xml b/yuthon_work_brand/views/stock_menus.xml index a31346008..942036201 100644 --- a/yuthon_work_brand/views/stock_menus.xml +++ b/yuthon_work_brand/views/stock_menus.xml @@ -6,7 +6,7 @@ 办公用品 product.template kanban,tree,form - [('product_tag_ids.name', '=', '办公用品'), ('product_variant_ids.stock_move_ids.picking_id.receipt_status', '=', 'receipt')] + [('product_tag_ids.name', '=', '办公用品'), ('product_variant_ids.stock_quant_ids.location_id.usage', '=', 'internal')] {'default_detailed_type': 'product'} @@ -14,7 +14,7 @@ 常用耗材 product.template kanban,tree,form - [('product_tag_ids.name', '=', '常用耗材'), ('product_variant_ids.stock_move_ids.picking_id.receipt_status', '=', 'receipt')] + [('product_tag_ids.name', '=', '常用耗材'), ('product_variant_ids.stock_quant_ids.location_id.usage', '=', 'internal')] {'default_detailed_type': 'product'} @@ -22,7 +22,7 @@ 固定资产 product.template kanban,tree,form - [('product_tag_ids.name', '=', '固定资产'), ('product_variant_ids.stock_move_ids.picking_id.receipt_status', '=', 'receipt')] + [('product_tag_ids.name', '=', '固定资产'), ('product_variant_ids.stock_quant_ids.location_id.usage', '=', 'internal')] {'default_detailed_type': 'product'} @@ -30,7 +30,7 @@ 日常消耗品 product.template kanban,tree,form - [('product_tag_ids.name', '=', '日常消耗品'), ('product_variant_ids.stock_move_ids.picking_id.receipt_status', '=', 'receipt')] + [('product_tag_ids.name', '=', '日常消耗品'), ('product_variant_ids.stock_quant_ids.location_id.usage', '=', 'internal')] {'default_detailed_type': 'product'} @@ -44,7 +44,7 @@ - + @@ -58,7 +58,7 @@ - +
已生成 张盘点表。
+ 点击填写盘点结果 +
+ 创建第一条盘点任务 +