Step 4: Map products
In this step, you store a Qikink SKU on each product variant. You add a card that shows the mapping on the product page, and a Set Qikink SKUs action that edits all variants in one dialog.
Commit: feat: map products to qikink skus and drop-ship them. Later tidied in refactor: use frappe utilities across the app. The code on this page is the tidied version.
What you'll build
- Two Custom Fields on Item: Qikink SKU and Qikink Plain Product.
commera_qikink/products.pyand two methods inapi.pythat read and save the SKUs.commera/product/cards/qikink/index.vue, a card on the product page.commera/product/actions/set-qikink-skus/index.vue, a Set Qikink SKUs action.
How Qikink products map to your items
One Qikink SKU is one model in one color and one size. In Commera, that is one variant Item: the item code on the order line. So the SKU goes on each variant, not on the product.
| Your product | Where the SKU goes |
|---|---|
| A T-shirt in Black and White, sizes S to XL | Eight variant Items, one SKU each |
| A product without variants | The product's own Item |
Qikink reads a SKU in one of two ways. The plugin stores a Plain tick next to each SKU:
| Plain | The SKU is | Example |
|---|---|---|
| Off | Your own design, from the My Products page of your Qikink dashboard | The SKU that Qikink shows for that design |
| On | A blank product from Qikink's catalog, printed with no design | MVnHs-Bk-S |
Each mapped variant also becomes a drop-ship item in ERPNext. The plugin ticks Delivered by Supplier (Drop Ship) on the Item, and it sets the Qikink supplier as the default supplier for the store's company. Step 5 and step 7 use these two values to find the Qikink lines of a cart or an order.
1. Add two Custom Fields to Item
Create commera_qikink/install.py:
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
MODULE = "Qikink"
def add_custom_fields():
create_custom_fields(
{
"Item": [
{
"fieldname": "commera_qikink_sku",
"label": "Qikink SKU",
"fieldtype": "Data",
"insert_after": "delivered_by_supplier",
"module": MODULE,
},
{
"fieldname": "commera_qikink_plain_product",
"label": "Qikink Plain Product",
"fieldtype": "Check",
"insert_after": "commera_qikink_sku",
"depends_on": "commera_qikink_sku",
"module": MODULE,
},
]
},
update=True,
)
Run it after install and after every migrate. Add these lines at the end of commera_qikink/hooks.py:
after_install = "commera_qikink.install.add_custom_fields"
after_migrate = "commera_qikink.install.add_custom_fields"
2. Read and save the SKUs
Create commera_qikink/products.py. First, add the functions that read the variants of a product and save their SKUs:
import frappe
from frappe import _
from frappe.query_builder import DocType
from frappe.utils.data import cint, cstr
MAX_SKU_LENGTH = 50
ITEM_FIELDS = [
"name as item_code",
"item_name",
"commera_qikink_sku as sku",
"commera_qikink_plain_product as plain_product",
]
def get_product_skus(item_template: str) -> list[dict]:
return get_chain_rows(item_template) or get_variant_rows(item_template) or get_own_rows(item_template)
def save_product_skus(item_template: str, skus: list[dict]) -> list[dict]:
current = {row.item_code for row in get_product_skus(item_template)}
entries = [get_entry(row, current, item_template) for row in skus]
mapped = [entry["item_code"] for entry in entries if entry["sku"]]
supplier = get_qikink_supplier() if mapped else None
for entry in entries:
values = {"commera_qikink_sku": entry["sku"] or None, "commera_qikink_plain_product": entry["plain"]}
if entry["sku"]:
values["delivered_by_supplier"] = 1
frappe.db.set_value("Item", entry["item_code"], values)
if mapped:
set_default_supplier(mapped, supplier)
return get_product_skus(item_template)
def get_chain_rows(item_template: str) -> list[dict]:
color_size_item = DocType("Color Size Item")
variant = DocType("Style Attribute Variant")
configurator = DocType("Style Attribute Configurator")
item = DocType("Item")
return (
frappe.qb.from_(color_size_item)
.join(variant)
.on(variant.name == color_size_item.parent)
.join(configurator)
.on(configurator.name == variant.configurator)
.join(item)
.on(item.name == color_size_item.item_code)
.select(
color_size_item.item_code,
item.item_name,
variant.attribute_value.as_("colour"),
color_size_item.size,
item.commera_qikink_sku.as_("sku"),
item.commera_qikink_plain_product.as_("plain_product"),
)
.where(
(configurator.item_template == item_template)
& (color_size_item.parenttype == "Style Attribute Variant")
)
.orderby(variant.creation)
.orderby(color_size_item.idx)
.run(as_dict=True)
)
def get_variant_rows(item_template: str) -> list[dict]:
return frappe.get_all(
"Item",
filters={"variant_of": item_template, "disabled": 0},
fields=ITEM_FIELDS,
order_by="name asc",
)
def get_own_rows(item_template: str) -> list[dict]:
return frappe.get_all(
"Item",
filters={"name": item_template},
fields=ITEM_FIELDS,
)
get_product_skus tries three places for the variants, in this order:
- Commera's own variant tables. A product in Commera is a template Item. Its colors are Style Attribute Variant rows, and each color has one Color Size Item row for each size, with the variant's item code.
- ERPNext variants, that is Items whose Variant Of is the template.
- The Item itself, for a product without variants.
Next, add the functions that check a row and set the supplier:
def get_entry(row: dict, current: set, item_template: str) -> dict:
item_code = cstr(row.get("item_code"))
if item_code not in current:
frappe.throw(_("{0} is not an item of product {1}.").format(item_code, item_template))
sku = cstr(row.get("sku")).strip()
if len(sku) > MAX_SKU_LENGTH:
frappe.throw(_("A Qikink SKU has at most {0} characters: {1}").format(MAX_SKU_LENGTH, sku))
return {"item_code": item_code, "sku": sku, "plain": cint(row.get("plain_product")) if sku else 0}
def get_qikink_supplier() -> str:
supplier = frappe.db.get_single_value("Qikink Settings", "supplier")
if not supplier:
frappe.throw(
_(
"Choose the Qikink supplier first, in Settings > Installed plugins > Qikink. "
"Qikink ships these products for you, so each one is bought from that supplier."
)
)
return supplier
def set_default_supplier(item_codes: list[str], supplier: str):
company = get_store_company()
defaults = frappe.get_all(
"Item Default",
filters={"parenttype": "Item", "parent": ["in", item_codes]},
fields=["name", "parent", "company"],
)
row_counts = {}
company_rows = {}
for row in defaults:
row_counts[row.parent] = row_counts.get(row.parent, 0) + 1
if row.company == company:
company_rows[row.parent] = row.name
for item_code in item_codes:
if item_code in company_rows:
frappe.db.set_value("Item Default", company_rows[item_code], "default_supplier", supplier)
continue
frappe.get_doc(
{
"doctype": "Item Default",
"parenttype": "Item",
"parentfield": "item_defaults",
"parent": item_code,
"idx": row_counts.get(item_code, 0) + 1,
"company": company,
"default_supplier": supplier,
}
).db_insert()
def get_store_company() -> str:
# Same fallback Commera uses when it makes a cart.
return frappe.get_cached_doc("Commera Settings").get("company") or frappe.db.get_single_value(
"Global Defaults", "default_company"
)
save_product_skus refuses an item that is not part of the product, and a SKU longer than 50 characters. It needs the Supplier from Qikink Settings before it maps a variant. Clearing a SKU keeps the drop-ship values, so step 5 still refuses that variant in a cart.
3. Add the API methods
Add two whitelisted methods to commera_qikink/api.py. Each one checks the Item permission before it calls products.py:
from commera_qikink import products
@frappe.whitelist(methods=["GET"])
def get_product_skus(item: str) -> list[dict]:
frappe.has_permission("Item", "read", doc=item, throw=True)
return products.get_product_skus(item)
@frappe.whitelist(methods=["POST"])
def save_product_skus(item: str, skus: str | list) -> list[dict]:
frappe.has_permission("Item", "write", doc=item, throw=True)
return products.save_product_skus(item, frappe.parse_json(skus))
Create commera_qikink/conditions.py. The action in sub-step 5 shows only to users who can edit the Item:
import frappe
def can_edit_item(doctype: str, name: str) -> bool:
return bool(frappe.has_permission("Item", "write", name))
4. Add the product card
Create commera/shared/skus.js for the labels that the card and the action share:
export const variantLabel = (row) => [row.colour, row.size].filter(Boolean).join(' / ') || row.item_name || row.item_code
export const modeLabel = (row) => (row.plain_product ? 'Plain' : 'My Products')
In apps/commera_qikink, run bench commera add product-card qikink. Replace the commera/product/cards/qikink/index.vue it writes with this:
<script>
export const plugin = { label: 'Qikink' }
</script>
<script setup>
import { computed } from 'vue'
import { Skeleton } from 'frappe-ui'
import { usePlugin, useMethodRead } from '@commera/admin'
import { modeLabel, variantLabel } from '../../../shared/skus'
const { record } = usePlugin()
const skusRequest = useMethodRead('commera_qikink.api.get_product_skus', {
params: () => ({ item: record.value.name }),
})
const rows = computed(() => skusRequest.data ?? [])
const mappedCount = computed(() => rows.value.filter((row) => row.sku).length)
</script>
<template>
<Skeleton v-if="skusRequest.loading && !skusRequest.data" class="h-16 w-full rounded-4" />
<template v-else>
<p class="text-sm text-ink-gray-5 tabular-nums">{{ mappedCount }} of {{ rows.length }} mapped to a Qikink SKU</p>
<div class="mt-2 divide-y divide-outline-gray-1">
<div v-for="row in rows" :key="row.item_code" class="flex items-center justify-between gap-3 py-2 last:pb-0">
<p class="min-w-0 truncate text-base text-ink-gray-8">{{ variantLabel(row) }}</p>
<div v-if="row.sku" class="min-w-0 text-right">
<p class="truncate text-base text-ink-gray-8">{{ row.sku }}</p>
<p class="text-sm text-ink-gray-5">{{ modeLabel(row) }}</p>
</div>
<p v-else class="text-base text-ink-gray-4">Not mapped</p>
</div>
</div>
</template>
</template>
usePlugin() gives the card the record of the page. On a product page, record.value.name is the template Item. Commera draws the card frame and its Qikink title from label.
5. Add the Set Qikink SKUs action
In apps/commera_qikink, run bench commera add product-action set-qikink-skus. Replace the commera/product/actions/set-qikink-skus/index.vue it writes with this, and delete the set_qikink_skus_product stub it adds to api.py:
<script>
export const plugin = {
label: 'Set Qikink SKUs',
icon: 'tag',
condition: 'commera_qikink.conditions.can_edit_item',
}
</script>
<script setup>
import { ref, watch } from 'vue'
import { FormControl } from 'frappe-ui'
import { useAction, usePlugin, useMethodAction, useMethodRead } from '@commera/admin'
import { variantLabel } from '../../../shared/skus'
const { record, toast } = usePlugin()
const action = useAction()
const rows = ref([])
const skusRequest = useMethodRead('commera_qikink.api.get_product_skus', {
params: () => ({ item: record.value.name }),
})
watch(
() => skusRequest.data,
(data) => {
rows.value = (data ?? []).map((row) => ({ ...row, sku: row.sku ?? '', plain_product: !!row.plain_product }))
},
{ immediate: true },
)
const saveRequest = useMethodAction('commera_qikink.api.save_product_skus')
action.setPrimary({ label: 'Save SKUs', loading: () => skusRequest.loading || saveRequest.loading })
action.onSubmit(async () => {
const skus = rows.value.map((row) => ({
item_code: row.item_code,
sku: row.sku.trim(),
plain_product: row.plain_product ? 1 : 0,
}))
await saveRequest.submit({ item: record.value.name, skus })
if (saveRequest.error) throw saveRequest.error
toast.success('Qikink SKUs saved')
return { reload: true }
})
</script>
<template>
<p class="text-base text-ink-gray-7">
Enter the SKU from Qikink's My Products page, or tick Plain for a blank catalog product printed with no
design. Mapped sizes are drop-shipped by your Qikink supplier.
</p>
<div class="mt-3 divide-y divide-outline-gray-1">
<div v-for="row in rows" :key="row.item_code" class="flex items-center gap-3 py-2">
<p class="min-w-0 flex-1 truncate text-base text-ink-gray-8">{{ variantLabel(row) }}</p>
<FormControl v-model="row.sku" class="w-48 shrink-0" placeholder="Not mapped" :maxlength="50" />
<FormControl v-model="row.plain_product" type="checkbox" label="Plain" />
</div>
</div>
</template>
Commera draws the dialog and its buttons. useAction() sets the primary button and runs onSubmit when the user clicks it. When onSubmit returns { reload: true }, Commera reloads the page, and the card shows the new mapping.
6. Migrate and set up the supplier
- Run
bench --site your.site migrate. It makes the two Custom Fields on Item. - Build the plugin with
bench build --app commera_qikink. - In Desk, create a Supplier named
Qikink. - Open Settings > Installed plugins > Qikink in
/commera. - Set Supplier to
Qikink.
Check it works
-
In
/commera, open Products and open a product with variants. -
Find the Qikink card. It shows the count of mapped variants, for example "0 of 4 mapped to a Qikink SKU", with "Not mapped" next to each variant.
-
Click the More actions button (the three dots next to Save), then click Set Qikink SKUs.
-
Type a catalog SKU for S, M and L, for example
MVnHs-Bk-S, and tick Plain for each one. Leave XL empty.
-
Click Save SKUs. A toast shows "Qikink SKUs saved", and the card shows the SKUs, each marked "Plain".
-
In Desk, open one of the mapped variant Items. Delivered by Supplier (Drop Ship) is ticked, and the Item Defaults row for your company has Default Supplier set to
Qikink.
Next steps
- Step 5: Block unmapped carts
- Dashboard entries: cards, actions,
usePluginanduseAction - SDK: the Commera functions a plugin can call