第 9 天,一場突如其來的颱風打亂了我的步調。風雨交加,身心俱疲,我不得不暫時放下鍵盤,選擇休息。儘管這讓我錯過了當天的文章發表,也因此宣告了這次鐵人 30 天挑戰的止步。
這不是一場比賽,而是一種自我學習與成長的過程。我並不後悔,因為這場挑戰教會了我,人生總有無法預測的變數,而堅持的意義並不在於完美無缺,而在於不輕易放棄。這次的停下來,是為了更好地出發。
我將會繼續完成剩餘的文章。這 30 天的鐵人旅程,主題是 Odoo 開發實戰,從後端到前端,從模組設計到實際部署,我會將所有的經驗和心得,毫不保留地分享給每一位讀者。
這不是一個結束,而是一個承諾。挑戰雖然失敗了,但我的創作精神將延續。感謝所有一路支持我的讀者,讓我們繼續一起學習與探索 Odoo 的奧秘!
今天我們把零件當主角,把庫存當配角,設計一個最小可用的庫存流。目標是:
Part
(零件主檔)StockMove
(入/出庫紀錄)Part.quantity
,出庫不足就禁止。parts.inventory.part
name
、code
(唯一)、description
、quantity
、team_id
、active
code
唯一,避免重複。quantity
表示現況庫存。from odoo import models, fields
class Part(models.Model):
_name = "parts.inventory.part"
_description = "Part"
_order = "code, id"
name = fields.Char(string="Part Name", required=True)
code = fields.Char(string="Part Code", required=True, index=True)
description = fields.Text(string="Description")
quantity = fields.Float(string="Quantity in Stock", default=0.0)
team_id = fields.Many2one("team.management.team", string="Team")
active = fields.Boolean(string="Active", default=True)
_sql_constraints = [
("uniq_part_code", "unique(code)", "Part code must be unique."),
]
parts.inventory.stock.move
part_id
、move_type
、quantity
、note
、date
、state
draft
confirmed
,更新 Part.quantity
cancel
,已確認則回滾庫存from odoo import models, fields, api
from odoo.exceptions import ValidationError
class StockMove(models.Model):
_name = "parts.inventory.stock.move"
_description = "Stock Move"
_order = "date desc, id desc"
part_id = fields.Many2one("parts.inventory.part", string="Part", required=True, ondelete="cascade")
team_id = fields.Many2one(related="part_id.team_id", store=True, string="Team")
move_type = fields.Selection([("in", "Stock In"), ("out", "Stock Out")],
string="Move Type", required=True, default="in")
quantity = fields.Float(string="Quantity", required=True)
note = fields.Char(string="Note")
date = fields.Datetime(string="Date", default=fields.Datetime.now)
state = fields.Selection([("draft","Draft"),("confirmed","Confirmed"),("cancel","Cancelled")],
default="draft", string="Status")
@api.constrains("quantity")
def _check_qty_positive(self):
for rec in self:
if rec.quantity <= 0:
raise ValidationError("Quantity must be > 0.")
def action_confirm(self):
for rec in self:
if rec.state != "draft":
continue
if not rec.part_id:
raise ValidationError("Part is required.")
part = rec.part_id.sudo()
if rec.move_type == "in":
part.quantity += rec.quantity
else:
if part.quantity < rec.quantity:
raise ValidationError("Not enough stock to move out.")
part.quantity -= rec.quantity
rec.state = "confirmed"
def action_cancel(self):
for rec in self:
if rec.state == "cancel":
continue
part = rec.part_id.sudo()
if rec.state == "confirmed":
if rec.move_type == "in":
if part.quantity < rec.quantity:
raise ValidationError("Cannot cancel: stock not enough to revert.")
part.quantity -= rec.quantity
else:
part.quantity += rec.quantity
rec.state = "cancel"
進入 Odoo Shell:
docker compose exec odoo bash -lc 'odoo shell -d db -c /etc/odoo/odoo.conf'
測試 CRUD:
env.cr.rollback()
Part = env['parts.inventory.part']
Move = env['parts.inventory.stock.move']
# 找不到就建立
p = Part.search([('code','=','M3-001')], limit=1) or Part.create({
'name': 'M3 Screw',
'code': 'M3-001',
'description': '測試螺絲',
'quantity': 0.0,
})
print("Part:", p.id, p.code, p.name, "qty:", p.quantity)
# 入庫 10
m_in = Move.create({'part_id': p.id, 'move_type': 'in', 'quantity': 10, 'note': '初次入庫'})
m_in.action_confirm()
p = Part.browse(p.id).with_context(prefetch_fields=False)
print("After IN 10 → qty:", p.read(['quantity'])[0]['quantity'])
# 出庫 4
m_out = Move.create({'part_id': p.id, 'move_type': 'out', 'quantity': 4, 'note': '領料'})
m_out.action_confirm()
p = Part.browse(p.id).with_context(prefetch_fields=False)
print("After OUT 4 → qty:", p.read(['quantity'])[0]['quantity'])
# 過量出庫
env.cr.rollback()
try:
Move.create({'part_id': p.id, 'move_type': 'out', 'quantity': 999}).action_confirm()
except Exception as e:
print("Expected:", type(e).__name__, str(e)[:120], "...")
parts_inventory
模組安裝成功Part
/ StockMove
模型可用進一步設計入庫/出庫流程: